Union-Find
Quick reference
| find(i) β with path compression + union by rank | O(Ξ±(n)) β O(1) |
| union(i, j) β with path compression + union by rank | O(Ξ±(n)) β O(1) |
| find(i) β no path compression | O(n) worst case |
| union(i, j) β no union by rank | O(n) worst case |
Picture a party where people keep getting introduced to each other. Every introduction between two people joins their two friend groups into one bigger group. At any point someone might ask: 'are Alice and Bob in the same friend group?' Answering that by re-tracing every introduction that ever happened would be slow and get slower as the party goes on. Union-find answers it almost instantly instead, by having every person keep a pointer to their group's current leader β check whether Alice's leader and Bob's leader are the same person, and you have your answer without retracing anything.
That's the whole structure: two operations, find (who's your group's leader?) and union (merge two groups into one). No traversal, no adjacency list, no visited set β none of the machinery from the last two topics. It answers a narrower question than a general graph does β only 'same group or not' and 'merge these groups' β but it answers that narrower question far faster than running DFS/BFS from scratch every time someone asks.
The array
structure UnionFind:
parent: array where parent[i] = i's parent (initially, every node is its own parent β its own group of one)
rank: array where rank[i] = a rough upper bound on the height of i's tree (initially 0 for everyone)find β follow parent pointers to the root, with path compression
function find(i):
if parent[i] != i:
parent[i] = find(parent[i]) // path compression: point straight at the root
return parent[i]union β merge two roots, with union by rank
function union(i, j):
rootI = find(i)
rootJ = find(j)
if rootI == rootJ:
return // already in the same group β nothing to do
if rank[rootI] < rank[rootJ]:
parent[rootI] = rootJ // attach the shorter tree under the taller one
elif rank[rootI] > rank[rootJ]:
parent[rootJ] = rootI
else:
parent[rootJ] = rootI // equal rank β pick one, and it grows taller
rank[rootI] += 1With both path compression and union by rank in place, find and union are essentially O(1) β technically O(Ξ±(n)), where Ξ± is the inverse Ackermann function. That name sounds intimidating; what it means in practice is simple: Ξ±(n) grows so slowly that for any number of elements you could ever actually have β not just thousands, but numbers far beyond the count of atoms in the observable universe β Ξ±(n) stays 4 or smaller. Say it plainly: for any real input, this is a constant number of steps. Drop either optimization and that guarantee is gone.
| Operation | Time | Space | Why |
|---|---|---|---|
| find(i) β with path compression + union by rank | O(Ξ±(n)) β O(1) | O(1) | Amortized over many calls, the tree stays so flat that walking to the root is, for any practical n, a small constant number of steps. |
| union(i, j) β with path compression + union by rank | O(Ξ±(n)) β O(1) | O(1) | Two find() calls plus one pointer reassignment β same near-constant bound as find, since that's the dominant cost. |
| find(i) β no path compression | O(n) worst case | O(1) | Every call re-walks the full chain from i to the root with nothing flattened afterward β repeated unions in the wrong order can leave that chain n nodes long. |
| union(i, j) β no union by rank | O(n) worst case | O(1) | Always attaching one specific side (say, j under i) regardless of tree size can chain n single-node trees onto one growing spine, one union at a time. |
find without path compression
The structure still gives correct answers β the root it finds is still the right root. What's lost is speed: every call re-walks the same chain from scratch instead of shortening it for next time. Enough unions chained in the wrong order and the tree degrades toward the same skewed, linked-list-like shape a BST degrades into under sorted insertions β a straight line of n nodes, one parent pointer at a time, and find becomes O(n).
union without rank (or size)
Same failure, different cause. If union(i, j) always makes j's root a child of i's root no matter their sizes, then a sequence like union(1,2), union(1,3), union(1,4), ... keeps stacking single-node trees onto the same growing spine β the tree's height grows by one with every union instead of staying flat. find still returns correct roots; it just has to walk further and further to get there.
Skipping the 'already same group' check before unioning
This is inefficiency, not a bug β worth telling apart from the two cases above. Re-running find on both, discovering rootI == rootJ, and doing nothing wastes a little work but produces the exact same structure as if the check caught it early. Contrast with skipping path compression or rank: those don't crash anything either, but they compound into real O(n) slowdowns as more operations run. This one is just a wasted comparison, once.
Forgetting to initialize every node as its own parent
find(i) assumes it can walk parent pointers starting from the premise that an untouched node is its own root β a group of one, by itself. Skip the initialization (leave parent[i] at some default like 0 or undefined) and find(i) either walks toward the wrong root entirely or crashes on an undefined lookup, depending on the language. Every node needs parent[i] = i set explicitly before the structure is used, even nodes that never end up in any union call.
Sign in to mark problems done β progress syncs across devices.