← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202627 phút đọc23 min read

Disjoint Set (Union-Find)Disjoint Set (Union-Find)

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Mô hình: partition của một tập hợp
  4. Biểu diễn bằng forest
  5. Cài đặt ngây thơ, và tại sao nó là O(n)
  6. Tối ưu 1 — union by rank / union by size
  7. Tối ưu 2 — path compression
  8. Cận kết hợp — O(α(n)) và α là gì
  9. Cài đặt đầy đủ
  10. Bảng tổng hợp độ phức tạp
  11. Khái niệm chính
  12. Ứng dụng 1 — Minimum spanning tree của Kruskal
  13. Ứng dụng 2 — Connected components và incremental connectivity
  14. Ứng dụng 3 — Phát hiện cycle trong undirected graph
  15. Ứng dụng 4 — Bài toán grid (“number of islands”)
  16. Ứng dụng 5 — Image segmentation
  17. Biến thể — weighted / parity union-find
  18. Phần tử không phải số nguyên
  19. Best Practices
  20. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The model: a partition of a set
  4. The forest representation
  5. The naive implementation, and why it is O(n)
  6. Optimization 1 — union by rank / union by size
  7. Optimization 2 — path compression
  8. The combined bound — O(α(n)) and what α is
  9. Full implementation
  10. Complexity summary
  11. Key Concepts
  12. Application 1 — Kruskal’s minimum spanning tree
  13. Application 2 — connected components and incremental connectivity
  14. Application 3 — cycle detection in an undirected graph
  15. Application 4 — grid problems (“number of islands”)
  16. Application 5 — image segmentation
  17. Variant — weighted / parity union-find
  18. Non-integer elements
  19. Best Practices
  20. 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:

Đó 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/sizepath 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ừ đó:

ab cùng một tập khi và chỉ khi find(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 ab 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 findO(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.

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, findunionO(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))α 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 đó α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 – 20
31
4 – 72
8 – 20473
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 đặtfindunionSpaceLý do
Forest ngây thơO(n) worstO(n) worstO(n)tree suy biến thành chuỗi
Chỉ union by size/rankO(log n) worstO(log n) worstO(n)chiều cao chứng minh được ≤ log₂ n
Chỉ path compressionO(log n) amortizedO(log n) amortizedO(n)làm phẳng nhưng không cân bằng
Cả haiO(α(n)) amortizedO(α(n)) amortizedO(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 uv đã 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 “ab 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./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

Tài liệu tham khảo

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:

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:

a and b are in the same set if and only if find(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:

OperationMeaning
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.

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 – 20
31
4 – 72
8 – 20473
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

ImplementationfindunionSpaceWhy
Naive forestO(n) worstO(n) worstO(n)trees degenerate into chains
Union by size/rank onlyO(log n) worstO(log n) worstO(n)height provably ≤ log₂ n
Path compression onlyO(log n) amortizedO(log n) amortizedO(n)flattening without balancing
BothO(α(n)) amortizedO(α(n)) amortizedO(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

References