Depth-First Search
Use Cases
| Use Case | Description | Example |
|---|---|---|
| Preorder Traversal of a Binary Tree | Given a binary tree, return the values of its nodes in preorder (root, left, right) traversal. | For the tree: 1, with children 2 and 3, and 2's children 4 and 5. The result is [1, 2, 4, 5, 3]. |
| Finding All Paths from Root to Leaves | Find all paths from the root to each leaf node in the binary tree. | For the tree: 1 with children 2 and 3, and 2's children 4 and 5. The result is [[1, 2, 4], [1, 2, 5], [1, 3]]. |
| Checking for Symmetry of a Binary Tree | Check if the binary tree is symmetric around its center. | For the tree: 1 with children 2 and 2 (both having children 3 and 3). The result is True (symmetric). |
Code Template
Most LeetCode tree solutions nest a def dfs(node): ... inside the outer method. Three patterns cover most problems:
| Pattern | When to use | Base case usually |
|---|---|---|
| Top-down | Preorder / inorder / postorder lists, or carry acc, depth, parent flag from root | if not node: return or return False / sentinel for “dead branch” |
| Bottom-up | Height, diameter, “is balanced”, max path through node, tree DP | if not node: return 0 (or -1, None, …) |
| Backtracking | All root-to-leaf paths, choose / unchoose on a tree | append before children, pop after |
Graphs / grids: same recursion idea, but add visited (set, or mutate the cell) before expanding neighbors — see 200. Number of Islands in Related Problem.
Pattern 1 — Top-down (preorder list or state such as path sum)
dfs runs before children see aggregated subtree answers. Use a shared result list, nonlocal count, or extra parameters (acc, depth, target).
Basic case
dfs()takes the current node and a container (list) you fill in the outer scope.if not node:exits before touching.val(never assume a child exists).
Collect node value
- Process the current node before recursing if you want preorder (root → left → right).
Recurse on children
- Recurse on each child (binary tree: left and right). Order controls traversal: left then right is the usual convention.
def dfs(node, result):if not node:return
Same traversal shell with parameters (e.g. 112. Path Sum): pass acc down instead of only using a list.
def dfs(node, acc: int) -> bool:if not node:return Falseacc += node.valif not node.left and not node.right:return acc == targetreturn dfs(node.left, acc) or dfs(node.right, acc)
Pattern 2 — Bottom-up (return value from children / post-order)
Use this when the answer is computed from children up (height, diameter, subtree validity, tree DP, etc.).
Base case for an empty subtree
if not node:handles the empty subtree. Return a sentinel that matches the problem:0for height/sum,-1orNonefor “invalid”,Truefor “empty is OK”, and so on.
Recurse before combining (post-order)
left = dfs(node.left)andright = dfs(node.right)run before you finalize the value fornode. Parent logic always sees completed answers from both subtrees.
Combine and return
- Merge
node.valwithleftandright. For global answers (diameter, max path), updateself.ansor anonlocalhere, then return what the parent needs (e.g. height).
def dfs(node):if not node:return 0 # example: height of empty tree
Pattern 3 — Backtracking (all root-to-leaf paths)
Push before children, list(path) when you store, and pop after recursion so the shared path stays correct for siblings.
Guard and enter
if not node: returnskips empty references.pathandresultusually live in the outer scope.
Choose current node
path.append(node.val)extends the current path before visiting children.
Leaf: snapshot path
- At a leaf,
result.append(list(path))copies the path; never appendpathitself or laterpops will mutate stored lists.
Internal nodes: recurse
- For internal nodes, recurse left then right while
pathstill includes thisnode.val.
Backtrack
path.pop()undoes the choice so the sibling branch sees the path without this node.
def dfs(node, path):if not node:return
One-line mental checklist
- Base case first (
None, out of bounds, or already visited on graphs/grids). - Top-down: process / update state, then recurse; bottom-up: recurse first, then combine; backtracking: choose, recurse, undo.
- Binary tree:
for child in (node.left, node.right)when both use the same logic. - Use
self.ans, a closure list, ornonlocalwhen you need both a return value and a global best.
Common mistakes
- Wrong Base Case
# Wrong: missing base casedef dfs(root, result):result.append(root.val) # Will fail on None# Wrong: wrong return valuedef dfs(root, result):if not root:return result # Should return None# Correct:if not root:return
Issues and consideration
BFS VS DFS:BFS is ideal when you need the shortest path in an unweighted graph or a level-by-level view. DFS fits deep paths, exhaustive choices, and many tree recurrences.- Cycles: on general graphs, mark visited (or use grid bounds + a “blocked” cell) before recursing into neighbors.
Related Problem
class Solution:def isBalanced(self, root: Optional[TreeNode]) -> bool:# Pattern 2 — bottom-up: children return height; parent combinesdef dfs(node):if not node:return 0left = dfs(node.left)right = dfs(node.right)if left == -1 or right == -1 or abs(left - right) > 1:return -1return max(left, right) + 1return dfs(root) != -1
Longest path between any two nodes passes through some “highest” node; at each node, depth-first search returns subtree height while updating the best diameter with left depth + right depth — Pattern 2 (bottom-up).
class Solution:def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:# Pattern 2 — bottom-up: return height; diameter through this node = left + rightdef dfs(node):if not node:return 0left = dfs(node.left)right = dfs(node.right)self.diameter = max(self.diameter, left + right)return max(left, right) + 1self.diameter = 0dfs(root)return self.diameter
144. Binary Tree Preorder Traversal
class Solution:def preorderTraversal(self, root: TreeNode) -> List[int]:# Pattern 1 — top-down: process node, then recurse on childrendef dfs(node, result):if not node:returnresult.append(node.val)for child in (node.left, node.right):dfs(child, result)result = []dfs(root, result)return result
class Solution:def hasPathSum(self, root: TreeNode, targetSum: int) -> bool:# Pattern 1 — top-down: same shell, pass running sum as a parameterdef dfs(node, acc):if not node:return Falseacc += node.valif not node.left and not node.right:return acc == targetSumfor child in (node.left, node.right):if dfs(child, acc):return Truereturn Falsereturn dfs(root, 0)
class Solution:def numIslands(self, grid: List[List[str]]) -> int:# Pattern 1 — top-down on a grid: base case (out of bounds / water / seen), then recurse to neighborsdef dfs(r, c):if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':returngrid[r][c] = '#'for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):dfs(nr, nc)if not grid:return 0rows, cols = len(grid), len(grid[0])count = 0for r in range(rows):for c in range(cols):if grid[r][c] == '1':dfs(r, c)count += 1return count