Facebook Pixel

323. Number of Connected Components in an Undirected Graph πŸ”’

Problem Description

You are given a graph with n nodes labeled from 0 to n-1. The graph is represented by an integer n (the number of nodes) and an array edges, where each element edges[i] = [ai, bi] indicates that there is an undirected edge connecting nodes ai and bi.

Your task is to find and return the total number of connected components in the graph.

A connected component is a group of nodes where there is a path between any two nodes in the group, and these nodes are not connected to any other nodes outside the group. In other words, it's a maximal set of nodes that are all reachable from each other through the edges.

For example, if you have 5 nodes (0, 1, 2, 3, 4) and edges [[0,1], [2,3]], there would be 3 connected components:

  • Component 1: nodes {0, 1} (connected by an edge)
  • Component 2: nodes {2, 3} (connected by an edge)
  • Component 3: node {4} (isolated node with no edges)

The solution uses Depth-First Search (DFS) to traverse the graph. It builds an adjacency list from the edges, then visits each node. When it encounters an unvisited node, it performs DFS to mark all nodes in that connected component as visited, counting this as one component. The process continues until all nodes have been visited, and the total count of components is returned.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: The problem explicitly states we have a graph with n nodes and edges connecting them.

Is it a tree?

  • No: The graph is not necessarily a tree. It can have multiple disconnected components, and there's no guarantee of a single connected structure without cycles.

Is the problem related to directed acyclic graphs?

  • No: The edges are undirected (each edge [ai, bi] connects nodes bidirectionally), and we're not dealing with directed edges or topological ordering.

Is the problem related to shortest paths?

  • No: We're not finding shortest paths between nodes. We're counting connected components.

Does the problem involve connectivity?

  • Yes: The core of the problem is about connectivity - we need to identify groups of nodes that are connected to each other (connected components).

Disjoint Set Union

  • While DSU could solve this problem, the solution uses DFS instead. Let's continue exploring the alternative path.

Does the problem have small constraints?

  • The constraints can vary, but for connectivity problems, DFS is a standard approach regardless of size.

DFS/backtracking

  • Yes: DFS is perfect for exploring connected components. We can start from an unvisited node and use DFS to mark all reachable nodes as visited, identifying one complete component.

Conclusion: The flowchart leads us to use DFS for this connectivity problem. DFS allows us to traverse each connected component completely, marking all nodes in that component as visited before moving to the next unvisited node, effectively counting the number of separate connected components in the graph.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we think about connected components, we can visualize the graph as several "islands" of nodes, where nodes within each island can reach each other through edges, but there's no way to travel between different islands.

The key insight is that if we start from any node and explore as far as possible through the edges, we'll discover all nodes in that particular component. Once we've visited all nodes in one component, we can move to any unvisited node - which must belong to a different component - and repeat the process.

This naturally leads us to a traversal approach. Starting from node 0, we can use DFS to "paint" all reachable nodes as visited. This gives us our first component. Then we look for the next unpainted node and repeat. Each time we start a new DFS from an unvisited node, we've found a new component.

Why DFS specifically? When we encounter a node, we want to immediately explore all its connections before moving on. DFS does exactly this - it goes as deep as possible along each path before backtracking. This ensures we fully explore one component before accidentally counting parts of it as separate components.

The algorithm becomes straightforward:

  1. Build an adjacency list from the edges to make traversal efficient
  2. Keep track of visited nodes to avoid counting the same component multiple times
  3. For each unvisited node, launch a DFS that marks all reachable nodes as visited
  4. Each successful DFS launch (returning 1 when starting from an unvisited node) represents discovering a new component

The beauty of this approach is that each node is visited exactly once, and we naturally partition the graph into its connected components through the traversal process.

Solution Approach

The implementation follows the DFS approach we identified through our analysis. Let's walk through the solution step by step:

1. Building the Adjacency List

First, we construct an adjacency list g where g[i] contains all neighbor nodes of node i:

g = [[] for _ in range(n)]
for a, b in edges:
    g[a].append(b)
    g[b].append(a)

Since the graph is undirected, we add both directions for each edge. This allows us to traverse from any node to its neighbors efficiently.

2. DFS Helper Function

The core of our solution is the DFS function:

def dfs(i: int) -> int:
    if i in vis:
        return 0
    vis.add(i)
    for j in g[i]:
        dfs(j)
    return 1

This function does two things:

  • If node i is already visited, it returns 0 (no new component found)
  • If node i is unvisited, it marks it as visited, recursively visits all its neighbors, and returns 1 (new component found)

The recursive calls to dfs(j) ensure we visit all nodes reachable from node i, effectively exploring the entire connected component.

3. Counting Components

The main logic uses a set vis to track visited nodes and counts components:

vis = set()
return sum(dfs(i) for i in range(n))

We iterate through all nodes from 0 to n-1. For each node:

  • If it's already visited (part of a previously discovered component), dfs returns 0
  • If it's unvisited (start of a new component), dfs returns 1 and marks all reachable nodes as visited

The sum() function accumulates these return values, giving us the total number of connected components.

Time and Space Complexity:

  • Time: O(V + E) where V is the number of vertices and E is the number of edges. We visit each node once and traverse each edge twice (once from each direction).
  • Space: O(V) for the adjacency list, visited set, and recursion stack in the worst case.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example with 6 nodes (labeled 0-5) and edges [[0,1], [0,2], [3,4]].

Step 1: Build the adjacency list

Node 0: neighbors [1, 2]
Node 1: neighbors [0]
Node 2: neighbors [0]
Node 3: neighbors [4]
Node 4: neighbors [3]
Node 5: neighbors []

Step 2: Initialize visited set and component count

  • vis = {} (empty set)
  • We'll call dfs(i) for each node 0 through 5

Step 3: Process each node

Starting with node 0:

  • Node 0 is not visited, so we mark it and explore
  • Visit neighbor 1: mark as visited
  • Visit neighbor 2: mark as visited
  • dfs(0) returns 1 (found new component)
  • vis = {0, 1, 2}

Node 1:

  • Already in vis, so dfs(1) returns 0

Node 2:

  • Already in vis, so dfs(2) returns 0

Node 3:

  • Node 3 is not visited, so we mark it and explore
  • Visit neighbor 4: mark as visited
  • dfs(3) returns 1 (found new component)
  • vis = {0, 1, 2, 3, 4}

Node 4:

  • Already in vis, so dfs(4) returns 0

Node 5:

  • Node 5 is not visited, so we mark it
  • No neighbors to explore
  • dfs(5) returns 1 (found new component)
  • vis = {0, 1, 2, 3, 4, 5}

Step 4: Sum the results Total = 1 + 0 + 0 + 1 + 0 + 1 = 3 connected components

The three components are:

  1. {0, 1, 2} - connected through edges
  2. {3, 4} - connected through an edge
  3. {5} - isolated node

Solution Implementation

1class Solution:
2    def countComponents(self, n: int, edges: List[List[int]]) -> int:
3        """
4        Count the number of connected components in an undirected graph.
5      
6        Args:
7            n: Number of nodes (labeled from 0 to n-1)
8            edges: List of edges where each edge is [node1, node2]
9      
10        Returns:
11            Number of connected components in the graph
12        """
13      
14        def dfs(node: int) -> int:
15            """
16            Depth-first search to mark all nodes in a connected component.
17          
18            Args:
19                node: Current node to visit
20          
21            Returns:
22                1 if this node starts a new component, 0 if already visited
23            """
24            # If node was already visited, it's part of a previously counted component
25            if node in visited:
26                return 0
27          
28            # Mark current node as visited
29            visited.add(node)
30          
31            # Visit all neighbors of the current node
32            for neighbor in adjacency_list[node]:
33                dfs(neighbor)
34          
35            # Return 1 to indicate a new component was found
36            return 1
37      
38        # Build adjacency list representation of the graph
39        adjacency_list = [[] for _ in range(n)]
40        for node_a, node_b in edges:
41            adjacency_list[node_a].append(node_b)
42            adjacency_list[node_b].append(node_a)
43      
44        # Track visited nodes
45        visited = set()
46      
47        # Count components by starting DFS from each unvisited node
48        return sum(dfs(node) for node in range(n))
49
1class Solution {
2    // Adjacency list to represent the graph
3    private List<Integer>[] adjacencyList;
4    // Boolean array to track visited nodes
5    private boolean[] visited;
6
7    /**
8     * Counts the number of connected components in an undirected graph
9     * @param n Number of nodes in the graph (0 to n-1)
10     * @param edges Array of edges where each edge is [node1, node2]
11     * @return Number of connected components
12     */
13    public int countComponents(int n, int[][] edges) {
14        // Initialize the adjacency list for n nodes
15        adjacencyList = new List[n];
16        visited = new boolean[n];
17      
18        // Create an empty ArrayList for each node
19        Arrays.setAll(adjacencyList, index -> new ArrayList<>());
20      
21        // Build the undirected graph by adding edges in both directions
22        for (int[] edge : edges) {
23            int nodeA = edge[0];
24            int nodeB = edge[1];
25            adjacencyList[nodeA].add(nodeB);
26            adjacencyList[nodeB].add(nodeA);
27        }
28      
29        // Count connected components by performing DFS from each unvisited node
30        int componentCount = 0;
31        for (int node = 0; node < n; node++) {
32            // If DFS returns 1, we found a new component
33            componentCount += dfs(node);
34        }
35      
36        return componentCount;
37    }
38
39    /**
40     * Depth-first search to explore all nodes in a connected component
41     * @param currentNode The current node being explored
42     * @return 1 if this node starts a new component, 0 if already visited
43     */
44    private int dfs(int currentNode) {
45        // If node is already visited, it's part of an existing component
46        if (visited[currentNode]) {
47            return 0;
48        }
49      
50        // Mark current node as visited
51        visited[currentNode] = true;
52      
53        // Recursively visit all adjacent nodes
54        for (int neighbor : adjacencyList[currentNode]) {
55            dfs(neighbor);
56        }
57      
58        // Return 1 to indicate we found a new component
59        return 1;
60    }
61}
62
1class Solution {
2public:
3    int countComponents(int n, vector<vector<int>>& edges) {
4        // Build adjacency list representation of the graph
5        vector<vector<int>> adjacencyList(n);
6        for (const auto& edge : edges) {
7            int nodeA = edge[0];
8            int nodeB = edge[1];
9            // Add bidirectional edges (undirected graph)
10            adjacencyList[nodeA].push_back(nodeB);
11            adjacencyList[nodeB].push_back(nodeA);
12        }
13      
14        // Track visited nodes
15        vector<bool> visited(n, false);
16      
17        // Define DFS function to traverse connected components
18        function<int(int)> dfs = [&](int node) -> int {
19            // If already visited, this node doesn't start a new component
20            if (visited[node]) {
21                return 0;
22            }
23          
24            // Mark current node as visited
25            visited[node] = true;
26          
27            // Visit all adjacent nodes
28            for (int neighbor : adjacencyList[node]) {
29                dfs(neighbor);
30            }
31          
32            // Return 1 to indicate this node started a new component
33            return 1;
34        };
35      
36        // Count the number of connected components
37        int componentCount = 0;
38        for (int i = 0; i < n; ++i) {
39            // Each unvisited node starts a new connected component
40            componentCount += dfs(i);
41        }
42      
43        return componentCount;
44    }
45};
46
1/**
2 * Counts the number of connected components in an undirected graph
3 * @param n - The number of nodes in the graph (nodes are labeled from 0 to n-1)
4 * @param edges - Array of edges where each edge is represented as [node1, node2]
5 * @returns The number of connected components in the graph
6 */
7function countComponents(n: number, edges: number[][]): number {
8    // Build adjacency list representation of the graph
9    const adjacencyList: number[][] = Array.from({ length: n }, () => []);
10  
11    // Add bidirectional edges to the adjacency list
12    for (const [nodeA, nodeB] of edges) {
13        adjacencyList[nodeA].push(nodeB);
14        adjacencyList[nodeB].push(nodeA);
15    }
16  
17    // Track visited nodes during DFS traversal
18    const visited: boolean[] = Array(n).fill(false);
19  
20    /**
21     * Performs depth-first search starting from the given node
22     * @param currentNode - The node to start DFS from
23     * @returns 1 if this is a new component (node not visited), 0 otherwise
24     */
25    const dfs = (currentNode: number): number => {
26        // If node is already visited, it belongs to a previously counted component
27        if (visited[currentNode]) {
28            return 0;
29        }
30      
31        // Mark current node as visited
32        visited[currentNode] = true;
33      
34        // Visit all adjacent nodes
35        for (const adjacentNode of adjacencyList[currentNode]) {
36            dfs(adjacentNode);
37        }
38      
39        // Return 1 to indicate this started a new component
40        return 1;
41    };
42  
43    // Count components by attempting DFS from each node
44    // Only unvisited nodes will contribute to the count
45    return adjacencyList.reduce((componentCount, _, nodeIndex) => {
46        return componentCount + dfs(nodeIndex);
47    }, 0);
48}
49

Time and Space Complexity

Time Complexity: O(n + m)

The algorithm performs a depth-first search (DFS) to count connected components in an undirected graph. Breaking down the time complexity:

  • Building the adjacency list: O(m) - iterates through all edges once
  • DFS traversal: O(n + m) - each node is visited at most once (O(n)), and for each node, we explore all its edges (O(m) total across all nodes)
  • The main loop calls dfs(i) for each node from 0 to n-1, but due to the visited set check, each node is actually processed only once

Therefore, the overall time complexity is O(n + m).

Space Complexity: O(n + m)

The space usage consists of:

  • Adjacency list g: O(n + m) - n lists to store nodes, and 2m total edge entries (each undirected edge is stored twice)
  • Visited set vis: O(n) - can contain at most n nodes
  • Recursion call stack: O(n) in the worst case when the graph is a long path

The dominant factor is the adjacency list storage, giving us a total space complexity of O(n + m).

Common Pitfalls

1. Forgetting to Handle Isolated Nodes

A common mistake is assuming all nodes will appear in the edges array. Isolated nodes (nodes with no edges) won't appear in the edges list but still count as separate components.

Incorrect approach:

# Only iterating through nodes that appear in edges
nodes_in_edges = set()
for a, b in edges:
    nodes_in_edges.add(a)
    nodes_in_edges.add(b)
return sum(dfs(i) for i in nodes_in_edges)  # Misses isolated nodes!

Correct approach:

# Iterate through ALL nodes from 0 to n-1
return sum(dfs(i) for i in range(n))

2. Treating the Graph as Directed

Since the graph is undirected, each edge should be added in both directions in the adjacency list.

Incorrect approach:

# Only adding edge in one direction
for a, b in edges:
    adjacency_list[a].append(b)  # Missing the reverse direction!

Correct approach:

# Adding edge in both directions
for a, b in edges:
    adjacency_list[a].append(b)
    adjacency_list[b].append(a)

3. Stack Overflow with Large Components

For graphs with very deep components (like a long chain), recursive DFS can cause stack overflow. Python's default recursion limit is around 1000.

Solution using iterative DFS:

def dfs_iterative(start: int) -> int:
    if start in visited:
        return 0
  
    stack = [start]
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        for neighbor in adjacency_list[node]:
            if neighbor not in visited:
                stack.append(neighbor)
    return 1

4. Using Wrong Data Structure for Visited Tracking

Using a list instead of a set for tracking visited nodes leads to O(n) lookup time instead of O(1).

Inefficient approach:

visited = []  # O(n) lookup time
if node not in visited:  # This is O(n) operation
    visited.append(node)

Efficient approach:

visited = set()  # O(1) lookup time
if node not in visited:  # This is O(1) operation
    visited.add(node)
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Given a sorted array of integers and an integer called target, find the element that equals to the target and return its index. Select the correct code that fills the ___ in the given code snippet.

1def binary_search(arr, target):
2    left, right = 0, len(arr) - 1
3    while left ___ right:
4        mid = (left + right) // 2
5        if arr[mid] == target:
6            return mid
7        if arr[mid] < target:
8            ___ = mid + 1
9        else:
10            ___ = mid - 1
11    return -1
12
1public static int binarySearch(int[] arr, int target) {
2    int left = 0;
3    int right = arr.length - 1;
4
5    while (left ___ right) {
6        int mid = left + (right - left) / 2;
7        if (arr[mid] == target) return mid;
8        if (arr[mid] < target) {
9            ___ = mid + 1;
10        } else {
11            ___ = mid - 1;
12        }
13    }
14    return -1;
15}
16
1function binarySearch(arr, target) {
2    let left = 0;
3    let right = arr.length - 1;
4
5    while (left ___ right) {
6        let mid = left + Math.trunc((right - left) / 2);
7        if (arr[mid] == target) return mid;
8        if (arr[mid] < target) {
9            ___ = mid + 1;
10        } else {
11            ___ = mid - 1;
12        }
13    }
14    return -1;
15}
16

Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More