Union Find(併查集)

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

範圍 — 併查集(disjoint set union)——無向圖的連通性、連通元件計數、環偵測,搭配路徑壓縮與按秩合併。 另見:union_find_examples.md — 支撐這些模式的十七道實戰題;diff_toposort_quickunion.md — 併查集 vs 拓撲排序,什麼題該用哪個工具;topology_sorting.md — 有向圖的排序;graph.md — 一般的圖論素材。

  • 在動態變化的圖上,有效率地判斷節點之間是否連通
  • 什麼時候用:動態連通性查詢、環偵測、MST 演算法、把元素分群
  • 代表性 LeetCode 題目:Graph Valid Tree、Number of Islands、Accounts Merge、Friend Circles
  • 資料結構:parent 陣列,加上做最佳化用的 size/rank 陣列
  • 狀態:連通元件、父子關係

時間複雜度: 加上最佳化後,每次操作近似 O(1)

LeetCode 題目清單

0) 概念

0-0) 併查集的兩種變體 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

Quick Find vs Quick Union

Quick Find:

  • Find:O(1) — 直接查陣列
  • Union:O(n) — 要更新整個元件裡的所有元素
  • 適用情境:find 次數遠多於 union 次數時
  • 實作方式:每個元素直接存自己的元件 ID
java
// Quick Find Implementation
class QuickFind {
    private int[] id;
    private int count; // number of components

    public QuickFind(int n) {
        id = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            id[i] = i; // Each element is its own component
        }
    }

    /**
     * time = O(1)
     * space = O(1)
     */
    public int find(int p) {
        return id[p]; // Direct lookup
    }

    /**
     * time = O(N)
     * space = O(1)
     */
    public void union(int p, int q) {
        int pID = find(p);
        int qID = find(q);

        if (pID == qID) return;

        // Change all entries with id[p] to id[q]
        for (int i = 0; i < id.length; i++) {
            if (id[i] == pID) {
                id[i] = qID;
            }
        }
        count--;
    }

    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }
}

Quick Union(加上最佳化):

  • Find:同時加上路徑壓縮與按秩/按大小合併,才是攤銷 O(α(n)) ≈ O(1)
  • Union:攤銷 O(α(n)) ≈ O(1),條件相同 — 只用其中一個最佳化只能到 O(log n)(見 0-1 的表)
  • 適用情境:通用;find 與 union 次數差不多時
  • 實作方式:存父節點指標,長成一棵樹

兩者比較:

操作 Quick Find Quick Union Quick Union + 最佳化
初始化 O(n) O(n) O(n)
Find O(1) 最差 O(n) O(α(n)) ≈ O(1)
Union O(n) 最差 O(n) O(α(n)) ≈ O(1)
空間 O(n) O(n) O(n)
最適合 find 很多 兩者均衡 通用

什麼時候用 Quick Find:

  • union 操作極少
  • 需要即時的 find 查詢
  • 資料量小,O(n) 的 union 還能接受

什麼時候用 Quick Union(最佳化版):

  • find 與 union 混著來、次數相當
  • 資料量大(幾百萬個元素)
  • 大多數實務情境(推薦)

0-1) 兩個關鍵最佳化 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

併查集能做到近似 O(1),靠的是兩個關鍵最佳化:

路徑壓縮:用在 find() 裡

  • 讓走過的每個節點直接指向根
  • 走訪的同時把樹壓扁
  • 遞迴寫法:parent[x] = find(parent[x])
  • 把之後的查詢攤還成 O(1)

按秩/按大小合併:用在 union() 裡

  • 永遠把小樹掛到大樹的根上
  • 讓樹高保持平衡(對數級)
  • 避免退化成一條線
  • 可以追樹高(rank),也可以追節點數(size)

沒有這兩個最佳化,操作會退化成 O(n)。只用其中一個是 O(log n);兩個一起用才是 O(α(n)),α 是反 Ackermann 函數(任何放得進記憶體的輸入都 ≤ 4,實務上等同常數)。

依使用的最佳化區分複雜度 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

N = 元素個數,M = find / union 的呼叫次數。每一列的空間都是 O(N)(parent,有用到時再加上 rank 或 size)。

最佳化 單次操作最差 每次操作攤銷 M 次操作總計(含初始化)
都沒有 O(N) O(N) O(N + M·N)
只有按秩/按大小合併 O(log N) O(log N) O(N + M log N)
只有路徑壓縮 O(N) O(log N) O(N + M log N)
兩者都有 O(log N) O(α(N)) O(N + M·α(N))

這張表講出了兩件「近似 O(1)」藏起來的事:

  • 攤銷不等於每一次呼叫。 就算兩個最佳化都用上,單次 find 仍可能走 O(log N) 條邊;α(N) 是一整串操作的平均。
  • 只有路徑壓縮時,單次呼叫仍是 O(N)。 對長度 N 的鏈第一次 find 會整條走完 — 而遞迴版 find 會遞迴 N 層,在 Python 超過約 1000 個節點就是 RecursionError。按秩/按大小合併把樹高壓在 log N,遞迴自然很淺。

大家第一次寫出來的「半最佳化」版本 — 有路徑壓縮,但 union 盲目地把一個根掛到另一個根下面:

python
# python
# IDEA: path compression only; union ignores tree size/height
# time = O(log N) amortized per op (single op O(N) worst), space = O(N)
class UnionFind(object):
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])   # path compression
        return self.parent[x]

    def union(self, a, b):
        root_a = self.find(a)
        root_b = self.find(b)
        if root_a == root_b:
            return False
        self.parent[root_a] = root_b    # blind attach: no rank/size comparison
        return True

它幾乎能過所有 LeetCode 題 — 攤銷 O(log N) 已經夠快 — 但面試時它的正確答案是 O(log N),不是 O(α(N))。要拿到 α(N),就加上 0-3 的 Python 模板裡的 rank(或 size)陣列。

面試時這樣講:

使用路徑壓縮加上按秩(或按大小)合併時,每次操作是攤銷 O(α(N)) — 反 Ackermann 函數,實務上等同常數。初始化 O(N),空間 O(N)。只有路徑壓縮時是攤銷 O(log N),而且單次呼叫仍可能是 O(N)。

0-2) 題型分類

  • 基本連通性:判斷兩點是否連通、數連通元件
  • 環偵測:判斷加上這條邊會不會產生環
  • 動態 MST:Kruskal 最小生成樹演算法
  • 加權併查集:處理節點之間的比值/權重(LC 399)
  • 格子題:二維格子的連通性(Number of Islands 系列變形)

0-3) 演算法模式/模板 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

核心操作:

  • find(x):取得 x 的根,順便做路徑壓縮
  • union(x, y):把兩個節點接起來;若本來就連通則回傳 false
  • connected(x, y):判斷兩個節點是否在同一個元件

模板(按大小合併):

java
class UnionFind {
    int[] parent, size;
    int components;

    public UnionFind(int n) {
        parent = new int[n];
        size = new int[n];
        components = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }

    // Path Compression: flatten tree by making nodes point directly to root
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // Compress path during recursion
        }
        return parent[x];
    }

    // Union by Size: attach smaller tree to larger tree
    public boolean union(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX == rootY) return false; // Already connected

        // Always attach smaller size to larger size
        if (size[rootX] < size[rootY]) {
            parent[rootX] = rootY;
            size[rootY] += size[rootX];
        } else {
            parent[rootY] = rootX;
            size[rootX] += size[rootY];
        }
        components--;
        return true;
    }
}

另一種模板(按秩合併):

java
class UnionFind {
    int[] parent, rank;
    int components;

    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        components = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 0; // Initial rank is 0
        }
    }

    // Path Compression: recursively flatten tree structure
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // Make x point directly to root
        }
        return parent[x];
    }

    // Union by Rank: attach lower rank tree to higher rank tree
    public void union(int x, int y) {
        int rootX = find(x), rootY = find(y);

        // Already in the same component
        if (rootX == rootY) return;

        // Attach smaller rank tree to larger rank tree
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY; // X's tree becomes child of Y
        } else if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX; // Y's tree becomes child of X
        } else {
            // Equal ranks: attach either way, increment rank of new root
            parent[rootX] = rootY;
            rank[rootY]++;
        }
        components--;
    }
}

Python 模板(按大小合併——乾淨的 class 寫法):

python
# python
# IDEA: path compression (in find) + union by size (attach smaller tree to larger)
# time = O(α(N)) ≈ O(1) amortized per op, space = O(N)
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))   # each node is its own root initially
        self.size = [1] * n            # size[root] = # nodes in that component
        self.components = n            # running count of components

    # Path Compression: point every visited node directly at the root
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])   # compress on the way back
        return self.parent[x]

    # Union by Size: attach the smaller tree under the larger one
    def union(self, x, y):
        root_x, root_y = self.find(x), self.find(y)
        if root_x == root_y:
            return False               # already connected (adding this edge => cycle)
        if self.size[root_x] < self.size[root_y]:
            root_x, root_y = root_y, root_x   # ensure root_x is the larger tree
        self.parent[root_y] = root_x
        self.size[root_x] += self.size[root_y]
        self.components -= 1
        return True

    def connected(self, x, y):
        return self.find(x) == self.find(y)

Python 模板(按秩合併——替代版):

python
# python
# IDEA: path compression + union by rank (approx tree height); rank++ only on equal ranks
# time = O(α(N)) ≈ O(1) amortized per op, space = O(N)
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n            # rank ~ upper bound on tree height
        self.components = n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])   # path compression
        return self.parent[x]

    def union(self, x, y):
        root_x, root_y = self.find(x), self.find(y)
        if root_x == root_y:
            return False
        # attach the lower-rank tree under the higher-rank tree
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1     # equal ranks: pick one root, bump its rank
        self.components -= 1
        return True

Python ASCII 追蹤——find(3) 過程中的路徑壓縮,鏈為 3 → 2 → 1 → 0 (root):

text
parent = [0, 0, 1, 2]        # index:  0  1  2  3

  Before find(3):            Recursion unwinds, each frame rewires parent[x] = root:

    0 (root)                   find(3) → find(2) → find(1) → find(0) returns 0
    |                          ↑ on the way back:
    1                            parent[1] = 0
    |                            parent[2] = 0
    2                            parent[3] = 0
    |
    3

  After find(3):             parent = [0, 0, 0, 0]

         0 (root)            # tree flattened: every node now points straight to root 0
       / | \                 # any later find() on 1/2/3 is O(1)
      1  2  3

Size 與 Rank 的關鍵差異

  • 按大小合併:追每棵樹實際的節點數
    • 需要知道元件大小時很好用
    • 每次 union 之後都要更新 size
  • 按秩合併:追樹高的近似值(上界)
    • 更省空間(rank 增長很慢)
    • 只有在合併兩棵秩相同的樹時,秩才會加一
    • 有路徑壓縮之後,rank 就不等於真正的樹高了

邊界情況:

  • 只有單一節點的圖
  • 本來就已經連通的節點
  • 不合法的索引

0-4) 併查集的六大模式 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

底下每一題都是同一個模板,差別只在對這個問題的答案不同:「什麼算一個節點,什麼時候把兩個節點合併?」 難的全在這個問題上;DSU 本身從頭到尾都沒變。

# 模式 節點是什麼 什麼時候合併 題解在
1 環偵測 一個頂點 每條邊都做——union 回傳 false 的那一刻,就是環 1) LC 684、5) LC 261
2 元件計數 一個頂點 每條邊都做;union 成功就把計數器減一 6) LC 323、4) LC 547
3 多餘的邊 一個頂點 同上,但要回傳第一條 union 失敗的邊 1) LC 684、2) LC 685
4 二維格子連通性 一個格子,壓平成 row * cols + col 只跟右邊與下面的鄰居合併,這樣每一對只會被考慮一次 9) LC 200、10) LC 130
5 加權併查集 一個變數 邊上帶著到父節點的比值,路徑壓縮時一路相乘 12) LC 399
6 依共用屬性合併 一筆紀錄的 id——或屬性本身 某筆紀錄出現了一個先前已有主人的屬性時(first_owner 對照表) Template 6: LC 721、15) LC 721

另外兩種結構性變形,改的是什麼時候跑演算法,而不是合併什麼:

  • 排序邊(Kruskal 風格) — 由便宜到貴依序處理邊,一旦你在意的那兩個端點連上就停。13) LC 1631。
  • 離線倒放 — 併查集不能拆開,所以當題目在移除東西時,就把過程倒過來重播、改成一個個加回去。14) LC 803。

Template 6: 依共用屬性合併 — LC 721 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

Recognise — 「兩個 X 若共用某個 Y 就屬於同一組」(一個 email、一列或一行、一個質因數、一個同義詞),而且這個關係有遞移性:A 跟 B 共用、B 跟 C 共用,那 A 和 C 就是同一組,即使它們之間什麼都沒共用。題目不給邊——邊是由「值相等」隱含出來的。n ≤ 10³–10⁵ 筆紀錄、每筆只有少數幾個屬性 → 任何兩兩比對(O(n²) 次紀錄比較)都是陷阱;預期是 O(屬性總數)。

Key point — 永遠不要拿紀錄彼此比對。把屬性走一遍,維護一個 first_owner[attribute] = record id 對照表:第一筆出現某屬性的紀錄擁有它,之後任何出現同一屬性的紀錄都跟這個主人 union。所以一個共用屬性就是一次 union,遞移性由 DSU 免費提供。

節點是什麼——唯一要做的決定。 DSU 裝哪一種東西都可以,兩種都正確;差別在於你得一路帶著什麼到最後:

節點是 Union 什麼 需要的對照表 答案的標籤從哪來 適用時機
紀錄 id(帳號索引) 目前的紀錄跟 first_owner[attr] attr → 第一個主人 id root 自己那筆紀錄(accounts[root][0]) 紀錄有索引;用陣列 DSU,不用字串當 key
屬性(email 本身) 一筆紀錄的每個屬性跟該紀錄的第一個屬性(星狀) attr → 標籤,外加以屬性為 key 的 dict DSU 組內任一屬性(email_to_name[root]) 沒有索引——屬性就是身分;當輸入直接給你成對的值時,這也是唯一的選擇(LC 737 的單字、LC 1061 的字母)
兩者,放在同一個 DSU 紀錄 id 跟它的每個屬性,屬性位移到自己的 id 區段 除了位移之外不需要 — 屬性值的範圍小且已知(LC 947:第 c 行變成節點 c + 10001)

下面先是紀錄 id 的寫法(Python 與 Java),接著是同一題以屬性當節點的寫法。這張卡是 LC 721 唯一的題解所在;範例文件只指向這裡。

python
# python
# LC 721 - Accounts Merge
# IDEA: a node is an ACCOUNT index; email -> first account that showed it; a repeat email
#       unions the current account with that owner. Group emails by the owner's root.
# time = O(E log E), space = O(E)   E = total emails; the per-group sort dominates
from collections import defaultdict


class Solution(object):
    def accountsMerge(self, accounts):
        parent = list(range(len(accounts)))
        size = [1] * len(accounts)

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]    # path halving
                x = parent[x]
            return x

        def union(a, b):
            root_a, root_b = find(a), find(b)
            if root_a == root_b:
                return
            if size[root_a] < size[root_b]:      # hang the smaller tree under the larger
                root_a, root_b = root_b, root_a
            parent[root_b] = root_a
            size[root_a] += size[root_b]

        # the bridge between the two kinds of thing: attribute -> first owner id
        email_owner = {}
        for i, account in enumerate(accounts):
            for email in account[1:]:
                if email in email_owner:
                    union(i, email_owner[email])   # shared attribute => same person
                else:
                    email_owner[email] = i

        # every email is filed once, under the root of the account that first showed it
        groups = defaultdict(list)
        for email, owner in email_owner.items():
            groups[find(owner)].append(email)

        merged = []
        for root, emails in groups.items():
            merged.append([accounts[root][0]] + sorted(emails))
        return merged
java
// java
// LC 721 - Accounts Merge
// IDEA: node = account index; email -> first owner; a repeat email unions the two accounts
// time = O(E log E), space = O(E)   E = total emails
public List<List<String>> accountsMerge(List<List<String>> accounts) {
    int n = accounts.size();
    int[] parent = new int[n];
    int[] size = new int[n];
    for (int i = 0; i < n; i++) {
        parent[i] = i;
        size[i] = 1;
    }
    Map<String, Integer> emailOwner = new HashMap<>();
    for (int i = 0; i < n; i++) {
        List<String> account = accounts.get(i);
        for (int j = 1; j < account.size(); j++) {
            String email = account.get(j);
            Integer owner = emailOwner.get(email);
            if (owner == null) {
                emailOwner.put(email, i);
            } else {
                union(parent, size, i, owner);   // shared email => same person
            }
        }
    }
    Map<Integer, List<String>> groups = new HashMap<>();
    for (Map.Entry<String, Integer> entry : emailOwner.entrySet()) {
        int root = find(parent, entry.getValue());
        groups.computeIfAbsent(root, k -> new ArrayList<>()).add(entry.getKey());
    }
    List<List<String>> merged = new ArrayList<>();
    for (Map.Entry<Integer, List<String>> entry : groups.entrySet()) {
        List<String> emails = entry.getValue();
        Collections.sort(emails);
        List<String> row = new ArrayList<>();
        row.add(accounts.get(entry.getKey()).get(0));
        row.addAll(emails);
        merged.add(row);
    }
    return merged;
}

private int find(int[] parent, int x) {
    while (parent[x] != x) {
        parent[x] = parent[parent[x]];   // path halving
        x = parent[x];
    }
    return x;
}

private void union(int[] parent, int[] size, int a, int b) {
    int rootA = find(parent, a);
    int rootB = find(parent, b);
    if (rootA == rootB) {
        return;
    }
    if (size[rootA] < size[rootB]) {     // hang the smaller tree under the larger
        int tmp = rootA;
        rootA = rootB;
        rootB = tmp;
    }
    parent[rootB] = rootA;
    size[rootA] += size[rootB];
}

以 email 當節點。 同一題,DSU 裡完全不放帳號索引:一個帳號的每個 email 都跟該帳號的第一個 email union(星狀,len(account) - 1 次 union,而不是兩兩相連),名字則從 email 讀回來,因為到最後已經沒有索引可用。

python
# python
# LC 721 - Accounts Merge (the node is the EMAIL itself)
# IDEA: union every email of an account with that account's first email; a shared email
#       then joins two accounts' stars into one component. Group emails by root.
# time = O(E log E), space = O(E)   E = total emails; the per-group sort dominates
from collections import defaultdict


class Solution(object):
    def accountsMerge(self, accounts):
        parent = {}            # email -> parent email; keys are created on first sight
        size = {}
        email_to_name = {}

        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
            if size[root_a] < size[root_b]:
                root_a, root_b = root_b, root_a
            parent[root_b] = root_a
            size[root_a] += size[root_b]

        for account in accounts:
            name = account[0]
            first_email = account[1]
            for email in account[1:]:
                if email not in parent:
                    parent[email] = email
                    size[email] = 1
                email_to_name[email] = name
                union(first_email, email)      # star: one union per email links the account

        groups = defaultdict(list)
        for email in parent:
            groups[find(email)].append(email)

        merged = []
        for root, emails in groups.items():
            # no account index survives, so the name is read off any email in the group
            merged.append([email_to_name[root]] + sorted(emails))
        return merged

Complexity

  • 時間 O(E log E),E = email 總數:屬性迴圈對每個 email 做一次 dict 查詢、至多一次 union,合計 O(E · α(n));對各組做 sorted(emails)——各組恰好把 E 個不同的 email 分割開——這個 O(E log E) 才是主導項。
  • 空間 O(E):email_owner 每個不同的 email 存一次;parent / size 是 O(n) ≤ O(E)。 以 email 當節點的寫法界限相同,只是 parent 改以 E 個 email 為 key。

Dry run

text
accounts = [John a x] [John y z] [John x y] [Mary m]      ids 0 1 2 3

i=0  a -> owner 0        x -> owner 0
i=1  y -> owner 1        z -> owner 1
i=2  x seen (owner 0) -> union(2, 0)    y seen (owner 1) -> union(2, 1)   => {0,1,2}
i=3  m -> owner 3

groups by find(owner):  root{0,1,2}: a x y z      root 3: m
-> [John a x y z] [Mary m]        accounts 0 and 1 share nothing, yet merge through 2

Traps

  • 用帳號分組,而不是用 email_owner。 走訪 accounts、把每個 email 掛到 find(i) 底下,會讓共用的 email 被收兩次([John a x x y y z]);要從主人對照表把每個 email 只歸檔一次,或改用 set 收集。
  • 用名字合併。 兩個沒有共同 email 的 John 是兩個人——LC 721 範例 1 就把 johnnybravo 分開。名字只是要印出的標籤,絕不是 union 的 key。
  • 兩兩比對帳號找共同 email:O(n² · k),而且遞移那一步仍然需要 DSU 或 BFS。主人對照表讓每個共用 email 都只是一次 O(1) 的 union。
  • 在長鏈上用遞迴 find 或 DFS。 1000 個帳號、每個跟下一個共用一個 email,就是 1000 層深的路徑;Python 預設遞迴上限是 1000。上面的迴圈寫法,或 DFS 用顯式 stack,都不受影響。
  • 屬性當節點時 id 區段重疊(LC 947):第 5 列和第 5 行是不同的東西。把其中一個區段位移(col + 10001),或用 ('r', 5) / ('c', 5) 當 dict 的 key。

Not this if — 那個「共用屬性」是必須檢驗的相似性,而不是可以 hash 的相等值(LC 839:兩個字串相同、或恰好差兩個位置,才相連)。沒有主人對照表可建;就是 O(n² · L) 的兩兩檢查,再做一般的元件計數。而當屬性帶有方向或順序(a 必須在 b 之前),那是拓樸排序,不是併查集。

Drill — 先是同一招換一個屬性,再來是「連結直接給你」的近親題。只有第一組需要 first_owner:那裡的連結是從相等的值找出來的。第二組的輸入已經把成對關係寫明,直接 union 即可,這張卡在那裡教的只有節點選擇(值本身就是節點):

LC 兩者如何相連 節點選擇
721 Accounts Merge 共用值:一個 email 帳號 id(或 email)
947 Most Stones Removed with Same Row or Column 共用值:一列或一行 列與行,彼此位移錯開
952 Largest Component Size by Common Factor 共用值:一個質因數,透過 first_owner[factor] 數字的索引
2709 Greatest Common Divisor Traversal 共用值:一個質因數,同 952 索引;答案 = 只有一個元件
990 Satisfiability of Equality Equations 明確成對:先 union 所有 a==b,再拿完成的 DSU 檢查每個 a!=b 變數本身——26 個字母,一個陣列
1061 Lexicographically Smallest Equivalent String 明確成對:s1[i] ~ s2[i] 字母本身,root = 組內最小者
737 Sentence Similarity II 明確成對:每一組相似詞對 單字本身,dict DSU

LC 721 · LC 990 · LC 1061 · LC 737 · LC 947 · LC 952 · LC 2709,由易到難。

1) 範例題目與程式碼索引

基本連通性與元件計數

  • LC 200 – Number of Islands:數二維格子上的連通元件

    • Java:leetcode_java/src/main/java/LeetCodeJava/DFS/NumberOfIslands.java:493
    • 模式:格子壓成一維(row * cols + col),併查集配四方向檢查
  • LC 261 – Graph Valid Tree:檢查 n-1 條邊是否剛好形成一個元件

    • Java:leetcode_java/src/main/java/LeetCodeJava/BFS/GraphValidTree.java:36
    • 模式:環偵測,加上「邊數必須剛好是 n-1」的驗證
  • LC 323 – Number of Connected Components:基本的元件計數

    • Java:leetcode_java/src/main/java/LeetCodeJava/Graph/NumberOfConnectedComponentsUndirectedGraph.java:49
    • 模式:追蹤元件數,union 成功就減一

環偵測與多餘邊

  • LC 684 – Redundant Connection:找出在樹上造成環的那條邊
    • Java:leetcode_java/src/main/java/LeetCodeJava/Tree/RedundantConnection.java:50
    • 模式:回傳第一條讓 union() 失敗的邊(偵測到環)— 這條邊同時也是環上在輸入中最後出現的邊,正是題目要求的平手規則(原因)

加權併查集

  • LC 399 – Evaluate Division:用帶比值的加權併查集解方程式
    • Java:leetcode_java/src/main/java/LeetCodeJava/DFS/EvaluateDivision.java:421
    • 模式:存比值,路徑壓縮時把比值一路相乘

進階應用

  • LC 130 – Surrounded Regions:用一個虛擬節點串起所有邊界區域
  • LC 547 – Friend Circles:在朋友關係矩陣裡找分群
  • LC 721 – Accounts Merge:依共用的 email 把帳號分群——Template 6 的錨點題
  • LC 886 – Possible Bipartition:偵測二分圖的衝突
  • LC 1135 – Connecting Cities:用 Kruskal 演算法求 MST
  • LC 1319 – Network Connections:把所有節點連起來所需的最少操作數
  • LC 2316 – Count Unreachable Pairs of Nodes:元件大小 + 邊掃邊算剩餘量的跨元件配對數(見 §2-13)

排序邊/離線併查集

  • LC 1631 – Path With Minimum Effort:Kruskal 掃描,最小化最大邊(見 §2-14)
  • LC 778 – Swim in Rising Water:同一種掃描,只是權重掛在格子上(見 §2-14)
  • LC 1697 – Checking Existence of Edge Length Limited Paths:查詢先依 limit 排序的離線做法(見 §2-14)
  • LC 803 – Bricks Falling When Hit:離線倒放 DSU + 虛擬屋頂節點(見 §2-15)

帶大小資訊與有向圖的變形

  • LC 827 – Making A Large Island:維護大小 + 翻轉時只算相異的鄰居根(見 §2-16)
  • LC 685 – Redundant Connection II:有向圖,兩個候選逐一排除(見 §2-17)
  • LC 1971 – Find if Path Exists in Graph:最基本的「把所有邊 union 起來,再做一次連通性查詢」

2) 圖解

基本 union 操作

text
Initial: [0] [1] [2] [3] [4]

After union(0,1): [0,1] [2] [3] [4]
                   1
                  /
                 0

After union(2,3): [0,1] [2,3] [4]
                   1     3
                  /     /
                 0     2

路徑壓縮圖解

text
Before find(1):           After find(1):
     4 (root)                4 (root)
     |                      /|\
     3                     1 2 3
     |
     2
     |
     1

Call find(1):
- 1 → 2 → 3 → 4 (traversal)
- During return, compress: parent[1] = 4, parent[2] = 4, parent[3] = 4
- Result: All nodes point directly to root

按秩合併範例

text
Initial state:
  0     1     2     3
rank: 0 0 0 0

union(0, 1):         union(2, 3):
    0                    2
   /                    /
  1                    3
rank[0] = 1         rank[2] = 1

union(0, 2):
    0 (rank=2)
   / \
  1   2
     /
    3

Why rank increased:
- rank[0] = 1, rank[2] = 1 (equal)
- Attach 2 to 0, increment rank[0] to 2

路徑壓縮實際運作

text
Scenario: find(A) in chain A→B→C→D→E (root)

Step 1: Recursive calls
  find(A) calls find(B)
    find(B) calls find(C)
      find(C) calls find(D)
        find(D) calls find(E)
          find(E) returns E

Step 2: Path compression during return
  parent[D] = E
  parent[C] = E  ← Compression happens here
  parent[B] = E  ← Skip intermediate nodes
  parent[A] = E  ← Direct link to root

Result:
Before:  A → B → C → D → E
After:   A → E
         B → E
         C → E
         D → E

3) 技巧與陷阱 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

常見錯誤:

  1. 忘記做路徑壓縮:時間會變成 O(n),而不是近似 O(1)

    java
    // WRONG: No path compression
    public int find(int x) {
        while (parent[x] != x) x = parent[x];
        return x;
    }
    
    // CORRECT: With path compression
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // Flatten on return
        }
        return parent[x];
    }
    
  2. 沒有維護元件數:union 裡漏掉減一

    java
    // WRONG: Forgot to decrement
    public void union(int x, int y) {
        parent[find(x)] = find(y);
    }
    
    // CORRECT: Track components
    public void union(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
            components--; // Important!
        }
    }
    
  3. 索引搞混:0-based 跟 1-based 混用

  4. 環偵測的時機:在 union 之後才檢查,而不是之前

  5. 更新錯父節點:union 時更新了節點本身,而不是它的根

    java
    // WRONG: Update x directly
    parent[x] = parent[y];
    
    // CORRECT: Update roots
    int rootX = find(x), rootY = find(y);
    parent[rootX] = rootY;
    
  6. 把 rank 跟 size 搞混:

    • Rank = 樹高的近似值(只有在合併兩棵秩相同的樹時才增加)
    • Size = 實際節點數(每次都加上被併進來的那一份)

怎麼最佳化:

  • find 裡一定要做路徑壓縮
  • 按大小/按秩合併,讓樹保持平衡
  • 維護元件數,查詢時可以直接回答
  • 用迭代版 find,省掉遞迴開銷

空間與時間的取捨:

  • 陽春版 UF:O(n) 空間,每次操作 O(n) 時間
  • 加上最佳化:O(n) 空間,每次操作 O(α(n)) ≈ O(1) 時間
  • α(n) 是反 Ackermann 函數,在實務輸入規模下等同常數

關鍵模式:

  1. 環偵測:if (find(x) == find(y)) return false; // cycle
  2. 元件計數:union 成功時把計數減一
  3. 二維格子壓成一維:座標轉換用 row * cols + col
  4. 虛擬節點:把邊界元素接到一個虛擬節點上,處理起來更簡單
  5. 加權關係:對方程式類的題目,在邊上存比值/距離

什麼時候「不要」用併查集:

  • 靜態的圖,DFS/BFS 就夠了
  • 本來就已經是一棵樹的 parent[] 陣列(例如 parent[0] = -1,LC 4015 Weighted Sum of a Tree)。它看起來像 DSU 陣列,但沒有 union() 就沒有東西要合併——直接記憶化 depth[x] = depth[parent[x]] + 1 然後往上爬就好。走法跟帶路徑壓縮的 find() 一樣,但完全不用那些簿記。見 dfs_advanced.md → Template 8。
  • 要求最短路徑(改用 Dijkstra/Floyd-Warshall)
  • 有向圖的強連通元件(改用 Tarjan)
  • 圖很小,直接查鄰接關係就夠了

實戰題解

十七道題收在 union_find_examples.md,是依「節點代表什麼」分組,而不是依題號:

分組 題目
環偵測與多餘邊 LC 684, 685, 990
元件計數與連通性 LC 547, 261, 323, 1319, 2316
格子題 LC 200, 130, 827
加權、排序邊與離線變形 LC 399, 1631, 803
併查集用在其他結構上 LC 721, 1202, 947