知識點總結
拓撲排序并非一種排序算法,它能得到一個 AOV 網絡的拓撲序列,用于判斷有向無環圖中是否有環,即可以判斷一系列活動是否有循環依賴;
解決一個工程中的任務是否能夠順利完成,判斷是否出現環。(補充:還有一種判斷圖中是否有環的數據結構是“并查集”)。
具體例子:去店里吃飯的問題:顧客要求先吃飯再付錢,商家要求先收錢再做菜,這就是循環依賴,拓撲排序就可以幫助我們判斷是否形成環。算法4那本書里面就有拓撲排序。
步驟:找無前驅的結點(即入度為 的結點),一個一個地刪去(使用隊列或者棧),刪的時候,把鄰居結點的入度(即度 -1 )。借助隊列實現。
“拓撲排序”用于對有先后順序的任務的輸出,如果先后順序形成一個環,那么就表示這些任務頭尾依賴,就永遠不能完成。
因此“拓撲排序”還可以用于檢測一個圖中是否有環。
LeetCode 上拓撲排序目前(截止 2019 年 2 月 16 日早)一共有 5 題。
我們就做兩題。
LeetCode 第 207 題:課程表
傳送門:207. 課程表。
現在你總共有 n 門課需要選,記為
0
到n-1
。在選修某些課程之前需要一些先修課程。 例如,想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們:
[0,1]
給定課程總量以及它們的先決條件,判斷是否可能完成所有課程的學習?
示例 1:
輸入: 2, [[1,0]] 輸出: true 解釋: 總共有 2 門課程。學習課程 1 之前,你需要完成課程 0。所以這是可能的。
示例 2:
輸入: 2, [[1,0],[0,1]] 輸出: false 解釋: 總共有 2 門課程。學習課程 1 之前,你需要先完成課程 0;并且學習課程 0 之前,你還應先完成課程 1。這是不可能的。
說明:
- 輸入的先決條件是由邊緣列表表示的圖形,而不是鄰接矩陣。詳情請參見圖的表示法。
- 你可以假定輸入的先決條件中沒有重復的邊。
提示:
- 這個問題相當于查找一個循環是否存在于有向圖中。如果存在循環,則不存在拓撲排序,因此不可能選取所有課程進行學習。
- 通過 DFS 進行拓撲排序 - 一個關于Coursera的精彩視頻教程(21分鐘),介紹拓撲排序的基本概念。
- 拓撲排序也可以通過 BFS 完成。
思路1:Kahn 算法,即拓撲排序。構建的鄰接表就是我們通常認識的鄰接表,每一個結點存放的是后繼結點的集合。
該方法的每一步總是輸出當前無前趨(即入度為零)的頂點。為避免每次選入度為 的頂點時掃描整個存儲空間,可設置一個隊列暫存所有入度為
的頂點。
具體做法如下:
1、在開始排序前,掃描對應的存儲空間,將入度為 的頂點均入隊列。
2、只要隊列非空,就從隊首取出入度為 的頂點,將這個頂點輸出到結果集中,并且將這個頂點的所有鄰接點的入度減
,在減
以后,發現這個鄰接點的入度為
,就繼續入隊。
最后檢查結果集中的頂點個數是否和課程數相同即可。
Python 代碼:
class Solution(object):
# 思想:該方法的每一步總是輸出當前無前趨(即入度為零)的頂點
def canFinish(self, numCourses, prerequisites):
"""
:type numCourses: int 課程門數
:type prerequisites: List[List[int]] 課程與課程之間的關系
:rtype: bool
"""
# 課程的長度
clen = len(prerequisites)
if clen == 0:
# 沒有課程,當然可以完成課程的學習
return True
# 步驟1:統計每個頂點的入度
# 入度數組,一開始全部為 0
in_degrees = [0 for _ in range(numCourses)]
# 鄰接表
adj = [set() for _ in range(numCourses)]
# 想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們: [0,1]
# [0,1] 表示 1 在先,0 在后
# 注意:鄰接表存放的是后繼 successor 結點的集合
for second, first in prerequisites:
in_degrees[second] += 1
adj[first].add(second)
# print("in_degrees", in_degrees)
# 首先遍歷一遍,把所有入度為 0 的結點加入隊列
res = []
queue = []
# 步驟2:拓撲排序開始之前,先把所有入度為 0 的結點加入到一個隊列中
for i in range(numCourses):
if in_degrees[i] == 0:
queue.append(i)
counter = 0
while queue:
top = queue.pop(0)
counter += 1
# 步驟3:把這個結點的所有后繼結點的入度減去 1,如果發現入度為 0 ,就馬上添加到隊列中
for successor in adj[top]:
in_degrees[successor] -= 1
if in_degrees[successor] == 0:
queue.append(successor)
return counter == numCourses
思路2:構建逆鄰接表,實現深度優先遍歷。思路其實也很簡單,其實就是檢測這個有向圖中有沒有環,只要存在環,課程就不能完成。
第 1 步:構造逆鄰接表;
第 2 步:遞歸處理每一個還沒有被訪問的結點;核心思想是:對于一個頂點 vertex
來說,我們先輸出了指向它的所有頂點,然后再輸出自己,就是這么簡單。
第 3 步:如果這個頂點沒有被遍歷過,就遞歸遍歷它,把所有指向它的結點都輸出了,再輸出自己。
注意:這個深度優先遍歷得通過逆鄰接表實現,當訪問一個結點的時候,應該遞歸訪問它的前驅結點,直至前驅結點沒有前驅結點為止。
Python 代碼:
# 207. 課程表
# 現在你總共有 n 門課需要選,記為 0 到 n-1。
# 在選修某些課程之前需要一些先修課程。 例如,想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們: [0,1]
# 給定課程總量以及它們的先決條件,判斷是否可能完成所有課程的學習?
class Solution(object):
# 這里使用逆鄰接表
def canFinish(self, numCourses, prerequisites):
"""
:type numCourses: int 課程門數
:type prerequisites: List[List[int]] 課程與課程之間的關系
:rtype: bool
"""
# 課程的長度
clen = len(prerequisites)
if clen == 0:
# 沒有課程,當然可以完成課程的學習
return True
# 深度優先遍歷,判斷結點是否訪問過
# 這里要設置 3 個狀態
# 0 就對應 False ,表示結點沒有訪問過
# 1 就對應 True ,表示結點已經訪問過,在深度優先遍歷結束以后才置為 1
# 2 表示當前正在遍歷的結點,如果在深度優先遍歷的過程中,
# 有遇到狀態為 2 的結點,就表示這個圖中存在環
visited = [0 for _ in range(numCourses)]
# 逆鄰接表,存的是每個結點的前驅結點的集合
# 想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們: [0,1]
# 1 在前,0 在后
inverse_adj = [set() for _ in range(numCourses)]
for second, first in prerequisites:
inverse_adj[second].add(first)
for i in range(numCourses):
# 在遍歷的過程中,如果發現有環,就退出
if self.__dfs(i, inverse_adj, visited):
return False
return True
def __dfs(self, vertex, inverse_adj, visited):
"""
注意:這個遞歸方法的返回值是返回是否有環
:param vertex: 結點的索引
:param inverse_adj: 逆鄰接表,記錄的是當前結點的前驅結點的集合
:param visited: 記錄了結點是否被訪問過,2 表示當前正在 DFS 這個結點
:return: 是否有環
"""
# 2 表示這個結點正在訪問
if visited[vertex] == 2:
# 表示遇到環
return True
if visited[vertex] == 1:
return False
visited[vertex] = 2
for precursor in inverse_adj[vertex]:
# 如果有環,就返回 True 表示有環
if self.__dfs(precursor, inverse_adj, visited):
return True
# 1 表示訪問結束
# 先把 vertex 這個結點的所有前驅結點都輸出之后,再輸出自己
visited[vertex] = 1
return False
這兩種思路都可以完成 LeetCode 第 210 題。
LeetCode 第 210 題:課程表 II
傳送門:210. 課程表 II。
現在你總共有 n 門課需要選,記為
0
到n-1
。在選修某些課程之前需要一些先修課程。 例如,想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們:
[0,1]
給定課程總量以及它們的先決條件,返回你為了學完所有課程所安排的學習順序。
可能會有多個正確的順序,你只要返回一種就可以了。如果不可能完成所有課程,返回一個空數組。
示例 1:
輸入: 2, [[1,0]] 輸出: [0,1] 解釋: 總共有 2 門課程。要學習課程 1,你需要先完成課程 0。因此,正確的課程順序為 [0,1] 。
示例 2:
輸入: 4, [[1,0],[2,0],[3,1],[3,2]] 輸出: [0,1,2,3] or [0,2,1,3] 解釋: 總共有 4 門課程。要學習課程 3,你應該先完成課程 1 和課程 2。并且課程 1 和課程 2 都應該排在課程 0 之后。 因此,一個正確的課程順序是 [0,1,2,3] 。另一個正確的排序是 [0,2,1,3] 。
說明:
- 輸入的先決條件是由邊緣列表表示的圖形,而不是鄰接矩陣。詳情請參見圖的表示法。
- 你可以假定輸入的先決條件中沒有重復的邊。
提示:
- 這個問題相當于查找一個循環是否存在于有向圖中。如果存在循環,則不存在拓撲排序,因此不可能選取所有課程進行學習。
- 通過 DFS 進行拓撲排序 - 一個關于Coursera的精彩視頻教程(21分鐘),介紹拓撲排序的基本概念。
- 拓撲排序也可以通過 BFS 完成。
思路1:拓撲排序。
Python 代碼:
class Solution(object):
def findOrder(self, numCourses, prerequisites):
"""
:type numCourses: int 課程門數
:type prerequisites: List[List[int]] 課程與課程之間的關系
:rtype: bool
"""
# 課程的長度
clen = len(prerequisites)
if clen == 0:
# 沒有課程,當然可以完成課程的學習
return [i for i in range(numCourses)]
# 入度數組,一開始全部為 0
in_degrees = [0 for _ in range(numCourses)]
# 鄰接表
adj = [set() for _ in range(numCourses)]
# 想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們: [0,1]
# 1 -> 0,這里要注意:不要弄反了
for second, first in prerequisites:
in_degrees[second] += 1
adj[first].add(second)
# print("in_degrees", in_degrees)
# 首先遍歷一遍,把所有入度為 0 的結點加入隊列
res = []
queue = []
for i in range(numCourses):
if in_degrees[i] == 0:
queue.append(i)
while queue:
top = queue.pop(0)
res.append(top)
for successor in adj[top]:
in_degrees[successor] -= 1
if in_degrees[successor] == 0:
queue.append(successor)
if len(res) != numCourses:
return []
return res
思路2:基于逆鄰接表的深度優先遍歷。
Python 代碼:
class Solution(object):
def findOrder(self, numCourses, prerequisites):
"""
:type numCourses: int 課程門數
:type prerequisites: List[List[int]] 課程與課程之間的關系
:rtype: bool
"""
# 課程的長度
clen = len(prerequisites)
if clen == 0:
# 沒有課程,當然可以完成課程的學習
return [i for i in range(numCourses)]
# 逆鄰接表
inverse_adj = [set() for _ in range(numCourses)]
# 想要學習課程 0 ,你需要先完成課程 1 ,我們用一個匹配來表示他們: [0,1]
# 1 -> 0,這里要注意:不要弄反了
for second, first in prerequisites:
inverse_adj[second].add(first)
visited = [0 for _ in range(numCourses)]
# print("in_degrees", in_degrees)
# 首先遍歷一遍,把所有入度為 0 的結點加入隊列
res = []
for i in range(numCourses):
if self.__dfs(i,inverse_adj, visited, res):
return []
return res
def __dfs(self, vertex, inverse_adj, visited, res):
"""
注意:這個遞歸方法的返回值是返回是否有環
:param vertex: 結點的索引
:param inverse_adj: 逆鄰接表,記錄的是當前結點的前驅結點的集合
:param visited: 記錄了結點是否被訪問過,2 表示當前正在 DFS 這個結點
:return: 是否有環
"""
# 2 表示這個結點正在訪問
if visited[vertex] == 2:
# DFS 的時候如果遇到一樣的結點,就表示圖中有環,課程任務便不能完成
return True
if visited[vertex] == 1:
return False
# 表示正在訪問這個結點
visited[vertex] = 2
# 遞歸訪問前驅結點
for precursor in inverse_adj[vertex]:
# 如果沒有環,就返回 False,
# 執行以后,逆拓撲序列就存在 res 中
if self.__dfs(precursor, inverse_adj, visited, res):
return True
# 能走到這里,說明所有的前驅結點都訪問完了,所以可以輸出了
# 并且將這個結點狀態置為 1
visited[vertex] = 1
# 先把 vertex 這個結點的所有前驅結點都輸出之后,再輸出自己
res.append(vertex)
# 最后不要忘記返回 False 表示無環
return False
(本節完)
二、關鍵路徑
重要的概念:(1)關鍵路徑(理解為什么是權值最大的)(2)關鍵活動
參考資料:
1、《大話數據結構》
2、極客時間《算法與數據結構之美》,作者:王爭
3、自己以前寫的題解:
LeetCode 題解之 207. Course Schedule(拓撲排序模板題 1 )
https://blog.csdn.net/lw_power/article/details/80795288
LeetCode 題解之 210. Course Schedule II(拓撲排序模板題 2 )
https://blog.csdn.net/lw_power/article/details/80795355
4、深入理解拓撲排序(Topological sort)
http://www.lxweimin.com/p/3347f54a3187
拓撲排序和 dfs
解決一個有依賴的工程是否能夠完成。
拓撲排序和關鍵路徑問題。
1、AOV 網:與頂點相關。圖中不允許有回路。
2、AOE 網:與邊相關。整個任務是否能夠完成取決于最長的關鍵路徑。
使用拓撲排序判斷 DAG 是否有回路。
LeetCode 第 207 題:課程表
參考資料:https://blog.csdn.net/ljiabin/article/details/45846837
拓撲排序的原理:在一個有向圖中,每次找到一個沒有前驅節點的結點(也就是入度為 0 的結點),然后把它指向的結點的邊都去掉,==重復這個過程(BFS)==,直到所有結點已被找到,或者沒有符合條件的節點(如果圖中有環存在)。
回顧一下圖的三種表示方式:
1、邊表示法(即題目中表示方法);
2、鄰接表法;
3、鄰接矩陣表示法。
用鄰接表存儲圖比較方便尋找入度為 的節點。
拓撲排序以及拓撲排序的偽代碼:
拓撲排序的寫法:
https://blog.csdn.net/qq508618087/article/details/50748965
LeetCode 第 210 題:課程表 II
要求返回一種拓撲排序的結果。
參考資料:http://zxi.mytechroad.com/blog/graph/leetcode-210-course-schedule-ii/
dfs 的做法:Java 的寫法以被指向的課程作為鍵。
注意:下面這個 dfs 的方法,有向圖的表示以先行課程作為鍵。