Backtracking
Quick reference
| subsets(nums), n elements | O(2βΏ) |
| permutations(nums), n elements | O(n!) |
| combinations(n, k) | O(C(n, k)) β up to O(2βΏ) |
| any backtracking search, generally | exponential or factorial |
Recursion Basics taught you to ask a smaller version of yourself to solve a smaller version of the problem. Backtracking is that same trick, plus one new move: try a choice, recurse as if it were correct, and if it turns out wrong β or you've explored everything reachable from it β undo the choice and try the next one. The undo is what's new. Plain recursion never takes anything back; backtracking does, on purpose, every single time.
Picture exploring a maze. At a fork you commit to a path and keep walking. If it dead-ends, you don't restart from the entrance β you walk back to the last fork and try a direction you haven't tried yet. That walk back is the backtrack: you're not giving up, you're returning to the most recent decision point with everything reset to how it looked before you made that choice, so the next attempt starts clean.
The reason this works at all is the same reason recursion works: the call stack remembers where you were. Each recursive call holds its own "which choice am I trying" state, so when a deeper call finishes β whether it succeeded or hit a dead end β control returns to exactly the point that made the choice, ready to try the next option.
Shape of a backtracking function
function backtrack(partialSolution, choicesRemaining):
if partialSolution is a complete answer:
record a copy of partialSolution
return
for each option in choicesRemaining:
partialSolution.add(option) // choose
backtrack(partialSolution, choicesRemaining - option) // explore
partialSolution.remove(option) // un-choose β the actual backtracksubsets β each element is in, or it's out
function subsets(nums):
result = []
current = []
function backtrack(i):
if i == len(nums): // base case: decided every element
result.add(copy of current)
return
current.add(nums[i]) // choose: include nums[i]
backtrack(i + 1) // explore
current.remove(nums[i]) // un-choose
// explore the "exclude nums[i]" branch β nothing to un-choose, we added nothing
backtrack(i + 1)
backtrack(0)
return resultpermutations β choose which unused element goes next
function permutations(nums):
result = []
current = []
used = array of false, size len(nums)
function backtrack():
if len(current) == len(nums): // base case: placed every element
result.add(copy of current)
return
for i in 0..len(nums):
if used[i]:
continue // already placed β not a legal choice right now
used[i] = true // choose
current.add(nums[i])
backtrack() // explore
current.remove(current.length - 1) // un-choose
used[i] = false // un-choose
backtrack()
return resultcombinations β subsets with a fixed size, no repeats of the same group
function combinations(n, k):
result = []
current = []
function backtrack(start):
if len(current) == k: // base case: picked enough elements
result.add(copy of current)
return
for i in start..n: // only look forward from 'start'
current.add(i) // choose
backtrack(i + 1) // explore β next call starts AFTER i
current.remove(current.length - 1) // un-choose
backtrack(1)
return resultpruning β cutting a branch before it's fully explored
function combinationSum(candidates, target):
result = []
current = []
function backtrack(start, remaining):
if remaining == 0: // base case: hit the target exactly
result.add(copy of current)
return
if remaining < 0: // PRUNE: overshot, no point continuing
return
for i in start..len(candidates):
current.add(candidates[i]) // choose
backtrack(i, remaining - candidates[i]) // explore (i, not i+1 β reuse allowed)
current.remove(current.length - 1) // un-choose
backtrack(0, target)
return resultBacktracking explores a tree of choices, so its worst case is however big that tree is β no shortcut changes that, because there usually isn't a shortcut for these problems. What pruning buys you is a smaller tree in practice, not a smaller ceiling on paper.
| Operation | Time | Space | Why |
|---|---|---|---|
| subsets(nums), n elements | O(2βΏ) | O(n) | Two choices β include or exclude β repeated for each of n elements, so the recursion tree has 2βΏ leaves. Space is just the recursion depth (at most n frames deep) plus the current partial subset, not the 2βΏ output itself. |
| permutations(nums), n elements | O(n!) | O(n) | n choices for the first slot, n-1 for the second, and so on β that product is n!. Depth of the recursion (and the size of `current` and `used`) is still only O(n). |
| combinations(n, k) | O(C(n, k)) β up to O(2βΏ) | O(k) | Bounded by how many size-k groups exist; in the worst case (k around n/2) that's still exponential in n. The stack only ever holds the current partial combination, which never exceeds size k. |
| any backtracking search, generally | exponential or factorial | O(depth of recursion) | Same call-stack-cost lesson as Recursion Basics: the number of pending calls waiting on the stack at any instant is what space measures, and that's bounded by how deep one path through the choice tree goes β not by how many total paths exist. |
Forgetting the un-choose step
This is the single most common backtracking bug. If `current.remove(...)` is missing (or runs before the recursive call instead of after), the partial solution silently keeps accumulating garbage from a branch you've already finished exploring β later branches see state left over from earlier ones instead of the clean slate they're supposed to get. The symptom is usually 'answers that look almost right, with extra or duplicated elements that don't belong.'
Duplicate input values without duplicate-skipping logic
Plain subsets/permutations code treats the two 1s as distinguishable because they sit at different indices, so it generates the same-looking answer twice β once for 'first 1 in, second 1 out' and once for the reverse, which look identical once printed. Fixing this needs an explicit rule, typically: sort the input first, then at each choice point skip an option if it's equal to the option immediately before it AND that previous option was skipped (not chosen) at this same level β otherwise the fix skips choices you actually needed.
Missing or wrong base case
Same failure family as Recursion Basics: no base case (or one that's never reached) means the function recurses until it blows the call stack. A base case that's technically present but wrong β e.g. checking `i > len(nums)` instead of `i == len(nums)` β can silently skip recording valid complete answers, or record incomplete ones.
Skipping an obvious prune
A correct-but-unpruned combinationSum will still find every valid combination β it's not wrong, it's just slow, because it keeps recursing down branches where `remaining` already went negative instead of cutting them off immediately. Asymptotically the worst case doesn't change, but in practice the difference between pruning and not pruning is often the difference between finishing instantly and timing out.
Sign in to mark problems done β progress syncs across devices.