Data structures & algorithms · Backtracking
Choose, explore, un-choose
Three lines generate every subset and every permutation. This page traces all subsets of 1 2 3 and makes the third line — the one everybody leaves out — impossible to miss.
Grow the search tree, then un-grow it →01 The idea
Recursion, plus an undo
Some problems ask for every valid answer, not the best one. Every subset of a list. Every ordering of four names. Every way to place eight queens so that none attacks another. There is no formula to plug into. You have to build candidates one decision at a time.
Backtracking is how you do that without losing track. You keep one working answer — a single list — and you make one decision at a time. When you have finished exploring everything that follows from a decision, you take that decision back and try the next one. All those partial answers form a tree, and your recursion is a depth-first walk of it. You never store the tree; it exists only as the chain of calls on the stack.
02 Hand trace
Every subset of 1 2 3, one action at a time
Take the array 1 2 3 and ask for every subset. At each level you face the same question about one item: take it, or skip it. Two choices, three times over, so 2 × 2 × 2 = 8 subsets. Below is the left half of that tree — every branch that takes the 1 — written out action by action. The boxes are cur, the one working list every call shares. A rose note means the search is still mid-flight; a green note means a subset has just been written down.
Read the labels down the left, not the boxes. Four chooses and four un-chooses, and they interleave — the first three rows are chooses in a row, but the pops that answer them come back one at a time, threaded between the branches. It is never four chooses followed by four un-chooses at the end. Each un-choose sits between the branch that has just finished and the branch about to start, and its only job is to hand that next branch the same cur the previous one received.
Two details are worth pausing on. A skip branch — the ones named in the green notes — has no matching un-choose, because it never pushed anything, so there is nothing to pop. And twelve rows in, cur is empty again after finding {1, 2, 3}, {1, 2}, {1, 3} and {1} — the four subsets that contain the 1. The other four, {2, 3}, {2}, {3} and the empty set, come from the half of the tree that skipped the 1 at level 0. The final un-choose is what makes that half start from an empty list instead of inheriting a decision.
03 Code
The template, and the bug you cannot see
The trace is the whole algorithm, so write it down. n is the number of items, a is the array, i is the level, and cur is the working list that every call shares. This shape is called pick / not-pick: line 5 explores everything that includes a[i], line 7 explores everything that does not, and the base case at line 2 fires when there is nothing left to decide.
The version on the left is what students hand in. It has the choose and it has both explores. What it does not have is the pop.
Broken · no un-choose
function subsets(i, cur): if i == n: record(cur); return cur.push(a[i]) # choose subsets(i + 1, cur) # explore subsets(i + 1, cur) # cur still holds a[i]
Correct · choose, explore, un-choose
function subsets(i, cur): if i == n: record(cur); return cur.push(a[i]) # 1 choose subsets(i + 1, cur) # 2 explore cur.pop() # 3 un-choose subsets(i + 1, cur) # explore without a[i]
The pop is not tidying up — it is the next branch's setup. Line 5 finishes everything that starts with a[i]. Line 7 starts everything that does not. Between the two, cur has to lose a[i], or line 7 inherits a decision it never made. The un-choose belongs to the sibling branch, not to the branch that just ended.
The broken version's first answer is correct, which is exactly why it survives. Run the left version on 1 2 3 and the first thing it records is {1, 2, 3} — right. The second is {1, 2, 3} again, then {1, 2, 3, 3} twice, then {1, 2, 3, 3, 2, 3}. Eight records still come out, so even the count looks plausible, and the empty set never appears at all. Check the last line of your output, not the first.
One list, shared by every frame. Every call pushes onto the same cur, so the working space is one list of at most n items — O(n). The alternative is to hand each call its own copy; then there is nothing to un-choose, because the copy was the undo. You pay an O(n) copy at every node instead of an O(1) push and pop — that is all 2ⁿ⁺¹ − 1 of them, 15 for three items — and because every live frame now holds its own list, the working space becomes O(n²).
05 Cheat sheet
The four problems you will be asked
| Problem | Answers | Cost to produce them | Stack depth |
|---|---|---|---|
| Subsets of n items · pick / not-pick | 2ⁿ | O(n · 2ⁿ) | O(n) |
| Permutations of n items · by swapping | n! | O(n · n!) | O(n) |
| Combination sum · candidates reusable, target t, smallest candidate m | input-dependent | O(n^(t/m)) | O(t/m) |
| N-Queens on an n × n board · one queen per row | 92 when n = 8 | O(nⁿ) blind, O(n!) on the column rule alone | O(n) |
06 Where & why
Brute force, with a conscience
Backtracking still walks an exponential tree, and no amount of clever coding changes that. What it buys you is two things: O(n) memory while it walks, and the right to delete an entire subtree the moment the partial answer is already illegal.
Reach for it when…
Use something else when…
| Situation | Better choice | Why |
|---|---|---|
| You need the count, not the arrangements | A DP table, or a closed form | Counting the subsets that hit a target sum is O(n · t) with a table. Listing them is O(2ⁿ). |
| Many different paths reach the same state | Memoisation, or bottom-up DP | Backtracking re-solves that state once per path. Caching on the state collapses the tree into a table. |
| You want one good answer fast, not all of them | Greedy, or branch and bound | Keep the best answer so far and cut any branch whose best possible finish cannot beat it. |
| The tree resists pruning and n is large — a route through 100 cities | Heuristics, or an ILP solver | 99! candidate routes. Nothing enumerates that, so you take a good answer with a proven bound instead. |
07 Interview questions
Say these out loud
Question four is the one that separates people who have read about backtracking from people who have debugged it.
What is backtracking?
Give me the template.
Why do you need the un-choose at all?
What exactly happens if you forget the pop when generating subsets?
If you pass a fresh copy of the list into each call, do you still need the un-choose?
How big is the tree?
What is the space complexity, given there are 2ⁿ answers?
What is pruning, and how much does it actually save?
Backtracking or dynamic programming?
Is backtracking just DFS?
Where would you actually use this?
08 Practice problems
Write the un-choose first
For each one, write the three lines before you write the logic. Then count your chooses and your un-chooses and check that the two numbers match.