Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Advanced Graph Traversal Techniques

Hello! Welcome back to our module on Graph Algorithms.

In our last lesson, we built a robust cycle detection algorithm for directed graphs. A key feature of that implementation was its ability to handle disconnected graphs by iterating through every node, ensuring we checked for cycles in all parts of the graph, not just the component we started in.

This lesson formalizes and expands on that idea. Our learning outcome is to adapt graph traversal algorithms to correctly handle disconnected components and self-loops. While we've touched on these cases, we will now address them explicitly to ensure our BFS and DFS implementations are truly production-ready. For a service like your inference scheduler Rudi, which must maintain high QoS, being able to gracefully handle any valid graph structure—not just "perfect" textbook examples—is non-negotiable.


1. Handling Disconnected Components

A graph is disconnected if there is no path between some pairs of vertices. It's essentially a collection of several independent sub-graphs.

Disconnected Undirected Graph and Adjacency List
This graph has two separate components. A traversal starting at node 0 would visit nodes {0, 1, 2, 3} but would never reach nodes {4, 5, 6}.

A standard traversal call like bfs(graph, start_node) or dfs(graph, start_node) will only explore the single component containing start_node. To visit every node, we need a simple but powerful wrapper pattern.

The following video provides an excellent explanation of what connected components are and introduces the pattern needed to traverse all of them.

G-4. What are Connected Components ?

The video 'What are Connected Components?' from the take U forward channel clearly defines the concept and outlines the standard algorithmic pattern for handling them.

Please watch from the beginning to 06:40. Focus on two key ideas: the definition of a component, and the use of a 'visited' array combined with an outer loop to ensure every component is traversed.

The Traversal Loop Pattern

As the video explained, the solution is to wrap our traversal function inside a loop that iterates through every vertex. This ensures that if a vertex wasn't visited by a previous traversal, it must belong to a new, undiscovered component, which we can then explore.

The general logic is:

  1. Initialize a visited set or boolean array for all vertices in the graph.
  2. Loop through each vertex v from 0 to V-1.
  3. If v has not been visited:
    a. You've found a new component.
    b. Start a standard traversal (BFS or DFS) from v.
    c. This traversal will find and mark all nodes within this component as visited.

This ensures every node in every component is visited exactly once. The MIT OCW lecture on BFS also briefly mentions this exact pattern as the standard way to visit all vertices in a potentially disconnected graph (see video "Lecture 13: Breadth-First Search (BFS)", from 50:27).

For a practical implementation of this pattern, let's look at the following resource.

Connected Components in an Undirected Graph

The article 'Connected Components in an Undirected Graph' from GeeksforGeeks provides full Python code showing how to wrap both DFS and BFS to find all components.

Please review 'Approach 1: Using Depth first search (DFS)' and 'Approach 2: Using Breadth first search (BFS)'. Focus on the structure of the getComponents function in the Python examples. Notice how it contains the outer loop and a visited array, calling the dfs or bfs helper function only for unvisited nodes.

Here's a concise Python implementation of the pattern to count the number of connected components:

from collections import defaultdict

def dfs_helper(node, adj, visited):
    """A standard recursive DFS to explore one component."""
    visited.add(node)
    for neighbor in adj[node]:
        if neighbor not in visited:
            dfs_helper(neighbor, adj, visited)

def count_components(num_nodes: int, edges: list[list[int]]) -> int:
    """
    Counts the number of connected components in an undirected graph.
    """
    if num_nodes == 0:
        return 0




    # Build adjacency list for an undirected graph
    adj = defaultdict(list)
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)

    visited = set()
    component_count = 0




    # The traversal loop pattern
    for i in range(num_nodes):
        if i not in visited:

```grasp
{
  "type": "exercise",
  "id": "b7373428-5ec7-469b-94ae-547d6db3429e"
}
        # This node is the start of a new, unexplored component
        component_count += 1
        dfs_helper(i, adj, visited)

return component_count

Example usage with the graph from the image LINK

Component 1: {0, 1, 2, 3}, Component 2: {4, 5, 6} (assuming 7 nodes)

edges = [[0, 1], [0, 2], [1, 3], [4, 5], [5, 6]]
print(f"Number of components: {count_components(7, edges)}") # Expected: 2


---




### 2. Handling Self-Loops

A **self-loop** is an edge that connects a vertex to itself (e.g., `u -> u`).

```grasp
{
  "type": "image",
  "title": "Directed Graph with Self-Loops",
  "id": "[LINK](https://www.graphable.ai/wp-content/uploads/2022/11/image-3.png)",
  "url": "https://www.graphable.ai/wp-content/uploads/2022/11/image-3.png",
  "caption": "In this graph, nodes 1 and 2 both have self-loops. These can cause issues for naive traversal algorithms."
}

How a self-loop affects a traversal depends on the algorithm's purpose and implementation details:

For Directed Graph Cycle Detection (DFS)

In our previous lesson, we used a 3-state DFS (with visiting and visited sets). This correctly handles self-loops.

  • When dfs_recursive(u) is called, u is added to the visiting set.
  • The loop for neighbor in adj[u] will encounter u itself.
  • The check if neighbor in visiting: will be true, and a cycle is correctly detected.

So, for cycle detection, your existing algorithm is already robust to self-loops.

For General Traversal (BFS)

This is where a subtle implementation detail becomes critical. Consider a BFS traversal starting from a node u that has a self-loop.

  • Incorrect Implementation: If you mark a node as visited after you pop it from the queue, a self-loop will cause an infinite loop. The algorithm will pop u, process its neighbors (including u), and add the unvisited u back to the queue indefinitely.
  • Correct Implementation: You must mark a node as visited before you push it onto the queue.

Let's see the difference in a BFS snippet:

from collections import deque

def correct_bfs(adj, start_node):
    queue = deque([start_node])
    visited = {start_node} # Mark visited immediately
    
    while queue:
        node = queue.popleft()
        print(f"Visiting {node}")
        
        for neighbor in adj[node]:
            if neighbor not in visited:
                visited.add(neighbor) # Mark visited BEFORE pushing
                queue.append(neighbor)


```grasp
{
  "type": "exercise",
  "id": "9b92dfb6-9db0-4c79-a34d-cad5c9676d19"
}

Example graph with a self-loop at node 1

adj_with_self_loop = {
0: [1],
1: [1, 2], # Self-loop on 1
2: []
}

print("Correct BFS handles self-loop:")
correct_bfs(adj_with_self_loop, 0)

Output will be: Visiting 0, Visiting 1, Visiting 2. It terminates correctly.


The BFS implementation in the GeeksforGeeks article you reviewed ([LINK](https://www.geeksforgeeks.org/dsa/connected-components-in-an-undirected-graph/)) follows this correct pattern. This pre-emptive marking prevents a node from ever being added to the queue more than once, elegantly handling both self-loops and simple cycles in an unweighted graph traversal.

---


```grasp
{
  "type": "exercise",
  "id": "1a52e1c3-7fc4-4da4-9087-b9fdde5f67df"
}

Conclusion

By combining these two patterns, you can write traversal code that is robust to the complexities of real-world graph data. Building systems that don't crash on unexpected inputs is a hallmark of a senior engineer. You'll find this particularly relevant in your work, where infrastructure services must be resilient. The exercise in your Rust book (LINK) about adapting MST algorithms for disconnected components further highlights how fundamental this concept is across graph theory.

Key Takeaways:

  • Standard BFS and DFS traversals only explore a single connected component.
  • To traverse a disconnected graph, wrap your traversal call in a loop that iterates through all vertices, using a visited set to find and explore new components.
  • Self-loops can cause infinite loops in naive traversals.
  • To correctly handle self-loops (and cycles in general during traversal), mark a node as visited before adding it to the queue (for BFS) or stack (for iterative DFS).

Next Lesson Preview

We have now mastered traversals (BFS and DFS) on unweighted graphs, making them robust to tricky structures. The next logical step is to introduce complexity to the edges themselves. In the next lesson, we will solve a shortest path problem on a weighted graph using Dijkstra's algorithm. This is fundamental for problems like finding the fastest network route or the lowest-cost path, where BFS alone is no longer sufficient.

Can't find a good explanation? Sign up and we'll make it for you

Sign up