Union Find(併查集)
範圍 — 併查集(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
// 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
# 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):把兩個節點接起來;若本來就連通則回傳 falseconnected(x, y):判斷兩個節點是否在同一個元件
模板(按大小合併):
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;
}
}
另一種模板(按秩合併):
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
# 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
# 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):textparent = [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
# 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
// 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
# 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
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),併查集配四方向檢查
- Java:
-
LC 261 – Graph Valid Tree:檢查 n-1 條邊是否剛好形成一個元件
- Java:
leetcode_java/src/main/java/LeetCodeJava/BFS/GraphValidTree.java:36 - 模式:環偵測,加上「邊數必須剛好是 n-1」的驗證
- Java:
-
LC 323 – Number of Connected Components:基本的元件計數
- Java:
leetcode_java/src/main/java/LeetCodeJava/Graph/NumberOfConnectedComponentsUndirectedGraph.java:49 - 模式:追蹤元件數,union 成功就減一
- Java:
環偵測與多餘邊
- LC 684 – Redundant Connection:找出在樹上造成環的那條邊
- Java:
leetcode_java/src/main/java/LeetCodeJava/Tree/RedundantConnection.java:50 - 模式:回傳第一條讓
union()失敗的邊(偵測到環)— 這條邊同時也是環上在輸入中最後出現的邊,正是題目要求的平手規則(原因)
- Java:
加權併查集
- LC 399 – Evaluate Division:用帶比值的加權併查集解方程式
- Java:
leetcode_java/src/main/java/LeetCodeJava/DFS/EvaluateDivision.java:421 - 模式:存比值,路徑壓縮時把比值一路相乘
- Java:
進階應用
- 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 操作
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
路徑壓縮圖解
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
按秩合併範例
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
路徑壓縮實際運作
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 — 高價值 — 這裡有缺口就會掉關
常見錯誤:
-
忘記做路徑壓縮:時間會變成 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]; } -
沒有維護元件數: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! } } -
索引搞混:0-based 跟 1-based 混用
-
環偵測的時機:在 union 之後才檢查,而不是之前
-
更新錯父節點:union 時更新了節點本身,而不是它的根
java// WRONG: Update x directly parent[x] = parent[y]; // CORRECT: Update roots int rootX = find(x), rootY = find(y); parent[rootX] = rootY; -
把 rank 跟 size 搞混:
- Rank = 樹高的近似值(只有在合併兩棵秩相同的樹時才增加)
- Size = 實際節點數(每次都加上被併進來的那一份)
怎麼最佳化:
- find 裡一定要做路徑壓縮
- 按大小/按秩合併,讓樹保持平衡
- 維護元件數,查詢時可以直接回答
- 用迭代版 find,省掉遞迴開銷
空間與時間的取捨:
- 陽春版 UF:O(n) 空間,每次操作 O(n) 時間
- 加上最佳化:O(n) 空間,每次操作 O(α(n)) ≈ O(1) 時間
- α(n) 是反 Ackermann 函數,在實務輸入規模下等同常數
關鍵模式:
- 環偵測:
if (find(x) == find(y)) return false; // cycle - 元件計數:union 成功時把計數減一
- 二維格子壓成一維:座標轉換用
row * cols + col - 虛擬節點:把邊界元素接到一個虛擬節點上,處理起來更簡單
- 加權關係:對方程式類的題目,在邊上存比值/距離
什麼時候「不要」用併查集:
- 靜態的圖,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 |