Skip to content
GitHub Twitter

Depth First Search (DFS) explained using Python

Join my Newsletter

Never miss any update. Subscribe to my newsletter.

    I won't send you spam. Unsubscribe at any time.

    Introduction to Depth-First Search (DFS)

    Depth First Search in action

    DFS is a graph traversal algorithm. It is designed to explore a graph's nodes. DFS is unlike its counterpart, Breadth-First Search (BFS). It goes deep into the graph before backtracking. The algorithm is recursive or stack-based. It visits a node and explores each branch as far as possible before backtracking.

    Understanding the Core of DFS

    Graph Traversal Mechanics

    Depth-First Search (DFS) is a graph traversal algorithm designed to systematically explore a graph's nodes. Unlike its counterpart, Breadth-First Search (BFS), DFS plunges deep into the graph structure before backtracking. The algorithm follows a recursive or stack-based approach, visiting a node and recursively exploring as far as possible along each branch before backtracking.

    The Graph Representation

    Consider a graph with a designated root node and its children, each node having a specific branching factor – the maximum number of children a node can have. The DFS algorithm starts at the root, explores as deeply as possible along each branch, and backtracks only when necessary.

        def find_target(self, curr_node, target_node, path, depth):
            """
            Recursively searches for the target node in the graph.
    
            :param curr_node: The current node being explored.
            :param target_node: The target node to search for.
            :param path: The path taken during the search.
            :param depth: The current depth of the search.
            :return: A tuple containing a boolean indicating if the target is found and the path taken.
            """
            # Check if the current node is the target node
            if curr_node.uuid == target_node:
                return True, path
    
            # Check if the depth has reached the specified limit
            elif depth >= self.depth:
                return False, []
    
            # Initialize a tuple to track if target is found and the path
            target_found = (False, [])
    
            # Append the current node's uuid to the path
            path.append(curr_node.uuid)
    
            # Iterate over the children of the current node
            for child in curr_node.children:
                # Recursively call find_target for each child
                target_found = target_found[0] or self.find_target(child, target_node, path, depth + 1)
                # If the target is found, return the result immediately
                if target_found[0]:
                    return target_found
    
            # Remove the current node's uuid from the path if target not found in its children
            path.pop()
    
            # Return the final result
            return False, path
    

    Time Complexity Analysis

    The time complexity of DFS depends on the depth of the graph and the target node's location. In the worst case, the target node is at the deepest depth. Then, the time complexity becomes exponential. A detailed analysis involves counting the nodes at each depth. This requires considering the branching factor.

    Nodes visited at each level

    To calculate the time complexity of DFS, we would need to consider the worst case. What's the worst case? The worst case is when our target value is at the right most branch of the graph. In that case we would need to discover all the nodes before reaching the rightmost value. In that case, we would need to traverse all the nodes in the graph. So now, the question becomes, how many total nodes are in the graph.

    Considering a branching factor = b, the total number of nodes will be:

    Total nondes = b^0 + b^1 + b^2 + b^3 ≅ b^n

    i.e. T(n) ≅ b^n

    Space Complexity Considerations

    The space complexity in DFS mainly depends on the graph's depth. The algorithm uses a stack or recursive call stack to traverse the graph. The depth reached during traversal sets the space complexity. It's denoted as O(h), with h as the max depth.

    O(h) = h // We just store h elements in our recursive stack

    Python Implementation

    Crafting a Python DFS Class

    Let's transition to the practical side of our exploration – a Python implementation of DFS. The code comprises a Node class representing graph nodes and a DFS class with methods for graph construction and target node search. The DFS algorithm is implemented using recursion and backtracking.

    from collections import deque
    
    class Node:
        # Class variable to keep track of node numbers
        nodeNumber = 0
    
        def __init__(self):
            # Increment the nodeNumber for each new node
            Node.nodeNumber = Node.nodeNumber + 1
            # Unique identifier for the node
            self.uuid = Node.nodeNumber
            print("Building: ", self.uuid)
            # List to store children nodes
            self.children = []
    
        def construct_and_get_child_node(self, child: object) -> object:
            """
            Constructs a child node and appends it to the current node's children list.
    
            :param child: The child node to be appended.
            :return: The appended child node.
            """
            self.children.append(child)
            return child
    
        def __eq__(self, other):
            # Custom equality check based on uuid
            return self.uuid == other.uuid
    
    
    class DFS:
        def __init__(self):
            # Initialize DFS with root, current node, branching factor, and depth
            self.root = None
            self.curr = None
            self.branching_factor = None
            self.depth = None
    
        @staticmethod
        def build_child_nodes_breadth_first(root, branching_factor, depth):
            """
            Builds child nodes in a breadth-first manner up to a specified depth.
    
            :param root: The root node.
            :param branching_factor: The maximum number of children for each node.
            :param depth: The depth of the graph.
            :return: None
            """
            if depth <= 0:
                return
    
            queue = deque([(root, 0)])
    
            while queue:
                current_node, current_depth = queue.popleft()
    
                if current_depth >= depth:
                    return
    
                for idx in range(1, branching_factor + 1):
                    child_node = current_node.construct_and_get_child_node(Node())
                    queue.append((child_node, current_depth + 1))
    
        def search(self, target_node):
            """
            Initiates the search for a target node in the graph.
    
            :param target_node: The target node to search for.
            :return: A tuple containing a boolean indicating if the target is found and the path taken.
            """
            self.curr = self.root
            path = []
            return self.find_target(self.curr, target_node, path, 0)
    
        def find_target(self, curr_node, target_node, path, depth):
            """
            Recursively searches for the target node in the graph.
    
            :param curr_node: The current node being explored.
            :param target_node: The target node to search for.
            :param path: The path taken during the search.
            :param depth: The current depth of the search.
            :return: A tuple containing a boolean indicating if the target is found and the path taken.
            """
            # Check if the current node is the target node
            if curr_node.uuid == target_node:
                return True, path
    
            # Check if the depth has reached the specified limit
            elif depth >= self.depth:
                return False, []
    
            # Initialize a tuple to track if target is found and the path
            target_found = (False, [])
    
            # Append the current node's uuid to the path
            path.append(curr_node.uuid)
    
            # Iterate over the children of the current node
            for child in curr_node.children:
                # Recursively call find_target for each child
                target_found = target_found[0] or self.find_target(child, target_node, path, depth + 1)
                # If the target is found, return the result immediately
                if target_found[0]:
                    return target_found
    
            # Remove the current node's uuid from the path if target not found in its children
            path.pop()
    
            # Return the final result
            return False, path
    
    
        def pretty_print_graph(self, node, indent=0, parent_arrow=""):
            """
            Pretty prints the graph structure.
    
            :param node: The current node being printed.
            :param indent: The indentation level for formatting.
            :param parent_arrow: The arrow indicating the relationship with the parent node.
            :return: None
            """
            print("  " * indent + f"{parent_arrow}Node: {node.uuid}")
            for child in node.children:
                self.pretty_print_graph(child, indent + 1, "│")
    
        def print_graph(self):
            # Initiates the pretty printing of the graph from the root node
            self.pretty_print_graph(self.root)
    
        def build_graph(self, branching_factor=3, depth=3):
            """
            Builds the graph structure with specified branching factor and depth.
    
            :param branching_factor: The maximum number of children for each node.
            :param depth: The depth of the graph.
            :return: None
            """
            self.root = Node()
            self.branching_factor = branching_factor
            self.depth = depth
            return DFS.build_child_nodes_breadth_first(self.root, branching_factor, depth)
    
    # Instantiate DFS
    dfs = DFS()
    
    # Build the graph with default parameters
    dfs.build_graph()
    print("Total nodes: ", Node.nodeNumber)
    
    # Search for a target node (39)
    path = dfs.search(39)
    print(path)
    

    TL;DR

    This guide has equipped you with a detailed understanding of DFS, from its traversal mechanics to time and space complexities. The provided Python implementation serves as a practical demonstration of the algorithm's application.

    Feel free to explore the code, experiment with different graphs, and deepen your grasp of DFS. As you navigate the technical depths, may your algorithms run efficiently and your coding endeavors be met with success.

    Happy coding, and may your technical explorations lead to new insights and achievements!

    If you have any questions or need clarification, feel free to email me at gautamsharma2813@gmail.com.

    Signing off,

    -G

    Join my Newsletter

    Never miss any update. Subscribe to my newsletter.

      I won't send you spam. Unsubscribe at any time.