Backtracking Bugs Don't Crash. That's What Makes Them Hard.

June 6, 20269 min read
dsaalgorithmsinterview-prepleetcode
Backtracking Bugs Don't Crash. That's What Makes Them Hard.
TL;DR
  • Reference bug: result.append(path) stores a pointer; use path[:] to snapshot the list before backtracking clears it.
  • Missing undo: Every append must pair with a pop; every grid mark with an unmark, or state leaks across branches.
  • Loop structure: Combinations advance with i+1 and a start index; permutations restart from 0 each level using a used[] array.
  • Duplicate guard: Use i > start and nums[i] == nums[i-1], not i > 0, or you prune valid branches in deeper recursive calls.
  • Collection point: Add to results at every node for subsets, only at remaining == 0 for target sums, only at full length for permutations.
  • Pruning is separate: All five bugs cause wrong answers; missing pruning only makes correct answers slow.

You submit. Wrong answer. You run your test case by hand and the logic looks fine. You add print statements. You stare at the recursion tree. You consider a career in finance.

That's the trap with backtracking: its bugs almost never throw exceptions. They don't stack overflow. They don't hang. They return a list of subsets missing three items, or permutations with duplicates, or a combination sum that includes partial paths it shouldn't. The code finishes with the same confident exit code whether the result is right or wrong. Zero conscience.

Five bugs account for nearly everything.


Bug 1: You Stored a Reference, Not a Snapshot

This is the most common backtracking bug in Python, and it bites even experienced engineers.

When you finish exploring a branch, you record the current path in your result list. The instinct is:

result.append(path)

You're storing a reference to the same list object, not a copy of its contents. As you backtrack and modify path, every snapshot you stored also changes, because they all point to the same object. By the time the function returns, your result contains eight identical empty lists. Technically correct. Completely useless.

Here it is concretely with subsets:

def subsets(nums: list[int]) -> list[list[int]]: result = [] def backtrack(start: int, path: list[int]) -> None: result.append(path) # Bug: reference, not copy for i in range(start, len(nums)): path.append(nums[i]) backtrack(i + 1, path) path.pop() backtrack(0, []) return result

Run this on [1, 2, 3] and you get [[], [], [], [], [], [], [], []]. Eight empty lists. Every item in result points to the same path, which ends up empty after all the pop() calls unwind. You built a house, took a photo, then demolished the house and are confused why the photo shows rubble.

The fix is one character:

result.append(path[:]) # shallow copy

For nested structures like the N-Queens board:

result.append([row[:] for row in board])

String-based backtracking avoids this because strings are immutable. path + char creates a new object. The moment you switch to a list, the reference trap is live. See mutable vs immutable in Python for why this distinction matters throughout Python code.


Bug 2: You Forgot to Undo the Choice (Rookie Move, Universal Mistake)

Backtracking requires exactly one thing: make a choice, recurse, then undo that choice before trying the next branch. Skip the reversal and you've written DFS with corrupted state. The code still runs. The results are wrong. You'll spend 45 minutes convinced the bug is somewhere else.

def permute(nums: list[int]) -> list[list[int]]: result = [] def backtrack(path: list[int], used: list[bool]) -> None: if len(path) == len(nums): result.append(path[:]) return for i in range(len(nums)): if used[i]: continue path.append(nums[i]) used[i] = True backtrack(path, used) path.pop() # must be here used[i] = False # must be here backtrack([], [False] * len(nums)) return result

Remove either of those last two lines and later branches see a path that still has elements from earlier ones. The output looks partially right, which makes it hard to diagnose. Some permutations will look correct. Others will be garbage. The worst kind of bug.

The grid version is sneakier. In Word Search (LeetCode 79), you mark the current cell to prevent revisiting it, recurse, then restore it. Forget the restore and later paths can't visit a cell that was only used by an earlier dead end.

temp = board[r][c] board[r][c] = "#" result = dfs(board, word, r, c, k + 1) board[r][c] = temp # never skip this

The symptom is specific: simple cases pass, grids where the correct path shares cells with a dead end fail. You'll be debugging for a while before you notice a dead end is polluting the board.


Bug 3: Combinations and Permutations Need Different Loop Structures

This is the structural mistake. Combinations use a start index. Permutations use a used[] array. Mixing them produces output that's almost right, which is the most infuriating kind of wrong.

Combinations (order doesn't matter, no reuse): Pass i + 1 to the recursive call. Once you've moved past an index, you don't come back. [1, 2] and [2, 1] are the same combination.

def combine(n: int, k: int) -> list[list[int]]: result = [] def backtrack(start: int, path: list[int]) -> None: if len(path) == k: result.append(path[:]) return for i in range(start, n + 1): path.append(i) backtrack(i + 1, path) path.pop() backtrack(1, []) return result

Combination Sum (reuse allowed): Pass i, not i + 1. This looks wrong because you're not advancing. It terminates because remaining shrinks every level and if candidates[i] > remaining: break prunes the rest. Trust the pruning.

def combinationSum(candidates: list[int], target: int) -> list[list[int]]: candidates.sort() result = [] def backtrack(start: int, path: list[int], remaining: int) -> None: if remaining == 0: result.append(path[:]) return for i in range(start, len(candidates)): if candidates[i] > remaining: break path.append(candidates[i]) backtrack(i, path, remaining - candidates[i]) # i, not i+1 path.pop() backtrack(0, [], target) return result

Permutations: No start index at all. Loop from 0 at every depth and use used[] to skip elements already in the path. If order doesn't matter, never revisit earlier choices. If order matters, every unused element is available at every depth.

The wrong combination: using start for a permutation problem. You get some permutations. Not all. The interviewer is not impressed.


Bug 4: i > start vs i > 0 Is Not a Typo, It's a Trap

When the input has duplicates and you need unique results, you sort first, then skip consecutive duplicates within the same decision level. The condition is i > start and nums[i] == nums[i-1], not i > 0 and nums[i] == nums[i-1].

Most explanations show the correct version without explaining why i > 0 is wrong. So you copy it, it passes, and you never actually understand it.

Take nums = [1, 2, 2]. You're in a recursive call where start=1, evaluating index 2 (value 2). Should you skip it?

With i > start: 2 > 1 is true and nums[2] == nums[1] is true, so you skip. Correct. From this decision point, you've already explored the branch starting with nums[1] = 2. Exploring again with nums[2] = 2 would generate identical results.

With i > 0: same thing here, coincidentally. But consider a deeper recursive call where start=2. When i=2, i > 0 is still true. You'd check nums[2] == nums[1], see 2 == 2, and skip. But index 1 is from the parent decision level. You've pruned a valid branch by comparing against the wrong reference point.

i > start means "we're not at the first choice at this level," which is exactly when a duplicate would produce an identical subtree.

def subsetsWithDup(nums: list[int]) -> list[list[int]]: nums.sort() # prerequisite result = [] def backtrack(start: int, path: list[int]) -> None: result.append(path[:]) for i in range(start, len(nums)): if i > start and nums[i] == nums[i - 1]: # i > start, not i > 0 continue path.append(nums[i]) backtrack(i + 1, path) path.pop() backtrack(0, []) return result

Two prerequisites, both required. Sort first: without it, identical values aren't adjacent and the check is meaningless. Use i > start, not i > 0. The tests will not tell you which one you got wrong.


Bug 5: Collecting Results at the Wrong Point in the Tree

"Base case" and "add result" are not the same step. They have different trigger conditions depending on the problem. Confuse them and you'll collect partial answers, miss valid ones, or both.

Subsets: Add at every node, including internal ones. Every prefix of every path is a valid subset. This feels wrong. Add at every node anyway.

def backtrack(start: int, path: list[int]) -> None: result.append(path[:]) # every call, not just leaves for i in range(start, len(nums)): path.append(nums[i]) backtrack(i + 1, path) path.pop()

Combinations with a target: Add only when remaining == 0. Adding at intermediate nodes gives you partial sums that aren't valid answers. Your result will have a bunch of sub-paths that look plausible but sum to the wrong number.

def backtrack(start: int, path: list[int], remaining: int) -> None: if remaining == 0: result.append(path[:]) return ...

Permutations: Add only when len(path) == len(nums).

A useful diagnostic: draw the recursion tree on three elements. Circle the nodes that belong in your result. Circling every level means subsets mode. Circling only the bottom means target-match mode. If you can't draw the tree for three elements, that's the actual problem.


Where Pruning Fits In

All five bugs are state-management issues. Pruning is the complement: check constraints before recursing rather than after. Pruning inside the loop but before the recursive call is almost always the right position. In combination sum, sorting and breaking on candidates[i] > remaining prunes entire branches. In N-Queens, validating a cell before placing a queen is what makes the algorithm tractable. See pruning in backtracking for how to identify pruning opportunities systematically.

A missing pruning opportunity doesn't give wrong answers. Just correct answers, slowly. Wrong answers are always one of the five patterns above.


Backtracking Bug Checklist

When your backtracking returns wrong answers, check in this order:

  1. Are you copying on collection? result.append(path[:]), never result.append(path).
  2. Is every append paired with a pop? Every grid mark with an unmark?
  3. Are you passing i, i+1, or starting fresh from 0? Match to the problem type.
  4. If the input has duplicates: did you sort first? Is your skip condition i > start?
  5. Where are you adding results? Subsets at every node, target problems at the match, permutations at full length.

If you want to hit all five of these bugs in real time, try them on SpaceComplexity. Explaining your reasoning out loud before you code forces you to think about it instead of copying a skeleton and hoping.


Further Reading