在有向圖中,邊是單向的:每條邊連線的兩個頂點都是一個有序對,它們的鄰接性是單向的。許多應用都是天然的有向圖,如下圖。為實現新增這種單向性的限制很容易也很自然,看起來沒什麼壞處。但實際上這種組合性的結構對演算法有深刻的影響,使得有向圖和無向圖的處理大有不同。
1.術語
雖然我們為有向圖的定義和無向圖幾乎相同(將使用的部分演算法和程式碼也是),但為了說明邊的方向性而產生的細小文字差異所代表的結構特性是重點。
定義:一幅有方向性的圖(或有向圖)是由一組頂點和一組有方向的邊組成的,每條有方向的邊都連著有序的一對頂點。
我們稱一條有向邊由第一個頂點指出並指向第二個頂點。在一幅有向圖中,一個頂點的出度為由該頂點指出的邊的總數;一個頂點的入度為指向該頂點的邊的總數。一條有向邊的第一個頂點稱為它的頭,第二個頂點則稱為它的尾。用 v->w 表示有向圖中一條由v 指向 w 的邊。一幅有向圖的兩個頂點的關係可能有四種:沒有邊相連;v->w; w-> v;v->w 和 w->v。
在一幅有向圖中,有向路徑由一系列頂點組成,對於其中的每個頂點都存在一條有向邊從它指向序列中的下一個頂點。有向環為一條至少含有一條邊且起點和終點相同的有向路徑。路徑或環的長度即為其中所包含的邊數。
當存在從 v 到 w 的有向路徑時,稱頂點 w 能夠由頂點 v 達到。我們需要理解有向圖中的可達性和無向圖中的連通性的區別。
2.有向圖的資料型別
有向圖API
有向圖表示
我們使用鄰接表來表示有向圖,其中邊 v -> w 表示頂點 v 所對應的鄰接連結串列中包含一個 w 頂點。這種表示方法和無向圖幾乎相同而且更明晰,因為每條邊都只會出現一次。
有向圖取反
Digraph 的 API 中還新增了一個 Reverse 方法。它返回該有向圖的一個副本,但將其中所有邊的方向反轉。在處理有向圖時這個方法有時很有用,因為這樣用例就可以找出“指向”每個頂點的所有邊,而 Adj 方法給出的是由每個頂點指出的邊所連線的所有頂點。
頂點的符號名
在有向圖中,使用符號名作為頂點也很簡單,參考 SymbolGraph。
namespace Digraphs { public class Digraph { private int v; private int e; private List<int>[] adj; public Digraph(int V) { this.v = V; this.e = 0; adj = new List<int>[v]; for (var i = 0; i < v; i++) { adj[i] = new List<int>(); } } public int V() { return v; } public int E() { return e; } public List<int> Adj(int v) { return adj[v]; } public void AddEdge(int v, int w) { adj[v].Add(w); e++; } public Digraph Reverse() { Digraph R = new Digraph(v); for (var i = 0; i < v; i++) foreach (var w in Adj(i)) R.AddEdge(w,i); return R; } } }
3.有向圖的可達性
在無向圖中介紹的深度優先搜尋 DepthFirstSearch ,解決了單點連通性的問題,使得用例可以判定其他頂點和給定的起點是否連通。使用完全相同的程式碼,將其中的 Graph 替換成 Digraph 也可以解決有向圖中的單點可達性問題(給定一幅有向圖和一個起點 s ,是否存在一條從 s 到達給定頂點 v 的有向路徑?)。
在新增了一個接受多個頂點的建構函式之後,這份 API 使得用例能夠解決一個更加一般的問題 -- 多點可達性 (給定一幅有向圖和頂點的集合,是否存在一條從集合中的任意頂點到達給定頂點 v 的有向路徑?)
下面的 DirectedDFS 演算法使用瞭解決圖處理的標準範例和標準的深度優先搜尋來解決。對每個起點呼叫遞迴方法 Dfs ,以標記遇到的任意頂點。
namespace Digraphs { public class DirectedDFS { private bool[] marked; public DirectedDFS(Digraph G, int s) { marked = new bool[G.V()]; Dfs(G,s); } public DirectedDFS(Digraph G, IEnumerable<int> sources) { marked = new bool[G.V()]; foreach (var s in sources) { if (!marked[s]) Dfs(G,s); } } private void Dfs(Digraph G, int V) { marked[V] = true; foreach (var w in G.Adj(V)) { if (!marked[w]) Dfs(G,w); } } public bool Marked(int v) { return marked[v]; } } }
在有向圖中,深度優先搜尋標記由一個集合的頂點可達的所有頂點所需的時間與被標記的所有頂點的出度之和成正比。
有向圖的尋路
在無向圖中的尋找路徑的演算法,只需將 Graph 替換為 Digraph 就能夠解決下面問題:
1.單點有向路徑:給定一幅有向圖和一個起點 s ,從 s 到給定目的頂點是否存在一條有向路徑?如果有,找出這條路徑。
2.單點最短有向路徑:給定一幅有向圖和一個起點 s ,從 s 到給定目的頂點 v 是否存在一條有向路徑?如果有,找出其中最短的那條(所含邊數最少)。
4.環和有向無環圖
在和有向圖相關的實際應用中,有向環特別的重要。沒有計算機的幫助,在一幅普通的有向圖中找出有向環可能會很困難。從原則上來說,一幅有向圖可能含有大量的環;在實際應用中,我們一般只重點關注其中一小部分,或者只想知道它們是否存在。
排程問題
一種應用廣泛的模型是給定一組任務並安排它們的執行順序,限制條件是這些任務的執行方法和開始時間。限制條件還可能包括任務的耗時以及消耗的資源。最重要的一種限制條件叫做優先順序限制,它指明瞭哪些任務必須在哪些任務之前完成。不同型別的限制條件會產生不同型別不同難度的排程問題。
下面以一個正在安排課程的大學生為例,有些課程是其他課程的先導課程:
如果假設該學生一次只能修一門課程,就會遇到優先順序下的排程問題:給定一組需要完成的任務,以及一組關於任務完成的先後次序的優先順序限制。在滿足限制條件的前提下應該如何安排並完成所有任務?
對於任意一個這樣的問題,我們先畫出一幅有向圖,其中頂點對應任務,有向邊對應優先順序順序。為了簡化問題,我們以整數為頂點:
在有向圖中,優先順序限制下的排程問題等價於一個基本問題--拓撲排序:給定一幅圖,將所有頂點排序,使得所有的有向邊均從排在前面的元素指向排在後面的元素(或者說明無法做到這一點)。
如圖,所有的邊都是向下的,所以清晰地表示了這幅有向圖模型所代表的有優先順序限制的排程問題的一個解決方法:按照這個順序,該同學可以滿足先導課程限制的條件下修完所有課程。
有向圖中的環
如果任務 x 必須在任務 y 之前完成,而任務 y 必須在任務 z 之前完成,但任務 z 又必須在任務 x 之前完成,那肯定是有人搞錯了,因為這三個限制條件是不可能被同時滿足的。一般來說,如果一個優先順序限制的問題中存在有向環,那麼這個問題肯定是無解的。要檢查這種錯誤,需要解決 有向環檢測:給定的有向圖中包含有向環嗎?如果有,按照路徑的方向從某個頂點並返回自己來找到環上的所有頂點。
一幅有向圖中含有環的數量可能是圖的大小的指數級別,因此我們只需找到一個環即可,而不是所有環。在任務排程和其他許多實際問題中不允許出現有向環,因此有向無環圖就變得很特殊。
基於深度優先搜尋可以解決有向環檢測的問題,因為由系統維護的遞迴呼叫的棧表示的正是“當前”正在遍歷的有向路徑。一旦我們找到了一條有向邊 v -> w 且 w 已經存在於棧中,就找到了一個環,因為棧表示的是一條由 w 到 v 的有向路徑,而 v -> w 正好補全了這個環。如果沒有找到這樣的邊,就意味著這副有向圖是無環的。DirectedCycle 基於這個思想實現的:
namespace Digraphs { public class DirectedCycle { private bool[] marked; private int[] edgeTo; private Stack<int> cycle;//有向環中的所有頂點(如果存在) private bool[] onStack;//遞迴呼叫的棧上的所有頂點 public DirectedCycle(Digraph G) { onStack = new bool[G.V()]; edgeTo = new int[G.V()]; marked = new bool[G.V()]; for (int v = 0; v < G.V(); v++) { if (!marked[v]) Dfs(G,v); } } private void Dfs(Digraph G, int v) { onStack[v] = true; marked[v] = true; foreach (var w in G.Adj(v)) { if (hasCycle()) return; else if (!marked[w]) { edgeTo[w] = v; Dfs(G, w); } else if (onStack[w]) { cycle = new Stack<int>(); for (int x = v; x != w; x = edgeTo[x]) cycle.Push(x); cycle.Push(w); cycle.Push(v); } } onStack[v] = false; } private bool hasCycle() { return cycle != null; } public IEnumerable<int> Cycle() { return cycle; } } }
該類為標準的的遞迴 Dfs 方法新增了一個布林型別的陣列 onStack 來儲存遞迴呼叫期間棧上的所有頂點。當它找到一條邊 v -> w 且 w 在棧中時,它就找到了一個有向環。環上的所有頂點可以通過 edgeTo 中的連結得到。
在執行 Dfs 時,查詢的是一條由起點到 v 的有向路徑。要儲存這條路徑,DirectedCycle 維護了一個 由頂點索引的陣列 onStack,以標記遞迴呼叫的棧上的所有頂點(在呼叫 Dfs 時將 onStack[ v ] 設為 true,在呼叫結束時將其設為 false)。DirectedCycle 同時也使用了一個 edgeTo 陣列,在找到有向環時返回環中的所有頂點。
頂點的深度優先次序與拓撲排序
優先順序限制下的排程問題等價於計算有向無環圖中的所有頂點的拓撲排序:
下面演算法的基本思想是深度優先搜尋正好只會訪問每個頂點一次。如果將 Dfs 的引數頂點儲存在一個資料結構中,遍歷這個資料結構實際上就能訪問圖中的所有頂點,遍歷的順序取決於這個資料結構的性質以及是在遞迴呼叫之前還是之後進行儲存。在典型的應用中,頂點一下三種排列順序:
前序:在遞迴呼叫之前將頂點加入佇列;
後序:在遞迴呼叫之後將頂點加入佇列;
逆後序:在遞迴呼叫之後將頂點壓入棧。
該類允許用例用各種順序遍歷深度優先搜尋經過得頂點。這在高階得有向圖處理演算法非常有用,因為搜尋得遞迴性使得我們能夠證明這段計算得許多性質。
namespace Digraphs { public class DepthFirstOrder { private bool[] marked; private Queue<int> pre;//所有頂點的前序排列 private Queue<int> post;//所有頂點的後序排列 private Stack<int> reversePost;//所有頂點的逆後序排列 public DepthFirstOrder(Digraph G) { marked = new bool[G.V()]; pre = new Queue<int>(); post = new Queue<int>(); reversePost = new Stack<int>(); for (var v = 0; v < G.V(); v++) { if (!marked[v]) Dfs(G,v); } } private void Dfs(Digraph G, int v) { pre.Enqueue(v); marked[v] = true; foreach (var w in G.Adj(v)) { if (!marked[w]) Dfs(G,w); } post.Enqueue(v); reversePost.Push(v); } public IEnumerable<int> Pre() { return pre; } public IEnumerable<int> Post() { return post; } public IEnumerable<int> ReversePost() { return reversePost; } } }
一幅有向無環圖得拓撲排序即為所有頂點的逆後序排列。
拓撲排序
namespace Digraphs { public class Topological { private IEnumerable<int> order; public Topological(Digraph G) { DirectedCycle cycleFinder = new DirectedCycle(G); if (cycleFinder.HasCycle()) { DepthFirstOrder dfs = new DepthFirstOrder(G); order = dfs.ReversePost(); } } public IEnumerable<int> Order() { return order; } public bool IsDAG() { return order != null; } } }
這段使用 DirectedCycle 檢測是否有環,使用 DepthFirstOrder 返回有向圖的逆後序。
使用深度優先搜尋對有向無環圖進行拓撲排序所需的時間和 V+E 成正比。第一遍深度優先搜尋保證了不存在有向環,第二遍深度優先搜尋產生了頂點的逆後序排列。
在實際應用中,拓撲排序和有向環的檢測總是一起出現,因為有向環的檢測是排序的前提。例如,在一個任務排程應用中,無論計劃如何安排,其背後的有向圖中包含的環意味著存在一個必須被糾正的嚴重錯誤。因此,解決任務排程類應用通常需要一下3步:
1.指明任務和優先順序條件;
2.不斷檢測並去除有向圖中的所有環,以確儲存在可行方案;
3.使用拓撲排序解決排程問題。
類似地,排程方案的任何變動之後都需要再次檢查是否存在環,然後再計算新的排程安排。
5.有向圖中的強連通性
如果兩個頂點 v 和 w 是相互可達的,則稱它們為強連通的。也就是說,即存在一條從 v 到 w 的有向路徑,也存在一條從 w 到 v 的有向路徑。如果一幅有向圖中的任意兩個頂點都是強連通的,則稱這副有向圖也是強連通的。
下面是強連通圖的例子,可以看到,環在強連通性的理解上起著重要的作用。
強連通分量
和無向圖中的連通性一樣,有向圖中的強連通性也是一種頂點之間的等價關係:
自反性:任意頂點 v 和自己都是強連通的。
對稱性:如果 v 和 w 是強連通的,那麼 w 和 v 也是。
傳遞性:如果 v 和 w 是強連通的且 w 和 x 也是強連通的,那麼 v 和 x 也是強連通的。
作為一種等價關係,強連通性將所有頂點分為了一些等價類,每個等價類都是由相互均為強連通的頂點的最大子集組成。我們稱這些子集為強連通分量。如下圖,一個含有 V 個頂點的有向圖含有 1~ V個強連通分量——一個強連通圖只含有一個強連通分量,而一個有向無環圖則含有 V 個強連通分量。需要注意的是強連通分量的定義是基於頂點的,而不是邊。有些邊連線的兩個頂點都在同一個強連通分量中,而有些邊連線的兩個頂點則不在同一強連通分量中。
強連通分量API
設計一種平方級別的演算法來計算強連通分量並不困難,單對於處理實際應用中的大型圖來說,平方級別的時間和空間需求是不可接受的。
Kosaraju演算法
在有向圖中如何高效地計算強連通分量?我們只需修改無向圖連通分量的演算法 CC ,KosarajuCC 演算法如下,它將會完成一下任務:
1.在給定的一幅有向圖 G 中,使用 DepthFirstOrder 來計算它的反向圖 GR 的逆後序排列;
2.在 G 中進行標準的深度優先搜尋,但是要按照剛才計算得到的順序而非標準的順序來訪問所有未被標記的頂點;
3.在建構函式中,所有在同一個遞迴 Dfs() 呼叫中被訪問到的頂點都在同一個強連通分量中,將它們按照和 CC 相同的方式識別出來。
namespace Digraphs { public class KosarajuCC { private bool[] marked;//已訪問的頂點 private int[] id;//強連通分量的識別符號 private int count;//強連通分量的數量 public KosarajuCC(Digraph G) { marked = new bool[G.V()]; id = new int[G.V()]; DepthFirstOrder order = new DepthFirstOrder(G.Reverse()); foreach (var s in order.ReversePost()) { if (!marked[s]) { Dfs(G,s); count++; } } } private void Dfs(Digraph G, int v) { marked[v] = true; id[v] = count; foreach (var w in G.Adj(v)) { if (!marked[w]) Dfs(G,w); } } public bool StronglyConnected(int v, int w) { return id[v] == id[w]; } public int Id(int v) { return id[v]; } public int Count() { return count; } } }
Kosaraju 演算法的預處理所需的時間和空間與 V+E 成正比且支援常數時間的有向圖強連通性的查詢。
再談可達性
在無向圖中如果兩個頂點 V 和 W 是連通的,那麼就既存在一條從 v 到 w 的路徑也存在一條從 w 到 v 的路徑。在有向圖中如果兩個頂點 v 和 w 是強連通的,那麼就既存在一條從 v 到 w 的路徑也存在另一條從 w 到 v 的路徑。但對於一對非強連通的頂點,也許存在一條從 v 到 w 的路徑,也許存在一條從 w 到 v 的路徑,也許兩條都不存在,但不可能兩條都存在。
頂點對的可達性:對於無向圖,等價於連通性問題;對於有向圖,它和強連通性有很大區別。 CC 實現需要線性級別的預處理時間才能支援常數時間的操作。在有向圖的相應實現中能否達到這樣的效能?
有向圖 G 的傳遞閉包是由相同的一組頂點組成的另一幅有向圖,在傳遞閉包中存在一條從 v 指向 w 的邊當且僅當在 G 中 w 是從 v 可達的。
根據約定,每個頂點對於自己都是可達的,因此傳遞閉包會含有 V 個自環。上圖只有 22 條有向邊,但它的傳遞閉包含有可能的 169 條有向邊中的 102 條。一般來說,一幅有向圖的傳遞閉包中所含的邊都比原圖中多得多。例如,含有 V 個頂點和 V 條邊的有向環的傳遞閉包是一幅含有 V 的平方條邊的有向完全圖。因為傳遞閉包一般都是稠密的,我們通常都將它們表示為一個布林值矩陣,其中 v 行 w 列的值為 true 當且僅當 w 是從 v 可達的。與其計算一幅有向圖的傳遞閉包,不如使用深度優先搜尋來實現如下API:
下面的演算法使用 DirectedDFS 實現:
namespace Digraphs { public class TransitiveClosure { private DirectedDFS[] all; public TransitiveClosure(Digraph G) { all = new DirectedDFS[G.V()]; for (var v = 0; v < G.V(); v++) all[v] = new DirectedDFS(G,v); } public bool Reachable(int v, int w) { return all[v].Marked(w); } } }
該演算法無論對於稀疏圖還是稠密圖,都是理想解決方案,但對於大型有向圖不適用,因為建構函式所需的空間和 V 的平方成正比,所需的時間和 V(V+ E) 成正比。
總結