LearnAbout

Union-Find

On this page
Quick reference
find(i) β€” with path compression + union by rankO(Ξ±(n)) β‰ˆ O(1)
union(i, j) β€” with path compression + union by rankO(Ξ±(n)) β‰ˆ O(1)
find(i) β€” no path compressionO(n) worst case
union(i, j) β€” no union by rankO(n) worst case
The Idea

Imagine a party where people are 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?" To answer this by a re-trace of every introduction that ever happened would be slow, and it would become slower as the party continues. Union-find answers it almost immediately instead. Every person keeps a pointer to the current leader of their group. Check whether the leader of Alice and the leader of Bob are the same person, and you have your answer with no re-trace.

This is the whole structure: two operations, find (who is the leader of your group?) and union (merge two groups into one). There is no traversal, no adjacency list, and 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 much faster than a new DFS/BFS every time someone asks.

DFS and BFS answer "is there a path from A to B" by an actual walk of the graph, every time. Union-find answers "have A and B ever been merged" by a maintained set of group leaders as merges happen. So the answer is almost instant, however large the graph has become.
Build It
The array
structure UnionFind:
    parent: array where parent[i] = the parent of i (at the start, every node is its own parent, its own group of one)
    rank: array where rank[i] = a rough upper bound on the height of the tree of i (at the start, 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 directly at the root
    return parent[i]
A root is a node that is its own parent, the leader of the group. Without the reassignment on the recursive call, find still works. It just walks the same long chain again on every future call. With the reassignment, every node visited on the way up is repointed directly at the root before the call returns. So the next find() on any of them is one step, not a chain-walk. This is the amortized idea from Big-O Notation, made concrete. Any single call can still walk a long chain, but it also flattens that chain for every call after it.
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 tree
    elif rank[rootI] > rank[rootJ]:
        parent[rootJ] = rootI
    else:
        parent[rootJ] = rootI           // equal rank: pick one, and it grows taller
        rank[rootI] += 1
To attach the shorter tree under the taller tree (instead of, for example, always attach j under i without a check of size) keeps the trees from growing into long chains as more unions happen. rank changes only when two equal-rank trees merge, because that is the one case where the result tree is genuinely one level taller than either input.
Know It

With 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 difficult, but its meaning in practice is simple: Ξ±(n) grows so slowly that for any number of elements that you could ever have (not only thousands, but numbers far beyond the count of atoms in the observable universe), Ξ±(n) stays 4 or smaller. In plain words: for any real input, this is a constant number of steps. If you drop either optimization, that guarantee is gone.

OperationTimeSpaceWhy
find(i) β€” with path compression + union by rankO(Ξ±(n)) β‰ˆ O(1)O(1)Amortized over many calls, the tree stays so flat that a walk to the root is, for any practical n, a small constant number of steps.
union(i, j) β€” with path compression + union by rankO(Ξ±(n)) β‰ˆ O(1)O(1)This is two find() calls plus one pointer reassignment, the same near-constant bound as find, because find is the main cost.
find(i) β€” no path compressionO(n) worst caseO(1)Every call walks the full chain from i to the root again, with nothing flattened after. Repeated unions in the wrong order can leave that chain n nodes long.
union(i, j) β€” no union by rankO(n) worst caseO(1)To always attach one specific side (for example, j under i) without a check of tree size can chain n single-node trees onto one growing spine, one union at a time.
Break It

find without path compression

the result of the recursive call is never written back to parent[i]

The structure still gives correct answers. The root that it finds is still the right root. What is lost is speed. Every call walks the same chain again from the start instead of a shorter chain for next time. Enough unions chained in the wrong order, and the tree degrades toward the same skewed, linked-list-like shape that a BST degrades into under sorted insertions: a straight line of n nodes, one parent pointer at a time. find becomes O(n).

union without rank (or size)

union always attaches one side under the other without a check of which tree is bigger

This is the same failure, with a different cause. If union(i, j) always makes the root of j a child of the root of i, without a check of 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 height of the tree 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

union(i, j) is called on two nodes that already share a root

This is inefficiency, not a bug, worth telling apart from the two cases above. To run find on both, find that rootI == rootJ, and do nothing wastes a little work. But it produces the exact same structure as if the check caught it early. Compare this to a skip of path compression or rank: those do not crash anything either, but they build up into real O(n) slowdowns as more operations run. This one is only a wasted comparison, one time.

Forgetting to initialize every node as its own parent

parent[i] is not set to i for some node before any find/union runs

find(i) assumes that it can walk parent pointers from the premise that an untouched node is its own root, a group of one, by itself. If you skip the initialization (leave parent[i] at some default such as 0 or undefined), find(i) either walks toward the wrong root or crashes on an undefined lookup, based on the language. Every node needs parent[i] = i set explicitly before you use the structure, even nodes that never end up in any union call.

Use It
Number of Provinces
union() every pair that the input matrix marks as connected. Then count the distinct roots, which is the number of distinct find() results across every node.
Medium
Redundant Connection
Process the edges in order and union() each pair. The first edge where find(i) == find(j) before the union is the one that creates a cycle, and the answer.
Medium
Number of Operations to Make Network Connected
union() every existing cable. Then count the distinct groups that result. To connect k separate groups into one always takes exactly k - 1 more cables.
Medium
Accounts Merge
union() two accounts whenever they share an email. After that, group every email by its find() root. Then combine and sort each group.
Medium
Lexicographically Smallest Equivalent String
union() each equivalent character pair, but change union() to always attach under the root that is alphabetically smaller. This is the same rank idea, used to track "smallest" instead of "tallest".
Medium
Most Stones Removed with Same Row or Column
union() any two stones that share a row or a column. The answer is the total stones minus the number of distinct groups, because every group can be reduced to one stone.
Medium
Satisfiability of Equality Equations
union() every '==' pair first. Then check every '!=' pair. If a '!=' pair shares a find() root, the equations contradict each other.
Medium
Smallest String With Swaps
union() every index pair that a swap connects. Group the indices by find() root. Sort the characters in each group. Then place them back in ascending index order.
Medium
Evaluate Division
This is a weighted variant. union() tracks not only the parent but the ratio to it. So find() must accumulate a product along the path to the root instead of only a return of who the root is.
Medium
The Earliest Moment When Everyone Become Friends
Sort the logs by timestamp. union() each pair in order. Track the group count as it becomes smaller. The timestamp where it first drops to 1 is the answer.
Medium