1. 程式人生 > >為什麼我要放棄javaScript資料結構與演算法(第九章)—— 圖

為什麼我要放棄javaScript資料結構與演算法(第九章)—— 圖

本章中,將學習另外一種非線性資料結構——圖。這是學習的最後一種資料結構,後面將學習排序和搜尋演算法。

第九章 圖

圖的相關術語

圖是網路結構的抽象模型。圖是一組由邊連線的節點(或頂點)。學習圖是重要的,因為在任何二元關係都可以用圖來表示。

任何社交網路都可以用圖來表示。

我們還可以用圖來表示道路、航班以及通訊狀態

道路

一個圖 G= (V,E)由以下元素組成。

  • V:一組頂點
  • E:一組邊。連線V中的頂點

道路

由一條邊連線在一起的頂點稱為相鄰頂點。比如,A和B 是相鄰的,A和D是相鄰的,A和C是相鄰的,A和E是不相鄰的。

一個頂點的度是其相鄰頂點的數量。比如,A和其他三個頂點相連線,因此,A的度為3;E和其他兩個頂點相連線,因此E的度為2.

路徑是頂點v1,v2,...vk的一個連續序列,其中 vi 和 vi+1 (下標)是相鄰的。以上一示意圖為例,其中包含的路徑A B E I 和 A C D G。

簡單路徑要求不包含重複的頂點。舉個例子,A D G是一條簡單路徑。除去最後一個頂點(因為它和第一個頂點是同一個頂點),環也是簡單路徑,比如A D C A(最後一個頂點重新回到A)

如果途中不存在環則稱該圖是無環的。如果圖中每兩個頂點間都存在路徑,則該圖是連通的。

有向圖和無向圖

圖可以是無向的(邊沒有方向)或是有向的(有向圖)。下圖就是有向圖。

有向圖

有向圖的邊有一個方向。如果圖中每兩個頂點間在雙向上都存在路徑,則該圖是強連通的。例如,C和D就是強連通的。圖還可以是未加權的或者加權的。加權圖的邊被賦予了權值。

加權圖

我們可以使用圖來解決電腦科學世界中的很多問題,比如搜尋圖中的一個特定頂點或搜尋一條特定邊,尋找圖中的一條路徑(從一個頂點到另一個頂點),尋找兩個頂點之間的最短路徑。

圖的表示

從資料結構角度來說,我們有多種方式來表示圖。在所有表示法中,不存在絕對正確的方法方式。圖的正確表示法取決於解決的問題和圖的型別。

鄰接矩陣

圖最常見的實現就是鄰接矩陣。每個節點和一個整數相關聯,該整數將作為陣列的索引。我們用一個二維陣列來表示頂點之間的連線。如果索引為i的節點和索引為 j的節點為鄰,則 array[i][j] === 1,否則 array[i][j] === 0,如下圖所示:

鄰接矩陣

不是強連通的圖(稀疏圖)如果用鄰接矩陣來表示,則矩陣中將會有很多0,這意味著我們浪費了計算機儲存空間來表示根本不存在的邊。例如,找給定頂點的相鄰頂點,即使該頂點只有一個相鄰的頂點,我們也不得不迭代一整行。鄰接矩陣表示法不夠好的另一個理由是,圖中頂點的數量可能會變化,而二維陣列不太靈活。

鄰接表

鄰接表

我們也可以使用一種叫做鄰接表的動態資料來表示圖。鄰接表由圖中每個頂點相鄰頂點列表所組成。存在好幾種方式來表示這種資料結構。我們可以用列表(陣列)、連結串列,甚至是散列表或者是字典來表示相鄰頂點列表。下面的示意圖展示了鄰接表等資料結構。

鄰接表

儘管鄰接表可能對大多數問題來說都是更好的選擇,但以上兩種表示法都有用,且它們有著不同的性質(例如,要找出頂點 vw是否相鄰,使用鄰接矩陣會比較快)。在本章中,就會使用鄰接表表示法。

關聯矩陣

我們還可以用關聯矩陣來表示圖。在關聯矩陣中,矩陣的行表示頂點,列表示邊。如下圖所示,我們使用二維陣列來表示兩者之間的連通性,如果頂點 v 是 邊e的入射點,則 array[v][e] === 1,否則 array[v][e] === 0

關聯矩陣

建立 Graph 類

我們先建立類的骨架

function Graph(){
    var vertices = [];
    var adjList = new Dictionary();
}

我們使用一個數組來儲存圖中所有頂點的名字,以及一個字典來儲存鄰接表。字典將會使用頂點的名字作為鍵,鄰接頂點列表作為值。 vertices陣列和 adjList字典兩者都是我們 Graph類的私有屬性。

接著我們實現兩個方法:一個用來向圖中新增一個新的頂點(因為圖例項化後是空的),另外一個方法用來新增頂點之間的邊。我們先實現 addVertex 方法

this.addVertex = function(v){
    vertices.push(v);
    adjList.set(v,[]);
}

這個方法接受頂點 v 作為引數。我們將該頂點新增到頂點列表中,並且在鄰接表中,設定頂點 v 作為鍵對應的字典值為一個空陣列。

實現 addEdge 方法

this.addEdge = function(v,w){
    adjList.get(v).push(w);         
    adjList.get(w).push(v);         
}

這個方法接受兩個頂點作為引數。首先,通過將 w 加入到 v 的鄰接表中,我們添加了一條自頂點 v 到頂點 w 的邊。如果你想實現一個有向圖,則(adjList.get(v).push(w))就足夠了。但是本章中大多數的例子都是基於無向圖,我們需要新增一條自w向v的邊。

測試

const graph = new Graph();
const myVertices = ['A','B','C','D','E','F','G','H','I'];
for(var i = 0; i < myVertices.length; i++){
    graph.addVertex(myVertices[i]);
}
graph.addEdge('A','B');
graph.addEdge('A','C');
graph.addEdge('A','D');
graph.addEdge('C','D');
graph.addEdge('C','G');
graph.addEdge('D','G');
graph.addEdge('D','H');
graph.addEdge('B','E');
graph.addEdge('B','F');
graph.addEdge('E','I');

實現 Graph類的 toString 方法,便於在控制檯輸出圖

this.toString = function(){
    var s = '';
    for(var i = 0; i < vertices.length; i++){
        s += vertices[i] + ' -> ';
        var neighbors = adjList.get(vertices[i]);
        for(var j = 0; j < neighbors.length; j++){
            s += neighbors[j] + ' ';
        }
        s += '\n';
    }
    return s;
}

我們為鄰接表表示法構建了一個字串,首先迭代 vertices 陣列列表,將頂點的名字加入字串中,接著取得該頂點的鄰接表,同樣也迭代該鄰接表,將相鄰頂點加入我們的字串。鄰接表迭代完成後,給我們的字串新增一個換行符。這樣就可以在控制看到一個漂亮的輸出了。

A -> B C D 
B -> A E F 
C -> A D G 
D -> A C G H 
E -> B I 
F -> B 
G -> C D 
H -> D 
I -> E 

圖的遍歷

和樹資料結構相似,我們可以訪問圖的所有節點。有兩種演算法可以對圖進行遍歷:廣度優先搜尋(Breadth-First Search,BFS)和深度優先搜尋(Depth-First Search,DFS)。圖遍歷可以用來尋找特定的頂點或者是尋找兩個頂點之間的路徑,檢查圖是否連通,檢查圖是否含有環等。

圖遍歷演算法的思想是必須追蹤每個第一次訪問的節點,並且追蹤有哪些節點還沒有被完全探索,對於兩種圖遍歷演算法,都需要明確指出第一個被訪問的節點。

完全探索一個頂點要求我們檢視該頂點的每一條邊。對於每一條邊所連線的沒有被訪問過的頂點,將其標註為被發現的,並將其加入待訪問的頂點。

為了保證演算法的效率,務必訪問每個頂點至多兩次。連通圖中每條邊和頂點都會被訪問到。

廣度優先搜尋演算法和深度優先搜尋演算法基本上是相同的,只有一點不同,那就是待訪問頂點列表的資料結構。

演算法 資料結構 描述
深度優先搜尋 通過將頂點存入棧中,頂點是沿著路徑被探索的,存在新的相鄰頂點就去訪問
廣度優先搜尋 佇列 通過將頂點存入佇列,最先入佇列的頂點先被探索

當要標註已經訪問過的頂點時,我們可以用三種顏色來放映它們的狀態

  • 白色:表示該頂點還沒有被訪問
  • 灰色:表示該頂點被訪問過,但是還沒有探索過
  • 黑色:表示該頂點被訪問過且被完全探索過

廣度優先搜尋

廣度優先搜尋演算法會從指定的第一個頂點開始遍歷圖,會訪問其所有相鄰點,就像一次訪問圖的一層。換句話說,就是先寬後深地訪問頂點,如下圖所示

廣度優先搜尋

以下是從頂點 v 開始的廣度優先搜尋演算法所遵循的步驟

  1. 建立一個佇列 Q
  2. 將 v 標註為被發現的(灰色),並將 v 入佇列Q
  3. 如果佇列Q 非空,則執行以下步驟
    1. 將 u 從 Q 中出佇列
    2. 將標註 u 為被發現的(灰色)
    3. 將 u 所有未被訪問過的鄰點(白色)入佇列
    4. 將 u 標註為已被探索的(黑色)

實現廣度優先搜尋演算法

// 顏色輔助-廣度優先搜尋演算法
this.initializeColor = function(){
    var color = [];
    for(var i = 0; i < vertices.length; i++){
        color[vertices[i]] = 'white';
    }
    return color;
}   
// 廣度優先搜尋演算法
this.bfs = function(v,callback){
    var color = this.initializeColor(),
    queue = new Queue();
    queue.enqueue(v);
    while(!queue.isEmpty()){
        var u = queue.dequeue(),
        neighbors = adjList.get(u);
        color[u] = 'grey';
        for(var i = 0; i < neighbors.length; i++){
            var w = neighbors[i];
            if(color[w] === 'white'){                   
                color[w] = 'grey';
                queue.enqueue(w);
            }
        }
        color[u] = 'black';
        if(callback){
            callback(u)
        }
    }
}

廣度優先搜尋和深度優先搜尋多需要標註被訪問過的頂點,為此,我們將使用一個輔助陣列 color,由於當演算法開始執行時,所有的頂點顏色都是白色,所以我們可以建立一個輔助函式 initializeColor 為這兩個演算法執行此初始化操作。

我們要的第一件事情是用 initializeColor 函式來將 color 陣列初始化為 white ,我們還需要宣告和建立一個 Queue 例項,它將會儲存待訪問和待探索的頂點。

bfs 方法接受一點頂點作為演算法的起始點。起始頂點是必要的,我們將此頂點如佇列。

如果佇列為空,我們將通過出佇列操作從佇列中移除一個頂點,並取得一個包含其所有鄰點的鄰接表。該頂點將被標註為 grey,表示我們已經發現了它(但還未被完全對其的探索)。

對於 u 的每個鄰點,我們取得其值,如果它還未被訪問過,則將其標註了grey,並將這個頂點加入佇列中,這樣當從佇列中出列的時候,我們可以完成對其的探索。

當完全探索該頂點和及其鄰點後,我們將標註該頂點為已探索過(黑色)。

我們實現的這個 bfs 方法也接受一個回撥。這個引數是可選的,如果我們傳遞了回撥函式,會用到它。

測試

function printNode(value){
    console.log('訪問了頂點:' + value);
}
graph.bfs(myVertices[0],printNode);

得到下面的結果

訪問了頂點:A
訪問了頂點:B
訪問了頂點:C
訪問了頂點:D
訪問了頂點:E
訪問了頂點:F
訪問了頂點:G
訪問了頂點:H
訪問了頂點:I

頂點訪問順序和之前的示意圖所展示的一致。

使用 BFS 尋找最短路徑

到目前為止,我們只展示了 BFS 演算法的基本原理。我們可以用該演算法做更多事情,而不只是輸出被訪問頂點的順序。例如,考慮如何來解決下面的問題。

給定一個圖G和源頂點v,找出每個頂點u,u和v之間最短的路徑(以邊的數量計)

對於給定頂點v,廣度優化演算法會訪問所有與其距離為1的頂點,接著是距離為2的頂點,以此類推。所以,可以用廣度優先演算法來解決這個問題。我們可以修改bfs方法以返回給我們一些資訊:

  • 從v到u的距離d[u]
  • 前溯點pred[u],用來推匯出從v到其他每個頂點u的最短路徑。

實現:

// 廣度優先搜尋演算法優化版本
this.BFS = function(v){
    var color = this.initializeColor(),
    queue = new Queue(),
    d = [],
    pred = [];
    queue.enqueue(v);

    for(var i = 0; i < vertices.length; i++){
        d[vertices[i]] = 0;
        pred[vertices[i]] = null;
    }
    while(!queue.isEmpty()){
        var u = queue.dequeue();
        neighbors = adjList.get(u);
        color[u] = 'grey';
        for(var i = 0; i < neighbors.length; i++){
            // w相鄰頂點
            var w = neighbors[i];
            if(color[w] === 'white'){
                color[w] == 'grey';
                d[w] = d[u] + 1;
                pred[w] = u;
                queue.enqueue(w);
            }
        }
        color[u] = 'black';
    }
    return {
        distance:d,
        predecessors: pred
    }
}

首先需要宣告陣列 d 來表示距離,以及 pred 陣列來表示前溯點。下一步用0來初始化陣列d,把pred賦值為 null。

當我們發現 頂點u的相鄰點w時,則設定w的前溯點值為u。我們還通過給d[u]加1來設定頂點v和相鄰點w之間的距離。

方法的最後返回一個包含d和pred的物件。

測試

var shortestPathA = graph.BFS(myVertices[0]);
console.log(shortestPathA);
// distance: [A: 0, B: 1, C: 1, D: 2, E: 2, F: 2, G: 2, H: 3, I: 3]
// predecessors: [A: null, B: "A", C: "A", D: "C", E: "B", F: "B",G: "D", , H: "D", , I: "E"]

通過前溯陣列,我們可以用下面這段程式碼來構建從頂點A到其他頂點的路徑:

var fromVertex = myVertices[0];
for(var i = 1; i < myVertices.length; i++){
    var toVertex = myVertices[i],
    path = new Stack();
    for(var v = toVertex; v !== fromVertex;v = shortestPathA.predecessors[v]){
        path.push(v);
    }
    path.push(fromVertex);
    var s = path.pop();
    while(!path.isEmpty()){
        s += '-' + path.pop();
    }
    console.log(s);
}

使用頂點A作為源頂點。對於每個其他頂點,我們會J計算頂點A到它的路徑。我們從頂點陣列得到toVertex ,然後會建立一個棧來 儲存路勁值。

接著,我們追溯 toVertext 到 fromVertext 的路徑。變數v被賦值為前溯點的值。這樣我們就可以方向追溯這條路徑。將變數v新增到棧中。最後,源頂點也會被新增到棧中,以得到完整的路徑。

這之後,我們建立了一個s字串,並將源頂點賦值給它。當棧是非空的時候,我們從棧中移出一個項並將其拼接到字串s的後面。最後在控制檯上輸出路徑。

A-B
A-C
A-C-D
A-B-E
A-B-F
A-C-D-G
A-C-D-H
A-B-E-I

深入學習的最短路徑演算法

本章中的圖不是加權圖。如果要計算加權圖中的最短路徑(例如,城市A 和城市B之間的最短路徑——GPS和Google Map 中用到的演算法),廣度優先搜尋未必合適。

舉個栗子,Dijkstra 演算法解決了單源中最短路徑問題。Bellman-Ford 演算法解決了邊權值為負的單源最短路徑問題。A*搜尋演算法解決了求僅一對頂點間的最短路徑問題,它用經驗法則來加速搜尋過程。Floyd-Warshall演算法解決了求所有頂點對間的最短路徑的這一問題。

圖是一個廣泛的主體,對最短路徑及其變種問題,我們有很多的解決方案。

深度優先搜尋

深度優先搜尋演算法將會從第一個指定的頂點開始遍歷圖,沿著路徑直到這條路徑最後一個頂點被訪問了,接著原路返回並探索下一條路徑。換句話說,它是先深度後廣度地訪問頂點,如下圖所示

深度優先搜尋

深度優先搜尋演算法不需要一個源頂點。在深度優先搜尋演算法中,若圖中頂點v未被訪問,則訪問該頂點。

要訪問頂點v,照下列的步驟

  1. 標註v為被發現的(灰色)
  2. 對於v的所有未訪問的鄰點w,訪問頂點w,標註v為已被探索的(黑色)

實現

// 深度優先探索演算法
this.dfs = function(callback){
    var color = this.initializeColor();
    for(var i = 0 ; i < vertices.length; i++){
        if(color[vertices[i]] === 'white' ){
            this.dfsVisit(vertices[i],color,callback);
        }
    }       
}
this.dfsVisit =function(u,color,callback){
    color[u] = 'grey';
    if(callback){
        callback(u);
    }
    var neighbors = adjList.get(u);
    for(var i = 0 ;i < neighbors.length; i++){
        var w = neighbors[i];
        if(color[w] === 'white'){
            arguments.callee(w,color,callback);
        }
    }
    color[u] = 'black';
}

首先,我們建立了顏色陣列,並用值white 為圖中的每個頂點對其進行了初始化,廣度優先搜尋也是這麼做的。接著,對於圖例項中每一個未被訪問過的頂點,我們呼叫遞迴函式 dfsVisit ,傳遞的引數為頂點、顏色陣列和回撥函式。

當訪問u頂點時,我們標註其為被發現的grey。如果有callback 函式的話,則執行該函式輸出已訪問過的頂點。接下來一步是取得包含頂點u的所有鄰點的列表。對於頂點u的每一個未被訪問過的鄰點w,我們將呼叫dfsVisit 函式,傳遞w和其他引數。最後,在該2頂點和鄰點按深度訪問之後,我們回退,意思是該頂點已經被完全探索了,並將其標註為black

測試

graph.dfs(printNode); 
// 訪問了頂點:A
// 訪問了頂點:B
// 訪問了頂點:E
// 訪問了頂點:I
// 訪問了頂點:F
// 訪問了頂點:C
// 訪問了頂點:D
// 訪問了頂點:G
// 訪問了頂點:H

下面這個示意圖展示了該演算法每一步的執行過程

深度優先搜尋執行過程

探索深度優化演算法

我們現在只是展示了深度優先搜尋演算法的工作原理。我們可以用該演算法做更多的事情,而不是隻輸出被訪問頂點的順序。

對於給定的圖G,我們希望深度優先探索演算法遍歷圖G的所有節點,構建“深林”(有根樹的一個集合)已經一組源頂點(根),並輸出兩個陣列:發現時間和完成探索時間。我們可以修改 dfs 方法來返回給我們一些資訊:

  • 頂點u的發現時間d[u]
  • 當頂點u被標註為黑色時,u的完成探索時間f[u]
  • 頂點u的前溯點p[u]
// 追蹤發現事件和完成探索時間
var time = 0;
// 深度優先探索演算法優化版本
this.DFS = function(){
    var color = this.initializeColor(),
    d = [],
    f = [],
    p = [];
    time = 0;

    for(var i = 0; i < vertices.length; i++){
        f[vertices[i]] = 0;
        d[vertices[i]] = 0;
        p[vertices[i]] = null;
    }

    for(i = 0; i< vertices.length; i++){
        if(color[vertices[i]] === 'white'){
            this.DFSVisit(vertices[i],color,d,f,p)
        }
    }
    return {
        discovery:d,
        finished:f,
        predecessors:p
    }
}
this.DFSVisit = function(u,color,d,f,p){
    console.log('發現了'+u);
    color[u] = 'grey';
    d[u] = ++time;
    var neighbors = adjList.get(u);
    for(var i = 0; i < neighbors.length; i++){
        var w = neighbors[i];
        if(color[w] === 'white'){
            p[w] = u;
            arguments.callee(w,color,d,f,p);
        }
    }
    color[u] = 'black';
    f[u] = ++time;
    console.log('探索了'+u);
}

首先我們需要一個變數來追蹤發現時間和完成探索時間。時間變數不能被作為引數傳遞,因為非物件的變數不能作為引用傳遞給其他Js方法。接下來,我們宣告陣列d、f和p。我們需要為圖的每一個頂點來初始化這些陣列。在這個方法結尾返回這些值。

當一個頂點第一次被發現時,我們要追蹤其發現時間。當它是由引自頂點u的邊而被發現的。我們追蹤它的前溯點。最後,當這個頂點被完全探索之後,我們追蹤其完成時間。

深度優先演算法背後的思想是什麼?

邊是從最近發現的u處被向外探索的。只有連線到未發現的頂點的邊被探索了。當u所有的邊都被探索了,該演算法返回u被發現的地方去探索其他的邊。這個過程持續到我們發現了雖偶有從原始頂點能夠觸及的頂點。如果還留有其他未被發現的頂點。我們對新的源頂點將重複這個過程。直到圖中所有的頂點都被探索了。

測試

var deepPath = graph.DFS();
console.log(deepPath);
/**
發現了A
發現了B
發現了E
發現了I
探索了I
探索了E
發現了F
探索了F
探索了B
發現了C
發現了D
發現了G
探索了G
發現了H
探索了H
探索了D
探索了C
探索了A

discovery: [A: 1, B: 2, C: 10, D: 11, E: 3, F: 7, G: 12, H: 14, I: 4]
finished: [A: 18, B: 9, C: 17, D: 16, E: 6, F: 8, G: 13, H: 15, I: 5]
predecessors: [A: null, B: "A", C: "A", D: "C", E: "B", G: "D", H: "D", I: "E"]
*/ 

小結

本章學習了幾種不同的方式來表示圖這一資料結構。並實現了用鄰接表表示圖的演算法。還學習了廣度優先搜尋和深度優先搜尋的實際應用。