cv.cm

編碼 · 演算法

最小生成樹教學:Kruskal 與 Prim 演算法

閱讀約 6 分鐘

簡答

連通、無向、加權圖的最小生成樹(MST)是一組邊,能把所有節點連起來、沒有任何環,且總權重最小。Kruskal 演算法先對所有邊排序,再利用 union-find 逐一加入不會形成環的最便宜邊。Prim 演算法則從起點長出一棵樹,利用優先佇列,每次加入離開這棵樹的最便宜邊。兩者的時間複雜度都是 O(E log E)。

最小生成樹解決什麼問題

假設你要用電纜連接幾棟建築、用道路連接幾個城鎮,或用網路線連接幾台伺服器,而每種可能的連線都有成本。你希望每個地方都(直接或間接)連通,且總成本最低。形成迴圈的多餘連線永遠不需要,所以答案是一棵樹:V 個節點恰好由 V − 1 條邊連接,這棵樹就是最小生成樹。

MST 不是最短路徑樹。它最小化的是所有邊的總權重,而不是從單一起點到其他節點的距離。

為什麼貪婪選擇有效:切割性質

把節點分成任意兩組。在跨越兩組的邊之中,最輕的那條屬於某棵最小生成樹。兩種演算法都依賴這個事實:

演算法貪婪規則輔助資料結構
Kruskal(1956)由輕到重依序取邊;若一條邊連接兩個不同的元件就保留它。Union-find(並查集)
Prim(1957;Jarník 亦於 1930 提出)長出一棵樹;反覆加入從樹連到樹外節點的最輕邊。最小堆積優先佇列

如果所有邊的權重都不同,MST 就是唯一的,所以兩種演算法會回傳相同的樹。權重相同時,可能有多棵總權重相同的 MST。

逐步範例

無向圖有 5 個節點,邊為 A–B 4、A–C 3、B–C 1、B–D 2、C–D 4、D–E 2、C–E 5。

Kruskal 演算法,邊依權重排序:

邊權重決定之後的元件
B–C1加入{B, C} {A} {D} {E}
B–D2加入{B, C, D} {A} {E}
D–E2加入{B, C, D, E} {A}
A–C3加入{A, B, C, D, E}:已有 4 條邊,完成
A–B、C–D、C–E4, 4, 5不需要(每一條都會形成環)—

從 A 開始的 Prim 演算法:離開 {A} 的最便宜邊是 A–C(3);從 {A, C} 出發是 C–B(1);從 {A, B, C} 出發是 B–D(2);從 {A, B, C, D} 出發是 D–E(2)。得到相同的樹,總權重為 3 + 1 + 2 + 2 = 8。

Python 的 Kruskal 演算法

Union-find 為每個節點保存一個父節點指標。find 回傳節點所屬元件的代表,兩個節點相連,當且僅當它們有相同的代表。

def kruskal(nodes, edges):
    parent = {n: n for n in nodes}

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]   # path halving
            x = parent[x]
        return x

    tree, total = [], 0
    for w, u, v in sorted(edges):
        ru, rv = find(u), find(v)
        if ru != rv:                          # different components: no cycle
            parent[ru] = rv
            tree.append((u, v, w))
            total += w
    return tree, total

nodes = "ABCDE"
edges = [(4, "A", "B"), (3, "A", "C"), (1, "B", "C"), (2, "B", "D"),
         (4, "C", "D"), (2, "D", "E"), (5, "C", "E")]
print(kruskal(nodes, edges))
# ([('B', 'C', 1), ('B', 'D', 2), ('D', 'E', 2), ('A', 'C', 3)], 8)

完整的 union-find 還會使用按秩或按大小合併,讓樹保持扁平。光是路徑減半在實務上就已經很快。當樹已有 V − 1 條邊時,可以提前結束迴圈。

Python 的 Prim 演算法

Prim 演算法需要鄰接串列。它和 Dijkstra 演算法一樣使用惰性刪除:推入每條離開這棵樹的邊,並略過遠端已在樹中的邊。

import heapq
from collections import defaultdict

def prim(edges, start):
    graph = defaultdict(list)
    for w, u, v in edges:
        graph[u].append((w, u, v))
        graph[v].append((w, v, u))
    in_tree = {start}
    heap = list(graph[start])
    heapq.heapify(heap)
    tree, total = [], 0
    while heap:
        w, u, v = heapq.heappop(heap)
        if v in in_tree:
            continue
        in_tree.add(v)
        tree.append((u, v, w))
        total += w
        for edge in graph[v]:
            if edge[2] not in in_tree:
                heapq.heappush(heap, edge)
    return tree, total

print(prim(edges, "A"))
# ([('A', 'C', 3), ('C', 'B', 1), ('B', 'D', 2), ('D', 'E', 2)], 8)

時間與空間複雜度

演算法時間空間最適合
KruskalO(E log E)O(V + E)稀疏圖,或邊已經以串列形式給定。排序是主要成本;union-find 每次操作幾乎是 O(1)。
使用二元堆積的 PrimO(E log V)O(V + E)以鄰接串列表示的圖。
使用陣列(不用堆積)的 PrimO(V²)O(V)稠密圖,例如所有點兩兩相連。

因為 E 至多為 V²,log E 至多為 2 log V,所以 O(E log E) 與 O(E log V) 是同一個數量級。

變化版本與常見錯誤

  • 非連通圖:不存在生成樹。這時 Kruskal 會回傳最小生成森林(每個元件一棵樹);從單一起點執行的 Prim 只會涵蓋該起點所在的元件。請檢查樹是否有 V − 1 條邊。
  • 最大生成樹:將邊依降序排序(或把權重取負)。這兩種演算法都可以處理負權重。
  • 有向圖需要另一種演算法(最小樹形圖,Chu-Liu/Edmonds);Kruskal 和 Prim 不適用。
  • 分群:執行 Kruskal,剩下 k 個元件時停止。結果就是分成 k 群的單連結分群。
  • 1926 年的 Borůvka 演算法一次對每個元件加入其最便宜的邊,很適合平行化。

何時使用與實際應用

  • 網路設計:在任何連通的配置都可接受時,以最便宜的方式用電纜、管線或道路連接各個據點。
  • 近似演算法:MST 可為度量旅行推銷員問題提供長度至多為最佳解兩倍的路線。
  • 分群與影像分割:切掉 MST 中最重的邊。
  • 面試題目:連接所有點的最小成本、以最小成本連接城市、最佳化村莊的供水配置。

常見問題

Kruskal 與 Prim 演算法有什麼差別?

Kruskal 從全域依由輕到重的順序處理所有邊,並用 union-find 合併元件。Prim 則用優先佇列從單一節點長出一棵樹。兩者都能找到最小生成樹。

最小生成樹是唯一的嗎?

當所有邊的權重都不同時是唯一的。權重相同時,可能有多棵總權重同為最小的樹。

最小生成樹和最短路徑樹一樣嗎?

不一樣。MST 最小化所有邊的總和,而最短路徑樹(例如 Dijkstra 演算法產生的)最小化的是從單一起點到每個節點的距離。這兩棵樹常常不同。

生成樹有幾條邊?

對有 V 個節點的連通圖,恰好是 V − 1 條。更少的邊無法連通所有節點,更多的邊則會形成環。

全部編碼教學