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.
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.
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.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)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.A naive DSU can degenerate into a linked list — find becomes O(n) and you TLE. Two optimizations fix that and they compose:
find, re-point every node you pass directly at the root. The next lookup on any of them is one hop.O(α(n)) amortized per operation, where α is the inverse Ackermann function — ≤ 4 for any input you will ever see. Effectively constant.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 Truecomponents = 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.find walks a long chain.true merged two groups. Decrement inside that branch, not on every call.1..n (common in cycle problems), allocate n + 1 slots or you index out of bounds.find on both endpoints (comparing raw labels instead of roots).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.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.
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:
find): re-point every node visited directly at the root, so future lookups are one hop.union): hang the shorter (or smaller) tree under the taller (or larger) one, so depth grows as slowly as possible.O(α(n)) per operation. Either optimization alone is good; both together is what makes DSU effectively constant time.Space is O(n) for the parent (and rank/size) arrays.
components = n and decrement on each successful union; the leftover count is the answer. (Or count self-pointing roots at the end.)union(u, v) returns false, then u and v already share a root — this edge closes a cycle (Redundant Connection).n nodes is a tree iff it has exactly n − 1 edges and no union ever returns false (no cycle).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;56▶ constructor(n: number) {7▶ this.parent = Array.from({ length: n }, (_, i) => i); // self-root8▶ this.rank = new Array(n).fill(0);9 this.components = n;10 }1112 find(x: number): number {13 if (this.parent[x] !== x)14 this.parent[x] = this.find(this.parent[x]); // path compression15 return this.parent[x];16 }1718 union(x: number, y: number): boolean {19 const rx = this.find(x), ry = this.find(y);20 if (rx === ry) return false; // already merged → cycle21 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}
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 time | DSU: 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) |
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.
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..n, build the DSU with n + 1 slots and ignore index 0. Many cycle problems (Redundant Connection) use 1-indexed nodes.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.
// 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)
}union already returns false for a no-op, so let the class own components and decrement inside the successful branch.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.
// 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
}n − 1 edges. With fewer the graph is disconnected; with the same count but a cycle it is not a tree.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.
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.
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.
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.