Track connected components with near-constant-time merges - cycles, islands, and network connectivity.
What union-find is
Union-Find (disjoint set union, DSU) maintains a collection of non-overlapping groups under two operations: find(x) - which group does x belong to - and union(x, y) - merge their groups. Each group is a tree of parent pointers whose root is the group's representative; two elements are connected exactly when their finds return the same root.
With two standard optimizations - path compression (during find, repoint every visited node at the root) and union by rank/size (attach the shorter tree under the taller) - both operations run in effectively constant amortized time (inverse Ackermann, below 5 for any input that fits in the universe).
How to recognize it
Signals: connectivity that CHANGES as edges arrive - 'are these connected after each merge', 'when does adding this edge create a cycle', 'how many groups remain', account/equivalence merging. The 'dynamic' part is the differentiator: on a static graph, DFS answers connectivity just as well; union-find shines when edges stream in and queries interleave.
Equivalence relations are the other tell: 'a == b and b == c implies a == c' - variable equality constraints, synonymous names, accounts sharing an email - all are union-find in disguise, sometimes with a hash map from strings to node ids in front.
The implementation
Two arrays: parent (initially parent[i] = i, everyone their own root) and rank (tree-height upper bound, initially 0). Find follows parents to the root, compressing on the way. Union finds both roots; if equal, the edge is internal (this is the cycle detector); otherwise attach lower rank under higher, incrementing rank only on ties. Track a component counter that decrements per successful union - 'number of groups' questions read it directly.
pseudocode
parent[i] = i for all i; rank[i] = 0; components = n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def union(x, y):
rx, ry = find(x), find(y)
if rx == ry: return false # already connected (cycle!)
if rank[rx] < rank[ry]: swap rx, ry
parent[ry] = rx
if rank[rx] == rank[ry]: rank[rx] += 1
components -= 1
return true
Complexity
m operations on n elements cost O(m x alpha(n)) with both optimizations - alpha is the inverse Ackermann function, effectively a constant. Space is O(n). Either optimization alone gives O(log n) amortized, still fine for interviews; using neither degrades to O(n) chains and is the classic silent performance bug.
Common pitfalls
Comparing parent[x] == parent[y] instead of find(x) == find(y) - parents are not roots except in fully compressed trees.
Skipping both optimizations: a sorted stream of unions builds a linked list and every find becomes O(n).
Forgetting that union returning false IS the cycle detection - re-deriving cycle checks with DFS next to a DSU is redundant.
String or coordinate elements: map them to integer ids first (hash map), or use a hash-map-keyed parent store.
Union-find handles merges only - there is no efficient 'un-union'. Deletion or time-travel questions need offline tricks or different structures; say so rather than improvising.
Worked examples
1. Number of provincesmedium
Given an adjacency matrix where isConnected[i][j] = 1 means cities i and j are directly connected, count the provinces (connected groups of cities).
Counting connected components with edges given as pairs - initialize n singleton components and union every connected pair.
Walk the upper triangle of the matrix (it is symmetric); each successful union decrements the component counter.
The answer is the counter afterwards - no second pass, no root-counting needed.
O(n^2 x alpha) time driven by reading the matrix. DFS solves the static version equally well; union-find wins the follow-up 'now connections arrive one at a time'.
pseudocode
dsu = DSU(n) # components = n
for i in 0 .. n-1:
for j in i+1 .. n-1:
if isConnected[i][j]:
dsu.union(i, j)
return dsu.components
Remember: Seed n singletons and let each successful union decrement a counter - components are counted by merging, not by traversal.
2. Redundant connectionmedium
A tree with one extra edge added is given as an edge list. Find the edge that can be removed to restore a tree (the last such edge in input order).
A tree on n nodes has exactly n-1 edges; the extra edge closes exactly one cycle. Process edges in order, unioning endpoints.
An edge whose endpoints already share a root would close a cycle - union returns false precisely there.
Because edges are processed in input order, the first failing union is the answer the problem asks for.
O(E x alpha) with no explicit graph built at all. Compare aloud: DFS cycle-finding needs the whole graph first; DSU detects the cycle at insertion time.
pseudocode
dsu = DSU(n)
for (u, v) in edges:
if not dsu.union(u, v): # already connected
return (u, v) # this edge closes the cycle
Remember: union() returning 'already connected' is cycle detection - the offending edge announces itself the moment it arrives.
3. Accounts mergemedium
Each account is a name plus a list of emails; accounts sharing any email belong to the same person. Merge them and return each person's sorted emails.
Shared email = same person, and personhood is transitive through chains of shared emails - an equivalence relation, so DSU.
Elements are emails: map each email to an integer id (or DSU keyed by string); within one account, union all its emails to the first.
After all unions, group emails by find(root) into buckets; each bucket is one person, labeled by any member account's name.
Sort each bucket for output. O(total emails x alpha) plus sorting. The transferable move: hash-map from domain objects to DSU ids makes DSU work on anything.
pseudocode
id = map email -> integer (assign on first sight)
owner = map email -> account name
for account in accounts:
for email in account.emails[1:]:
dsu.union(id[email], id[account.emails[0]])
groups = map root -> list of emails, via find(id[email])
return for each group: [owner name] + sorted emails
Remember: Transitive 'same as' relations are DSU's home turf - map real-world keys to ids, union within each record, then bucket by root.
Check yourself
Try to answer before revealing - retrieving from memory is what makes it stick.
1What do path compression and union by rank each accomplish?
Compression saves memory; rank saves time
✓ Compression flattens trees during find; rank prevents trees from growing tall during union - together they make operations effectively O(1) amortized
They are alternative names for the same optimization
They enable deletion of elements
Rank keeps merges from producing chains; compression retroactively flattens whatever chains form. Combined, m operations cost O(m x alpha(n)) - inverse Ackermann, below 5 for any realistic n.
2How does union-find detect a cycle in an incrementally built graph?
By running DFS after each insertion
✓ An edge whose two endpoints already have the same root would close a cycle - union reports this by failing
By counting edges against nodes
It cannot detect cycles
Same root means an existing path already joins the endpoints; adding a direct edge creates a second path, i.e. a cycle. The check is free - it is the first step of union.
3When does union-find beat DFS for connectivity?
Always - it is asymptotically faster
✓ When connectivity evolves: edges arrive over time with queries interleaved, or you only need component counts as merges happen
On directed graphs
When the graph is a tree
DFS answers static connectivity in O(V+E) just fine. DSU's advantage is incremental: each new edge costs near-O(1) and queries are instant, with no re-traversal - and note it handles merges only, never edge deletions.
4Why must connectivity checks call find(x) == find(y) rather than compare parent[x] == parent[y]?
✓ parent[] may contain stale intermediate nodes - only the roots identify components, and find follows (and compresses) the chain to reach them
The two are equivalent
parent[] is private to the structure
find() is needed to update ranks
Except immediately after full compression, parent[x] is just the next hop, not the representative. Two connected elements can easily have different immediate parents.
Frequently asked questions
How do I use union-find with strings or coordinates as elements?
Assign integer ids on first sight via a hash map (or key the parent map by the objects directly). Grid problems flatten (row, col) to row * cols + col. The DSU core never changes.
Can union-find handle removing edges?
Not directly - merges are one-way. Standard workarounds: process queries offline in reverse (deletions become insertions), or use heavier structures (link-cut trees). In an interview, naming the limitation and the reverse-processing trick is usually the expected answer.
Is union by size different from union by rank?
Functionally interchangeable: size attaches the smaller tree, rank attaches the shorter one (by height bound). Both keep trees shallow and hit the same asymptotics; size has the small bonus of tracking component sizes for free.