從這篇文章開始介紹圖相關的演算法,這也是Algorithms線上課程第二部分的第一次課程筆記。 圖的應用很廣泛,也有很多非常有用的演算法,當然也有很多待解決的問題,根據性質,圖可以分為無向圖和有向圖。本文先介紹無向圖,後文再介紹有向圖。 之所以要研究圖,是因為圖在生活中應用比較廣泛:
無向圖
圖是若干個頂點(Vertices)和邊(Edges)相互連線組成的。邊僅由兩個頂點連線,並且沒有方向的圖稱為無向圖。 在研究圖之前,有一些定義需要明確,下圖中表示了圖的一些基本屬性的含義,這裡就不多說明。
圖的API 表示
在研究圖之前,我們需要選用適當的資料結構來表示圖,有時候,我們常被我們的直覺欺騙,如下圖,這兩個其實是一樣的,這其實也是一個研究問題,就是如何判斷圖的形態。 要用計算機處理圖,我們可以抽象出以下的表示圖的API: Graph的API的實現可以由多種不同的資料結構來表示,最基本的是維護一系列邊的集合,如下: 還可以使用鄰接矩陣來表示:
也可以使用鄰接列表來表示:
由於採用如上方式具有比較好的靈活性,採用鄰接列表來表示的話,可以定義如下資料結構來表示一個Graph物件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public class Graph { private readonly int verticals;//頂點個數 private int edges;//邊的個數 private List<int>[] adjacency;//頂點聯接列表 public Graph(int vertical) { this.verticals = vertical; this.edges = 0; adjacency=new List<int>[vertical]; for (int v = 0; v < vertical; v++) { adjacency[v]=new List<int>(); } } public int GetVerticals () { return verticals; } public int GetEdges() { return edges; } public void AddEdge(int verticalStart, int verticalEnd) { adjacency[verticalStart].Add(verticalEnd); adjacency[verticalEnd].Add(verticalStart); edges++; } public List<int> GetAdjacency(int vetical) { return adjacency[vetical]; } } |
圖也分為稀疏圖和稠密圖兩種,如下圖: 在這兩個圖中,頂點個數均為50,但是稀疏圖中只有200個邊,稠密圖中有1000個邊。在現實生活中,大部分都是稀疏圖,即頂點很多,但是頂點的平均度比較小。
採用以上三種表示方式的效率如下:
在討論完圖的表示之後,我們來看下在圖中比較重要的一種演算法,即深度優先演算法:
深度優先演算法
在談論深度優先演算法之前,我們可以先看看迷宮探索問題。下面是一個迷宮和圖之間的對應關係: 迷宮中的每一個交會點代表圖中的一個頂點,每一條通道對應一個邊。 迷宮探索可以採用Trémaux繩索探索法。即:
- 在身後放一個繩子
- 訪問到的每一個地方放一個繩索標記訪問到的交會點和通道
- 當遇到已經訪問過的地方,沿著繩索回退到之前沒有訪問過的地方:
圖示如下:
下面是迷宮探索的一個小動畫:
深度優先搜尋演算法模擬迷宮探索。在實際的圖處理演算法中,我們通常將圖的表示和圖的處理邏輯分開來。所以演算法的整體設計模式如下:
- 建立一個Graph物件
- 將Graph物件傳給圖演算法處理物件,如一個Paths物件
- 然後查詢處理後的結果來獲取資訊
下面是深度優先的基本程式碼,我們可以看到,遞迴呼叫dfs方法,在呼叫之前判斷該節點是否已經被訪問過。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
public class DepthFirstSearch { private bool[] marked;//記錄頂點是否被標記 private int count;//記錄查詢次數 private DepthFirstSearch(Graph g, int v) { marked = new bool[g.GetVerticals()]; dfs(g, v); } private void dfs(Graph g, int v) { marked[v] = true; count++; foreach (int vertical in g.GetAdjacency(v)) { if (!marked[vertical]) dfs(g,vertical); } } public bool IsMarked(int vertical) { return marked[vertical]; } public int Count() { return count; } } |
試驗一個演算法最簡單的辦法是找一個簡單的例子來實現。
深度優先路徑查詢
有了這個基礎,我們可以實現基於深度優先的路徑查詢,要實現路徑查詢,我們必須定義一個變數來記錄所探索到的路徑。 所以在上面的基礎上定義一個edgesTo變數來後向記錄所有到s的頂點的記錄,和僅記錄從當前節點到起始節點不同,我們記錄圖中的每一個節點到開始節點的路徑。為了完成這一日任務,通過設定edgesTo[w]=v,我們記錄從v到w的邊,換句話說,v-w是做後一條從s到達w的邊。 edgesTo[]其實是一個指向其父節點的樹。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
public class DepthFirstPaths { private bool[] marked;//記錄是否被dfs訪問過 private int[] edgesTo;//記錄最後一個到當前節點的頂點 private int s;//搜尋的起始點 public DepthFirstPaths(Graph g, int s) { marked = new bool[g.GetVerticals()]; edgesTo = new int[g.GetVerticals()]; this.s = s; dfs(g, s); } private void dfs(Graph g, int v) { marked[v] = true; foreach (int w in g.GetAdjacency(v)) { if (!marked[w]) { edgesTo[w] = v; dfs(g,w); } } } public bool HasPathTo(int v) { return marked[v]; } public Stack<int> PathTo(int v) { if (!HasPathTo(v)) return null; Stack<int> path = new Stack<int>(); for (int x = v; x!=s; x=edgesTo[x]) { path.Push(x); } path.Push(s); return path; } } |
上圖中是黑色線條表示 深度優先搜尋中,所有定點到原點0的路徑, 他是通過edgeTo[]這個變數記錄的,可以從右邊可以看出,他其實是一顆樹,樹根即是原點,每個子節點到樹根的路徑即是從原點到該子節點的路徑。 下圖是深度優先搜尋演算法的一個簡單例子的追蹤。
廣度優先演算法
通常我們更關注的是一類單源最短路徑的問題,那就是給定一個圖和一個源S,是否存在一條從s到給定定點v的路徑,如果存在,找出最短的那條(這裡最短定義為邊的條數最小) 深度優先演算法是將未被訪問的節點放到一個堆中(stack),雖然在上面的程式碼中沒有明確在程式碼中寫stack,但是 遞迴 間接的利用遞迴堆實現了這一原理。 和深度優先演算法不同, 廣度優先是將所有未被訪問的節點放到了佇列中。其主要原理是:
- 將 s放到FIFO中,並且將s標記為已訪問
- 重複直到佇列為空
- 移除最近最近新增的頂點v
- 將v未被訪問的節點新增到佇列中
- 標記他們為已經訪問
廣度優先是以距離遞增的方式來搜尋路徑的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
class BreadthFirstSearch { private bool[] marked; private int[] edgeTo; private int sourceVetical;//Source vertical public BreadthFirstSearch(Graph g, int s) { marked=new bool[g.GetVerticals()]; edgeTo=new int[g.GetVerticals()]; this.sourceVetical = s; bfs(g, s); } private void bfs(Graph g, int s) { Queue<int> queue = new Queue<int>(); marked[s] = true; queue.Enqueue(s); while (queue.Count()!=0) { int v = queue.Dequeue(); foreach (int w in g.GetAdjacency(v)) { if (!marked[w]) { edgeTo[w] = v; marked[w] = true; queue.Enqueue(w); } } } } public bool HasPathTo(int v) { return marked[v]; } public Stack<int> PathTo(int v) { if (!HasPathTo(v)) return null; Stack<int> path = new Stack<int>(); for (int x = v; x!=sourceVetical; x=edgeTo[x]) { path.Push(x); } path.Push(sourceVetical); return path; } } |
廣度優先演算法的搜尋步驟如下:
廣度優先搜尋首先是在距離起始點為1的範圍內的所有鄰接點中查詢有沒有到達目標結點的物件,如果沒有,繼續前進在距離起始點為2的範圍內查詢,依次向前推進。
總結
本文簡要介紹了無向圖中的深度優先和廣度優先演算法,這兩種演算法時圖處理演算法中的最基礎演算法,也是後續更復雜演算法的基礎。其中圖的表示,圖演算法與表示的分離這種思想在後續的演算法介紹中會一直沿用,下文將講解無向圖中深度優先和廣度優先的應用,以及利用這兩種基本演算法解決實際問題的應用。