Back to Graph Theory Series

Union-Find (Disjoint Set Union)

August 30, 2026 Wasil Zafar 23 min read

Union–Find maintains a changing partition using tiny parent-pointer forests. Two local rewrites—attach small trees below large ones and flatten paths after queries—make long sequences of operations almost constant-time.

Contents

  1. A Bit of History
  2. The Forest Model
  3. Working Principle
  4. Two Optimizations That Change Everything
  5. The Inverse Ackermann Bound
  6. Implementation
  7. Real-World Applications
  8. Variants & Limitations
  9. Pitfalls & Debugging
  10. Exercises

A Bit of History

The Union-Find data structure was introduced by Bernard Galler and Michael Fischer in a short 1964 paper on compiler design, motivated by tracking equivalence classes of program variables. Over the following decade, researchers including M. D. McIlroy, Robert Morris, and independently J. D. Tritter contributed the "union by rank/size" and "path compression" optimizations you'll use here. But the full story of why those optimizations are so effective wasn't nailed down until 1975, when Robert Tarjan (already a familiar name from Parts 6 and 8) proved that a sequence of \(m\) operations on \(n\) elements takes \(O(m \, \alpha(n))\) time, where \(\alpha\) is the inverse Ackermann function — a function that grows so slowly that \(\alpha(n) \leq 4\) for any \(n\) you could ever physically enumerate, even though it is not, strictly speaking, a constant.

The Forest Model

Union–Find stores a partition: every element belongs to exactly one set, sets never overlap, and together they contain every element. Each set is represented as a rooted tree. The root is the set's representative and satisfies parent[root] == root.

find(x)

Follow parents to a root

The exact root label is arbitrary. Two elements are in the same set precisely when their roots are equal.

union(x, y)

Join roots, not arbitrary nodes

Compute both roots first. If they differ, make one root a child of the other and decrease the component count.

Union operations turn singleton sets into a forest Six singleton roots are first paired into three two-node trees. Union of B and D finds roots A and C, then attaches C below A, leaving two components represented by A and E. 1 · Six singleton sets2 · Three unions3 · union(B, D) ABCDEF Every node is its own root ABCDEF {A,B} · {C,D} · {E,F} ABCDEF find(B)=A, find(D)=C; attach C to A
A union never needs to preserve a particular root name. It only preserves the partition: after union(B,D), A, B, C, and D share representative A.

Working Principle

The forest representation needs only one parent entry per element. A root points to itself; every other node points one step closer to its root. The fundamental equivalence is:

$$x \text{ and } y \text{ are connected} \iff \operatorname{find}(x)=\operatorname{find}(y).$$

Calling union(x,y) first replaces both arguments by their roots. If the roots are equal, the operation changes nothing and returns false. Otherwise it links the two trees, updates root-only metadata such as size, decrements the number of sets, and returns true.

InvariantWhy it matters
parent[root] == rootEvery parent chain has a recognizable stopping point
Every node has exactly one parentThe representation is a forest, not an arbitrary graph
size[root] is valid at rootsUnion can attach the smaller set below the larger one
count decreases only on a successful unionIt always equals the number of current sets

Analogy: Corporate Mergers and Org Charts

Each group is a company's org chart, with one CEO as representative. find(employee) walks up the reporting chain. A merger links one CEO below the other; linking an ordinary employee would give that employee two incompatible roles and violate the forest invariant.

Two Optimizations That Change Everything

1. Union by size or rank

When two roots differ, attach the root of the smaller tree below the root of the larger tree. A node's depth can increase only when its entire set joins a set at least as large, so the size of its containing set at least doubles. That can happen at most $\lfloor\log_2 n\rfloor$ times.

Rank is an alternative upper-bound heuristic for height. When two unequal ranks merge, the larger rank stays unchanged; when equal ranks merge, the winning root's rank increases by one. After path compression, rank is no longer the tree's exact current height—and it does not need to be.

2. Path compression

A find(x) first reaches the root, then redirects every parent pointer on that search path straight to the root. The partition does not change; only its internal representation becomes flatter.

The deliberately tall chain below isolates compression's effect. Union by size would already make such a five-node chain difficult to create through normal unions.

Path compression flattens a parent-pointer chain Before find E, E points to D, D to C, C to B, and B to root A. After find E, every visited node points directly to A. Before find(E)After find(E) ABCDE walk 4 links ABCDE parent[B] = parent[C] = parent[D] = parent[E] = A future finds need one link
Compression is opportunistic: it flattens the path a query actually visits. Other branches remain valid and will flatten when later finds reach them.
before:E → D → C → B → A·after find(E):E → A

The Inverse Ackermann Bound

The Ackermann function grows extraordinarily quickly; its inverse $\alpha(n)$ grows extraordinarily slowly. For every input size that can arise in physical computation, $α(n)$ is a tiny single-digit value. That makes Union–Find effectively constant-time in practice, but the mathematical bound is still not literal $O(1)$.

For $n$ elements and a sequence of $m$ operations using both optimizations:

$$\text{Total time: } O((n+m)\alpha(n)) \qquad \text{Space: } O(n)$$

OperationAmortized costImportant qualifier
Initialize one element$O(1)$Initializing all $n$ elements costs $O(n)$
find$O(\alpha(n))$A single call is not promised worst-case constant time
union$O(\alpha(n))$It performs two finds plus constant root-link work
connected$O(\alpha(n))$It compares two representatives

Amortized Means “Average Over the Sequence”

A particular find may walk several links, but it pays for future calls by flattening them. The theorem bounds the total bill for the whole operation sequence, not every receipt in isolation.

Implementation

The listings use union by size. Only roots have meaningful size values; always call find before reading or updating that metadata.

MethodReturn valueMutation
find(x)Representative rootCompresses the visited parent path
union(x,y)true only when two sets mergeLinks roots, adds sizes, decrements count
connected(x,y)Whether representatives matchMay compress two paths
component_size(x)Size stored at find(x)May compress one path
class UnionFind:
    def __init__(self, elements):
        items = list(dict.fromkeys(elements))  # stable deduplication
        self.parent = {e: e for e in items}
        self.size = {e: 1 for e in items}
        self.count = len(items)

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

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False
        if self.size[rx] < self.size[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        self.size[rx] += self.size[ry]
        self.count -= 1
        return True

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

    def component_size(self, x):
        return self.size[self.find(x)]

uf = UnionFind(["A", "B", "C", "D", "E"])
uf.union("A", "B")
uf.union("C", "D")
print(uf.connected("A", "B"))   # True
print(uf.connected("A", "C"))   # False
uf.union("B", "C")
print(uf.connected("A", "D"))   # True (A-B-C-D all merged transitively)
print(uf.count)                 # 2 groups remain: {A,B,C,D} and {E}
print(uf.component_size("D")) # 4
#include <iostream>
#include <stdexcept>
#include <utility>
#include <vector>
#include <numeric>
using namespace std;

class UnionFind {
    vector<int> parent, size_;
    int count_;
public:
    UnionFind(int n) : parent(n < 0 ? 0 : n), size_(n < 0 ? 0 : n, 1), count_(n) {
        if (n < 0) throw invalid_argument("n must be non-negative");
        iota(parent.begin(), parent.end(), 0);  // parent[i] = i
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);  // path compression
        return parent[x];
    }

    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (size_[rx] < size_[ry]) swap(rx, ry);
        parent[ry] = rx;
        size_[rx] += size_[ry];
        --count_;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
    int componentSize(int x) { return size_[find(x)]; }
    int count() const { return count_; }
};

int main() {
    UnionFind uf(5);   // elements 0..4
    uf.unite(0, 1);
    uf.unite(2, 3);
    uf.unite(1, 2);
    bool result = uf.connected(0, 3);  // true
    int size = uf.componentSize(3);    // 4
    cout << boolalpha << result << " size=" << size
         << " components=" << uf.count() << '\n';
    return 0;
}
class UnionFind {
    int[] parent, size;
    int count;

    UnionFind(int n) {
        if (n < 0) throw new IllegalArgumentException("n must be non-negative");
        parent = new int[n];
        size = new int[n];
        count = n;
        for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);  // path compression
        return parent[x];
    }

    boolean union(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (size[rx] < size[ry]) { int t = rx; rx = ry; ry = t; }
        parent[ry] = rx;
        size[rx] += size[ry];
        count--;
        return true;
    }

    boolean connected(int x, int y) { return find(x) == find(y); }
    int componentSize(int x) { return size[find(x)]; }

    public static void main(String[] args) {
        UnionFind uf = new UnionFind(5);
        uf.union(0, 1);
        uf.union(2, 3);
        uf.union(1, 2);
        System.out.println(uf.connected(0, 3));  // true
        System.out.println(uf.count);             // 2 groups: {0,1,2,3} and {4}
        System.out.println(uf.componentSize(3));  // 4
    }
}

Real-World Applications

Kruskal's MST

Process edges by weight. Add $(u,v)$ only when find(u) != find(v); the union both records the merge and certifies that the edge did not close a cycle.

Component labeling

Union adjacent pixels or cells that belong to the same region. Representatives then provide compact labels for all connected regions.

Percolation

As grid cells open, union each cell with open neighbors. Two virtual boundary nodes make “does top connect to bottom?” a single connectivity query.

Entity merging

Accounts sharing a verified identifier can be unioned transitively. A final scan groups records by representative.

The Monotone Sweet Spot

DSU is ideal when relationships are added and never removed. If edges arrive as a stream, every successful union monotonically coarsens the partition. It answers connectivity, component count, and root-level aggregates without searching the full component on each query.

What DSU Does Not Know

A representative proves that two elements are connected, but the parent forest is not a path in the original graph. Standard DSU cannot return the actual graph path, its length, a shortest path, or the edges responsible for connectivity unless you store additional structures.

Variants & Limitations

NeedVariant or strategyMain tradeoff
Undo recent mergesRollback DSULog parent/size changes and usually omit path compression
Offline edge deletionsProcess queries backwardA deletion becomes an addition; requires the full query sequence
Parity or relative differencesPotential / weighted DSUStore a value from each node to its parent and update it during compression
Arbitrary online insertions and deletionsFully dynamic connectivity structureSubstantially more complex than DSU

Standard path-compressed DSU cannot split a component efficiently because its forest remembers the partition, not the history of original edges. Rollback DSU is the important exception to “no undo”: attach by size, record each changed field on a stack, and restore a previous snapshot. Avoid ordinary path compression because one find could rewrite many unlogged pointers.

Pitfalls & Debugging

Six Common Failure Modes

  1. Comparing raw parents: use find(x) == find(y); two nodes can share a root without sharing an immediate parent.
  2. Linking non-roots: compute representatives before changing any parent pointer.
  3. Reading stale size: size[v] is meaningful only when $v$ is a root.
  4. Decrementing count twice: a redundant union must leave count unchanged.
  5. Using DSU for directed reachability: ordinary DSU models undirected equivalence, not one-way paths.
  6. Combining rollback with unlogged compression: compression mutates many pointers and invalidates simple snapshots.

Exercises

  1. Trace: starting from six singleton sets, run the unions in the first SVG and write the parent and size arrays after every successful merge.
  2. Redundant edge: call union(A,D) after A and D are already connected. Explain why the method returns false and the component count does not change.
  3. Height proof: prove that union by size alone limits every node's depth to $\lfloor\log_2 n\rfloor$.
  4. Percolation: model a 3×3 grid with virtual top and bottom nodes, then find the first opened cell that creates a top-to-bottom connection.
  5. Rollback challenge: implement snapshot() and rollback(snapshot) using union by size without path compression.

Mental Model to Keep

A representative names a set; it is not an original-graph path. Union by size keeps parent trees shallow, path compression makes observed paths flatter, and every query reduces to comparing roots.