最小生成樹 — Kruskal 與 Prim 演算法完整比較與實作 | 資料結構與演算法

2026/07/12
最小生成樹 — Kruskal 與 Prim 演算法完整比較與實作 | 資料結構與演算法

想像一位城市規劃師,需要在六座城市之間鋪設光纖網路,使所有城市互聯互通,同時讓總鋪設費用最低。這個問題的答案,正是 最小生成樹(Minimum Spanning Tree,MST)。本文完整介紹 KruskalPrim 兩大演算法的原理、差異,以及 JavaScript/TypeScript 與 C++ 的完整實作。

前言

城市道路鋪設的比喻能精準描述 MST 的核心問題:用最小的總成本,讓圖中所有頂點互相連通,且不形成環路

生活中的 MST 應用場景非常廣泛:電信公司的光纜佈線、電路板的最低成本連線、聚類分析中的樹狀分群,乃至 Google 資料中心的骨幹網路規劃,都能看到 MST 的身影。

學習本文後,你將能夠:

  • 理解生成樹(Spanning Tree)的定義與 MST 的性質
  • 掌握 Cut Property(切割定理)——MST 演算法正確性的理論基石
  • 熟練使用 Kruskal(排序 + 併查集)與 Prim(優先佇列)求解 MST
  • 知道在稀疏圖與稠密圖上分別選用哪種演算法
  • 解決 LeetCode 上的 MST 相關題目

核心概念

生成樹(Spanning Tree)

給定一個連通無向帶權圖 G = (V, E),生成樹是滿足下列條件的子圖:

  1. 包含圖中所有 V 個頂點
  2. 恰好有 V - 1 條邊
  3. 圖中無環(即為樹結構)

**最小生成樹(MST)**就是所有生成樹中,邊的總權重最小的那棵樹。

範例圖(6 頂點,9 條邊)

        0
       / \
      4   3
     /     \
    1---2---2
    |  / \  |
    6 5   4 1
    | /     \|
    3---------5

邊列表:
(0,1)=4  (0,2)=3  (1,2)=2  (1,3)=6
(2,3)=5  (2,4)=4  (2,5)=1  (3,5)=4  (4,5)=1

這張圖的 MST 總權重為 11,包含邊:(2,5)=1、(4,5)=1、(1,2)=2、(0,2)=3、(3,5)=4。


Cut Property(切割定理)

Cut Property 是 MST 演算法正確性的理論基礎,理解它能幫助你從「知道演算法步驟」升級到「理解為什麼這樣做是對的」。

定義: 給定圖 G 的任意一個 Cut(切割)——即將頂點集合 V 分成兩個非空子集 S 與 V-S——跨越切割兩側的最小權重邊(稱為 Light Edge)一定屬於某棵 MST。

Cut Property 示意:

  S = {A, B}          V-S = {C, D, E}

     A ---4--- C
     |         |
     2         1
     |         |
     B ---7--- D
               \
                3
                 \
                  E

跨 Cut 的邊:A-C(4), B-D(7)
Light Edge = A-C(4)(最小跨 Cut 邊)
∴ A-C 一定屬於某棵 MST

直覺解釋: 無論如何分割頂點,連接兩側最便宜的那條邊一定「划算」——假設不選它,最終的生成樹仍然必須讓兩側連通,而其他選項只會更貴。

Kruskal 與 Prim 都是 Cut Property 的不同實現:

  • Kruskal:每次選全圖最輕的邊,隱式地定義了一個 Cut(已加入邊所形成的森林兩側)
  • Prim:每次從已知集合 S 向外延伸最輕邊,直接使用 S vs V-S 的 Cut

Kruskal 演算法

核心思想: 將所有邊按權重升序排列,依序嘗試加入每條邊;若加入後不形成環則接受,否則跳過。加入 V-1 條邊後停止。

判斷是否成環 需要使用 Union-Find(並查集):若邊的兩端頂點已在同一集合,加入此邊將成環,跳過;否則合併兩集合並加入此邊。

Kruskal 逐步示意(6 頂點範例):

Step 0: 初始 — 6 個獨立節點

Step 1: 加入 (2,5) 權重=1
  2 ─── 5
  Find(2)≠Find(5) → Union → 加入 MST

Step 2: 加入 (4,5) 權重=1
  2 ─── 5 ─── 4
  Find(4)≠Find(5) → Union → 加入 MST

Step 3: 加入 (1,2) 權重=2
  1 ─── 2 ─── 5 ─── 4
  Find(1)≠Find(2) → Union → 加入 MST

Step 4: 加入 (0,2) 權重=3
      0
      |
  1 ─── 2 ─── 5 ─── 4
  Find(0)≠Find(2) → Union → 加入 MST

Step 5: 加入 (0,1) 權重=4 → SKIP(Find(0)==Find(1),成環)
Step 5: 加入 (2,4) 權重=4 → SKIP(Find(2)==Find(4),成環)
Step 5: 加入 (3,5) 權重=4
      0
      |
  1 ─── 2 ─── 5 ─── 4
              |
              3
  Find(3)≠Find(5) → Union → 加入(第 V-1=5 條邊,結束)

MST 總權重 = 1+1+2+3+4 = 11

時間複雜度: O(E log E),瓶頸在邊的排序。


Prim 演算法

核心思想: 從任意頂點出發,維護一個已加入 MST 的集合 S。每次從連接 S 內外的邊中選出最輕的邊,將對應的外部頂點加入 S,重複直到所有頂點都加入。

實作工具: MinHeap(最小堆積),儲存「從 S 到 S 外的候選邊」,每次取最小。

Prim 逐步示意(從頂點 0 出發):

初始:visited={0},Heap 放 0 的所有鄰邊

Step 1: Heap 最小 = (0→2, 重=3)
  visited={0,2},加入 2 的鄰邊
  MST: 0───2

Step 2: Heap 最小 = (2→5, 重=1)
  visited={0,2,5},加入 5 的鄰邊
  MST: 0───2───5

Step 3: Heap 最小 = (5→4, 重=1)
  visited={0,2,5,4}
  MST: 0───2───5───4

Step 4: Heap 最小 = (2→1, 重=2)
  visited={0,2,5,4,1},加入 1 的鄰邊
  MST:     0
           |
       1 ─── 2 ─── 5 ─── 4

Step 5: Heap 最小 = (5→3, 重=4)
  visited={0,2,5,4,1,3},MST 完成!

MST 總權重 = 3+1+1+2+4 = 11 ✓

時間複雜度: O((V+E) log V),使用 Binary Heap 時。

Lazy Deletion 技巧: Heap 中可能存有指向已加入 MST 頂點的邊(過期項目)。每次取出時檢查 if (visited[u]) continue,直接跳過即可,不需額外清理 Heap。


比較表:Kruskal vs Prim

特性KruskalPrim (Binary Heap)Prim (Fibonacci Heap)
時間複雜度O(E log E)O((V+E) log V)O(E + V log V)
空間複雜度O(V + E)O(V + E)O(V + E)
主要資料結構Union-Find + 排序Min-HeapFibonacci Heap
起點依賴不依賴依賴(任意均可)同 Prim
稀疏圖(E ≈ V)較優可用可用
稠密圖(E ≈ V²)需排序 O(V² log V²)O(V² log V)O(V²),最優
實作複雜度中(需 Union-Find)中(需 Min-Heap)高(難實作)
增量加邊支援不支援可改造支援可改造支援

選用指引:

  • 稀疏圖(E « V²)或邊已排序 → Kruskal
  • 稠密圖(E ≈ V²)追求理論最優 → Prim + Fibonacci Heap
  • 競程或面試通用選擇Prim + Binary Heap(夠快又好實作)

JavaScript / TypeScript 實作

UnionFind(供 Kruskal 使用)

並查集(Union-Find) 是 Kruskal 的核心工具。它能在近乎常數時間內回答「兩個頂點是否已連通?」並合併兩個集合。

class UnionFind {
  private parent: number[];
  private rank: number[];

  constructor(n: number) {
    // 初始化:每個節點的父節點是自己
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank = new Array(n).fill(0);
  }

  find(x: number): number {
    // 路徑壓縮:將沿途所有節點直接指向根節點
    if (this.parent[x] !== x) {
      this.parent[x] = this.find(this.parent[x]);
    }
    return this.parent[x];
  }

  union(x: number, y: number): boolean {
    const rx = this.find(x);
    const ry = this.find(y);
    if (rx === ry) return false; // 已連通,加入此邊會成環
    // 按秩合併:秩小的樹接在秩大的樹下
    if (this.rank[rx] < this.rank[ry]) {
      this.parent[rx] = ry;
    } else if (this.rank[rx] > this.rank[ry]) {
      this.parent[ry] = rx;
    } else {
      this.parent[ry] = rx;
      this.rank[rx]++;
    }
    return true; // 成功合併
  }
}

兩個優化:

  • 路徑壓縮(Path Compression)find 時讓沿途節點直接指向根,加速後續查詢
  • 按秩合併(Union by Rank):矮樹接在高樹下,控制樹高,確保 find 接近 O(1)

兩者合用,每次操作的攤還時間為 O(α(V)),α 是 Ackermann 函數的反函數,實際上視為常數。


MinHeap(供 Prim 使用)

JavaScript 標準庫沒有內建的 MinHeap,需要自行實作或使用第三方套件。

class MinHeap<T> {
  private heap: T[] = [];

  constructor(private compare: (a: T, b: T) => number) {}

  push(val: T): void {
    this.heap.push(val);
    this.siftUp(this.heap.length - 1);
  }

  pop(): T | undefined {
    if (this.heap.length === 0) return undefined;
    const top = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this.siftDown(0);
    }
    return top;
  }

  get size(): number {
    return this.heap.length;
  }

  private siftUp(i: number): void {
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.compare(this.heap[i], this.heap[parent]) < 0) {
        [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]];
        i = parent;
      } else {
        break;
      }
    }
  }

  private siftDown(i: number): void {
    const n = this.heap.length;
    while (true) {
      let smallest = i;
      const l = 2 * i + 1;
      const r = 2 * i + 2;
      if (l < n && this.compare(this.heap[l], this.heap[smallest]) < 0) smallest = l;
      if (r < n && this.compare(this.heap[r], this.heap[smallest]) < 0) smallest = r;
      if (smallest === i) break;
      [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]];
      i = smallest;
    }
  }
}

Kruskal 演算法完整實作

// 輸入:n 個頂點(0-indexed),edges = [u, v, weight][]
// 輸出:MST 的邊列表;若圖不連通,返回能覆蓋的最小生成森林

function kruskal(
  n: number,
  edges: [number, number, number][]
): [number, number, number][] {
  // Step 1:按邊權升序排列
  edges.sort((a, b) => a[2] - b[2]);

  const uf = new UnionFind(n);
  const mst: [number, number, number][] = [];

  for (const [u, v, w] of edges) {
    // Step 2:嘗試合併;若兩端已連通則跳過
    if (uf.union(u, v)) {
      mst.push([u, v, w]);
      // Step 3:MST 恰有 V-1 條邊即完成
      if (mst.length === n - 1) break;
    }
  }

  return mst;
}

// 計算 MST 總權重
function kruskalMSTWeight(n: number, edges: [number, number, number][]): number {
  const mstEdges = kruskal(n, [...edges]); // 複製避免修改原陣列
  return mstEdges.reduce((sum, [, , w]) => sum + w, 0);
}

// 範例
const n = 6;
const edges: [number, number, number][] = [
  [0, 1, 4], [0, 2, 3], [1, 2, 2], [1, 3, 6],
  [2, 3, 5], [2, 4, 4], [2, 5, 1], [3, 5, 4], [4, 5, 1],
];
console.log(kruskalMSTWeight(n, edges)); // 輸出:11

核心流程回顧:

  1. 邊排序(O(E log E))
  2. 逐邊嘗試:union 回傳 true 表示兩端不在同一集合,加入 MST
  3. 滿 V-1 條邊提早結束

Prim 演算法完整實作

// 輸入:n 個頂點,adj = 鄰接表,adj[u] = [[v, weight], ...]
// 輸出:MST 總權重;若圖不連通返回 -1

function prim(n: number, adj: [number, number][][]): number {
  const visited = new Uint8Array(n); // 0=未加入,1=已加入 MST
  // MinHeap 儲存 [weight, toNode]
  const heap = new MinHeap<[number, number]>((a, b) => a[0] - b[0]);
  let totalWeight = 0;
  let edgeCount = 0;

  // 從頂點 0 出發,將 0 的所有鄰邊加入 Heap
  visited[0] = 1;
  for (const [neighbor, weight] of adj[0]) {
    heap.push([weight, neighbor]);
  }

  while (heap.size > 0 && edgeCount < n - 1) {
    const item = heap.pop();
    if (!item) break;
    const [weight, u] = item;

    if (visited[u]) continue; // Lazy Deletion:跳過已在 MST 中的頂點

    // 將 u 加入 MST
    visited[u] = 1;
    totalWeight += weight;
    edgeCount++;

    // 將 u 的所有未加入 MST 的鄰邊推入 Heap
    for (const [neighbor, w] of adj[u]) {
      if (!visited[neighbor]) {
        heap.push([w, neighbor]);
      }
    }
  }

  // edgeCount < n-1 代表圖不連通
  return edgeCount === n - 1 ? totalWeight : -1;
}

// 建圖輔助函式(無向圖)
function buildAdjList(
  n: number,
  edges: [number, number, number][]
): [number, number][][] {
  const adj: [number, number][][] = Array.from({ length: n }, () => []);
  for (const [u, v, w] of edges) {
    adj[u].push([v, w]);
    adj[v].push([u, w]); // 無向圖:雙向加邊
  }
  return adj;
}

// 範例
const adj = buildAdjList(n, edges);
console.log(prim(n, adj)); // 輸出:11

Lazy Deletion 關鍵: if (visited[u]) continue 這行讓我們不需要維護「已在 MST 中的頂點的舊邊」,直接在彈出時忽略即可。這讓實作大幅簡化,代價是 Heap 中可能存有少量過期項目。


C++ 對照實作

C++ 的 priority_queue 預設是最大堆,使用 greater<> 改為最小堆;排序直接用 sort 搭配 structured binding(C++17)。

#include <bits/stdc++.h>
using namespace std;

// ===== Union-Find(供 Kruskal 使用)=====
struct UnionFind {
    vector<int> parent, rank_;
    explicit UnionFind(int n) : parent(n), rank_(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // 路徑壓縮
        return parent[x];
    }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) rank_[rx]++;
        return true;
    }
};

// ===== Kruskal:O(E log E) =====
// edges: vector<tuple<weight, u, v>>
long long kruskal(int n, vector<tuple<int,int,int>>& edges) {
    sort(edges.begin(), edges.end()); // 按 weight 升序(tuple 預設按第一個元素)

    UnionFind uf(n);
    long long totalWeight = 0;
    int edgeCount = 0;

    for (auto& [w, u, v] : edges) {
        if (uf.unite(u, v)) {
            totalWeight += w;
            if (++edgeCount == n - 1) break; // MST 完成
        }
    }
    return edgeCount == n - 1 ? totalWeight : -1; // -1 表示圖不連通
}

// ===== Prim:O((V+E) log V),priority_queue 版 =====
long long prim(int n, vector<vector<pair<int,int>>>& adj) {
    vector<bool> visited(n, false);
    // Min-Heap:{weight, node}
    priority_queue<pair<int,int>,
                   vector<pair<int,int>>,
                   greater<pair<int,int>>> pq;

    pq.push({0, 0}); // 從節點 0 出發,初始虛擬邊權 = 0
    long long totalWeight = 0;
    int edgeCount = 0;

    while (!pq.empty()) {
        auto [w, u] = pq.top(); pq.pop();

        if (visited[u]) continue; // Lazy Deletion
        visited[u] = true;
        totalWeight += w;
        if (u != 0) edgeCount++; // 起點虛擬邊不計入邊數

        for (auto& [v, weight] : adj[u]) {
            if (!visited[v]) pq.push({weight, v});
        }
    }
    return edgeCount == n - 1 ? totalWeight : -1;
}

int main() {
    int n = 6;
    // Kruskal 輸入:{weight, u, v}
    vector<tuple<int,int,int>> edges = {
        {4,0,1},{3,0,2},{2,1,2},{6,1,3},
        {5,2,3},{4,2,4},{1,2,5},{4,3,5},{1,4,5}
    };
    cout << kruskal(n, edges) << "\n"; // 11

    // Prim 輸入:鄰接表
    vector<vector<pair<int,int>>> adj(n);
    for (auto& [w, u, v] : edges) {
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }
    cout << prim(n, adj) << "\n"; // 11
    return 0;
}

複雜度分析

演算法時間複雜度空間複雜度瓶頸
KruskalO(E log E)O(V + E)邊的排序
Prim (Binary Heap)O((V+E) log V)O(V + E)Heap 操作
Prim (Fibonacci Heap)O(E + V log V)O(V + E)理論最優,難實作
Prim (稠密圖線性掃描)O(V²)O(V)適合完全圖

何時用哪種:

  • E ≈ V(稀疏圖):Kruskal,排序 O(V log V) 很快
  • E ≈ V²(稠密圖):Prim + 線性掃描,O(V²) 勝過排序的 O(V² log V)
  • 面試 / 競程通用:Prim + Binary Heap,O(E log V) 夠用且容易實作
  • 理論最優追求:Prim + Fibonacci Heap,O(E + V log V)

變體與延伸

次小生成樹(Second Minimum Spanning Tree)

求重量第二小的生成樹。經典做法:

  1. 先求出標準 MST(總重 W)
  2. 枚舉每條不在 MST 中的非樹邊 e(u,v,w)
  3. 用 e 替換 MST 上 u→v 路徑中的最重邊(可用 LCA + 稀疏表在 O(log V) 內找到)
  4. 記錄替換後的最小重量差,得到次小 MST

總時間複雜度 O(E log V)。

Borůvka 演算法

與 Kruskal / Prim 並稱三大 MST 演算法。每輪讓每個連通分量各自選出「連接外部的最輕邊」,合併後分量數量減半。理論複雜度 O(E log V),在分散式環境(MapReduce)中特別有用,因為每輪可以並行處理。

最大生成樹(Maximum Spanning Tree)

只需將 Kruskal 的邊排序改為降序,或 Prim 的 MinHeap 改為 MaxHeap,即可求最大生成樹——生成樹總邊重最大者。

應用場景:最大瓶頸路(Widest Path Problem)——兩頂點間路徑上最小邊重最大化的路徑,正好是最大生成樹上的唯一路徑。

有向圖的最小生成樹(Minimum Spanning Arborescence)

在有向圖中以指定根節點為根的最小生成樹,使用 朱劉演算法(Edmonds’ Algorithm) 求解,時間複雜度 O(VE)。


面試考點

MST 的四大性質

  1. 唯一性:若圖中所有邊的權重互不相同,MST 唯一;若有相同邊權,MST 可能不唯一(但總重相同)
  2. V-1 條邊:MST 恰好包含 V-1 條邊
  3. 無環:MST 是樹,不含環
  4. Cut Property 保證:任意 Cut 的 Light Edge 一定屬於某棵 MST

常見陷阱

陷阱一:Prim 忘記 Lazy Deletion

// 錯誤寫法:沒有檢查 visited
while (heap.size > 0) {
  const [weight, u] = heap.pop()!;
  visited[u] = 1;      // 若 u 已加入,這裡會重複計算!
  totalWeight += weight;
}

// 正確寫法:
const [weight, u] = heap.pop()!;
if (visited[u]) continue; // 必須有這行

陷阱二:Kruskal 不檢查連通性

稀疏圖或不連通圖上,若 edgeCount < n-1 則無法形成完整 MST,回傳值應為 -1(不連通),而非誤以為已找到 MST。

陷阱三:稠密圖用 Kruskal 效能差

E = O(V²) 時,排序需 O(V² log V)。不如用 Prim 的線性掃描版 O(V²)。

陷阱四:等權邊導致 MST 不唯一

面試題可能特別考「關鍵邊(Critical Edge)」與「偽關鍵邊(Pseudo-Critical Edge)」的區分(LeetCode 1489),需要理解 MST 在等權邊存在時的行為。


LeetCode 練習題

題號題目難度核心技巧
1135Connecting Cities with Minimum CostMedium直接 Kruskal
1584Min Cost to Connect All PointsMediumKruskal 或 Prim(完全圖)
1489Find Critical and Pseudo-Critical EdgesHardMST + 強制加入/排除邊
778Swim in Rising WaterHard二分搜 + BFS 或 Kruskal 變體
1168Optimize Water DistributionHard虛擬節點 + MST

LeetCode 1584 — Min Cost to Connect All Points

在二維平面上,N 個點之間的連接成本為曼哈頓距離,求連通所有點的最低成本(即 MST)。

function minCostConnectPoints(points: number[][]): number {
  const n = points.length;
  if (n === 1) return 0;

  // 稠密圖優化版 Prim:不使用 MinHeap,改用 minDist 陣列線性掃描
  // 時間 O(n²),空間 O(n),比 MinHeap 版 O(n² log n) 更優(稠密完全圖)
  const minDist = new Array(n).fill(Infinity); // minDist[i] = 點 i 到 MST 的最小距離
  const inMST = new Uint8Array(n);
  minDist[0] = 0; // 從點 0 出發
  let totalCost = 0;

  for (let iter = 0; iter < n; iter++) {
    // 找到距離 MST 最近的未加入點
    let u = -1;
    for (let i = 0; i < n; i++) {
      if (!inMST[i] && (u === -1 || minDist[i] < minDist[u])) {
        u = i;
      }
    }

    inMST[u] = 1;
    totalCost += minDist[u];

    // 更新所有未加入點到 MST 的最小距離
    for (let v = 0; v < n; v++) {
      if (!inMST[v]) {
        const dist = Math.abs(points[u][0] - points[v][0]) +
                     Math.abs(points[u][1] - points[v][1]);
        if (dist < minDist[v]) {
          minDist[v] = dist;
        }
      }
    }
  }

  return totalCost;
  // 時間:O(n²)    空間:O(n)
}

// 測試
console.log(minCostConnectPoints([[0,0],[2,2],[3,10],[5,2],[7,0]])); // 輸出:20
console.log(minCostConnectPoints([[3,12],[-2,5],[-4,1]]));           // 輸出:18

LeetCode 1489 — Find Critical and Pseudo-Critical Edges

找出 MST 中的關鍵邊(移除後 MST 重量增加)與偽關鍵邊(有某棵 MST 包含此邊但它不是關鍵邊)。

function findCriticalAndPseudoCriticalEdges(
  n: number,
  edges: number[][]
): number[][] {
  // 記錄原始邊索引(排序後需還原)
  const indexedEdges = edges.map((e, i) => [...e, i]);
  indexedEdges.sort((a, b) => a[2] - b[2]); // 按權重排序

  // Kruskal 輔助函式
  // skipIdx:強制排除的邊索引(-1 表示不排除)
  // forceIdx:強制包含的邊索引(-1 表示不強制)
  // 回傳 MST 重量,圖不連通回傳 Infinity
  function kruskalWith(skipIdx: number, forceIdx: number): number {
    const parent = Array.from({ length: n }, (_, i) => i);
    const rank = new Array(n).fill(0);
    let totalWeight = 0;
    let edgeCount = 0;

    function find(x: number): number {
      if (parent[x] !== x) parent[x] = find(parent[x]);
      return parent[x];
    }
    function union(x: number, y: number): boolean {
      const rx = find(x), ry = find(y);
      if (rx === ry) return false;
      if (rank[rx] < rank[ry]) parent[rx] = ry;
      else if (rank[rx] > rank[ry]) parent[ry] = rx;
      else { parent[ry] = rx; rank[rx]++; }
      return true;
    }

    // 先強制加入 forceIdx 的邊
    if (forceIdx !== -1) {
      const [u, v, w] = indexedEdges[forceIdx];
      union(u, v);
      totalWeight += w;
      edgeCount++;
    }

    for (let i = 0; i < indexedEdges.length; i++) {
      if (i === skipIdx) continue;
      const [u, v, w] = indexedEdges[i];
      if (union(u, v)) {
        totalWeight += w;
        edgeCount++;
      }
    }

    return edgeCount === n - 1 ? totalWeight : Infinity;
  }

  const baseMST = kruskalWith(-1, -1);
  const critical: number[] = [];
  const pseudoCritical: number[] = [];

  for (let i = 0; i < indexedEdges.length; i++) {
    const originalIdx = indexedEdges[i][3]; // 還原原始邊索引

    if (kruskalWith(i, -1) > baseMST) {
      // 強制排除後 MST 重量增加 → 關鍵邊
      critical.push(originalIdx);
    } else if (kruskalWith(-1, i) === baseMST) {
      // 強制包含後 MST 重量不變 → 偽關鍵邊
      pseudoCritical.push(originalIdx);
    }
  }

  return [critical, pseudoCritical];
  // 時間:O(E² × α(V))    空間:O(V + E)
}

// 測試
console.log(findCriticalAndPseudoCriticalEdges(5, [
  [0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]
]));
// 輸出:[[0,1], [2,3,4,5]]

console.log(findCriticalAndPseudoCriticalEdges(4, [
  [0,1,1],[1,2,1],[2,3,1],[0,3,1]
]));
// 輸出:[[], [0,1,2,3]]

解題思路:

  • 先求標準 MST 重量(baseMST
  • 對每條邊 i 做兩個測試:
    1. 強制排除 i:若新 MST 重量 > baseMST 或圖不連通 → i 是關鍵邊
    2. 若非關鍵邊,強制包含 i:若 MST 重量 == baseMST → i 是偽關鍵邊
  • 對每條邊各跑兩次 Kruskal,總時間 O(E² × α(V))

總結

最小生成樹是圖論中最優雅的問題之一——它有清晰的數學定義(Cut Property)、兩種截然不同的演算法視角(全局選邊 vs 局部擴張),以及豐富的實務應用。

本文涵蓋的核心知識點:

  • 生成樹:V 個頂點、V-1 條邊、無環的連通子圖
  • Cut Property:任意切割的最輕跨 Cut 邊一定屬於某棵 MST
  • Kruskal:邊排序 + Union-Find,適合稀疏圖,O(E log E)
  • Prim:MinHeap + Lazy Deletion,適合稠密圖,O((V+E) log V)
  • 面試重點:MST 不唯一條件、關鍵邊 vs 偽關鍵邊、Lazy Deletion 的必要性

掌握 MST 之後,下一篇將繼續探索圖論的另一個核心主題——最短路徑演算法(Dijkstra / Bellman-Ford / Floyd-Warshall),讓你在圖論問題上更加游刃有餘。

希望這篇文章能幫助你建立對最小生成樹的完整認識。如果有任何問題或想深入討論的地方,歡迎透過 聯絡頁面 與我交流!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。