Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 61

Union-Find for Graph Connectivity

Recommended resources

Hello! Let's continue our journey into advanced graph algorithms.

In our last lesson, we built a complete, optimized Union-Find (or Disjoint Set Union) data structure from the ground up. You now have a powerful UnionFind class in your toolkit, complete with path compression and union by rank/size optimizations, making its operations nearly constant time.

Today, we'll put that tool to work. Our goal is to apply the Union-Find data structure to efficiently solve graph connectivity problems, with a specific focus on a classic interview question: counting the number of connected components in a graph. This will demonstrate the practical, high-ROI nature of the Union-Find pattern you've learned.

1. Understanding Connected Components

A connected component is a subgraph in which any two vertices are connected to each other by paths, and which is connected to no additional vertices in the supergraph. In simpler terms, it's a distinct, self-contained "island" within a larger graph.

Connected Components and Union-Find Algorithm
CAPTION: An undirected graph with 8 nodes. We can visually identify three separate "islands" or connected components: {0, 1, 2}, {3, 4, 5}, and {6, 7}. The goal is to determine this count programmatically.

For a problem like this, your first thought might be to use Depth-First Search (DFS) or Breadth-First Search (BFS). You could iterate through each node, and if a node hasn't been visited, you'd start a traversal (DFS/BFS) from it, increment a component counter, and mark every node in that component as visited. This works and has a time complexity of , where is the number of vertices and is the number of edges.

However, the Union-Find data structure provides a more natural and often more efficient way to model this problem, especially in dynamic scenarios.


2. The Union-Find Strategy for Counting Components

The core idea is to reframe the problem from "traversing to find islands" to "merging sets until no more merges are possible."

Here is the algorithm:

  1. Initialization: Assume at the very beginning, no nodes are connected. Therefore, each of the N nodes is its own isolated component. We start with count = N components.
  2. Process Edges: We iterate through each edge (u, v) in the graph.
  3. Union Operation: For each edge, we try to union the sets containing u and v.
    • If u and v are already in the same set (i.e., their roots are the same), this edge is redundant. It connects two nodes that were already connected. The number of components does not change.
    • If u and v are in different sets, the union operation will merge them. This means we have just connected two previously separate components into one larger component. Therefore, we decrement our component count by 1.
  4. Final Count: After processing all the edges, the final value of count is our answer.

This approach elegantly tracks the reduction of components as edges are "added" to the graph. Let's watch a video that walks through this exact logic and contrasts it with the DFS approach.

Number of Connected Components in an Undirected Graph - Union Find - Leetcode 323 - Python

The NeetCode video 'Number of Connected Components in an Undirected Graph' is a perfect resource for this topic. It's tailored for interview preparation and clearly explains how Union-Find is a natural fit for this problem.

Watch the entire video. Pay close attention to these key parts: The DFS/BFS approach (00:00 - 04:07): Understand this as the baseline to compare against. The Union-Find logic (04:07 - 10:50): Focus on how the algorithm starts with n components and decrements the count with each successful union. The Python implementation (10:50 - 15:53): Observe how the UnionFind data structure is used to solve the problem.


3. Implementation and Analysis

Let's integrate this logic with the UnionFind class we developed in the previous lesson. A key design choice is to have the union method return a boolean: True if a merge occurred and False otherwise. This makes the calling code clean and explicit.

Here's the complete solution in Python:

# Assuming the UnionFind class from the previous lesson is available
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n
        # This is a new attribute for this specific problem
        self.num_components = n

    def find(self, i):
        if self.parent[i] == i:
            return i
        self.parent[i] = self.find(self.parent[i])
        return self.parent[i]

    def union(self, i, j):
        root_i = self.find(i)
        root_j = self.find(j)
        
        if root_i != root_j:
            # Union by size
            if self.size[root_i] < self.size[root_j]:
                root_i, root_j = root_j, root_i
            
            self.parent[root_j] = root_i
            self.size[root_i] += self.size[root_j]
            
            # A merge happened, so decrement the component count
            self.num_components -= 1
            return True # Indicate a successful union
        
        return False # No union was performed

# --- Main function to solve the problem ---
def count_connected_components(n, edges):
    """
    Counts the number of connected components in a graph.
    
    Args:
        n (int): The number of nodes, labeled 0 to n-1.
        edges (list of lists): A list of edges, e.g., [[0, 1], [1, 2], [3, 4]].
        
    Returns:
        int: The number of connected components.
    """
    if n == 0:
        return 0
        
    uf = UnionFind(n)
    
    for u, v in edges:
        uf.union(u, v)
        
    return uf.num_components

Notice how clean the main function is. We initialize UnionFind and simply loop through the edges, calling union. The component counting logic is neatly encapsulated within the UnionFind class itself.

An Alternative Counting Method

Another way to find the number of components is to process all unions first and then count the number of unique roots at the end. Each unique root represents one connected component.

# This function uses the same UnionFind class, but a different counting strategy.
def count_connected_components_alternative(n, edges):
    if n == 0:
        return 0
    
    # We don't need the `num_components` attribute in the UF class for this method
    uf = UnionFind(n) 
    
    for u, v in edges:
        uf.union(u, v)
    
    # After all unions, count the number of unique roots.
    # Each node's root is found by calling find().
    root_set = {uf.find(i) for i in range(n)}
    
    return len(root_set)

This alternative is conceptually simple but slightly less efficient, as it requires an extra pass over all nodes at the end to count the roots. The first method of decrementing the count during the union process is generally preferred as it gives the final answer immediately after processing the edges.

Test your understanding!

Given n = 6 nodes and the following edges: [[0, 1], [2, 3], [0, 4], [3, 5]]

Trace the number of connected components using the decrementing union method. What is the component count after each edge is processed?

  1. Initial state
  2. After union(0, 1)
  3. After union(2, 3)
  4. After union(0, 4)
  5. After union(3, 5)
Show answer
  1. Initial state: num_components = 6. Sets are {0}, {1}, {2}, {3}, {4}, {5}.
  2. union(0, 1): Nodes 0 and 1 were in different sets. They are merged. num_components becomes 5. Sets are now {0, 1}, {2}, {3}, {4}, {5}.
  3. union(2, 3): Nodes 2 and 3 were in different sets. They are merged. num_components becomes 4. Sets are now {0, 1}, {2, 3}, {4}, {5}.
  4. union(0, 4): Nodes 0 and 4 were in different sets. The set {4} is merged into {0, 1}. num_components becomes 3. Sets are now {0, 1, 4}, {2, 3}, {5}.
  5. union(3, 5): Nodes 3 and 5 were in different sets. The set {5} is merged into {2, 3}. num_components becomes 2. Sets are now {0, 1, 4}, {2, 3, 5}.

The final number of connected components is 2.

Complexity Analysis

  • Time Complexity:
    • for initializing the UnionFind data structure.
    • For each of the edges, we perform one union operation, which involves two find calls. With optimizations, this loop takes time.
    • Since is nearly constant, the complexity is effectively linear in the number of nodes and edges.
  • Space Complexity: to store the parent and size/rank arrays.

This performance is excellent and makes Union-Find a go-to solution for connectivity-based problems in interviews.


Conclusion

You have now successfully applied the Union-Find data structure to solve a common and practical graph problem. This lesson moved from theory to application, a crucial step in preparing for technical interviews where you're expected not just to know data structures, but to know when and how to use them effectively.

Key Takeaways:

  • Counting connected components can be intuitively modeled as a process of merging sets.
  • The algorithm starts with N components and decrements the count for each successful union of two previously disconnected sets.
  • The Union-Find data structure is the perfect tool for this, providing near-constant time operations for find and union.
  • The overall time complexity of is highly efficient and competitive with DFS/BFS-based solutions.

Preview of the Next Lesson:

We will stay on the topic of undirected graphs but shift our focus to another fundamental problem: detecting cycles. While you might notice that a union operation on two nodes already in the same set implies a cycle, we will explore the canonical DFS-based approach for cycle detection. This will involve tracking visited nodes and their parents during a traversal to identify "back edges." This will add another important graph pattern to your problem-solving repertoire.