DSA Field Notes
Course map / 07 Backtracking
NeetCode · DSA for Beginners · Lesson 22

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

Analogy

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.

dashed grey = tried, hit a dead end, walked back orange = the path that worked S entrance A fork B fork E exit ✕ dead end ✕ dead end ① try up first ② ③ try down first ④
Every dead end sends you back exactly one fork. At A you try ① and fail, so you return to A and take ②. At B you try ③ and fail, return to B, take ④. You never restart from the entrance: you only undo the last choice. That "undo one step, try the next option" is the whole algorithm.

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 (root is None): there is no path at all, so return False. Note that NeetCode writes null; in Python it is None.
  • 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.
  1. Arbitrarily explore the left subtree first.
  2. If left returns True, return True.
  3. If left returns False, explore the right subtree.
  4. If right returns True, return True.
  5. If both return False, return False.
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.

4 ① 0 ② returns False blocked 1 ③ returns True ∅ 7 never visited 2 ④ leaf: True 0 blocked, not visited orange = the path found dashed = explored and rejected faded = never visited
The left subtree is rejected in one step. Call ② sees 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.
CallNodeCheck that firesReturnsBack in the caller
①4not 0, not a leaf → recurse left(waiting)-
②0root.val == 0False4: left was False, recurse right
③1not 0, not a leaf → recurse left(waiting)-
④2no children → leafTrue1: left was True → return True
①4right was TrueTruedone: a valid path exists
Keep in mind

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.

Where is the backtracking?

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.

4 ① append 4 0 ② returns False blocked 1 ③ append 1 ∅ 7 never visited 3 ④ ⑦ append 3, later pop 3 2 ⑧ leaf: True ∅ ⑤ 0 ⑥ ⑤ and ⑥ return False, so ⑦ pops 3 and returns False to 1, which tries 2
Node 3 is the whole point of this example. It is not 0 and it is not a leaf, so it looks fine and gets appended (④). Only after both of its children fail (⑤ null, ⑥ a 0) do we learn it was a dead end. Step ⑦ pops it. The dashed edges are the branches that were explored and rejected.

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.

  1. Add root 4 to the list. path = [4].
  2. Left subtree is invalid (4's left child is 0), so that call returns False. Check the right subtree.
  3. 1 is valid: add it. path = [4, 1].
  4. 3 is valid: add it. path = [4, 1, 3].
  5. 3's left child is None: return False.
  6. 3's right child is 0: base case, return False.
  7. Remove 3 from the list (pop). If a valid path went through 3 we would have returned True already. path = [4, 1].
  8. Back up to 3's parent, node 1, and check its right subtree.
  9. Add 2. 2 is a leaf, so return True, and the whole function returns True. path = [4, 1, 2].
path drawn as a stack: newest value on top. Orange = the cell that changed in this step. 4 ① append 4 [4] 4 1 ③ append 1 [4, 1] 4 1 3 ④ append 3 [4, 1, 3] 4 1 3 ⑦ pop → 3 [4, 1] 4 1 2 ⑨ append 2 [4, 1, 2]
The list is a stack. 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.

#CallWhat happensPath afterReturns
①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
Keep in mind

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.

Append after the checks, not before

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.

CALL STACK at step ⑥ leafPath(0) ⑥ leafPath(3) ④ leafPath(1) ③ leafPath(4) ① 4 frames = height 3, plus 1 PATH LIST at step ⑥ 4 1 3 0 1 2 3 values: the 0 call appended nothing never more than one value per level
Both memory users grow and shrink together. A frame is pushed when a call starts and popped when it returns; a value is appended when a call passes the checks and popped when it fails. Neither can be taller than the tree. For a balanced tree h is about log n; for a chain-shaped tree h is n.
Keep in mind

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.

one lap = one candidate at this level of the recursion 1. choose path.append(v) 2. explore recurse deeper 3. un-choose path.pop() next option at the same fork
Orange boxes must be mirror images. Whatever 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
append 1 skip 1 append 2 skip 2 append 2 skip 2 [ ] ① dfs(0) [1] ② [ ] ⑤ dfs(1) [1, 2] [1] [2] [ ] ③ ④ ⑥ ⑦ dfs(2) orange edge = append then pop on the way back; orange box = res.append(cur[:])
Same shape as the tree maze, but the tree is built by your choices instead of given to you. Depth = index 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 forUseWhy
All solutions, every path, every arrangement, or the actual path itselfBacktrackingYou must build each answer explicitly; there is no shortcut around listing them.
Does any solution exist, with small nBacktracking with pruningStop at the first success, like canReachLeaf.
A count, a minimum, or a maximum, and the same subproblem shows up many timesDynamic programmingCache each subproblem once (memoization) instead of re-exploring it. Backtracking would redo the same work exponentially.
A count, but the subproblems never repeatBacktracking (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.

FunctionTimeExtra spaceNote
canReachLeafO(n)O(h)visits each node at most once; recursion depth = height
leafPathO(n)O(h)path list never exceeds h + 1 values
allLeafPathsO(n · h)*O(L · h)L = number of leaves; each copy of a path costs O(h)
subsetsO(n · 2n)O(n)2n subsets, each copied in O(n); space excludes the output
permutationsO(n · n!)O(n)n! orderings, each of length n
combinationsO(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

Forgetting the pop

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.

Appending before the base-case check

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.

Storing the list instead of a copy

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().

Returning early when all answers are needed

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.

Wrong leaf test

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.

Mutable default argument

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.

Undoing only half of the choice

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.

No start index in combinations

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.

Using backtracking where DP was asked

"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 used array, combinations is a start index; 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.

#ProblemMove it trainsLevel
112Path SumcanReachLeaf with a running sum instead of a 0 checkEasy
257Binary Tree PathsallLeafPaths: append, recurse both sides, popEasy
113Path Sum IIcollect every path with a copy at each leafMedium
78Subsetstake-or-skip decision treeMedium
77Combinationsstart indexMedium
46Permutationsused array, undo two thingsMedium
39Combination Sumstart index with reuse (dfs(i)) and a sum pruneMedium
90Subsets IIsort, then skip duplicates at the same levelMedium
40Combination Sum IIstart index plus duplicate skippingMedium
17Letter Combinations of a Phone Numberone choice per digit; build a string instead of a listMedium
79Word Searchbacktracking on a grid: mark cell, explore 4 directions, unmarkMedium
51N-Queensthree "used" sets (columns, both diagonals) chosen and un-chosen togetherHard

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.