Union-Find (Disjoint Set Union)
Track which items belong to the same group with near-constant-time union and find, using path compression and union by rank.
- O(α(n))Amortised find or union, effectively O(1)
- α(n) ≤ 4Inverse Ackermann for any realistic input
- O(n)Space for parent and rank arrays
Show what to say
“When the question is how many groups, or whether an edge closes a cycle in an undirected graph, especially as links arrive one at a time, I use union-find. Each group is a tree whose root names it; with path compression and union by rank, find and union are effectively constant.”
Trade-offs
- Union-find vs BFS/DFS for components: on a fixed graph both are linear and BFS/DFS gives paths; union-find wins when edges arrive over time.
- Compression plus rank vs neither: O(α(n)) amortised against O(n) worst case, because the trees become chains.
- Loop vs recursive find: the loop is safer in JavaScript, where a recursive find on a 100,000-node chain overflows the stack without union by rank.
Bring it up before they ask
Union the roots, not the items (find(a) and find(b)), lower the group count only when union returns true, and allocate n + 1 slots for 1-based labels.
Union-find keeps each group as a tree whose root is the group's name, so "are these connected?" means "same root?". With two small tricks, each step is almost instant.
When to reach for it
Tap the signals you can see in the problem.
Remembergroupsarrive one at a timecreate a cycleundirectedequivalent
- Count groups
What you watch
number of provincescountdrops per merge - Cycle detection
What you watch
redundant connectionunionreturnsfalse - Keyed groups
What you watchroots in a
accounts mergeMap - Kruskal's MST
What you watchedges that join two groups
min cost to connect points
The core idea: one root per group
Picture friend groups. Each group has one leader.
Read more · 122 words
Everyone points to a friend, and following friends ends at the leader. Are two people in the same group? Compare their leaders. To merge two groups, one leader starts following the other.
The only slow part is climbing to the leader, and that depends on how tall the tree is. Two tricks keep trees flat:
- Union by rank: hang the shorter tree under the taller one, so trees stay short.
- Path compression: after a climb, point everyone you passed straight at the leader.
Watch the badges (each node's leader). Edge 3-0 is rejected: both ends share leader 0. Try your own edges with Edit input.
With both tricks, each step is close to O(1), so you can treat it as constant.
Union by rank: merging groups as edges arrive
- count
- 7
Every node starts as its own root (parent[i] = i), so there are 7 groups. The badge under each node names its current root; roots are highlighted in parent.
function countComponents(n, edges) {
const parent = [...Array(n).keys()];
const rank = new Array(n).fill(0);
let count = n;
const find = (x) => {
if (parent[x] !== x) parent[x] = find(parent[x]);
return parent[x];
};
for (const [a, b] of edges) {
let ra = find(a), rb = find(b);
if (ra === rb) continue;
if (rank[ra] < rank[rb]) [ra, rb] = [rb, ra];
parent[rb] = ra;
if (rank[ra] === rank[rb]) rank[ra]++;
count--;
}
return count;
}All steps as text
- Every node starts as its own root (parent[i] = i), so there are 7 groups. The badge under each node names its current root; roots are highlighted in parent.
- Edge 0-1: find(0) = 0 and find(1) = 1.
- Different roots, both rank 0. Hang 1 under 0: parent[1] = 0. Every node whose root was 1 now reaches 0.
- The two trees were equally tall, so the merged one is one level taller: rank[0] = 1. Two groups became one, so count drops to 6.
- Edge 2-3: find(2) = 2 and find(3) = 3.
- Different roots, both rank 0. Hang 3 under 2: parent[3] = 2. Every node whose root was 3 now reaches 2.
- The two trees were equally tall, so the merged one is one level taller: rank[2] = 1. Two groups became one, so count drops to 5.
- Edge 1-3: find(1) = 0 and find(3) = 2.
- Different roots, both rank 1. Hang 2 under 0: parent[2] = 0. Every node whose root was 2 now reaches 0.
- The two trees were equally tall, so the merged one is one level taller: rank[0] = 2. Two groups became one, so count drops to 4.
- Edge 4-5: find(4) = 4 and find(5) = 5.
- Different roots, both rank 0. Hang 5 under 4: parent[5] = 4. Every node whose root was 5 now reaches 4.
- The two trees were equally tall, so the merged one is one level taller: rank[4] = 1. Two groups became one, so count drops to 3.
- find(3) walks 3 → 2 → 0 to the root 0. On the way back, path compression points 3 straight at 0, so the next find from there is one hop.
- Edge 3-0: find(3) = 0 and find(0) = 0.
- Both ends already have root 0: 3 and 0 are connected without this edge, so adding it would close a cycle. Skip it; count stays 3.
- Edge 5-6: find(5) = 4 and find(6) = 6.
- Different roots. 4's tree is taller (rank 1 vs 0), so 6 goes under it and the height doesn't grow: parent[6] = 4. count drops to 2.
- All edges are processed. count = 2: the groups are {0, 1, 2, 3} and {4, 5, 6}. A node that never appears in an edge still counts, because count started at n.
Play with it
Click two people to make them friends.
Read more · 111 words
Then press Worst case for a 9-link chain, turn path compression on, switch clicks to Find and find 0: the chain snaps flat.
Step by step
Decide what the items are
Number them
0 … n - 1(grid cells asr * cols + c), or use aMap.Decide what links two items
An edge, a shared email,
a == b: oneunioneach.Start with everyone alone
parent[i] = i,rank[i] = 0,count = n.Union every link
true: merged, lower the count.false: already connected, so the edge closes a cycle.Read answers through find
Use
countorfind(a) === find(b). Neverparent[i]: a parent is not always a root.
The class and counting groups
Template 1: the UnionFind class
The version to memorise. find climbs, then re-points the path at the root. union returns whether it merged.
Read more · 33 words
Here a chain flattens, and the second find is one hop:
A recursive find works too, but the loop is safer in JavaScript: a chain of 100,000 nodes overflows the call stack.
// Template 1: union-find (disjoint set union) over items 0 … n - 1.
// parent[] stores a forest: following parent links from any item ends at its group's root.
// Two items are in the same group exactly when they have the same root.
export class UnionFind {
constructor(n) {
this.parent = Array.from({ length: n }, (_, i) => i); // every item starts as its own root
this.rank = new Array(n).fill(0); // upper bound on each root's tree height
this.count = n; // number of separate groups
}
find(x) {
let root = x;
while (this.parent[root] !== root) root = this.parent[root];
while (this.parent[x] !== root) { // path compression: point the whole path at the root
const next = this.parent[x];
this.parent[x] = root;
x = next;
}
return root;
}
union(a, b) {
let ra = this.find(a);
let rb = this.find(b);
if (ra === rb) return false; // already in one group (an edge here closes a cycle)
if (this.rank[ra] < this.rank[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra; // union by rank: the shorter tree goes under the taller
if (this.rank[ra] === this.rank[rb]) this.rank[ra]++;
this.count--;
return true;
}
connected(a, b) {
return this.find(a) === this.find(b);
}
}find with path compression
- x
- 0
- root
- 0
find(0): each arrow is a parent pointer. Start at 0 and climb until a node points at itself; that node is the root.
function find(parent, x) {
let root = x;
while (parent[root] !== root) {
root = parent[root];
}
while (parent[x] !== root) {
const next = parent[x];
parent[x] = root;
x = next;
}
return root;
}All steps as text
- find(0): each arrow is a parent pointer. Start at 0 and climb until a node points at itself; that node is the root.
- parent[0] = 1, so climb to 1.
- parent[1] = 2, so climb to 2.
- parent[2] = 3, so climb to 3.
- parent[3] = 4, so climb to 4. parent[4] = 4: 4 is the root, after 4 hops.
- 0 pointed at 1. Point it straight at the root instead: parent[0] = 4. Then continue from 1.
- 1 pointed at 2. Point it straight at the root instead: parent[1] = 4. Then continue from 2.
- 2 pointed at 3. Point it straight at the root instead: parent[2] = 4. Then continue from 3.
- Every node on the path now points directly at 4. This find took 4 hops; the next find from any of those nodes takes one. Return 4.
- Now find(1). The previous find already re-pointed 1 at the root, so this climb is a single hop.
- parent[1] = 4, so climb to 4. parent[4] = 4: 4 is the root, after 1 hop.
- 1 already pointed at the root: one hop, nothing to shorten. Return 4.
Template 2: count groups
Start with n groups. Every successful union removes one, so what is left is the answer.
This solves Number of ProvincesMedium and Number of Connected ComponentsMedium.
import { UnionFind } from './union-find.js';
// Template 2: count groups. Start with n groups (every item alone); each union that really
// merges two roots removes one group. Pairs inside an existing group change nothing.
export function countGroups(n, pairs) {
const uf = new UnionFind(n);
for (const [a, b] of pairs) uf.union(a, b);
return uf.count;
}
// The same loop answers "how big is each group?": bucket every item under its root.
export function groupsOf(n, pairs) {
const uf = new UnionFind(n);
for (const [a, b] of pairs) uf.union(a, b);
const groups = new Map();
for (let i = 0; i < n; i++) {
const root = uf.find(i); // always find(i), never parent[i]: a parent is not always a root
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(i);
}
return [...groups.values()];
}Cycles and keyed items
Template 3: cycle detection
An edge whose ends already share a root closes a cycle.
Read more · 28 words
That is all of Redundant ConnectionMedium. For Graph Valid TreeMedium, n nodes form a tree when there are n - 1 edges and none closes a cycle. Directed cycles need Topological Sort.
import { UnionFind } from './union-find.js';
// Template 3: cycle detection in an undirected graph. Add the edges one by one; the first edge
// whose two ends already share a root closes a cycle (they were connected without it).
export function firstCycleEdge(n, edges) {
const uf = new UnionFind(n);
for (const [a, b] of edges) {
if (!uf.union(a, b)) return [a, b];
}
return null; // the edges form a forest
}
// A graph is a tree when it has exactly n - 1 edges and none of them closes a cycle.
export function isTree(n, edges) {
return edges.length === n - 1 && firstCycleEdge(n, edges) === null;
}Template 4: items that aren't numbers
For emails or words, keep parent in a Map and add each key when you first see it. This version joins the smaller group to the larger (union by size), which also gives each group's size.
For Accounts MergeMedium, union the emails directly.
// Template 4: union-find over arbitrary keys (emails, words, "r,c" cells) with union by size.
// Keys are added the first time they are seen, so nothing has to be numbered up front,
// and every root knows how many items its group holds.
export class KeyedUnionFind {
constructor() {
this.parent = new Map();
this.sizes = new Map(); // root -> number of items in its group
}
add(x) {
if (!this.parent.has(x)) {
this.parent.set(x, x);
this.sizes.set(x, 1);
}
}
find(x) {
this.add(x);
let root = x;
while (this.parent.get(root) !== root) root = this.parent.get(root);
while (this.parent.get(x) !== root) { // path compression
const next = this.parent.get(x);
this.parent.set(x, root);
x = next;
}
return root;
}
union(a, b) {
let ra = this.find(a);
let rb = this.find(b);
if (ra === rb) return false;
if (this.sizes.get(ra) < this.sizes.get(rb)) [ra, rb] = [rb, ra];
this.parent.set(rb, ra); // union by size: the smaller group joins the larger
this.sizes.set(ra, this.sizes.get(ra) + this.sizes.get(rb));
return true;
}
sizeOf(x) {
return this.sizes.get(this.find(x));
}
groups() {
const out = new Map();
for (const x of this.parent.keys()) {
const root = this.find(x);
if (!out.has(root)) out.set(root, []);
out.get(root).push(x);
}
return [...out.values()];
}
}Try it
// Edit the inputs and press Run. The classes and functions are the templates above.
const uf = new UnionFind(6);
uf.union(0, 1);
uf.union(2, 3);
uf.union(1, 3); // two rank-1 trees: 2 goes under 0
console.log(uf.connected(0, 2), uf.connected(0, 4)); // true false
console.log(uf.count); // 3
console.log(uf.parent); // [0, 0, 0, 2, 4, 5]
uf.find(3); // walks 3 → 2 → 0 and re-points 3 at the root
console.log(uf.parent); // [0, 0, 0, 0, 4, 5]
console.log(countGroups(5, [[0, 1], [1, 2], [3, 4]])); // 2
console.log(firstCycleEdge(4, [[0, 1], [1, 2], [2, 3], [3, 1]])); // [3, 1]
const emails = new KeyedUnionFind();
emails.union('ana@x.com', 'ana@work.com');
emails.union('ana@work.com', 'a.dev@z.com');
console.log(emails.sizeOf('a.dev@z.com')); // 3Complexity and JavaScript
Complexity
Read more · 88 words
| Version | find / union | Space |
|---|---|---|
| Both tricks | almost O(1) | O(n) |
| One trick only | O(log n) | O(n) |
| Neither | O(n): trees become chains | O(n) |
Kruskal's MST is dominated by sorting: O(E log E).
JavaScript you'll use
JavaScript refresher: Classes for data structures Arrays: create, read, update Map and SetThe trap: a recursive find on a long chain throws RangeError: Maximum call stack size exceeded.
const parent = Array.from({ length: n }, (_, i) => i); // [0, 1, 2, …, n - 1]
const rank = new Array(n).fill(0);
[ra, rb] = [rb, ra]; // swap so ra is the taller root
const id = (r, c) => r * cols + c; // grid cell → item number
const uf = new UnionFind(n + 1); // labels 1 … n: one extra slotInterview questions
Union-find or BFS/DFS for connected components?
On a fixed graph both are linear. Union-find wins when edges arrive over time: each answer is near-instant, where BFS would start again.
Can union-find remove an edge?
Not directly. If you know all queries, run them in reverse so deletions become additions.
How do you find the size of the largest group?
Union by size: add the smaller size to the larger root on each merge, and track the maximum.
How does Kruskal's algorithm use union-find?
Sort edges by weight. Take an edge only if union(a, b) returns true. Stop at n - 1 edges.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Practice problems
Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.
- Number of ProvincesCount the groups of cities that are linked to each other, directly or through other cities.Medium
- Number of Connected ComponentsGiven n nodes and a list of undirected edges, count how many separate pieces the graph falls into.Medium
- Redundant ConnectionA tree got one extra edge. Find the edge you can delete to turn the graph back into a tree.Medium
- Graph Valid TreeDecide whether n nodes and a list of undirected edges form a single tree: connected, with no cycles.Medium
- Accounts MergeMerge account records that share an email, and list each person's emails in sorted order.Medium
- Min Cost to Connect All PointsConnect points on a plane with the least total Manhattan distance so that every point is reachable from every other.Medium
- Number of IslandsCount the groups of connected land cells in a grid of land and water.Medium
- Max Area of IslandFind the size of the largest group of connected land cells in a 0/1 grid.Medium
- Path With Minimum EffortWalk across a height map from corner to corner so that the steepest single step is as gentle as possible.Medium
- Swim in Rising WaterWater rises one unit per second over a grid of distinct heights. When can you first swim from one corner to the other?Hard