Union-Find
Union-find tracks merging groups with near-constant-time find and union. Learn the parent-array structure, path compression and union by size, where it beats DFS (incremental edges, cycle-closing edges, Kruskal), and watch the parent array evolve and a redundant edge get caught.
🎯 What you'll learn
- find, union and the parent array
- Path compression and union by size (α(n))
- Components, cycle detection and Kruskal's MST
- Six LeetCode problems with animated walkthroughs
📚 Before you start
Union-find (disjoint set union, DSU) tracks which items belong to the same group while groups keep merging. It supports two operations: find(x) returns the representative ("root") of x's group, and union(a, b) merges two groups. With path compression and union by size/rank, both run in α(n) amortized time, effectively constant (α grows so slowly it's ≤ 4 for any input that fits in the universe).
1When to Use It
- "are these two nodes connected?" asked repeatedly while edges are added over time
- count connected components / groups (provinces, islands with dynamic land, merging accounts)
- detect the edge that creates a cycle in an undirected graph (Redundant Connection, valid tree)
- Kruskal's minimum spanning tree: add cheapest edges that join different components
- equations/relations: "a == b", "a != b" consistency checks
DFS/BFS can also count components, but union-find shines when edges arrive incrementally and you need answers after each one.
2Template
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function dsuDemo(n: number, edges: number[][]): any[] {
const d = new DSU(n);
const merged = edges.map(([a, b]) => d.union(a, b));
return [merged, d.components];
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def dsu_demo(n, edges):
d = DSU(n)
merged = [d.union(a, b) for a, b in edges]
return [merged, d.components]- Path compression (
parent[x] = parent[parent[x]], or full compression with recursion) flattens trees during finds. - Union by size attaches the smaller tree under the larger, keeping trees shallow.
unionreturningFalsemeans the endpoints were already connected: in an undirected graph, that edge closes a cycle.
3Kruskal's Minimum Spanning Tree
Sort edges by weight; for each edge, union its endpoints if they're in different components and add its weight. Stop after n − 1 edges. O(E log E) for the sort; union-find makes each cycle check near-constant.
Under the HoodWhy α(n)▾
Without either optimization a sequence of unions can build a chain, making find O(n). Union by size alone guarantees trees of height O(log n) (a node's tree at least doubles in size every time its depth increases). Adding path compression gives the inverse-Ackermann bound proved by Tarjan: amortized O(α(n)) per operation. In interviews, "near O(1) amortized, with path compression and union by rank" is the expected answer.
⚠️ Common Pitfalls
- Comparing
parent[a] == parent[b]instead offind(a) == find(b). - Forgetting path compression or union by size, leading to O(n) chains.
- Using union-find on directed graphs for cycle detection: it only handles undirected connectivity.
- Mapping non-integer items (emails, coordinates): give each a numeric id or use a dict-based parent map.
- Off-by-one with 1-indexed nodes: allocate
n + 1slots.
📋 Quick Reference
parent[i] = i; find: while parent[x] != x: parent[x] = parent[parent[x]]; x = parent[x]
union: ra, rb = find(a), find(b); if same → False (cycle); attach smaller under larger
components: start n, decrement per successful union
Kruskal: sort edges; add if union succeeds; stop at n-1 edges
α(n) ≈ O(1) amortizedSix problems: counting components, catching the cycle-closing edge, validating trees, grouping by shared identifiers, and Kruskal's minimum spanning tree.
1Number of Connected Components in an Undirected Graph (LeetCode 323)
🟠 Medium. Given n nodes and an edge list, return the number of connected components.
n = 5, [[0,1],[1,2],[3,4]] → 2Approach. Start with n components; each successful union merges two of them.
Complexity. O(E · α(n)) time, O(n) space.
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function countComponents(n: number, edges: number[][]): number {
const d = new DSU(n);
for (const [a, b] of edges) d.union(a, b);
return d.components;
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def count_components(n, edges):
d = DSU(n)
for a, b in edges:
d.union(a, b)
return d.components2Redundant Connection (LeetCode 684)
🟠 Medium. A tree with one extra edge added is given as an edge list (nodes 1..n). Return the edge that can be removed to make it a tree (the last such edge in the input).
[[1,2],[1,3],[2,3]] → [2,3]Approach. Union the endpoints of each edge in order; the first edge whose endpoints are already connected closes the cycle and is the answer.
Complexity. O(n · α(n)).
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function findRedundantConnection(edges: number[][]): number[] {
const d = new DSU(edges.length + 1);
for (const [a, b] of edges) if (!d.union(a, b)) return [a, b];
return [];
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def find_redundant_connection(edges):
d = DSU(len(edges) + 1)
for a, b in edges:
if not d.union(a, b):
return [a, b]
return []3Graph Valid Tree (LeetCode 261)
🟠 Medium. Given n nodes and undirected edges, return true if they form a valid tree.
n = 5, [[0,1],[0,2],[0,3],[1,4]] → trueApproach. A tree has exactly n − 1 edges and no cycle. Check the edge count first, then union every edge; any failed union means a cycle.
Complexity. O(n · α(n)).
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function validTree(n: number, edges: number[][]): boolean {
if (edges.length !== n - 1) return false;
const d = new DSU(n);
return edges.every(([a, b]) => d.union(a, b));
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def valid_tree(n, edges):
if len(edges) != n - 1:
return False
d = DSU(n)
return all(d.union(a, b) for a, b in edges)4Number of Provinces (LeetCode 547)
🟠 Medium. isConnected[i][j] = 1 if cities i and j are directly connected. Return the number of provinces (connected groups).
[[1,1,0],[1,1,0],[0,0,1]] → 2Approach. Union every connected pair (upper triangle of the matrix) and count components. DFS over the matrix works equally well.
Complexity. O(n² · α(n)).
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function findCircleNum(isConnected: number[][]): number {
const n = isConnected.length, d = new DSU(n);
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (isConnected[i][j]) d.union(i, j);
return d.components;
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def find_circle_num(is_connected):
n = len(is_connected)
d = DSU(n)
for i in range(n):
for j in range(i + 1, n):
if is_connected[i][j]:
d.union(i, j)
return d.components5Accounts Merge (LeetCode 721)
🟠 Medium. Each account is [name, email1, email2, …]. Accounts sharing any email belong to the same person. Merge them; return each person's name followed by their sorted emails.
two John accounts sharing johnsmith@mail.com merge into oneApproach. Give every email an id. Union all emails within one account (with its first email). Then group emails by root, sort each group, and prepend the owner's name.
Complexity. O(E log E) for E emails (sorting dominates).
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function accountsMerge(accounts: string[][]): string[][] {
const ids = new Map<string, number>(), owner = new Map<string, string>();
for (const acc of accounts) for (const email of acc.slice(1)) {
if (!ids.has(email)) { ids.set(email, ids.size); owner.set(email, acc[0]); }
}
const d = new DSU(ids.size);
for (const acc of accounts) for (const email of acc.slice(2)) d.union(ids.get(acc[1])!, ids.get(email)!);
const groups = new Map<number, string[]>();
for (const [email, i] of ids) {
const r = d.find(i);
if (!groups.has(r)) groups.set(r, []);
groups.get(r)!.push(email);
}
return [...groups.values()].map((g) => [owner.get(g[0])!, ...g.sort()]);
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def accounts_merge(accounts):
ids, owner = {}, {}
for acc in accounts:
for email in acc[1:]:
if email not in ids:
ids[email] = len(ids)
owner[email] = acc[0]
d = DSU(len(ids))
for acc in accounts:
first = ids[acc[1]]
for email in acc[2:]:
d.union(first, ids[email])
groups = {}
for email, i in ids.items():
groups.setdefault(d.find(i), []).append(email)
return [[owner[g[0]]] + sorted(g) for g in groups.values()]6Min Cost to Connect All Points (LeetCode 1584)
🟠 Medium. Points on a plane; connecting two costs their Manhattan distance. Return the minimum cost to connect all points (a minimum spanning tree).
[[0,0],[2,2],[3,10],[5,2],[7,0]] → 20Approach. Kruskal: build all O(n²) edges, sort by cost, and add each edge whose endpoints are in different components until n − 1 edges are added. (Prim's algorithm with an O(n²) array scan is also great here since the graph is complete.)
Complexity. O(n² log n) with Kruskal, O(n²) with array-based Prim.
class DSU {
parent: number[];
size: number[];
components: number;
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.components = n;
}
find(x: number): number {
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a: number, b: number): boolean {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.size[ra] += this.size[rb];
this.components--;
return true;
}
}
function minCostConnectPoints(points: number[][]): number {
const n = points.length, edges: [number, number, number][] = [];
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++)
edges.push([Math.abs(points[i][0] - points[j][0]) + Math.abs(points[i][1] - points[j][1]), i, j]);
edges.sort((a, b) => a[0] - b[0]);
const d = new DSU(n);
let total = 0, used = 0;
for (const [cost, i, j] of edges) {
if (d.union(i, j)) { total += cost; if (++used === n - 1) break; }
}
return total;
}class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.components = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra
self.parent[rb] = ra # attach smaller under larger
self.size[ra] += self.size[rb]
self.components -= 1
return True
def min_cost_connect_points(points):
n = len(points)
edges = sorted((abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]), i, j)
for i in range(n) for j in range(i + 1, n))
d, total, used = DSU(n), 0, 0
for cost, i, j in edges:
if d.union(i, j):
total += cost
used += 1
if used == n - 1:
break
return total🧠 Key takeaways
- DSU = find (root of a group) + union (merge two groups).
- Path compression + union by size → amortized α(n) ≈ O(1).
- A failed union on an undirected edge means that edge closes a cycle.
- Use it for incremental connectivity, component counts and Kruskal's MST.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.