Pattern 15 of 20

Union-Find

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).

  1. Counting connected components with edges given as pairs - initialize n singleton components and union every connected pair.
  2. Walk the upper triangle of the matrix (it is symmetric); each successful union decrements the component counter.
  3. The answer is the counter afterwards - no second pass, no root-counting needed.
  4. 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).

  1. A tree on n nodes has exactly n-1 edges; the extra edge closes exactly one cycle. Process edges in order, unioning endpoints.
  2. An edge whose endpoints already share a root would close a cycle - union returns false precisely there.
  3. Because edges are processed in input order, the first failing union is the answer the problem asks for.
  4. 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.

  1. Shared email = same person, and personhood is transitive through chains of shared emails - an equivalence relation, so DSU.
  2. 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.
  3. After all unions, group emails by find(root) into buckets; each bucket is one person, labeled by any member account's name.
  4. 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.

Keep this pattern in memory

Reading it once won't stick. Sign in and CoderFlow will bring it back right before you'd forget it.

Start reviewing