Minimum Spanning Tree(最小生成樹,MST)

圖論優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 無向加權圖上的最小生成樹:切割性質、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) 題型分類

  1. 經典的加權邊清單 — 邊直接給你;原封不動跑 Kruskal(LC 1135)
  2. 完全幾何圖 — 每一對點都是一條邊,權重是某種距離(LC 1584)
  3. 虛擬源點 — 「在某個節點蓋一座站,或把它接到某座站」:加一個節點 0,它連到節點 i 的邊權就是蓋站的價錢(LC 1168)
  4. 邊的分類 — 哪些邊在每一棵 MST 裡/在某些 MST 裡:排除或強制選某一條邊後重建 MST(LC 1489)
  5. 瓶頸樹 — 最大化生成樹裡最弱的邊/最小化最強的邊:排序後逐條合併,或二分搜尋 + 併查集檢查可行性(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 — 必備 — 幾乎每一輪面試都會出現

步驟:

  1. 把所有邊依權重排序,最便宜的在前。
  2. 對每條邊 (w, a, b):若 a 與 b 已在同一個連通分量,就跳過(會形成環);否則把它們合併並加上 w。
  3. 選滿 n - 1 條邊就停。最後不到 n - 1 條 → 圖不連通。
python
# 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
// 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 追蹤一次,方便比較兩種 處理順序 — 兩者最後得到同一棵樹。

text
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
# 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 — 必備 — 幾乎每一輪面試都會出現

步驟:

  1. 推入 (0, start) — 第一個節點加入不用花錢。
  2. 彈出最便宜的 (cost, node)。若 node 已經在樹裡就跳過 — 它稍早已用更便宜的價錢加入了。
  3. 否則把它加入樹、加上 cost,並對每條連到樹外鄰居的邊推入 (w, neighbour)。
  4. 全部 n 個節點都進來就停。在那之前 heap 就空了 → 圖不連通。
python
# 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
// 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 從不看全域的邊順序 — 只看離開目前這棵樹最便宜的那條邊:

text
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
# 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:

text
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]]:

text
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
# 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
// 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
# 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
// 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
# 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。 每一題「每個節點付一筆固定價錢,或把它接到鄰居」都能這樣化簡。

text
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
# 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
# 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
// 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)

決策樹:

text
"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)