☕ Buy me a coffee — $3

Depth-First Search (DFS)

Overview

Depth-First Search (DFS) is a graph traversal algorithm that explores as far as possible along each branch before backtracking. It uses a stack data structure, which can be explicitly implemented or implicitly managed via recursive function calls. The algorithm begins at a specified starting node, marks it as visited, and explores its first unvisited neighbor. This process continues recursively, diving deeper into the graph until a dead-end (node with no unvisited neighbors) is reached. At this point, the algorithm backtracks to explore alternative paths.

DFS has numerous applications in computer science. It is widely used in pathfinding problems, such as navigating a maze or solving puzzles. It is also essential for cycle detection in graphs, topological sorting for dependency resolution, and connected component analysis to identify distinct subgraphs. In artificial intelligence, DFS is employed for exhaustive state-space searches, such as solving n-queens problems or traversing decision trees.

Historical Origins in Maze-Solving

The idea of depth-first search predates computer science by nearly a century. The algorithm's essential mechanic, go as deep as you can along one path, back up when you hit a dead end, remember where you have been, was formalised by the French mathematician Charles Pierre Trémaux in the 1880s as a systematic method for solving mazes. Trémaux's algorithm, still taught in recreational mathematics courses, involves marking each junction and each corridor as you traverse it, with a simple rule for deciding which direction to go next based on whether the mark count is zero, one, or two. Follow the rules and you will find the exit if one exists, and the marks you leave behind constitute a proof of correctness, you will never walk down the same corridor more than twice in each direction.

The algorithm was rediscovered and formalised in the computer-science sense by John Hopcroft and Robert Tarjan in a 1973 paper "Algorithm 447: Efficient Algorithms for Graph Manipulation." Hopcroft and Tarjan's contribution was not the traversal itself, that was well-known, but the observation that DFS's structural properties enable a whole family of algorithms that had previously required more complex techniques. They showed how DFS could compute strongly connected components, biconnected components, planarity testing, and more, all in linear time O(V + E). Their paper and the subsequent work it inspired earned Hopcroft and Tarjan the Turing Award in 1986.

Tarjan's later single-scan SCC algorithm (1972) and the two-pass Kosaraju algorithm (unpublished but well-known) are both variations on DFS-plus-postorder-numbering, and they are among the most beautiful algorithms in the field, a single traversal of the graph, combined with a stack and some counter arithmetic, computes structural properties that would otherwise take much more work to determine.

DFS Edge Classification

A DFS traversal implicitly partitions the edges of the graph into four categories, and understanding this partition is what makes DFS-based algorithms possible.

  • Tree edges: edges that lead to unvisited vertices when traversed, the edges that form the DFS tree. Every visited vertex except the source has exactly one tree edge leading to it.
  • Back edges: edges (u, v) where v is an ancestor of u in the DFS tree, that is, v is currently on the recursion stack. The presence of a back edge is the definition of a cycle in the graph: any back edge closes a cycle consisting of the back edge itself and the tree-edge path from v down to u. Cycle detection in a directed graph reduces to detecting a back edge during DFS.
  • Forward edges: edges (u, v) where v is a descendant of u in the DFS tree but not directly connected by a tree edge (a "shortcut" down the tree). Only possible in directed graphs.
  • Cross edges: edges (u, v) where u and v have no ancestor-descendant relationship in the DFS tree, e.g. they are in different subtrees. Also only in directed graphs.

The classification is easy to compute during the traversal itself: maintain a "colour" for each vertex (WHITE = not yet visited, GRAY = on the recursion stack, BLACK = finished), and classify each edge when it is examined:

  • WHITE target ⇒ tree edge
  • GRAY target ⇒ back edge
  • BLACK target and discovery time of target > discovery time of source ⇒ forward edge
  • BLACK target and discovery time of target < discovery time of source ⇒ cross edge

Note that an undirected graph's DFS has only tree and back edges, there are no forward or cross edges, because every edge in an undirected graph is traversed in both directions during DFS and one of the traversals establishes the tree edge.

Algorithms Built on DFS

A remarkable number of important graph algorithms are essentially "run DFS and record some extra bookkeeping." Understanding this reduces a whole family of problems to variations of the same underlying template.

Topological Sort

Ordering the vertices of a directed acyclic graph so that every edge points from an earlier vertex to a later one. The DFS-based algorithm is elegantly simple: run DFS, and when a vertex finishes (its DFS call returns), prepend it to a list. The reversed finish order is a valid topological order. If DFS ever finds a back edge, the graph has a cycle and no topological order exists. Build systems (make, Bazel, Cargo's dependency resolver) use this algorithm to determine the order to compile files. Python's graphlib.TopologicalSorter and Java's TopologicalSort in various libraries all use DFS internally.

Strongly Connected Components

A strongly connected component (SCC) of a directed graph is a maximal set of vertices such that every vertex in the set is reachable from every other. SCCs partition the vertices; the "SCC graph" (with SCCs as super-nodes) is a DAG. Tarjan's 1972 algorithm computes all SCCs in a single DFS pass by tracking a "lowlink" value per vertex, the smallest discovery time reachable from that vertex via any combination of tree, back, and cross edges. When a vertex's DFS call finishes with lowlink equal to its own discovery time, all vertices currently on an auxiliary stack above it form an SCC. Kosaraju's alternative algorithm uses two DFS passes on the graph and its transpose. Both are O(V + E).

Biconnected Components and Bridges

An articulation point is a vertex whose removal disconnects the graph; a bridge is an edge whose removal does. These are critical failure points in a network: a single-router path from one subnet to another passes through an articulation point. Tarjan's DFS-based algorithm for finding all articulation points and bridges runs in a single pass by tracking discovery times and lowlink values, the same machinery as SCC computation, in a different arrangement.

Bipartite Testing and 2-Colouring

A graph is bipartite (2-colourable) if and only if it contains no odd-length cycle. DFS can colour the graph alternately as it descends, and if it ever tries to give a vertex a colour different from one it already has, the graph is not bipartite. This is O(V + E) and the standard method for testing bipartiteness in practice.

Cycle Detection in Directed Graphs

As mentioned above: a directed graph has a cycle if and only if DFS finds a back edge. In an undirected graph, DFS finds a cycle when it encounters a visited vertex that is not its parent. Both variations are O(V + E).

When DFS Beats BFS (and Vice Versa)

DFS and BFS have identical time and space complexity (O(V + E) and O(V)) but very different behaviour, and choosing between them is a real engineering decision.

  • Use DFS when the answer is defined by structure or reachability, topological sort, cycle detection, connected components, SCC, bridges, maze paths. Anything where you want to visit "everything reachable" without caring about how far things are.
  • Use BFS when distance matters, shortest path in an unweighted graph, level-order tree traversal, "find the closest" problems. BFS's frontier expansion gives it the shortest-path property that DFS does not.
  • DFS uses less memory on average. DFS's stack depth is O(diameter); BFS's queue can hold O(width). For trees these are usually about the same order; for wide flat graphs BFS uses much more memory; for narrow deep graphs DFS uses much more.
  • DFS can be written recursively. This is a real advantage for code clarity when the problem has a natural recursive shape, but a real disadvantage when the graph is deep enough to overflow the call stack. Python's default recursion limit of 1000 makes recursive DFS unsuitable for large linear chains, use the iterative version with an explicit stack when the graph might be deep.

How It Works

DFS works by:

  1. Starting at a specified node and marking it as visited
  2. Exploring the first unvisited neighbor
  3. Recursively applying DFS to that neighbor
  4. Backtracking when no unvisited neighbors remain
  5. Continuing until all reachable nodes are visited

The "depth-first" nature means the algorithm goes as deep as possible before exploring other branches, creating a path that goes from the root to a leaf before backtracking.

DFS Algorithm Pseudocode


DFS(node):
    if node is NULL:
        return
    mark node as visited
    for each neighbor in node.children:
        if neighbor is not visited:
            DFS(neighbor)
                

Implementation

Recursive Implementation


def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    
    visited.add(start)
    print(start)  # Process node
    
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)
    
    return visited
                

Iterative Implementation (Using Stack)


def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    
    while stack:
        node = stack.pop()
        
        if node not in visited:
            visited.add(node)
            print(node)  # Process node
            
            # Add neighbors to stack (reverse order to maintain DFS order)
            for neighbor in reversed(graph[node]):
                if neighbor not in visited:
                    stack.append(neighbor)
    
    return visited
                

Complexity Analysis

  • Time Complexity: O(V + E) - where V is the number of vertices and E is the number of edges. Each vertex and edge is visited once.
  • Space Complexity: O(h) auxiliary, where h is the length of the longest path, O(V) in the worst case for both the recursive call stack and the iterative explicit stack. (Storing the graph itself as an adjacency list is a further O(V + E), but that is the input, not the algorithm's overhead.)

Mind the recursion limit. Python caps recursion at 1000 frames by default, so the recursive version raises RecursionError on any graph containing a path longer than that, and a 10,000-node chain or a degenerate tree is not an exotic input. Use the iterative version for graphs whose depth you do not control. Raising sys.setrecursionlimit() is not a real fix, since the underlying C stack can still overflow and crash the interpreter outright.

DFS Framework

  • Start at the root or specified node and mark it as visited.
  • Use recursion or a stack to explore neighbors or children.
  • Implement clear base cases to handle stopping conditions.
  • Track necessary state, like visited nodes or current path.
  • Use backtracking when exploring all possible solutions is required.

Applications

  • Pathfinding: Finding paths in mazes or graphs
  • Cycle Detection: Detecting cycles in directed and undirected graphs
  • Topological Sorting: Ordering nodes based on dependencies
  • Connected Components: Finding all nodes reachable from a starting node
  • Tree/Graph Traversal: Exploring tree structures
  • Puzzle Solving: Solving problems like n-queens, sudoku

Popular LeetCode Questions Using DFS

104. Maximum Depth of Binary Tree

Problem: Given the root of a binary tree, return its maximum depth. A binary tree's maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.

Solution: This problem uses a Depth-First Search (DFS) approach to calculate the maximum depth of a binary tree. The idea is to recursively determine the depth of the left and right subtrees and then return the greater of the two depths, incremented by one to account for the current node. The base case occurs when the function encounters a null node (indicating an empty subtree), in which case it returns a depth of zero.

This approach ensures that all nodes in the binary tree are visited once, making it efficient. The time complexity of this solution is O(N), where N is the number of nodes in the tree. The space complexity is O(H), where H is the height of the tree, as the recursion stack can grow up to the depth of the tree.

                    
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right

class Solution:
    def maxDepth(self, root: Optional[TreeNode]) -> int:
        if not root:
            return 0
        
        left = self.maxDepth(root.left)
        right = self.maxDepth(root.right)

        return 1 + max(left, right)
                    
                    

112. Path Sum

Problem: Given the root of a binary tree and an integer targetSum, return true if the tree has a root-to-leaf path such that adding up all the values along the path equals targetSum.

Solution: This problem uses a Depth-First Search (DFS) approach to recursively traverse the binary tree. The key idea is to check, at every node, whether the remaining target sum can be achieved along a root-to-leaf path. A node is considered a "leaf" if it has no left or right children. The base case checks if the tree is empty (returning False) or if the current node is a leaf and its value matches the remaining targetSum (returning True).

For non-leaf nodes, the algorithm recursively calls itself on the left and right subtrees, subtracting the current node's value from the remaining targetSum. If any of these recursive calls return True, the function concludes that a valid path exists. This ensures that all possible root-to-leaf paths are explored, making the solution comprehensive. The time complexity is O(N), where N is the number of nodes in the tree, as each node is visited once. The space complexity is O(H), where H is the height of the tree, due to the recursive call stack.

                    
# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
        if not root:
            return False
        if not root.left and not root.right:
            return root.val == targetSum
        
        left = self.hasPathSum(root.left, targetSum - root.val)
        right = self.hasPathSum(root.right, targetSum - root.val)

        return left or right
                    
                    

Related Algorithms

Explore other graph algorithms: