Algorithm

Union-Find (DSU)

Graphs Pattern

Union-Find (Disjoint Set Union)

Union-Find maintains a collection of elements partitioned into disjoint groups, supporting two operations efficiently as connections are added over time.

find(x) returns a representative element identifying the group x belongs to. Two elements are in the same group exactly when their representatives are equal.

union(x, y) merges the groups containing x and y into one.

Both operations run in effectively constant time once the two standard optimisations are applied, which makes the structure suited to problems where connections arrive one at a time and connectivity must be queried in between.

CONSTRAINTS
  • V = number of elements, typically labelled 0 to V-1
  • find(x) returns the representative of x's group; the specific value is arbitrary but consistent
  • union(x, y) merges two groups; merging elements already grouped together is a no-op
  • Connectivity is symmetric — the structure models undirected relationships only
  • O(α(V)) amortised per operation with path compression and union by rank; O(V) space
EXAMPLE 1
Input: 5 elements. union(0,1), union(3,4), then find(0) == find(1)?
Output: true
The first merge places 0 and 1 in one group, so they share a representative. The groups are {0,1}, {2}, {3,4} — note element 2 was never mentioned and remains alone.
EXAMPLE 2
Input: 5 elements. union(0,1), union(1,2), then find(0) == find(2)?
Output: true
Elements 0 and 2 were never merged directly, but the two operations chained them through 1 into a single group {0,1,2}. Connectivity is transitive, which is precisely what the structure tracks.
EXAMPLE 3
Input: 3 elements. find(0) == find(1)?
Output: false
With no unions performed, every element is its own group and its own representative. This is the starting state: V elements means V groups.
EXAMPLE 4
Input: 3 elements. union(0,1), union(0,1) again — how many groups remain?
Output: 2
The second merge finds 0 and 1 already share a representative and does nothing. Repeated unions are harmless, and detecting that a union was redundant is exactly how this structure finds cycles.
Are the elements integers from 0 to V-1?
Usually, which lets the parent structure be a plain array. If they are strings or objects, use a hash map from element to parent instead — the logic is unchanged, only the storage differs.
Does the specific value returned by find matter?
No. It is an arbitrary member of the group, and it changes as merges happen. Never store a find result and reuse it later — always re-query, since an intervening union may have changed it.
Can this structure split a group apart again?
No, and this is its main limitation. Union-Find only merges; there is no efficient delete or un-union. Problems that remove edges over time usually need to be reversed and processed backwards so that removals become additions.
Does it work on directed graphs?
Not meaningfully. It models symmetric connectivity, so applying it to directed edges tells you only whether nodes are connected when directions are ignored. Directed reachability needs a traversal.

Every graph technique so far answers connectivity by walking the graph. That works when the graph is fixed and you ask once. It fails badly under a different setting: connections arriving one at a time, with "are these two connected?" asked in between.

The problem traversal cannot solve well

Suppose friendships are added to a social network one by one, and after each addition you must answer whether two given people are connected through any chain.

With a traversal, each query means a fresh search from one person to see whether the other is reachable — O(V + E) every time. With Q queries that is O(Q × (V + E)), and on a network of millions with constant updates it is hopeless.

The waste is structural rather than incidental. Each search rediscovers the entire shape of a group merely to answer a yes-or-no question about membership. The group has not changed since the last query, yet its structure is re-derived from scratch.

That suggests keeping the grouping as a maintained fact instead of a computed one.

The reframe: give every group a name

Rather than storing which elements connect to which, store, for each element, a pointer toward a single distinguished member of its group — its representative, or root.

Now "are x and y connected?" becomes "do x and y have the same representative?" No searching, just two lookups and a comparison.

And merging two groups becomes cheap in a way that is genuinely surprising the first time. To merge, you do not touch the members at all. You take the representative of one group and point it at the representative of the other. Every element that followed the first representative now, by following one extra step, arrives at the second. One pointer write merges two groups of any size.

The structure formed is a forest: each group is a tree whose root points at itself, and find walks up from any element to its root.

python
parent = list(range(V))          # everyone starts as their own root

def find(x):
    while parent[x] != x:
        x = parent[x]
    return x

def union(x, y):
    rx, ry = find(x), find(y)
    if rx == ry:
        return False             # already together; nothing to do
    parent[rx] = ry              # one write merges two whole groups
    return True

The boolean returned by union is worth noting now, because several problems depend on it: it reports whether the merge actually did anything. A false return means the two were already connected — which, if you are adding a graph edge, means that edge closed a cycle.

Why the naive version is slow, and the two fixes

The version above has a bad case. Merge 0 into 1, then 1 into 2, then 2 into 3, and so on. Each merge points the current root at the new element, producing a single chain of length V. Now find(0) walks V steps, and the structure is no better than the linear scan it replaced.

The problem is that the trees are allowed to grow tall. Two independent fixes keep them flat, and both are needed for the full guarantee.

Union by rank. When merging, attach the shorter tree under the taller one. Attaching a shorter tree under a taller root leaves the height unchanged, because the shorter tree's deepest element is still no deeper than the taller tree's. So height only increases when two trees of equal height merge — and then it grows by exactly one while the size at least doubles. Since size can double at most log₂V times before exceeding V, the height is capped at log₂V. Tracking approximate height rather than exact size is why the field is called rank.

Path compression. Whenever find walks from an element to the root, it has just learned the answer for every element along the way. So instead of discarding that, repoint each of them directly at the root on the way back. The next lookup for any of them takes one step. A tall chain gets flattened into a shallow star the first time it is traversed, so slow lookups pay for their own elimination.

python
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.count = n                        # number of groups, useful on its own

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])   # compress on the way back
        return self.parent[x]

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False                      # already connected
        if self.rank[rx] < self.rank[ry]:     # attach shorter under taller
            rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1                # heights were equal: the tree got taller
        self.count -= 1
        return True
Crucial Noteunion must call find on both arguments and merge the roots, never the arguments themselves. Writing parent[x] = y directly detaches x from its own group, silently splitting it — and since nothing errors, the bug shows up as a wrong count much later.
Crucial Noterank is not the size of the tree and should not be used as one. It is an upper bound on height, and after path compression the real height is usually far smaller. If you need actual group sizes, track a separate size array — union by size works just as well as union by rank and gives you that number for free.
What α means

With both optimisations, the amortised cost per operation is O(α(V)), where α is the inverse Ackermann function. The precise definition is not the point; the magnitude is. α grows so slowly that for any V that could be stored on any physical computer — beyond the number of atoms in the observable universe — α(V) is less than 5.

So each operation is constant time for every practical purpose, though not constant in the strict theoretical sense. Saying "effectively constant, formally inverse Ackermann" is exactly right in an interview.

Worth being precise about amortised: an individual find on a freshly built tall chain really can cost more than constant. But that call flattens the path it walked, so the cost cannot recur. Averaged over any sequence of operations, the per-operation cost is bounded by α.

Worked example

Four elements, starting as {0}, {1}, {2}, {3}, with all ranks 0 and count 4.

- union(0, 1). Roots are 0 and 1, both rank 0. Ranks are equal, so 1 is attached under 0 and 0's rank rises to 1. Groups: {0,1}, {2}, {3}. Count 3.
- union(2, 3). Roots 2 and 3, both rank 0. Attach 3 under 2, rank of 2 becomes 1. Groups: {0,1}, {2,3}. Count 2.
- union(1, 2). find(1) walks to 0; find(2) returns 2. Ranks are both 1, so they are not swapped, 2 is attached under 0, and 0's rank rises to 2. All four elements now share root 0. Count 1.
- find(3). The walk goes 3 → 2 → 0. Path compression rewrites both: 3 now points directly at 0, and 2 already did. The tree is a flat star.
- find(3) again returns 0 in a single step, because the previous call paid to flatten the path.
- union(0, 3) now finds both roots are 0 and returns false without changing anything — the two were already connected.

That final false is the whole basis of cycle detection: an edge whose union is redundant is an edge that closes a loop.

When to choose this over traversal

Both approaches count components and detect cycles in undirected graphs, so the choice depends on the shape of the problem rather than the question.

Reach for Union-Find when the input is a list of edges rather than an adjacency structure; when edges arrive incrementally and connectivity must be queried between additions; when you need a running count of components; or when the question is specifically about whether adding an edge creates a cycle, which is a single find-comparison.

Reach for traversal when you need something about the structure rather than membership — the actual path between two nodes, distances, the vertices in each component, or anything at all about a directed graph.

Recognition signals: connected components, provinces, merging accounts, friend circles, detecting a redundant edge, building a minimum spanning tree. Kruskal's algorithm is Union-Find plus sorting the edges — you take each edge cheapest-first and keep it only if its union succeeds, which is exactly "keep it unless it closes a cycle".

Train the reflex: when connections accumulate over time and you keep being asked who is connected to whom, stop searching the graph. Give every group a name and merge names instead.

Interactive Strategy Visualization

Union-Find Evolution

Dynamic Connectivity
O(α(N)) Complexity
0
1
2
3
4
5

Phase 1: Initial State

Every element starts as its own representative (Leader). Each node points to itself.

O(V) Naive Parent Chain
O(log V) Union By Rank Alone
O(α(V)) Rank Plus Path Compression