最小生成樹 — Kruskal 與 Prim 演算法完整比較與實作 | 資料結構與演算法
想像一位城市規劃師,需要在六座城市之間鋪設光纖網路,使所有城市互聯互通,同時讓總鋪設費用最低。這個問題的答案,正是 最小生成樹(Minimum Spanning Tree,MST)。本文完整介紹 Kruskal 與 Prim 兩大演算法的原理、差異,以及 JavaScript/TypeScript 與 C++ 的完整實作。
前言
城市道路鋪設的比喻能精準描述 MST 的核心問題:用最小的總成本,讓圖中所有頂點互相連通,且不形成環路。
生活中的 MST 應用場景非常廣泛:電信公司的光纜佈線、電路板的最低成本連線、聚類分析中的樹狀分群,乃至 Google 資料中心的骨幹網路規劃,都能看到 MST 的身影。
學習本文後,你將能夠:
- 理解生成樹(Spanning Tree)的定義與 MST 的性質
- 掌握 Cut Property(切割定理)——MST 演算法正確性的理論基石
- 熟練使用 Kruskal(排序 + 併查集)與 Prim(優先佇列)求解 MST
- 知道在稀疏圖與稠密圖上分別選用哪種演算法
- 解決 LeetCode 上的 MST 相關題目
核心概念
生成樹(Spanning Tree)
給定一個連通無向帶權圖 G = (V, E),生成樹是滿足下列條件的子圖:
- 包含圖中所有 V 個頂點
- 恰好有 V - 1 條邊
- 圖中無環(即為樹結構)
**最小生成樹(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
| 特性 | Kruskal | Prim (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-Heap | Fibonacci 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
核心流程回顧:
- 邊排序(O(E log E))
- 逐邊嘗試:
union回傳true表示兩端不在同一集合,加入 MST - 滿 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;
}
複雜度分析
| 演算法 | 時間複雜度 | 空間複雜度 | 瓶頸 |
|---|---|---|---|
| Kruskal | O(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)
求重量第二小的生成樹。經典做法:
- 先求出標準 MST(總重 W)
- 枚舉每條不在 MST 中的非樹邊 e(u,v,w)
- 用 e 替換 MST 上 u→v 路徑中的最重邊(可用 LCA + 稀疏表在 O(log V) 內找到)
- 記錄替換後的最小重量差,得到次小 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 的四大性質
- 唯一性:若圖中所有邊的權重互不相同,MST 唯一;若有相同邊權,MST 可能不唯一(但總重相同)
- V-1 條邊:MST 恰好包含 V-1 條邊
- 無環:MST 是樹,不含環
- 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 練習題
| 題號 | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 1135 | Connecting Cities with Minimum Cost | Medium | 直接 Kruskal |
| 1584 | Min Cost to Connect All Points | Medium | Kruskal 或 Prim(完全圖) |
| 1489 | Find Critical and Pseudo-Critical Edges | Hard | MST + 強制加入/排除邊 |
| 778 | Swim in Rising Water | Hard | 二分搜 + BFS 或 Kruskal 變體 |
| 1168 | Optimize Water Distribution | Hard | 虛擬節點 + 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 做兩個測試:
- 強制排除 i:若新 MST 重量 > baseMST 或圖不連通 → i 是關鍵邊
- 若非關鍵邊,強制包含 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),讓你在圖論問題上更加游刃有餘。
希望這篇文章能幫助你建立對最小生成樹的完整認識。如果有任何問題或想深入討論的地方,歡迎透過 聯絡頁面 與我交流!