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

原文連接:Graph Data Structures for Beginnersnode

衆成翻譯地址:初學者應該瞭解的數據結構: Graphgit

系列文章,建議不瞭解圖的同窗慢慢閱讀一下這篇文章,但願對你有所幫助~若是想深刻理解圖,那不建議閱讀這篇基礎文章,這裏有更多深刻的知識能夠探索~如下是譯文正文:程序員

Graph Data Structures for Beginners

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

你極可能在不一樣的應用中接觸到圖(或樹)。好比你想知道從家出發怎麼去公司最近,就能夠利用圖的(尋路)算法來獲得答案!咱們將探討上述場景與其餘有趣的狀況。算法

在上一篇文章中,咱們探討了線性的數據結構,如數組、鏈表、集合、棧等。本文將以此(譯者注:即線性數據結構,沒看過前文也不要緊,其實也很好懂)爲基礎。數組


本篇是如下教程的一部分(譯者注:若是你們以爲還不錯,我會翻譯整個系列的文章):服務器

初學者應該瞭解的數據結構與算法(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 個或多個元素app

兩個節點相連的部分稱爲邊(edge)。節點也被稱做頂點(vertice)

Graph is composed of vertices and edges

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

若是全部的邊都是雙向(譯者注:或者理解爲沒有方向)的,那咱們就有了一個無向圖(undirected graph)。反之若是邊是有向的,咱們獲得的就是有向圖(directed graph)。你能夠將有向圖和無向圖想象爲單行道或雙行道組成的交通網。

Directed vs Undirected graph

頂點的邊能夠是從本身出發再鏈接回本身(如藍色的頂點),擁有這樣的邊的圖被稱爲自環

圖能夠有環(cycle),即若是遍歷圖的頂點,某個頂點能夠被訪問超過一次。而沒有環的圖被稱爲無環圖(acyclic graph)

Cyclic vs Acyclic directed graph

此外,無環無向圖也被稱爲樹(tree)。在下篇文章中,咱們將深刻套路這種數據結構。

在圖中,從一個頂點出發,並不是全部頂點都是可到達的。可能會存在孤立的頂點或者是相分離的子圖。若是一個圖全部頂點都至少有一條邊(譯者注:原文表述有點奇怪,我的認爲不該該是至少有一條邊,而是從任一節點出發,沿着各條邊能夠訪問圖中任意節點),這樣的圖被稱爲連通圖(connected graph)。而當一個圖中兩兩不一樣的頂點之間都恰有一條邊相連,這樣的圖就是徹底圖(complete graph)

Complete vs Connected graph

對於徹底圖而言,每一個頂點都有 圖的頂點數 - 1 條邊。在上面徹底圖的例子中,一共有7個頂點,所以每一個頂點有6條邊。

圖的應用

當圖的每條邊都被分配了權重時,咱們就有了一個加權圖(weighted graph)。若是邊的權重被忽略,那麼能夠將(每條邊的)權重都視爲 1(譯者注:權重都是同樣,也就是無權重)。

Airports weighted graph

加權圖應用的場景不少,根據待解決問題主體的不一樣,有不一樣的展示。一塊兒來看一些具體的場景吧:

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

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

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

    • 頂點 = 服務器
    • 邊 = 數據鏈路
    • 權重 = 鏈接速度

通常而言, 圖在現實世界中的應用有:

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

Graph applications: path finder

咱們學習了圖的基礎以及它的一些應用場景。接下來一塊兒學習怎麼使用代碼來表示圖。

圖的表示

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

  1. 鄰接表
  2. 鄰接矩陣

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

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

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

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

深度優先搜索是圖的另外一種搜索方法,經過遞歸搜索頂點的首個相鄰頂點,再搜索其餘相鄰頂點,從而訪問全部的頂點。

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)等。同時權衡了圖的不一樣實現方式:鄰接矩陣和鄰接表。咱們將在另一篇文章(更深刻地)介紹圖的其餘應用,如查找圖的兩個頂點間的最短距離及其餘有趣的算法(譯者注:這篇文章介紹的比較基礎,圖的各類算法纔是最有趣的,有興趣的同窗能夠看這個)。

相關文章
相關標籤/搜索