Union-Find (Disjoint Set)

Maintain a forest where every element points toward a representative root. find() follows parents to that root (compressing the path), and union() links two roots (by rank or size). Near-O(1) amortized, it answers “are these connected?” and “how many groups?” online, as the edges arrive.

Technique3 problems
The unlock

Every element belongs to a group, and every group has one boss (the root). To ask “are x and y in the same group?” you just walk each one up to its boss and compare bosses. To mergetwo groups, you point one boss at the other. Two cheap tricks — flatten the climb (path compression) and always hang the smaller tree under the bigger (union by rank) — keep the trees nearly flat, so both questions run in near-constant time.

MENTAL MODEL A forest of bosses — find the boss, link the bosses

Don't picture a graph; picture a forest. Each element is a node that stores a single pointer: parent[i]. A node whose parent is itself is a root— the representative (“boss”) of its whole group. Everything else points, directly or transitively, at exactly one root.

  • find(x) = follow pointers to the boss. Climb parent links until you hit a node that points to itself. That self-pointer is the answer.
  • union(x, y) = link the two bosses. Find both roots; if they differ, point one root at the other. Two groups become one.
The reframe →“connected” never means “there is a path I can trace.” It means same boss. You never store the path — only the one pointer per node that eventually leads home.

SEE IT The forest, a merge, and a compressed climb

A union re-parents one root under another; a later find flattens whatever it climbed so the next lookup is even cheaper:

Union-Find is a FOREST: each element points toward its set's root.

  before union(1,3)              after union(1,3)  (root 1 absorbs root 3)

      1        3                       1
     / \      / \                     /|\
    0   2    4   5                   0 2 3
                                        / \
                                       4   5

  find(4):  4 → 3 → 1   ⇒ root is 1        connected(0,5)?  find(0)=1, find(5)=1 ✓
            └ then path-compress: parent[4]=1, parent[3]=1 (flatten the climb)

  "Are x and y connected?"  ⇒  find(x) === find(y)
  "How many groups?"        ⇒  count of i where parent[i] === i  (the roots)
The tell → the two questions Union-Find answers cheaply are “same group?” (find(x) === find(y)) and “how many groups?” (the count of self-pointing roots). If you need the actual route between x and y, this is the wrong tool.

WHY IT IS FAST Two tricks turn O(n) climbs into α(n)

A naive DSU can degenerate into a linked list — find becomes O(n) and you TLE. Two optimizations fix that and they compose:

  • Path compression. During find, re-point every node you pass directly at the root. The next lookup on any of them is one hop.
  • Union by rank (or size). Always attach the shorter/smaller tree under the taller/bigger one, so the trees never get tall in the first place.
The payoff → together they give O(α(n)) amortized per operation, where α is the inverse Ackermann function — ≤ 4 for any input you will ever see. Effectively constant.

RECURSION SHAPE find and union, in plain English

The whole structure is two short routines. find climbs to the boss; union links two bosses and decrements the group count:

find(x):                       # who is x's representative?
    while parent[x] != x:      # climb until a node points to itself
        x = parent[x]          # (with compression: re-point along the way)
    return x                   # that self-pointer IS the root

union(x, y):                   # put x's set and y's set together
    rx, ry = find(x), find(y)
    if rx == ry: return False  # already one set → nothing to do (a CYCLE edge)
    link rx under ry (by rank) # attach the shorter tree beneath the taller
    components -= 1            # two groups just became one
    return True
The component-count trick → initialize components = n and decrement only on a successfulunion (one that actually merged two distinct roots). When the edges run out, that counter is your answer — no second pass needed.

WHEN IT BREAKS The mistakes that quietly cost you

  • No path compression → TLE. Skip it and the trees grow tall; on large inputs every find walks a long chain.
  • Decrementing the count on every union. Only a union that returns true merged two groups. Decrement inside that branch, not on every call.
  • Off-by-one sizing. If nodes are labelled 1..n (common in cycle problems), allocate n + 1 slots or you index out of bounds.
Debug reflex → a wrong component count almost always means you decremented on a no-op union, or you forgot to call find on both endpoints (comparing raw labels instead of roots).

MNEMONIC Find the boss, link the bosses.

Find the boss, link the bosses. Every group has one root (boss). find(x) walks pointers up to x's boss; union(x, y)points one boss at the other. “Connected” means same boss; the number of bosses is the number of components. Step the Visualize tab and watch the parent array flatten and the count drop.

THE PATTERN A forest of representative roots

Union-Find (a.k.a. Disjoint Set Union) maintains a partition of n elements into disjoint sets. It is stored as a forest: a single parent[] array where a root points to itself and every other node points one step toward its root.

Two operations drive everything. find(x) returns the representative root of x's set by following parent pointers. union(x, y) merges the two sets by linking one root under the other. From these you get connectivity queries and a live component count, computed onlineas edges arrive — no need to see the whole graph first.

KEY IDEA Representative root + two optimizations

The unlock is the representative root: collapse a whole set to a single canonical element, so “same set?” reduces to “same root?”. Two optimizations keep the trees flat, and they compound:

  • Path compression (in find): re-point every node visited directly at the root, so future lookups are one hop.
  • Union by rank / size (in union): hang the shorter (or smaller) tree under the taller (or larger) one, so depth grows as slowly as possible.
Together → amortized inverse-Ackermann O(α(n)) per operation. Either optimization alone is good; both together is what makes DSU effectively constant time.

COST Complexity at a glance

find / union
O(α(n))
Amortized, with compression + union by rank.
m operations
O(m·α(n))
α(n) ≤ 4 for any realistic n — effectively constant.
naive (no opts)
O(n)
Per find — the tree degenerates into a chain → TLE.

Space is O(n) for the parent (and rank/size) arrays.

VARIANTS Count components · detect the cycle edge

  • Count connected components. Initialize components = n and decrement on each successful union; the leftover count is the answer. (Or count self-pointing roots at the end.)
  • Detect the cycle-forming edge. Process edges in order; if union(u, v) returns false, then u and v already share a root — this edge closes a cycle (Redundant Connection).
  • Validate a tree. A graph on n nodes is a tree iff it has exactly n − 1 edges and no union ever returns false (no cycle).

RUN IT Find the boss, link the bosses

step 0 / 7
STARTStart with n = 5 singletons — every element is its own root, so parent[i] = i and there are 5 components. The parent[] row below stores each element's parent; a ★ marks a root. Find the boss, link the bosses.
1class UnionFind {
2 parent: number[];
3 rank: number[];
4 components: number;
5
6 constructor(n: number) {
7 this.parent = Array.from({ length: n }, (_, i) => i); // self-root
8 this.rank = new Array(n).fill(0);
9 this.components = n;
10 }
11
12 find(x: number): number {
13 if (this.parent[x] !== x)
14 this.parent[x] = this.find(this.parent[x]); // path compression
15 return this.parent[x];
16 }
17
18 union(x: number, y: number): boolean {
19 const rx = this.find(x), ry = this.find(y);
20 if (rx === ry) return false; // already merged → cycle
21 if (this.rank[rx] < this.rank[ry]) this.parent[rx] = ry;
22 else if (this.rank[rx] > this.rank[ry]) this.parent[ry] = rx;
23 else { this.parent[ry] = rx; this.rank[rx]++; }
24 this.components--;
25 return true;
26 }
27}
parent[]00 ★11 ★22 ★33 ★44 ★
State
5
components
5
n
endpoints / roots in playcurrent root (★)component count
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for Union-Find when the problem is about grouping by connectivity and edges arrive incrementally — you only need to know which set things end up in, not the route between them.

"count connected components / number of groups"DSU: start at n, decrement on each successful union
"are x and y connected" with edges added over timeDSU: union as edges arrive; query find(x) === find(y)
"redundant edge / first edge that forms a cycle"DSU: the edge whose union returns false closes a cycle
"is this a valid tree"DSU: exactly n−1 edges AND every union succeeds (no cycle)

RED FLAGSWhen it's NOT this pattern

  • You need the actual shortest path, the distance, or the route itself. Union-Find only tells you whether two nodes share a component, never the path between them. Use BFS (unweighted) or Dijkstra (weighted) instead.
  • Edges get removedover time. Standard DSU only supports merging, not splitting — deletions need a different structure (e.g. offline / rollback DSU) or a rebuild.
  • You must enumerate or order the members of each group / traverse them.That is a traversal job — reach for DFS/BFS component labelling, which can also yield the count.

TEMPLATE DSU class — path compression + union by rank

When → The reusable core. Drop this in whenever a problem is about connectivity or grouping. union returns false when the two endpoints are already merged, which is exactly the hook cycle-detection problems need.

dsu-class-path-compression-union-by-rank.ts
class UnionFind {
  private parent: number[];
  private rank: number[];
  public components: number;

  constructor(n: number) {
    // Each element starts in its own set, pointing at itself.
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank = new Array(n).fill(0);
    this.components = n;
  }

  // Return the representative root of x, compressing the path on the way up.
  find(x: number): number {
    if (this.parent[x] !== x) {
      this.parent[x] = this.find(this.parent[x]); // PATH COMPRESSION
    }
    return this.parent[x];
  }

  // Merge the sets of x and y. Returns false if they were already merged.
  union(x: number, y: number): boolean {
    const rx = this.find(x), ry = this.find(y);
    if (rx === ry) return false;                  // same set already

    // UNION BY RANK — hang the shorter tree under the taller one.
    if (this.rank[rx] < this.rank[ry]) {
      this.parent[rx] = ry;
    } else if (this.rank[rx] > this.rank[ry]) {
      this.parent[ry] = rx;
    } else {
      this.parent[ry] = rx;
      this.rank[rx]++;                            // tie → root grows by one
    }
    this.components--;
    return true;
  }

  connected(x: number, y: number): boolean {
    return this.find(x) === this.find(y);
  }
}
1- vs 0-indexed: if nodes are labelled 1..n, build the DSU with n + 1 slots and ignore index 0. Many cycle problems (Redundant Connection) use 1-indexed nodes.

TEMPLATE Count connected components

When → Anytime the answer is “how many groups remain after adding all the edges.” Track the live count instead of doing a second pass over the roots.

count-connected-components.ts
// Number of connected components in an undirected graph.
function countComponents(n: number, edges: [number, number][]): number {
  const uf = new UnionFind(n);   // start with n separate components
  for (const [u, v] of edges) {
    uf.union(u, v);              // each SUCCESSFUL merge drops the count by 1
  }
  return uf.components;          // (equivalently: count i where find(i) === i)
}
Only decrement on a real merge: union already returns false for a no-op, so let the class own components and decrement inside the successful branch.

TEMPLATE Cycle detection — first edge that closes a cycle

When → Redundant Connection and “is this a valid tree”. Iterate edges in order; the first edge whose union returns false is the one joining two already-connected nodes.

cycle-detection-first-edge-that-closes-a-cycle.ts
// Redundant Connection — the first edge that closes a cycle.
function findRedundantConnection(edges: [number, number][]): [number, number] {
  const uf = new UnionFind(edges.length + 1); // nodes are 1..n
  for (const [u, v] of edges) {
    // union returns false ⇒ u and v already share a root ⇒ this edge makes a cycle.
    if (!uf.union(u, v)) return [u, v];
  }
  return [-1, -1]; // unreachable when a redundant edge is guaranteed to exist
}
Valid-tree check: additionally require exactly n − 1 edges. With fewer the graph is disconnected; with the same count but a cycle it is not a tree.

PITFALL Forgetting path compression → TLE

Without compression (or union by rank), the forest can degenerate into a single long chain and each find becomes O(n). On large inputs this is the difference between passing and timing out. The one line parent[x] = find(parent[x]) inside find is not optional in practice.

PITFALL Skipping union by rank / size

Linking roots arbitrarily lets trees grow tall. Always attach the shorter/smaller tree under the taller/larger one (track rank or size per root). On a tie, pick either root and bump its rank by one.

PITFALL "Already unioned" (cycle) vs a fresh merge

When find(x) === find(y), the edge is redundant — it closes a cycle and must not change the component count. Only a union of two distinct roots is a real merge. Returning a boolean from union (false = already merged) makes both the cycle test and the count bookkeeping fall out cleanly.

PITFALL 0- vs 1-indexed nodes

Mixing labelling conventions is a classic off-by-one. If the input uses nodes 1..n, size the arrays at n + 1; if it uses 0..n−1, size them at n. Initialize parent[i] = i for every valid index.