最小生成樹
用最少的總成本把所有點連起來。Kruskal 從最便宜的邊開始挑、用並查集避免成環;Prim 從一個點往外長、用堆積挑最近的。
做法
1/10
在樹上/剛收下跳過:會成環已合併的群(標記是代表點)
所有的邊,由便宜到貴
- GH 1
- DE 2
- AC 3
- EG 3
- EH 3
- AB 4
- DF 4
- BD 5
- BC 6
- FH 6
- CD 7
- CF 8
- 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 O | n = 250 | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| Kruskal:排序+並查集 | O(n log n) | 9,707 | 47,766 | 231,944 | 1,060,402 | ×109 (×112) |
| Prim:堆積比較 | O(n log n) | 13,427 | 69,311 | 409,292 | 1,947,853 | ×145 (×112) |
和其他做法比
| 邊數 | Kruskal:排序比較 | Kruskal:看了幾條邊 | Prim:堆積比較 | Prim:放進堆積 | |
|---|---|---|---|---|---|
| 稀疏:200 個點、約 600 條邊 | 589 | 4,931 | 428 | 7,789 | 589 |
| 稠密:200 個點兩兩相連 | 19,900 | 265,250 | 479 | 59,071 | 19,900 |
兩種做法在兩張圖上找到的總權重都一樣(3586、234)。Kruskal 的大頭是先把所有邊排序;Prim 每收進一個點就把它的邊放進堆積,所以在稠密圖上堆積進出也跟著變多。
真實世界裡的它
- 電信與電力公司規劃骨幹線路的第一版草圖。
- 影像分割:把像素當點、顏色差異當權重,切開最貴的邊。
- 旅行推銷員問題的近似解:繞最小生成樹走一圈,長度不會超過最佳解的兩倍。
取捨與陷阱
- 最小生成樹不是最短路徑:樹上兩點之間的路可能繞很遠。要的是兩點之間最短,請用 Dijkstra。
- 圖不連通時沒有生成樹,Kruskal 會得到一片森林;要檢查收下的邊是不是剛好 V−1 條。
- 權重有相同值時,最小生成樹可能不只一棵:總權重一定相同,但選到的邊可能不同。