Disjoint Set (Union-Find)Disjoint Set (Union-Find)
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Mô hình: partition của một tập hợp
- Biểu diễn bằng forest
- Cài đặt ngây thơ, và tại sao nó là O(n)
- Tối ưu 1 — union by rank / union by size
- Tối ưu 2 — path compression
- Cận kết hợp — O(α(n)) và α là gì
- Cài đặt đầy đủ
- Bảng tổng hợp độ phức tạp
- Khái niệm chính
- Ứng dụng 1 — Minimum spanning tree của Kruskal
- Ứng dụng 2 — Connected components và incremental connectivity
- Ứng dụng 3 — Phát hiện cycle trong undirected graph
- Ứng dụng 4 — Bài toán grid (“number of islands”)
- Ứng dụng 5 — Image segmentation
- Biến thể — weighted / parity union-find
- Phần tử không phải số nguyên
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- The model: a partition of a set
- The forest representation
- The naive implementation, and why it is O(n)
- Optimization 1 — union by rank / union by size
- Optimization 2 — path compression
- The combined bound — O(α(n)) and what α is
- Full implementation
- Complexity summary
- Key Concepts
- Application 1 — Kruskal’s minimum spanning tree
- Application 2 — connected components and incremental connectivity
- Application 3 — cycle detection in an undirected graph
- Application 4 — grid problems (“number of islands”)
- Application 5 — image segmentation
- Variant — weighted / parity union-find
- Non-integer elements
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Disjoint-set — còn gọi là union-find hay merge-find set — theo dõi một partition của một tập hợp: một tập các phần tử được chia thành những nhóm không chồng lấn, trong đó mỗi phần tử thuộc về đúng một nhóm. Nó chỉ trả lời hai câu hỏi và không làm gì khác:
find(x)—xđang nằm trong nhóm nào?union(a, b)— gộp nhóm chứaavới nhóm chứab.
Đó là một interface rất nhỏ, và chính sự hạn hẹp đó là điểm mấu chốt. Union-find là câu trả lời cho câu hỏi “hai thứ này có kết nối với nhau không?” khi các kết nối liên tục xuất hiện và không bao giờ biến mất. Hầu hết các cấu trúc graph trả lời câu hỏi connectivity bằng cách duyệt — một lần BFS hoặc DFS từ a tốn O(V + E) cho mỗi lần hỏi. Union-find trả lời cùng câu hỏi đó trong thời gian gần như hằng số, vì nó không duyệt gì cả: nó duy trì câu trả lời một cách tăng dần ngay khi các edge xuất hiện.
Phần đáng chú ý nhất là độ phức tạp. Với hai tối ưu nhỏ — union by rank/size và path compression — một chuỗi m phép toán trên n phần tử tốn O(m · α(n)), trong đó α là hàm Ackermann ngược (inverse Ackermann). α(n) tăng chậm đến mức nó không vượt quá 4 với bất kỳ n nào bạn có thể lưu trữ được về mặt vật lý, nên trên thực tế mỗi phép toán là hằng số. Robert Tarjan chứng minh năm 1975 rằng cận này là chặt — không cấu trúc dựa trên pointer nào làm tốt hơn được — điều này khiến union-find là một trong số ít data structure mà tính tối ưu đã được giải quyết dứt điểm.
“Khách hàng” nổi tiếng nhất của nó là thuật toán Kruskal cho minimum spanning tree (xem ./15-minimum-spanning-trees.md), nhưng nó xuất hiện ở mọi nơi mà “gộp hai nhóm này” là thao tác tự nhiên: connected components, phát hiện cycle trong undirected graph, image segmentation, percolation, các bài toán grid kiểu “number of islands”, và việc quản lý equivalence class trong type checker và compiler.
Kiến thức nền tảng
Mô hình: partition của một tập hợp
Về mặt hình thức, với một universe S = {0, 1, …, n−1}, một partition là một họ các tập con S₁, S₂, …, S_k sao cho mỗi S_i khác rỗng, không hai tập nào chồng lấn, và hợp của chúng bằng S. Union-find duy trì một partition như vậy dưới các phép merge.
Mỗi tập con bầu ra một representative (hay leader, hay root) — một thành viên bất kỳ đại diện cho cả tập. find(x) trả về representative của tập chứa x, và toàn bộ hợp đồng của cấu trúc này suy ra từ đó:
avàbcùng một tập khi và chỉ khifind(a) == find(b).
Đây là lý do việc find trả về “một phần tử nào đó” thay vì “một id của tập” là đủ. Bạn không bao giờ cần liệt kê các thành viên của một tập để so sánh hai tập; bạn so sánh leader của chúng.
Ba phép toán nguyên thủy là:
| Phép toán | Ý nghĩa |
|---|---|
make_set(x) | Tạo một tập singleton mới {x}. Thường được làm cho toàn bộ n phần tử lúc khởi tạo. |
find(x) | Trả về representative của tập chứa x. |
union(a, b) | Thay hai tập chứa a và b bằng hợp của chúng. Không làm gì nếu chúng đã cùng tập. |
Hãy chú ý cái thiếu: không có split, không có delete, không có cách nào gỡ một phần tử ra khỏi tập. Union-find có tính đơn điệu (monotone) — các tập chỉ gộp lại, không bao giờ tách ra. Chính hạn chế đó mua lấy tốc độ, và đây là điều đầu tiên cần kiểm tra khi quyết định cấu trúc này có phù hợp với bài toán của bạn hay không.
Biểu diễn bằng forest
Cách cài đặt chuẩn lưu partition dưới dạng một forest: mỗi tập là một tree, với root là representative. Một mảng số nguyên parent[] là đủ — parent[x] là parent của x, và root trỏ vào chính nó.
parent = [0, 0, 1, 1, 4, 4, 5]
0 4
/ \ / \
1 ... 5 ...
/ \ |
2 3 6
find(3) đi 3 → 1 → 0, trả về 0
find(6) đi 6 → 5 → 4, trả về 4
find(3) != find(6), nên 3 và 6 nằm ở hai tập khác nhau
Hình dạng tree ở đây không liên quan gì tới graph đang được mô hình hóa. Nó thuần túy là cấu trúc bookkeeping nội bộ: các edge của forest là “ai đã nói cho tôi biết leader của tôi”, chứ không phải edge của input graph. Điều này đáng để ghi nhớ, vì nó giải thích tại sao forest có thể được sắp xếp lại tùy ý (path compression làm đúng việc đó) mà không làm thay đổi bất kỳ câu trả lời nào.
Cài đặt ngây thơ, và tại sao nó là O(n)
Phiên bản hiển nhiên không cân bằng gì cả: find đi ngược lên root, union treo root này dưới root kia.
class NaiveDisjointSet:
"""Union-Find không có tối ưu nào. Đúng, nhưng suy biến thành linked list."""
def __init__(self, n):
self.parent = list(range(n)) # mỗi phần tử khởi đầu là root của chính nó
def find(self, x):
while self.parent[x] != x: # đi ngược lên — tốn O(chiều cao của tree)
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[rb] = ra # tùy tiện: root của b chui xuống dưới root của a
Vấn đề là union chọn một cách tùy tiện, và một thứ tự merge mang tính đối kháng (hoặc chỉ đơn giản là không may) sẽ dựng lên một đường thẳng chứ không phải một bụi cây:
ds = NaiveDisjointSet(n)
for i in range(1, n):
ds.union(i, i - 1) # mỗi bước chôn cả chuỗi hiện có sâu thêm một tầng
sau union(1,0), union(2,1), union(3,2), union(4,3):
0 → 1 → 2 → 3 → 4 (chuỗi parent; 4 là root)
find(0) giờ tốn 4 bước nhảy pointer. Với n phần tử, nó tốn n−1.
Forest đã trở thành một linked list có chiều cao n−1, nên find là O(n) và một chuỗi m phép toán là O(m·n). Như vậy còn tệ hơn cả việc chạy BFS. Cả hai tối ưu dưới đây tồn tại chính là để ngăn chặn hình dạng này.
Tối ưu 1 — union by rank / union by size
Cách sửa cho chuỗi thẳng là ngừng chọn tùy tiện. Khi merge hai tree, gắn cây thấp hơn xuống dưới cây cao hơn (union by rank) hoặc cây nhỏ hơn xuống dưới cây lớn hơn (union by size). Cách nào cũng giữ cho cây sâu vẫn là root và chiều cao không tăng.
- Union by size — lưu
size[r]= số phần tử trong tree gốcr. Gắn root nhỏ hơn xuống dưới root lớn hơn. - Union by rank — lưu
rank[r]= một cận trên cho chiều cao của tree. Gắn root có rank thấp hơn xuống dưới root có rank cao hơn; nếu rank bằng nhau, chọn bất kỳ và tăng rank của bên thắng lên 1.
Cả hai đều cho cùng một đảm bảo tiệm cận: chiều cao ≤ log₂ n. Lập luận cho union by size chỉ dài một dòng và rất đáng nhớ — độ sâu của một node chỉ tăng khi tree của nó là bên nhỏ hơn trong một lần merge, và điều đó ít nhất làm gấp đôi kích thước tree mà nó đang sống trong. Một tree n phần tử chỉ có thể gấp đôi tối đa log₂ n lần, nên không phần tử nào bị đẩy xuống quá log₂ n lần.
Trong thực tế union by size nhỉnh hơn một chút: bộ đếm size tự nó đã hữu ích (kích thước component, truy vấn component lớn nhất) và nó luôn chính xác, trong khi rank chỉ còn là cận trên một khi path compression bắt đầu làm phẳng các tree.
Chỉ với tối ưu này, find và union là O(log n) worst case — đã đủ dùng, và quan trọng là nó đúng cho từng phép toán, không chỉ amortized.
Tối ưu 2 — path compression
Union by rank ngăn tree trở nên sâu. Path compression làm cho chúng trở nên nông: sau khi find(x) đã đi từ x lên root, nó đi lại con đường đó lần nữa và trỏ mọi node vừa đi qua thẳng vào root.
trước find(6): sau find(6):
0 0
| / | \
3 3 5 6
|
5
|
6
một lần đi dài 3 bước trả trước cho mọi lần find sau này trên 3, 5, 6 — giờ mỗi lần chỉ 1 bước
Không có gì trong partition thay đổi — mọi node trên đường đi đều có cùng root trước và sau. Chỉ có phần bookkeeping trở nên rẻ hơn. Đây là insight then chốt khiến việc làm phẳng hiển nhiên là an toàn.
Chỉ riêng path compression (không có union by rank) cũng cho O(log n) amortized, nhưng sức mạnh thực sự của nó chỉ xuất hiện khi kết hợp với việc cân bằng.
Cận kết hợp — O(α(n)) và α là gì
Với cả hai union by rank/size và path compression, Tarjan đã chứng minh rằng bất kỳ chuỗi m phép toán nào trên n phần tử đều tốn tổng cộng O(m · α(n)), trong đó α là hàm Ackermann ngược.
Hàm Ackermann A(m, n) là ví dụ kinh điển trong sách giáo khoa về một hàm tính được nhưng tăng nhanh hơn mọi hàm primitive recursive — nhanh hơn hàm mũ, nhanh hơn tháp lũy thừa, nhanh hơn bất cứ thứ gì có tên. Một biến thể phổ biến:
A(0, n) = n + 1
A(m, 0) = A(m − 1, 1)
A(m, n) = A(m − 1, A(m, n − 1))
A(1, n) ≈ n + 2
A(2, n) ≈ 2n + 3
A(3, n) ≈ 2^(n+3) − 3
A(4, 2) = 2^2^2^2^2 − 3 ≈ 2 × 10^19728 ← đã vượt xa số nguyên tử trong vũ trụ
α(n) là nghịch đảo của nó: đại khái, số k nhỏ nhất sao cho A(k, k) ≥ n. Vì A bùng nổ, α bò rất chậm:
n | α(n) |
|---|---|
| 0 – 2 | 0 |
| 3 | 1 |
| 4 – 7 | 2 |
| 8 – 2047 | 3 |
2048 – A(4,4) (một số có khoảng 10^19728 chữ số) | 4 |
Vậy với mọi input vừa trong một máy tính — mọi input vừa trong mọi máy tính từng được chế tạo — α(n) ≤ 4. Union-find không phải là O(1) theo nghĩa đen; nó được chứng minh là không phải O(1) (Tarjan và van Leeuwen đã chứng minh cận dưới khớp Ω(m α(n)) cho lớp cấu trúc này). Nhưng khác biệt giữa α(n) và một hằng số là không quan sát được, và hoàn toàn hợp lý khi coi union-find là hằng số lúc tính toán ngân sách thời gian cho một thuật toán. Chi phí O(E log E) của Kruskal bị chi phối bởi việc sort, chứ không phải bởi union-find.
Cài đặt đầy đủ
class DisjointSet:
"""Union-Find trên các số nguyên 0..n-1, với union by size và path compression.
Mọi phép toán là O(alpha(n)) amortized, tức là <= 4 với mọi n thực tế.
Space là O(n): hai mảng số nguyên.
"""
def __init__(self, n):
self.parent = list(range(n)) # parent[x] == x nghĩa là x là root
self.size = [1] * n # size[r] chỉ có nghĩa khi r là root
self.components = n # số tập rời nhau hiện tại
def find(self, x):
"""Trả về representative của tập chứa x, nén đường đi trên đường về."""
root = x
while self.parent[root] != root: # lượt 1: tìm root
root = self.parent[root]
while self.parent[x] != root: # lượt 2: trỏ mọi thứ vào root
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
"""Gộp tập của a và b. Trả về False nếu chúng vốn đã cùng một tập."""
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
if self.size[ra] < self.size[rb]: # tree nhỏ hơn chui xuống dưới tree lớn hơn
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.components -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
def set_size(self, x):
"""Số phần tử trong tập của x — O(alpha(n)), nhờ mảng size."""
return self.size[self.find(x)]
Hai lưu ý cài đặt quan trọng trong code thật:
find dạng lặp, không phải đệ quy. Dạng đệ quy đẹp hơn —
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # nén trên đường quay lên
return self.parent[x]
— nhưng trên một cấu trúc vừa mới dựng, lần find đầu tiên có thể đệ quy sâu O(log n), và trong Python (giới hạn đệ quy mặc định là 1000) hoặc trên stack hạn chế thì đó là nguy cơ crash thực sự với n lớn. Phiên bản lặp hai lượt không có giới hạn đó.
Path halving là một lựa chọn thay thế rẻ hơn, chỉ một lượt, cho cùng cận tiệm cận và là thứ mà hầu hết template competitive programming dùng:
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # trỏ x vào ông nội của nó
x = self.parent[x]
return x
Nó làm ngắn đi một nửa độ dài đường đi ở mỗi lần duyệt thay vì làm phẳng hoàn toàn, chỉ trong một lượt, không cần vòng lặp thứ hai và không tốn thêm bộ nhớ. Trong benchmark nó thường là nhanh nhất trong ba cách.
Bảng tổng hợp độ phức tạp
| Cài đặt | find | union | Space | Lý do |
|---|---|---|---|---|
| Forest ngây thơ | O(n) worst | O(n) worst | O(n) | tree suy biến thành chuỗi |
| Chỉ union by size/rank | O(log n) worst | O(log n) worst | O(n) | chiều cao chứng minh được ≤ log₂ n |
| Chỉ path compression | O(log n) amortized | O(log n) amortized | O(n) | làm phẳng nhưng không cân bằng |
| Cả hai | O(α(n)) amortized | O(α(n)) amortized | O(n) | cận Tarjan; α(n) ≤ 4 trong thực tế |
Hãy chú ý chỗ chữ “amortized” gánh trọng lượng: một lần find đơn lẻ trên cấu trúc đã nén vẫn có thể tốn O(log n) (nó có thể chính là lần duyệt làm phẳng cây). Chính chuỗi phép toán mới là gần hằng số. Với các hệ real-time cứng, nơi độ trễ từng phép toán quan trọng hơn throughput, chỉ dùng union by rank cho đảm bảo O(log n) worst case mà không cần amortization.
Khái niệm chính
Ứng dụng 1 — Minimum spanning tree của Kruskal
Đây là ứng dụng kinh điển. Thuật toán Kruskal sort các edge theo trọng số và tham lam nhận từng edge trừ khi nó đóng thành một cycle. “Nó có đóng thành cycle không?” chính xác là “hai đầu mút này đã kết nối chưa?”, và đó chính xác là find(u) == find(v).
def kruskal(n, edges):
"""MST của một graph liên thông trên n vertex.
edges: list các (weight, u, v). Trả về (total_weight, chosen_edges).
Độ phức tạp: O(E log E) cho việc sort, cộng O(E * alpha(V)) cho union-find,
nên việc sort chi phối.
"""
ds = DisjointSet(n)
total, chosen = 0, []
for w, u, v in sorted(edges): # tăng dần theo trọng số
if ds.union(u, v): # union trả về False nếu u,v đã kết nối
total += w
chosen.append((w, u, v))
if len(chosen) == n - 1: # spanning tree trên n vertex có n-1 edge
break
return total, chosen
edges = [(1, 0, 1), (4, 0, 2), (3, 1, 2), (2, 1, 3), (5, 2, 3)]
print(kruskal(4, edges)) # (6, [(1, 0, 1), (2, 1, 3), (3, 1, 2)])
Cách viết if ds.union(...) — trong đó union trả về việc nó có thực sự merge gì không — là lý do union nên trả về boolean thay vì None. Nó tiết kiệm một cặp lời gọi find dư thừa và đọc lên đúng y như mô tả bằng tiếng Anh của thuật toán. Xem ./15-minimum-spanning-trees.md để có ngữ cảnh đầy đủ về Kruskal và so sánh với thuật toán Prim.
Ứng dụng 2 — Connected components và incremental connectivity
Nếu bạn dựng graph từng edge một và cần trả lời “a có tới được b không?” tại những thời điểm bất kỳ trong quá trình dựng, union-find là công cụ đúng còn BFS thì không. ds.components cho bạn số connected component miễn phí, được cập nhật ở mỗi lần merge.
def component_count(n, edges):
ds = DisjointSet(n)
for u, v in edges:
ds.union(u, v)
return ds.components
def largest_component(n, edges):
ds = DisjointSet(n)
for u, v in edges:
ds.union(u, v)
return max(ds.set_size(x) for x in range(n))
Đây là connectivity kiểu incremental (hay partially dynamic): edge được thêm vào, không bao giờ bị xóa. Nếu edge cũng có thể bị xóa, union-find không dùng được — không có cách nào un-merge, vì path compression đã hủy thông tin về việc lần merge nào đã đặt node nào ở đâu. Fully dynamic connectivity cần Euler-tour tree hoặc link-cut tree, hoặc, nếu biết trước toàn bộ truy vấn, dùng kỹ thuật offline dynamic connectivity: một segment tree trên trục thời gian kết hợp với rollback DSU (chỉ union by size, không path compression, kèm một stack các bản ghi undo — O(log n) mỗi phép toán nhưng đảo ngược được).
So sánh với cách tiếp cận dựa trên duyệt trong ./13-graph-data-structures.md: một lần DFS gán nhãn toàn bộ component trong O(V + E) và đơn giản hơn khi graph tĩnh và bạn chỉ cần gán nhãn một lần. Hãy dùng union-find khi edge đến theo dòng, khi truy vấn xen kẽ với việc chèn edge, hoặc khi bạn đang ở bên trong Kruskal.
Ứng dụng 3 — Phát hiện cycle trong undirected graph
Một undirected edge (u, v) đóng thành cycle chính xác khi u và v đã cùng một component. Nên phát hiện cycle chỉ gọn trong bốn dòng:
def has_cycle(n, edges):
"""True nếu undirected simple graph có chứa cycle. O(E * alpha(n))."""
ds = DisjointSet(n)
for u, v in edges:
if not ds.union(u, v): # hai đầu mút vốn đã kết nối
return True
return False
Hai lưu ý. Thứ nhất, cách này chỉ dùng cho graph undirected — cycle có hướng cần DFS kèm tô màu, hoặc topological sort của Kahn, vì union-find không có khái niệm về hướng của edge. Thứ hai, nó giả định graph là simple graph: một self-loop (u, u) hoặc một edge lặp lại sẽ bị báo là cycle, điều này thường là cái bạn muốn nhưng vẫn nên nói rõ.
Một hệ quả hữu ích: một graph n vertex là một tree khi và chỉ khi nó có đúng n − 1 edge, không lời gọi union nào bị từ chối, và ds.components == 1 ở cuối.
Ứng dụng 4 — Bài toán grid (“number of islands”)
Các bài toán connectivity trên grid ánh xạ sang union-find bằng cách làm phẳng chỉ số 2 chiều: ô (r, c) trở thành phần tử r * cols + c.
def count_islands(grid):
"""Đếm số nhóm 4-connected của các ô '1' trong grid gồm ký tự '1'/'0'."""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
ds = DisjointSet(rows * cols)
for r in range(rows):
for c in range(cols):
if grid[r][c] != '1':
continue
# chỉ nhìn lên và sang trái: cặp xuống/phải sẽ do ô kia xử lý
for dr, dc in ((-1, 0), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
ds.union(r * cols + c, nr * cols + nc)
roots = {ds.find(r * cols + c)
for r in range(rows) for c in range(cols) if grid[r][c] == '1'}
return len(roots)
grid = [
"11000",
"11000",
"00100",
"00011",
]
print(count_islands([list(row) for row in grid])) # 3
Đánh giá thẳng thắn: với câu hỏi “đếm số đảo một lần” thuần túy, DFS hoặc BFS flood fill đơn giản hơn và nhanh ngang nhau (O(rows × cols) cả hai cách), và đó là thứ bạn nên viết. Union-find kiếm được chỗ đứng ở các biến thể động — “number of islands II”, nơi các ô đất được thêm vào từng cái một và bạn phải báo cáo số đảo sau mỗi lần thêm. Ở đó, flood fill sẽ phải chạy lại từ đầu ở mỗi truy vấn trong khi union-find chỉ merge với tối đa bốn neighbour trong O(α).
Union-find cũng xử lý rất gọn bài toán percolation kinh điển: thêm một node ảo “top” nối với hàng trên cùng và một node ảo “bottom” nối với hàng dưới cùng; hệ thống percolate chính xác khi ds.connected(top, bottom).
Ứng dụng 5 — Image segmentation
Union-find là con ngựa kéo cho hai bài toán computer vision khác nhau:
Connected-component labelling — phiên bản ảnh nhị phân của bài toán đảo. Quét ảnh, union mỗi pixel foreground với các neighbour đã quét, rồi đọc các root ra làm nhãn component. Thuật toán Hoshen–Kopelman hai lượt kinh điển đúng nghĩa đen là như vậy: lượt một gán nhãn tạm và ghi lại các quan hệ tương đương vào một union-find, lượt hai thay mỗi nhãn tạm bằng find của nó.
Segmentation dựa trên graph (Felzenszwalb–Huttenlocher, 2004) — coi mỗi pixel là một vertex và mỗi cặp pixel kề nhau là một edge có trọng số bằng độ chênh lệch màu. Sort các edge theo trọng số và chạy một thứ rất gần với Kruskal: merge hai vùng chỉ khi edge giữa chúng nhỏ so với độ biến thiên nội tại vốn đã có trong cả hai vùng. Union-find mang theo không chỉ partition mà cả thống kê từng vùng (internal difference, size), thứ mà điều kiện merge tra cứu:
def should_merge(ds, internal, a, b, weight, k):
"""Điều kiện merge của Felzenszwalb-Huttenlocher, dạng phác thảo.
internal[r] là trọng số edge lớn nhất bên trong vùng r; k điều khiển mức
ưu tiên cho component lớn (k lớn hơn cho ra các vùng lớn hơn).
"""
ra, rb = ds.find(a), ds.find(b)
if ra == rb:
return False
threshold_a = internal[ra] + k / ds.set_size(ra)
threshold_b = internal[rb] + k / ds.set_size(rb)
return weight <= min(threshold_a, threshold_b)
Mẫu hình ở đây tổng quát hóa tốt vượt xa phạm vi xử lý ảnh: union-find với dữ liệu bổ sung theo từng tập. Lưu bất kỳ giá trị tổng hợp nào bạn muốn (size, sum, min, max, một histogram) chỉ ở root, và cập nhật nó bên trong union khi hai root gộp lại. Nó vẫn đúng chính vì chỉ bản sao ở root mới được đọc, và chỉ union mới ghi vào nó.
Biến thể — weighted / parity union-find
Cấu trúc thuần túy ghi lại “a và b có quan hệ với nhau”. Biến thể weighted (hay parity, hay DSU with potentials) ghi thêm quan hệ đó như thế nào, bằng cách lưu offset của mỗi node so với parent của nó và cộng dồn các offset trong lúc path compression. Phiên bản parity trả lời “graph này có bipartite không?” theo kiểu online — phiên bản offline cần một lần BFS tô 2 màu, vốn không xử lý được các edge đến từng cái một.
class ParityDisjointSet:
"""Union-Find có theo dõi parity (màu 0/1) của mỗi node so với root của nó.
union(a, b) khẳng định "a và b phải khác màu" và trả về False nếu điều đó
mâu thuẫn với những gì đã biết — tức là tồn tại một odd cycle.
"""
def __init__(self, n):
self.parent = list(range(n))
self.rel = [0] * n # rel[x] = parity của x so với parent[x]
self.size = [1] * n
def find(self, x):
"""Trả về (root, parity của x so với root), vừa đi vừa nén."""
if self.parent[x] == x:
return x, 0
root, par = self.find(self.parent[x])
self.parent[x] = root
self.rel[x] ^= par # parity x->root = parity x->parent XOR parent->root
return root, self.rel[x]
def union(self, a, b):
ra, pa = self.find(a)
rb, pb = self.find(b)
if ra == rb:
return pa != pb # đã có quan hệ: chỉ nhất quán nếu màu khác nhau
if self.size[ra] < self.size[rb]:
ra, rb, pa, pb = rb, ra, pb, pa
self.parent[rb] = ra
self.rel[rb] = pa ^ pb ^ 1 # edge mới khẳng định a và b khác nhau
self.size[ra] += self.size[rb]
return True
def is_bipartite(n, edges):
ds = ParityDisjointSet(n)
return all(ds.union(u, v) for u, v in edges)
print(is_bipartite(4, [(0, 1), (1, 2), (2, 3), (3, 0)])) # True (chu trình 4)
print(is_bipartite(3, [(0, 1), (1, 2), (2, 0)])) # False (tam giác)
Thay XOR bằng phép cộng modulo và bạn có “DSU with potentials” tổng quát, giải các hệ ràng buộc hiệu (x_a − x_b = d) một cách tăng dần và phát hiện mâu thuẫn ngay khi chúng xuất hiện.
Phần tử không phải số nguyên
Mọi thứ ở trên giả định phần tử là 0 .. n−1. Với các object hashable bất kỳ, hãy ánh xạ chúng sang số nguyên bằng một dictionary, hoặc lưu parent trực tiếp dưới dạng dict:
class HashDisjointSet:
"""Union-Find trên phần tử hashable bất kỳ, tạo lazily ở lần dùng đầu tiên."""
def __init__(self):
self.parent = {}
self.size = {}
def make_set(self, x):
if x not in self.parent:
self.parent[x] = x
self.size[x] = 1
def find(self, x):
self.make_set(x)
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root:
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
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
return True
Đúng, nhưng chậm hơn thấy rõ: mỗi bước nhảy pointer giờ là một lần hash lookup thay vì một lần truy cập mảng theo chỉ số, nên bạn mất đi cache locality vốn làm phiên bản mảng bay nhanh (xem ./07-hash-tables.md và ./04-arrays.md). Khi hiệu năng quan trọng, hãy ánh xạ sang số nguyên một lần từ đầu và dùng phiên bản mảng.
Best Practices
- Luôn áp dụng cả hai tối ưu. Chúng cộng lại chỉ bốn dòng code và đưa cấu trúc từ
O(n)xuống gần nhưO(1). Không có kịch bản nào mà phiên bản ngây thơ là đánh đổi đúng. - Ưu tiên union by size hơn union by rank trừ khi có lý do ngược lại. Chi phí như nhau, và mảng size kiêm luôn truy vấn kích thước component — thứ mà bạn gần như luôn cần tới.
- Cho
uniontrả về boolean báo việc merge có thực sự xảy ra hay không. Nó biến Kruskal và phát hiện cycle thành một dòng và tránh một cặp lời gọifindtrùng lặp. - Viết
finddạng lặp (hai lượt hoặc path halving). Bản đệ quy thanh lịch nhưng có thể làm tràn stack trên một cấu trúc lớn vừa mới dựng — và trong Python nó chạm giới hạn đệ quy mặc định từ rất lâu trước khintrở nên thú vị. - Không bao giờ đọc
size[x]hayrank[x]vớixkhông phải root. Những mảng đó chỉ được duy trì ở root; giá trị cũ ở chỗ khác là bug union-find phổ biến nhất. Luôn đi quafindtrước. - Kiểm tra bài toán có tính monotone trước khi chọn union-find. Các tập chỉ gộp lại. Nếu bài toán cần xóa, tách, hay gỡ edge, bạn cần cấu trúc khác (link-cut tree, Euler-tour tree) hoặc kỹ thuật offline segment-tree-trên-trục-thời-gian với rollback DSU.
- Dùng rollback DSU khi cần undo, và nhớ rằng rollback đòi hỏi bỏ path compression — việc làm phẳng là không thể đảo ngược. Điều đó tốn
O(log n)mỗi phép toán thay vìO(α(n)), gần như luôn là cái giá chấp nhận được. - Dùng DFS/BFS thay thế khi graph tĩnh và bạn chỉ cần component một lần.
O(V + E)trong một lượt, code đơn giản hơn và không cần cấu trúc phụ. Union-find thắng khi truy vấn connectivity xen kẽ với việc chèn edge. - Gắn giá trị tổng hợp vào root, cập nhật chúng trong
union. Kích thước component, tổng, min/max, một bộ đếm — tất cả đều miễn phí nếu bạn chỉ đọc chúng quafind(x)và chỉ ghi khi hai root gộp lại. - Đừng dùng union-find cho reachability có hướng. Nó mô hình hóa một quan hệ tương đương vô hướng; hướng là vô hình với nó. Phát hiện cycle có hướng cần DFS tô màu hoặc thuật toán Kahn — xem ./13-graph-data-structures.md.
- Hãy nói “gần như hằng số”, đừng nói “hằng số”.
O(α(n))không phảiO(1), và có cận dưới khớp chứng minh rằng nó không thể là như vậy. Trong ngân sách độ phức tạp, coi nó là hằng số thì không sao; trong một bài phân tích viết ra, trích dẫnαcho đúng cho thấy bạn hiểu tại sao.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Disjoint-set data structure — Wikipedia
- cp-algorithms — Disjoint Set Union
- Ackermann function — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 19: Data Structures for Disjoint Sets
- Princeton Algorithms — Union-Find (Sedgewick & Wayne)
- VisuAlgo — Union-Find Disjoint Sets
- Kruskal’s algorithm — Wikipedia
- Connected-component labeling — Wikipedia
- Big-O Cheat Sheet
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
A disjoint-set structure — also called union-find or a merge-find set — tracks a partition of a set: a collection of elements split into non-overlapping groups, where every element belongs to exactly one group. It answers exactly two questions and does nothing else:
find(x)— which group isxin?union(a, b)— merge the group containingawith the group containingb.
That is a tiny interface, and its narrowness is the whole point. Union-find is the answer to “are these two things connected?” when the connections keep arriving and never go away. Most graph structures answer connectivity by traversing — a BFS or DFS from a costs O(V + E) every time you ask. Union-find answers the same question in effectively constant time, because it never traverses anything: it maintains the answer incrementally as edges arrive.
The remarkable part is the complexity. With two small optimizations — union by rank/size and path compression — a sequence of m operations over n elements costs O(m · α(n)), where α is the inverse Ackermann function. α(n) grows so slowly that it is at most 4 for any n you could physically store, so in practice each operation is constant time. Robert Tarjan proved in 1975 that this bound is tight — no pointer-based structure can do better — which makes union-find one of the few data structures whose optimality is settled.
Its most famous customer is Kruskal’s algorithm for minimum spanning trees (see ./15-minimum-spanning-trees.md), but it turns up wherever “merge these two groups” is the natural operation: connected components, cycle detection in undirected graphs, image segmentation, percolation, “number of islands” grid problems, and equivalence-class bookkeeping in type checkers and compilers.
Fundamentals
The model: a partition of a set
Formally, given a universe S = {0, 1, …, n−1}, a partition is a family of subsets S₁, S₂, …, S_k such that every S_i is non-empty, no two overlap, and their union is S. Union-find maintains such a partition under merges.
Each subset elects a representative (or leader, or root) — one arbitrary member that stands for the whole set. find(x) returns the representative of x’s set, and the entire contract of the structure follows:
aandbare in the same set if and only iffind(a) == find(b).
This is why find returning “some element” rather than “a set id” is enough. You never need to enumerate a set’s members to compare two sets; you compare their leaders.
The three primitive operations are:
| Operation | Meaning |
|---|---|
make_set(x) | Create a new singleton set {x}. Usually done for all n elements at construction. |
find(x) | Return the representative of the set containing x. |
union(a, b) | Replace the two sets containing a and b with their union. No-op if already together. |
Note what is missing: there is no split, no delete, no way to remove an element from a set. Union-find is monotone — sets only ever merge, never break apart. That restriction is what buys the speed, and it is the first thing to check when deciding whether the structure fits your problem.
The forest representation
The standard implementation stores the partition as a forest: one tree per set, with the root being the representative. A single integer array parent[] is enough — parent[x] is x’s parent, and a root points at itself.
parent = [0, 0, 1, 1, 4, 4, 5]
0 4
/ \ / \
1 ... 5 ...
/ \ |
2 3 6
find(3) walks 3 → 1 → 0, returns 0
find(6) walks 6 → 5 → 4, returns 4
find(3) != find(6), so 3 and 6 are in different sets
The tree shape here has nothing to do with the graph being modelled. It is purely an internal bookkeeping structure: the edges of the forest are “who told me my leader”, not edges of the input graph. This is worth internalizing, because it explains why the forest can be reshaped freely (path compression does exactly that) without changing any answer.
The naive implementation, and why it is O(n)
The obvious version does no balancing at all: find walks to the root, union hangs one root under the other.
class NaiveDisjointSet:
"""Union-Find with no optimizations. Correct, but degenerates to a linked list."""
def __init__(self, n):
self.parent = list(range(n)) # every element starts as its own root
def find(self, x):
while self.parent[x] != x: # walk up — costs O(height of the tree)
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[rb] = ra # arbitrary: b's root goes under a's root
The problem is that union picks arbitrarily, and an adversarial (or merely unlucky) order of merges builds a path, not a bush:
ds = NaiveDisjointSet(n)
for i in range(1, n):
ds.union(i, i - 1) # each step buries the whole existing chain one level deeper
after union(1,0), union(2,1), union(3,2), union(4,3):
0 → 1 → 2 → 3 → 4 (parent chain; 4 is the root)
find(0) now costs 4 pointer hops. With n elements, it costs n−1.
The forest has become a linked list of height n−1, so find is O(n) and a sequence of m operations is O(m·n). That is worse than just running a BFS. Both optimizations below exist to prevent exactly this shape.
Optimization 1 — union by rank / union by size
The fix for the chain is to stop choosing arbitrarily. When merging two trees, attach the shorter one under the taller one (union by rank) or the smaller one under the bigger one (union by size). Either way the deep tree keeps its root and its height does not grow.
- Union by size — store
size[r]= number of elements in the tree rooted atr. Attach the smaller root under the larger. - Union by rank — store
rank[r]= an upper bound on the tree’s height. Attach the lower-rank root under the higher-rank one; if ranks tie, pick either and increment the winner’s rank by 1.
Both give the same asymptotic guarantee: height ≤ log₂ n. The argument for union by size is a one-liner and worth remembering — a node’s depth increases only when its tree is the smaller side of a merge, and that at least doubles the size of the tree it lives in. A tree of n elements can double at most log₂ n times, so no element can be pushed down more than log₂ n times.
In practice union by size is slightly preferable: the size counter is useful in its own right (component sizes, largest-component queries) and it stays exact, whereas rank becomes only an upper bound once path compression starts flattening trees.
With this alone, find and union are O(log n) worst case — already usable, and importantly it holds per operation, not just amortized.
Optimization 2 — path compression
Union by rank stops trees from getting deep. Path compression makes them get shallow: after find(x) has walked from x to the root, it walks the path again and points every node it passed directly at the root.
before find(6): after find(6):
0 0
| / | \
3 3 5 6
|
5
|
6
one walk of length 3 pays for all future finds on 3, 5, 6 — each now costs 1 hop
Nothing about the partition changes — every node on the path had the same root before and after. Only the bookkeeping got cheaper. This is the key insight that makes the flattening obviously safe.
Path compression alone (without union by rank) also gives O(log n) amortized, but its real power appears only in combination with balancing.
The combined bound — O(α(n)) and what α is
With both union by rank/size and path compression, Tarjan showed that any sequence of m operations on n elements costs O(m · α(n)) total, where α is the inverse Ackermann function.
The Ackermann function A(m, n) is the textbook example of a function that is computable but grows faster than any primitive recursive function — faster than exponential, faster than tower-of-exponentials, faster than anything with a name. A common variant:
A(0, n) = n + 1
A(m, 0) = A(m − 1, 1)
A(m, n) = A(m − 1, A(m, n − 1))
A(1, n) ≈ n + 2
A(2, n) ≈ 2n + 3
A(3, n) ≈ 2^(n+3) − 3
A(4, 2) = 2^2^2^2^2 − 3 ≈ 2 × 10^19728 ← already far beyond the number of atoms in the universe
α(n) is its inverse: roughly, the smallest k such that A(k, k) ≥ n. Because A explodes, α crawls:
n | α(n) |
|---|---|
| 0 – 2 | 0 |
| 3 | 1 |
| 4 – 7 | 2 |
| 8 – 2047 | 3 |
2048 – A(4,4) (a number with ~10^19728 digits) | 4 |
So for every input that fits in a computer — every input that will fit in every computer ever built — α(n) ≤ 4. Union-find is not literally O(1); it is provably not O(1) (Tarjan and van Leeuwen proved a matching Ω(m α(n)) lower bound for this class of structures). But the difference between α(n) and a constant is unobservable, and it is entirely fair to reason about union-find as constant time when budgeting an algorithm. Kruskal’s O(E log E) cost is dominated by the sort, not by the union-find.
Full implementation
class DisjointSet:
"""Union-Find over integers 0..n-1, with union by size and path compression.
All operations are O(alpha(n)) amortized, which is <= 4 for any realistic n.
Space is O(n): two integer arrays.
"""
def __init__(self, n):
self.parent = list(range(n)) # parent[x] == x means x is a root
self.size = [1] * n # size[r] is meaningful only when r is a root
self.components = n # number of disjoint sets right now
def find(self, x):
"""Return the representative of x's set, compressing the path on the way."""
root = x
while self.parent[root] != root: # pass 1: locate the root
root = self.parent[root]
while self.parent[x] != root: # pass 2: repoint everything at it
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
"""Merge the sets of a and b. Return False if they were already the same set."""
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
if self.size[ra] < self.size[rb]: # smaller tree goes under the bigger one
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.components -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
def set_size(self, x):
"""Number of elements in x's set — O(alpha(n)), thanks to the size array."""
return self.size[self.find(x)]
Two implementation notes that matter in real code:
Iterative find, not recursive. The recursive form is prettier —
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # compress on the way back up
return self.parent[x]
— but on a freshly built structure the first find can recurse O(log n) deep, and in Python (default recursion limit 1000) or on a constrained stack that is a real crash risk for large n. The two-pass iterative version has no such limit.
Path halving is a cheaper single-pass alternative that gives the same asymptotic bound and is what most competitive-programming templates use:
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # point x at its grandparent
x = self.parent[x]
return x
It halves the path length on every traversal instead of flattening it completely, in one pass with no second loop and no extra memory. In benchmarks it is usually the fastest of the three.
Complexity summary
| Implementation | find | union | Space | Why |
|---|---|---|---|---|
| Naive forest | O(n) worst | O(n) worst | O(n) | trees degenerate into chains |
| Union by size/rank only | O(log n) worst | O(log n) worst | O(n) | height provably ≤ log₂ n |
| Path compression only | O(log n) amortized | O(log n) amortized | O(n) | flattening without balancing |
| Both | O(α(n)) amortized | O(α(n)) amortized | O(n) | Tarjan’s bound; α(n) ≤ 4 in practice |
Note where “amortized” is load-bearing: a single find on a compressed structure can still cost O(log n) (it may be the traversal that does the flattening). It is the sequence that is near-constant. For hard real-time systems where per-operation latency matters more than throughput, union by rank alone gives a worst-case O(log n) guarantee with no amortization.
Key Concepts
Application 1 — Kruskal’s minimum spanning tree
This is the canonical use. Kruskal’s algorithm sorts edges by weight and greedily accepts each one unless it would close a cycle. “Would it close a cycle?” is exactly “are these two endpoints already connected?”, which is exactly find(u) == find(v).
def kruskal(n, edges):
"""MST of a connected graph on n vertices.
edges: list of (weight, u, v). Returns (total_weight, chosen_edges).
Complexity: O(E log E) for the sort, plus O(E * alpha(V)) for the union-find,
so the sort dominates.
"""
ds = DisjointSet(n)
total, chosen = 0, []
for w, u, v in sorted(edges): # ascending weight
if ds.union(u, v): # union returns False if u,v already connected
total += w
chosen.append((w, u, v))
if len(chosen) == n - 1: # a spanning tree on n vertices has n-1 edges
break
return total, chosen
edges = [(1, 0, 1), (4, 0, 2), (3, 1, 2), (2, 1, 3), (5, 2, 3)]
print(kruskal(4, edges)) # (6, [(1, 0, 1), (2, 1, 3), (3, 1, 2)])
The if ds.union(...) idiom — where union returns whether it actually merged anything — is the reason union should return a boolean rather than None. It saves a redundant pair of find calls and reads exactly like the algorithm’s English description. See ./15-minimum-spanning-trees.md for Kruskal in context and its comparison with Prim’s algorithm.
Application 2 — connected components and incremental connectivity
If you build the graph edge by edge and need to answer “is a reachable from b?” at arbitrary points during construction, union-find is the right tool and BFS is not. ds.components gives the number of connected components for free, updated on every merge.
def component_count(n, edges):
ds = DisjointSet(n)
for u, v in edges:
ds.union(u, v)
return ds.components
def largest_component(n, edges):
ds = DisjointSet(n)
for u, v in edges:
ds.union(u, v)
return max(ds.set_size(x) for x in range(n))
This is incremental (or partially dynamic) connectivity: edges are added, never removed. If edges can also be deleted, union-find does not work — there is no way to un-merge, because path compression has destroyed the information about which merge put each node where. Fully dynamic connectivity needs Euler-tour trees or link-cut trees, or, if all the queries are known in advance, the offline dynamic connectivity trick: a segment tree over the time axis combined with a rollback DSU (union by size only, no path compression, with a stack of undo records — O(log n) per operation but reversible).
Compare with the traversal-based approach in ./13-graph-data-structures.md: a single DFS labels all components in O(V + E) and is simpler when the graph is static and you only need the labelling once. Reach for union-find when edges stream in, when queries interleave with insertions, or when you are inside Kruskal.
Application 3 — cycle detection in an undirected graph
An undirected edge (u, v) closes a cycle exactly when u and v are already in the same component. So cycle detection is four lines:
def has_cycle(n, edges):
"""True if the undirected simple graph contains a cycle. O(E * alpha(n))."""
ds = DisjointSet(n)
for u, v in edges:
if not ds.union(u, v): # endpoints were already connected
return True
return False
Two caveats. First, this is for undirected graphs only — a directed cycle needs DFS with colouring, or Kahn’s topological sort, because union-find has no notion of edge direction. Second, it assumes a simple graph: a self-loop (u, u) or a repeated edge will be reported as a cycle, which is usually what you want but is worth stating.
A useful corollary: a graph with n vertices is a tree iff it has exactly n − 1 edges, no union call is ever rejected, and ds.components == 1 at the end.
Application 4 — grid problems (“number of islands”)
Grid connectivity problems map onto union-find by flattening the 2-D index: cell (r, c) becomes element r * cols + c.
def count_islands(grid):
"""Count 4-connected groups of '1' cells in a grid of '1'/'0' characters."""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
ds = DisjointSet(rows * cols)
for r in range(rows):
for c in range(cols):
if grid[r][c] != '1':
continue
# only look up and left: the down/right pairs are handled from the other cell
for dr, dc in ((-1, 0), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
ds.union(r * cols + c, nr * cols + nc)
roots = {ds.find(r * cols + c)
for r in range(rows) for c in range(cols) if grid[r][c] == '1'}
return len(roots)
grid = [
"11000",
"11000",
"00100",
"00011",
]
print(count_islands([list(row) for row in grid])) # 3
Honest assessment: for the plain “count the islands once” question, a DFS or BFS flood fill is simpler and just as fast (O(rows × cols) either way), and that is what you should write. Union-find earns its place in the dynamic variants — “number of islands II”, where land cells are added one at a time and you must report the island count after each addition. There, flood fill would restart from scratch on every query while union-find just merges with up to four neighbours in O(α).
Union-find also handles the classic percolation problem elegantly: add a virtual “top” node connected to the top row and a virtual “bottom” node connected to the bottom row; the system percolates exactly when ds.connected(top, bottom).
Application 5 — image segmentation
Union-find is the workhorse of two distinct computer-vision tasks:
Connected-component labelling — the binary-image version of the island problem. Scan the image, union each foreground pixel with its already-scanned neighbours, then read off the roots as component labels. The classic two-pass Hoshen–Kopelman algorithm is literally this: pass one assigns provisional labels and records equivalences in a union-find, pass two replaces each provisional label with its find.
Graph-based segmentation (Felzenszwalb–Huttenlocher, 2004) — treat every pixel as a vertex and every adjacent pixel pair as an edge weighted by colour difference. Sort the edges by weight and run something very close to Kruskal: merge two regions only if the edge between them is small relative to the internal variation already present in both. The union-find carries not just the partition but per-region statistics (internal difference, size), which the merge predicate consults:
def should_merge(ds, internal, a, b, weight, k):
"""Felzenszwalb-Huttenlocher merge predicate, sketched.
internal[r] is the largest edge weight inside region r; k controls the
preference for larger components (a larger k yields larger regions).
"""
ra, rb = ds.find(a), ds.find(b)
if ra == rb:
return False
threshold_a = internal[ra] + k / ds.set_size(ra)
threshold_b = internal[rb] + k / ds.set_size(rb)
return weight <= min(threshold_a, threshold_b)
The pattern here generalizes well beyond images: union-find with augmented per-set data. Store any aggregate you like (size, sum, min, max, a histogram) on the root only, and update it inside union when the roots merge. It stays correct precisely because only the root’s copy is ever read, and only union ever writes it.
Variant — weighted / parity union-find
The plain structure records “a and b are related”. A weighted (or parity, or DSU with potentials) variant additionally records how they are related, by storing each node’s offset relative to its parent and accumulating offsets during path compression. The parity version answers “is this graph bipartite?” online — the offline version needs a BFS 2-colouring, which cannot handle edges arriving one at a time.
class ParityDisjointSet:
"""Union-Find that also tracks the parity (0/1 colour) of each node vs its root.
union(a, b) asserts "a and b must have different colours" and returns False
if that contradicts what is already known — i.e. an odd cycle exists.
"""
def __init__(self, n):
self.parent = list(range(n))
self.rel = [0] * n # rel[x] = parity of x relative to parent[x]
self.size = [1] * n
def find(self, x):
"""Return (root, parity of x relative to root), compressing as we go."""
if self.parent[x] == x:
return x, 0
root, par = self.find(self.parent[x])
self.parent[x] = root
self.rel[x] ^= par # x->root parity = x->parent parity XOR parent->root
return root, self.rel[x]
def union(self, a, b):
ra, pa = self.find(a)
rb, pb = self.find(b)
if ra == rb:
return pa != pb # already related: consistent only if colours differ
if self.size[ra] < self.size[rb]:
ra, rb, pa, pb = rb, ra, pb, pa
self.parent[rb] = ra
self.rel[rb] = pa ^ pb ^ 1 # the new edge asserts a and b differ
self.size[ra] += self.size[rb]
return True
def is_bipartite(n, edges):
ds = ParityDisjointSet(n)
return all(ds.union(u, v) for u, v in edges)
print(is_bipartite(4, [(0, 1), (1, 2), (2, 3), (3, 0)])) # True (4-cycle)
print(is_bipartite(3, [(0, 1), (1, 2), (2, 0)])) # False (triangle)
Replace XOR with modular addition and you get the general “DSU with potentials”, which solves systems of difference constraints (x_a − x_b = d) incrementally and detects contradictions the moment they appear.
Non-integer elements
Everything above assumes elements are 0 .. n−1. For arbitrary hashable objects, map them to integers with a dictionary, or store parent as a dict directly:
class HashDisjointSet:
"""Union-Find over arbitrary hashable elements, created lazily on first use."""
def __init__(self):
self.parent = {}
self.size = {}
def make_set(self, x):
if x not in self.parent:
self.parent[x] = x
self.size[x] = 1
def find(self, x):
self.make_set(x)
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root:
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
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
return True
Correct, but noticeably slower: every pointer hop is now a hash lookup rather than an array index, so you lose the cache locality that makes the array version fly (see ./07-hash-tables.md and ./04-arrays.md). When performance matters, do the integer mapping once up front and use the array version.
Best Practices
- Always apply both optimizations. They are four lines of code between them and take the structure from
O(n)to effectivelyO(1). There is no scenario in which the naive version is the right trade-off. - Prefer union by size over union by rank unless you have a reason not to. It costs the same, and the size array doubles as a component-size query, which you nearly always end up wanting.
- Make
unionreturn a boolean saying whether a merge actually happened. It turns Kruskal and cycle detection into one-liners and avoids a duplicate pair offindcalls. - Write
finditeratively (two-pass or path halving). The recursive version is elegant but can blow the stack on a large, freshly built structure — and in Python it hits the default recursion limit well beforengets interesting. - Never read
size[x]orrank[x]for a non-rootx. Those arrays are only maintained at roots; stale values elsewhere are the single most common union-find bug. Always go throughfindfirst. - Check that your problem is monotone before choosing union-find. Sets only merge. If your problem needs deletion, splitting, or edge removal, you need a different structure (link-cut trees, Euler-tour trees) or the offline segment-tree-on-time technique with a rollback DSU.
- Use a rollback DSU when you need undo, and remember that rollback requires dropping path compression — the flattening is irreversible. That costs you
O(log n)per operation instead ofO(α(n)), which is almost always an acceptable price. - Reach for DFS/BFS instead when the graph is static and you need the components once.
O(V + E)in one pass, with simpler code and no auxiliary structure. Union-find wins when connectivity queries interleave with edge insertions. - Attach aggregates to the root, update them in
union. Component size, sum, min/max, a counter — all are free if you only read them viafind(x)and only write them when two roots merge. - Do not use union-find for directed reachability. It models an undirected equivalence relation; direction is invisible to it. Directed cycle detection needs DFS colouring or Kahn’s algorithm — see ./13-graph-data-structures.md.
- Say “effectively constant”, not “constant”.
O(α(n))is notO(1), and there is a matching lower bound proving it cannot be. In a complexity budget, treating it as constant is fine; in a written analysis, quotingαcorrectly shows you know why.
References
- roadmap.sh — Data Structures & Algorithms
- Disjoint-set data structure — Wikipedia
- cp-algorithms — Disjoint Set Union
- Ackermann function — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 19: Data Structures for Disjoint Sets
- Princeton Algorithms — Union-Find (Sedgewick & Wayne)
- VisuAlgo — Union-Find Disjoint Sets
- Kruskal’s algorithm — Wikipedia
- Connected-component labeling — Wikipedia
- Big-O Cheat Sheet