🌲 Depth-First Search (DFS)¶
What You'll Learn
- ✅ What DFS is and how it explores graphs branch by branch
- ✅ Recursive and iterative DFS on graphs and trees in Python
- ✅ Cycle detection, topological sort, and path finding with DFS
- ✅ Time and Space complexity analysis
- ✅ When to use DFS vs BFS
Depth-First Search dives as deep as possible down one branch before backtracking and exploring the next. Think of it like navigating a maze — you follow one corridor all the way to a dead end, then backtrack to the last junction and try another route. This makes DFS the go-to for cycle detection, topological sorting, and backtracking problems.
New to graph algorithms?
Make sure you're comfortable with recursion and have read the BFS note first. BFS and DFS solve different problems — understanding both together is more valuable than either alone.
Already know the basics?
Jump to Cycle Detection or Topological Sort to see the most powerful DFS applications.
Keep in mind
Always track visited nodes on graphs with cycles — without it, DFS loops forever. For trees (no cycles), the visited set can be omitted. Python's default recursion limit is 1000 — for very deep graphs, use the iterative version.
How It Works¶
flowchart TD
A([Start at source node]) --> B[Mark source as visited]
B --> C[Process node]
C --> D{Unvisited neighbours?}
D -->|No| E([Backtrack])
D -->|Yes| F[Pick next unvisited neighbour]
F --> G[Recurse into neighbour]
G --> B
E --> H{More unvisited neighbours at parent?}
H -->|Yes| F
H -->|No| I([DFS Complete])
1️⃣ Recursive DFS on a Graph¶
def dfs_recursive(graph: dict, node: str,
visited: set = None, order: list = None) -> list:
"""
Depth-First Search — recursive implementation.
Returns nodes in the order they were visited.
Args:
graph: adjacency list — dict mapping node to list of neighbours
node: current node being visited
visited: set of already-visited nodes (created on first call)
order: accumulates visit order (created on first call)
"""
if visited is None:
visited = set()
if order is None:
order = []
visited.add(node)
order.append(node)
for neighbour in graph[node]:
if neighbour not in visited:
dfs_recursive(graph, neighbour, visited, order)
return order
# Example graph (undirected)
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E'],
}
print(dfs_recursive(graph, 'A'))
Output:
Mutable default argument warning
Never write def dfs(graph, node, visited=set()) — Python creates the default set once and reuses it across all calls. Always default to None and initialise inside the function.
2️⃣ Iterative DFS on a Graph¶
Replaces the call stack with an explicit stack — avoids Python's recursion limit on deep graphs.
def dfs_iterative(graph: dict, start: str) -> list:
"""
Depth-First Search — iterative implementation using an explicit stack.
Returns nodes in the order they were visited.
"""
visited = set()
stack = [start] # DFS uses a stack (LIFO)
order = []
while stack:
node = stack.pop() # Pop from top (LIFO)
if node in visited:
continue
visited.add(node)
order.append(node)
# Push neighbours in REVERSE order so leftmost is processed first
for neighbour in reversed(graph[node]):
if neighbour not in visited:
stack.append(neighbour)
return order
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E'],
}
print(dfs_iterative(graph, 'A'))
Output:
Why reversed neighbours?
A stack is LIFO — the last item pushed is the first popped. Pushing neighbours in reverse order ensures the first neighbour in the list is explored first, matching the recursive version's behaviour.
3️⃣ DFS on a Tree¶
Tree DFS has three classic orderings depending on when you process the current node.
class TreeNode:
def __init__(self, val: int):
self.val = val
self.left = None
self.right = None
def pre_order(root: TreeNode, result: list = None) -> list:
"""Root → Left → Right"""
if result is None:
result = []
if root:
result.append(root.val) # Process BEFORE children
pre_order(root.left, result)
pre_order(root.right, result)
return result
def in_order(root: TreeNode, result: list = None) -> list:
"""Left → Root → Right (produces sorted output for BSTs)"""
if result is None:
result = []
if root:
in_order(root.left, result)
result.append(root.val) # Process BETWEEN children
in_order(root.right, result)
return result
def post_order(root: TreeNode, result: list = None) -> list:
"""Left → Right → Root (process children before parent)"""
if result is None:
result = []
if root:
post_order(root.left, result)
post_order(root.right, result)
result.append(root.val) # Process AFTER children
return result
# Build example tree:
# 1
# / \
# 2 3
# / \
# 4 5
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
print("Pre-order: ", pre_order(root))
print("In-order: ", in_order(root))
print("Post-order:", post_order(root))
Output:
| Order | Pattern | Common Use |
|---|---|---|
| Pre-order | Root → Left → Right | Copy a tree, serialize/deserialize, prefix expressions |
| In-order | Left → Root → Right | Sorted output from BST, infix expressions |
| Post-order | Left → Right → Root | Delete a tree, evaluate postfix expressions, directory sizes |
4️⃣ Cycle Detection¶
def has_cycle_undirected(graph: dict) -> bool:
"""
Detects a cycle in an undirected graph using DFS.
Returns True if a cycle exists.
"""
visited = set()
def dfs(node: str, parent: str) -> bool:
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
if dfs(neighbour, node):
return True
elif neighbour != parent:
return True # Back edge found → cycle exists
return False
for node in graph:
if node not in visited:
if dfs(node, None):
return True
return False
graph_cycle = {'A': ['B'], 'B': ['A', 'C'], 'C': ['B', 'A']}
graph_no_cycle = {'A': ['B'], 'B': ['A', 'C'], 'C': ['B']}
print(has_cycle_undirected(graph_cycle)) # Output: True
print(has_cycle_undirected(graph_no_cycle)) # Output: False
Output:
def has_cycle_directed(graph: dict) -> bool:
"""
Detects a cycle in a directed graph using DFS with a recursion stack.
Returns True if a cycle exists.
"""
visited = set()
rec_stack = set() # Tracks nodes in current DFS path
def dfs(node: str) -> bool:
visited.add(node)
rec_stack.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
if dfs(neighbour):
return True
elif neighbour in rec_stack:
return True # Back edge in directed graph → cycle
rec_stack.remove(node) # Remove from current path on backtrack
return False
for node in graph:
if node not in visited:
if dfs(node):
return True
return False
graph_cycle = {'A': ['B'], 'B': ['C'], 'C': ['A']} # A→B→C→A
graph_no_cycle = {'A': ['B'], 'B': ['C'], 'C': []}
print(has_cycle_directed(graph_cycle)) # Output: True
print(has_cycle_directed(graph_no_cycle)) # Output: False
Output:
Undirected vs Directed cycle detection
In undirected graphs, track the parent node to avoid falsely flagging the edge you came from as a cycle. In directed graphs, use a recursion stack — a node is only a cycle if it's reachable again on the current active path.
5️⃣ Topological Sort (DFS)¶
Topological sort orders nodes of a Directed Acyclic Graph (DAG) so every edge points from earlier to later. Classic use case: task scheduling, build systems, course prerequisites.
def topological_sort(graph: dict) -> list:
"""
Returns a topological ordering of nodes in a DAG.
Uses DFS post-order: a node is added AFTER all its dependencies.
Assumes graph is a DAG (no cycles).
"""
visited = set()
result = []
def dfs(node: str) -> None:
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
dfs(neighbour)
result.append(node) # Add AFTER all descendants are processed
for node in graph:
if node not in visited:
dfs(node)
return result[::-1] # Reverse post-order = topological order
# Example: course prerequisites (directed edges = "must take before")
# A → C means A must be taken before C
graph = {
'A': ['C'],
'B': ['C', 'D'],
'C': ['E'],
'D': ['F'],
'E': ['F'],
'F': [],
}
print(topological_sort(graph))
Output:
Why reverse post-order?
In DFS, a node is added to result only after all its neighbours are fully explored. So the last node added has no outgoing edges — it belongs at the end. Reversing gives the correct ordering where dependencies come first.
6️⃣ Find All Paths (Backtracking)¶
def find_all_paths(graph: dict, start: str, target: str) -> list[list]:
"""
Finds ALL paths from start to target using DFS + backtracking.
Returns a list of paths (each path is a list of nodes).
"""
all_paths = []
def dfs(node: str, path: list, visited: set) -> None:
if node == target:
all_paths.append(list(path)) # Found a path — save a copy
return
for neighbour in graph[node]:
if neighbour not in visited:
visited.add(neighbour)
path.append(neighbour)
dfs(neighbour, path, visited) # Recurse deeper
path.pop() # Backtrack — undo the choice
visited.remove(neighbour)
dfs(start, [start], {start})
return all_paths
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['E'],
'D': ['F'],
'E': ['F'],
'F': [],
}
for path in find_all_paths(graph, 'A', 'F'):
print(path)
Output:
The backtracking pattern
The key is undoing your choice after recursion — path.pop() and visited.remove(neighbour). This restores state so the next branch starts fresh. This pattern appears in virtually every backtracking problem.
7️⃣ Step-by-Step Trace¶
graph = { A:[B,C], B:[A,D,E], C:[A,F], D:[B], E:[B,F], F:[C,E] }
start = A (recursive DFS)
Call stack builds DOWN, unwinds UP:
dfs(A) visited={A} order=[A]
│
├─ dfs(B) visited={A,B} order=[A,B]
│ │
│ ├─ dfs(D) visited={A,B,D} order=[A,B,D]
│ │ └─ B already visited → backtrack
│ │ ← return from dfs(D)
│ │
│ ├─ dfs(E) visited={A,B,D,E} order=[A,B,D,E]
│ │ ├─ B already visited → skip
│ │ └─ dfs(F) visited={A,B,C,D,E,F} order=[A,B,D,E,F]
│ │ ├─ dfs(C) visited+C order=[A,B,D,E,F,C]
│ │ │ └─ A,F already visited → backtrack
│ │ └─ E already visited → backtrack
│ │ ← return from dfs(F)
│ │ ← return from dfs(E)
│ └─ A already visited → backtrack
│ ← return from dfs(B)
│
└─ C already visited → backtrack
← return from dfs(A)
Final order: [A, B, D, E, F, C] ✅
8️⃣ Memory Model¶
Graph depth = 4 (A → B → E → F)
Active call stack at deepest point:
┌──────────────────────┐ ← top of stack
│ dfs(F) │
├──────────────────────┤
│ dfs(E) │
├──────────────────────┤
│ dfs(B) │
├──────────────────────┤
│ dfs(A) │
└──────────────────────┘ ← bottom (initial call)
Stack depth = O(h) where h = longest DFS path
Worst case (linear graph): h = V → O(V) stack space
Balanced tree: h = log(V) → O(log V) stack space
Recursive DFS: Iterative DFS:
┌─────────────────────┐ ┌─────────────────────┐
│ Uses Python's own │ │ Uses explicit list │
│ call stack │ │ as stack │
│ │ │ │
│ Limit: ~1000 frames │ │ Limit: system memory │
│ (RecursionError) │ │ (no hard limit) │
│ │ │ │
│ Clean, readable │ │ Verbose, but safe │
│ code │ │ for deep graphs │
└─────────────────────┘ └─────────────────────┘
Rule of thumb:
- n < 1000 nodes → recursive is fine
- n >= 1000 nodes → use iterative or sys.setrecursionlimit()
9️⃣ Complexity Analysis¶
| Operation | Complexity | Explanation |
|---|---|---|
| Graph DFS | O(V + E) | Visit every vertex once, traverse every edge once |
| Tree DFS | O(n) | Visit every node exactly once |
| Cycle detection | O(V + E) | Standard DFS with extra set tracking |
| Topological sort | O(V + E) | Standard DFS + reversal |
| Find all paths | O(V!) worst case | Exponential — all permutations in complete graph |
| Structure | Complexity | Reason |
|---|---|---|
| Visited set | O(V) | One entry per node |
| Recursion stack | O(h) | h = max depth of DFS tree |
| Iterative stack | O(V) worst case | All nodes on stack simultaneously |
| Total | O(V + E) | Adjacency list + auxiliary structures |
🔟 DFS vs BFS¶
| Feature | DFS | BFS |
|---|---|---|
| Data structure | Stack / Recursion (LIFO) | Queue (FIFO) |
| Traversal order | Branch by branch | Level by level |
| Shortest path | ❌ Not guaranteed | ✅ Guaranteed (unweighted) |
| Memory | O(h) — stores one path | O(V) — stores entire frontier |
| Cycle detection | ✅ Natural fit | ✅ Possible but less natural |
| Topological sort | ✅ Post-order DFS | ✅ Kahn's algorithm (BFS-based) |
| Backtracking | ✅ Natural fit | ❌ Awkward |
| Best for | Deep search, cycles, topo sort, paths | Shortest path, level-order, nearest nodes |
✅ Use DFS when: - Cycle detection in directed or undirected graphs - Topological sorting of a DAG - Finding all paths between two nodes - Backtracking problems (mazes, permutations, combinations) - Checking connectivity (connected components) - Tree traversals: pre/in/post-order - Memory is limited — DFS uses O(h) vs BFS's O(V)
❌ Use BFS instead when: - You need the shortest path in an unweighted graph - You need level-order traversal of a tree - The graph is deep but narrow (DFS stack grows huge)
✅ Quick Reference Summary¶
| Topic | Key Point |
|---|---|
| Strategy | Go as deep as possible, backtrack when stuck |
| Data structure | Recursion (call stack) or explicit stack (LIFO) |
| Visited tracking | Always use a set for O(1) lookup on graphs |
| Time complexity | O(V + E) for graphs, O(n) for trees |
| Space complexity | O(h) recursive, O(V) iterative |
| Shortest path? | ❌ No — use BFS for shortest paths |
| Tree orders | Pre = Root first, In = Left-Root-Right, Post = Root last |
| Cycle detection | Undirected: track parent. Directed: track recursion stack |
| Topological sort | Post-order DFS, then reverse the result |
| Backtracking | Add → recurse → remove (undo) — restore state after each branch |
| Recursion limit | Python default = 1000. Use iterative DFS for deep graphs |
| Mark visited when? | On entry to the node — before processing neighbours |