編碼 · 資料結構
並查集(Union-Find)教學:原理、程式碼與時間複雜度
簡答
並查集(union-find)又稱不相交集合聯集(disjoint set union,DSU),用來追蹤被分成互不重疊群組的元素。它支援兩個操作:find(x) 回傳 x 所屬群組的代表元素,union(x, y) 合併 x 與 y 所在的群組。每個群組都以父指標組成的樹來儲存。搭配路徑壓縮,以及按秩或按大小合併,任何一連串操作的均攤時間幾乎都是每次常數時間。
並查集解決什麼問題
想像一個電腦網路,纜線被一條一條加進來,而每加一條,你都得回答「電腦 A 和電腦 B 有連通嗎?」。每個問題都跑一次圖搜尋,每次要花 O(n + m)。並查集則在加入纜線的同時維護連通的群組,因此每個問題幾乎都能在 O(1) 內回答。
同樣的需求也出現在 Kruskal 最小生成樹演算法(這兩個端點是否已屬於同一棵樹?)、偵測無向圖中的環、把共用同一個電子郵件的帳號分組、滲流(percolation)模擬,以及影像處理中標記連通區域。
用白話理解
每個元素都指向一個父節點。指向自己的元素就是根,而根就是整個群組的代表。要找到 x 所屬的群組,就沿著父指標一路走到根。要合併兩個群組,就找出兩個根,讓其中一個根指向另一個。
兩項最佳化可以讓樹保持扁平:
- 按秩(或按大小)合併:把較矮(或較小)的樹接到較高(或較大)的樹底下,這樣只有在兩棵相等的樹合併時,樹的高度才會增加。
- 路徑壓縮:在
find的過程中,讓路徑上的每個節點都直接指向根,下次查找就只需一步。
實例演練:五個元素
從元素 0 到 4 開始,每個元素各自成一組。父陣列是 [0, 1, 2, 3, 4]。我們使用按大小合併,大小相等時讓第二個根成為子節點。
| 操作 | 找到的根 | 之後的父陣列 | 群組 |
|---|---|---|---|
| 開始 | - | [0, 1, 2, 3, 4] | {0} {1} {2} {3} {4} |
| union(0, 1) | 0 和 1,大小相等 | [0, 0, 2, 3, 4] | {0, 1} {2} {3} {4} |
| union(2, 3) | 2 和 3,大小相等 | [0, 0, 2, 2, 4] | {0, 1} {2, 3} {4} |
| union(1, 3) | 0 和 2,大小相等 | [0, 0, 0, 2, 4] | {0, 1, 2, 3} {4} |
| find(3) | 3 到 2 到 0,根為 0 | [0, 0, 0, 0, 4] | 路徑已壓縮:3 現在直接指向 0 |
| connected(1, 4) | 根為 0 和 4 | 不變 | False,不同群組 |
Python 實作
這個版本使用按大小合併,以及帶路徑壓縮的迭代式 find,所以不會有遞迴深度的問題。當兩個元素原本就在同一組時,union 會回傳 False,這對偵測環很有用。
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.groups = n
def find(self, x):
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # path compression
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # ra is the larger root
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
uf = UnionFind(5)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 3)
print(uf.find(3), uf.parent) # 0 [0, 0, 0, 0, 4]
print(uf.connected(1, 4), uf.groups) # False 2範例:偵測無向圖中的環
逐一處理每條邊。如果一條邊的兩個端點已經有相同的根,這條邊就會形成環。Kruskal 演算法在依權重排序邊之後,用的正是這個檢查。
def has_cycle(n, edges):
uf = UnionFind(n)
for a, b in edges:
if not uf.union(a, b):
return True
return False
print(has_cycle(4, [(0, 1), (1, 2), (2, 3)])) # False
print(has_cycle(4, [(0, 1), (1, 2), (2, 0)])) # True時間與空間複雜度
| 版本 | find / union(每次操作的最差情況) | 對 n 個元素做 m 次操作 |
|---|---|---|
| 無任何最佳化 | O(n) | O(m n),樹可能退化成鏈 |
| 只有按秩或按大小合併 | O(log n) | O(m log n) |
| 只有路徑壓縮 | 均攤 O(log n) | 均攤 O(m log n) |
| 兩項最佳化都有 | 均攤 O(α(n)) | O(m α(n)) |
α(n) 是反 Ackermann 函數,成長得極其緩慢,對於真實電腦可能容納的任何 n,它最多只有 4。實務上這代表每次操作都是常數時間。Robert Tarjan 在 1975 年證明了這個界限。父陣列與大小陣列的空間為 O(n)。
變體與常見錯誤
- 比較元素而不是比較根:
union必須連接find(a)與find(b),而不是a和b本身。 - 以為
parent[x]就是代表元素。經過多次 union 之後,只有find(x)才是正確的;在依父節點分組之前,要先對每個元素呼叫find。 - 在沒有按秩合併的情況下,對大型輸入使用遞迴的
find可能碰到 Python 的遞迴上限;上面的迭代版本可以避免這個問題。 - 元素不是 0 到 n-1 的整數:先用
dict把它們對應到索引,或把父節點存在dict中,並以延遲方式新增元素。 - 並查集無法拆分群組。如果邊被刪除,就需要不同的資料結構,或是以反向處理刪除的離線方法。
- 帶權並查集會在每個節點上儲存到父節點的偏移量,可以回答像「a 比 b 重多少?」這樣的問題。
何時使用,以及常見問題
當群組只會合併,而你需要詢問兩個元素是否在同一組,或一共有多少個群組時,請使用並查集。對於靜態圖上的單一連通性問題,單純的 BFS 或 DFS 更簡單。如果需要實際的路徑,請改用圖搜尋,因為並查集只知道成員關係。
常見題目:連通元件的數量、島嶼數量(以網格儲存格為元素)、冗餘連接(形成環的那條邊)、帳號合併、像 a == b, b != c 這種等式方程式的可滿足性,以及 Kruskal 最小生成樹。
常見問題
union-find 和 DSU 有什麼差別?
它們是同一種資料結構的兩個名稱。不相交集合聯集(DSU)和不相交集合森林(disjoint-set forest)也是常見的稱呼。
為什麼並查集幾乎是 O(1)?
搭配按秩合併與路徑壓縮,每次操作的均攤成本是 O(α(n)),其中 α 是反 Ackermann 函數。對任何實際的輸入規模,它都不會超過 4。
按秩合併和路徑壓縮需要兩個都用嗎?
只用其中一個就能得到均攤 O(log n) 的表現,通常已經夠快。兩個都用則能得到近乎常數的界限,而且只多幾行程式碼。
並查集能告訴我某個群組有哪些成員嗎?
不能直接做到。你可以對每個元素呼叫 find,再依根分組,成本為 O(n α(n));或是為每個根維護一份成員串列,並在 union 時合併這些串列。
該用按秩合併還是按大小合併?
兩者的漸近界限相同。通常偏好按大小合併,因為每個群組的大小本身就是有用的資訊。