演算法之《圖》Java實現
- 2019 年 10 月 3 日
- 筆記
在上上篇部落格中,我們介紹了演算法中中的查找演算法,其中大部分是在介紹查找演算法中需要用得到的數據結構。在這一篇部落格中,我們將來開啟圖的新篇章。
圖的源碼
數據結構之圖
在前面我們所介紹的樹的數據結構中,我們可以明顯的感覺到,樹的表示是分層的,例如父子關係,而其他關係只能間接的表示,例如同級關係。而圖卻不受這種限制。圖是由頂點(或結點)及頂點之間的關係組成的集合。通常,圖中的頂點數量或者一個頂點與其他頂點之間的連線的個數不受限制。(C++數據結構與演算法)
定義(百度百科)
主要有以下兩種定義。
二元組的定義:
圖G是一個有序二元組(V,E),其中V稱為頂集(Vertices Set),E稱為邊集(Edges set),E與V不相交。它們亦可寫成V(G)和E(G)。
E的元素都是二元組,用(x,y)表示,其中x,y∈V。
三元組的定義:
圖G是指一個三元組(V,E,I),其中V稱為頂集,E稱為邊集,E與V不相交;I稱為關聯函數,I將E中的每一個元素映射到 。如果e被映射到(u,v),那麼稱邊e連接頂點u,v,而u,v則稱作e的端點,u,v此時關於e相鄰。同時,若兩條邊i,j有一個公共頂點u,則稱i,j關於u相鄰。
在介紹圖之前,首先讓我們來了解一下圖中的一個重要的分類。圖的術語特別的多,不過我們可以慢慢的了解,因為定義都比較簡單(我將在下面慢慢的介紹一些術語)。
-
無向圖:圖是有一組頂點和一組能夠將兩個頂點相連的邊組成的。可能概念有點模糊,但是可以根下面的有向圖相比較就特別簡單了。
-
有向圖:由一組頂點和一組有方向的邊組成,每條有方向的邊都連接著有序的一對頂點
(這張來自百度百科的圖片都快糊了)
圖的分類其實很多,但是我們主要介紹的就是這兩種分類,還有一些分類可能會在接下來的部落格中提到(我也不確定會不會提到,還沒寫)
圖的術語表
-
相鄰:如果兩個頂點通過一條邊相連, 則稱這兩個頂點是相鄰的,並稱這條邊依附於這兩個頂點
-
度數:某個頂點的度數即為依附於它的邊的總數。
-
子圖:一幅圖的所有邊的一個子集以及他們所依附的所有頂點組成的圖。
-
路徑:由邊順序鏈接的一系列頂點。
-
簡單路徑:一條沒有重複頂點的路徑。
-
環:一條至少包含一條邊且起點和終點相同的路徑。
-
簡單環:除了第一個頂點和最後一個頂點之外,其餘頂點不重複出現的環。
-
連通圖:任意兩個頂點之間互通。一副非連通的圖由諾幹個連通的部分組成。
-
圖的密度:已連接的頂點對占所有可能被連接的頂點對的比例。
-
平行邊:連接同一對頂點的兩條邊稱為平行邊。
-
二分圖:圖中的每一條邊所連接的兩個頂點都分別屬於不同的部分,如下圖所示:
在這一章部落格中,我所講的內容會偏向於演算法,並不會在數據結構上面說很多內容。
無向圖
OK,在前面說完這麼多,首先讓我們來說下最簡單的圖:無向圖
不過在說在無向圖的操作之前,我們至少得解決一個問題:我們使用如何的結構去儲存圖。在前面我們知道,圖不是像樹一樣(絕大部分的樹),只需要關心父子關係,而不需要關心兄弟關係。簡單點來說,就是樹的關係是縱向的(從上到下),而圖卻並不是這樣,圖之間的關係是並列的。相信看過圖這種數據結構的人,應該對於圖的儲存結構的方式可以說的信口拈來吧。下面是一些圖的儲存的方法:
-
鄰接矩陣表示法
下圖一眼就可以看懂,如果結點a與結點b之間相連接,則A(a,b) = A(b,a) = 1,否則為0。
-
鄰接表表示法
在鄰接表表示法中,第一列代表的為結點,如0,1,2……,而後面的則代表為結點與其他結點相連接的結點。(例如0結點後面為1,4結點,則代表0結點與1結點和4結點相連接【在這裡我們可以發現,第5行的4結點的後面同樣有1結點】)
-
關聯矩陣表示法
那麼我們該選擇哪一種的表示方式呢?兩種各有優缺點:
-
如果我們需要處理頂點V的鄰接頂點,我們使用鄰接表只需要deg(v)步操作(deg:圖論中點連出的邊數)。而在鄰接矩陣中,我們需要進行|V|步操作。但是在當我們需要插入或者刪除一個鄰接與v的節點的時候,我們需要對鄰接表進行調整,而對於鄰接矩陣,只需要進行0和1的變換即可。
-
鄰接矩陣的空間複雜度是O(V*V),而鄰接表的複雜度為O(V+E),V為頂點數,E為邊數。
我們將會遇到的應用使用幾乎都是稀疏圖——《演算法第四版》
在這裡我們可以再想一下,在最稠密的情況下(每一個結點都與其他結點相連接),鄰接矩陣的空間複雜度會遠遠的 小於鄰接表(n!和n^2不在一個數量級)。
- 如果出現平行邊了,我們只能夠使用鄰接表去表示。
說了這麼多,在下面的數據結構中,除非特殊說明,我們選擇使用鄰接表來進行數據儲存。我們可以上程式碼了。
首先是抽象類的程式碼:
package graph; import java.awt.*; import java.util.ArrayList; import java.util.List; /** * 圖的抽象數據結構 * @author xiaohui */ public abstract class Graph { // 頂點數量 int V; // 邊的數量 int E; // 鄰接表 List[] adj; // 構造一個含有V個頂點的圖,但是不含邊 Graph(int V) { adj = new ArrayList[V]; for (int i = 0; i < V; i++) { adj[i] = new ArrayList<Integer>(); } this.V = V; } /** * @return 返回頂點的數量 */ int V(){ return V; } /** * @return 返回邊的數量 */ int E(){ return E; } /** * 在圖中添加一條邊v-w * @param v * @param w */ abstract void addEdge(int v, int w); /** * 獲得與v相鄰的所有頂點 * @param v * @return */ abstract Iterable<Integer> adj(int v); /** * 與結點s相連通的所有結點 * @param s * @return */ abstract Iterable<Integer>search(int s); /** * 是否存在S結點到V結點的路徑 * @param s * @param v * @return */ abstract boolean hasPathTo(int s,int v); /** * 找出s到v結點的路徑 * @param s * @param v * @return */ abstract Iterable<Integer> pathTo(int s,int v); /** * 便於進行列印 * @return */ @Override public String toString() { String s = "Graph{" + "V=" + V + ", E=" + E + '}'; for (int v=0;v<V;v++){ s += (v+":"); for (int w :this.adj(v)) { s += w+" "; } s+= "n"; } return s; } }
大家可能發現,上面的數據結構設計的不是很嚴謹,比如說結點都是使用了Int數據類型,而沒有使用泛型。同樣,這些方法不一定全部在一個類中實現,可能會進行分離。
首先讓我們來實現較為簡單的幾個函數。
@Override void addEdge(int v, int w) { adj[v].add(w); adj[w].add(v); this.E ++; } @Override Iterable<Integer> adj(int v) { return adj[v]; }
接下來我們需要實現的就是眾所周知的搜索函數了(因為深度優先搜索和廣度有限搜索應該算大名鼎鼎的演算法了吧)。我們想知道途中有哪一些的點,使用不同的演算法會產生不同的作用效果。
深度優先搜索
深度優先搜索類似i走迷宮,一條路走到黑,如果發現這條路走不通,就在前一個路口繼續向前走。就像下面這樣(圖片節選自《演算法第四版》)
那麼演算法中,我們需要解決什麼問題呢?我們可以通過adj函數得到結點的相鄰結點,但是如果我們如何保證結點已經被我們訪問過了,我們就需要一個標誌mark,這個標誌代表著這個結點是否已經被訪問過。(HashSet這種數據結構也可以做到這種事情)。步驟如下:
- 將被訪問的結點標記為已訪問
- 遞歸地訪問它的所有沒有被標記過的鄰居結點
/** * 無向圖的深度優先搜索 * @author xiaohui */ public class DepthFirstSearch { private boolean[] marked; private int count; public DepthFirstSearch(UndirGraph graph,int s){ marked = new boolean[graph.V()]; dfs(graph,s); } private void dfs(UndirGraph graph, int s) { marked[s] = true; count++; for (int v:graph.adj(s)){ if (!marked[v]){ dfs(graph,v); } } } public boolean getMarked(int w) { return marked[w]; } public int getCount() { return count; } }
大家可以有上面的程式碼可以i很簡單的知道,獲得與s相同的結點,只需要對dfs進行遞歸即可,並將結點的marked標誌設置為true即可。現在我們就可以完善search函數了。
Iterable<Integer> search(int s) { DepthFirstSearch dfs = new DepthFirstSearch(this,s); List list = new ArrayList(dfs.getCount()); for (int i=0;i<this.V();i++) { if (dfs.getMarked(i)){ list.add(i); } } return list; }
在上面的深度優先搜索的演算法,其實還有一個應用,那就是尋找路徑的問題,也就是說,通過深度優先演算法,我們可以知道A結點和X結點是否存在一條路徑,如果有,則輸出路徑。
/** * @author xiaohui * 通過深度優先搜索尋找路徑 */ public class DepthFirstSearchPath { private boolean[] marked; /** * 從起點到一個頂點的已知路徑上面的最後一個頂點,例如: * 0-3-4-5-6 則 edgeTo[6] = 5 */ private int[] edgeTo; /** * 起點 */ private final int s; /** * 在graph中找出起點為s的路徑 * @param graph * @param s */ public DepthFirstSearchPath(Graph graph,int s) { marked = new boolean[graph.V()]; this.s = s; edgeTo = new int[graph.V()]; dfs(graph,s); } private void dfs(Graph graph, int s) { marked[s] = true; for (int v:graph.adj(s)){ if (!marked[v]){ edgeTo[v] = s; dfs(graph,v); } } } /** * v的頂點是否可達,也就是說是否存在s到v的路徑 * @param v * @return */ public boolean hasPathTo(int v){ return marked[v]; } /** * 返回s到v的路徑 * @param v * @return */ public Iterable<Integer> pathTo(int v){ if (!hasPathTo(v)){ return null; } Stack<Integer> path = new Stack<>(); for (int x = v;x!=s;x = edgeTo[x]){ path.push(x); } path.push(s); return path; }
在上面的演算法中, 我們首先進行深度優先遍歷將每個結點是否被遍歷保存到marked[]數組中,然後,在edgeTo[]數組我們保存了進行深度遍歷中被遍歷結點的上一個結點,示意圖如下圖所示(圖片節選自《演算法》):
現在我們可以補全上文中的一些函數了。
/** * 是否存在S結點到V結點的路徑 * @param s * @param v * @return */ @Override boolean hasPathTo(int s, int v) { DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s); return dfsPath.hasPathTo(v); } /** * 找出s到v結點的路徑 * @param s * @param v * @return */ @Override Iterable<Integer> pathTo(int s, int v) { DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s); return dfsPath.pathTo(v); }
通過深度優先搜索,我們可以得到s結點的路徑,那麼深度優先搜索還有什麼用法呢?其中有一個用法就是尋找出一幅圖的所有連通分量。
public class CC { private boolean[] marked; /** * id代表結點所屬的連通分量為哪一個,例如: * id[1] =0,id[3]=1 * 代表1結點屬於0連通分量,3結點屬於1連通分量 */ private int[] id; /** * count代表連通分量的表示,0,1…… */ private int count; public CC(Graph graph) { marked = new boolean[graph.V()]; id = new int[graph.V()]; for (int s=0;s<graph.V();s++){ if (!marked[s]){ count++; dfs(graph,s); } } } private void dfs(Graph graph,int v) { marked[v] = true; id[v] = count; for (int w:graph.adj(v)) { if (!marked[w]){ dfs(graph,w); } } } /** * v和w是否屬於同一連通分量 * @param v * @param w * @return */ public boolean connected(int v,int w){ return id[v]==id[w]; } /** * 獲得連通分量的數量 * @return */ public int getCount() { return count; } /** * 結點屬於哪一個連通分量 * @param w * @return */ public int id(int w){ return id[w]; } }
在下圖中,有三個連通分量。
說完深度優先搜索,我們可以來說一說廣度優先搜索演算法了。在前面的深度優先搜索中,我們將深度優先搜索演算法比喻成迷宮,它可以帶我們從一個結點走到另外一個結點(也就是尋找路徑問題),但是如果我們需要去解決最短路徑的問題,使用深度優先搜索能不能解決呢?答案是不能,我們可以想一想,使用深度優先搜索,我們是一條道走到“黑”,有可能離開始結點最近的結點反而還有可能最後遍歷。但是廣度優先遍歷卻可以解決這個問題。
廣度優先遍歷
廣度優先的演算法在迷宮中類似這樣:我們先遍歷開始結點的相鄰結點並將結點,然後按照與起點的距離的順序來遍歷所有的頂點。在前面的深度優先遍歷中,我們使用了隱式的棧【LIFO】(遞歸)來進行保存結點,而在廣度優先遍歷中,我們將使用顯式的隊列(FIFO)來保存結點。
進行廣度優先遍歷的演算法步驟如下:
先將起點加入隊列,然後重複以下步驟:
- 取隊列中的下一個頂點v並標記它
- 將與v相鄰的所有未被標記過的結點加入隊列
package graph.undir; import java.util.LinkedList; import java.util.Queue; import java.util.Stack; /** * @author xiaohui * 廣度優先遍歷 */ public class BreadthFirstSearch { private boolean[] marked; private final int s; private int[] edgeTo; public BreadthFirstSearch(Graph graph,int s) { this.s = s; this.marked = new boolean[graph.V()]; this.edgeTo = new int[graph.V()]; bfs(graph,s); } private void bfs(Graph graph, int s) { Queue<Integer> queue = new LinkedList<>(); marked[s] = true; // 將s加入隊列中 queue.offer(s); while(!queue.isEmpty()){ // 從隊列中刪除結點 int v = queue.poll(); for (int w: graph.adj(v)) { if (!marked[w]){ edgeTo[w] = v; marked[w] = true; queue.offer(w); } } } } public boolean hasPathTo(int v){ return marked[v]; } public Iterable<Integer> pathTo(int v){ if (hasPathTo(v)){ return null; } Stack<Integer> path = new Stack<>(); for (int i = v; i != s; i = edgeTo[i]) { path.push(i); } path.push(s); return path; } }
對於從s可達的任意頂點v,廣度優先搜索都能找到一條從s到v的最短路徑。下面是進行廣度優先遍歷的情況圖:
在這裡我們可以思考一下如何使用廣度優先搜索或者深度優先搜索解決這兩個問題:
- 圖G是無環圖嗎?(假設不存在自環或者平行邊)
- 圖G是二分圖嗎?
在上面兩個問題的解決方法很簡單。
第一個問題中,我們可以這樣思考:在進行搜索的時候,如果A結點的鄰居結點B已經被被標記了,但是如果在B結點中,它的鄰居結點C已經被標記了,但是如果鄰居結點C並不是結點A,那麼這幅圖就是一個有環圖。道理很簡單,在前面我們知道,通過一個已經被標記的結點,我們肯定可以通過該節點回到起點s,那麼C結點有一條路徑回到起點,A結點也有一條路徑回到起點,而B結點將A和C結點連接起來了,形成了一個環。
第二個問題中,和第一個問題很類似,在C結點中,如果C結點的顏色不和A結點一樣(則和B結點一樣),那麼該圖一定不會是一個二分圖。
有向圖
在有向圖中,邊是單邊的,也就是說,邊是由一個結點指向另外一個結點, 兩個結點的鄰接性是單向的。在一幅有向圖中,一個頂點的出度為該頂點指出的邊的總數,入度為指向該頂點的邊的總數。在一幅有向圖中間存在4種關係:
A->B,A<-B,A B(沒有邊相連接),A->B A<-B
- 有向路徑:由一系列頂點組成,對於其中的每一個頂點都存在一條有向邊從它指向序列中的下一個頂點。
- 有向環: 為一條至少含有一條邊且起點和終點相同的有向路徑。
有向圖詳解
在有向圖中,對程式碼需要進行一些改變,在addEdgeo函數中,我們不再是添加2條邊,而是只是添加一條邊,同時我們添加了一個reserve函數,目的是將邊的方向進行翻轉。
/** * 在圖中添加一條邊v-w * @param v * @param w */ @Override void addEdge(int v, int w) { adj[v].add(w); E++; } /** * 遍歷每一個結點,然後進行翻轉 * @return 返回翻轉後的圖 */ public DiGraph reverse(){ DiGraph diGraph = new DiGraph(V); for (int i = 0; i < V; i++) { for (int w:adj(i)){ diGraph.addEdge(w,i); } } return diGraph; } /** * 獲得與v相鄰的所有頂點 * * @param v * @return */ @Override Iterable<Integer>adj(int v) { return adj[v]; }
路徑問題
上面的程式碼還是比較簡單的。在無向圖中,我們研究了結點的可達性,使用深度優先演算法來探究兩個結點是否可達,而在有向圖中,單點可達性:是否存在一條從s到達給定頂點v的有向路徑。
/** * @author xiaohui * 有向圖的深度優先演算法 */ public class DirectGraphDFS { private boolean[] marked; /** * 有向圖的深度優先演算法構造函數 * @param diGraph * @param s 起點 */ public DirectGraphDFS(DiGraph diGraph,int s) { marked = new boolean[diGraph.V()]; dfs(diGraph,s); } /** * 深度遞歸演算法 * @param diGraph * @param v */ private void dfs(DiGraph diGraph, int v) { marked[v] = true; for (int w:diGraph.adj(v)) { if (!marked[w]){ dfs(diGraph,v); } } } /** * 起點s可達到v嗎 * @param v * @return */ public boolean pathTo(int v){ return marked[v]; } }
在一文看懂javaGC這篇部落格中,我們討論了在Java虛擬機中,我們使用了可達性分析演算法來判斷一個對象是否已經死亡。在下圖中灰色的方塊代表的是可以被回收的對象。
同樣,在無向圖中,我們可以通過搜索來找出結點之間的路徑,以及通過廣度優先搜索來找出最短路徑,同樣,在有向圖中我們同樣能夠做到這樣。同樣,在演算法中,和前面的無向圖之間的演算法一毛一樣,沒什麼改變。
調度問題
調度問題說起來很簡單,就是先有雞還是先有蛋的問題。一種應用廣泛的模型就是給定一組任務並安排它們的執行順序,其中順序會有限制條件去限制(例如任務的執行的開始時間,也可能是任務的時耗)。其中最重要的一種條件叫優先順序限制。
在優先順序限制中,明確的指明了哪些任務必須在哪些任務之前完成,在有向圖中,優先順序限制下的調度問題等價於下面的問題:
拓撲排序:給定一幅有向圖,將所有的頂點排序, 使得所有的有向邊均從排在前面的元素指向排在後面的元素(或者說明無法做到這一點)
在下面的圖是一個有向圖進行拓撲排序後的結果。
在前面我們說了,必須明確任務的先後關係,那麼如果如果任務關係形成了環狀,比如說A要在B之前完成,B要在C之前完成,但是C要在A之前完成, 那麼這個問題肯定是無解的。so,我們在進行拓撲排序之前得先判斷有向圖中間是否有環。(也就是說優先順序限制下的調度問題等價於計算有向無環圖的所有a丁丁的拓撲排序)
/** * 查找有向圖中是否存在環 * @author xiaohui */ public class DirectedCycle { private boolean[] marked; private int[] edgeTo; /** * 有向環中所有頂點 */ private Stack<Integer> cycle; /** * 頂點是否在遞歸調用棧上 */ private boolean[] onStack; public DirectedCycle(Graph graph) { onStack = new boolean[graph.V()]; edgeTo = new int[graph.V()]; marked = new boolean[graph.V()]; for (int v=0;v<graph.V();v++){ if (!marked[v]){ dfs(graph,v); } } } private void dfs(Graph graph, int v) { onStack[v] = true; marked[v] = true; for (int w:graph.adj(v)){ if (this.hasCycle()){ return; } else if(!marked[w]){ edgeTo[w] = v; dfs(graph,w); } // 當它的鄰居結點已經被標記時,且在同一個調用棧中。 else if (onStack[w]){ cycle = new Stack<>(); for (int x= v;x != w;x = edgeTo[x]){ cycle.push(x); } cycle.push(w); cycle.push(v); } onStack[v] = false; } } /** * 有向圖中是否含有環 * @return */ public boolean hasCycle(){ return cycle == null; } /** * 獲得有向環中的頂點 * @return */ public Iterable cycle(){ return this.cycle; } }
在這裡我將著重解釋下onStack這個數組的作用。我們可以回想一下我們在無向圖中如果查找一個圖中是否存在一個環:我們通過查看結點的下一個結點是不是被標記的來判斷的。之所以這樣因為無向圖是雙嚮導通的,我們必然可以根據被標記的點回去,但是我們想想,有向圖可以嗎?顯然是不行的,因為有向圖是單嚮導通的。我們並不能通過已經被標記的結點又回到起點。因此,onStack的作用就在與這個地方。當某結點A的鄰居結點的onStack為true的時候,說明該鄰居結點結點正處於遞歸的過程中,則該鄰居結點能夠通過遞歸得到結點A。而當onStack為false的時候則說明改鄰居結點不能通過遞歸回到回到結點A。
說完有向圖中間的環的檢測方法,我們就可以來討論一下如何對有向圖的頂點進行拓撲排序了。
實際上深度優先搜索也算得上是一種拓撲排序。在深度優先搜索中,我們能夠保證每個頂點的訪問順序必定會符合拓撲排序的規律。根據遞歸的情況,下面有3中排序的規律:
- 前序:在遞歸調用之前將頂點加入隊列
- 後序:在遞歸調用之後將頂點加入隊列
- 逆後序:在遞歸調用之後將頂點壓入棧
有向圖中基於深度優先搜索的頂點排序:
import java.util.LinkedList; import java.util.Queue; import java.util.Stack; /** * 深度遞歸頂點排序 * @author xiaohui */ public class DfsOrder { private boolean[] marked; /** * 前序 */ private Queue<Integer> pre; /** * 後序 */ private Queue<Integer> post; /** * 逆後序 */ private Stack<Integer> reversePost; public DfsOrder(Graph graph) { this.marked = new boolean[graph.V()]; this.pre = new LinkedList<>(); this.post = new LinkedList<>(); this.reversePost = new Stack<>(); for (int i = 0; i < graph.V(); i++) { if(!marked[i]){ dfs(graph,i); } } } private void dfs(Graph graph, int v) { pre.offer(v); marked[v] = true; for (int w:graph.adj(v)) { if (!marked[w]){ dfs(graph,w); } } post.offer(v); reversePost.push(v); } public Iterable<Integer> reversePost(){ return this.reversePost; } }
而在其中逆後序排序便是拓撲排序了。
強連通性
我們已經知道在有向圖中,邊是單向的。但是如果兩個頂點是互相可達(類似無向圖)的,就稱他們為強連通的。如果一幅圖中的任意兩個頂點都是強連通的,就稱這幅圖是強連通的。
兩個頂點是強連通的當且盡當它們都在一個普通的有向環中。:很簡單的解釋,在環中,兩個結點都是互相可達的。
連通性有下面3個性質:
- 自反性:任意頂點和自己是強連通的
- 傳遞性:v和w是強連通,w和x是強連通的,則v和x是強連通的
- 對稱性:v和w是強連通,則w和v也是強連通的。
強連通分量:
下面是一張有向圖和它的強連通分量。每一個陰影塊就是就是一個強連通分量。
以高中的生物知識來說,上面就是一個生態系統的能量流通圖,在某些生物之間能量可以相互流通,這樣就是一個強連通分量了,但是對於某些來說,只有對於生態系統只有輸出並不會得到輸入(比如說陽光),而有些只有輸入沒有輸出(消費者? 不確定對不對,高中知識有點忘了)。
接下來我們需要去尋找強連通分量了。在無向圖中,我們計算連通分量僅僅是在dfs演算法中加了區區幾行程式碼便完美地解決了連通分量的問題。那麼,我們在有向圖中應該怎麼做呢?
在這裡我們可以思考一下我們前面所說的強連通性的規律,以及我們在調度問題中如何檢測環的演算法來解決這個問題。
在這裡有一個比較暴力的解決方法,對於某個結點V,我們得到在有向圖中V可以到達的頂點,然後進行遍歷,得到可到達V的頂點。然後呢,我們取他們的交集。這樣就可以得到連通分量了。但是顯而易見,這樣的時間複雜度是O(n2)。找出可到達V的頂點的時間複雜度是O(n2),取並集的時間複雜度視數據結構而定,使用數組的話時間複雜度是O(n^2)。
總所周知,一般我們是不接受平方級別的時間複雜度的(比如說排序),而在無向圖中,獲得連通分量的時間複雜度僅僅為O(n),那麼在有向圖中間我們的解法是否可以像無向圖一樣美妙呢?
有一個演算法叫做Kosaraju,非常的簡潔,讓我們來說一說這個演算法的步驟,然後再來討論它為什麼要這樣做?
- 將一幅圖G進行反向也就是調用reverse()函數得到G2
- 將G2進行拓撲排序得到它的逆後序排序(也就是一個序列)。
- 然後對圖進行深度優先搜索,進行深度搜索的順序就是第2個步驟中的逆後序序列。
- 在構造函數中,使用同一個dfs()函數調用被訪問的頂點都在同一個強連通分量中間。
接下來是程式碼,大家會發現,程式碼特別的少
/** * 使用Kosaraju演算法的得到強通分量 * @author xiaohui */ public class DfsSCC { private boolean[] marked; private int[] id; private int count = 0; public DfsSCC(DiGraph graph) { marked = new boolean[graph.V()]; id = new int[graph.V()]; DfsOrder order = new DfsOrder(graph.reverse()); for (int s:order.reversePost()){ dfs(graph,s); count++; } } private void dfs(DiGraph graph, int v) { marked[v] = true; id[v] = count; for (int w:graph.adj(v)){ if (!marked[v]){ dfs(graph,w); } } } /** * 返回某結點強連通的id * @param v * @return */ public int id(int v){ return id[v]; } /** * 判斷v和w是否屬於強連通 * @param v * @param w * @return */ public boolean stronglyConnected(int v,int w){ return id[v]==id[w]; } /** * 返回強連通分量的數目 * @return */ public int cout(){ return count; } }
上面便是尋找強連通分量的程式碼,接下來我們要好好的思考一下為什麼能夠達到這種效果。
首先我們可以很很簡單的知道,每個和s強連通的頂點v都會在構造函數dfs(graph,s)被訪問到。接下來我們需要思考的是,為什麼構造函數中dfs(graph,s)函數所到達的任意頂點v都必然是和s強連通的。
設v是dfs(graph,s)達到的某個頂點,那麼原圖G中必然會有一條s到v
的路徑,現在我們只需要找到v到s
的路徑即可。等價於證明G2(G通過reverse()函數得到)有一條s到v
的路徑。
在這裡我們可以想一想,v結點在拓撲排序中會不會出現在s結點的前面?當然不會!!(如果出現在前面,在dfs(graph,s)中就不會調用dfs(graph,v),因為v結點已經被標記了。)
因此現在我們已經確定了v結點在s結點的後面, 那麼代表著什麼呢?代表著在G2的深度優先遍歷中,dfs(graph,v)調用結束絕逼在dfs(graph,s)之前調用(棧是先進後出),那麼在圖G2中就分為兩種情況:
- dfs(graph,v)在dfs(graph,s)調用之前結束
- dfs(graph,v)在dfs(graph,s)調用結束之前結束
因為在圖G中有一條s->v的路徑,在圖G2中有一條v->s的路徑,則第一種情況不可能出現。則第二種情況說明了G2中有一條s->v的路線。則圖G中有一條v->s的路徑。
下面是一張過程示意圖(左邊是對G2進行逆後序排序,右邊是根據排序的結果進行深度遞歸)
最小生成樹(無向圖)
在說最小生成樹之前,我們得先來說說加權圖。下圖中便是一副加權無向圖。加權圖和圖的區別在於它的每一條邊都有一個權值,那麼它有什麼用呢?舉個栗子:圖我們可以應用在網路流量方面,那麼每一條邊的h權值可以代表某一時刻的網路品質,那麼當流量進行選擇的時候,肯定會選擇品質好的那一路。(實際上網路流量選擇要比這還複雜,因為還要考慮到負載均衡的情況。)
那麼什麼是最小生成樹呢?
圖的生成樹是它的一棵含有其所有頂點的無環連通子圖。
一幅加權圖的**最小生成樹(MST)**是它的一棵權值(樹的所有邊的權值之和)最小的生成樹。如上圖的黑色邊構成的無環連通子圖。在這裡我們規定:
- 只考慮連通圖:試想一下,如果不連通,我們又如何知道兩個頂點之間的權值呢?
- 邊的權重:邊的權值可以為正數,負數,0
- 所有邊的權只能都各不相同:相同的話,最小生成樹就不唯一了
下面是生成樹的一些性質:
- 用一條邊連接樹中的任意兩個頂點都會產生一個新的環。
- 從樹中任意刪除一條邊都將會得到兩棵獨立的樹。
如下圖:
根據上面的兩個性質,我們可以將圖中所有的頂點切分為兩個非空且不重疊的兩個集合。而其中橫切邊是一條連接兩個屬於不同集合的頂點的邊。
切分定理:把加權圖中的所有頂點分為集合、檢查橫跨兩個集合的所有邊並識別哪條邊應屬於圖的最小生成樹。
當然,在切分中,我們會得到一條權重最小的邊(這條邊必然屬於最小生成樹的邊),但是並不代表著其它的邊就不屬於最小生成樹。
最小生成樹的貪心演算法
切分定理是解決最小生成樹問題的所有演算法的基礎。而這些演算法都是貪心演算法的特殊情況:使用切分定理找到一條邊,然後不斷的切分,直到找出所有的最小生成樹的所有邊。
最小生成樹的貪心演算法:我們將含有V個頂點的加權連通圖的最小生成樹的邊標記為黑色(初始狀態邊是灰色的),找到一種切分,它產生的橫切邊均不為黑色,然後權重最小的橫切變標記為黑色。反覆,直到標記了V-1條黑色邊為止。
下面是一個貪心最小生成樹演算法的圖:
因為有權無向圖的邊發生了改變,所以定義數據結構的程式碼也得發生改變。
加權無向圖的數據結構
帶權重的邊的數據類型
/** * 定義一條邊的數據類型結構 */ public class Edge implements Comparable<Edge> { /** * 一條邊的某一個頂點 */ private final int v; /** * 一條邊的另外一個頂點 */ private final int w; /** * 邊的權重 */ private final double weight; public Edge(int v, int w, double weight) { this.v = v; this.w = w; this.weight = weight; } public double weight(){ return weight; } /** * 得到邊的某一個頂點 * @return */ public int either(){ return v; } /** * 通過某一個頂點得到邊的另外一個頂點 * @param vertex * @return */ public int other(int vertex){ if(vertex == w){ return v; }else if(vertex==v){ return w; }else{ throw new RuntimeException("沒有這一條邊"); } } /** * 邊進行比較 * @param o * @return */ @Override public int compareTo(Edge o) { if (this.weight() > o.weight()){ return 1; }else if (this.weight() < o.weight()){ return -1; } return 0; } @Override public String toString() { return "Edge{" + "v=" + v + ", w=" + w + ", weight=" + weight + '}'; } }
加權無向圖的數據類型:
import java.util.ArrayList; import java.util.List; /** * 加權無向圖的數據結構 */ public class EdgeWeightedGraph { /** * 頂點總數 */ private final int V; /** * 邊的總數 */ private int E; /** * 邊 */ private List<Edge>[] adj; public EdgeWeightedGraph(int V) { this.V = V; this.E = 0; adj = new ArrayList[V]; for (int i = 0; i < V; i++) { adj[i] = new ArrayList<Edge>(); } } public int V() { return V; } public int E() { return E; } public void addEdge(Edge e) { int v = e.either(), w = e.other(v); adj[v].add(e); adj[w].add(e); E++; } public Iterable<Edge> adj(int v) { return adj[v]; } /** * 獲取圖中的所有邊 * @return */ public Iterable<Edge> edges(){ List<Edge> list = new ArrayList<>(); for (int i = 0; i < V; i++) { for (Edge e:adj[i]){ /** * 如果i和j為一條邊e,那麼adj[i] = e;adj[j] = e;這兩條邊是一樣的,所以我們需要去除一條邊 */ if (e.other(i)>i){ list.add(e); } } } return list; } }
在定義好數據結構後,我們就可以開始來說一下生成最小樹的演算法了
最小生成樹的演算法
對於最小生成樹有兩種常用的演算法,普里姆演算法(Prim演算法)和克魯斯卡爾演算法(Kruskal演算法)。這兩種演算法都是基於貪心演算法的演算法。首先讓我們來說一下Kruskal演算法,這個比較簡單。
Kruskal演算法
Kruskal演算法很簡單,首先我們得到所有的邊,然後根據邊的權重對邊進行排序(從小到大)。然後我們將邊根據順序加入最小生成樹中(必須保證加入的邊不會與已經加入的邊構成環)
現在這個問題就分成了兩個部分:
- 如何排序——使用排序演算法即可(使用堆排序),使用優先隊列
- 如何檢測迴路。
我們來著重討論第二點,如何檢測迴路
如何檢測迴路,我們可以使用union-find演算法。首先我們說一下這個的原理:
首先我們有N個獨立分散的點,如果我們將點用線段進行連接,如何避免成環。我們可以這樣想,,像樹一樣,有根節點,如果兩個結點的根節點是一樣的,那麼毋庸置疑,將兩個結點進行連接肯定會成環。
其中,這個演算法有3種寫法:
- quick-find演算法。
- quick-union演算法。
- 加權quick-union演算法
我將介紹加權quick-union演算法,因為這個在最最壞的情況下時間複雜度也只有lgN。
quick-union演算法
/** * 加權quick-union演算法 */ public class WeightQuickUnionUF { /** * 結點的父節點 */ private int[] id; /** * (由結點索引的)各個根節點所對應的根節點的大小 */ private int[] sz; /** * 連通分量的數量 */ private int count; /** * 進行初始化,初始化後其中每一個結點都是一個連通分量 * 其中結點的父節點為自己本身 * @param N */ public WeightQuickUnionUF(int N) { count = N; id = new int[N]; for (int i = 0; i < N; i++) { id[i] = i; } sz = new int[N]; for (int i = 0; i < N; i++) { sz[i] = 1; } } /** * p和q是否相鏈接,若相連接,則在同一個連通分量裡面 * @param p * @param q * @return */ public boolean connected(int p,int q){ return find(p) == find(q); } /** * 找到根節點 * @param v * @return */ private int find(int v) { // 在根節點中id[v]= v(在初始化的時候定義的) while(v != id[v]){ v = id[v]; } return v; } /** * 在p和q之間添加一條鏈接 * @param p * @param q */ public void union(int p,int q){ int i = find(p); int j = find(q); // 如果是同一條連通分量,則返回,沒必要添加 if (i==j){ return ; } // 這一步的目的是將小樹加入大樹 if (sz[i]<sz[j]){ id[i] = j; sz[j] += sz[i]; }else{ id[j] = i; sz[i] += sz[j]; } count --; } }
上面的演算法在union中,是將小樹加入大樹。目的是減少在最壞情況下的時間複雜度。
下面便是Kruskal演算法的實現部分
package graph.weight; import java.util.ArrayList; import java.util.LinkedList; import java.util.PriorityQueue; import java.util.Queue; public class KruskalMST { /** * 優先隊列 */ private PriorityQueue<Edge> pq; /** * 最小生成樹 */ private Queue<Edge> mst; public KruskalMST(EdgeWeightedGraph graph) { pq = new PriorityQueue<>(); mst = new LinkedList<>(); // 將邊加入優先隊列 for (Edge edge:graph.edges()){ pq.add(edge); } mst(graph); } private void mst(EdgeWeightedGraph graph) { WeightQuickUnionUF uf = new WeightQuickUnionUF(graph.V()); while(!pq.isEmpty()){ // 從裡面取出最小的元素 Edge e = pq.poll(); int v = e.either(); int w = e.other(v); // 防止成環 if (uf.connected(v,w)){ continue ; } uf.union(v,w); mst.offer(e); } } public Queue getMst() { return mst; } }
ok,說完Kruskal演算法,讓我們來說一說Prim演算法。
Prim演算法
prim演算法和kruskal演算法一樣,同樣使用的是貪心演算法。它的原理很簡單,就是每一步為都會為一棵生長中的樹添加一條邊(總是將連接樹中的頂點與不在樹中的頂點切權重最小的邊加入樹中)。
下面是一個示例:
其中prim演算法有兩種實現方式:
-
延時實現
- 把一個頂點加入最小生成樹中。
- 再把它的所有的連接著未訪問頂點的鄰接邊都放入優先隊列(作為橫切邊)。
- 然後從橫切邊中彈出權重最小的一條,檢查它兩邊的頂點是否在最小生成樹中,如果在,則忽略該邊,從頭開始步驟3。如果不在,則把它和它所連接的頂點加入最小生成樹中。
- 再對它所連接的頂點作和以上相同的操作。直到優先隊列為空。
下面是延時實現的程式碼:
import java.util.LinkedList; import java.util.PriorityQueue; import java.util.Queue; /** * prim演算法延時實現 */ public class LazyPrimMST { private boolean[] marked; private Queue<Edge> mst; /** * 橫切邊 */ private PriorityQueue<Edge> pq; public LazyPrimMST(EdgeWeightedGraph graph){ marked = new boolean[graph.V()]; pq = new PriorityQueue<>(); mst = new LinkedList<>(); mst(graph); } /** * 生成最小樹 * @param graph */ public void mst(EdgeWeightedGraph graph){ visit(graph,0); while (!pq.isEmpty()){ // 從優先隊列中得到最小的邊 Edge e= pq.poll(); int v = e.either(); int w = e.other(v); // 如果兩個頂點都被標記了,則看下一條邊 if (marked[v] && marked[w]){ continue; } // 將邊加入mst mst.add(e); if (!marked[v]){ visit(graph,v); } if (!marked[w]){ visit(graph,w); } } } /** * 標記頂點v並將其(所有)邊(邊所相連接的另外一個頂點未被標記)加入隊列中 * @param graph * @param v */ public void visit(EdgeWeightedGraph graph,int v){ marked[v] = true; for (Edge e:graph.adj(v)){ // 另外一個頂點 if (!marked[e.other(v)]){ // 將頂點假如優先隊列 pq.add(e); } } } /** * 返回最小生成樹 * @return */ public Queue<Edge> getMst() { return this.mst; } }
當時大家可能發現了一個問題,那就是在優先隊列中保存了很多沒有用的邊,無疑,這些邊是需要佔用空間的,那麼如果我們去除這些失效的邊,是不是就可以節約空間了呢? 於是便又有了下面的演算法。
-
即時實現
- 先把一個頂點放入最小生成樹中。
- 遍歷該頂點的鄰接邊結點(結點A),如果邊(邊W)所連接的某個頂點(結點B)不在最小生成樹中,且它(結點B)到該頂點(結點A)的距離大於該邊(邊W)的權重,則該結點(結點B)到頂點(結點A)的距離變成該邊的權重,並且更新索引優先隊列中的邊。
- 從索引優先隊列彈出權重最小的邊,然後對它所連接的頂點作以上操作,直到棧空。
import java.util.ArrayList; import java.util.HashMap; import java.util.PriorityQueue; /** * 即時版本的prim演算法 */ public class PrimMST { /** * 某結點距離樹最近的邊 */ private Edge[] edgeTo; /** * 某結點距離樹的權重距離 */ private double[] distTo; /** * 結點是否在樹中 */ private boolean[] marked; /** * 有效的橫切邊(也就是被保存在優先隊列中有效的邊) * key: 結點的id * value:權重 */ private IndexMinPQ<Double> pq; public PrimMST(EdgeWeightedGraph graph) { edgeTo = new Edge[graph.V()]; distTo = new double[graph.V()]; marked = new boolean[graph.V()]; // IndexMinPQ是索引優先隊列,並不是Java本身的庫,在我的github庫中有這個IndexMinPQ pq = new IndexMinPQ<>(graph.V()); // 初始化結點到樹的距離為無限大 for (int i = 0; i < graph.V(); i++) { distTo[i] = Double.POSITIVE_INFINITY; } pq.insert(0,0.0); while(!pq.isEmpty()){ visit(graph,pq.delMin()); } } private void visit(EdgeWeightedGraph graph, int v) { marked[v] = true; // 遍歷與v相連的結點 for (Edge e:graph.adj(v)){ int w = e.other(v); // 假如w已經為最小樹的結點,則不進行處理 if (marked[w]){ continue; } // 假如w結點的權重小於w結點到樹的距離 if (e.weight()<distTo[w]){ // w結點到最小樹的邊為e edgeTo[w] = e; // 距離為e邊的權重 distTo[w] = e.weight(); // 將結點放入優先隊列 if (pq.contains(w)){ pq.changeKey(w,distTo[w]); } else{ pq.insert(w,distTo[w]); } } } } /** * 返回最小生成樹 * @return */ public ArrayList<Edge> mst(){ ArrayList<Edge> list = new ArrayList<>(); for (Edge e:edgeTo) { if (e!=null){ list.add(e); } } return list; } }
即時的prim演算法和延時的prim演算法很相似,原理上面並沒有什麼發生改變,只不過在即時的prim演算法中,我們只將有用的邊假如優先隊列,而沒有用的邊就不管它了。這樣我們就節約了空間,以及遍歷邊的時間。
性能特點:(V個頂點E條邊)
演算法(最壞情況下) 空間 時間 Kruskal演算法 E ElogE 延時的Prim演算法 E ElogE 即時的Prim演算法 V ElogV
最短路徑
最短路徑問題是一個很常見的問題,因為導航地圖,流量負載互動都會遇到它。這個我們等幾天更新後再討論……
參考書籍:
《演算法》——聖書
再如何不可思議的事情,一旦做的次數多了,便會習慣直至麻木甚至開始樂在其中。 ——將夜