跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 最小生成樹

最小生成樹

用最少的總成本把所有點連起來。Kruskal 從最便宜的邊開始挑、用並查集避免成環;Prim 從一個點往外長、用堆積挑最近的。

做法
1/10
4365978243361ABCDEFGH
在樹上/剛收下跳過:會成環已合併的群(標記是代表點)
所有的邊,由便宜到貴
  1. GH 1
  2. DE 2
  3. AC 3
  4. EG 3
  5. EH 3
  6. AB 4
  7. DF 4
  8. BD 5
  9. BC 6
  10. FH 6
  11. CD 7
  12. CF 8
  13. BE 9

把 13 條邊依權重排好。一開始每個點各自一群;Kruskal 會依序拿起每條邊,只要它連起兩個不同的群就收下。

目前總權重
0
最小生成樹
22
樹的邊數
7
亮起來的是這一步執行的程式碼
type Edge = [number, number, number]; // [u, v, weight]
function find(parent: number[], x: number): number {
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
function kruskal(n: number, edges: Edge[]): Edge[] {
const sorted = [...edges].sort((a, b) => a[2] - b[2] || a[0] - b[0] || a[1] - b[1]);
const parent = Array.from({ length: n }, (_, i) => i);
const size = new Array(n).fill(1);
const tree: Edge[] = [];
for (const [u, v, w] of sorted) {
if (tree.length === n - 1) break;
let ru = find(parent, u), rv = find(parent, v);
if (ru === rv) continue;
if (size[ru] < size[rv]) [ru, rv] = [rv, ru];
parent[rv] = ru;
size[ru] += size[rv];
tree.push([u, v, w]);
}
return tree;
}
type Entry = [number, number, number, number]; // [weight, seq, from, to]
const before = (a: Entry, b: Entry) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
class EdgeHeap {
private a: Entry[] = [];
get size() { return this.a.length; }
push(e: Entry) {
const a = this.a;
a.push(e);
for (let i = a.length - 1, p; i > 0 && before(a[i], a[(p = (i - 1) >> 1)]); i = p) [a[i], a[p]] = [a[p], a[i]];
}
pop(): Entry {
const a = this.a, top = a[0], last = a.pop()!;
if (a.length === 0) return top;
a[0] = last;
for (let i = 0; ; ) {
const l = 2 * i + 1, r = l + 1;
let m = i;
if (l < a.length && before(a[l], a[m])) m = l;
if (r < a.length && before(a[r], a[m])) m = r;
if (m === i) return top;
[a[i], a[m]] = [a[m], a[i]];
i = m;
}
}
}
function prim(n: number, edges: Edge[]): Edge[] {
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]);
}
for (const list of adj) list.sort((a, b) => a[0] - b[0]);
const inTree = new Array(n).fill(false);
const heap = new EdgeHeap();
let seq = 0;
const add = (node: number) => {
inTree[node] = true;
for (const [to, w] of adj[node]) {
if (!inTree[to]) heap.push([w, seq++, node, to]);
}
};
add(0);
const tree: Edge[] = [];
while (heap.size > 0 && tree.length < n - 1) {
const [w, , from, to] = heap.pop();
if (inTree[to]) continue;
tree.push([from, to, w]);
add(to);
}
return tree;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 要用最少的總成本把所有地點連起來,而且不在乎任兩點之間繞多遠:拉網路線、鋪水管、電網。
  • 分群:做出最小生成樹後拿掉最貴的 k−1 條邊,就分成 k 群(single-linkage clustering)。
  • 邊很稀疏、或邊本來就排好序時用 Kruskal;圖很稠密、或想從某個點慢慢往外長時用 Prim。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
Kruskal
排序佔大頭;並查集每次幾乎是常數
O(E log E)O(E log E)
Prim(二元堆積)O(E log V)O(E log V)
Prim(陣列,稠密圖)
E 接近 V² 時反而比用堆積快
O(V²)O(V²)

空間:O(V + E)

Big O 實測:n 變大時步數怎麼長

數的是:比較次數(n 個點、約 4n 條邊的隨機連通圖)

Big On = 250n = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
Kruskal:排序+並查集O(n log n)9,70747,766231,9441,060,402×109 (×112)
Prim:堆積比較O(n log n)13,42769,311409,2921,947,853×145 (×112)

和其他做法比

邊數Kruskal:排序比較Kruskal:看了幾條邊Prim:堆積比較Prim:放進堆積
稀疏:200 個點、約 600 條邊5894,9314287,789589
稠密:200 個點兩兩相連19,900265,25047959,07119,900

兩種做法在兩張圖上找到的總權重都一樣(3586、234)。Kruskal 的大頭是先把所有邊排序;Prim 每收進一個點就把它的邊放進堆積,所以在稠密圖上堆積進出也跟著變多。

真實世界裡的它

  • 電信與電力公司規劃骨幹線路的第一版草圖。
  • 影像分割:把像素當點、顏色差異當權重,切開最貴的邊。
  • 旅行推銷員問題的近似解:繞最小生成樹走一圈,長度不會超過最佳解的兩倍。

取捨與陷阱

  • 最小生成樹不是最短路徑:樹上兩點之間的路可能繞很遠。要的是兩點之間最短,請用 Dijkstra。
  • 圖不連通時沒有生成樹,Kruskal 會得到一片森林;要檢查收下的邊是不是剛好 V−1 條。
  • 權重有相同值時,最小生成樹可能不只一棵:總權重一定相同,但選到的邊可能不同。

LeetCode 練習