原文連接:Graph Data Structures for Beginnersnode
衆成翻譯地址:初學者應該瞭解的數據結構: Graphgit
系列文章,建議不瞭解圖的同窗慢慢閱讀一下這篇文章,但願對你有所幫助~若是想深刻理解圖,那不建議閱讀這篇基礎文章,這裏有更多深刻的知識能夠探索~如下是譯文正文:程序員
在這篇文章中,咱們將要探索非線性的數據結構:圖,將涵蓋它的基本概念及其典型的應用。github
你極可能在不一樣的應用中接觸到圖(或樹)。好比你想知道從家出發怎麼去公司最近,就能夠利用圖的(尋路)算法來獲得答案!咱們將探討上述場景與其餘有趣的狀況。算法
在上一篇文章中,咱們探討了線性的數據結構,如數組、鏈表、集合、棧等。本文將以此(譯者注:即線性數據結構,沒看過前文也不要緊,其實也很好懂)爲基礎。數組
本篇是如下教程的一部分(譯者注:若是你們以爲還不錯,我會翻譯整個系列的文章):服務器
初學者應該瞭解的數據結構與算法(DSA)網絡
如下是本文對圖操做的小結:數據結構
鄰接表 | 鄰接矩陣 | |
---|---|---|
空間複雜度 | O(|V|+ |E|) | O(|V|²) |
添加頂點 | O(1) | O(|V|²) |
移除頂點 | O(|V| + |E|) | O(|V|)² |
添加邊 | O(1) | O(1) |
移除邊 (基於 Array 實現) | O(|E|) | O(1) |
移除邊 (基於 HashSet 實現) | O(1) | O(1 |
獲取相鄰的頂點 | O(|E|) | O(|V|) |
判斷是否相鄰 (基於 Array 實現) | O(|E|) | O(1) |
判斷是否相鄰 (基於 HashSet 實現) | O(1) | O(1) |
圖是一種(包含若干個節點),每一個節點能夠鏈接 0 個或多個元素app
兩個節點相連的部分稱爲邊(edge)。節點也被稱做頂點(vertice)。
一個頂點的**度(degree)**是指與該頂點相連的邊的條數。好比上圖中,紫色頂點的度是 3,藍色頂點的度是 1。
若是全部的邊都是雙向(譯者注:或者理解爲沒有方向)的,那咱們就有了一個無向圖(undirected graph)。反之若是邊是有向的,咱們獲得的就是有向圖(directed graph)。你能夠將有向圖和無向圖想象爲單行道或雙行道組成的交通網。
頂點的邊能夠是從本身出發再鏈接回本身(如藍色的頂點),擁有這樣的邊的圖被稱爲自環。
圖能夠有環(cycle),即若是遍歷圖的頂點,某個頂點能夠被訪問超過一次。而沒有環的圖被稱爲無環圖(acyclic graph)。
此外,無環無向圖也被稱爲樹(tree)。在下篇文章中,咱們將深刻套路這種數據結構。
在圖中,從一個頂點出發,並不是全部頂點都是可到達的。可能會存在孤立的頂點或者是相分離的子圖。若是一個圖全部頂點都至少有一條邊(譯者注:原文表述有點奇怪,我的認爲不該該是至少有一條邊,而是從任一節點出發,沿着各條邊能夠訪問圖中任意節點),這樣的圖被稱爲連通圖(connected graph)。而當一個圖中兩兩不一樣的頂點之間都恰有一條邊相連,這樣的圖就是徹底圖(complete graph)。
對於徹底圖而言,每一個頂點都有 圖的頂點數 - 1 條邊。在上面徹底圖的例子中,一共有7個頂點,所以每一個頂點有6條邊。
當圖的每條邊都被分配了權重時,咱們就有了一個加權圖(weighted graph)。若是邊的權重被忽略,那麼能夠將(每條邊的)權重都視爲 1(譯者注:權重都是同樣,也就是無權重)。
加權圖應用的場景不少,根據待解決問題主體的不一樣,有不一樣的展示。一塊兒來看一些具體的場景吧:
航空線路圖 (如上圖所示)
GPS 導航
網絡
通常而言, 圖在現實世界中的應用有:
咱們學習了圖的基礎以及它的一些應用場景。接下來一塊兒學習怎麼使用代碼來表示圖。
圖的表示有兩種主要方式:
讓咱們以有向圖爲例子,闡述這兩種表示方式:
這是一個擁有四個頂點的圖。當一個頂點有一條邊指向它自身時(譯者注:即閉合的路徑),稱之爲自環(self-loop)。
鄰接矩陣使用二維數組(N x N)來表示圖。如若不一樣頂點存在鏈接的邊,就賦值兩頂點交匯處爲1(也能夠是這條邊的權重),反之賦值爲 0 或者 -。
咱們能夠經過創建如下的鄰接矩陣,來表示上面的圖:
a b c d e
a 1 1 - - -
b - - 1 - -
c - - - 1 -
d - 1 1 - -
複製代碼
如你所見,矩陣水平與垂直兩個方向都列出了全部的頂點。若是圖中只有不多頂點互相鏈接,那麼這個圖就是稀疏圖(sparse graph)。若是圖相連的頂點不少(接近兩兩頂點都相連)的話,咱們稱這種圖爲稠密圖(dense graph)。而若是圖的每一個頂點都直接鏈接到除此以外的全部頂點,那就是一個徹底圖(complete graph)。
注意,你必須意識到對於無向圖而言,鄰接矩陣始終是對角線對稱的。然而,對於有向圖而言,並不是老是如此(反例如上面的有向圖)。
那查詢兩個頂點是否相鄰的時間複雜度是什麼呢?
在鄰接矩陣中,查詢兩個頂點是否相鄰的時間複雜度是 O(1)。
那空間複雜度呢?
利用鄰接矩陣存儲一個圖,空間複雜度是 O(n²),n 爲頂點的數量,所以也能夠表示爲 O(|V|²)。
添加一個頂點的時間複雜度呢?
鄰接矩陣根據頂點的數量存儲爲 V x V
的矩陣。所以每增長一個頂點,矩陣須要重建爲 V+1 x V+1
的新矩陣。
(所以,)在鄰接矩陣中添加一個頂點的時間複雜度是 O(|V|²)。
如何獲取相鄰的頂點?
因爲鄰接矩陣是一個 V x V
的矩陣,爲了獲取全部相鄰的頂點,咱們必須去到該頂點所在的行中,查詢它與其餘頂點是否有邊。
以上面的鄰接矩陣爲例,假設咱們想知道與頂點 b
相鄰的頂點有哪些,就須要到達記錄 b
與其餘節點關係的那一行中進行查詢。
a b c d e
b - - 1 - -
複製代碼
訪問它與其餘全部頂點的關係,所以:
在鄰接矩陣中,查詢相鄰頂點的時間複雜度是 O(|V|)。
想象一下,若是你須要將 FaceBook 中人們的關係網表示爲一個圖。你必須創建一個 20億 x 20億
的鄰接矩陣,而該矩陣中不少位置都是空的。沒有任何人可能認識其餘全部人,最多也就認識幾千我的。
一般,咱們使用鄰接矩陣處理稀疏圖時,會浪費不少空間。這就是大多時候使用鄰接表而不是鄰接矩陣去表示一個圖的緣由(譯者注:鄰接矩陣也有優點的,尤爲是表示有向稠密圖時,比鄰接表要方便得多)。
表示一個圖,最經常使用的方式是鄰接表。每一個頂點都有一個記錄着與它所相鄰頂點的表。
可使用一個數組或者 HashMap 來創建一個鄰接表,它存儲這全部的頂點。每一個頂點都有一個列表(能夠是數組、鏈表、集合等數據結構),存放着與其相鄰的頂點。
例如上面的圖,對於頂點 a,與之相鄰的有頂點 b,同時也是自環;而頂點 b 則有指向頂點 c 的邊,如此類推:
a -> { a b }
b -> { c }
c -> { d }
d -> { b c }
複製代碼
和想象中的同樣,若是想知道一個頂點是否鏈接着其餘頂點,就必須遍歷(頂點的)整個列表。
在鄰接表中查詢兩個頂點是否相連的時間複雜度是 O(n),n 爲頂點的數量,所以也能夠表示爲 O(|V|)。
那空間複雜度呢?
利用鄰接表存儲一個圖的空間複雜度是 O(n),n 爲頂點數量與邊數量之和,所以也能夠表示爲 O(|V| + |E|)。
要表示一個圖,最多見的方式是使用鄰接表。有幾種實現鄰接表的方式:
最簡單的實現方式之一是使用 HashMap。HashMap 的鍵是頂點的值,HashMap 的值是一個鄰接數組(即也該頂點相鄰頂點的集合)。
const graph = {
a:[ 'a','b' ],
b:[ 'c' ],
c:[ 'd' ],
d:[ 'b','c' ]
};
複製代碼
圖一般須要實現如下兩種操做:
添加或刪除一個頂點須要更新鄰接表。
假設須要刪除頂點 b。咱們不但須要 delete graph['b']
,還須要刪除頂點 a 與頂點 d 的鄰接數組中的引用。
每當移除一個頂點,都須要遍歷整個鄰接表,所以時間複雜度是 O(|V| + |E|)。有更好的實現方式嗎?稍後再回答這問題。首先讓咱們以更面向對象的方式實現鄰接表,以後切換(鄰接表的底層)實現將更容易。
先從頂點的類開始,在該類中,除了保存頂點自身以及它的相鄰頂點集合以外,還會編寫一些方法,用於在鄰接表中增長或刪除相鄰的頂點。
class Node {
constructor(value) {
this.value = value;
this.adjacents = []; // adjacency list
}
addAdjacent(node) {
this.adjacents.push(node);
}
removeAdjacent(node) {
const index = this.adjacents.indexOf(node);
if (index > -1) {
this.adjacents.splice(index, 1);
return node;
}
}
getAdjacents() {
return this.adjacents;
}
isAdjacent(node) {
return this.adjacents.indexOf(node) > -1;
}
}
複製代碼
注意,addAdjacent
方法的時間複雜度是 O(1),但刪除相鄰頂點的函數時間複雜度是 O(|E|)。若是不使用數組而是用 HashSet 會怎樣呢?(刪除相鄰頂點的)時間複雜度會降低到 O(1)。但如今先讓代碼能跑起來,以後再作優化。
Make it work. Make it right. Make it faster.
如今有了 Node
類,是時候編寫 Graph
類,它能夠執行添加或刪除頂點和邊。
Graph.constructor
class Graph {
constructor(edgeDirection = Graph.DIRECTED) {
this.nodes = new Map();
this.edgeDirection = edgeDirection;
}
// ...
}
Graph.UNDIRECTED = Symbol('directed graph'); // one-way edges
Graph.DIRECTED = Symbol('undirected graph'); // two-ways edges
複製代碼
首先,咱們須要確認圖是有向仍是無向的,當添加邊時,這會有所不一樣。
Graph.addEdge
添加一條新的邊,須要知道兩個頂點:邊的起點與邊的終點。
addEdge(source, destination) {
const sourceNode = this.addVertex(source);
const destinationNode = this.addVertex(destination);
sourceNode.addAdjacent(destinationNode);
if(this.edgeDirection === Graph.UNDIRECTED) {
destinationNode.addAdjacent(sourceNode);
}
return [sourceNode, destinationNode];
}
複製代碼
咱們往邊的起點添加了一個相鄰頂點(即邊的終點)。若是該圖是無向圖,也須要往邊的終點添加一個相鄰頂點(即邊的起點),由於(無向圖中)邊是雙向的。
在鄰接表中新增一條邊的時間複雜度是:O(1)。
若是新添加的邊兩端的頂點並不存在,就必需先建立(不存在的頂底),下面讓咱們來實現它!
Graph.addVertex
建立頂點的方式是往 this.nodes
中新增一個頂點。this.nodes
中存儲着的是一組組鍵值對,鍵是頂點的值,值是 Node
類的實例。注意看下面代碼的 5-6 行(即 const vertex = new Node(value); this.nodes.set(value, vertex);
):
addVertex(value) {
if(this.nodes.has(value)) {
return this.nodes.get(value);
} else {
const vertex = new Node(value);
this.nodes.set(value, vertex);
return vertex;
}
}
複製代碼
不必覆寫已存在的頂點。所以先檢查一下頂點是否存在,若是不存在才創造一個新節點。
在鄰接表中新增一個頂點的時間複雜度是: O(1)。
Graph.removeVertex
從一個圖中刪除一個頂點會相對麻煩一點。咱們必須檢查待刪除的頂點是否爲其餘頂點的相鄰頂點。
removeVertex(value) {
const current = this.nodes.get(value);
if(current) {
for (const node of this.nodes.values()) {
node.removeAdjacent(current);
}
}
return this.nodes.delete(value);
}
複製代碼
必須訪問每一個頂點及其它們的相鄰頂點集合。
在鄰接表中刪除一個頂點的時間複雜度是: O(|V| + |E|)。
最後,一塊兒來實現刪除一條邊吧!
Graph.removeEdge
刪除一條邊是十分簡單的,與新增一條邊相似。
removeEdge(source, destination) {
const sourceNode = this.nodes.get(source);
const destinationNode = this.nodes.get(destination);
if(sourceNode && destinationNode) {
sourceNode.removeAdjacent(destinationNode);
if(this.edgeDirection === Graph.UNDIRECTED) {
destinationNode.removeAdjacent(sourceNode);
}
}
return [sourceNode, destinationNode];
}
複製代碼
刪除與新增一條邊主要的不一樣是:
Node.removeAdjacent
而不是 Node.addAdjacent
。因爲 removeAdjacent
須要遍歷相鄰節點的集合,所以它的運行時是:
在鄰接表中刪除一條邊的時間複雜度是: O(|E|)。
接下來,咱們將討論如何從圖中搜索。
廣度優先搜索是一種從最初的頂點開始,優先訪問全部相鄰頂點的搜索方法。
接下來一塊兒看看如何用代碼來實現它:
*bfs(first) {
const visited = new Map();
const visitList = new Queue();
visitList.add(first);
while(!visitList.isEmpty()) {
const node = visitList.remove();
if(node && !visited.has(node)) {
yield node;
visited.set(node);
node.getAdjacents().forEach(adj => visitList.add(adj));
}
}
}
複製代碼
正如你所見的同樣,咱們使用了一個隊列來暫存待訪問的頂點,隊列遵循先進先出(FIFO)的原則。
同時也是用了 JavaScript generators,要注意函數名前面 *
(,那是生成器的標誌)。經過生成器,能夠一次迭代一個值(即頂點)。對於巨型(包含數以百萬計的頂點)的圖而言是頗有用的,不少狀況下不用訪問圖的每個頂點。
如下是如何使用上述 BFS 代碼的示例:
const graph = new Graph(Graph.UNDIRECTED);
const [first] = graph.addEdge(1, 2);
graph.addEdge(1, 3);
graph.addEdge(1, 4);
graph.addEdge(5, 2);
graph.addEdge(6, 3);
graph.addEdge(7, 3);
graph.addEdge(8, 4);
graph.addEdge(9, 5);
graph.addEdge(10, 6);
bfsFromFirst = graph.bfs(first);
bfsFromFirst.next().value.value; // 1
bfsFromFirst.next().value.value; // 2
bfsFromFirst.next().value.value; // 3
bfsFromFirst.next().value.value; // 4
// ...
複製代碼
你能夠在這找到更多的測試代碼。
接下來該講述深度優先搜索了!
深度優先搜索是圖的另外一種搜索方法,經過遞歸搜索頂點的首個相鄰頂點,再搜索其餘相鄰頂點,從而訪問全部的頂點。
DFS 的實現近似於 BFS,但使用的是棧而不是隊列:
*dfs(first) {
const visited = new Map();
const visitList = new Stack();
visitList.add(first);
while(!visitList.isEmpty()) {
const node = visitList.remove();
if(node && !visited.has(node)) {
yield node;
visited.set(node);
node.getAdjacents().forEach(adj => visitList.add(adj));
}
}
}
複製代碼
測試例子以下:
const graph = new Graph(Graph.UNDIRECTED);
const [first] = graph.addEdge(1, 2);
graph.addEdge(1, 3);
graph.addEdge(1, 4);
graph.addEdge(5, 2);
graph.addEdge(6, 3);
graph.addEdge(7, 3);
graph.addEdge(8, 4);
graph.addEdge(9, 5);
graph.addEdge(10, 6);
dfsFromFirst = graph.dfs(first);
visitedOrder = Array.from(dfsFromFirst);
const values = visitedOrder.map(node => node.value);
console.log(values); // [1, 4, 8, 3, 7, 6, 10, 2, 5, 9]
複製代碼
正如你所看到的,BFS 與 DFS 所用的圖(的數據)是同樣的,然而訪問頂點的順序卻很是不同。BFS 是從 1 到 10 按順序輸出,DFS 則是先進入最深處訪問頂點(譯者注:其實這個例子是先序遍歷,看起來可能不太像先深刻最深處)。
咱們接觸了圖的一些基礎操做,如何添加和刪除一個頂點或一條邊,如下是前文涵蓋內容的小結:
鄰接表 | 鄰接矩陣 | |
---|---|---|
空間複雜度 | O(|V|+ |E|) | O(|V|²) |
添加頂點 | O(1) | O(|V|²) |
移除頂點 | O(|V| + |E|) | O(|V|)² |
添加邊 | O(1) | O(1) |
移除邊 (基於 Array 實現) | O(|E|) | O(1) |
移除邊 (基於 HashSet 實現) | O(1) | O(1 |
獲取相鄰的頂點 | O(|E|) | O(|V|) |
判斷是否相鄰 (基於 Array 實現) | O(|E|) | O(1) |
判斷是否相鄰 (基於 HashSet 實現) | O(1) | O(1) |
正如上表所示,鄰接表中幾乎全部的操做方法都是更快的。鄰接矩陣比鄰接表性能更高的方法只有一處:檢查頂點是否與其餘頂點相鄰,然而使用 HashSet 而不是 Array 實現鄰接表的話,也能在恆定時間內獲取結果 :)
圖能夠是不少現實場景的抽象,如機場,社交網絡,互聯網等。咱們介紹了一些圖的基礎算法,如廣度優先搜索(BFS)與深度優先搜索(DFS)等。同時權衡了圖的不一樣實現方式:鄰接矩陣和鄰接表。咱們將在另一篇文章(更深刻地)介紹圖的其餘應用,如查找圖的兩個頂點間的最短距離及其餘有趣的算法(譯者注:這篇文章介紹的比較基礎,圖的各類算法纔是最有趣的,有興趣的同窗能夠看這個)。