數據結構 - 圖

圖是網絡結構的抽象模型。圖是一組由邊連接的節點(或頂點),任何二元關係都可以用圖來表示。

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

  • V: 一組頂點
  • E: 一組邊,連接V中的頂點

下圖表示一個圖:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-T6j4akhD-1584450013346)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445277875.png)]

由一條邊連接在一起的頂點稱爲相鄰頂點。比如,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。

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

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

有向圖和無向圖

圖可以是無向的(邊沒有方向)或是有向的(有向圖)。如下圖所示,有向圖的邊有一個方向:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-8QXMce3h-1584450013369)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445540939.png)]

如果圖中每兩個頂點間在雙向上都存在路徑,則該圖是強連通的。例如,C和D是強連通的, 而A和B不是強連通的。

圖還可以是未加權的(目前爲止我們看到的圖都是未加權的)或是加權的。如下圖所示,加 權圖的邊被賦予了權值:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-WSvmCLtu-1584450013372)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445721483.png)]

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

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

鄰接矩陣

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QqXWCYMf-1584450013376)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445856364.png)]

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

鄰接表

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-s1GHZrdI-1584450013383)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445963798.png)]

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

關聯矩陣

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-togZF3Vl-1584450013387)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584446742425.png)]

關聯矩陣通常用於邊的數量比頂點多的情況下,以節省空間和內存。

創建圖類:

class Graph() {

    constructor() {
        this.vertices = []
        this.adjList = new Dictionary()
    }

    // 添加頂點
    addVertex(v) {
        this.vertices.push(v)
        this.adjList.set(v, [])
    }

    // 添加線
    addEdge(v, w) {
        this.adjList.get(v).push(w)
        this.adjList.get(w).push(v)
    }

    toString() {
        return this.vertices.reduce((r, v, i) => {
            return this.adjList.get(v).reduce((r, w, i) => {
                return r + `${w} `
            }, `${r}\n${v} => `)
        }, '')
    }
}

使用圖類:

const graph = new Graph()

;['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'].forEach(c => graph.addVertex(c))

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')

console.log(graph.toString())

// 輸出
/*
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)

圖遍歷可以用來尋找特定的頂點或尋找兩個頂點之間的路徑,檢查圖是否連通,檢查圖是否含有環等。

在實現算法之前,讓我們來更好地理解一下圖遍歷的思想方法。

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

完全探索一個頂點要求我們查看該頂點的每一條邊。對於每一條邊所連接的沒有被訪問過的 頂點,將其標註爲被發現的,並將其加進待訪問頂點列表中。

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

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

  • 深度優先搜索:桟,通過將頂點存入桟中,頂點是沿着路徑被探索的,存在新的相鄰頂點就去訪問

  • 廣度優先搜索 :隊列,通過將頂點存入隊列中,最先入隊列的頂點先被探索

廣度優先搜索

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Rg8P6a7U-1584450013390)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584447758347.png)]

以下是我們的方法實現的。

維護兩個隊列,分別用於存儲已讀和待讀頂點,兩者具有互斥性,即某頂點在訪問時只會屬於一種類型,本質是通過不斷遞歸將相鄰的頂點進行訪問和維度標爲已讀。

讓我們來實現廣度優先搜索算法:

// breadth first search
bfs(v, callback) {
    const read = []
    const adjList = this.adjList
    let pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
}

讓我們執行下面這段代碼來測試一下這個算法:

graph.bfs(graph.vertices[0], value => console.log('Visited vertex: ' + value))

輸出結果:

Visited vertex: A
Visited vertex: B
Visited vertex: C
Visited vertex: D
Visited vertex: E
Visited vertex: F
Visited vertex: G
Visited vertex: H
Visited vertex: I

使用BFS尋找最短路徑

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

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

對於給定頂點V,廣度優先算法會訪問所有與其距離爲1的頂點,接着是距離爲2的頂點,以此類推。所以,可以用廣度優先算法來解這個問題。我們可以修改bfs方法以返回給我們一些信息:

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

讓我們來看看改進過的廣度優先方法的實現:

bfs(v, callback) {
    const read = []
    const distances = []
    const predecessors = []
    const adjList = this.adjList
    const pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            distances[key] = distances[key] || 0
            predecessors[key] = predecessors[key] || null
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                    distances[v] = distances[key] + 1
                    predecessors[v] = key
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
    return { distances, predecessors }
}

輸出結果:

distances: [A: 0, B: 1, C: 1, D: 1, E: 2, F: 2, G: 2, H: 2 ,工:3]
predecessors: [A: null, B: "A", C: "A", D: "A", E: "B", F: " B", G: " C", H: "D", I: "E"]

這意味着頂點A與頂點B、C和D的距離爲1;與頂點E、F、G和H的距離爲2;與頂點I的距離
通過前溯點數組,我們可以用下面這段代碼來構建從頂點A到其他頂點的路徑:

distance(fromVertex) {
    const vertices = this.vertices
    const { distances, predecessors } = this.bfs(fromVertex)
    vertices.forEach(toVertex => {
        if (!!distances[toVertex]) {
            let preVertex = predecessors[toVertex]
            let slug = ''
            while (fromVertex !== preVertex) {
                slug = `${preVertex} - ${slug}`
                preVertex = predecessors[preVertex]
            }
            slug = `${fromVertex} - ${slug}${toVertex}`
            console.log(slug)
        }
    })
}

執行該代碼段,我們會得到如下輸出:

graph.distance(graph.vertices[0])
// 輸出如下:
// A - B
// A - C
// A - D
// A - B - E
// A - B - F
// A - C - G
// A - D - H
// A - B - E - I

這裏,我們得到了從頂點A到圖中其他頂點的最短路徑(衡量標準是邊的數量)。

深度優先搜索

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7R454VVM-1584450013395)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584449439749.png)]

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

深度優先搜索算法核心是遞歸,普通的對象遞歸模型即可滿足需求,對比已讀頂點是否已完全覆蓋即可。

深度優先算法的實現:

// depth first search
dfs(callback) {
    const read = []
    const adjList = this.adjList
    const readVertices = vertices => {
        vertices.forEach(key => {
            if (read.includes(key)) return false
            read.push(key)
            if (callback) callback(key)
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key))
            }
        })
    }
    readVertices(adjList.keys)
}

讓我們執行下面的代碼段來測試一下df s方法:

graph.dfs(value => console.log('Visited vertex: ' + value))

// 輸出如下:
// Visited vertex: A 
// Visited vertex: B 
// Visited vertex: E 
// Visited vertex: I
// Visited vertex: F 
// Visited vertex: C 
// Visited vertex: D 
// Visited vertex: G 
// Visited vertex: H

下圖展示了該算法每一步的執行過程:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-a7r3Rli3-1584450013401)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584449527034.png)]

探索深度優先算法

到目前爲止,我們只是展示了深度優先搜索算法的工作原理。我們可以用該算法做更多的事 情,而不只是輸出被訪問頂點的順序。

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

  • 頂點 u 的發現時間 d[u]
  • 當頂點 u 被標註爲已讀時,u 的完成探索時間
  • 頂點 u 的前溯點 p[u]

讓我們來看看改進了的 DFS 方法的實現:

// depth first search
dfs(callback) {
    let readTimer = 0
    const read = []
    const readTimes = []
    const finishedTimes = []
    const predecessors = []
    const adjList = this.adjList
    const readVertices = (vertices, predecessor) => {
        vertices.forEach(key => {
            readTimer++
            if (adjList.get(key).every(v => read.includes(v)) && !finishedTimes[key]) {
                finishedTimes[key] = readTimer
            }
            if (read.includes(key)) return false
            readTimes[key] = readTimer
            read.push(key)
            if (callback) callback(key)
            predecessors[key] = predecessors[key] || predecessor || null
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key), key)
            }
        })
    }
    readVertices(adjList.keys)
    return { readTimes, finishedTimes, predecessors }
}

深度優先算法背後的思想是什麼?邊是從最近發現的頂點 u 處被向外探索的。只有連接到未發現的頂點的邊纔會探索。當 u 所有的邊都被探索了,該算法回退到 u 被發現的地方去探索其他的邊。這個過程持續到我們發現了所有從原始頂點能夠觸及的頂點。如果還留有任何其他未被發現的頂點,我們對新源頂點重複這個過程,直到圖中所有的頂點都被探索了。

對於改進過的深度優先搜索,有兩點需要我們注意:

  • 時間(time)變量值的範圍只可能在圖頂點數量的一倍到兩倍之間
  • 對於所有的頂點 u,d[u] < f[u] 意味着,發現時間的值比完成時間的值小,完成時所有頂點都已經被探索過了

學習於:JavaScript中的數據結構與算法學習

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章