cv.cm

編碼 · 演算法

拓樸排序教學:原理、程式碼與時間複雜度

閱讀約 6 分鐘

簡答

拓樸排序會把有向無環圖(DAG)的節點排成一個順序,使得每條邊 u → v 都滿足 u 排在 v 之前。它回答的是:「如果有些任務依賴其他任務,我該以什麼順序完成它們?」兩種標準做法是 Kahn 演算法(反覆移除沒有入邊的節點)和 DFS(以完成時間的反向順序輸出節點)。兩者的時間複雜度都是 O(V + E),也都能偵測使排序不可能的環。

拓樸排序解決什麼問題

許多實際任務都有先決條件:一門課需要先修其他課程、原始檔必須在它引入的檔案之後編譯、套件必須在其相依套件之後安裝、試算表的儲存格必須在它參照的儲存格之後計算。把每個任務畫成節點,把每條「必須先於」的規則畫成有向邊,拓樸順序就是符合所有邊的任務序列。

順序存在,若且唯若圖中沒有有向環。如果 A 需要 B、B 又需要 A,就不存在合法的順序,而好的拓樸排序會回報這一點,而不是無窮迴圈。DAG 通常有許多合法順序;演算法只會回傳其中一個。

用白話說明原理

Kahn 演算法(Arthur Kahn 於 1962 年發表)從前面開始處理:

  1. 計算每個節點的入邊數(入度)。
  2. 把所有入度為 0 的節點放進佇列:這些任務沒有尚未滿足的先決條件。
  3. 從佇列取出一個節點,附加到結果中。對它的每條外向邊,把目標節點的入度減 1;如果降到 0,就把目標加入佇列。
  4. 佇列清空後,如果結果包含所有節點,它就是一個拓樸順序;否則剩下的節點屬於某個環。

DFS 方法則從後面開始處理:一個節點必須等它指向的所有節點都完成後才算完成,所以把節點依完成時間反向列出,就能讓每個節點排在所有後繼節點之前。

逐步範例

穿衣服:內褲 → 長褲、長褲 → 鞋子、襪子 → 鞋子、長褲 → 皮帶、襯衫 → 皮帶、襯衫 → 領帶、領帶 → 外套、皮帶 → 外套。以下是 Kahn 演算法依先進先出順序處理佇列節點的過程:

步驟取出入度降為 0 的節點之後的佇列目前的結果
起始—內褲、襪子、襯衫內褲、襪子、襯衫—
1內褲長褲襪子、襯衫、長褲內褲
2襪子無(鞋子仍在等長褲)襯衫、長褲內褲、襪子
3襯衫領帶長褲、領帶…、襯衫
4長褲鞋子、皮帶領帶、鞋子、皮帶…、長褲
5領帶無(外套在等皮帶)鞋子、皮帶…、領帶
6鞋子無皮帶…、鞋子
7皮帶外套外套…、皮帶
8外套無空…、外套

最終順序:內褲、襪子、襯衫、長褲、領帶、鞋子、皮帶、外套。全部 8 個節點都已排入,所以沒有環。

Python 實作

使用 deque 的 Kahn 演算法:

from collections import deque
def topo_sort(graph):
    """graph: dict node -> list of nodes that must come after it."""
    indegree = {n: 0 for n in graph}
    for n in graph:
        for m in graph[n]:
            indegree[m] += 1
    queue = deque(n for n in graph if indegree[n] == 0)
    order = []
    while queue:
        n = queue.popleft()
        order.append(n)
        for m in graph[n]:
            indegree[m] -= 1
            if indegree[m] == 0:
                queue.append(m)
    if len(order) != len(graph):
        raise ValueError("graph has a cycle")
    return order
dress = {
    "underwear": ["pants"], "socks": ["shoes"], "shirt": ["belt", "tie"],
    "pants": ["shoes", "belt"], "tie": ["jacket"], "belt": ["jacket"],
    "shoes": [], "jacket": [],
}
print(topo_sort(dress))
# ['underwear', 'socks', 'shirt', 'pants', 'tie', 'shoes', 'belt', 'jacket']

DFS 版本在節點的所有後繼都完成後才附加該節點,最後再反轉串列:

def topo_sort_dfs(graph):
    order, state = [], {}             # state: 1 = in progress, 2 = done
    def visit(n):
        if state.get(n) == 1:
            raise ValueError("graph has a cycle")
        if state.get(n) == 2:
            return
        state[n] = 1
        for m in graph[n]:
            visit(m)
        state[n] = 2
        order.append(n)
    for n in graph:
        visit(n)
    return order[::-1]
print(topo_sort_dfs(dress))
# ['shirt', 'tie', 'socks', 'underwear', 'pants', 'belt', 'jacket', 'shoes']

兩種輸出都是合法的。從 Python 3.9 起,標準函式庫也提供 graphlib.TopologicalSorter。請注意,它接受的是從每個節點對應到其前驅節點的映射,與上面字典的方向相反:

from graphlib import TopologicalSorter
ts = TopologicalSorter({"b": {"a"}, "c": {"a", "b"}})   # node: its prerequisites
print(list(ts.static_order()))  # ['a', 'b', 'c']

時間與空間複雜度

方法時間空間原因
Kahn 演算法O(V + E)O(V)每個節點只進入佇列一次;每條邊只會降低一次入度。
DFSO(V + E)O(V)每個節點只拜訪一次,每條邊只會經過一次;遞迴堆疊最多可達 V 層。

變化版本與常見錯誤

  • 最小順序優先:把佇列換成最小堆積(heapq),每次都挑出可用節點中最小的,即可在 O((V + E) log V) 內得到字典序最小的順序。
  • 平行排程:以「輪」為單位執行 Kahn 演算法,每輪一次取出所有入度為 0 的節點。每一輪就是一批可以同時執行的任務,輪數即為最長相依鏈的長度。graphlib.TopologicalSorter 以 get_ready() 和 done() 支援這種用法。
  • DAG 中的最長或最短路徑:依拓樸順序鬆弛各條邊,即使有負權重,也能在 O(V + E) 內得到答案。
  • 計算不同順序的數量是困難得多的問題;對大型圖,請不要嘗試列出全部順序。

遞迴 DFS 版本和 Python 中任何遞迴 DFS 一樣有遞迴深度限制:如果鏈上有數千個節點,請改用 Kahn 演算法。

何時使用與實際應用

  • Make 等建置工具依相依關係排列目標,套件管理工具依相依圖排列安裝順序。
  • 試算表依相依順序重新計算儲存格,並回報循環參照。
  • 任務排程器與工作流程引擎(例如以 DAG 為基礎的資料管線)依拓樸順序執行任務。
  • 編譯器排列宣告順序並計算運算式圖;資料庫排列外部鍵的建立或刪除順序。
  • 面試題目:課程表 I 與 II、外星字典(由排序後的單字推得字母順序)、平行課程、建置計畫的最小高度。

常見問題

有環的圖可以做拓樸排序嗎?

不行。有環表示某個任務必須先於自己。Kahn 演算法能偵測到這一點,因為有些節點的入度永遠降不到 0;DFS 方法則是在走到仍在處理中的節點時偵測到。

拓樸順序是唯一的嗎?

通常不是。只有當圖中存在一條經過所有節點的路徑,使每一步都只有一個可選節點時,順序才是唯一的。

該用 Kahn 演算法還是 DFS?

兩者都是 O(V + E)。Kahn 演算法是迭代式的,自然能回報環,也容易改成平行批次或最小優先的順序。如果你已經有 DFS 的輔助函式,DFS 版本則更短。

Python 有內建的拓樸排序嗎?

有。Python 3.9 新增的 graphlib.TopologicalSorter 可透過 static_order() 回傳順序,圖中有環時會拋出 graphlib.CycleError。

全部編碼教學