Depth First Search In a Graph

Depth First Search
🔗
Click here to suggest a better video or view suggestions..

Depth-First Search (DFS) is a fundamental algorithm used in graph theory and computer science to explore and traverse a graph or a tree-like data structure. The basic idea behind DFS is to start at a particular node (the root or starting node) and then explore as far as possible along each branch before coming back and trying out other paths. This process continues until all the reachable nodes have been visited or the desired node has been found.

DFS prioritizes exploring the depth of the graph before breadth, visiting all the nodes in a particular branch before moving to the next branch. Hence the name “Depth First Search”.

The recursive and iterative DFS implementations mentioned in this article visits/traverses all nodes present in a given graph. If you need to find for a specific value, you can stop the Depth first search immediately after coming across the desired node.

Recursive Implementation

Below is the algorithm for performing Depth first search on a graph using recursion technique:

First we start at a given node or root of the graph/tree for which we need to perform Depth First Search, and then:

  1. Print the current Node.
  2. Mark the current node as visited.
  3. For each unvisited neighbor of the current node: a. Recursively call the DFS function on that neighbor.
  4. Once all neighbors have been visited, backtrack to the previous node and continue with DFS for all other unvisited neighbors.

We need to perform the above algorithm for each node present in the graph so that we do not miss any nodes which are not connected to some other nodes. Below is the implementation of recursive DFS algorithm:


def dfs_recursive(current_node, graph, visited):
  """
  Function to recursively perform a Depth First Search (DFS)
  starting from the current_node.
  
  Args:
  current_node: The node from which DFS starts.
  graph: The graph represented as an adjacency list.
  visited: A set to keep track of all visited nodes.
  """

  # 1. Print the current node
  print(current_node, end=' ')
  
  # 2. Mark the current node as visited
  visited.add(current_node)
  
  # 3. Iterate through each neighbor of the current node
  for neighbor in graph[current_node]:
    # If the neighbor is not visited yet
    if neighbor not in visited:
      # 3.a Recursively call the DFS function on the neighbor
      dfs_recursive(neighbor, graph, visited)

# Sample graph represented as an adjacency list
graph = {
    'A' : ['B', 'C'],
    'B' : ['A', 'C', 'D'],
    'C' : ['A', 'B', 'E', 'F'],
    'D' : ['B'],
    'E' : ['C'],
    'F' : ['C']
}

# Initialize an empty set to store all visited elements
visited = set()

# Perform dfs_recursive function for each node in the graph
for node in graph:
  if node not in visited:
    # Start DFS at the current node
    dfs_recursive(node, graph, visited)

Iterative Implementation

In the recursive DFS approach, the algorithm uses function calls to keep track of the search path, with the system call stack managing the order of exploration. Each recursive call represents a step deeper into the graph, and the algorithm backtracks by returning from the function calls.

On the other hand, the iterative DFS implementation uses an explicit stack data structure to manage the search process. Instead of relying on the system call stack, the algorithm pushes the vertices onto the stack and pops them off as it explores the graph.

One key benefit of the iterative approach is that it can handle larger and deeper graphs more efficiently, as it avoids the potential for stack overflow errors that can occur in the recursive implementation. Additionally, the iterative version can be more easily parallelized, as the stack can be divided and processed concurrently by multiple threads or processes.

Below is the algorithm for performing depth first search using iterative approach:

  1. Start with the root node of the graph or tree.
  2. Push the root node onto a stack.
  3. While the stack is not empty:
    • Pop a node from the stack.
    • Visit and process the popped node.
    • Push all the unvisited neighbors of the popped node onto the stack, in the order they appear in the graph or tree.
  4. Repeat steps 3 until the stack is empty.

Below is the implementation of the iterative DFS algorithm:


def dfs_iterative(start_node, graph, visited):
  """
  Function to iteratively perform a Depth First Search (DFS)
  starting from the current_node.
  
  Args:
  start_node: The node from which DFS starts.
  graph: The graph represented as an adjacency list.
  visited: A set to keep track of all visited nodes.
  """

  # Create a stack to store the nodes to visit
  stack = [start_node]
  
  # While the stack is not empty
  while stack:
    # Pop a node from the stack
    current_node = stack.pop()
    
    if current_node not in visited:
      # Process/Print the current node
      print(current_node, end=' ')
      
      # Mark the current node as visited
      visited.add(current_node)
    
      # Push all the unvisited neighbors of the current node onto the stack
      for neighbor in reversed(graph[current_node]):
        if neighbor not in visited:
          stack.append(neighbor)

# Sample graph represented as an adjacency list
graph = {
    'A' : ['B', 'C'],
    'B' : ['A', 'C', 'D'],
    'C' : ['A', 'B', 'E', 'F'],
    'D' : ['B'],
    'E' : ['C'],
    'F' : ['C']
}

# Initialize an empty set to store all visited elements
visited = set()

# Perform dfs_iterative function for each node in the graph
for node in graph:
  if node not in visited:
    # Start DFS at the current node
    dfs_iterative(node, graph, visited)

Time and Space Complexity

Time Complexity:

The time complexity of DFS depends on the number of vertices (V) and edges (E) in the graph. In the worst case, DFS visits all the vertices and edges in the graph, resulting in a time complexity of O(V + E).

This means that the time taken to perform DFS grows linearly with the size of the graph, as the algorithm needs to visit each vertex and edge at most once.

Space Complexity:

In addition to the space used to store the graph as adjacency list or matrix, additional space is required by function call stack in recursive implementation and custom stack used in iterative implementation. This additional space complexity is O(H), where (H) is the maximum depth of the recursion. It’s maximum value can go up to the total number of vertices in the graph.

  • Topological Sorting: DFS is useful for determining the topological order of a directed acyclic graph (DAG). This is particularly beneficial in scenarios such as task scheduling and resolving dependencies where some tasks must be completed before others.
  • Cycle Detection: DFS can be employed to detect cycles in a graph. This is crucial in applications like compiler design, where cycles can indicate issues in code dependencies, or in network routing, where cycles might cause inefficiencies or routing loops.
  • Graph Traversal: DFS can traverse all the nodes in a graph, visiting nodes by diving deep into each branch before backtracking, which can be useful for exploring hierarchical structures or game trees.
  • Connected Components: In undirected graphs, DFS can be used to easily find all the connected components, which is essential in clustering applications and network analysis.