圖是由一組頂點和一組可以將兩個頂點相連的邊組成。html
頂點叫什麼名字並不重要,但咱們須要一個方法來指代這些頂點。通常使用 0 至 V-1 來表示一張含有 V 個頂點的圖中的各個頂點。這樣約定是爲了方便使用數組的索引來編寫可以高效訪問各個頂點信息的代碼。用一張符號表來爲頂點的名字和 0 到 V-1 的整數值創建一一對應的關係並不困難,所以直接使用數組索引做爲結點的名稱更方便且不失通常性,也不會損失什麼效率。前端
咱們用 v-w 的記法來表示鏈接 v 和 w 的邊, w-v 是這條邊的另外一種表示方法。算法
在繪製一幅圖時,用圓圈表示頂點,用鏈接兩個頂點的線段表示邊,這樣就能直觀地看出圖地結構。但這種直覺有時可能會誤導咱們,由於圖地定義和繪製地圖像是無關的,一組數據能夠繪製不一樣形態的圖像。設計模式
特殊的圖數組
自環:即一條鏈接一個頂點和其自身的邊;數據結構
多重圖:鏈接同一對頂點的兩條邊成爲平行邊,含有平行邊的圖稱爲多重圖。ide
沒有平行邊的圖稱爲簡單圖。函數
1.相關術語oop
當兩個頂點經過一條邊相連時,稱這兩個頂點是相鄰得,並稱這條邊依附於這兩個頂點。某個頂點的度數即爲依附於它的邊的總數。子圖是由一幅圖的全部邊的一個子集(以及它們所依附的全部頂點)組成的圖。許多計算問題都須要識別各類類型的子圖,特別是由可以順序鏈接一系列頂點的邊所組成的子圖。性能
在圖中,路徑是由邊順序鏈接的一系列頂點。簡單路徑是一條沒有重複頂點的路徑。環是一條至少含有一條邊且起點和終點相同的路徑。簡單環是一條(除了起點和終點必須相同以外)不含有重複頂點和邊的環。路徑或環的長度爲其中所包含的邊數。
當兩個頂點之間存在一條鏈接雙方的路徑時,咱們稱一個頂點和另外一個頂點是連通的。
若是從任意一個頂點都存在一條路徑到達另外一個任意頂點,咱們稱這副圖是連通圖。一幅非連通的圖由若干連通的部分組成,它們都是其極大連通子圖。
通常來講,要處理一張圖須要一個個地處理它的連通份量(子圖)。
樹是一幅無環連通圖。互不相連的樹組成的集合稱爲森林。連通圖的生成樹是它的一幅子圖,它含有圖中的全部頂點且是一棵樹。圖的生成森林是它的全部連通子圖的生成樹的集合。
樹的定義很是通用,稍做改動就能夠變成用來描述程序行爲(函數調用層次)模型和數據結構。當且僅當一幅含有 V 個結點的圖 G 知足下列 5 個條件之一時,它就是一棵樹:
G 有 V - 1 條邊且不含有環;
G 有 V - 1 條邊且是連通的;
G 是連通的,但刪除任意一條都會使它再也不連通;
G 是無環圖,但添加任意一條邊都會產生一條環;
G 中的任意一對頂點之間僅存在一條簡單路徑;
圖的密度是指已經鏈接的頂點對佔全部可能被鏈接的頂點對的比例。在稀疏圖中,被鏈接的頂點對不多;而在稠密圖中,只有少部分頂點對之間沒有邊鏈接。通常來講,若是一幅圖中不一樣的邊的數量在頂點總數 v 的一個小的常數倍之內,那麼咱們認爲這幅圖是稀疏的,不然就是稠密的。
二分圖是一種可以將全部結點分爲兩部分的圖,其中圖的每條邊所鏈接的兩個頂點都分別屬於不一樣的集合。
2.表示無向圖的數據結構
圖的幾種表示方法
接下來要面對的圖處理問題就是用哪一種數據結構來表示圖並實現這份API,包含下面兩個要求:
1.必須爲可能在應用中碰到的各類類型圖預留出足夠的空間;
2.Graph 的實例方法的實現必定要快。
下面有三種選擇:
1.鄰接矩陣:咱們可使用一個 V 乘 V 的布爾矩陣。當頂點 v 和 w 之間有鏈接的邊時,定義 v 行 w 列的元素值爲 true,不然爲 false。這種表示方法不符合第一個條件--含有上百萬個頂點的圖所需的空間是不能知足的。
2.邊的數組:咱們可使用一個 Edge 類,它含有兩個 int 實例變量。這種表示方法很簡單但不知足第二個條件--要實現 Adj 須要檢查圖中的全部邊。
3.鄰接表數組:使用一個以頂點爲索引的列表數組,其中每一個元素都是和該頂點相連的頂點列表。
非稠密圖的標準表示成爲鄰接表的數據結構,它將每一個頂點的全部相鄰頂點都保存在該頂點對應的元素所指向的一張鏈表中。咱們使用這個數組就是爲了快速訪問給定頂點的鄰接頂點列表。這裏使用 Bag 來實現這個鏈表,這樣咱們就能夠在常數時間內添加新的邊或遍歷任意頂點的全部相鄰頂點。
要添加一條鏈接 v 與 w 的邊,咱們將 w 添加到 v 的鄰接表中並把 v 添加到 w 的鄰接表中。所以在這個數據結構中每條邊都會出現兩次。這種 Graph 的實現的性能特色:
1.使用的空間和 V+E 成正比;
2.添加一條邊所需的時間爲常數;
3.遍歷頂點 v 的全部相鄰頂點所需的時間和 v 的度數成正比。
對於這些操做,這樣的特性已是最優的了,並且支持平行邊和自環。注意,邊的插入順序決定了 Graph 的鄰接表中頂點的出現順序。多個不一樣的鄰接表可能表示着同一幅圖。由於算法在使用 Adj() 處理全部相鄰的頂點時不會考慮它們在鄰接表中的出現順序,這種差別不會影響算法的正確性,但在調試或是跟蹤鄰接表的軌跡時須要注意這一點。
public class Graph { private int v; private int e; private List<int>[] adj; //鄰接表(用List 代替 bag) /// <summary> /// 建立一個含有V個頂點但不含有邊的圖 /// </summary> /// <param name="V"></param> public Graph(int V) { v = V; e = 0; adj = new List<int>[V]; for (var i = 0; i < V; i++) adj[i] = new List<int>(); } public Graph(string[] strs) { foreach (var str in strs) { var data = str.Split(' '); int v = Convert.ToInt32(data[0]); int w = Convert.ToInt32(data[1]); AddEdge(v,w); } } /// <summary> /// 頂點數 /// </summary> /// <returns></returns> public int V() { return v; } /// <summary> /// 邊數 /// </summary> /// <returns></returns> public int E() { return e; } /// <summary> /// 向圖中添加一條邊 v-w /// </summary> /// <param name="v"></param> /// <param name="w"></param> public void AddEdge(int v, int w) { adj[v].Add(w); adj[w].Add(v); e++; } /// <summary> /// 和v相鄰的全部頂點 /// </summary> /// <param name="v"></param> /// <returns></returns> public IEnumerable<int> Adj(int v) { return adj[v]; } /// <summary> /// 計算 V 的度數 /// </summary> /// <param name="G"></param> /// <param name="V"></param> /// <returns></returns> public static int Degree(Graph G, int V) { int degree = 0; foreach (int w in G.Adj(V)) degree++; return degree; } /// <summary> /// 計算全部頂點的最大度數 /// </summary> /// <param name="G"></param> /// <returns></returns> public static int MaxDegree(Graph G) { int max = 0; for (int v = 0; v < G.V(); v++) { var d = Degree(G, v); if (d > max) max = d; } return max; } /// <summary> /// 計算全部頂點的平均度數 /// </summary> /// <param name="G"></param> /// <returns></returns> public static double AvgDegree(Graph G) { return 2.0 * G.E() / G.V(); } /// <summary> /// 計算自環的個數 /// </summary> /// <param name="G"></param> /// <returns></returns> public static int NumberOfSelfLoops(Graph G) { int count = 0; for (int v = 0; v < G.V(); v++) { foreach (int w in G.Adj(v)) { if (v == w) count++; } } return count / 2; //每條邊都被計算了兩次 } public override string ToString() { string s = V() + " vertices, " + E() + " edges\n"; for (int v = 0; v < V(); v++) { s += v + ":"; foreach (int w in Adj(v)) { s += w + " "; } s += "\n"; } return s; } }
在實際應用中還有一些操做可能有用,例如:
添加一個頂點;
刪除一個頂點。
實現這些操做的一種方法是,使用符號表 ST 來代替由頂點索引構成的數組,這樣修改以後就不須要約定頂點名必須是整數了。可能還須要:
刪除一條邊;
檢查圖是否含有 v-w。
要實現這些方法,可能須要使用 SET 代替 Bag 來實現鄰接表。咱們稱這種方法爲鄰接集。如今還不須要,由於:
不須要添加,刪除頂點和邊或是檢查一條邊是否存在;
上述操做使用頻率很低或者相關鏈表很短,能夠直接使用窮舉法遍歷;
某些狀況下會使性能損失 logV。
3.圖的處理算法的設計模式
由於咱們會討論大量關於圖處理的算法,因此設計的首要目標是將圖的表示和實現分離開來。爲此,咱們會爲每一個任務建立一個相應的類,用例能夠建立相應的對象來完成任務。類的構造函數通常會在預處理中構造各類數據結構,以有效地響應用例的請求。典型的用例程序會構造一幅圖,將圖做爲參數傳遞給某個算法類的構造函數,而後調用各類方法來獲取圖的各類性質。
咱們用起點 s 區分做爲參數傳遞給構造函數的頂點與圖中的其餘頂點。在這份 API 中,構造函數的任務就是找到圖中與起點連通的其餘頂點。用例能夠調用 marked 方法和 count 方法來了解圖的性質。方法名 marked 指的是這種基本方法使用的一種實現方式:在圖中從起點開始沿着路徑到達其餘頂點並標記每一個路過的頂點。
在 union-find算法 已經見過 Search API 的實現,它的構造函數會建立一個 UF 對象,對圖中的每條邊進行一次 union 操做並調用 connected(s,v) 來實現 marked 方法。實現 count 方法須要一個加權的 UF 實現並擴展它的API,以便使用 count 方法返回 sz[find(v)]。
下面的一種搜索算法是基於深度優先搜索(DFS)的,它會沿着圖的邊尋找喝起點連通的全部頂點。
4.深度優先搜索
要搜索一幅圖,只須要一個遞歸方法來遍歷全部頂點。在訪問其中一個頂點時:
1.將它標記爲已訪問;
2.遞歸地訪問它全部沒有被標記過地鄰居頂點。
這種方法稱爲深度優先搜索(DFS)。
namespace Graphs { /// <summary> /// 使用一個 bool 數組來記錄和起點連通地全部頂點。遞歸方法會標記給定地頂點並調用本身來訪問該頂點地相鄰頂點列表中 /// 全部沒有被標記過地頂點。 若是圖是連通的,每一個鄰接鏈表中的元素都會被標記。 /// </summary> public class DepthFirstSearch { private bool[] marked; private int count; public DepthFirstSearch(Graph G,int s) { marked = new bool[G.V()]; Dfs(G,s); } private void Dfs(Graph g, int V) { marked[V] = true; count++; foreach (var w in g.Adj(V)) { if (!marked[w]) Dfs(g,w); } } public bool Marked(int w) { return marked[w]; } } }
深度優先搜索標記與起點連通的全部頂點所需的時間和頂點的度數之和成正比。
這種簡單的遞歸模式只是一個開始 -- 深度優先搜索可以有效處理許多和圖有關的任務。
1.連通性。給定一幅圖,兩個給定的頂點是否連通?(兩個給定的頂點之間是否存在一條路徑?路徑檢測) 圖中有多少個連通子圖?
2.單點路徑。給定一幅圖和一個起點 s ,從 s 到給定目的頂點 v 是否存在一條路徑?若是有,找出這條路徑。
5.尋找路徑
單點路徑的API:
構造函數接受一個起點 s 做爲參數,計算 s 到與 s 連通的每一個頂點之間的路徑。在爲起點 s 建立 Paths 對象以後,用例能夠調用 PathTo 方法來遍歷從 s 到任意和 s 連通的頂點的路徑上的全部頂點。
實現
下面的算法基於深度優先搜索,它添加了一個 edgeTo[ ] 整型數組,這個數組能夠找到從每一個與 s 連通的頂點回到 s 的路徑。它會記住每一個頂點到起點的路徑,而不是記錄當前頂點到起點的路徑。爲了作到這一點,在由邊 v-w 第一次任意訪問 w 時,將 edgeTo[w] = v 來記住這條路徑。換句話說, v-w 是從s 到 w 的路徑上最後一條已知的邊。這樣,搜索的結果是一棵以起點爲根結點的樹,edgeTo[ ] 是一棵由父連接表示的樹。 PathTo 方法用變量 x 遍歷整棵樹,將遇到的全部頂點壓入棧中。
public class DepthFirstPaths { private bool[] marked; private int[] edgeTo; //從起點到一個頂點的已知路徑上的最後一個頂點 private int s;//起點 public DepthFirstPaths(Graph G, int s) { marked = new bool[G.V()]; edgeTo = new int[G.V()]; this.s = s; Dfs(G,s); } private void Dfs(Graph G, int v) { marked[v] = true; foreach (int w in G.Adj(v)) { if (!marked[w]) { edgeTo[w] = v; Dfs(G,w); } } } public bool HasPathTo(int v) { return marked[v]; } public IEnumerable<int> PathTo(int v) { if (!HasPathTo(v)) return null; Stack<int> path = new Stack<int>(); for (int x = v; x != s; x = edgeTo[x]) path.Push(x); path.Push(s); return path; } }
使用深度優先搜索獲得從給定起點到任意標記頂點的路徑所需的時間與路徑長度成正比。
6.廣度優先搜索
深度優先搜索獲得的路徑不只取決於圖的結構,還取決於圖的表示和遞歸調用的性質。
單點最短路徑:給定一幅圖和一個起點 s ,從 s 到給定目的頂點 v 是否存在一條路徑?若是有,找出其中最短的那條(所含邊最少)。
解決這個問題的經典方法叫作廣度優先搜索(BFS)。深度優先搜索在這個問題上沒有什麼做用,由於它遍歷整個圖的順序和找出最短路徑的目標沒有任何關係。相比之下,廣度又出現搜索正式爲了這個目標纔出現的。
要找到從 s 到 v 的最短路徑,從 s 開始,在全部由一條邊就能夠到達的頂點中尋找 v ,若是找不到就繼續在與 s 距離兩條邊的全部頂點中查找 v ,如此一直進行。
在程序中,在搜索一幅圖時遇到有不少邊須要遍歷的狀況時,咱們會選擇其中一條並將其餘邊留到之後再繼續搜索。在深度優先搜索中,咱們用了一個能夠下壓棧。使用LIFO (後進先出)的規則來描述下壓棧和走迷宮時先探索相鄰的
通道相似。從有待搜索的通道中選擇最晚遇到過的那條。在廣度優先搜索中,咱們但願按照與起點距離的順序來遍歷全部頂點,使用(FIFO,先進先出)隊列來代替棧便可。咱們將從有待搜索的通道中選擇最先遇到的那條。
實現
下面的算法使用了一個隊列來保存全部已經被標記過但其鄰接表還未被檢查過的頂點。先將頂點加入隊列,而後重複下面步驟知道隊列爲空:
1.取隊列的下一個頂點 v 並標記它;
2.將與 v 相鄰的全部未被標記過的頂點加入隊列。
下面的 Bfs 方法不是遞歸。它顯示地使用了一個隊列。和深度優先搜索同樣,它的結果也是一個數組 edgeTo[ ] ,也是一棵用父連接表示的根結點爲 s 的樹。它表示了 s 到每一個與 s 連通的頂點的最短路徑。
namespace Graphs { /// <summary> /// 廣度優先搜索 /// </summary> public class BreadthFirstPaths { private bool[] marked;//到達該頂點的最短路徑已知嗎? private int[] edgeTo;//到達該頂點的已知路徑上的最後一個頂點 private int s;//起點 public BreadthFirstPaths(Graph G,int s) { marked = new bool[G.V()]; edgeTo = new int[G.V()]; this.s = 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 (var w in G.Adj(v)) { if (!marked[w])//對於每一個未標記的相鄰頂點 { edgeTo[w] = v;//保存最短路徑的最後一條邊 marked[w] = true;//標記它,由於最短路徑已知 queue.Enqueue(w);//並將它添加到隊列中 } } } } public bool HasPathTo(int v) { return marked[v]; } } }
軌跡:
對於從 s 可達的任意頂點 v ,廣度優先搜索都能找到一條從 s 到 v 的最短路徑,沒有其餘從 s 到 v 的路徑所含的邊比這條路徑更少。
廣度優先搜索所需的時間在最壞狀況下和 V+E 成正比。
咱們也可使用廣度優先搜索來實現已經用深度優先搜索實現的 Search API,由於它檢查全部與起點連通的頂點和邊的方法只取決於查找能力。
廣度優先搜索和深度優先搜索在搜索中都會先將起點存入數據結構,而後重複如下步驟直到數據結構清空:
1.取其中的下一個頂點並標記它;
2.將 v 的全部相鄰而又未被標記的頂點加入數據結構。
這兩個算法的不一樣之處在於從數據結構中獲取下一個頂點的規則(對於廣度優先搜索來講是最先加入的頂點,對於深度優先搜索來講是最晚加入的頂點)。這種差別獲得了處理圖的兩種徹底不一樣的視角,儘管不管使用哪一種規則,全部與起點連通的頂點和邊都會被檢查到。
深度優先搜索不斷深刻圖中並在棧中保存了全部分叉的頂點;廣度優先搜索則像扇面通常掃描圖,用一個隊列保存訪問過的最前端的頂點。深度優先搜索探索一幅圖的方式是尋找離起點更遠的頂點,只在碰到死衚衕時才訪問進出的頂點;廣度優先搜索則首先覆蓋起點附近的頂點,只在臨近的全部頂點都被訪問了以後才向前進。根據應用的不一樣,所須要的性質也不一樣。
7.連通份量
深度優先搜索的下一個直接應用就是找出一幅圖的全部連通份量。在 union-find 中 「與......連通」 是一種等價關係,它可以將全部頂點切分紅等價類(連通份量)。
實現
CC 的實現使用了 marked 數組來尋找一個頂點做爲每一個連通份量中深度優先搜索的起點。遞歸的深度優先搜索第一次調用的參數是頂點 0 -- 它會標記全部與 0 連通的頂點。而後構造函數中的 for 循環會查找每一個沒有被標記的頂點並遞歸調用 Dfs 來標記和它相鄰的全部頂點。另外,還使用了一個以頂點做爲索引的數組 id[ ] ,值爲連通份量的標識符,將同一連通份量中的頂點和連通份量的標識符關聯起來。這個數組使得 Connected 方法的實現變得很是簡單。
namespace Graphs { public class CC { private bool[] marked; private int[] id; private int count; public CC(Graph G) { marked = new bool[G.V()]; id = new int[G.V()]; for (var s = 0; s < G.V(); s++) { if (!marked[s]) { Dfs(G,s); count++; } } } private void Dfs(Graph G, int v) { marked[v] = true; id[v] = count; foreach (var w in G.Adj(v)) { if (!marked[w]) Dfs(G,w); } } public bool Connected(int v, int w) { return id[v] == id[w]; } public int Id(int v) { return id[v]; } public int Count() { return count; } } }
深度優先搜索的預處理使用的時間和空間與 V + E 成正比且能夠在常數時間內處理關於圖的連通性查詢。由代碼可知每一個鄰接表的元素都只會被檢查一次,共有 2E 個元素(每條邊兩個)。
union-find 算法
CC 中基於深度優先搜索來解決圖連通性問題的方法與 union-find算法 中的算法相比,理論上,深度優先搜索更快,由於它能保證所需的時間是常數而 union-find算法不行;但在實際應用中,這點差別微不足道。union-find算法其實更快,由於它不須要完整地構造表示一幅圖。更重要的是,union-find算法是一種動態算法(咱們在任什麼時候候都能用接近常數的時間檢查兩個頂點是否連通,甚至是添加一條邊的時候),但深度優先搜索則必須對圖進行預處理。
所以,咱們在只須要判斷連通性或是須要完成大量連通性查詢和插入操做混合等相似的任務時,更傾向使用union-find算法,而深度優先搜索則適合實現圖的抽象數據類型,由於它能更有效地利用已有的數據結構。
使用深度優先搜索還能夠解決 檢測環 和雙色問題:
檢測環,給定的圖是無環圖嗎?
namespace Graphs { public class Cycle { private bool[] marked; private bool hasCycle; public Cycle(Graph G) { marked = new bool[G.V()]; for (var s = 0; s < G.V(); s++) { if (!marked[s]) Dfs(G,s,s); } } private void Dfs(Graph g, int v, int u) { marked[v] = true; foreach (var w in g.Adj(v)) { if (!marked[w]) Dfs(g, w, v); else if (w != u) hasCycle = true; } } public bool HasCycle() { return hasCycle; } } }
是二分圖嗎?(雙色問題)
namespace Graphs { public class TwoColor { private bool[] marked; private bool[] color; private bool isTwoColorable = true; public TwoColor(Graph G) { marked = new bool[G.V()]; color = new bool[G.V()]; for(var s = 0;s<G.V();s++) { if (!marked[s]) Dfs(G,s); } } private void Dfs(Graph g, int v) { marked[v] = true; foreach (var w in g.Adj(v)) { if (!marked[w]) { color[w] = !color[v]; Dfs(g, w); } else if (color[w] == color[v]) isTwoColorable = false; } } public bool IsBipartite() { return isTwoColorable; } } }
8.符號圖
在典型應用中,圖都是經過文件或者網頁定義的,使用的是字符串而非整數來表示和指代頂點。爲了適應這樣的應用,咱們使用符號圖。符號圖的API:
這份API 定義一個構造函數來讀取並構造圖,用 name() 和 index() 方法將輸入流中的頂點名和圖算法使用的頂點索引對應起來。
實現
須要用到3種數據結構:
1.一個符號表 st ,鍵的類型爲 string(頂點名),值的類型 int (索引);
2.一個數組 keys[ ],用做反向索引,保存每一個頂點索引對應的頂點名;
3.一個 Graph 對象 G,它使用索引來引用圖中頂點。
SymbolGraph 會遍歷兩遍數據來構造以上數據結構,這主要是由於構造 Graph 對象須要頂點總數 V。在典型的實際應用中,在定義圖的文件中指明 V 和 E 可能會有些不便,而有了 SymbolGraph,就不須要擔憂維護邊或頂點的總數。
namespace Graphs { public class SymbolGraph { private Dictionary<string, int> st;//符號名 -> 索引 private string[] keys;//索引 -> 符號名 private Graph G; public SymbolGraph(string fileName, string sp) { var strs = File.ReadAllLines(fileName); st = new Dictionary<string, int>(); //第一遍 foreach (var str in strs) { var _strs = str.Split(sp); foreach (var _str in _strs) { st.Add(_str,st.Count); } } keys = new string[st.Count]; foreach (var name in st.Keys) { keys[st[name]] = name; } //第二遍 將每一行的第一個頂點和該行的其餘頂點相連 foreach (var str in strs) { var _strs = str.Split(sp); int v = st[_strs[0]]; for (var i = 1; i < _strs.Length; i++) { G.AddEdge(v,st[_strs[i]]); } } } public bool Contains(string s) { return st.ContainsKey(s); } public int Index(string s) { return st[s]; } public string Name(int v) { return keys[v]; } public Graph Gra() { return G; } } }
間隔的度數
可使用 SymbolGraph 和 BreadthFirstPaths 來查找圖中的最短路徑:
總結