原文連結:Graph Data Structures for Beginners
眾成翻譯地址:初學者應該瞭解的資料結構: Graph
系列文章,建議不瞭解圖的同學慢慢閱讀一下這篇文章,希望對你有所幫助~如果想深入理解圖,那不建議閱讀這篇基礎文章,這裡有更多深入的知識可以探索~以下是譯文正文:
在這篇文章中,我們將要探索非線性的資料結構:圖,將涵蓋它的基本概念及其典型的應用。
你很可能在不同的應用中接觸到圖(或樹)。比如你想知道從家出發怎麼去公司最近,就可以利用圖的(尋路)演算法來得到答案!我們將探討上述場景與其他有趣的情況。
在上一篇文章中,我們探討了線性的資料結構,如陣列、連結串列、集合、棧等。本文將以此(譯者注:即線性資料結構,沒看過前文也沒關係,其實也很好懂)為基礎。
本篇是以下教程的一部分(譯者注:如果大家覺得還不錯,我會翻譯整個系列的文章):
初學者應該瞭解的資料結構與演算法(DSA)
- 演算法的時間複雜性與大 O 符號
- 每個程式設計師應該知道的八種時間複雜度
- 初學者應該瞭解的資料結構:Array、HashMap 與 List (譯文)
- 初學者應該瞭解的資料結構: Graph ? 即本文
- 初學者應該瞭解的資料結構:Tree (敬請期待)
- 附錄 I:遞迴演算法分析
以下是本文對圖操作的小結:
鄰接表 | 鄰接矩陣 | |
---|---|---|
空間複雜度 | 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 個或多個元素
兩個節點相連的部分稱為邊(edge)。節點也被稱作頂點(vertice)。
一個頂點的**度(degree)**是指與該頂點相連的邊的條數。比如上圖中,紫色頂點的度是 3,藍色頂點的度是 1。
如果所有的邊都是雙向(譯者注:或者理解為沒有方向)的,那我們就有了一個無向圖(undirected graph)。反之如果邊是有向的,我們得到的就是有向圖(directed graph)。你可以將有向圖和無向圖想象為單行道或雙行道組成的交通網。
頂點的邊可以是從自己出發再連線回自己(如藍色的頂點),擁有這樣的邊的圖被稱為自環。
圖可以有環(cycle),即如果遍歷圖的頂點,某個頂點可以被訪問超過一次。而沒有環的圖被稱為無環圖(acyclic graph)。
此外,無環無向圖也被稱為樹(tree)。在下篇文章中,我們將深入套路這種資料結構。
在圖中,從一個頂點出發,並非所有頂點都是可到達的。可能會存在孤立的頂點或者是相分離的子圖。如果一個圖所有頂點都至少有一條邊(譯者注:原文表述有點奇怪,個人認為不應該是至少有一條邊,而是從任一節點出發,沿著各條邊可以訪問圖中任意節點),這樣的圖被稱為連通圖(connected graph)。而當一個圖中兩兩不同的頂點之間都恰有一條邊相連,這樣的圖就是完全圖(complete graph)。
對於完全圖而言,每個頂點都有 圖的頂點數 - 1 條邊。在上面完全圖的例子中,一共有7個頂點,因此每個頂點有6條邊。
圖的應用
當圖的每條邊都被分配了權重時,我們就有了一個加權圖(weighted graph)。如果邊的權重被忽略,那麼可以將(每條邊的)權重都視為 1(譯者注:權重都是一樣,也就是無權重)。
加權圖應用的場景很多,根據待解決問題主體的不同,有不同的展現。一起來看一些具體的場景吧:
-
航空線路圖 (如上圖所示)
- 頂點 = 機場
- 邊 = 兩個機場間的飛行線路
- 權重 = 兩個機場間的距離
-
GPS 導航
- 頂點 = 交叉路口
- 邊 = 道路
- 權重 = 從一個路口到另一個路口所花的時間
-
網路
- 頂點 = 伺服器
- 邊 = 資料鏈路
- 權重 = 連線速度
一般而言, 圖在現實世界中的應用有:
- 電子電路
- 航空控制
- 行車導航
- 電信設施: 基站建設規劃
- 社交網路: Facebook 利用圖來推薦(你可能認識的)朋友
- 推薦系統: Amazon/Netflix 利用圖來推薦產品與電影
- 利用圖來規劃物流線路
我們學習了圖的基礎以及它的一些應用場景。接下來一起學習怎麼使用程式碼來表示圖。
圖的表示
圖的表示有兩種主要方式:
- 鄰接表
- 鄰接矩陣
讓我們以有向圖為例子,闡述這兩種表示方式:
這是一個擁有四個頂點的圖。當一個頂點有一條邊指向它自身時(譯者注:即閉合的路徑),稱之為自環(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 的鍵是頂點的值,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) - 圖的搜尋
廣度優先搜尋是一種從最初的頂點開始,優先訪問所有相鄰頂點的搜尋方法。
接下來一起看看如何用程式碼來實現它:
*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) -圖的搜尋
深度優先搜尋是圖的另一種搜尋方法,通過遞迴搜尋頂點的首個相鄰頂點,再搜尋其他相鄰頂點,從而訪問所有的頂點。
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)等。同時權衡了圖的不同實現方式:鄰接矩陣和鄰接表。我們將在另外一篇文章(更深入地)介紹圖的其他應用,如查詢圖的兩個頂點間的最短距離及其他有趣的演算法(譯者注:這篇文章介紹的比較基礎,圖的各種演算法才是最有趣的,有興趣的同學可以看這個)。