Disjoint-Set Union-Find

Imagine a network of computers being connected one cable at a time. After each cable, you must instantly answer a question. Are these two machines now in the same network? A naive approach — flooding a search through the graph every time — becomes unbearably slow as the network grows. What is needed is a structure that tracks groups of connected items. It must merge two groups, or test if two items belong to the same group. This must happen in essentially constant time. This is exactly the problem the disjoint-set (or union-find) data structure solves. It maintains a collection of disjoint sets over a universe of elements. It supports two operations: `Find(x)`, which reports which set `x` belongs to, and `Union(x, y)`, which merges the sets containing `x` and `y`. These operations look deceptively simple. But a naive implementation degrades to linear time per operation. The elegance of union-find lies in two small, independently discovered optimizations. These are union by rank and path compression. Together they push the amortized cost per operation down. The result is a function so slowly growing it is, for all practical purposes, constant. Union-find is the workhorse behind Kruskal's minimum spanning tree algorithm. It also supports cycle detection in graphs, and connected-component queries in dynamic graphs. It also drives unification algorithms in compilers and theorem provers. It supports image-processing tasks like connected-component labeling. It also appears inside register allocation, network connectivity monitoring, and percolation simulations in physics. Anywhere a system must repeatedly answer "same group?" and "merge these groups," this structure is the default choice. This holds true over a growing sequence of union operations. This article builds union-find from first principles. It covers the naive forest representation, and the two optimizations that tame its worst case. It includes a careful complexity analysis culminating in the inverse-Ackermann bound. It also provides a full worked trace, and the pitfalls that trip up even experienced implementers.

Key takeaways
  • Union-find maintains disjoint sets with two operations: `Find` (which set is this in?) and `Union` (merge two sets).
  • Represent each set as a rooted tree; the root is the set's canonical representative.
  • Union by rank attaches the shorter tree under the taller one's root, keeping trees shallow.
  • Path compression flattens the tree during `Find`, so future queries are nearly instant.
  • Combined, `m` operations on `n` elements cost `O(m · α(n))` total, where `α` is the inverse Ackermann function — essentially constant.
  • It powers Kruskal's MST algorithm, cycle detection, dynamic connectivity, and compiler unification.
The core insight

Representing each set as a tree, then aggressively flattening it (path compression) while always attaching the smaller structure to the larger (union by rank), turns two seemingly linear-time operations into amortized near-constant-time ones.

The idea

A disjoint-set forest represents each set as a rooted tree where every element points to a parent, and the root of the tree is the set's designated representative. Two elements belong to the same set exactly when following parent pointers from each of them leads to the same root. This is the key structural idea: instead of storing explicit set membership lists, union-find encodes set membership implicitly through tree structure, using only one array of parent pointers. Two elements `x` and `y` are in the same set if and only if `Find(x) = Find(y)`, where `Find` walks up parent pointers until it reaches a node that is its own parent (the root). `Union(x, y)` merges the two sets by first finding the roots of `x` and `y`, and if they differ, making one root point to the other. Initially, in `MakeSet(x)`, every element is its own singleton set — its own root. The naive version of this idea is correct but slow: repeated unions can build a long chain (a degenerate tree resembling a linked list), making `Find` take `O(n)` time in the worst case. The fix comes from two complementary heuristics. Union by rank (or the closely related union by size) keeps trees shallow by always attaching the root of the smaller-ranked tree beneath the root of the larger-ranked tree, preventing the accidental construction of long chains. Path compression goes further: every time `Find` walks a path to the root, it rewires every visited node to point directly at the root, so subsequent queries on those nodes are instant. Neither optimization alone gives the best bound; together, they yield the celebrated near-constant amortized time per operation, expressed using the inverse Ackermann function `α(n)`.

How it works

The structure keeps two arrays indexed by element: `parent[x]`, the parent pointer, and `rank[x]`, an upper bound on the height of the subtree rooted at `x` (used only to guide unions, not maintained as an exact height once path compression starts firing).

`MakeSet(x)` initializes `x` as its own singleton set: `parent[x] = x`, `rank[x] = 0`.

`Find(x)` walks parent pointers to the root, then compresses the path so every node on it points directly to the root. This is most cleanly written recursively: ``` FIND(x): if parent[x] != x: parent[x] = FIND(parent[x]) // path compression return parent[x] ``` An equivalent, stack-free iterative version first walks to the root, then walks again to relink: ``` FIND-ITER(x): root = x while parent[root] != root: root = parent[root] // second pass: path compression while parent[x] != root: next = parent[x] parent[x] = root x = next return root ```

`Union(x, y)` finds the two roots and, if they differ, attaches the shorter tree under the taller one, using `rank` as the tie-breaking guide: ``` UNION(x, y): rx = FIND(x) ry = FIND(y) if rx == ry: return // already in the same set if rank[rx] < rank[ry]: swap(rx, ry) parent[ry] = rx // attach shorter/equal-rank tree under taller if rank[rx] == rank[ry]: rank[rx] = rank[rx] + 1 ```

`Connected(x, y)` is simply `FIND(x) == FIND(y)`. The full pseudocode for building a disjoint-set structure over `n` elements and processing a sequence of operations: ``` MAKE-SET(x): parent[x] = x rank[x] = 0 INITIALIZE(n): for x = 1 to n: MAKE-SET(x) ``` A subtle but important detail: `rank[x]` is only ever incremented when two trees of equal rank are merged (the tie-break case). This guarantees rank behaves like an upper bound on tree height even though path compression can make actual heights shrink below the stored rank. Because rank never decreases, the invariant "a tree rooted at `x` with rank `r` has at least `2^r` nodes" (proved below) is preserved throughout the algorithm's execution, and this is precisely what bounds the tree heights logarithmically even before path compression is added.

Figure
Disjoint-set forest before and after Find(f) with path compression
Before FIND(f): chain a ← b ← c ← d ← e ← f a b c d e f Root is a. FIND(f) walks f→e→d→c→b→a (5 hops). After FIND(f): all nodes point directly to root a a b c d e f Future FIND on b, c, d, e, f now takes 1 hop.

Complexity & correctness

Correctness. The core invariant is: at every point in the execution, the disjoint-set forest partitions the universe into trees, and two elements are in the same set if and only if they have the same root. This holds by induction on the operation sequence. `MakeSet(x)` trivially creates a singleton tree rooted at `x`. `Find(x)` never changes which tree any node belongs to — it only shortcuts pointers within the same tree, since every relinked node still ultimately points to the same root it pointed to before. `Union(x, y)` only changes the tree structure by attaching one entire root (and hence its whole tree) under another root; this merges exactly the two sets containing `x` and `y`, without touching any other tree. Hence the partition into sets, defined by "same root", is preserved correctly across arbitrary sequences of operations, and set membership queries via `Find` equality are always accurate.

Why union by rank keeps trees shallow. Define the rank of a root as the length of the longest root-to-leaf path at the time it was assigned as a non-root under the rule "rank only increases when merging equal-rank trees." One can prove by induction that a tree rooted at a node of rank `r` contains at least `2^r` nodes. Base case: rank `0` trees are singletons, `2^0 = 1` node. Inductive step: a tree of rank `r+1` is formed only by merging two trees of rank `r`, each with at least `2^r` nodes, giving at least `2^{r+1}` nodes. Since ranks are bounded by `log n` (because `2^r ≤ n`), tree height is `O(log n)` using union by rank alone, even without path compression. This already gives `O(log n)` per operation.

Why path compression alone helps, and why together they are better still. Path compression flattens trees on every find, so repeated queries on the same elements become cheap. Used alone (with naive union), a sequence of `m` operations costs `O(m log n)` amortized. The real payoff appears when both optimizations are combined: a classical result, first analyzed rigorously by Tarjan, shows that a sequence of `m` union/find operations on `n` elements takes ``` O(m · α(n)) ``` total time, where `α(n)` is the inverse Ackermann function — the functional inverse of the extraordinarily fast-growing Ackermann function. `α(n)` grows so slowly that `α(n) ≤ 4` for any `n` up to numbers vastly larger than the number of atoms in the observable universe. For every practical input size, `α(n)` is a small constant (at most 4 or 5), so the amortized cost per operation is, in effect, `O(1)`.

Proof sketch of the bound (potential-function argument). The standard proof assigns each node a level based on how large its subtree's rank gap is relative to a tower-of-twos function, and bounds the number of times a node can jump levels due to path compression across the whole operation sequence. Nodes are partitioned into `O(α(n))` groups by rank ranges growing as fast as the Ackermann hierarchy; a node's parent pointer can be "compressed past" a boundary of its group only once per group, because rank is monotonically increasing along any root path and never decreases. Summing the bounded number of expensive per-node compressions across all `n` nodes and adding the `O(1)` work per operation for cheap steps yields the `O(m α(n))` total — the details require careful bookkeeping but the intuition is: each node can only be "compressed away from its group" a bounded number of times before it never needs compressing again relative to that scale, and there are only `α(n)` scales to exhaust.

Space complexity. Two arrays of size `n` (`parent`, `rank`), so `O(n)` space — no auxiliary structures needed.

Honest tradeoffs. Union-find does not support splitting a set back apart (no efficient `Delete` or `Split`), does not preserve insertion order or enumerate a set's members efficiently without extra bookkeeping (e.g., an auxiliary linked list per set for that purpose), and offers no way to recover the original tree shape once compressed — it is a pure query structure for connectivity, not a general graph structure. If arbitrary set splitting is required, other structures (like a fully dynamic connectivity structure with link-cut trees) are needed instead, at higher cost.

Worked example

Trace `Union` and `Find` over elements `{1,...,8}` with union by rank and path compression, processing the operations in order.

StepOperationActionResulting forest (parent pointers)rank array
0`Initialize(8)`each `i` its own root`1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8`all 0
1`Union(1,2)`roots 1,2 equal rank → attach 2 under 1, bump rank[1]`2→1`rank[1]=1
2`Union(3,4)`roots 3,4 equal rank → attach 4 under 3, bump rank[3]`2→1, 4→3`rank[3]=1
3`Union(1,3)`roots 1,3 equal rank (1) → attach 3 under 1, bump rank[1]`3→1, 4→3, 2→1`rank[1]=2
4`Union(5,6)`roots 5,6 equal rank → attach 6 under 5`6→5`rank[5]=1
5`Union(7,8)`roots 7,8 equal rank → attach 8 under 7`8→7`rank[7]=1
6`Union(5,7)`roots 5,7 equal rank (1) → attach 7 under 5`7→5`rank[5]=2
7`Union(1,5)`roots 1,5 equal rank (2) → attach 5 under 1`5→1`rank[1]=3
8`Find(8)`walk `8→7→5→1`; root is 1; compress: `parent[8]=1, parent[7]=1` (5 already =1, no change needed since it's on the path too — `parent[5]=1` unchanged)`8→1, 7→1` (path compression applied)ranks unchanged
9`Find(4)`walk `4→3→1`; root is 1; compress: `parent[4]=1``4→1`ranks unchanged

After step 9, every element's parent points directly at `1` except where the tree structure already had direct children of `1` (namely `2`, `3`, `5`, and now `4`, `7`, `8`). The single tree is now of height at most 2, down from a height-3 tree before compression — subsequent `Find` calls on any of `2..8` are single hops. Trace `Find(8)` step by step to make the compression mechanics explicit: ``` FIND(8): parent[8] = 7 ≠ 8, so recurse: FIND(7) parent[7] = 5 ≠ 7, so recurse: FIND(5) parent[5] = 1 ≠ 5, so recurse: FIND(1) parent[1] = 1 → base case, return 1 parent[5] = 1 (unchanged, already correct) return 1 parent[7] = 1 (compressed from 5 to 1) return 1 parent[8] = 1 (compressed from 7 to 1) return 1 ``` This confirms the table: the recursive unwind rewrites `parent[7]` and `parent[8]` to point straight to the true root `1`, while `parent[5]` needed no change since it already pointed at `1`.

Common pitfalls & traps

PitfallWhy it happensFix
Forgetting path compression in `Find`Copy-pasting a "walk to root" loop without the second relinking passAlways rewrite visited nodes' parents to the root before returning
Comparing `x == y` instead of `Find(x) == Find(y)`Confusing element identity with set membershipConnectivity queries must go through `Find`, never compare raw labels
Using union by rank and incrementing rank on every unionMisremembering the rule as "always +1"Increment rank only when merging two roots of equal rank
Treating `rank` as an exact height after compressionAssuming rank is recomputed liveRank is only an upper bound guide for merging; never recompute it from current tree shape
Off-by-one in `MakeSet` indices (0-indexed vs 1-indexed arrays)Mixing element IDs with array indices inconsistentlyFix an indexing convention up front and initialize all elements before any union
Assuming O(1) worst case per single operationConfusing amortized bound with per-call worst caseState the bound as amortized `O(α(n))` over a sequence, not a hard per-call guarantee
No way to undo a unionStructure is not designed for splitting setsUse a separate rollback log (union by rank without path compression) if undo is required, e.g., for offline algorithms with backtracking

Pioneers & history

The disjoint-set forest with union by rank and path compression is one of the cleanest success stories in algorithm design: two simple local rules combine to defeat what looks like an unavoidable logarithmic — or even linear — cost, and the proof that they do so requires genuinely deep combinatorial analysis connecting data structures to the Ackermann function, a connection that surprised the field.

Origins · 1 minRobert Endre Tarjan and how this was discovered
RE
Robert Endre Tarjan
born 1948 · Princeton University and Bell Labs, pioneer of amortized analysis and graph algorithms

Tarjan formalized the amortized complexity of union-find with path compression and union by rank, proving the inverse-Ackermann bound and establishing amortized analysis as a rigorous tool. His work with Jan van Leeuwen and others sharpened the bounds further and connected union-find's performance to the fast-growing hierarchy of functions studied in recursion theory, a result that remains a canonical example of "surprising tightness" in data structure analysis.

History
1964
Bernard Galler and Michael Fischer describe an early union-find data structure with union heuristics.
1975
Robert Tarjan analyzes path compression, proving an `O(log n)` amortized bound and laying groundwork for tighter results.
1975
Tarjan (with later refinements by Tarjan and van Leeuwen) proves the `O(m α(n))` bound combining union by rank with path compression.
1984
Tarjan and van Leeuwen publish a systematic comparison of union-find heuristics, tightening constants and clarifying variants (union by size vs. rank, full vs. partial compression).
1985
onward: The inverse-Ackermann bound becomes a standard exercise in advanced algorithms courses and a canonical example of amortized analysis via potential functions.

Where this matters

Union-find is embedded in the machinery of many production systems and classical algorithms. Kruskal's minimum spanning tree algorithm relies on union-find to detect, in near-constant time, whether adding an edge would create a cycle — sorting edges by weight and using `Find` to check components is what makes Kruskal's algorithm run in `O(E log E)` dominated by the sort, not the connectivity checks. Cycle detection in undirected graphs, generally, uses the same idea: process edges, and if both endpoints are already in the same set, a cycle exists. In compilers and type systems, union-find implements efficient unification for type inference (as in Hindley-Milner style algorithms), where type variables are merged as constraints are discovered. In computer vision, connected-component labeling in binary images uses union-find to merge pixel labels across scan lines in a single pass. In network and social-graph analytics, streaming connectivity queries — "are these two nodes in the same connected component after this batch of edge insertions?" — are answered with union-find when only insertions (not deletions) occur, which is the common case for "add friend," "merge accounts," or "add link" style event streams. It also underlies percolation simulations in statistical physics and least common ancestor offline algorithms (Tarjan's off-line LCA algorithm reuses the same forest). Anywhere a system incrementally merges group memberships and must repeatedly test group equality, this structure — building on ideas from Arrays and Dynamic Arrays for its backing storage and echoing tree-traversal patterns familiar from Stacks-based iterative traversal — is the default and near-optimal tool, closely related in spirit to broader techniques in Amortized Analysis used throughout data structure design.

Ask the tutor: Can you show how union-find's amortized bound is proved using a potential-function argument over rank-based groupings, in full detail?

Why can't union-find efficiently support removing an element from a set?

The tree representation only records "who is my parent," not "who are my children." Removing a node from the middle of a tree would require finding and relinking all of its descendants to a new ancestor, which the structure has no efficient way to enumerate — there is no reverse pointer. Supporting deletions efficiently generally requires an entirely different structure, such as a fully dynamic connectivity data structure built on Euler tour trees or link-cut trees, which maintain extra balancing invariants at a higher constant-factor cost. Union-find is intentionally specialized for a stream of insert-only merges (unions) and membership queries (finds), which is exactly the pattern in algorithms like Kruskal's MST, so its design deliberately trades away deletion support for extreme simplicity and speed on the operations it does support.

Is union-find's per-operation time really O(1), or is that an oversimplification?

It is an amortized bound, not a per-call worst-case guarantee. A single unlucky `Find` call, on a tree that happens to still be tall because compression has not yet flattened that particular path, can take longer than constant time. What the inverse-Ackermann theorem guarantees is that summed over any sequence of `m` operations, the total work is `O(m · α(n))`, so the average cost per operation is near-constant. For a single isolated call in the worst possible circumstance, the bound is closer to `O(log n)`; it is the combination of many calls, each contributing to flattening the structure for the others, that produces the striking amortized result.

What is the difference between union by rank and union by size?

Union by size attaches the tree with fewer nodes under the root of the tree with more nodes, using a maintained node count instead of rank. Union by rank uses an approximate height bound instead. Both achieve the same asymptotic guarantee — trees stay at height `O(log n)` before path compression, and `O(α(n))` amortized with it — because both rules prevent a small tree from ever being placed as the new parent of a large tree, which is the actual property that matters for the height bound. In practice they perform almost identically; union by size is sometimes preferred because "how many elements are in this set" is often independently useful information the application wants anyway, whereas rank has no meaning outside the internals of the structure.

Can union-find be used to detect cycles in an undirected graph?

Yes, and this is one of its most common practical applications. Process edges one at a time: for edge `(u, v)`, call `Find(u)` and `Find(v)`. If they return the same root, `u` and `v` were already connected before this edge, so adding it would create a cycle. Otherwise, call `Union(u, v)` to merge their components and continue. This is exactly the mechanism inside Kruskal's minimum spanning tree algorithm, where edges are considered in increasing weight order and only added if they connect two previously separate components, guaranteeing the resulting edge set is acyclic and spans the graph with minimum total weight.

Does path compression change the correctness guarantees, or only performance?

Only performance — correctness is unaffected. Path compression never changes which set any element belongs to; it only shortens the path from a node to its (unchanged) root. Since `Find` returning the same root for two elements is the definition of "same set," and compression preserves every element's eventual root, all reachability relationships and thus all past and future query answers remain exactly as they would be without compression. The optimization is purely about reducing future traversal cost, never about altering which merges have occurred.

What happens if you call Union on two elements already in the same set?

Both `Find` calls return the same root, the equality check short-circuits, and the function returns without modifying any parent or rank value. This is essential for correctness in algorithms like Kruskal's, where redundant edges within an already-connected component must be safely ignorable no-ops rather than errors, and it also means `Union` is idempotent: calling it repeatedly on the same pair (or any pair already connected transitively) has no further effect on the structure after the first successful merge.

How does the inverse Ackermann function actually behave numerically?

The Ackermann function A(m, n) grows faster than any primitive recursive function — even towers of exponentials are dwarfed by it for modest arguments. Its inverse α(n) is defined so that α(n) is the smallest m such that A(m, m) ≥ n (informally). Because A grows so explosively, α(n) stays at 4 or below for any n up to a number with more digits than atoms in the observable universe. In every realistic computing context, α(n) behaves as the constant 4, which is why practitioners state the union-find bound colloquially as "amortized constant time," even though the formally precise statement involves this slowly growing but technically unbounded function.

Before this