Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Union-Find (Connected Components)
The data structure behind Kruskal’s MST, network connectivity and image-region labeling: a disjoint-set (union-find) that answers "are these two connected?" in near-constant time. Implement it with union by rank and path compression, then count the connected components. Solve it in Python or TypeScript, with hidden tests.
The problem
You are given n nodes labeled 0…n-1 and a list of undirected edges. Two nodes are in the same component if there is a path of edges between them. Return the number of connected components after applying every edge. Implement it with a disjoint-set (union-find) — do not just BFS the graph.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement count_components(n, edges): build a disjoint-set over n nodes, union the endpoints of every edge, then return how many distinct roots remain. Use path compression + union by rank so it scales.
Examples and constraints 5 examples
n = 5, edges = [[0,1],[1,2],[3,4]]2n = 4, edges = []4n = 3, edges = [[0,1],[1,2],[0,2]]1n = 6, edges = [[0,1],[2,3],[4,5],[1,2],[3,4]]1n = 3, edges = [[2,2]]3- 1 ≤ n ≤ 10⁵, 0 ≤ edges.length ≤ 10⁵
- Use path compression in find and union by rank/size — together they give near-O(1) amortized (inverse-Ackermann) operations.
- A redundant edge (both endpoints already in the same set) must not change the count.
Hints 4 ideas
- Keep a
parentarray where each node points at another node in its set; a root points at itself. find(x)walks parents to the root. Compress the path as you go (point nodes straight at the root or their grandparent) so future finds are fast.union(a, b)finds both roots; if different, attach the smaller-rank tree under the larger to keep trees shallow.- The answer is the number of nodes that are their own root — equivalently,
len({find(i) for i in range(n)}).
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach labels components directly, often called quick-find: give every node a label comp[i] = i, and for each edge whose endpoints have different labels, scan all n nodes and relabel every node carrying the second label with the first. At the end, count the distinct labels. It is correct, because each label always names exactly one component, and it needs O(n) space. But every successful merge costs a full O(n) scan, so the whole run is O(n · E): at the limits of 105 nodes and 105 edges, that is up to 1010 label checks. Union-find makes a merge one pointer change instead.
The approach
Represent each component as a tree of parent pointers whose root names the component; the invariant is that two nodes are connected exactly when following parents from each reaches the same root. Merging two components is then a single pointer change, root under root, and an edge whose endpoints already share a root is redundant. Two rules keep the trees shallow. Union by rank hangs the lower-rank root under the higher, so a tree of rank r holds at least 2^r nodes and no path is longer than log2 n. Path halving repoints nodes closer to the root during every find.
In the reference, parent = list(range(n)) makes every node its own root and rank starts at 0. find(x) loops while parent[x] != x, setting parent[x] = parent[parent[x]] before stepping up, which points each visited node at its grandparent. union(a, b) returns early when the roots match, swaps them so ra has the higher rank, sets parent[rb] = ra, and adds 1 to rank[ra] only on a tie. The answer is len({find(i) for i in range(n)}).
Walk through an example
Trace the first example, count_components(5, [[0, 1], [1, 2], [3, 4]]).
| step | action | roots found | parent | rank |
|---|---|---|---|---|
| 1 | start: every node is its own root | — | [0, 1, 2, 3, 4] | [0, 0, 0, 0, 0] |
| 2 | union(0, 1): equal ranks, so 1 goes under 0 and rank[0] rises | 0, 1 | [0, 0, 2, 3, 4] | [1, 0, 0, 0, 0] |
| 3 | union(1, 2): find(1) is 0; 2 goes under the higher-rank 0 | 0, 2 | [0, 0, 0, 3, 4] | [1, 0, 0, 0, 0] |
| 4 | union(3, 4): equal ranks, so 4 goes under 3 | 3, 4 | [0, 0, 0, 3, 3] | [1, 0, 0, 1, 0] |
| 5 | {find(i) for i in range(5)} | {0, 3} | unchanged | returns 2 |
Complexity
Optimal: O((n + E) · α(n)) time with union by rank and path halving, where α is the inverse Ackermann function, at most 4 for any input that fits in memory; O(n) space for parent and rank. Brute force (quick-find relabeling): O(n · E) time, O(n) space.
Edge cases to test
- No edges:
count_components(4, [])→4. Every node is its own root. - A redundant edge:
[[0, 1], [1, 2], [0, 2]]on 3 nodes →1. The last edge finds the same root twice andunionreturns early. - A self-loop:
count_components(3, [[2, 2]])→3. Both finds return 2, so nothing changes. - Duplicate edges:
count_components(2, [[0, 1], [0, 1]])→1; only the first copy merges anything. - Merges in an awkward order:
[[0, 1], [2, 3], [4, 5], [1, 2], [3, 4]]on 6 nodes →1. Later edges join existing trees, sounionmust link roots, not the endpoints themselves. - A single node:
count_components(1, [])→1; andn = 0, below the stated limits, returns 0.
Common mistakes
- Skipping path compression or union-by-rank — trees degenerate into O(n) chains.
- Union-ing the elements directly instead of their roots.
- Comparing parents instead of roots to test connectivity.
- Counting components as
nminus the number of edges: redundant edges, self-loops and duplicates do not merge anything, so only successful unions may be subtracted.
Follow-ups an interviewer asks next
- Edges arrive one at a time. How do you report the count after each?
- Keep a counter that starts at
nand decrements wheneverunionactually links two different roots. Each edge then costsO(α(n))and the answer is always available without the final scan over all nodes. This is the usual form of the problem in streaming settings, such as tracking how many islands exist as land cells are added one by one. - What if edges can also be deleted?
- Union-find cannot split a set, because nothing records which edge joined two trees. If all the operations are known in advance, process them in reverse, so deletions become additions. Otherwise you need a fully dynamic connectivity structure, which exists but is far more complex; rebuilding from scratch after each deletion costs
O(n + E)each time. - How would you find the edge that creates a cycle?
- Process the edges in order: the first edge whose endpoints already share a root closes a cycle, because a path between them existed before the edge was added. That is LeetCode’s Redundant Connection, and it is exactly the check Kruskal’s algorithm makes when it skips an edge while building a minimum spanning tree in order of increasing weight.
- How do you get the size of each component?
- Use union by size instead of rank: keep
size[root], attach the smaller tree under the larger, and add the smaller size into the new root. The largest component is then a running maximum updated on each union. Union by size gives the same logarithmic height bound as union by rank, so the complexity is unchanged.
Where this shows up
Union-Find powers Kruskal’s minimum spanning tree, dynamic connectivity, cycle detection, and region/segment merging in images. Any “are these two in the same group, and merge groups fast” problem reaches for it.
Reference solution
Reference solution — Python
def count_components(n, edges):
parent = list(range(n)) # each node starts as its own root
rank = [0] * n
def find(x): # root of x, with path compression
while parent[x] != x:
parent[x] = parent[parent[x]] # halve the path
x = parent[x]
return x
def union(a, b): # merge the two sets (union by rank)
ra, rb = find(a), find(b)
if ra == rb:
return # already connected — redundant edge
if rank[ra] < rank[rb]:
ra, rb = rb, ra
parent[rb] = ra
if rank[ra] == rank[rb]:
rank[ra] += 1
for a, b in edges:
union(a, b)
return len({find(i) for i in range(n)})
print(count_components(5, [[0, 1], [1, 2], [3, 4]])) # -> 2
print(count_components(4, [])) # -> 4Reference solution — TypeScript
function countComponents(n: number, edges: number[][]): number {
const parent = Array.from({ length: n }, (_, i) => i); // own root
const rank = new Array(n).fill(0);
function find(x: number): number { // root, with compression
while (parent[x] !== x) {
parent[x] = parent[parent[x]]; // halve the path
x = parent[x];
}
return x;
}
function union(a: number, b: number): void { // union by rank
let ra = find(a), rb = find(b);
if (ra === rb) return; // redundant edge
if (rank[ra] < rank[rb]) { const t = ra; ra = rb; rb = t; }
parent[rb] = ra;
if (rank[ra] === rank[rb]) rank[ra]++;
}
for (const [a, b] of edges) union(a, b);
const roots = new Set<number>();
for (let i = 0; i < n; i++) roots.add(find(i));
return roots.size;
}
console.log(countComponents(5, [[0, 1], [1, 2], [3, 4]])); // -> 2
console.log(countComponents(4, [])); // -> 4Questions about Union-Find (Connected Components)
What is the time complexity of union-find?
With both union by rank and path compression or path halving, a sequence of m operations on n elements takes O(m times alpha of n) time, where alpha is the inverse Ackermann function. It grows so slowly that it is at most 4 for any input that could fit in a computer’s memory, which is why the operations are described as nearly constant time. With only union by rank, each find is O(log n) in the worst case. With neither rule, a bad merge order can build a chain, and a single find can take O(n).
What is the difference between path compression and path halving?
Both flatten the tree while find walks up to the root. Full path compression makes a second pass, or uses recursion, to point every node on the path directly at the root. Path halving, which the reference solution uses, does it in the same single pass: each visited node is pointed at its grandparent before the walk moves up, so the path roughly halves in length each time. Path halving needs no recursion and no second loop, and combined with union by rank it has the same inverse Ackermann bound as full compression.
When should you use union-find instead of BFS or DFS?
For a single connected-components count on a graph you already have in full, BFS or DFS is also linear and works fine. Union-find wins when edges arrive over time and you need answers in between, when you only need to know whether two items are in the same group rather than the paths between them, or when you are building something like a minimum spanning tree that keeps asking whether an edge would close a cycle. It also needs no adjacency lists, just two arrays of length n.
Why does union-find use union by rank?
Without it, merging trees in an unlucky order can produce one long chain, and every find on that chain walks all of it. Union by rank always hangs the shorter tree under the taller one, with rank as an upper bound on height. A root of rank r can only be created by merging two trees of rank r minus 1, so a tree of rank r contains at least 2 to the power r nodes, and no tree can be taller than log base 2 of n. Path halving then shortens those paths further over time.
This idea in other formats
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →