[譯文] 初學者應該瞭解的資料結構: Graph

sea_ljf發表於2018-07-27

原文連結:Graph Data Structures for Beginners

眾成翻譯地址:初學者應該瞭解的資料結構: Graph

系列文章,建議不瞭解圖的同學慢慢閱讀一下這篇文章,希望對你有所幫助~如果想深入理解圖,那不建議閱讀這篇基礎文章,這裡有更多深入的知識可以探索~以下是譯文正文:

Graph Data Structures for Beginners

在這篇文章中,我們將要探索非線性的資料結構:圖,將涵蓋它的基本概念及其典型的應用。

你很可能在不同的應用中接觸到圖(或樹)。比如你想知道從家出發怎麼去公司最近,就可以利用圖的(尋路)演算法來得到答案!我們將探討上述場景與其他有趣的情況。

在上一篇文章中,我們探討了線性的資料結構,如陣列、連結串列、集合、棧等。本文將以此(譯者注:即線性資料結構,沒看過前文也沒關係,其實也很好懂)為基礎。


本篇是以下教程的一部分(譯者注:如果大家覺得還不錯,我會翻譯整個系列的文章):

初學者應該瞭解的資料結構與演算法(DSA)

  1. 演算法的時間複雜性與大 O 符號
  2. 每個程式設計師應該知道的八種時間複雜度
  3. 初學者應該瞭解的資料結構:Array、HashMap 與 List (譯文)
  4. 初學者應該瞭解的資料結構: Graph ? 即本文
  5. 初學者應該瞭解的資料結構:Tree (敬請期待)
  6. 附錄 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)

[譯文] 初學者應該瞭解的資料結構: Graph
Graph is composed of vertices and edges

一個頂點的**度(degree)**是指與該頂點相連的邊的條數。比如上圖中,紫色頂點的度是 3,藍色頂點的度是 1。

如果所有的邊都是雙向(譯者注:或者理解為沒有方向)的,那我們就有了一個無向圖(undirected graph)。反之如果邊是有向的,我們得到的就是有向圖(directed graph)。你可以將有向圖和無向圖想象為單行道或雙行道組成的交通網。

[譯文] 初學者應該瞭解的資料結構: Graph
Directed vs Undirected graph

頂點的邊可以是從自己出發再連線回自己(如藍色的頂點),擁有這樣的邊的圖被稱為自環

圖可以有環(cycle),即如果遍歷圖的頂點,某個頂點可以被訪問超過一次。而沒有環的圖被稱為無環圖(acyclic graph)

[譯文] 初學者應該瞭解的資料結構: Graph
Cyclic vs Acyclic directed graph

此外,無環無向圖也被稱為樹(tree)。在下篇文章中,我們將深入套路這種資料結構。

在圖中,從一個頂點出發,並非所有頂點都是可到達的。可能會存在孤立的頂點或者是相分離的子圖。如果一個圖所有頂點都至少有一條邊(譯者注:原文表述有點奇怪,個人認為不應該是至少有一條邊,而是從任一節點出發,沿著各條邊可以訪問圖中任意節點),這樣的圖被稱為連通圖(connected graph)。而當一個圖中兩兩不同的頂點之間都恰有一條邊相連,這樣的圖就是完全圖(complete graph)

[譯文] 初學者應該瞭解的資料結構: Graph
Complete vs Connected graph

對於完全圖而言,每個頂點都有 圖的頂點數 - 1 條邊。在上面完全圖的例子中,一共有7個頂點,因此每個頂點有6條邊。

圖的應用

當圖的每條邊都被分配了權重時,我們就有了一個加權圖(weighted graph)。如果邊的權重被忽略,那麼可以將(每條邊的)權重都視為 1(譯者注:權重都是一樣,也就是無權重)。

[譯文] 初學者應該瞭解的資料結構: Graph
Airports weighted graph

加權圖應用的場景很多,根據待解決問題主體的不同,有不同的展現。一起來看一些具體的場景吧:

  • 航空線路圖 (如上圖所示)

    • 頂點 = 機場
    • 邊 = 兩個機場間的飛行線路
    • 權重 = 兩個機場間的距離
  • GPS 導航

    • 頂點 = 交叉路口
    • 邊 = 道路
    • 權重 = 從一個路口到另一個路口所花的時間
  • 網路

    • 頂點 = 伺服器
    • 邊 = 資料鏈路
    • 權重 = 連線速度

一般而言, 圖在現實世界中的應用有:

  • 電子電路
  • 航空控制
  • 行車導航
  • 電信設施: 基站建設規劃
  • 社交網路: Facebook 利用圖來推薦(你可能認識的)朋友
  • 推薦系統: Amazon/Netflix 利用圖來推薦產品與電影
  • 利用圖來規劃物流線路

[譯文] 初學者應該瞭解的資料結構: Graph
Graph applications: path finder

我們學習了圖的基礎以及它的一些應用場景。接下來一起學習怎麼使用程式碼來表示圖。

圖的表示

圖的表示有兩種主要方式:

  1. 鄰接表
  2. 鄰接矩陣

讓我們以有向圖為例子,闡述這兩種表示方式:

[譯文] 初學者應該瞭解的資料結構: Graph
digraph

這是一個擁有四個頂點的圖。當一個頂點有一條邊指向它自身時(譯者注:即閉合的路徑),稱之為自環(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) - 圖的搜尋

廣度優先搜尋是一種從最初的頂點開始,優先訪問所有相鄰頂點的搜尋方法。

[譯文] 初學者應該瞭解的資料結構: Graph
Breadth First Search in a graph

接下來一起看看如何用程式碼來實現它:

*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) -圖的搜尋

深度優先搜尋是圖的另一種搜尋方法,通過遞迴搜尋頂點的首個相鄰頂點,再搜尋其他相鄰頂點,從而訪問所有的頂點。

[譯文] 初學者應該瞭解的資料結構: Graph
Depth First Search in a graph

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)等。同時權衡了圖的不同實現方式:鄰接矩陣和鄰接表。我們將在另外一篇文章(更深入地)介紹圖的其他應用,如查詢圖的兩個頂點間的最短距離及其他有趣的演算法(譯者注:這篇文章介紹的比較基礎,圖的各種演算法才是最有趣的,有興趣的同學可以看這個)。

相關文章