Minimum Spanning Tree(最小生成樹,MST)
範圍 — 無向加權圖上的最小生成樹:切割性質、Kruskal 演算法(排序好的邊 + 併查集)與 Prim 演算法(heap 或稠密陣列),各自附逐步追蹤與變形,以及建立在它們之上的題型 — 完全幾何圖、虛擬源點、關鍵邊與瓶頸樹。 另見:union_find.md — Kruskal 底下跑的併查集;heap.md — Prim 底下的優先佇列;shortest_path_comparison.md — Prim 和 Dijkstra 只差一項;graph.md — 圖的表示法、走訪,以及把題目分流到這裡的圖論題目目錄。
- 核心想法:用剛好
n - 1條邊、最小的總權重,把全部n個節點連起來 — 貪心地拿「不會形成環的最便宜那條邊」 - 什麼時候用:「把每個點/城市/房子都連起來」、「讓所有點連通的最小成本」、「任兩點之間恰好只有一條簡單路徑」 — 要的是最便宜的樹,不是最便宜的路徑
- 代表性 LeetCode 題目:LC 1584、LC 1135、LC 1168、LC 1489、LC 3600
- 資料結構:併查集 + 排序好的邊清單(Kruskal),或 min-heap + visited 集合(Prim)
- 典型狀態:Kruskal —
parent[]與已選的邊數;Prim —in_tree,以及每個樹外節點目前已知最便宜的那條連入邊
時間複雜度: Kruskal O(E log E)(排序);Prim 用 heap 是 O(E log V);Prim 用一般陣列是 O(V^2)
空間複雜度: 圖本身 O(V + E),併查集或 visited 集合 O(V)
LeetCode 題目清單
0) 概念
0-0) 核心原理
一張有 n 個節點的連通無向圖,它的生成樹是邊的一個子集:能走到每個節點、而且沒有環
— 所以它永遠有剛好 n - 1 條邊,任兩個節點之間也恰好只有一條簡單路徑。最小生成樹
就是所有生成樹中,邊權總和最小的那一棵。
為什麼貪心是對的 — 兩個性質:
- 切割性質(cut property):把節點任意分成兩群,橫跨這個切割最便宜的那條邊,一定屬於某一棵 MST。Prim 就是反覆套用這個性質(切割是「已在樹裡」vs「還沒進來」)。
- 環性質(cycle property):若某條邊是某個環上唯一最貴的邊,它就不屬於任何 MST。Kruskal 就是反覆套用這個性質:一條兩端早已連通的邊會形成一個環,環上其他邊都不比它重,所以跳過它 仍然留得下一棵 MST。(權重相同時,被跳過的邊可能屬於另一棵 MST — 這正是 LC 1489 會有 「偽關鍵邊」的原因。)
關鍵洞見:
- MST 最小化的是樹上邊的總和。它不會最小化任兩個特定節點之間的距離 — 那是最短路徑 (Dijkstra),完全是另一個問題。
- MST 同時也會最小化樹上最大的那條邊(它是一棵最小瓶頸生成樹),所以「最小化最大邊」 這類題目,其實都是喬裝過的 Kruskal 掃描。
- 圖不連通就沒有生成樹:Kruskal 結束時選不到
n - 1條邊,Prim 結束時拜訪不到n個節點。 題目有要求時就回傳-1。
0-1) 題型分類
- 經典的加權邊清單 — 邊直接給你;原封不動跑 Kruskal(LC 1135)
- 完全幾何圖 — 每一對點都是一條邊,權重是某種距離(LC 1584)
- 虛擬源點 — 「在某個節點蓋一座站,或把它接到某座站」:加一個節點
0,它連到節點i的邊權就是蓋站的價錢(LC 1168) - 邊的分類 — 哪些邊在每一棵 MST 裡/在某些 MST 裡:排除或強制選某一條邊後重建 MST(LC 1489)
- 瓶頸樹 — 最大化生成樹裡最弱的邊/最小化最強的邊:排序後逐條合併,或二分搜尋 + 併查集檢查可行性(LC 3600,參見 LC 1631)
0-2) Kruskal 還是 Prim? 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
| 你拿到的圖 | 該用 | 理由 |
|---|---|---|
一份邊清單,E 接近 V(稀疏) |
Kruskal | 邊本來就有了;排序一次,O(E log E) |
| 一份鄰接表,或「從任一點開始往外長」 | Prim + heap | 骨架跟 Dijkstra 一樣,O(E log V) |
平面上的點 — 每一對都是一條邊(稠密,E ≈ V²/2) |
Prim + 陣列 | O(V²)、不用邊清單、O(V) 記憶體 — 對一般的稠密圖已是最佳,因為每條邊都非看過一次不可 |
| 需要排除/強制選某條特定邊 | Kruskal | 很容易跳過某條邊或先把它合併,再重跑一次 |
| 邊依權重順序陸續進來,或兩個節點一連通就要停 | Kruskal | 每加一條邊,它的併查集都能回答「連通了沒?」。未排序的邊流這樣做得不到 MST — 晚到的便宜邊無法取代已選的較重邊 |
面試時,Kruskal 通常是比較快能寫對的那個;圖很稠密時要點名的是 Prim,而在那種情況下真正 勝過其他做法的,是稠密陣列版的 Prim。
1) 通用形式
1-1) Kruskal — 排序好的邊 + 併查集 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
步驟:
- 把所有邊依權重排序,最便宜的在前。
- 對每條邊
(w, a, b):若a與b已在同一個連通分量,就跳過(會形成環);否則把它們合併並加上w。 - 選滿
n - 1條邊就停。最後不到n - 1條 → 圖不連通。
# python
# IDEA: Kruskal — sort edges, take each one that joins two different components
# time = O(E log E), space = O(V + E)
def kruskal(n, edges):
"""n nodes 0..n-1, edges = [(weight, a, b), ...]; return MST cost or -1."""
parent = list(range(n))
rank = [0] * n
def find(x):
# path halving: point x at its grandparent while walking up
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
root_a, root_b = find(a), find(b)
if root_a == root_b:
return False # already connected -> this edge closes a cycle
if rank[root_a] < rank[root_b]:
root_a, root_b = root_b, root_a
parent[root_b] = root_a # hang the shorter tree under the taller one
if rank[root_a] == rank[root_b]:
rank[root_a] += 1
return True
total_cost = 0
edges_used = 0
for weight, a, b in sorted(edges):
if union(a, b):
total_cost += weight
edges_used += 1
if edges_used == n - 1: # a tree on n nodes has exactly n - 1 edges
return total_cost
return total_cost if n <= 1 else -1 # ran out of edges: disconnected
// java
// IDEA: Kruskal — sort edges, take each one that joins two different components
// time = O(E log E), space = O(V + E)
public int kruskal(int n, int[][] edges) { // edges[i] = {weight, a, b}
int[] parent = new int[n];
int[] rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
int[][] sorted = edges.clone();
Arrays.sort(sorted, (x, y) -> Integer.compare(x[0], y[0]));
int totalCost = 0;
int edgesUsed = 0;
for (int[] edge : sorted) {
int rootA = find(parent, edge[1]);
int rootB = find(parent, edge[2]);
if (rootA == rootB) {
continue; // would close a cycle
}
if (rank[rootA] < rank[rootB]) {
int tmp = rootA; rootA = rootB; rootB = tmp;
}
parent[rootB] = rootA;
if (rank[rootA] == rank[rootB]) {
rank[rootA]++;
}
totalCost += edge[0];
edgesUsed++;
if (edgesUsed == n - 1) {
return totalCost;
}
}
return n <= 1 ? 0 : -1; // disconnected
}
private int find(int[] parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]]; // path halving
x = parent[x];
}
return x;
}
Kruskal 演算法逐步追蹤 — 同一張圖,依序處理每條邊
同一張圖在 1-2 也會用 Prim 追蹤一次,方便比較兩種 處理順序 — 兩者最後得到同一棵樹。
graph G, 5 nodes: 0-1 (2) 1-2 (3) 2-4 (4) 1-4 (5) 0-3 (6) 1-3 (8) 3-4 (9)
0 ---2--- 1 ---3--- 2
| / \ /
6 8 5 4
| / \/
3 -------9------ 4
sorted edge find(a) == find(b)? action components after total
0-1 (2) no take {0,1} {2} {3} {4} 2
1-2 (3) no take {0,1,2} {3} {4} 5
2-4 (4) no take {0,1,2,4} {3} 9
1-4 (5) YES — 1 and 4 joined skip (would close cycle 1-2-4)
0-3 (6) no take {0,1,2,3,4} 15
4 edges = n - 1 -> stop; 1-3 (8) and 3-4 (9) are never looked at
MST = {0-1, 1-2, 2-4, 0-3}, cost 15
時間花在哪裡: 排序是 O(E log E),因為 E ≤ V²,這和 O(E log V) 是同一個量級。
E 次 find/union 在路徑壓縮 + 依秩合併之下是 O(E · α(V)) — 實際上就是線性。所以 Kruskal
就是一次排序加一趟線性掃描;如果邊本來就排好序(或權重是可以分桶的小整數),它幾乎是線性的。
Kruskal 變形 — 模板要改哪裡
| 題目這樣說 | 對 1-1 的改動 | LC |
|---|---|---|
| 最大生成樹/最大化總和 | 改成遞減排序(或把權重取負) | 1102(瓶頸版) |
| 圖可能不連通;每個連通分量各自算成本 | 不要回傳 -1;結果是一座最小生成森林,共有 n - edges_used 棵樹 |
— |
「最小化從 s 到 t 路徑上最大的那條邊」 |
遞增排序;一旦 find(s) == find(t) 就停止掃描;當下的權重就是答案 |
1631 |
「最大化從 s 到 t 路徑上最小的那條邊」 |
遞減排序;同樣提早停止 | 1102 |
很多個「只用 < limit 的邊能不能連通?」查詢 |
把查詢也依 limit 排序,兩者一起掃(離線) | 1697 — union_find_examples.md §13 |
| 有些邊是必選的 | 掃描之前先把它們合併(並算進成本);若某條必選邊連接的兩點已被其他必選邊連通,它們就形成環,不存在合法的生成樹 — 回傳 -1 |
1489、3600 |
| 「在這裡蓋,或接過去」 | 加一個虛擬節點 0,每個節點各一條邊 |
1168 — 2-3 |
| 要回傳樹本身,而不只是成本 | 把每條選中的邊收集起來 | 見下方 |
需要第二段程式碼的理由:它會回傳樹的邊,也涵蓋最大生成樹的變形,這是只算成本的模板做不到的。
# python
# IDEA: Kruskal that returns the chosen edges; maximum=True builds a MAXIMUM spanning tree
# time = O(E log E), space = O(V + E)
def kruskal_tree(n, edges, maximum=False):
"""edges = [(weight, a, b), ...]; return (total, tree_edges).
len(tree_edges) < n - 1 means the graph is disconnected (the result is a forest)."""
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = 0
tree_edges = []
for weight, a, b in sorted(edges, reverse=maximum):
root_a, root_b = find(a), find(b)
if root_a == root_b:
continue # closes a cycle
parent[root_a] = root_b
total += weight
tree_edges.append((a, b, weight))
if len(tree_edges) == n - 1:
break
return total, tree_edges
1-2) Prim — 用 min-heap 長出一棵樹 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
步驟:
- 推入
(0, start)— 第一個節點加入不用花錢。 - 彈出最便宜的
(cost, node)。若node已經在樹裡就跳過 — 它稍早已用更便宜的價錢加入了。 - 否則把它加入樹、加上
cost,並對每條連到樹外鄰居的邊推入(w, neighbour)。 - 全部
n個節點都進來就停。在那之前 heap 就空了 → 圖不連通。
# python
# IDEA: Prim — the cheapest edge leaving the tree is always safe (cut property)
# time = O(E log V), space = O(V + E)
import heapq
def prim(n, adj):
"""adj[u] = [(weight, v), ...] for an undirected graph; return MST cost or -1."""
in_tree = [False] * n
min_heap = [(0, 0)] # (cost to join the tree, node)
total_cost = 0
nodes_joined = 0
while min_heap and nodes_joined < n:
cost, node = heapq.heappop(min_heap)
if in_tree[node]:
continue # a stale, more expensive offer for this node
in_tree[node] = True
total_cost += cost
nodes_joined += 1
for weight, neighbour in adj[node]:
if not in_tree[neighbour]:
heapq.heappush(min_heap, (weight, neighbour))
return total_cost if nodes_joined == n else -1
// java
// IDEA: Prim — the cheapest edge leaving the tree is always safe (cut property)
// time = O(E log V), space = O(V + E)
public int prim(int n, List<List<int[]>> adj) { // adj.get(u) = [{weight, v}, ...]
boolean[] inTree = new boolean[n];
PriorityQueue<int[]> minHeap = new PriorityQueue<>((x, y) -> Integer.compare(x[0], y[0]));
minHeap.offer(new int[]{0, 0}); // {cost to join the tree, node}
int totalCost = 0;
int nodesJoined = 0;
while (!minHeap.isEmpty() && nodesJoined < n) {
int[] top = minHeap.poll();
int cost = top[0];
int node = top[1];
if (inTree[node]) {
continue; // stale offer
}
inTree[node] = true;
totalCost += cost;
nodesJoined++;
for (int[] edge : adj.get(node)) {
if (!inTree[edge[1]]) {
minHeap.offer(new int[]{edge[0], edge[1]});
}
}
}
return nodesJoined == n ? totalCost : -1;
}
Prim 演算法逐步追蹤 — 同一張圖 G
沿用 Kruskal 逐步追蹤 的那張圖,
從節點 0 開始長。Prim 從不看全域的邊順序 — 只看離開目前這棵樹最便宜的那條邊:
pop in tree? action push heap after total
(0, 0) no join 0 (2,1) (6,3) (2,1) (6,3) 0
(2, 1) no join 1 via 0-1 (3,2) (5,4) (8,3) (3,2) (5,4) (6,3) (8,3) 2
(3, 2) no join 2 via 1-2 (4,4) (4,4) (5,4) (6,3) (8,3) 5
(4, 4) no join 4 via 2-4 (9,3) (5,4) (6,3) (8,3) (9,3) 9
(5, 4) YES stale — skip (6,3) (8,3) (9,3)
(6, 3) no join 3 via 0-3 15
all 5 nodes joined -> stop
MST = {0-1, 1-2, 2-4, 0-3}, cost 15 — the same tree Kruskal built, found in a different order
過期的 (5, 4) 就是 lazy 版的代價:每有一個鄰居提出報價,節點就會被推入一次,只有第一次彈出才算數。
Lazy、eager 與稠密 Prim — 以及如何還原出整棵樹
| 版本 | heap 裡放什麼 | 時間 | 什麼時候用 |
|---|---|---|---|
| Lazy(上面的模板) | 每條被提出的邊;過期的在彈出時跳過 | O(E log E) = O(E log V) |
預設 — Python 的 heapq 沒有 decrease-key |
| Eager(索引式 heap) | 每個樹外節點一筆,直接就地降低它的 key | O(E log V),heap 大小 ≤ V |
Java/C++ 有索引式優先佇列時;面試中很少值得 |
| 稠密(陣列,不用 heap) | 什麼都不放 — 每一輪掃描 min_dist[] |
O(V²) |
完全圖或接近完全圖 — 2-1 |
想回傳整棵樹而不只是成本,就在 heap 的每一筆裡帶上這條邊是從哪個節點來的 — 那就是 Prim 的
parent[]。需要第二段程式碼的理由:它會回傳邊,這是模板的 (cost, node) 做不到的。
# python
# IDEA: Prim that records which tree node each node was joined from
# time = O(E log V), space = O(V + E)
import heapq
def prim_tree(n, adj, start=0):
"""adj[u] = [(weight, v), ...]; return (total, tree_edges), or None if disconnected."""
in_tree = [False] * n
min_heap = [(0, start, -1)] # (edge weight, node, the tree node it joins from)
total = 0
tree_edges = []
while min_heap:
weight, node, came_from = heapq.heappop(min_heap)
if in_tree[node]:
continue
in_tree[node] = True
total += weight
if came_from != -1:
tree_edges.append((came_from, node, weight))
for next_weight, neighbour in adj[node]:
if not in_tree[neighbour]:
heapq.heappush(min_heap, (next_weight, neighbour, node))
return (total, tree_edges) if len(tree_edges) == n - 1 else None
1-3) Prim vs Dijkstra — 只差一項
兩個迴圈長得一模一樣 — (key, node) 的 heap、節點被彈出第二次就跳過、把它的鄰居推進去。
差別只在 key:
Dijkstra : push (dist[u] + w, v) key = whole PATH cost from the source -> cheapest path A -> B
Prim : push (w, v) key = the single EDGE into the tree -> cheapest tree over ALL
在 Prim 裡寫成 cost + weight,它就變成了 Dijkstra,LC 1584 會得到錯的答案
(最短路徑樹不是最小生成樹)。見
shortest_path_comparison.md。
1-4) 常見陷阱
- 在過期的彈出上算了成本。 lazy Prim 裡一個節點每有一個鄰居就會被推一次;只有它第一次
被彈出時才是最便宜的。加成本之前先檢查
in_tree。 - 把有向邊當無向邊(或反過來)。 MST 是給無向圖用的 — Prim 的鄰接表要兩個方向都加。 有向的「最小樹形圖」是另一套演算法(Chu–Liu/Edmonds),面試不會考。
- 忘了不連通的情況。 回傳成本之前,先檢查
edges_used == n - 1(Kruskal)或nodes_joined == n(Prim)。 - 節點從 1 開始編號。 LC 1135 與 LC 1168 的節點是
1..n;parent的大小要開n + 1。 - 替完全圖建一份邊清單。
n = 1000個點就有約 500,000 條邊外加一次排序; 2-1 的稠密 Prim 完全不需要邊清單。
2) LC 範例
2-1) Min Cost to Connect All Points — LC 1584 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
題目: 把平面上所有 points 連起來;連接兩點的成本是它們的曼哈頓距離
|xi - xj| + |yi - yj|。回傳最小總成本。
核心想法: 「任兩點之間恰好只有一條簡單路徑」代表要的是一棵生成樹,最便宜的那棵就是 完全圖的 MST — 每個點是一個節點,每一對點是一條邊。邊從來不會直接給你;需要時再算出來。
執行追蹤 — points = [[0,0],[2,2],[3,10],[5,2],[7,0]]:
start from point 0 (cost 0)
tree {0} cheapest way out: 0-1 = 4 take 1 total 4
tree {0,1} cheapest way out: 1-3 = 3 take 3 total 7
tree {0,1,3} cheapest way out: 3-4 = 4 take 4 total 11
tree {0,1,3,4} cheapest way out: 1-2 = 9 take 2 total 20
all 5 points in the tree -> 20
# python
# LC 1584 - Min Cost to Connect All Points
# IDEA: Prim + min-heap on the implicit complete graph
# time = O(n^2 log n), space = O(n^2)
import heapq
class Solution:
def minCostConnectPoints(self, points):
n = len(points)
in_tree = set()
min_heap = [(0, 0)] # (cost to join this point, point index)
total_cost = 0
while len(in_tree) < n:
cost, point = heapq.heappop(min_heap)
if point in in_tree:
continue # only the FIRST (cheapest) pop joins the tree
in_tree.add(point)
total_cost += cost
x1, y1 = points[point]
for other in range(n):
if other not in in_tree:
x2, y2 = points[other]
heapq.heappush(min_heap, (abs(x1 - x2) + abs(y1 - y2), other))
return total_cost
// java
// LC 1584 - Min Cost to Connect All Points
// IDEA: Prim + min-heap on the implicit complete graph
// time = O(n^2 log n), space = O(n^2)
public int minCostConnectPoints(int[][] points) {
int n = points.length;
boolean[] inTree = new boolean[n];
PriorityQueue<int[]> minHeap = new PriorityQueue<>((x, y) -> Integer.compare(x[0], y[0]));
minHeap.offer(new int[]{0, 0}); // {cost to join, point index}
int totalCost = 0;
int joined = 0;
while (joined < n) {
int[] top = minHeap.poll();
int point = top[1];
if (inTree[point]) {
continue;
}
inTree[point] = true;
totalCost += top[0];
joined++;
for (int other = 0; other < n; other++) {
if (!inTree[other]) {
int dist = Math.abs(points[point][0] - points[other][0])
+ Math.abs(points[point][1] - points[other][1]);
minHeap.offer(new int[]{dist, other});
}
}
}
return totalCost;
}
稠密 Prim — O(n²),不用 heap
需要第二種解法的理由:更好的上界。 圖是完全圖,heap 只會多帶來一個 log n 和 O(n²)
的記憶體。改成維護 min_dist[i] = 從樹連到點 i 最便宜的邊,每一輪掃描找出最小值:
n 輪 × O(n) 掃描 = O(n²) 時間、O(n) 空間 — 這裡幾種做法中最好的,對一般稠密圖也已是最佳,
因為每條邊都得看過一次。面試時在 heap 版之後點出它。
(曼哈頓距離有額外的幾何性質:有一個 O(n log n) 的演算法只保留 O(n) 條候選邊 — 每個點在 8 個
八分區各自最近的鄰居 — 再對它們跑 Kruskal。這遠超過面試的要求,但別說 O(n²) 是 LC 1584 的下界。)
# python
# LC 1584 - Min Cost to Connect All Points
# IDEA: Prim with a min_dist array instead of a heap (dense graph)
# time = O(n^2), space = O(n)
class Solution:
def minCostConnectPoints(self, points):
n = len(points)
INF = float("inf")
min_dist = [INF] * n # cheapest edge from the tree to each point
in_tree = [False] * n
min_dist[0] = 0
total_cost = 0
for _ in range(n):
# the outside point closest to the tree
closest = -1
for i in range(n):
if not in_tree[i] and (closest == -1 or min_dist[i] < min_dist[closest]):
closest = i
in_tree[closest] = True
total_cost += min_dist[closest]
# the new tree point may offer a cheaper edge to the points still outside
x1, y1 = points[closest]
for i in range(n):
if not in_tree[i]:
distance = abs(x1 - points[i][0]) + abs(y1 - points[i][1])
if distance < min_dist[i]:
min_dist[i] = distance
return total_cost
// java
// LC 1584 - Min Cost to Connect All Points
// IDEA: Prim with a minDist array instead of a heap (dense graph)
// time = O(n^2), space = O(n)
public int minCostConnectPointsDense(int[][] points) {
int n = points.length;
int[] minDist = new int[n]; // cheapest edge from the tree to each point
boolean[] inTree = new boolean[n];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[0] = 0;
int totalCost = 0;
for (int round = 0; round < n; round++) {
// the outside point closest to the tree
int closest = -1;
for (int i = 0; i < n; i++) {
if (!inTree[i] && (closest == -1 || minDist[i] < minDist[closest])) {
closest = i;
}
}
inTree[closest] = true;
totalCost += minDist[closest];
// the new tree point may offer a cheaper edge to the points still outside
for (int i = 0; i < n; i++) {
if (!inTree[i]) {
int distance = Math.abs(points[closest][0] - points[i][0])
+ Math.abs(points[closest][1] - points[i][1]);
if (distance < minDist[i]) {
minDist[i] = distance;
}
}
}
}
return totalCost;
}
Kruskal 也會過 — 產生全部 n(n-1)/2 對,再丟給
1-1 — 但在這題它是三種裡最慢的,
因為它要把每一對都拿來排序。
2-2) Connecting Cities With Minimum Cost — LC 1135
題目: n 座城市 1..n,connections[i] = [a, b, cost]。回傳把每座城市都連起來的
最小成本,做不到就回傳 -1。
核心想法: 這就是毫無偽裝的 1-1 — 一份邊清單、
一張稀疏圖,不連通時答案就是 -1。唯一的改動是城市從 1 開始編號:把它們平移到 0..n-1
(或把併查集開成 n + 1)。
# python
# LC 1135 - Connecting Cities With Minimum Cost
# IDEA: Kruskal (template 1-1), cities shifted to 0-indexed
# time = O(E log E), space = O(n + E)
class Solution:
def minimumCost(self, n, connections):
edges = [(cost, a - 1, b - 1) for a, b, cost in connections]
return kruskal(n, edges) # -1 when fewer than n - 1 edges join
2-3) Optimize Water Distribution in a Village — LC 1168 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
題目: 每間房子 i 要嘛花 wells[i-1] 自己蓋一口井,要嘛用水管接到另一間房子。
回傳讓每間房子都有水的最小總成本。
核心想法 — 虛擬節點。 「在房子 i 蓋一口井」等同於「從水源 0 拉一條水管到房子 i」,
成本是 wells[i-1]。加上這個節點和這 n 條邊,答案就是這張 n + 1 個節點的圖的 MST。
每一題「每個節點付一筆固定價錢,或把它接到鄰居」都能這樣化簡。
n = 3, wells = [1,2,2], pipes = [[1,2,1],[2,3,1]]
virtual edges: 0-1 (1) 0-2 (2) 0-3 (2)
pipe edges : 1-2 (1) 2-3 (1)
Kruskal: 0-1 (1) take, 1-2 (1) take, 2-3 (1) take -> 3 edges for 4 nodes -> cost 3
# python
# LC 1168 - Optimize Water Distribution in a Village
# IDEA: Kruskal + virtual node 0 (a well == a pipe from node 0)
# time = O((n + m) log (n + m)), space = O(n + m)
class Solution:
def minCostToSupplyWater(self, n, wells, pipes):
edges = [(cost, 0, house + 1) for house, cost in enumerate(wells)]
for a, b, cost in pipes:
edges.append((cost, a, b))
return kruskal(n + 1, edges) # n houses + the water source
2-4) Find Critical and Pseudo-Critical Edges in MST — LC 1489
題目: 把每條邊分類:每一棵 MST 都含有它就是關鍵邊(critical);某些 MST 含有它、 但不是全部,就是偽關鍵邊(pseudo-critical)。
核心想法 — 圍繞一條邊重建 MST。 先算一次 MST 的權重 best。接著對每條邊 e:
- 排除
e再重建:若圖變得不連通、或權重升到比best高,e就是關鍵邊; - 否則強制先選
e(合併它、算進它的權重)再重建:若權重仍等於best,e就是 偽關鍵邊。
在只排序一次的邊上最多跑 2E + 1 次 Kruskal — 一次算 best、每條邊一次排除、每條非關鍵邊一次強制:O(E² · α(V)),E ≤ 200 綽綽有餘。
# python
# LC 1489 - Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree
# IDEA: Kruskal with one edge excluded / forced, compared with the true MST weight
# time = O(E^2 * α(V)), space = O(V + E)
class Solution:
def findCriticalAndPseudoCriticalEdges(self, n, edges):
# keep the original index: the answer is reported by it
order = sorted(range(len(edges)), key=lambda i: edges[i][2])
def mst_weight(skip, force):
parent = list(range(n))
size = [1] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
root_a, root_b = find(a), find(b)
if root_a == root_b:
return False
if size[root_a] < size[root_b]:
root_a, root_b = root_b, root_a
parent[root_b] = root_a # union by size keeps the α(V) bound
size[root_a] += size[root_b]
return True
weight = 0
used = 0
if force != -1:
a, b, w = edges[force]
union(a, b)
weight += w
used += 1
for i in order:
if i == skip:
continue
a, b, w = edges[i]
if union(a, b):
weight += w
used += 1
return weight if used == n - 1 else float("inf")
best = mst_weight(-1, -1)
critical, pseudo = [], []
for i in range(len(edges)):
if mst_weight(i, -1) > best:
critical.append(i)
elif mst_weight(-1, i) == best:
pseudo.append(i)
return [critical, pseudo]
// java
// LC 1489 - Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree
// IDEA: Kruskal with one edge excluded / forced, compared with the true MST weight
// time = O(E^2 * α(V)), space = O(V + E)
public List<List<Integer>> findCriticalAndPseudoCriticalEdges(int n, int[][] edges) {
// sort edge INDICES by weight: the answer is reported by original index
Integer[] order = new Integer[edges.length];
for (int i = 0; i < edges.length; i++) {
order[i] = i;
}
Arrays.sort(order, (x, y) -> Integer.compare(edges[x][2], edges[y][2]));
int best = mstWeight(n, edges, order, -1, -1);
List<Integer> critical = new ArrayList<>();
List<Integer> pseudo = new ArrayList<>();
for (int i = 0; i < edges.length; i++) {
if (mstWeight(n, edges, order, i, -1) > best) {
critical.add(i);
} else if (mstWeight(n, edges, order, -1, i) == best) {
pseudo.add(i);
}
}
return Arrays.asList(critical, pseudo);
}
// MST weight with edge `skip` left out and edge `force` taken first; MAX_VALUE if disconnected
private int mstWeight(int n, int[][] edges, Integer[] order, int skip, int force) {
int[] parent = new int[n];
int[] size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
int weight = 0;
int used = 0;
if (force != -1) {
union(parent, size, edges[force][0], edges[force][1]);
weight += edges[force][2];
used++;
}
for (int i : order) {
if (i == skip) {
continue;
}
if (union(parent, size, edges[i][0], edges[i][1])) {
weight += edges[i][2];
used++;
}
}
return used == n - 1 ? weight : Integer.MAX_VALUE;
}
private boolean union(int[] parent, int[] size, int a, int b) {
int rootA = find(parent, a); // find() from template 1-1
int rootB = find(parent, b);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
int tmp = rootA; rootA = rootB; rootB = tmp;
}
parent[rootB] = rootA; // union by size keeps the α(V) bound
size[rootA] += size[rootB];
return true;
}
2-5) 相似題目
| LC | 題目 | 題型 | 備註 |
|---|---|---|---|
| 1584 | Min Cost to Connect All Points | 完全幾何圖 | Prim;稠密陣列版 Prim 是 O(n²) — 2-1 |
| 1135 | Connecting Cities With Minimum Cost | 邊清單 | 原封不動的 Kruskal,不連通回傳 -1 — 2-2 |
| 1168 | Optimize Water Distribution in a Village | 虛擬源點 | 井 = 從節點 0 出發的邊 — 2-3 |
| 1489 | Find Critical and Pseudo-Critical Edges in MST | 邊的分類 | 排除/強制選一條邊 — 2-4 |
| 3600 | Maximize Spanning Tree Stability with Upgrades | 瓶頸樹 | 二分搜尋穩定度,用併查集檢查「只用 ≥ 它的邊」是否還能組成生成樹 |
| 1102 | Path With Maximum Minimum Value | 瓶頸路徑,最大化 | Kruskal 依權重遞減排序,左上與右下角一連通就停 — Kruskal 變形 |
| 1697 | Checking Existence of Edge Length Limited Paths | 離線查詢 | 查詢和邊都排序,一起掃 — union_find_examples.md §13 |
| 1631 | Path With Minimum Effort | 瓶頸路徑 | 起點與終點一連通就停下的 Kruskal 掃描 — union_find_examples.md |
| 1167 | Minimum Cost to Connect Sticks | 不是 MST | 合併成本會重複計入已合併的長度 — 是 Huffman,一種 heap 貪心(heap.md) |
決策樹:
"connect ALL nodes, minimum total cost"?
├── no: "cheapest route from A to B" -> shortest path (Dijkstra.md)
└── yes: an MST
├── edges given as a list -> Kruskal (1-1)
├── every pair is an edge (points) -> Prim, dense array for O(n^2) (2-1)
├── "build here OR connect to a neighbour" -> add a virtual node 0, then Kruskal (2-3)
├── "which edges are in every / some MST" -> Kruskal with one edge excluded / forced (2-4)
└── "maximise the min / minimise the max" -> sort + union until connected, or
binary search + DSU feasibility (2-5)