DFS (Depth-First Search)
Scope — The main DFS reference: the ten core depth-first templates — tree traversal, grid flood fill, path finding, backtracking, tree modification, post-order aggregation, boundary elimination, shape signatures and weighted-edge traversal — with the recognition table that picks between them. See also — deep dives split out of this file: dfs_advanced.md — Euler paths (Hierholzer), Tarjan bridges, trie + wildcard DFS, depth-indexed stack DFS, distance-bucket leaf pairing, N-ary and
parent[]rollups; dfs_examples.md — the worked-solution archive and the full problem index by pattern and difficulty. Neighbouring sheets: bfs.md — the breadth-first counterpart and how to choose; backtrack.md — DFS that undoes state on the way back up; graph.md — representation; tree.md — DFS on trees.
LeetCode Problem Lists
Overview
Depth-First Search (DFS) is a graph/tree traversal algorithm that explores as far as possible along each branch before backtracking. It uses recursion or a stack to maintain the traversal path.
Key Properties
- Time Complexity: O(V + E) for graphs, O(n) for trees
- Space Complexity: O(h) for recursion stack, where h = height
- Core Idea: Go deep before going wide
- Data Structure: Stack (implicit via recursion or explicit)
- When to Use: Path finding, cycle detection, topological sort, tree traversal, backtracking problems
References
Problem Categories
Each pattern below is presented exactly once — as a template in the next section. This table is the index into them: match the recognition keywords, then jump to the template.
| # | Pattern | Recognition keywords | Template | Canonical LC | Also |
|---|---|---|---|---|---|
| 1 | Tree traversal, paired DFS | “traverse”, “visit all”, “print tree”, “serialize”, “is it a mirror”, “are two trees the same” | T1 | LC 94 | 144, 145, 297, 449, 100, 101, 951 |
| 2 | Graph / grid traversal, components | “connected components”, “islands”, “cycle detection” | T2 | LC 200 | 695, 133, 207, 210, 419 |
| 3 | Path problems | “path sum”, “root to leaf”, “all paths”, “does a path exist” | T3 | LC 112 | 113, 257, 129, 1971 |
| 4 | Backtracking | “all combinations”, “permutations”, “subsets” | T4 | LC 46 | 78, 39, 17, 22, 51, 79 |
| 5 | Tree modification | “delete”, “insert”, “trim”, “convert” | T5 | LC 450 | 701, 669, 538, 226, 114 |
| 6 | Subtree aggregation & LCA | “subtree sum”, “duplicate subtrees”, “LCA”, “deepest leaves”, “minimum moves between adjacent nodes” | T6 | LC 543 | 124, 236, 508, 652, 663, 979, 2049 |
| 7 | Boundary elimination (2 passes) | “closed islands”, “surrounded regions”, “captured” | T7 | LC 1254 | 130, 417, 1020 |
| 8 | Path signatures (shape encoding) | “distinct islands”, “unique shapes”, “same shape after translation” | T8 | LC 694 | 711, 652 |
| 9 | Grid DFS + backtracking | “one path”, “collect the most”, “cannot revisit a cell” | T9 | LC 1219 | 79, 329, 980 |
| 10 | Weighted-edge DFS (ratio queries) | “evaluate division”, “exchange rates”, “transitive ratios” | T10 | LC 399 | 721, 1101, 737 |
Not on this sheet — these live in dfs_advanced.md: two-grid validation (LC 1905),
edge-direction tracking (LC 1466), component pair counting (LC 2316), Euler paths (LC 332, 753), Tarjan
bridges (LC 1192), trie + wildcard DFS (LC 211, 676), depth-indexed stack DFS (LC 388, 1233),
distance-bucket leaf pairing (LC 1530), N-ary post-order rollup (LC 3965), tree ⟷ string codecs
(LC 606, 536) and parent[]-array depth climbs (LC 4015). The full problem list by pattern and by
difficulty is in dfs_examples.md → Problems by Pattern.
Templates & Algorithms
Template Comparison Table
| Template | Use Case | Key Operation | Time | Space | When to Use |
|---|---|---|---|---|---|
| 1. Tree Traversal | Visit all nodes | Recursive/Stack | O(n) | O(h) | Tree problems |
| 2. Graph / Grid DFS | Explore graph, flood fill | Visited set / in-place mark | O(V+E) | O(V) | Graph & grid exploration |
| 3. Path Finding | Find specific paths | Track path | O(n) | O(h) | Path problems |
| 4. Backtracking | Try all paths | Undo choices | O(b^d) | O(d) | Combinatorial |
| 5. Modification | Change structure | Update nodes | O(n) | O(h) | Tree editing |
| 6. Bottom-up | Aggregate info | Post-order return | O(n) | O(h) | Subtree problems |
| 7. 2-Pass DFS | Boundary elimination | Two-phase flood | O(m×n) | O(m×n) | Closed/surrounded regions |
| 8. Path Signature | Encode shapes | Directional tracking | O(m×n) | O(m×n) | Distinct shape counting |
| 9. Grid DFS + Backtrack | One best path in a grid | Mark, recurse, restore | O(4^k) | O(k) | Overlapping paths from many starts |
| 10. Weighted Graph DFS | Ratio/division queries | Product accumulation | O(Q·(V+E)) | O(V+E) | Transitive ratio computation |
Universal DFS Template
def dfs(node, visited=None):
"""
Universal DFS template for trees and graphs
Can be adapted for various problems
"""
# Base case
if not node or (visited and node in visited):
return
# Mark as visited (for graphs)
if visited is not None:
visited.add(node)
# Process current node (pre-order position)
process(node)
# Recursive calls
for neighbor in get_neighbors(node):
dfs(neighbor, visited)
# Post-order processing if needed
# process_after(node)
Template 1: Tree Traversal — LC 94 Priority 5 of 5 — Must know — expect it in almost every loop
- Description: Visit all nodes in specific order (preorder, inorder, postorder)
- Recognition: “Traverse”, “visit all”, “print tree”, “serialize”
- Examples: LC 94, LC 144, LC 145, LC 297, LC 449; paired DFS — LC 100, LC 101 (see the variation below)
# Preorder: Root -> Left -> Right
def preorder(root):
if not root:
return []
return [root.val] + preorder(root.left) + preorder(root.right)
# Inorder: Left -> Root -> Right
def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# Postorder: Left -> Right -> Root
def postorder(root):
if not root:
return []
return postorder(root.left) + postorder(root.right) + [root.val]
# Iterative with Stack
def dfs_iterative(root):
if not root:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.val)
# Add right first so left is processed first (LIFO)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
Variation: paired (dual-pointer) DFS — LC 101 Symmetric Tree
Source:
symmetric-tree.py
- Description: DFS that carries two cursors instead of one. The recursion no longer computes a value from a node — it checks a relation between a pair of nodes, and recurses on the paired children.
- Recognition: “is it a mirror of itself”, “are these two trees the same”, “symmetric around its centre”, “same shape and same values” — anything whose predicate needs two nodes to even be stated.
- Key Technique: the helper takes
(a, b), notnode. Which pairing you recurse on is the problem:(a.left, b.left)+(a.right, b.right)→ straight pairing = “identical trees” (LC 100)(a.left, b.right)+(a.right, b.left)→ mirror pairing = “symmetric” (LC 101)
- Core Idea:
- A single tree is symmetric iff its two subtrees are mirrors of each other, so the launch is
helper(root.left, root.right)— the root itself is never compared to anything. - Three base cases, in this order: both
None→True; exactly oneNone→False; values differ →False. Only then descend. - Descend on the mirror pairing and
andthe two results: the outer pair (a.left↔b.right) and the inner pair (a.right↔b.left). andshort-circuits, so the first mismatch anywhere aborts the whole traversal.
- A single tree is symmetric iff its two subtrees are mirrors of each other, so the launch is
LC 101 trace — root = [1,2,2,null,3,null,3] helper(a, b) pairs a.left <-> b.right
a.right <-> b.left
1 helper(a=2, b=2) vals equal -> descend
/ \ |- helper(a.left=null, b.right=3) exactly one null -> FALSE
2 2 |- (never evaluated: `and` short-circuits)
\ \
3 3 answer = False
a.right b.right <- both 3's are RIGHT children, so they are not mirror partners
- The one bug worth memorising: recursing on the straight pairing here does not “almost work” — it answers a different question (“is the left subtree equal to the right subtree?”) and is wrong in both directions:
straight pairing (a.left<->b.left, a.right<->b.right) on the same two inputs:
[1,2,2,null,3,null,3] null==null, then 3==3 -> True (correct answer: False)
[1,2,2,3,4,4,3] 3 vs 4 -> False (correct answer: True)
# python
# LC 101 - Symmetric Tree
# IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing (a.left, b.right) / (a.right, b.left)
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def isSymmetric(self, root):
if not root:
return True
# NOTE: the helper takes TWO nodes -> the root is never compared to anything
return self.helper(root.left, root.right)
def helper(self, left, right):
if not left and not right: # both empty -> mirrored
return True
if not left or not right: # exactly one empty -> not mirrored
return False
if left.val != right.val:
return False
return (
self.helper(left.left, right.right) # outer pair
and
self.helper(left.right, right.left) # inner pair
)
// java
// LC 101 - Symmetric Tree
// IDEA: paired (dual-pointer) DFS; recurse on the MIRROR pairing
// time = O(n), space = O(h)
public boolean isSymmetric(TreeNode root) {
if (root == null) {
return true;
}
return helper(root.left, root.right);
}
private boolean helper(TreeNode left, TreeNode right) {
if (left == null && right == null) {
return true;
}
if (left == null || right == null) {
return false;
}
if (left.val != right.val) {
return false;
}
return helper(left.left, right.right) // outer pair
&& helper(left.right, right.left); // inner pair
}
Iterative form — the pair lives on the stack
The problem’s own follow-up asks for it, and it is the general way to de-recurse a paired DFS: the stack holds pairs, pushed and popped two entries at a time.
# python
# LC 101 - Symmetric Tree (iterative — the stated follow-up)
# IDEA: same mirror pairing, kept on an explicit stack; pop two entries = pop one pair
# time = O(n), space = O(n)
def isSymmetric(root):
if not root:
return True
stack = [root.left, root.right]
while stack:
p, q = stack.pop(), stack.pop() # NOTE: pops ONE pair
if not p and not q:
continue
if not p or not q or p.val != q.val:
return False
# push the two mirror pairs
stack.append(p.left)
stack.append(q.right) # outer pair
stack.append(p.right)
stack.append(q.left) # inner pair
return True
-
Push
Nones on purpose. Unlike the iterative traversal in Template 1, you may not skip a null child: a(None, node)pair has to be popped and compared to returnFalse. Guarding the pushes withif p.left:silently accepts asymmetric trees. -
The pop reverses the pair —
stack.pop(), stack.pop()hands back(q.left, p.right), i.e. the second-pushed entry first. Harmless, because the mirror predicate is symmetric in its two arguments; it is not harmless in a paired DFS whose predicate is directional (e.g. “isba subtree ofa”), where you must pop into the right slots. -
A pair queue works identically — swap the stack for a
dequeandpopleft()twice; the order of comparison changes, the answer does not. See the LC 101 row in bfs.md. -
Similar Classic LC Problems:
- LC 100 - Same Tree (the same skeleton on the straight pairing)
- LC 951 - Flip Equivalent Binary Trees (try both pairings and
orthem) - LC 572 - Subtree of Another Tree (LC 100’s paired DFS relaunched at every node)
- LC 617 - Merge Two Binary Trees (paired DFS that builds a node instead of returning a bool)
- LC 1612 - Check If Two Expression Trees are Equivalent (paired walk + leaf multiset compare)
- LC 226 - Invert Binary Tree (the mirror as a transformation — symmetric is
isSameTree(root, invert(root)), at the cost of mutating the tree) - LC 872 - Leaf-Similar Trees (deliberate contrast: two independent DFS runs whose leaf sequences are compared afterwards, because the trees’ shapes are allowed to differ)
Template 2: Graph / Grid DFS (Flood Fill) — LC 200 Priority 5 of 5 — Must know — expect it in almost every loop
- Description: Explore graphs, find components, detect cycles
- Recognition: “Connected components”, “islands”, “cycle detection”
- Examples: LC 200, LC 695, LC 133, LC 207, LC 210
def dfs_graph(graph, start):
"""
DFS for graph with cycle handling
"""
visited = set()
result = []
def dfs(node):
if node in visited:
return
visited.add(node)
result.append(node)
for neighbor in graph[node]:
dfs(neighbor)
dfs(start)
return result
# For detecting cycles
def has_cycle(graph):
visited = set()
rec_stack = set()
def dfs(node):
visited.add(node)
rec_stack.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.remove(node)
return False
for node in graph:
if node not in visited:
if dfs(node):
return True
return False
Variation: count components without flood fill — LC 419 Battleships in a Board
Twist: when every component is guaranteed to be a straight 1×k / k×1 line, you don’t need DFS at
all — count only the cells that are the top-left end of a ship, which makes it O(1) extra space
(no visited set, no in-place mutation). Good answer to the classic follow-up “can you do it in one
pass, O(1) space, without modifying the board?” on top of the LC 200 flood-fill baseline.
// java
// LC 419 - Battleships in a Board
// IDEA: a cell starts a NEW ship iff it is 'X' and has no 'X' above and no 'X' to its left
// time = O(M*N), space = O(1)
public int countBattleships(char[][] board) {
int count = 0;
for (int r = 0; r < board.length; r++) {
for (int c = 0; c < board[0].length; c++) {
if (board[r][c] != 'X') continue;
if (r > 0 && board[r - 1][c] == 'X') continue; // continuation of a vertical ship
if (c > 0 && board[r][c - 1] == 'X') continue; // continuation of a horizontal ship
count++;
}
}
return count;
}
# python
# LC 419 - Battleships in a Board
# IDEA: count only the top-left cell of each ship -> no visited set needed
# time = O(M*N), space = O(1)
def countBattleships(board):
count = 0
for r in range(len(board)):
for c in range(len(board[0])):
if board[r][c] != 'X':
continue
if r > 0 and board[r - 1][c] == 'X':
continue
if c > 0 and board[r][c - 1] == 'X':
continue
count += 1
return count
If the “ships are straight lines” guarantee is dropped, fall back to the plain LC 200 grid DFS above.
Template 3: Path Finding — LC 112 Priority 5 of 5 — Must know — expect it in almost every loop
- Description: Find paths with specific properties in trees/graphs
- Recognition: “Path sum”, “root to leaf”, “all paths”, “longest path”
- Examples: LC 112, LC 113, LC 257, LC 124, LC 543
📚 Related Patterns: For comprehensive path problem patterns with multiple variations (path sum, max path, consecutive sequences, prefix sum technique), see bst.md Template 7 (Path Problems) which provides 7 detailed path patterns with full implementations.
def find_paths(root, target):
"""
Find all root-to-leaf paths with sum = target
"""
def dfs(node, curr_sum, path, result):
if not node:
return
# Update current state
curr_sum += node.val
path.append(node.val)
# Check if leaf and target met
if not node.left and not node.right:
if curr_sum == target:
result.append(path[:])
# Explore children
dfs(node.left, curr_sum, path, result)
dfs(node.right, curr_sum, path, result)
# Backtrack
path.pop()
result = []
dfs(root, 0, [], result)
return result
DFS Early Return Pattern — return TRUE eagerly, FALSE lazily
Problem: When searching for a path in DFS, what’s the difference between these two approaches?
❌ WRONG Approach: Not Checking Return Value
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
if (node == destination) return true;
visited[node] = true;
for (int next : map.get(node)) {
if (!visited[next]) {
// ❌ WRONG: Ignoring return value - continues searching even after path found!
dfsPathVisitor(next, destination, map, visited);
}
}
return false; // Will ALWAYS return false (except for direct hits)
}
✅ CORRECT Approach: Early Return on Success
private boolean dfsPathVisitor(int node, int destination, Map<Integer, List<Integer>> map, boolean[] visited) {
if (node == destination) return true;
visited[node] = true;
for (int next : map.get(node)) {
if (!visited[next]) {
// ✅ CORRECT: Return immediately when path found!
if (dfsPathVisitor(next, destination, map, visited)) {
return true;
}
}
}
return false; // Only return false if ALL paths explored
}
📊 Concrete Example: Why Early Return Matters
Test Case:
Graph: 0 -- 1 -- 2 -- 3
| |
4 -------- 5
Adjacency List:
0: [1, 4]
1: [0, 2]
2: [1, 3, 5]
3: [2]
4: [0, 5]
5: [2, 4]
Task: Find path from 0 to 3
Scenario 1: ❌ WRONG (Without Early Return)
Call Stack Trace:
1. dfsPathVisitor(0, 3, ..., visited=[])
→ visited = [0]
→ Loop neighbors: [1, 4]
2. dfsPathVisitor(1, 3, ..., visited=[0]) // First neighbor
→ visited = [0, 1]
→ Loop neighbors: [0, 2] (skip 0, already visited)
3. dfsPathVisitor(2, 3, ..., visited=[0,1])
→ visited = [0, 1, 2]
→ Loop neighbors: [1, 3, 5] (skip 1)
4. dfsPathVisitor(3, 3, ..., visited=[0,1,2])
→ ✅ Found! Returns TRUE
← Returns TRUE to level 3
← But level 2 IGNORES the return value!
← Continues checking neighbor 5
5. dfsPathVisitor(5, 3, ..., visited=[0,1,2])
→ visited = [0, 1, 2, 5]
→ Loop neighbors: [2, 4] (both visited)
← Returns FALSE
← Level 2 finishes loop, returns FALSE
← Level 1 receives FALSE from neighbor 1
6. dfsPathVisitor(4, 3, ..., visited=[0,1,2,5]) // Second neighbor
→ visited = [0, 1, 2, 5, 4]
→ Loop neighbors: [0, 5] (both visited)
← Returns FALSE
← Level 0 finishes loop, returns FALSE
❌ FINAL RESULT: FALSE (Path exists but not detected!)
Why it fails:
- Found destination at step 4 (returned TRUE)
- But parent call at step 3 ignored the TRUE result
- Continued exploring other neighbors unnecessarily
- Eventually returned FALSE because other paths didn’t reach destination
Scenario 2: ✅ CORRECT (With Early Return)
Call Stack Trace:
1. dfsPathVisitor(0, 3, ..., visited=[])
→ visited = [0]
→ Loop neighbors: [1, 4]
2. dfsPathVisitor(1, 3, ..., visited=[0]) // First neighbor
→ visited = [0, 1]
→ Loop neighbors: [0, 2] (skip 0)
3. dfsPathVisitor(2, 3, ..., visited=[0,1])
→ visited = [0, 1, 2]
→ Loop neighbors: [1, 3, 5] (skip 1)
4. dfsPathVisitor(3, 3, ..., visited=[0,1,2])
→ ✅ Found! Returns TRUE
← Returns TRUE to level 3
← Level 2 checks: if (TRUE) return true; ✅
← Returns TRUE immediately (skips remaining neighbors!)
← Level 1 checks: if (TRUE) return true; ✅
← Returns TRUE immediately (skips neighbor 4!)
✅ FINAL RESULT: TRUE (Correct!)
Why it works:
- Found destination at step 4 (returned TRUE)
- Parent call at step 3 checked the return value
- Immediately returned TRUE without exploring other paths
- Propagated TRUE all the way back to the root
🎯 Key Insights
| Aspect | ❌ Without Early Return | ✅ With Early Return |
|---|---|---|
| Correctness | ❌ Returns FALSE even when path exists | ✅ Returns TRUE when path found |
| Efficiency | Explores ALL paths unnecessarily | Stops immediately upon finding path |
| Time Complexity | O(V + E) always (full traversal) | O(V + E) worst case, but often much better |
| Use Case | Collecting ALL paths/results | Finding ANY path (exists/not exists) |
📝 When to Use Each Pattern
Pattern 1: Early Return (Path Existence Check)
// Use when: "Does path exist?" "Can we reach?" "Is there a route?"
if (dfs(next)) {
return true; // Found one path - that's enough!
}
Examples: LC 1971 (Path Exists), LC 797 (All Paths), LC 79 (Word Search)
Pattern 2: Continue Without Return (Collecting All Results)
// Use when: "Find ALL paths" "Count all solutions" "Collect all combinations"
dfs(next); // Don't return early - need to explore all branches
Examples: LC 257 (All Root-to-Leaf Paths), LC 113 (Path Sum II), LC 22 (Generate Parentheses)
Template 4: Backtracking — LC 46
- Description: Try all possibilities, undo choices
- Recognition: “All combinations”, “permutations”, “subsets”
- Examples: LC 46, LC 78, LC 39, LC 17
def backtrack_template(candidates, target):
"""
General backtracking template
"""
def backtrack(start, path, remaining):
# Base case - found solution
if remaining == 0:
result.append(path[:])
return
# Try all possibilities
for i in range(start, len(candidates)):
if candidates[i] > remaining:
continue
# Make choice
path.append(candidates[i])
# Recurse
backtrack(i, path, remaining - candidates[i])
# Undo choice (backtrack)
path.pop()
result = []
backtrack(0, [], target)
return result
Template 5: Tree Modification — LC 450
- Description: Modify tree structure or values during traversal
- Recognition: “Delete”, “insert”, “trim”, “convert”
- Examples: LC 450, LC 701, LC 669, LC 538
def modify_tree(root, condition):
"""
Modify tree structure based on condition
"""
if not root:
return None
# Recursively modify subtrees first
root.left = modify_tree(root.left, condition)
root.right = modify_tree(root.right, condition)
# Modify current node based on condition
if not condition(root):
# Example: delete node, return child
if not root.left:
return root.right
if not root.right:
return root.left
# Handle two children case
# ... (find successor/predecessor)
return root
Idiom: reassign the subtree, then return the node
- Assign sub tree to node, then return updated node at final stage (Important !!!)
// java
// LC 199
private TreeNode _dfs(TreeNode node){
if (node == null){
return null;
}
/** NOTE !!! no need to create global node, but can define inside the method */
TreeNode root2 = node;
root2.left = this._dfs(node.left);
root2.right = this._dfs(node.right);
/** NOTE !!! we need to return root as final step */
return root2;
}
Template 6: Bottom-up (Post-Order) DFS — LC 543 Priority 5 of 5 — Must know — expect it in almost every loop
- Description: Process subtrees and aggregate results bottom-up; find the lowest common ancestor of target nodes
- Recognition: “Subtree sum”, “duplicate subtrees”, “LCA”, “smallest subtree containing”, “lowest common ancestor”, “deepest leaves”, “minimum moves between adjacent nodes”
- Examples: LC 508, LC 652, LC 236, LC 663, LC 865, LC 979, LC 1123
- When to Use LCA Approach:
- Two (or more) target nodes exist in different subtrees and you need the first node that “sees” both sides
- “Smallest subtree that contains [condition X]” — this is LCA in disguise
- Targets may be given (LC 236: find LCA of p, q) or implicit (LC 865/1123: all nodes at max depth)
- Core Idea (Post-Order / Bottom-Up):
- Recurse left and right subtrees first (post-order)
- Each subtree returns a
(node, depth/info)pair upward - At each node, compare left vs right results:
- Left deeper → answer is in the left subtree, propagate left result up
- Right deeper → answer is in the right subtree, propagate right result up
- Equal depth → current node is the LCA (deepest paths meet here), return current node
- The root of the recursion holds the final answer
- Key Variants:
- Standard LCA (LC 236): Targets p, q are given; return first node that sees both in different subtrees
- Depth-Based LCA (LC 865/1123): Targets are discovered (deepest nodes); use depth comparison to find where deepest paths converge
- Paint + Answer (LC 865 Editorial V1): Two-pass — first DFS computes all depths, second DFS finds the subtree containing all max-depth nodes
- BFS + Parent Map (LC 865 V0-4): BFS to find deepest level, then walk parents upward until all converge to one node
- Similar Classic LC Problems:
- LC 236 - Lowest Common Ancestor of a Binary Tree (standard LCA)
- LC 235 - Lowest Common Ancestor of a Binary Search Tree (BST property optimization)
- LC 865 - Smallest Subtree with all the Deepest Nodes (depth-based LCA)
- LC 1123 - Lowest Common Ancestor of Deepest Leaves (same as LC 865)
- LC 1644 - Lowest Common Ancestor of a Binary Tree II (nodes may not exist)
- LC 1650 - Lowest Common Ancestor of a Binary Tree III (with parent pointers)
- LC 1676 - Lowest Common Ancestor of a Binary Tree IV (multiple target nodes)
def bottom_up_dfs(root):
"""
Process subtrees first, then current node
Useful for subtree problems
"""
def dfs(node):
if not node:
return 0 # or base value
# Process subtrees first
left_result = dfs(node.left)
right_result = dfs(node.right)
# Process current node using subtree results
current_result = process(node, left_result, right_result)
# Update global result if needed
self.global_result = max(self.global_result, current_result)
return current_result
self.global_result = 0
dfs(root)
return self.global_result
Global-accumulator form — LC 124 Binary Tree Maximum Path Sum
Source:
binary-tree-maximum-path-sum.py
- Key Idea: a node computes two different values, and confusing them is the whole difficulty of
the problem:
- The answer candidate (
left + right + node.val) — the path that turns at this node, using both children. It is recorded into a global max and never returned. - The value returned to the parent (
max(left, right) + node.val) — a path continuing upward can only use one child, because a path is a sequence of nodes, not a fork.
- The answer candidate (
- Recognition: “path does not need to pass through the root”, “path may turn at some node”, “maximise over all paths” — anything where the best local answer is not the value the parent needs.
- Why
max(0, ...): a child subtree whose best downward sum is negative is simply dropped — attaching it can only make the path worse. Clamping to0is how “drop it” is spelled. - Why
float('-inf')and not0: the path must be non-empty, so an all-negative tree ([-3]→-3) must be allowed to win. Seeding at0silently returns0there. - Do not return
left + right + node.valupward. That is the single most common bug: it hands the parent a forked path, which the parent then forks again, producing a shape that is not a path at all.
# python
# LC 124 - Binary Tree Maximum Path Sum
# IDEA: post-order DFS; record the `turning` path globally, return the best `straight` path upward
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def maxPathSum(self, root):
self.max_sum = float('-inf')
def dfs(node):
if not node:
return 0
left = max(0, dfs(node.left)) # drop a negative subtree
right = max(0, dfs(node.right)) # drop a negative subtree
# (1) candidate: path TURNS here, uses BOTH children -> global only
self.max_sum = max(self.max_sum, left + right + node.val)
# (2) upward: path CONTINUES, so only ONE child may be kept
return max(left, right) + node.val
dfs(root)
return self.max_sum
// java
// LC 124 - Binary Tree Maximum Path Sum
// IDEA: post-order DFS; record the `turning` path globally, return the best `straight` path upward
// time = O(n), space = O(h)
private int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxSum = Integer.MIN_VALUE;
dfs(root);
return maxSum;
}
private int dfs(TreeNode node) {
if (node == null) {
return 0;
}
int left = Math.max(0, dfs(node.left)); // drop a negative subtree
int right = Math.max(0, dfs(node.right)); // drop a negative subtree
maxSum = Math.max(maxSum, left + right + node.val); // turns here
return Math.max(left, right) + node.val; // continues upward
}
Variant: no clamp — carry the node into the negative branch instead
Equivalent form that appears in the wild: rather than clamping a negative child to 0, restart the
branch at node.val. The two branch values then each already include node.val, so the turning
candidate has to subtract it back out once.
# python
# LC 124 - Binary Tree Maximum Path Sum (no-clamp variant)
# IDEA: a negative branch restarts at root.val, so both sides carry root.val -> subtract one copy
# time = O(n), space = O(h)
def dfs(node):
if not node:
return 0
l_max, r_max = dfs(node.left), dfs(node.right)
l_max = node.val if l_max < 0 else l_max + node.val
r_max = node.val if r_max < 0 else r_max + node.val
self.maximum = max(self.maximum, l_max + r_max - node.val) # NOTE: `- node.val`
return max(l_max, r_max)
Prefer the
max(0, ...)form. It is shorter, the- node.valcorrection is easy to forget, and the clamp reads directly as the invariant “a negative subtree is never worth attaching”.
Variation: post-order balance / flow accumulation — LC 979 Distribute Coins in Binary Tree
- Description: Post-order DFS where each node returns its subtree’s surplus/deficit (
balance), while a global counter accumulates|balance|across every edge - Recognition: “move one unit between adjacent nodes”, “minimum number of moves”, “make every node have exactly one X”, “total supply equals total demand”
- Key Technique: The answer is a sum over edges, not over nodes. Every tree edge is a bridge: cutting the edge above a subtree splits the tree into exactly two components, so the coins crossing it can only be
|balance(subtree)|. The traffic is forced — there is nothing to search or optimise, only to count. - Examples: LC 979 (Distribute Coins in Binary Tree)
- Core Idea:
balance(node) = node.val - 1 + balance(left) + balance(right)— the node keeps 1 coin, and the rest of the subtree’s net excess (> 0) or shortfall (< 0) is pushed up to the parent.- Every coin crossing an edge is one move, so the edge above a subtree costs
|balance(subtree)|moves →moves += |balance|. - Only the magnitude matters: a coin flowing up and a coin flowing down cost the same, which is why
abs()is taken at accumulation time. balance(root) == 0always (the problem guaranteesΣ node.val == n) — that invariant is what makes the greedy edge count optimal.
- Two equivalent accumulation spots (both appear in the wild, same total):
- Charge from the parent:
self.moves += abs(left) + abs(right)before returning — each non-root node is charged once, as somebody’s child. - Charge from the node:
self.moves += abs(current_balance)after computing it — each node pays for its own edge to its parent; the root adds|0| = 0.
- Charge from the parent:
- Important Notes:
- Return the signed balance but accumulate the absolute one. Returning
abs(...)upward is the classic bug: a-2deficit has to stay negative so it can cancel a sibling’s+2surplus at their parent. node.val - 1is the whole trick — “every node keeps exactly one coin” turns a distribution problem into a flow-conservation problem.- Do not short-circuit on
node.val == 1; a locally balanced node is still a conduit for its subtrees’ traffic. - Do not flatten the tree into an undirected adjacency list and diffuse coins with BFS — the common wrong first instinct. A BFS frontier is local: it cannot see that the left subtree is short 3 coins while the right subtree has 3 spare, and therefore cannot know those 3 must travel up through the root and back down. It ends up shuffling coins along non-optimal paths (or looping) because it has no notion of a subtree’s net demand.
- What the post-order rollup supplies is exactly the missing global view:
balance(subtree)is the net surplus/deficit of a whole component, and it only exists bottom-up. Re-modelling the tree as a graph discards the cut structure above (every edge a bridge) that makes each edge’s cost a closed form rather than a search.
- Return the signed balance but accumulate the absolute one. Returning
LC 979 trace — root = [0, 3, 0] balance = val - 1 + left + right
0 dfs(left 3) -> 3 - 1 + 0 + 0 = +2 moves += 2 (2 coins go UP)
/ \ dfs(right 0) -> 0 - 1 + 0 + 0 = -1 moves += 1 (1 coin goes DOWN)
3 0 dfs(root 0) -> 0 - 1 + 2 + (-1) = 0 <- always 0 at root
total moves = 2 + 1 = 3
# python
# LC 979 - Distribute Coins in Binary Tree
# IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
# time = O(n), space = O(h) # h = tree height, worst O(n)
class Solution:
def distributeCoins(self, root):
self.moves = 0
def dfs(node):
if not node:
return 0
left = dfs(node.left) # net surplus/deficit of left subtree
right = dfs(node.right) # net surplus/deficit of right subtree
balance = node.val - 1 + left + right # keep 1 coin, push the rest up
self.moves += abs(balance) # this subtree's edge to its parent
return balance # NOTE: signed, never abs()
dfs(root)
return self.moves
// java
// LC 979 - Distribute Coins in Binary Tree
// IDEA: post-order DFS; each subtree returns its net balance, each edge costs |balance| moves
// time = O(n), space = O(h)
private int moves = 0;
public int distributeCoins(TreeNode root) {
moves = 0;
dfs(root);
return moves;
}
private int dfs(TreeNode node) {
if (node == null) {
return 0;
}
int left = dfs(node.left);
int right = dfs(node.right);
int balance = node.val - 1 + left + right; // keep 1 coin, push the rest up
moves += Math.abs(balance); // this subtree's edge to its parent
return balance; // NOTE: signed, never Math.abs()
}
- Similar Classic LC Problems:
- LC 979 - Distribute Coins in Binary Tree (canonical post-order balance/flow)
- LC 2477 - Minimum Fuel Cost to Report to the Capital (same edge-flow count, but
ceil(people / seats)per edge) - LC 1443 - Minimum Time to Collect All Apples in a Tree (post-order, charge 2 per useful edge)
- LC 1339 - Maximum Product of Splitted Binary Tree (post-order subtree sum, then cut one edge)
- LC 508 - Most Frequent Subtree Sum (per-subtree value rolled up post-order)
- LC 124 - Binary Tree Maximum Path Sum (return one value up, aggregate a different one globally)
- LC 2049 - Count Nodes With the Highest Score (subtree size rollup — see the variation below)
Variation: subtree size aggregation (remove-node scoring) — LC 2049
- Description: Post-order DFS that returns each node’s subtree size, while simultaneously computing a per-node value (score) derived from the sizes of the components formed when that node is removed
- Recognition: “remove node and edges → tree splits into subtrees”, “product/sum of component sizes”, “score of a node”, “tree given as
parents[]array” - Key Technique: One DFS returns
subtree_size = 1 + Σ child_subtree_size. When node is removed, the components are (a) each child’s subtree, and (b) the parent side =n - subtree_size. Aggregate these on the fly. - Examples: LC 2049 (Count Nodes With the Highest Score)
- Core Idea:
- Removing node
xcuts it intolen(children[x])child components plus the “above” component (everything outside x’s subtree). child component size= subtree size of each child (returned by DFS).parent / above component size=n - subtree_size(x)(only counts if> 0, i.e. x is not the root).score(x) = Π(child subtree sizes) × max(1, n - subtree_size(x))— every subtree size is computed exactly once, giving O(n) time / O(n) space (needed since n ≤ 10^5).
- Removing node
- Build the tree from
parents[]:children[parents[i]].append(i)fori != root; root is the index whereparents[i] == -1(usually node 0). - Pattern variants:
- One-pass DFS (return size + multiply/track max inline) — most concise
- Two-pass (pass 1: precompute
subtree_size[]array; pass 2: iterate nodes computing scores) — decouples size calc from scoring, easier to reason about
- Important Notes:
- Guard the parent component with
max(1, ...)orif remaining > 0— root has no “above” component. - Use a
Counter/dict keyed by score to count how many nodes hit the max, or track(max_score, count)running maxima. - Generalizes beyond binary trees — the same DFS works for any tree given via
parents[]/adjacency list.
- Guard the parent component with
- Similar Classic LC Problems:
- LC 2049 - Count Nodes With the Highest Score (canonical remove-node scoring)
- LC 1519 - Number of Nodes in the Sub-Tree With the Same Label (subtree aggregation via DFS)
- LC 508 - Most Frequent Subtree Sum (per-subtree value + frequency count)
- LC 543 - Diameter of Binary Tree (bottom-up subtree metric)
- LC 124 - Binary Tree Maximum Path Sum (return subtree value, aggregate global max)
- LC 834 - Sum of Distances in Tree (subtree size + reroot DP, advanced follow-up)
Template 7: 2-Pass DFS (Boundary Elimination) — LC 1254
- Description: Eliminate boundary-connected cells first, then process interior
- Recognition: “Closed islands”, “surrounded regions”, “captured pieces”
- Examples: LC 1254, LC 130, LC 417
// java
// LC 1254
// V0
// IDEA: 2-Pass DFS (Boundary Elimination)
/**
* Algorithm:
* Pass 1: Start from all boundary cells and flood-fill to eliminate
* all islands connected to the boundary (these cannot be closed)
* Pass 2: Count remaining land cells as closed islands
*
* Time: O(m×n), Space: O(m×n) for recursion stack
*/
public int closedIsland(int[][] grid) {
if (grid == null || grid.length == 0) {
return 0;
}
int rows = grid.length;
int cols = grid[0].length;
// Pass 1: Eliminate boundary-connected islands
// Flood top and bottom borders
for (int c = 0; c < cols; c++) {
flood(grid, 0, c); // Top border
flood(grid, rows - 1, c); // Bottom border
}
// Flood left and right borders
for (int r = 0; r < rows; r++) {
flood(grid, r, 0); // Left border
flood(grid, r, cols - 1); // Right border
}
// Pass 2: Count closed islands
int count = 0;
for (int r = 1; r < rows - 1; r++) {
for (int c = 1; c < cols - 1; c++) {
if (grid[r][c] == 0) {
count++;
flood(grid, r, c); // Mark entire island
}
}
}
return count;
}
private void flood(int[][] grid, int r, int c) {
int rows = grid.length;
int cols = grid[0].length;
// Base case: out of bounds or water
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == 1) {
return;
}
grid[r][c] = 1; // Mark land as water (visited)
// Flood 4-directionally
flood(grid, r + 1, c);
flood(grid, r - 1, c);
flood(grid, r, c + 1);
flood(grid, r, c - 1);
}
# python
# LC 1254
def closedIsland(grid):
"""
2-Pass DFS approach
"""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
def flood(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == 1:
return
grid[r][c] = 1
flood(r + 1, c)
flood(r - 1, c)
flood(r, c + 1)
flood(r, c - 1)
# Pass 1: Eliminate boundary islands
for c in range(cols):
flood(0, c)
flood(rows - 1, c)
for r in range(rows):
flood(r, 0)
flood(r, cols - 1)
# Pass 2: Count closed islands
count = 0
for r in range(1, rows - 1):
for c in range(1, cols - 1):
if grid[r][c] == 0:
count += 1
flood(r, c)
return count
Template 8: Path Signature (Shape Encoding) — LC 694
- Description: Encode the shape/structure of islands or subtrees using unique path signatures
- Recognition: “Distinct islands”, “unique shapes”, “count different structures”, “same shape after translation”
- Key Technique: Record directional movements during DFS traversal to create a canonical signature
- Examples: LC 694, LC 711, LC 652
// Java implementation with directional encoding
public int numDistinctIslands(int[][] grid) {
if (grid == null || grid.length == 0 || grid[0].length == 0) {
return 0;
}
Set<String> uniqueIslandShapes = new HashSet<>();
int rows = grid.length;
int cols = grid[0].length;
// Iterate through every cell in the grid
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
// Start DFS only on unvisited land cells
if (grid[r][c] == 1) {
StringBuilder pathSignature = new StringBuilder();
// Start DFS from (r, c). 'S' marks the start
dfs(grid, r, c, pathSignature, 'S');
if (pathSignature.length() > 0) {
uniqueIslandShapes.add(pathSignature.toString());
}
}
}
}
return uniqueIslandShapes.size();
}
/**
* DFS with directional encoding
* Records the direction taken to reach each cell
* Uses 'O' delimiter when backtracking
*/
private void dfs(int[][] grid, int r, int c, StringBuilder path, char direction) {
int rows = grid.length;
int cols = grid[0].length;
// Base cases: Out of bounds or water/visited
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == 0) {
return;
}
// 1. Mark as visited by setting to 0
grid[r][c] = 0;
// 2. Record the direction taken to reach this cell
path.append(direction);
// 3. Recurse in FIXED order (Down, Up, Right, Left)
dfs(grid, r + 1, c, path, 'D'); // Down
dfs(grid, r - 1, c, path, 'U'); // Up
dfs(grid, r, c + 1, path, 'R'); // Right
dfs(grid, r, c - 1, path, 'L'); // Left
// 4. Add delimiter when backtracking
// This distinguishes different branch structures
path.append('O');
}
Two interchangeable encodings. The Java block above records the direction taken into each cell (
D/U/R/L+ anOdelimiter on the way back up); the Python block below records the relative coordinate(r-r0, c-c0)of each cell instead. Both are translation-invariant and rotation-sensitive — pick either, but never mix them inside one signature.
def count_distinct_shapes(grid):
"""
Count distinct island shapes using path signatures
Key: Encode each island's shape as a unique string
"""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
unique_shapes = set()
def dfs(r, c, r0, c0, path):
"""
DFS with path signature encoding
r0, c0: Starting position for relative encoding
path: StringBuilder to record the shape signature
"""
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != 1:
return
# Mark as visited
grid[r][c] = 0
# Encode relative position
path.append(f"({r - r0},{c - c0})")
# Visit neighbors in FIXED order (critical for consistency)
dfs(r + 1, c, r0, c0, path) # Down
dfs(r - 1, c, r0, c0, path) # Up
dfs(r, c + 1, r0, c0, path) # Right
dfs(r, c - 1, r0, c0, path) # Left
# Iterate through grid in fixed order (top-left to bottom-right)
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
path = []
dfs(r, c, r, c, path) # Start with (r, c) as origin
unique_shapes.add(tuple(path))
return len(unique_shapes)
Key Concepts for Path Signatures:
-
Canonical Traversal Order
- Always check neighbors in the same fixed sequence (e.g., D, U, R, L)
- This ensures identical shapes produce identical signatures
-
Starting Point Normalization
- Grid traversal in fixed order (top-to-bottom, left-to-right)
- The first land cell encountered becomes the origin
- All coordinates are relative to this origin
-
Why Delimiters Matter
textShape 1: 11 Shape 2: 1 1 11 Without delimiter: "SDRO" vs "SDRO" (Same - Wrong!) With delimiter: "SDOO" vs "SDRO" (Different - Correct!) -
Consistency Guarantees
- Same shape → Same signature (always)
- Different shapes → Different signatures
- Translation invariant (position doesn’t matter)
- Rotation/reflection sensitive (as required)
Template 9: Grid DFS + Backtracking — 3 Styles Compared (LC 1219 Path with Maximum Gold)
Problem: In an
m x ngrid, collect the most gold on a single path. You may start/stop at any gold cell, move up/down/left/right, never revisit a cell, and never step on a0cell. Since a path can start anywhere, we launch a DFS from every gold cell. Because paths overlap across different start cells, we backtrack (restore the cell) after each DFS so the grid is clean for the next launch.Source:
path-with-maximum-gold.py
All three versions are correct. They differ in where three decisions are made:
- Guard — is the neighbor valid (in-bounds + gold + not visited)?
- Accumulate — where does
cur_goldget the current cell added? - Update max — where do we record
self.max_gold?
Quick Comparison
| V0-1 — validate in child | V0-2 — validate before call | V0-3 — update max in loop | |
|---|---|---|---|
| Neighbor loop | 4 explicit recursive calls | for m in moves: |
for m in moves: |
| Guard location | top of child (base case) | before the recursive call | before the recursive call |
Accumulate cur_gold |
inside child (+= grid[r][c]) |
at call site (cur_gold + grid[..]) |
at call site (cur_gold + grid[..]) |
| Start value passed | 0 |
grid[start] |
grid[start] |
Update max_gold |
top of child (once per cell) | top of child (once per cell) | inside loop (per neighbor) — needs seed |
| Extra recursive calls? | Yes — invalid neighbors still call+return | No — only valid neighbors recurse | No — only valid neighbors recurse |
| Handles isolated start cell? | ✅ automatic | ✅ automatic | ⚠️ only via caller seed |
| Verdict | ✅ cleanest default | ✅ efficient, idiomatic | ⚠️ works, but fragile — avoid |
Mental model of the difference: V0-1 pushes the validity check down into the callee (“the
child decides if it should exist”) — so the base case doubles as the guard. V0-2 / V0-3 pull it
up into the caller (“the parent only calls valid children”) — so there is no wasted stack frame,
but the start cell must be validated separately (done by if grid[y][x] > 0 in the launch loop).
V0-1 — Validate inside the child (recommended default)
# python — LC 1219
# GUARD lives at the top of the child → doubles as the recursion base case.
# Cleanest to reason about: you may call dfs() on ANY coordinate (even off-grid);
# the child rejects itself. Cost: every invalid neighbor still spends one call frame.
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
rows, cols = len(grid), len(grid[0])
for r in range(rows):
for c in range(cols):
if grid[r][c] > 0:
self.dfs(grid, r, c, 0) # start value = 0
return self.max_gold
def dfs(self, grid, r, c, cur_gold):
rows, cols = len(grid), len(grid[0])
# (1) GUARD: out of bounds OR empty(0) OR visited(-1) → stop
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] <= 0:
return
cache = grid[r][c]
cur_gold += cache # (2) ACCUMULATE here
self.max_gold = max(self.max_gold, cur_gold) # (3) UPDATE MAX per cell entry
grid[r][c] = -1 # mark visited
# recurse into ALL 4 dirs unconditionally — guard filters at the top
self.dfs(grid, r + 1, c, cur_gold)
self.dfs(grid, r - 1, c, cur_gold)
self.dfs(grid, r, c + 1, cur_gold)
self.dfs(grid, r, c - 1, cur_gold)
grid[r][c] = cache # BACKTRACK: restore
When to use: your default for grid DFS. Fewest ways to get it wrong — the start cell and neighbors go through the same guard, so there is no special-casing. Prefer it when clarity matters or when the start cell might itself be invalid.
V0-2 — Validate before the call, accumulate at the call site
# python — LC 1219
# GUARD is inline BEFORE each recursive call → no wasted frames on invalid neighbors.
# The launch loop's `if grid[y][x] > 0` validates the START cell (child no longer does).
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
L, W = len(grid), len(grid[0])
for y in range(L):
for x in range(W):
if grid[y][x] > 0:
self.dfs(grid, x, y, grid[y][x]) # start value = the cell itself
return self.max_gold
def dfs(self, grid, x, y, cur_gold):
L, W = len(grid), len(grid[0])
self.max_gold = max(self.max_gold, cur_gold) # (3) UPDATE MAX per cell entry — safe
cache = grid[y][x]
grid[y][x] = -1 # mark visited
moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
for dx, dy in moves:
x_, y_ = x + dx, y + dy
# (1) GUARD before recursing + (2) ACCUMULATE at the call site
if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
self.dfs(grid, x_, y_, cur_gold + grid[y_][x_])
grid[y][x] = cache # BACKTRACK: restore
When to use: when you want the efficient / idiomatic competitive form — a moves array
scales cleanly to 8-direction or diagonal problems, and you skip the useless calls into walls.
Because max_gold is still updated at entry (before the loop), an isolated start cell is scored
correctly with no extra code. This is the version to reach for once you’re comfortable.
V0-3 — Update max inside the loop (works, but fragile — avoid)
# python — LC 1219
# Same structure as V0-2, BUT max_gold is updated INSIDE the loop (on `next_gold`),
# not at cell entry. Consequence: the entry cell is never scored by the DFS itself,
# so a lone gold cell with no gold neighbors would be missed → the launch loop must
# SEED max_gold with grid[y][x]. That extra dependency is exactly what makes it fragile.
class Solution:
def getMaximumGold(self, grid):
self.max_gold = 0
L, W = len(grid), len(grid[0])
for y in range(L):
for x in range(W):
if grid[y][x] > 0:
self.max_gold = max(self.max_gold, grid[y][x]) # ⚠️ REQUIRED seed
self.dfs(grid, x, y, grid[y][x])
return self.max_gold
def dfs(self, grid, x, y, cur_gold):
L, W = len(grid), len(grid[0])
cache = grid[y][x]
grid[y][x] = -1 # mark visited (once per frame)
moves = [[-1, 0], [1, 0], [0, 1], [0, -1]]
for dx, dy in moves:
x_, y_ = x + dx, y + dy
if 0 <= x_ < W and 0 <= y_ < L and grid[y_][x_] > 0:
# NOTE: do NOT mark/unmark grid[y][x] here inside the loop.
# Marking is per-cell-entry, not per-neighbor: the same cell is
# explored by all 4 branches of THIS frame; re-marking each
# iteration would corrupt the shared state.
next_gold = cur_gold + grid[y_][x_]
self.max_gold = max(self.max_gold, next_gold) # (3) UPDATE MAX in loop
self.dfs(grid, x_, y_, next_gold)
grid[y][x] = cache # BACKTRACK: restore
When to use: effectively never as a first choice. It’s included to show the trap: moving the
max-update into the loop makes the entry cell invisible to the DFS, forcing the caller-side seed.
Miss that one line and single-cell (or fully-isolated) inputs silently return 0. Prefer V0-1 / V0-2.
Things to note (all versions)
- Backtracking is mandatory here, not optional. A path may start from many cells and paths
overlap; restoring
grid[r][c] = cacheafter the recursion lets later launches reuse the cell. Contrast with plain “count islands” (LC 200) where you mark-and-never-restore. - In-place visited marking (
-1/0) avoids an extravisitedset — fine because we undo it. The guard treats empty and visited uniformly (<= 0), so no separate visited check is needed. - Mark/unmark exactly once per frame, wrapping the neighbor exploration — never per neighbor.
- Where you update max determines whether you need a seed: update at cell entry (V0-1/V0-2) and every cell (including isolated ones) is counted for free; update per neighbor (V0-3) and you owe the caller a seed for the start cell.
- Complexity (all three):
time = O(4^k)worst case wherek ≤ 25is the number of gold cells (each cell branches into ≤3 unvisited neighbors after the first);space = O(k)recursion depth.
Template 10: Weighted Graph DFS (Division/Ratio Queries) — LC 399
- Description: Build a weighted directed graph where edge weights represent ratios/division results, then DFS to compute transitive ratios between any two connected nodes
- Recognition: “Evaluate division”, “exchange rates”, “currency conversion”, “ratio queries”, “transitive relationships with weights”
- Key Technique: Model equations as a bidirectional weighted graph (
Map<String, Map<String, Double>>), DFS with accumulated product along the path - Examples: LC 399 (Evaluate Division), LC 1101 (The Earliest Moment When Everyone Become Friends - variant), LC 721 (Accounts Merge - graph grouping variant)
- Core Algorithm Idea:
- Graph Construction: For each equation
a / b = val, add edgea → bwith weightvaland edgeb → awith weight1/val - Query Processing: For query
c / d, DFS fromctod, multiplying edge weights along the path - Product Accumulation: Pass a running product through DFS; when target is reached, the product is the answer
- Alternative: Union-Find with ratio tracking (store
node → rootratio for O(α(n)) queries)
- Graph Construction: For each equation
- Important Notes:
- Bidirectional Edges: Always store both
a→bandb→awith reciprocal weights - Visited Set: Reset per query to allow independent path exploration
- Early Termination: If either node not in graph, return -1.0 immediately
- Self-Division: If
start == endand node exists in graph, return 1.0 - Product vs Additive: Unlike shortest-path problems, this uses multiplicative accumulation
- Bidirectional Edges: Always store both
- Similar Classic LC Problems:
- LC 399 - Evaluate Division (canonical weighted graph DFS)
- LC 1976 - Number of Ways to Arrive at Destination (weighted graph traversal)
- LC 787 - Cheapest Flights Within K Stops (weighted graph with constraints)
- LC 743 - Network Delay Time (weighted graph exploration)
- LC 1334 - Find the City With the Smallest Number of Neighbors at a Threshold Distance
# 399 Evaluate Division
# there is also an "union find" solution
class Solution:
def calcEquation(self, equations, values, queries):
from collections import defaultdict
# build graph
graph = defaultdict(dict)
for (x, y), v in zip(equations, values):
graph[x][y] = v
graph[y][x] = 1.0/v
ans = [self.dfs(x, y, graph, set()) for (x, y) in queries]
return ans
def dfs(self, x, y, graph, visited):
if not graph:
return
if x not in graph or y not in graph:
return -1
if x == y:
return 1
visited.add(x)
for n in graph[x]:
if n in visited:
continue
visited.add(n)
d = self.dfs(n, y, graph, visited)
if d > 0:
return d * graph[x][n]
return -1.0
// java
// V1
// IDEA: DFS
// https://leetcode.com/problems/evaluate-division/solutions/3543256/image-explanation-easiest-concise-comple-okpu/
public double[] calcEquation_1(List<List<String>> equations, double[] values, List<List<String>> queries) {
HashMap<String, HashMap<String, Double>> gr = buildGraph(equations, values);
double[] finalAns = new double[queries.size()];
for (int i = 0; i < queries.size(); i++) {
String dividend = queries.get(i).get(0);
String divisor = queries.get(i).get(1);
/** NOTE !!!
*
* either dividend nor divisor NOT in graph, return -1.0 directly
*/
if (!gr.containsKey(dividend) || !gr.containsKey(divisor)) {
finalAns[i] = -1.0;
} else {
/** NOTE !!!
*
* we use `vis` to check if element already visited
* (to avoid repeat accessing)
* `vis` init again in every loop
*/
HashSet<String> vis = new HashSet<>();
/**
* NOTE !!!
*
* we init `ans` and pass it to dfs method
* (but dfs method return NOTHING)
* -> `ans` is init, and pass into dfs,
* -> so `ans` value is updated during dfs recursion run
* -> and after dfs run completed, we get the result `ans` value
*/
double[] ans = { -1.0 };
double temp = 1.0;
dfs(dividend, divisor, gr, vis, ans, temp);
finalAns[i] = ans[0];
}
}
return finalAns;
}
/** NOTE !!! below dfs method */
public void dfs(String node, String dest, HashMap<String, HashMap<String, Double>> gr, HashSet<String> vis,
double[] ans, double temp) {
/** NOTE !!! we use `vis` to check if element already visited */
if (vis.contains(node))
return;
vis.add(node);
if (node.equals(dest)) {
ans[0] = temp;
return;
}
for (Map.Entry<String, Double> entry : gr.get(node).entrySet()) {
String ne = entry.getKey();
double val = entry.getValue();
/** NOTE !!! update temp as `temp * val` */
dfs(ne, dest, gr, vis, ans, temp * val);
}
}
public HashMap<String, HashMap<String, Double>> buildGraph(List<List<String>> equations, double[] values) {
HashMap<String, HashMap<String, Double>> gr = new HashMap<>();
for (int i = 0; i < equations.size(); i++) {
String dividend = equations.get(i).get(0);
String divisor = equations.get(i).get(1);
double value = values[i];
gr.putIfAbsent(dividend, new HashMap<>());
gr.putIfAbsent(divisor, new HashMap<>());
gr.get(dividend).put(divisor, value);
gr.get(divisor).put(dividend, 1.0 / value);
}
return gr;
}
Summary & Quick Reference
Decision Flowchart
DFS Problem Analysis Flowchart:
1. Is it a tree/graph traversal problem?
├── YES → Check structure type
│ ├── Tree? → Use Tree Templates (1, 3, 5, 6)
│ │ ├── Need specific order? → Template 1 (Traversal)
│ │ ├── Need paths? → Template 3 (Path Finding)
│ │ ├── Need to modify? → Template 5 (Modification)
│ │ └── Need subtree info? → Template 6 (Bottom-up)
│ └── Graph? → Use Graph Template (2)
│ ├── Has cycles? → Add visited set
│ ├── Need all paths? → Track path
│ └── Multi-source? → Start from all sources
└── NO → Continue to 2
2. Is it a combinatorial problem?
├── YES → Use Backtracking Template (4)
│ ├── Permutations? → Swap elements
│ ├── Combinations? → Start index
│ ├── Subsets? → Include/exclude
│ └── Constraint satisfaction? → Check validity
└── NO → Continue to 3
3. Does it require exploring all possibilities?
├── YES → Use DFS with appropriate state tracking
│ ├── Grid problem? → 4-directional DFS
│ ├── String problem? → Index-based DFS
│ └── Decision tree? → Choice-based DFS
└── NO → Consider different algorithm
4. Special considerations:
├── Need shortest path? → Consider BFS instead
├── Has optimal substructure? → Consider DP
└── Need all solutions? → DFS with backtracking
Problem-Solving Steps
- Identify pattern: Tree, graph, backtracking, or path
- Choose template: Select appropriate DFS template
- Track state: Visited set, path list, or global variable
- Handle base cases: Null nodes, boundaries, target found
- Test edge cases: Empty input, single node, cycles
Common Mistakes & Tips
🚫 Common Mistakes:
- Forgetting visited set: Infinite loops in graphs
- Not backtracking: Incorrect paths in combinatorial problems
- Wrong traversal order: Using preorder when postorder needed
- Modifying while traversing: Can break iteration
- Not handling null: NullPointerException
- ⚠️ CRITICAL: Not returning immediately when path found: When searching for a path in DFS, must return true immediately when found (see detailed explanation below)
✅ Best Practices:
- Use visited set for graphs: Prevent cycles
- Clone paths:
path[:]when storing results - Check boundaries first: In grid problems
- Use meaningful names:
visitednotv - Consider iterative: For deep recursion
Interview Tips
- Clarify problem type: Tree or graph? Cycles possible?
- State approach: “I’ll use DFS because…”
- Discuss complexity: Time and space analysis
- Handle edge cases: Empty, single element, cycles
- Optimize if needed: Memoization, pruning
Pro Tips for Pattern Selection
- Two-pass problems: If you need to eliminate something first (boundary, edges), use Template 7
- Shape comparison: If comparing structures/shapes, use Template 8 (Path Signatures)
- Bottom-up aggregation: If answer depends on processing children first, use Template 6
- Try all possibilities: If problem asks for “all” solutions/combinations, use Template 4 (Backtracking)
- Overlapping paths from many starts: mark, recurse, then restore — Template 9
- Two nodes in the predicate: if the question cannot be stated about one node (“mirror”, “same tree”), carry a pair through the recursion — Template 1’s paired-DFS variation
- Anything that does not fit: check dfs_advanced.md before inventing a pattern
Related Topics
- bfs.md: when the shortest path is needed
- dp.md: overlapping subproblems — memoize the DFS
- backtrack.md: DFS for combinations, with undo
- union_find.md: alternative for connectivity
- topology_sorting.md: DFS application for dependencies
- dfs_advanced.md: the rare templates split out of this sheet
- dfs_examples.md: worked solutions and the full problem index
Must-Know Problems for Interviews: LC 94, 100, 101, 104, 112, 113, 124, 200, 236, 297, 399, 694 Advanced Problems: LC 124, 297, 329, 472, 652, 694, 711 Path Signature Pattern: LC 694 (Distinct Islands), LC 711 (Distinct Islands II), LC 652 (Find Duplicate Subtrees)