Number of Connected Components in an Undirected Graph

Think of people in a room. Some hold hands in small clusters. Others stand alone. How many separate clusters are there? That is what this problem asks about a graph. Counting these clusters is a core graph skill, and it gives you a second reason to learn union-find.

🎯 The Problem

You get a graph. You must count how many separate groups it has.

The rules:

  • The nodes are labeled 0 to n - 1.
  • Each edge [a, b] connects node a and node b.
  • The connection works both ways, so this is an undirected graph.
  • A group where you can travel between every node is one connected component.
  • Return the number of connected components.

For example, with 5 nodes and edges [[0,1],[1,2],[3,4]], nodes 0, 1, and 2 form one group and 3 and 4 form another. So the answer is 2.

Input: n = 5, edges = [[0,1],[1,2],[3,4]]
Output: 2
Explanation: {0, 1, 2} is one component and {3, 4} is another.

Here is the graph. You can see the two separate clusters clearly.

0

1

2

3

4

🐒 Approach 1: DFS Over an Adjacency List (Better)

The idea in one line: walk each cluster once and count one for every fresh cluster you start.

The idea:

  • Build an adjacency list, which is the list of neighbors for each node.
  • DFS (depth first search) follows one path as deep as it goes, then backs up.

How it works:

  • Loop over every node.
  • When you reach a node not visited yet, you found a new cluster. Add one to the count.
  • Run a DFS from that node and mark every node it can reach as visited.
  • Marking the whole cluster means you count each cluster only once.

Why it works but costs more:

  • It needs the adjacency list and a recursion stack.
  • Time is O(V + E). Space is O(V + E) for the list and the stack.

Here is the adjacency-list DFS code:

connected_components_dfs.py
def count_components(n, edges):
graph = [[] for _ in range(n)]
for a, b in edges:
graph[a].append(b); graph[b].append(a)
seen = set()
def dfs(node):
seen.add(node)
for nei in graph[node]:
if nei not in seen: dfs(nei)
count = 0
for i in range(n):
if i not in seen:
count += 1; dfs(i)
return count

⚑ Approach 2: Union-Find (Best)

The idea in one line: start with every node alone, then merge groups as edges arrive and count the merges.

The idea:

  • Union-find tracks which group each node is in and merges two groups fast.
  • It is also called a disjoint set, because the groups never overlap.
  • No graph traversal and no adjacency list needed.

How it works:

  • Treat every node as its own group. So with n nodes you start with n groups.
  • Keep a parent array. Following parents up leads to the top node, the root.
  • Two nodes share a group when they share a root.
  • find(x) walks up to the root. union(a, b) points one root at the other.
  • Read each edge and union its two nodes.
  • Start the count at n. Each union that merged two separate groups lowers it by one.
  • Edges between already-joined nodes change nothing.

Why it is fast:

  • Path compression points each node on the find path straight at the root.
  • Union by rank attaches the shorter group under the taller one.
  • Together they make each operation almost constant time.

Here is the count dropping as we union each edge in the example.

Start: 5 groups

Union 0 and 1: 4 groups

Union 1 and 2: 3 groups

Union 3 and 4: 2 groups

Answer: 2 components

Steps to Solve

  1. Create a parent array where every node is its own parent, and a rank array of zeros.
  2. Start a counter at n, since every node begins as its own group.
  3. Write find(x) that walks up to the root and points each node along the way straight to the root.
  4. Write union(a, b) that finds both roots and attaches the lower-rank root under the higher-rank one.
  5. For each edge, union the two nodes. If the union actually merged two groups, lower the counter by one.
  6. Return the counter.

This Python version keeps parent and rank as lists and counts merges directly.

connected_components.py
def count_components(n, edges):
parent = list(range(n)) # each node is its own parent
rank = [0] * n # rough height of each group
def find(x):
if parent[x] != x:
parent[x] = find(parent[x]) # path compression
return parent[x]
def union(a, b):
ra, rb = find(a), find(b)
if ra == rb:
return False # already in same group
if rank[ra] < rank[rb]: # union by rank
parent[ra] = rb
elif rank[ra] > rank[rb]:
parent[rb] = ra
else:
parent[rb] = ra
rank[ra] += 1
return True # a real merge happened
count = n
for a, b in edges:
if union(a, b):
count -= 1 # one fewer group
return count
n = 5
edges = [[0, 1], [1, 2], [3, 4]]
print(count_components(n, edges))

The output of the above code will be:

2

Let us walk through the Python version line by line, because the counting idea is the whole trick.

parent = list(range(n)) makes every node its own parent. So at the start each node is alone. rank = [0] * n gives every group rank zero, since a single node has no height yet.

Inside find(x), the check if parent[x] != x: asks whether x is the root. The root is the only node that points to itself. If not, parent[x] = find(parent[x]) finds the true root and also points x straight at it. That is path compression, and it keeps later lookups short.

Inside union(a, b), we get both roots first. if ra == rb: return False means the two nodes were already in the same group, so no merge happens. The rank lines attach the shorter group under the taller one and bump the rank only when the two heights tie.

count = n starts the answer assuming every node is separate. Then if union(a, b): count -= 1 is the key line. A successful union joined two different groups into one, so the number of groups drops by one. Edges between already-joined nodes return False, so they do not change the count. When the loop ends, count holds the number of connected components.

⏱️ Time and Space Complexity

The DFS approach builds an adjacency list and visits every node and edge once, so it is O(V + E) time and O(V + E) space. Union-find skips the adjacency list. With path compression and union by rank, each operation is almost constant time, the inverse Ackermann alpha of n, which stays tiny. So union-find runs in about O(E * alpha(V)) time and O(V) space. Both are fast. Union-find wins on memory because it stores only two small arrays.

Approach Time Complexity Space Complexity
DFS over adjacency list O(V + E) O(V + E)
Union-find with compression and rank O(E * alpha(V)) O(V)

Tip

A quick mental check: the number of components always equals the number of nodes minus the number of successful unions. Start at n and subtract one for each real merge. That single counter is all you need.

🧩 Key Takeaways

  • βœ… A connected component is a group where you can travel between every node.
  • βœ… Union-find starts with every node alone, so the count begins at n.
  • βœ… Every union that merges two different groups lowers the count by one.
  • βœ… Path compression and union by rank keep each operation almost constant time.
  • βœ… DFS also solves it in O(V + E), but union-find uses less memory.

Check Your Knowledge

4 questions Show quiz Hide quiz

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    What is a connected component?

    Why: A connected component is a set of nodes where a path exists between any two of them.

  2. 2

    When using union-find, what value does the count start at?

    Why: Every node begins as its own group, so the count starts at n and drops as groups merge.

  3. 3

    When does a union lower the component count?

    Why: If the two nodes already share a root, no merge happens, so the count only drops on a real merge.

  4. 4

    What makes union-find operations almost constant time?

    Why: Path compression flattens trees and union by rank keeps them short, giving near constant time per operation.

πŸš€ What’s Next?