Depth-First Search

Top interview pattern
80 min read
Updated June 2026
What you'll learn
  • Trace how a recursion frame stores local state and how returning restores the previous frame
  • Design DFS parameters by separating what flows down from parent to child and what flows back up
  • Implement the five DFS families: tree recursion, graph components, grid flood fill, cycle detection, and post-order aggregation
  • Choose DFS over BFS (or vice versa) based on whether the problem needs depth-first exploration or distance-order processing

DFS explores one branch to its end before trying another. The call stack remembers where to backtrack. The pattern covers trees, graphs, and grids, each with a different strategy for tracking what has been visited.

What DFS Actually Does

DFS chooses one branch, carries context in the current stack frame, and explores until it reaches a base case or a dead end. Then it returns, and the previous frame continues with the next branch. The call stack manages all the bookkeeping: which branches have been tried, what state was active at each decision point, and where to resume after a branch finishes.

Without DFS, exploring all branches of a tree or graph means manually tracking every unfinished branch, every partial result, and every position that still needs work. DFS replaces that manual management with function calls. Each call handles one node. Returning from that call restores the caller's state automatically.

Problem: Given a binary tree, find its maximum depth.

Tree DFS ยท Maximum Depth

Current
Node 1
Depth
1
Returns
โ€”
Action
ENTER
123456
Call Stack
dfs(1)โ† top

ENTER node 1 (root)

Recursive pattern: get answer from both subtrees, combine, return.

Step 1 of 15

Each call asks its children for their depth, waits for both answers, then reports the larger one plus one. The call stack grows as DFS descends and shrinks as nodes return their results.

Maximum Depth of Binary Tree
1def max_depth(root):
2 if root is None:
3 return 0
4
5 left_depth = max_depth(root.left)
6 right_depth = max_depth(root.right)
7
8 return max(left_depth, right_depth) + 1

How the Recursion Stack Explores

Each recursive call pushes a frame onto the call stack. A frame stores the current node, the function's local variables, and the point in the code where execution will resume after the call returns. When the call finishes, the frame is popped, and the previous frame picks up exactly where it left off.

This is why DFS does not need an explicit data structure to track which branches are still pending. The call stack does it. Each pending branch corresponds to a frame that has called into a child and is waiting for the return value.

The order in which you place work relative to recursive calls determines traversal order. If you process the current node before calling children, that is pre-order. If you process it after both children return, that is post-order. The stack is the same in both cases. The difference is where the "do work" line sits in relation to the recursive calls.

Recursion depth equals the longest path from the root to the deepest node being explored. For balanced trees that is O(log n). For skewed trees (every node has only one child) it is O(n), which can exceed Python's default recursion limit of about 1000 frames.

What Each Frame Is Responsible For

Every recursion frame has three responsibilities. First, it receives context from its parent through parameters. Second, it does local computation at the current node. Third, it either returns a value to its parent or mutates shared state. Designing a DFS solution means deciding what happens in each of these three stages.

What Flows Down

Parameters carry information from parent to child. The type of parameter determines whether you need cleanup after returning from a branch.

Pattern A: Accumulating Parameters (Immutable)

This pattern works well for tracking running totals, depths, or constraints. No cleanup needed because integers are immutable, so each branch gets its own copy.

Immutable parameter (int)
1def has_path_sum(node, target):
2 if node is None:
3 return False
4 if is_leaf(node):
5 return node.val == target
6 # Pass modified value - no cleanup needed
7 return (has_path_sum(node.left, target - node.val) or
8 has_path_sum(node.right, target - node.val))

Because target - node.val creates a new integer, each recursive branch has its own copy. No state sharing, no bugs.

Pattern B: Path Parameters (Mutable)

Track the actual sequence of visited nodes with this pattern. Requires cleanup because lists are shared across branches.

Mutable parameter (list)
1def all_paths(node, path, result):
2 if node is None:
3 return
4
5 path.append(node.val) # Add to shared list
6
7 if is_leaf(node):
8 result.append(path.copy()) # MUST copy
9 else:
10 all_paths(node.left, path, result)
11 all_paths(node.right, path, result)
12
13 path.pop() # Remove before returning (cleanup)
Common Mistake
Why path.copy()?
Lists are passed by reference. Without path.copy(), all saved paths point to the same list object. As you continue traversing, that list changes. Every saved path contains incorrect data.

Pattern C: Bound Parameters

Use this pattern for validating constraints that narrow at each level.

Bound parameters for BST validation
1def is_valid_bst(node, min_val=float('-inf'), max_val=float('inf')):
2 if node is None:
3 return True
4 if not (min_val < node.val < max_val):
5 return False
6 # Tighten bounds as we descend
7 return (is_valid_bst(node.left, min_val, node.val) and
8 is_valid_bst(node.right, node.val, max_val))

Pattern D: Parent Parameter

Use in undirected graphs to skip the node you came from.

Parent parameter for undirected graphs
1def dfs(node, graph, visited, parent=None):
2 visited.add(node)
3 for neighbor in graph[node]:
4 if neighbor == parent:
5 continue # Skip where we came from
6 if neighbor in visited:
7 return True # Cycle found!
8 if dfs(neighbor, graph, visited, node):
9 return True
10 return False

Pattern E: Depth Parameter

Track how many levels deep you are. Essential for level-based problems like "nodes at distance K" or "rightmost node per level".

Depth parameter for level-based problems
1def dfs(node, depth=0):
2 """Track depth/level as you go deeper."""
3 if not node:
4 return
5 # Use depth here (e.g., add to level's list)
6 levels[depth].append(node.val) # or any depth-based logic
7 dfs(node.left, depth + 1)
8 dfs(node.right, depth + 1)
9
10# Example: Binary Tree Right Side View
11def right_side_view(root):
12 result = []
13 def dfs(node, depth):
14 if not node:
15 return
16 if depth == len(result): # First node at this depth
17 result.append(node.val)
18 dfs(node.right, depth + 1) # Right first!
19 dfs(node.left, depth + 1)
20 dfs(root, 0)
21 return result

What Flows Up

The return type controls what information flows back from child to parent. There are four distinct patterns. The problem statement almost always tells you which one applies.

Problem asks for...ShapeReturns
"max/min/sum of tree"A. Return valuea number
"does X exist?"B. Return boolTrue or False (stop early if found)
"find <strong>all</strong> paths/solutions"C. Mutate externalnothing, just adds to result list
"max path" + needs subtree infoD. Hybrida value, and also updates a global
Return scalar
1# A. Return value (max depth)
2def dfs(node):
3 if not node:
4 return 0
5 return max(dfs(node.left), dfs(node.right)) + 1
Short-circuit
1# B. Return bool (path sum exists)
2def dfs(node, target):
3 if not node:
4 return False
5 if is_leaf(node):
6 return node.val == target
7 return (dfs(node.left, target - node.val) or
8 dfs(node.right, target - node.val))
Append to external list
1# C. Mutate (all paths)
2def dfs(node, path, result):
3 if not node:
4 return
5 path.append(node.val)
6 if is_leaf(node):
7 result.append(path.copy())
8 else:
9 dfs(node.left, path, result)
10 dfs(node.right, path, result)
11 path.pop() # backtrack
Return + update global
1# D. Hybrid (max path sum)
2def max_path_sum(root):
3 best = [float('-inf')]
4 def dfs(node):
5 if not node:
6 return 0
7 l = max(dfs(node.left), 0)
8 r = max(dfs(node.right), 0)
9 best[0] = max(best[0], l + r + node.val)
10 return max(l, r) + node.val
11 dfs(root)
12 return best[0]

Why best = [float('-inf')] and not best = float('-inf')? Python closures can read outer variables but cannot rebind them (without nonlocal). Wrapping in a list lets you mutate best[0] from inside the nested function.

Why max(dfs(node.left), 0)? Clamps negative subtrees to zero. If a subtree's best path is negative, drop it entirely rather than letting it drag down the sum.

Why return max(l, r) + node.val but update with l + r + node.val? The parent can only extend one branch, so we return the best single-branch path. But the global max might use both branches through this node, so we check that separately.

Interview Tip
Hybrid pattern
If the problem asks for a maximum path that can bend through a node, you need hybrid: return the best single branch for the parent, update a global variable with the full path sum. Family 5 (Post-Order Aggregation) covers this pattern in depth.

The Three Traversal Orders

Traversal order determines when the current node does its work relative to its children. The choice depends on the problem shape: whether the node needs information before going deeper, needs to process children in sorted order, or needs child results before it can compute its own answer.

Tree Traversal Orders

Order
Pre-order
Pattern
Mark โ†’ Left โ†’ Right
Output
[1]
Action
MARK
11234567

Step 1 of 14 ยท Process root first, then recurse. Used for tree serialization.

Pre-order: Process node, then children

Use pre-order when the node's work must happen before its children exist or are processed. Cloning a tree requires creating the parent node before attaching its children. Serializing a tree records the root first so the deserializer can reconstruct the tree top-down.

In-order: Left child, then node, then right

Use in-order when the problem depends on sorted order within a BST. In-order traversal visits BST nodes from smallest to largest. BST validation checks that each visited value exceeds the previous one. Kth-smallest extraction stops after visiting exactly k nodes.

Post-order: Children first, then node

Use post-order when the parent's value depends on results from both children. Computing tree height requires knowing both subtree heights before taking the max. Computing diameter requires both subtree depths before summing them. This is the most common order in DFS problems.

OrderWhen to useExample problems
Pre-orderNode must be created or recorded before children are processedClone tree, serialize tree
In-orderProblem requires sorted-order access in a BSTBST validation, kth smallest in BST
Post-orderParent answer depends on child resultsMax depth, tree diameter, delete tree

Code Templates

The only difference between these three is where you place the "process node" line:

Three Traversal Orders
1def preorder(node, result=None):
2 """Process node BEFORE children."""
3 if result is None:
4 result = []
5 if not node:
6 return result
7 result.append(node.val) # Process
8 preorder(node.left, result) # Left
9 preorder(node.right, result) # Right
10 return result
11
12def inorder(node, result=None):
13 """Process node BETWEEN children (BST gives sorted order)."""
14 if result is None:
15 result = []
16 if not node:
17 return result
18 inorder(node.left, result) # Left
19 result.append(node.val) # Process
20 inorder(node.right, result) # Right
21 return result
22
23def postorder(node, result=None):
24 """Process node AFTER children (need child results first)."""
25 if result is None:
26 result = []
27 if not node:
28 return result
29 postorder(node.left, result) # Left
30 postorder(node.right, result) # Right
31 result.append(node.val) # Process
32 return result
33
34# Example tree: 1
35# / \
36# 2 3
37# Pre-order: [1, 2, 3] (root first)
38# In-order: [2, 1, 3] (left, root, right)
39# Post-order: [2, 3, 1] (children first)

The concatenation version ([node.val] + left + right) is easier to read but creates new lists at every call, making it O(nยฒ). The accumulator version appends in-place for O(n).

Key Insight
Traversal Order Selection
Determine when the current node's value is needed:

โ€ข Before going deeper? Pre-order (cloning, serialization)
โ€ข In sorted order for BST? In-order
โ€ข After I know child results? Post-order (height, diameter, most problems)

Family 1: Tree Recursion

Tree recursion is the most common DFS family. Each call owns exactly one subtree. There are no cycles, so no visited set is needed. The parent calls left and right children, waits for both results, then combines them.

Key Insight
Each call owns one subtree
Each call owns one subtree. It is the only call responsible for that subtree's answer. No other call will visit those same nodes.

Almost every tree DFS follows this structure. The only things that change are the base case return value, the recursive calls, and the combine step.

Tree DFS Template
1def dfs(node):
2 # 1. BASE CASE: What to return for None
3 if node is None:
4 return base_value
5
6 # 2. RECURSE: Get results from children
7 left_result = dfs(node.left)
8 right_result = dfs(node.right)
9
10 # 3. COMBINE: Merge results with current node
11 return combine(left_result, right_result, node.val)

Three decisions completely determine your solution: the base case return value (0, True, -inf), what you do with the left and right results, and how you combine them with the current node.

Concrete Example: Max Depth

1def max_depth(node):
2 if node is None:
3 return 0
4
5 left_depth = max_depth(node.left)
6 right_depth = max_depth(node.right)
7
8 return max(left_depth, right_depth) + 1

The base case returns 0 because an empty subtree contributes no depth. Each node returns the larger child depth plus one. The root's return value is the tree's maximum depth.

Worked Example: Path Sum II

Problem: find all root-to-leaf paths where the sum equals target. This combines downward parameters (remaining sum, current path) with mutation (appending to a result list). No value flows back up.

Path Sum II
1def path_sum_ii(root, target):
2 result = []
3
4 def dfs(node, remaining, path):
5 if node is None:
6 return
7
8 path.append(node.val)
9
10 if not node.left and not node.right and remaining == node.val:
11 result.append(path.copy())
12 else:
13 dfs(node.left, remaining - node.val, path)
14 dfs(node.right, remaining - node.val, path)
15
16 path.pop()
17
18 dfs(root, target, [])
19 return result

remaining - node.val: Immutable integer, so each branch automatically gets its own copy. No cleanup needed.

path.append / path.pop(): The path list is shared mutable state. After exploring the branch that includes the current node, pop() restores the path so the next sibling sees the correct state.

path.copy(): Without this, every saved path points to the same list object, which keeps changing as traversal continues.

remaining == node.val: We subtract before recursing into children, so at the leaf we compare the remaining amount to the leaf's own value.

Common Mistake
Negative child contributions in max path sum
When computing max path sum, a negative child sum makes the path worse. Clamp with max(child, 0) so you can drop an entire subtree instead of being forced to include it. Without this, a subtree with sum -5 drags down every path that passes through it.

Family 2: Graph Components

Unlike trees, graphs can have cycles and multiple paths to the same node. The visited set prevents infinite loops by ensuring each node is processed exactly once. Once a node enters the visited set, it is finalized and no other DFS call will process it again.

Key Insight
Visited nodes are finalized
Visited nodes are finalized and must not be reprocessed. Adding a node to the visited set before exploring its neighbors guarantees that no other path will start a second DFS from that node.

Graph DFS ยท Visited Set

Current
A
Visited
{}
Stack
[A]
Action
ENTER
ABCDEFG(shared)
Call Stack
dfs(A)โ†

ENTER A

Pick any unvisited node. Order does not affect correctness.

Step 1 of 26

The key difference from tree DFS is the visited set. Check before processing, add immediately before exploring neighbors.

Graph DFS Template
1def dfs(node, graph, visited):
2 if node in visited:
3 return
4 visited.add(node) # Mark BEFORE exploring
5
6 for neighbor in graph[node]:
7 dfs(neighbor, graph, visited)

if node in visited: return: Prevents infinite loops on cyclic graphs. Without this guard, DFS revisits the same node endlessly through back edges.

visited.add(node) before the loop: Must happen before exploring neighbors. If you mark after, another path can reach this node before it is marked, causing duplicate processing.

for neighbor in graph[node]: Iterates over the adjacency list. In trees you access .left/.right directly. In graphs you iterate over all neighbors.

Connected Components

To count connected components, iterate over all nodes and start DFS from each unvisited one. Each DFS call marks an entire component as visited. The number of DFS initiations equals the number of components.

1def count_components(n, graph):
2 visited = set()
3 count = 0
4
5 for node in range(n):
6 if node not in visited:
7 dfs(node, graph, visited)
8 count += 1 # Found a new component
9
10 return count
Common Mistake
Late visited marking
If you add to visited after processing neighbors (instead of before), you can revisit the same node through different paths before it is marked. This causes duplicate processing and, in cyclic graphs, infinite loops. Always add to visited before the neighbor loop.

Family 3: Grid Flood Fill

Grids are implicit graphs where each cell connects to its 4 (or 8) adjacent cells. There is no explicit adjacency list. Bounds checking replaces it. Cells are often marked visited in-place by changing their value, which eliminates the need for a separate visited set.

Key Insight
Marked cells belong to one component
Marked cells have already been assigned to a component. Changing a cell's value from land to water (or any sentinel) guarantees that no future DFS call will process it again.

Grid DFS ยท Number of Islands

Islands Found
0
Cells Marked
0 / 10
Current
โ€”
Action
CHECK
01234012341100110011000100110001000
Call Stack
(empty)
Land (1)
Water (0)
Current
ร—
Marked

Scan grid row-by-row for unvisited land

Two layers: outer scan finds islands, inner DFS explores each one.

Step 1 of 18

Each DFS call checks bounds and cell value before proceeding. If the cell is out of bounds, already visited, or not the target value, the call returns immediately. Otherwise it marks the cell and recurses in four directions.

Grid DFS (Number of Islands)
1def dfs(grid, r, c):
2 # Bounds check + visited check
3 if r < 0 or r >= len(grid) or c < 0 or c >= len(grid[0]):
4 return
5 if grid[r][c] != '1': # Not land or already visited
6 return
7
8 grid[r][c] = '0' # Mark visited in-place
9
10 # Explore 4 directions
11 dfs(grid, r + 1, c) # down
12 dfs(grid, r - 1, c) # up
13 dfs(grid, r, c + 1) # right
14 dfs(grid, r, c - 1) # left

Counting Islands

Scan every cell. When you find unvisited land, start a DFS to mark the entire connected island. The number of DFS initiations equals the number of islands.

1def num_islands(grid):
2 if not grid:
3 return 0
4
5 count = 0
6 for r in range(len(grid)):
7 for c in range(len(grid[0])):
8 if grid[r][c] == '1':
9 dfs(grid, r, c) # Flood-fill this island
10 count += 1
11
12 return count
Interview Tip
If you cannot modify the input grid, use a separate visited set: visited = set() and visited.add((r, c)).

Family 4: Cycle Detection

Detecting cycles in a directed graph requires 3 states, not 2. A standard visited set cannot distinguish between a node on the current DFS path (cycle if revisited) and a node fully explored in an earlier call (safe to reach again).

Key Insight
State tracks the active path
State 1 (visiting) means the node is on the current recursion path. State 2 (done) means the node and all its descendants have been fully explored with no cycle found. Only reaching a state-1 node indicates a cycle.

Cycle Detection ยท 3-State Coloring

0: Unvisited
1: Visiting
2: Done
state[]
0
0
0
0
0
0
ENTER
012345
Call Stack
dfs(3)โ†

ENTER node 3

Process this leaf first to demonstrate the DONE state.

Step 1 of 17

State 0unvisited
State 1visiting (on current recursion path)
State 2done (fully explored, confirmed safe)
3-State Cycle Detection
1def has_cycle(graph, n):
2 # 0 = unvisited, 1 = visiting, 2 = done
3 state = [0] * n
4
5 def dfs(node):
6 if state[node] == 1: # Back edge โ†’ cycle!
7 return True
8 if state[node] == 2: # Already processed
9 return False
10
11 state[node] = 1 # Mark as visiting
12
13 for neighbor in graph[node]:
14 if dfs(neighbor):
15 return True
16
17 state[node] = 2 # Mark as done
18 return False
19
20 # Check all nodes (graph may be disconnected)
21 for node in range(n):
22 if state[node] == 0:
23 if dfs(node):
24 return True
25 return False

state[node] == 1: This node is on the current DFS path. Reaching it again means we followed a cycle back to an ancestor still being explored.

state[node] == 2: This node was fully explored in an earlier DFS call. Reaching it is safe. Another path leads here, but no cycle.

state[node] = 2 after the loop: All descendants explored with no cycle found. Marking as done prevents re-exploring this subgraph from other entry points.

for node in range(n) at the bottom: The graph may be disconnected. Starting DFS only from node 0 would miss cycles in unreachable components.

Interview Tip
Undirected vs Directed
Undirected graphs use a different approach: track the parent node and skip it. A cycle exists if you reach a visited node that is not your parent. Directed graphs need 3 states because edges are one-way, so there is no parent to skip.

Family 5: Post-Order Aggregation

Some problems require each node to compute a value that depends on both children's results, and also to update a separate global answer. The return value flows up to the parent. The global update captures the best answer seen across the entire tree.

Key Insight
Post-order: children before parent
Child results must be known before the parent can compute. The parent uses child values for its own return value and for the global update. These are two separate computations, and confusing them is the most common error.

The classic example is Binary Tree Maximum Path Sum. A path can bend through a node, using both left and right branches. But the value returned to the parent can only extend through one branch, because a path cannot split.

Binary Tree Maximum Path Sum
1def max_path_sum(root):
2 best = [float('-inf')]
3 def dfs(node):
4 if not node:
5 return 0
6 l = max(dfs(node.left), 0)
7 r = max(dfs(node.right), 0)
8 best[0] = max(best[0], l + r + node.val)
9 return max(l, r) + node.val
10 dfs(root)
11 return best[0]

max(dfs(node.left), 0): Clamps negative subtrees to zero. If a subtree's best path is negative, drop it entirely rather than letting it drag down the sum.

best[0] = max(best[0], l + r + node.val): The global answer can use both branches through this node, because the path bends here. This is checked at every node.

return max(l, r) + node.val: The parent can only extend one branch. A path cannot split at two different nodes. So the return value picks the better side.

The same hybrid pattern applies to tree diameter (return depth to parent, update global with left + right) and similar problems where the best answer passes through some node using both subtrees.

DFS vs BFS

The most common mistake is using DFS for shortest-path problems. BFS processes all nodes at distance d before any node at distance d + 1, so the first time it reaches a target is the shortest path. DFS has no such guarantee. It can reach a node via a long detour first.

DFS vs BFS ยท Finding Path from S to G

DFS (Stack)
SABCDG
Stack (LIFO)
S
dfs(S)at S
BFS (Queue)
SABCDG
Queue (FIFO)
S
queue.append(S)at S

Step 1 of 6

Problem shapeUseWhy
"shortest path" / "minimum steps"BFSBFS expands in distance order. Every node at distance d is processed before distance d + 1, so the first arrival is the shortest.
"nearest X" / "closest to"BFSBFS finds nodes in order of increasing distance. The first match is the closest by definition.
"level by level" / "each depth"BFSEach iteration of the BFS loop processes exactly one level, making level-order traversal natural.
"all paths" / "every solution"DFSDFS explores one complete branch before trying the next, which naturally enumerates all possibilities.
"does path exist" (any path)DFSDFS finds any valid path by following one branch to completion. No distance guarantee needed.
"compute from subtrees"DFSPost-order DFS computes child results before the parent, which is required when the parent depends on subtree values.
"cycle detection"DFSThree-state tracking (unvisited, visiting, done) maps directly to DFS recursion entry and exit.
"connected components"EitherBoth traverse all reachable nodes from a starting point. DFS is typically shorter to write.

Complexity Analysis

Time and space complexity differ by structure type.

Tree DFS

  • Time: O(n) because we touch each node exactly once and do constant work at each one.
  • Space: O(h) where h is the tree height. The call stack grows as deep as the tree. For a balanced tree that's O(log n), but a skewed tree (every node has only one child) is O(n).

Graph DFS

  • Time: O(V + E) because we visit each vertex once and look at each edge once when checking neighbors.
  • Space: O(V) for the visited set, plus O(V) for the recursion stack in the worst case (a long chain of nodes).

Grid DFS

  • Time: O(m x n) because we visit each cell at most once.
  • Space: O(m x n) in the worst case. Imagine a snake-like path that visits every cell before backtracking.
Problem typeTimeSpace
Tree traversalO(n)O(h)
Graph traversalO(V+E)O(V)
Grid (m x n)O(m x n)O(m x n)
Cycle detectionO(V+E)O(V)
Interview Tip
Why O(V+E) not O(V x E)?
Each vertex enters the visited set exactly once, so we do V vertex operations. Each edge is examined exactly once when checking neighbors. The work is additive (V + E), not multiplicative (V x E).

Common Pitfalls

Six common DFS bugs. Each passes simple test cases but fails on edge inputs.

Missing Base Case

Broken code
1def dfs(node):
2 # No base case!
3 left = dfs(node.left) # Crashes when node is None
4 right = dfs(node.right)
5 return max(left, right) + 1

The trap: When node is None, node.left throws an error. Even if it didn't, recursion would never terminate.

Fix: Always add if node is None: return base_value first.

Late Visited Marking

Broken code
1def dfs(node, graph):
2 for neighbor in graph[node]:
3 dfs(neighbor, graph)
4 visited.add(node) # Too late!

The trap: You can revisit the same node through different paths before it's marked visited. This causes infinite loops in graphs with cycles.

Fix: Add to visited before processing neighbors.

Forcing Negative Branches Into the Path

Broken code
1def max_path_sum(node):
2 if node is None:
3 return 0
4 left = max_path_sum(node.left)
5 right = max_path_sum(node.right)
6 return node.val + max(left, right)

The problem: This always adds the best child path to the current node, even when that child path is negative. A subtree with sum -5 drags down every path that passes through it, but the code has no way to drop it.

Fix: Clamp child contributions with max(left, 0). This lets the node ignore a negative subtree entirely instead of being forced to include it.

Two-State Directed Cycle Detection

Broken code
1def has_cycle(node, visited):
2 if node in visited:
3 return True # Wrong! This fires for finished nodes too
4 visited.add(node)
5 for neighbor in graph[node]:
6 if has_cycle(neighbor, visited):
7 return True
8 return False

The trap: You can't distinguish between a node in the current path (cycle) and a node finished earlier (safe).

Fix: Use 3 states: 0=unvisited, 1=visiting, 2=done. Only state 1 indicates a cycle.

Overcomplicated Parameter Type

Overcomplicated vs Simple
1def has_path_sum(node, current=[]):
2 current.append(node.val) # Overkill! Using list for sum
3 total = sum(current)
4 # ... now you have to manage cleanup
5
6# Better: just use an int
7def has_path_sum(node, remaining):
8 # Integers are immutable, no cleanup needed
9 return has_path_sum(node.left, remaining - node.val)

The trap: Using a mutable list when an immutable int works fine adds complexity. Now you need cleanup, copies, and there's more room for bugs.

Rule: Use immutable (int, tuple) when you only need an aggregate. Use mutable (list) only when you need the actual sequence.

Inconsistent Return and Mutate

Inconsistent patterns
1def dfs(node, result):
2 if node is None:
3 return 0 # Returns int...
4 left = dfs(node.left, result)
5 right = dfs(node.right, result)
6 result.append(left) # ...but also mutates
7 return left + right + node.val # Confused pattern

The trap: This code tries to do both patterns at once but does neither correctly. It's unclear what the function is supposed to do.

Fix: Pick one pattern and be deliberate. If you need hybrid (like Max Path Sum), clearly separate what you return vs what you update globally.

When DFS Breaks

DFS is not the right tool for every traversal problem. Recognizing when it fails is as important as knowing how to use it.

Shortest Path in Unweighted Graphs

DFS can find a path, but it has no guarantee that the path is the shortest. DFS follows one branch to completion before trying others, so it can reach a node via a long detour before discovering the short route. BFS processes all nodes at distance d before distance d + 1, so the first arrival is always the shortest path.

Stack Overflow on Deep Inputs

Python's default recursion limit is about 1000 frames. A linked-list-shaped tree with 10,000 nodes will crash recursive DFS. For inputs that can be arbitrarily deep, convert to iterative DFS using an explicit stack, or increase the recursion limit (which risks real stack overflow at the OS level).

Missing Visited Set on Graphs

Treating a graph as a tree (no visited set) causes infinite recursion on any cycle. This is especially common with grid problems where students forget to mark cells, and with undirected graphs where the edge back to the parent creates an immediate cycle if not handled.

Level-Order Requirements

Problems that need to process nodes level by level, such as finding the rightmost node at each depth or computing level averages, are naturally solved with BFS. DFS can be adapted (by passing a depth parameter and grouping by depth), but BFS handles this without extra bookkeeping because it processes one complete level per iteration.

Iterative DFS

Two scenarios require iterative DFS: inputs deeper than the call stack limit (~1000 in Python), and problems that need pause/resume (BST iterator). Convert using an explicit stack.

Pre-order (Simple)

1def dfs_preorder(root):
2 if root is None:
3 return []
4
5 result = []
6 stack = [root]
7
8 while stack:
9 node = stack.pop()
10 result.append(node.val) # Process on pop
11
12 # Push right first so left is popped first
13 if node.right:
14 stack.append(node.right)
15 if node.left:
16 stack.append(node.left)
17
18 return result

Why push right before left? The stack is LIFO (last in, first out). If we push right first, then left, we pop left first. This matches recursive left-to-right order.

Post-order (Tricky)

Post-order is harder iteratively because you need to process a node after both children. The key technique: track whether a node's children have already been processed.

1def dfs_postorder(root):
2 if root is None:
3 return []
4
5 result = []
6 stack = [(root, False)] # (node, children_processed)
7
8 while stack:
9 node, processed = stack.pop()
10
11 if processed:
12 result.append(node.val) # Process after children
13 else:
14 # First visit: push self again (will process later)
15 # then push children (will process first)
16 stack.append((node, True))
17 if node.right:
18 stack.append((node.right, False))
19 if node.left:
20 stack.append((node.left, False))
21
22 return result

In-order (BST Iterator Pattern)

In-order iterative is essential for BST problems that need pause/resume, like the BST iterator.

1def inorder_iterative(root):
2 result, stack, node = [], [], root
3 while stack or node:
4 while node:
5 stack.append(node)
6 node = node.left
7 node = stack.pop()
8 result.append(node.val)
9 node = node.right
10 return result
Interview Tip
When to use iterative
  • Deep trees (avoid stack overflow)
  • Need fine-grained control over traversal
  • Languages with small call stacks

In interviews, recursive is usually preferred for clarity. Mention iterative as an optimization if asked about stack overflow.

Practice Problems

DFS problems cluster into tree traversals and graph explorations.

Tree DFS

ProblemDifficultyApproach
Maximum Depth of Binary TreeEasyReturn 1 + max(left, right). Base case: null returns 0. The classic "return value up" pattern.
Path SumEasyPass remaining sum down. At leaf, check if remaining equals node value. No need to track the path itself.
Same TreeEasyCompare nodes at each position. Both null = same. One null = different. Both exist = compare values and recurse.
Validate Binary Search TreeMediumPass valid range (min, max) down. Each node must be within range. Update range when going left (max=node) or right (min=node).
Lowest Common AncestorMediumIf current node is p or q, return it. If left and right both return non-null, current is the LCA. Otherwise propagate the non-null result.
Diameter of Binary TreeMediumDiameter passes through some node. At each node, calculate left_depth + right_depth. Return single-side max to parent, track global max separately.
Binary Tree Maximum Path SumHardA path can start and end anywhere. At each node, compute "through this node" (left + node + right) for global max, but return "extendable path" (node + max(left, right)) to parent.

Graph DFS

ProblemDifficultyApproach
Clone GraphMediumUse hash map: original node โ†’ cloned node. Before recursing, check if already cloned. This handles cycles and prevents infinite recursion.
Course ScheduleMediumCycle detection in directed graph. Use three states: unvisited, in-progress, completed. Back edge (hitting in-progress) means cycle exists.
Course Schedule IIMediumTopological sort via DFS. Add node to result after all descendants are processed (post-order). Reverse at the end. Detect cycles same as Course Schedule.
Pacific Atlantic Water FlowMediumReverse the problem: DFS from ocean edges inward, marking cells that can reach each ocean. Answer is intersection of both sets.
Number of Connected ComponentsMediumStandard connected components. Loop through all nodes; if unvisited, DFS to mark component. Count DFS initiations.

Grid DFS

ProblemDifficultyApproach
Number of IslandsMediumMark connected land cells as visited using DFS from each unvisited land cell
Max Area of IslandMediumTrack island size by counting cells during DFS traversal
Surrounded RegionsMediumDFS from border O cells to mark safe ones, then flip remaining O to X
Word SearchMediumBacktracking DFS trying each direction, mark visited to avoid reuse

Reference Templates

Compact code shapes for quick recovery. Each template shows the minimal structure for its family.

DFS Family Templates
1# Tree DFS
2def dfs(node):
3 if node is None:
4 return base_value
5 left = dfs(node.left)
6 right = dfs(node.right)
7 return combine(left, right, node.val)
8
9# Graph DFS
10def dfs(node, graph, visited):
11 if node in visited:
12 return
13 visited.add(node)
14 for neighbor in graph[node]:
15 dfs(neighbor, graph, visited)
16
17# Grid DFS
18def dfs(grid, r, c):
19 if r < 0 or r >= len(grid) or c < 0 or c >= len(grid[0]):
20 return
21 if grid[r][c] != '1':
22 return
23 grid[r][c] = '0'
24 for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
25 dfs(grid, r + dr, c + dc)
26
27# Directed Cycle Detection
28def dfs(node):
29 if state[node] == 1: return True # cycle
30 if state[node] == 2: return False # safe
31 state[node] = 1
32 for neighbor in graph[node]:
33 if dfs(neighbor): return True
34 state[node] = 2
35 return False
36
37# Post-Order Hybrid (return + global update)
38def dfs(node):
39 if not node: return 0
40 l = max(dfs(node.left), 0)
41 r = max(dfs(node.right), 0)
42 best[0] = max(best[0], l + r + node.val)
43 return max(l, r) + node.val

Base Case Reference

Combine operationBase caseWhy
max(l, r) + 10Empty contributes 0 depth, leaf becomes max(0,0)+1 = 1
l + r + 10Empty contributes 0 nodes, leaf becomes 0+0+1 = 1
l and rTrueEmpty is vacuously valid, only fails when a node violates
l or rFalseEmpty has no valid path, only succeeds when a path is found

Pattern Recap

Tree Recursion. Each call owns one subtree. No visited set needed. The parent waits for both children, then combines their results.

Graph Components. Visited nodes are finalized. Mark before exploring neighbors. Count components by counting DFS initiations from unvisited nodes.

Grid Flood Fill. Marked cells belong to a component. Bounds checking replaces the adjacency list. In-place marking eliminates the need for a separate visited set.

Cycle Detection. Three states distinguish nodes on the current path (visiting) from nodes fully explored in earlier calls (done). Only reaching a visiting node indicates a cycle.

Post-Order Aggregation. Child results must be known before the parent computes. The return value flows to the parent. A separate global variable captures the best answer across the whole tree.

Rebuilding the Pattern

When DFS feels unclear on a new problem, start with five questions.

First, identify the structure. Trees have no cycles. Graphs may have cycles and need a visited set. Grids are implicit graphs with bounds checking instead of an adjacency list.

Second, determine what state flows down from parent to child. Running totals, paths, bounds, depth, and parent references are the common parameter patterns. Immutable values (integers, tuples) need no cleanup. Mutable values (lists) require backtracking after each branch.

Third, determine what flows back up. Return a scalar when the parent needs a single aggregate. Return a boolean when the parent needs to short-circuit. Mutate an external list when collecting multiple answers. Use hybrid (return plus global update) when the best answer passes through a node using both subtrees.

Fourth, choose the traversal order. Pre-order if the node must be processed before its children. In-order if the problem needs sorted BST access. Post-order if the parent depends on child results. Most DFS problems are post-order.

Fifth, determine what prevents revisiting or infinite recursion. Trees need no protection. Graphs use a visited set marked before exploring neighbors. Directed graphs with cycle detection use three states. Grids mark cells in-place.

The pattern is recoverable from these five decisions: what structure you are exploring, what context each node needs from its parent, what each call returns, when the node does its work, and how revisiting is prevented.

Check Your Understanding

Use these questions to check whether you can reason through the pattern without looking at the template.

Ready to test your understanding?

13 questions covering DFS families, parameters, and traversal patterns.

Practice this pattern

Apply the guide to complete interview problems with explanations and code.

Learn Depth-First Search in a guided sequence

The Interview Course connects this pattern to its prerequisites, worked lessons, and progressively harder problems.

Explore the course