Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°29

Union-Find (Connected Components)

Medium GraphsDisjoint SetData Structures

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.

SOLVE IT YOURSELF

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.

YOUR TASK

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.

CPython · WebAssembly
Examples and constraints 5 examples
EXAMPLE 1
Input n = 5, edges = [[0,1],[1,2],[3,4]]
Output 2
{0,1,2} and {3,4}
EXAMPLE 2
Input n = 4, edges = []
Output 4
no edges → every node is its own component
EXAMPLE 3
Input n = 3, edges = [[0,1],[1,2],[0,2]]
Output 1
the extra edge is redundant
EXAMPLE 4
Input n = 6, edges = [[0,1],[2,3],[4,5],[1,2],[3,4]]
Output 1
three pairs form first, then two edges join them into one component
EXAMPLE 5
Input n = 3, edges = [[2,2]]
Output 3
edge case: a self-loop joins a node to itself and changes nothing
CONSTRAINTS
  • 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
HINTS — 4 IDEAS
  1. Keep a parent array where each node points at another node in its set; a root points at itself.
  2. 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.
  3. union(a, b) finds both roots; if different, attach the smaller-rank tree under the larger to keep trees shallow.
  4. 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]]).

stepactionroots foundparentrank
1start: every node is its own root—[0, 1, 2, 3, 4][0, 0, 0, 0, 0]
2union(0, 1): equal ranks, so 1 goes under 0 and rank[0] rises0, 1[0, 0, 2, 3, 4][1, 0, 0, 0, 0]
3union(1, 2): find(1) is 0; 2 goes under the higher-rank 00, 2[0, 0, 0, 3, 4][1, 0, 0, 0, 0]
4union(3, 4): equal ranks, so 4 goes under 33, 4[0, 0, 0, 3, 3][1, 0, 0, 1, 0]
5{find(i) for i in range(5)}{0, 3}unchangedreturns 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 and union returns 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, so union must link roots, not the endpoints themselves.
  • A single node: count_components(1, []) → 1; and n = 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 n minus 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 n and decrements whenever union actually links two different roots. Each edge then costs O(α(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, []))                        # -> 4
Reference 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, []));                       // -> 4

Questions 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.

Finished this one? 0 / 76 Challenges done
NextImplement a Trie Next on the “Structures you build” ladder · rung 2 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges