DAG圖和拓撲排序(Topological sorting)
一個無環的有向圖稱為有向無環圖(DAG)。圖的頂點可以表示要執行的任務,並且邊可以表示一個任務必須在另一個之前執行的約束; 在這個應用程序中,拓撲排序只是任務的有效序列。 當且僅當圖形沒有有向循環時,即如果它是有向無環圖(DAG),則可以進行拓撲排序。 任何DAG都具有至少一個拓撲排序。
在計算機科學領域,有向圖的拓撲排序或拓撲排序是其頂點的線性排序。在圖中假設從頂點 i 到頂點 j 中有一條有向路徑 i -> j。那么我們稱 i 為 j 的前驅,稱 j 為 i 的后繼。
定義:
對於一個DAG(有向無環圖)𝐺,將 𝐺 中所有頂點排序為一個線性序列,使得圖中任意一對頂點 𝑢 和 𝑣,若 𝑢 和 𝑣 之間存在一條從 𝑢 指向 𝑣 的邊,那么 𝑢 在線性序列中一定在 𝑣 前。
注意:DAG的拓撲序可能並不唯一。只要滿足對於(u->v),在線性序列中,u在v前面即可
用途:
- 判環
- 判鏈
- 處理依賴性任務規划問題
- 用在項目管理、 數據庫查詢優化和矩陣乘法的次序優化上。
Kahn算法
拓撲排序的算法非常簡單,常用的方式為Kahn’s algorithm算法,Kahn算法有時候也叫做toposort的bfs版本。基本步驟為:
- 將入度為 0 的點組成一個集合 𝑆
- 從 𝑆 中取出一個頂點 𝑢,插入拓撲序列。
- 遍歷頂點 𝑢 的所有出邊,並全部刪除,如果刪除這條邊后對方的點入度為 0,也就是沒刪前,𝑢→𝑣 這條邊已經是 𝑣 的最后一條入邊,那么就把 𝑣 插入 𝑆。
- 重復執行上兩個操作,直到 𝑆=∅。此時檢查拓撲序列是否正好有 𝑛 個節點,不多不少。如果拓撲序列中的節點數比 𝑛 少,說明 𝐺 非DAG,無拓撲序,返回false。如果拓撲序列中恰好有 𝑛 個節點,說明 𝐺 是DAG,返回拓撲序列。
也就是說,Kahn算法的核心就是維護一個入度為0的頂點。
代碼如下:
1 def topoSort(graph, ind): 2 topo = [] 3 queue = [] 4 5 for i in range(len(ind)): 6 if ind[i] == 0: 7 queue.append(i) 8 9 while queue: 10 node = queue.pop(0) 11 topo.append(node) 12 for i in range(len(graph[node])): 13 v = graph[node][i] 14 ind[v] -= 1 15 if ind[v] == 0: 16 queue.append(v) 17 18 if len(topo) == len(ind): 19 print(topo) 20 return True 21 return False 22 23 24 if __name__ == '__main__': 25 data = [ 26 [0, 1], 27 [0, 2], 28 [1, 2], 29 [1, 3], 30 [2, 3], 31 [2, 5], 32 [3, 4], 33 [7, 6], 34 ] 35 n = 8 36 ind = [0 for _ in range(n)] 37 graph = [[] for _ in range(n)] 38 for u, v in data: 39 graph[u].append(v) 40 ind[v] += 1 41 42 topoSort(graph, ind)
利用DFS實現拓撲排序
當一個有向圖無環的時候,我們可以利用DFS算法來實現拓撲排序。原理很簡單,由於圖中沒有環,那么由圖中某點出發的時候,最先退出DFS的頂點一定是出度為0的頂點,也就是拓撲排序中最后的一個頂點(逆向思維)。因此按DFS退出的先后記錄下的頂點序列就是逆向的拓撲排序的序列。
從任意一個未被訪問的結點出發做深搜后序遍歷。遍歷所有結點,回溯前記錄結點,最后路徑再倒序一下就是正確的拓撲排序(或者建圖的時候就把邊的方向倒了,最后得到的排序不用倒)。如果有多個子圖,要多次深搜,直到所有結點都被訪問完(所有子圖都搜完)得到多個子序列,再拼接一起就是答案。
代碼如下:
1 def dfs(node, graph, vis, order): 2 vis[node] = True 3 for n in graph[node]: 4 if not vis[n]: 5 dfs(n, graph, vis, order) 6 order.append(node) 7 8 9 def topoDfsSort(graph): 10 order = [] 11 vis = [False for _ in range(n)] 12 for i in range(n): 13 if not vis[i]: 14 dfs(i, graph, vis, order) 15 print(order[::-1]) 16 17 18 if __name__ == '__main__': 19 data = [ 20 [0, 1], 21 [0, 2], 22 [1, 2], 23 [1, 3], 24 [2, 3], 25 [2, 5], 26 [3, 4], 27 [7, 6], 28 ] 29 n = 8 30 ind = [0 for _ in range(n)] 31 graph = [[] for _ in range(n)] 32 for u, v in data: 33 graph[u].append(v) 34 ind[v] += 1 35 36 topoDfsSort(graph)
對於DFS方法有人可能疑惑為什么要回溯前才記錄(要用后序遍歷的原因),而且為什么可以從任一點開始?
- 能回溯的點說明已經把子代遍歷完,確定是最后的了,於是可以記錄下來,得到一個倒序記錄。拓撲排序的一個節點可能有多個父結點,所以無法確定某點為先。如下圖,如果我已遍歷得0–>2順序,但是在2之前還有一個點1,明顯不對,先序遍歷行不通。這是由結構所決定的,不同於樹,拓撲排序分支節點之間可能有聯系的,導致一個節點可能有多個父結點,故沒有嚴格的先后順序。而從后往前記錄,因為節點遍歷完分支,故不怕節點再指向任何節點也就不可能指回前面節點,后序遍歷可行。如果要對一個二叉樹做拓撲排序,代碼與此大同小異,二叉樹只有兩個子結點,無需for循環而已。
- 從任意點開始遍歷都行,是因為后序遍歷保證了已記錄的就是最靠后的了。后面記錄的結點肯定不會后於已記錄的結點。比如先從3開始搜,3->4(記錄下4->3),然后無論從哪個結點再開始搜,都不會打亂順序了,012都是先於3(可能的記錄下為4->3->5->2->1->0->6->7),567放3前面也沒事(可能的記錄為4->3->6->7->5->2->1->0)。
拓撲排序實現的時間復雜度
Kahn算法和dfs算法的時間復雜度都為 O(𝐸+𝑉)。
另外,如果要求字典序最小或最大的拓撲序,只需要將Kahn算法中的q隊列替換為優先隊列即可,總時間復雜度為 O(𝐸+𝑉log𝑉)。
