Backtracking Field Notes
Try a choice, go deeper, and if it leads nowhere, undo the choice and try the next one. One tree problem teaches the whole idea. The same three lines then solve subsets, permutations, and combinations.
0Why backtracking exists
You are trapped in a maze. At every fork you pick a corridor and walk. When you hit a dead end, you do not give up. You walk back to the last fork and take the corridor you have not tried yet. Do this at every fork and you will either find the exit or prove there is none.
Backtracking is a brute-force method: try every possible solution, and the moment a partial attempt hits a dead end, undo the last step and try the next option. It overlaps a lot with DFS (depth-first search): you go as deep as you can before you turn around. The new part is the undo. You keep a record of the choices made so far, and when a branch fails you remove those choices before trying the next branch.
Two words you will meet on every backtracking problem. A choice is one option at a fork (go left, go right, take this number, skip it). A dead end is a partial attempt that can no longer become a valid answer. Detecting dead ends early is called pruning; it is what separates backtracking from blindly listing everything.
1The motivating problem: reach a leaf without a 0
The question
Given a binary tree, determine if there exists a path from the root to a leaf (a node with no children) such that no node on the path has value 0. Return True if such a path exists, else False. A node with value 0 is a wall in the maze: you cannot walk through it.
The reasoning, step by step
Use DFS. The constraint is "no node with value 0". Work out the small cases first, then the recursive case.
- Empty tree (
rootisNone): there is no path at all, so returnFalse. Note that NeetCode writesnull; in Python it isNone. - Node value is 0: this branch is blocked, return
False. - Reached a leaf (and it is not 0): we walked root-to-leaf without touching a 0, return
True. - Otherwise: if a solution exists, it is in the left subtree or the right subtree.
- Arbitrarily explore the left subtree first.
- If left returns
True, returnTrue. - If left returns
False, explore the right subtree. - If right returns
True, returnTrue. - If both return
False, returnFalse.
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def canReachLeaf(root):
if not root or root.val == 0: # empty, or a wall: dead end
return False
if not root.left and not root.right: # a leaf, and it is not 0: found a path
return True
if canReachLeaf(root.left): # try left first (arbitrary choice)
return True
if canReachLeaf(root.right): # left failed, try right
return True
return False # both failed: this node is a dead end
The lesson's example: [4,0,1,null,7,2,0]
Root 4. Its left child is 0, and that 0 has a right child 7. Its right child is 1, with children 2 and 0. A path is invalid if it contains a 0. The only valid root-to-leaf path is 4 → 1 → 2.
root.val == 0 and returns False without looking at 7. Call ③ goes left first, call ④ is a leaf, so True bubbles up: 2 → 1 → 4. The right child of 1 is never visited because the left already answered True. Circled numbers are the order the calls start.| Call | Node | Check that fires | Returns | Back in the caller |
|---|---|---|---|---|
| ① | 4 | not 0, not a leaf → recurse left | (waiting) | - |
| ② | 0 | root.val == 0 | False | 4: left was False, recurse right |
| ③ | 1 | not 0, not a leaf → recurse left | (waiting) | - |
| ④ | 2 | no children → leaf | True | 1: left was True → return True |
| ① | 4 | right was True | True | done: a valid path exists |
The order of the two if checks matters. if not root or root.val == 0 must come first so that a 0 leaf is rejected before the leaf check can say True. A leaf with value 0 is a wall, not an exit.
In canReachLeaf the "undo" is invisible: when a call returns False, the recursion simply comes back to the parent and the parent tries its other child. Nothing has to be cleaned up because we stored nothing. The next section adds a stored path, and then the undo becomes a real line of code: path.pop().
2Variation: also build the path
The question
Same rule, but now return the path itself: the node values from root to leaf, in the order they were visited. We pass a parameter path (a Python list) into the recursion. Every call that is on a promising branch appends its value. Every call that turns out to be a dead end removes its value again before returning. That removal is the backtrack.
def leafPath(root, path):
if not root or root.val == 0:
return False
path.append(root.val) # choose: this node is on the path (for now)
if not root.left and not root.right:
return True # leaf: keep it in the path, done
if leafPath(root.left, path): # explore left
return True
if leafPath(root.right, path): # explore right
return True
path.pop() # un-choose: no valid path goes through here
return False
Call it with an empty list: path = []; leafPath(root, path). When the function returns True, path holds the answer. When it returns False, path is empty again, because every append was matched by a pop.
The lesson's example: [4,0,1,null,7,3,2,null,null,null,0]
Root 4. Left child 0 with a right child 7. Right child 1 with children 3 and 2. Node 3 has a right child 0. This tree is chosen so that one branch (through 3) looks promising, gets added to the path, and then must be removed.
The path list, moment by moment
The lesson describes nine steps. Here they are, and under them the exact state of path after each one.
- Add root 4 to the list.
path = [4]. - Left subtree is invalid (4's left child is 0), so that call returns
False. Check the right subtree. - 1 is valid: add it.
path = [4, 1]. - 3 is valid: add it.
path = [4, 1, 3]. - 3's left child is
None: returnFalse. - 3's right child is 0: base case, return
False. - Remove 3 from the list (
pop). If a valid path went through 3 we would have returnedTruealready.path = [4, 1]. - Back up to 3's parent, node 1, and check its right subtree.
- Add 2. 2 is a leaf, so return
True, and the whole function returnsTrue.path = [4, 1, 2].
append pushes, pop pops, and only the top ever changes. That is why the same list can be shared by every recursive call: a call only ever removes the value it added itself, because every deeper call has already cleaned up before returning False.Full trace of leafPath on this tree
Each row is one event inside the recursion. "Path after" is the list at that moment. Read it top to bottom and match the circled numbers with the tree figure above.
| # | Call | What happens | Path after | Returns |
|---|---|---|---|---|
| ① | leafPath(4) | 4 is not 0 → append 4. Not a leaf → call left | [4] | waiting |
| ② | leafPath(0) | root.val == 0 → nothing appended | [4] | False |
| ① | leafPath(4) | left False → call right | [4] | waiting |
| ③ | leafPath(1) | append 1. Not a leaf → call left | [4, 1] | waiting |
| ④ | leafPath(3) | append 3. Not a leaf → call left | [4, 1, 3] | waiting |
| ⑤ | leafPath(None) | not root | [4, 1, 3] | False |
| ④ | leafPath(3) | left False → call right | [4, 1, 3] | waiting |
| ⑥ | leafPath(0) | root.val == 0 | [4, 1, 3] | False |
| ⑦ | leafPath(3) | right False too → path.pop() removes 3 | [4, 1] | False |
| ③ | leafPath(1) | left False → call right | [4, 1] | waiting |
| ⑧ | leafPath(2) | append 2. No children → leaf | [4, 1, 2] | True |
| ③ | leafPath(1) | right True → return without popping | [4, 1, 2] | True |
| ① | leafPath(4) | right True → return without popping | [4, 1, 2] | True |
A True return skips the pop on purpose. The values stay in the list all the way up, so when the outermost call returns True the caller finds the full path [4, 1, 2] in the list it passed in. Only a False return cleans up.
The append sits below the if not root or root.val == 0 line. If you append first, a 0 node lands in the path and then returns False without popping, and the list is corrupted for every later call. Rule: a call that returns from the base case must not have changed shared state.
3Time and space
Time: O(n)
In the worst case there is no valid path, or the only one is in the last branch, and the recursion visits every node once. Each visit does constant work (a few comparisons, one append, one pop). So the total is O(n), where n is the number of nodes. Nodes below a 0 are skipped, so the real count is often smaller, but Big-O reports the worst case.
Space: O(h)
Two things use memory, and both are bounded by the height h of the tree (the longest root-to-leaf distance). First, the call stack: recursion goes at most h levels deep, because each call moves one level down. Second, the path list: it holds one value per active call that appended, so it never holds more than h + 1 values at once. The rejected values are popped before the list can grow further.
If you copy the path when you find an answer (res.append(path[:]), see the Patterns section), each copy costs O(h) extra. That is the price of returning all solutions instead of the first one.
The lesson closes with this: backtracking is a common pattern and it applies far beyond binary trees. Subsets, permutations, and combinations are the classic examples and they are covered in later lessons and courses. The next section shows that the exact same three lines drive all of them.
4Moves that solve most backtracking problems
leafPath already contains the whole template. Pull it out and you can reuse it on problems that have nothing to do with trees.
Move 1: The universal loop, choose → explore → un-choose
Every backtracking function has the same body. Make a choice and record it. Recurse to explore what follows from that choice. When the recursion returns, undo the choice so the next option starts from a clean state.
choose changes, un-choose must reverse: an append needs a pop, a used[i] = True needs used[i] = False, a grid cell marked visited must be unmarked. If they do not match, the state leaks into the next lap.def backtrack_template(candidates):
# records every ordered selection of distinct items, to show the shape
res, choice = [], []
def explore():
res.append(list(choice)) # record the current state
for c in candidates:
if c in choice:
continue # prune: this option is not allowed
choice.append(c) # 1. choose
explore() # 2. explore
choice.pop() # 3. un-choose
explore()
return res
backtrack_template([1, 2]) # [[], [1], [1, 2], [2], [2, 1]]
When to use: the problem says "all", "every", "generate", "enumerate", or asks whether any arrangement satisfies a rule, and the input is small (n ≤ about 20). The three questions to answer before you code: what are the choices at each step, when is a partial state a dead end (prune), and when is a state a complete answer (record it)?
Move 2: Subsets (take it or skip it)
For each element you make a two-way choice: include it or not. That gives a binary decision tree with 2n leaves, one per subset. There is no dead end here; every leaf is an answer, so no pruning is needed.
def subsets(nums):
res, cur = [], []
def dfs(i):
if i == len(nums):
res.append(cur[:]) # copy! cur keeps changing after this
return
cur.append(nums[i]) # choose: take nums[i]
dfs(i + 1)
cur.pop() # un-choose
dfs(i + 1) # other choice: skip nums[i]
dfs(0)
return res
subsets([1, 2]) # [[1, 2], [1], [2], []]
len(subsets([1, 2, 3])) # 8
i. Going down the orange edge is cur.append; coming back up it is cur.pop. The DFS reaches the leaves in the order ③④⑥⑦, which is exactly the output order [[1,2],[1],[2],[]].When to use: "all subsets", "all combinations of any size", or any problem where each item is independently in or out.
Move 3: Permutations (a used array is the choose / un-choose)
Order matters and every element must appear exactly once. At each level try every element that is not used yet. The used list is the state you must undo.
def permutations(nums):
res, cur, used = [], [], [False] * len(nums)
def dfs():
if len(cur) == len(nums):
res.append(cur[:])
return
for i in range(len(nums)):
if used[i]:
continue # prune: already in cur
used[i] = True # choose
cur.append(nums[i])
dfs() # explore
cur.pop() # un-choose (both changes!)
used[i] = False
dfs()
return res
permutations([1, 2, 3])
# [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
When to use: "all orderings", "arrangements", or anything where [1,2] and [2,1] count as different answers. n! answers, so n is always small.
Move 4: Combinations (a start index stops duplicates)
Choose k numbers out of 1..n where order does not matter. To avoid producing both [1,2] and [2,1], each call only looks at numbers larger than the last one chosen. That is the start parameter.
def combinations(n, k):
res, cur = [], []
def dfs(start):
if len(cur) == k:
res.append(cur[:])
return
for i in range(start, n + 1):
cur.append(i) # choose
dfs(i + 1) # explore only larger numbers
cur.pop() # un-choose
dfs(1)
return res
combinations(4, 2) # [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]
When to use: "choose k of n", "combination sum", "pick a team". If elements may be reused (Combination Sum), recurse with dfs(i) instead of dfs(i + 1).
Move 5: Collect every path (Path Sum II shape)
leafPath stops at the first answer. Interview problems usually want all of them. Two changes: never return early, and record a copy of the path at each leaf. Because nothing returns early, the pop happens on every exit, so the un-choose can move to the end of the function.
def allLeafPaths(root):
res, path = [], []
def dfs(node):
if not node or node.val == 0:
return
path.append(node.val) # choose
if not node.left and not node.right:
res.append(path[:]) # record a copy, keep going
else:
dfs(node.left) # explore both, always
dfs(node.right)
path.pop() # un-choose on every exit
dfs(root)
return res
# tree [1,2,3,4,5,null,6] → [[1,2,4], [1,2,5], [1,3,6]]
# the lesson's trees → [[4,1,2]]
Backtracking or dynamic programming?
Both explore choices recursively, so beginners mix them up. The decision comes from what the question asks for.
| Question asks for | Use | Why |
|---|---|---|
| All solutions, every path, every arrangement, or the actual path itself | Backtracking | You must build each answer explicitly; there is no shortcut around listing them. |
| Does any solution exist, with small n | Backtracking with pruning | Stop at the first success, like canReachLeaf. |
| A count, a minimum, or a maximum, and the same subproblem shows up many times | Dynamic programming | Cache each subproblem once (memoization) instead of re-exploring it. Backtracking would redo the same work exponentially. |
| A count, but the subproblems never repeat | Backtracking (DFS) | Nothing to cache; a memo would not help. |
Test for repeated subproblems: does the answer for the rest of the input depend only on a small state (index, remaining sum) and not on the exact choices made so far? If yes, DP. If the whole path matters (N-Queens, Sudoku, "print all paths"), backtracking.
5Big-O summary
For enumeration problems the output itself is huge, so the time is "number of answers × cost to build one". Say that sentence in the interview; it shows you know where the cost comes from.
| Function | Time | Extra space | Note |
|---|---|---|---|
canReachLeaf | O(n) | O(h) | visits each node at most once; recursion depth = height |
leafPath | O(n) | O(h) | path list never exceeds h + 1 values |
allLeafPaths | O(n · h)* | O(L · h) | L = number of leaves; each copy of a path costs O(h) |
subsets | O(n · 2n) | O(n) | 2n subsets, each copied in O(n); space excludes the output |
permutations | O(n · n!) | O(n) | n! orderings, each of length n |
combinations | O(k · C(n, k)) | O(k) | C(n, k) = n! / (k! (n-k)!) answers |
The asterisk on allLeafPaths: the visit is O(n), and the O(n · h) is a loose bound for the copying, since a tree has at most n leaves. "Extra space" means the recursion stack and the working list, not the returned answers. Interviewers usually ask "excluding the output"; say which one you mean.
6Mistakes to watch for
The path keeps values from failed branches, so the final answer contains ghosts like [4, 1, 3, 2]. Every append in a backtracking function needs a matching pop on the failure path.
If path.append(root.val) comes before if not root or root.val == 0, the code crashes on None and, worse, puts 0 values into the path that are never popped. Check first, then choose.
res.append(cur) stores a reference to the one shared list. After the recursion finishes, every entry in res is the same empty list. Use cur[:], list(cur), or cur.copy().
if dfs(left): return True is right for "does a path exist" and wrong for "list every path". For all-answers problems the recursive calls return nothing; you collect into res and let every branch run.
if not root.left or not root.right treats a node with one child as a leaf. A leaf has no children: not root.left and not root.right. In tree A this bug would make node 0 (with one child 7) look like a leaf.
def leafPath(root, path=[]) creates one list that is shared across every call of the function in the whole program. The second time you call it, the old path is still inside. Pass [] explicitly at the call site.
In permutations, popping cur but leaving used[i] = True means that number is never available again. If choose touches two things, un-choose must touch both, in reverse order.
Looping from 0 every time produces [1,2] and [2,1] as separate answers. Pass start and loop from it so choices only move forward.
"How many ways" with overlapping subproblems (climbing stairs, coin change count) explodes exponentially without a memo. If you only need a number and the same state repeats, cache it.
Interview one-liners
- "Backtracking is DFS plus undo: I make a choice, recurse, and remove the choice before trying the next one."
- "The base cases come first: an empty node or a 0 returns False before anything is added to the path, so a failed call never changes shared state."
- "A leaf that passed the checks returns True and skips the pop, so the values stay in the list all the way up to the caller."
- "Time is O(n) because every node is visited at most once; space is O(h) for the recursion stack and the path list."
- "For all-solutions problems I record a copy,
res.append(cur[:]), because the working list keeps changing." - "Subsets is take-or-skip, permutations is a
usedarray, combinations is astartindex; all three are the same choose-explore-unchoose loop." - "If the question wants a count or an optimum and subproblems repeat, I switch to DP with a memo instead of backtracking."
7Practice, in this order
The first three are the lesson's problem in LeetCode clothing. Then the three classic enumerations, then the ones that add pruning and a grid. Do them without notes, then compare with the templates above.
| # | Problem | Move it trains | Level |
|---|---|---|---|
| 112 | Path Sum | canReachLeaf with a running sum instead of a 0 check | Easy |
| 257 | Binary Tree Paths | allLeafPaths: append, recurse both sides, pop | Easy |
| 113 | Path Sum II | collect every path with a copy at each leaf | Medium |
| 78 | Subsets | take-or-skip decision tree | Medium |
| 77 | Combinations | start index | Medium |
| 46 | Permutations | used array, undo two things | Medium |
| 39 | Combination Sum | start index with reuse (dfs(i)) and a sum prune | Medium |
| 90 | Subsets II | sort, then skip duplicates at the same level | Medium |
| 40 | Combination Sum II | start index plus duplicate skipping | Medium |
| 17 | Letter Combinations of a Phone Number | one choice per digit; build a string instead of a list | Medium |
| 79 | Word Search | backtracking on a grid: mark cell, explore 4 directions, unmark | Medium |
| 51 | N-Queens | three "used" sets (columns, both diagonals) chosen and un-chosen together | Hard |
Your next step today: LeetCode 257. Write allLeafPaths from memory, then change it to return only the first path and check that the pop moves back to the failure branch.
8Self-check
Answer out loud before opening. If you cannot answer in one breath, reread that section.
Q1In one sentence, what is the difference between plain DFS and backtracking?
Both go as deep as possible before turning around. Backtracking also keeps a record of the choices made so far and undoes the last choice when a branch fails, so the next branch starts from a clean state.
Q2What are the three base cases of canReachLeaf, in the order they are checked, and what does each return?
1. root is None → False (no path). 2. root.val == 0 → False (blocked). 3. No left and no right child → True (reached a leaf without a 0). The first two share one if and must come before the leaf check.
Q3Why does the lesson say "arbitrarily explore the left subtree first"?
Because either order finds a path if one exists. Left-first is just a convention. If the left returns True we never look at the right; if it returns False we try the right.
Q4In leafPath, why is path.append(root.val) placed after the 0 check and not before?
A call that fails the base case returns False immediately and never reaches the pop. If it had appended first, the 0 would stay in the list forever. Rule: a base-case return must not change shared state.
Q5Why is there no pop before return True?
A True means "this node is on the answer path". We want it to stay in the list so that when the outermost call returns, the caller's list holds the full root-to-leaf path. Only failed calls clean up after themselves.
Q6Trace it: run leafPath on [4,0,1,null,7,3,2,null,null,null,0]. Write the state of path after each append or pop.
[4] (append 4) → [4,1] (append 1; the 0 child appended nothing) → [4,1,3] (append 3) → [4,1] (3's children were None and 0, so pop 3) → [4,1,2] (append 2, a leaf, return True). Final answer [4, 1, 2].
Q7Trace it: run leafPath on the tree [1,0,2,null,null,0,0] (root 1, left 0, right 2 whose children are both 0). What is returned and what is in path at the end?
Append 1 → [1]. Left child is 0 → False. Right child 2: append → [1,2]. Both of 2's children are 0 → both False → pop 2 → [1] → 2 returns False. Back in 1: both False → pop 1 → [] → return False. Result: False and an empty list. Every append was undone.
Q8State the time and space complexity of leafPath and explain the space part.
Time O(n): each node is visited at most once. Space O(h): the recursion is at most h levels deep, and the path list holds one value per active call that appended, so at most h + 1 values.
Q9What are the three lines of the universal backtracking template, and what must be true about the first and third?
Choose (for example path.append(v)), explore (recurse), un-choose (path.pop()). The third must exactly reverse the first. If choose changes two things, un-choose changes both, in reverse order.
Q10Why must you write res.append(cur[:]) and not res.append(cur) in subsets?
cur is one list that keeps being modified by later appends and pops. Storing it stores a reference; at the end every entry in res would be the same (empty) list. The slice makes a snapshot.
Q11How do subsets, permutations, and combinations differ in the choices at each level?
Subsets: two choices per element, take or skip, depth = index. Permutations: choose any not-yet-used element, tracked with a used array. Combinations: choose any element with index ≥ start so that order never repeats.
Q12A problem asks "how many root-to-leaf paths have sum 22". Another asks "how many ways to climb n stairs with steps of 1 or 2". Which one is backtracking and which is DP, and why?
Path counting on a tree is backtracking/DFS: each node is visited once and no subproblem repeats. Climbing stairs is DP: the answer for step k is asked again and again by different branches, so a memo turns exponential work into O(n). The rule is: same state reached many times → cache it.