python/volume-3/chapter-41ch 41 / 64 · 10 min

41Recursion & backtracking — searching a tree of choices

In Chapter 40 we learned to win by remembering — dynamic programming, where you cache a subproblem's answer so you never solve it twice. Now we drop one level, down to the raw search that the memory was quietly speeding up. Some problems have no clever formula. The only honest move is to try the possibilities — arrange the songs, place the queens, fill the grid. Do that naively and you drown in an ocean of combinations. So here's the plan: recursion to explore those possibilities one choice at a time, backtracking to undo a choice and try the next, and pruning to shut the door on billions of dead ends before you ever walk through them. The whole way through, we keep asking the one question that matters — how do you search a tree with more leaves than there are atoms in the room, and still finish before lunch? By the end, you'll look at any "find a valid or optimal arrangement" and see a tree of choices — and know exactly where to prune it.

Recursion: shrink the problem until the answer is obvious Every choice sprouts a branch — the combinatorial explosion Backtracking: walk the tree, undo on the way back Pruning: the whole game
scroll to begin

01Recursion: shrink the problem until the answer is obvious

Let's start with the one idea the whole chapter stands on. Here's recursion: solve a big problem by solving a smaller version of the same problem, and keep shrinking until the version is so tiny the answer needs no thought at all. That tiny version is the base case — the floor you fall to and stop. Every step above it is the recursive case — do a sliver of the work yourself, then hand the rest to a smaller copy of yourself.

Watch it work on something concrete: the total length of a playlist. The total of [200, 247, 245] is just "the first song, plus the total of everything after it." That second half is the same problem on a shorter list. Shrink it far enough and you reach the empty playlist, whose total is plainly 0. That's the whole recipe — no loop, no cleverness, just a problem that eats itself one item at a time.

shrink.pypython
def total(songs):
    if not songs:                       # BASE CASE: empty list → answer is 0
        return 0
    return songs[0] + total(songs[1:])  # first + total of the REST (a smaller list)

print(total([200, 247, 245]))           # 692
print(total([]))                        # 0

Lines 2–3 are the floor: an empty playlist sums to 0, no recursion needed. Line 4 is the shrink: peel off songs[0], then ask the same function to total songs[1:] — a list one shorter. Run on CPython 3.12.7, total([200, 247, 245]) returns 692 (200 + 247 + 245) and total([]) returns 0. Notice there is no loop anywhere — the repetition is the function calling itself.

Where does the machine keep its place across all those nested calls? On the call stack — Volume 1, chapter 9. Each call gets a frame: a scrap of memory holding that call's own songs, frozen mid-addition, waiting for its smaller copy to return a number. The frames pile up until the base case, then collapse back down. Each one finishes its + with the value handed up from below. The call stack is recursion's bookkeeping — you don't manage the partial state, the stack does.

calls stack UP (each waits on a smaller copy) total([200,247,245]) = 200 + ? total([247,245]) = 247 + ? total([245]) = 245 + ? total([]) = 0 ← BASE CASE returns collapse DOWN with values: ↑ 692 ↑ 492 ↑ 245 ↑ 0 unwinding Four frames alive at the deepest point — each holds its own half-done sum. The stack IS the memory of "where was I?" — you never wrote a line to manage it.
Fig — recursion grows the call stack on the way down and collapses it on the way up; each frame is a frozen partial state the machine remembers for you.
A recursion with no floor never lands
Forget the base case (or fail to shrink toward it) and the stack grows without end. CPython caps it — sys.getrecursionlimit() is 1000 by default — and stops you with RecursionError: maximum recursion depth exceeded (both quoted from a real 3.12.7 run). Every recursion needs a floor and a guarantee it moves toward it.

Summing a list only ever shrinks one way — chop the head, recurse on the tail, a straight line down. But what if at each step you face several options? Then the single line splits into a branching tree — and trees grow fast. →

02Every choice sprouts a branch — the combinatorial explosion

Recursion becomes powerful — and dangerous — the moment each step offers more than one option. Then it stops drawing a line and starts drawing a tree of choices. The root is "nothing decided yet," each branch is one option, and each leaf at the bottom is one complete solution. To generate every possibility, you walk every branch.

Count the leaves and you feel the danger. Arrange 3 songs in order: the first slot has 3 candidates, then 2 remain for the second, then 1 — that's 3 × 2 × 1 = 6 = 3! orderings (a permutation is one ordering of all the items). Pick a subset instead — each song is independently in or out — and you get 2 × 2 × 2 = 2³ = 8 subsets. We didn't just assert those numbers. We counted the choices at each level and multiplied. That multiplication is the whole story of why brute force dies.

Why so fast? Because it doesn't add — it multiplies. Ten songs: 10! = 3,628,800 orderings, still nothing. Twenty songs: 20! = 2,432,902,008,176,640,000 — 2.4 quintillion. At a billion arrangements per second, that's about 77 years just to list them (both figures computed in Python below). Add one more item and you multiply the wait again. This is the combinatorial explosion, and it is the reason the rest of the chapter exists.

Wait —
if arranging just 20 items can't be brute-forced in a lifetime, how does a delivery app find a near-best route through thousands of stops in under a second?
start A B C 3 first choices B C A C A B ×2 ABCACB BACBCA CABCBA 3 × 2 × 1 = 6 leaves = 3! permutations multiply the branches at each level — that product is why brute force explodes
Fig — a choice tree: multiply the options at each level to count the leaves. Six here; 2.4 quintillion at twenty items.
InteractiveSlide the input size — watch brute force die
count of possibilities (bars are log-scaled: each 21px ≈ ×10) orderings n! subsets 2ⁿ to list all orderings at 1 billion/sec: …
8
n! grows faster than any exponential — that gap between the red and amber bars is the wall you hit first.
"It runs on my example" is a trap here
A permutation solver that feels instant on 8 items has done 40,320 steps. The same code on 15 items faces 1.3 trillion. Nothing in the code changed — the tree got taller, and each level multiplied the last. Always ask what happens at the input sizes you'll actually see.

If listing 20! possibilities takes 77 years, brute force is hopeless — yet solvers crack far harder-looking puzzles in milliseconds. Their secret isn't a faster computer. It's refusing to visit most of the tree. →

03Backtracking: walk the tree, undo on the way back

Backtracking is how you actually walk a choice tree without building it all in memory at once. You go depth-first: make one choice, dive deeper as if it were final, and when you've either found a solution or run out of options, step back and undo that choice so the next branch starts from a clean slate. Three beats, over and over: choose → explore → un-choose. That un-choose is the "back" in backtracking. It restores the shared partial state, so the sibling branch isn't polluted by the one you just abandoned.

Here it is generating every subset of the playlist. At each song you branch twice: skip it, or take it. Two options × n songs = 2ⁿ leaves, exactly as we counted.

backtrack.pypython
def subsets(items):
    out, chosen = [], []
    def bt(i):
        if i == len(items):        # reached the bottom: record this subset
            out.append(chosen.copy())
            return
        bt(i + 1)                  # BRANCH 1: skip items[i]
        chosen.append(items[i])    # CHOOSE items[i]
        bt(i + 1)                  # BRANCH 2: explore with it taken
        chosen.pop()               # UN-CHOOSE — restore state for the caller
    bt(0)
    return out

print(subsets(["Levels", "Titanium", "Wake Me Up"]))

Lines 4–6 are the base case: once i walks past the last song, chosen holds one complete subset, so we save a copy (not the live list — it's about to change). Line 8 explores the "skip this song" branch. Lines 9–11 are the choose/explore pair: append the song, then recurse to decide the rest with it included. Line 12 is the crucial un-choose: chosen.pop() removes the song, so that when control returns to the caller, chosen looks exactly as it did before this call touched it. Run on 3.12.7, the call produces all 8 subsets — from [] through ['Levels', 'Titanium', 'Wake Me Up']. Swap the two-way branch for a "place each item that's still unused" loop and the same pattern gives all 6 permutations of [1, 2, 3]: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] (also a real run).

at a node an option remains? CHOOSE (append) EXPLORE (recurse) UN-CHOOSE (pop) One shared list. Choose adds, explore descends, un-choose removes — so every branch starts from the exact state its parent had.
Fig — the backtracking cycle. The un-choose step is what lets a single mutable list serve the whole tree without corruption.
The template you'll reuse forever
Almost every backtracking solution is: if solution complete → record it; else for each option → choose, recurse, un-choose. Permutations, subsets, N-queens, sudoku, maze-solving — same skeleton, different "options" and "complete." Learn it once; recognise it everywhere. (This is depth-first search on an implicit tree — the same DFS you'll meet on real graphs in chapter 44.)

So far we still visit all 2ⁿ leaves — we've organised the search, not shortened it. The real magic is a single extra if: check a partial solution before descending, and cut whole subtrees you can prove are hopeless. →

04Pruning: the whole game

Here is the idea that separates a toy from a solver. As you build a solution one choice at a time, keep asking: can this partial choice possibly complete into a valid answer? The instant the answer is "no," abandon the entire subtree beneath it. Every arrangement that started with this bad prefix is dead, so you never generate a single one of them. That is pruning, and it is the difference between exploring 10⁹ dead ends and 10³ live ones.

The classic showcase is the N-queens puzzle: place N chess queens on an N×N board so none attacks another (no two share a row, column, or diagonal). Put one queen per row — that rules out any row-clash for free — and at each row try each column. Here's the pruning rule: the moment a queen conflicts with one already placed, skip that column entirely and don't recurse into the rows below it. A whole doomed subtree vanishes.

Count the savings on the standard 8×8 board and watch three numbers fall off a cliff. Place 8 queens on any of 64 squares blindly: that's C(64,8) = 4,426,165,368 placements — 4.4 billion (computed in Python). Be a little smarter — one queen per row, all column combinations — and it's 8⁸ = 16,777,216 leaves. Now add the pruning if, and backtracking touches just 2,057 partial placements to find all 92 solutions. That's over 8,000× fewer nodes than even the row-by-row brute force, and two million times fewer than the naïve count. Same puzzle. One conditional. The tree barely grows before pruning mows it down.

place one queen per row · a conflict prunes the whole branch below it ✕ = attacked → forbidden row 0 placed CONFLICT prune ✂ The red subtree is never built. Every board that starts with the conflict is killed at birth.
Fig — pruning kills a branch before descending. One if at the top of a subtree erases everything beneath it.

Turn the knob yourself. Slide N below and compare the brute-force tree against what backtracking actually visits. The gap is the wasted work you never do.

InteractiveN-queens: nodes visited vs the tree you skipped
search size (bars log-scaled: each 28px ≈ ×10) brute force Nᴺ backtracking pruning skips …
8
The backtracking count is computed live by actually running the solver in your browser — same logic, same numbers as the Python run.
↺ The thing people get backwards
People hear "backtracking" and think slow, exhaustive, tries everything. Backwards. The brute force tries everything. Backtracking's entire reason to exist is to not try everything — to prove a prefix is doomed and delete its whole subtree unseen. The exhaustive tree is the enemy; pruning is the weapon. A backtracking solver's speed is measured by how much it refuses to look at.

✗ The myth

N-queens has an exponential worst case, so backtracking is a dead end — you might as well not bother.

✓ The reality

Worst-case exponential ≠ typical-case slow. Good pruning makes the real search tiny: 8-queens needs 2,057 nodes, not 16 million. The worst case is a ceiling you rarely touch, not the bill you actually pay.

Pruning tames N-queens because a bad prefix is provably hopeless. But some problems hide their answer so well that no pruning rule cracks the tree open. What then? →

05When the tree won't shrink: NP-hard, and taming the beast

For some problems, nobody has found a pruning rule — or any trick — that reliably avoids the exponential, and there's deep evidence none exists. These are the NP-hard problems. Take the travelling salesman: visit N cities by the shortest possible loop. With 20 cities there are (20−1)!/2 = 60,822,550,204,416,000 distinct tours — 60 quadrillion, computed in Python. Or SAT (is there an assignment of true/false that makes a logic formula true?), which has 2ⁿ assignments. For either one, there is no known algorithm that beats the exponential in the worst case. The tree is genuinely dense with plausible-looking branches.

And yet we solve big instances every day. Not with a magic fast algorithm, but by taming the tree with three moves you can name:

This is why real SAT solvers chew through formulas with millions of variables, why sudoku apps solve instantly, and why chess AI before neural networks was backtracking the game tree with alpha-beta pruning — the same "this branch can't beat what I've found, so cut it" logic. The exponential never went away. It got outmanoeuvred.

best = 42 bound = 51 bound = 60 > 42 → cut ✂ explore explore If a subtree's BEST possible score can't beat what you already hold, you skip the whole thing — pruning by bound, not just by validity.
Fig — branch and bound: an optimistic estimate that still loses to the current best lets you cut a subtree without exploring it.
Catastrophic backtracking — the bug that DDoSes you
Regular-expression engines backtrack too, and a careless pattern turns that into an exponential trap. The pattern (a+)+$ on a string of a's that ends in a non-match forces the engine to try every way of splitting the a's. Timed on this machine (3.12.7; values are hardware-dependent): 20 a's took ~0.06 s, 24 a's ~0.98 s, 26 a's ~4.1 s, 28 a's ~17 s — each extra character roughly doubles the time. That's a real denial-of-service class called ReDoS: a 30-character input can hang a server. Same tree explosion, hiding inside a one-line regex.
Where you meet this
Backtracking with pruning is the engine inside constraint solvers and SAT/SMT solvers (hardware verification, program analysis), puzzle and game engines (sudoku, crosswords, chess/Go search with alpha-beta), regex matchers (and their ReDoS blowups), compilers and type checkers (searching for a valid parse or type assignment), route and schedule optimisers (branch and bound), and every "find a valid configuration" wizard you've ever clicked through.
The deeper cut — why visit-order doesn't change the answer but changes the speed

Backtracking explores the same set of nodes no matter what order you try the children in — unless you prune by bound or stop at the first solution. Then order is everything. Suppose you want the single best tour. If your first dive happens to find a near-optimal tour early, your "best so far" is strong, so branch-and-bound's cut condition (bound ≥ best) fires on almost every later subtree, and you prune a whole forest. If instead you find a terrible tour first, your bound is weak and you explore far more. Good heuristics exist to make that first dive a good one: nearest-neighbour for TSP, most-constrained-variable for sudoku ("fill the cell with the fewest legal options first"), killer/history heuristics for chess. None of them change what the optimum is. They change how quickly you can prove the rest can't beat it. That's the practical craft of solving exponential problems: you can't shrink the tree's size, so you shrink the part of it you're forced to look at.

Branch and bound prunes by "can't win." But there's a fourth weapon, and it's the bridge to the crown jewel of the last chapter: what if the tree keeps re-solving the same subproblem? →

06Backtracking + memory = dynamic programming (the mental move)

Walk a choice tree and you'll often notice the same subproblem showing up on many different branches. The naïve recursive Fibonacci is the purest example (stepped in traces T29/T30): fib(5) calls fib(3) down two separate paths, and each one rebuilds the same subtree from scratch. The tree is exponential not because the answer is hard, but because you keep re-deriving what you already knew.

The fix is one line of thinking: remember the answers you've already computed. Cache each subproblem's result the first time, and on every later encounter, read it instead of recomputing. That collapses the exponential tree into a linear walk. Backtracking that caches overlapping subproblems is dynamic programming — the previous chapter's crown jewel is exactly this chapter's search plus a memo. Look for two conditions: the subproblems overlap (worth caching), and the best full answer is built from best sub-answers (safe to build up).

InteractiveFibonacci without a memo — the calls it wastes re-deriving what it already knew
times a fib(k) gets computed · bars log-scaled (each 34px ≈ ×10) naïve fib(n) with a memo THE PRICE OF FORGETTING 2,692,537
30
The naive tree re-solves the same fib(k) on branch after branch. A memo solves each one once — the red bar is the work; the green bar is the answer.
f5 f4 f3 f3 f2 f2 f1 f3 computed twice f2 computed three times …the tree re-solves what it knows add a memo memo f2 → 1 f3 → 2 f4 → 3 each solved once Overlapping subproblems + a cache = dynamic programming (chapter 40).
Fig — when the same subproblem recurs across branches, a memo turns an exponential search into a linear one — that's the DP bridge.

That reframing is the transferable move — the 1% habit this chapter installs. Faced with any "find a valid or optimal arrangement," don't reach for a formula. Ask three questions in order: (1) Can I frame this as building a solution one choice at a time — a tree of choices? (2) Can I prune — is there a cheap test that proves a partial choice is hopeless, so I skip its subtree? (3) Do subtrees repeat — should I memoise? Choice-tree, then prune, then memo. That sequence turns "impossible, 2.4 quintillion cases" into "feasible, a few thousand."

You already run a rough version of this off-screen. Take debugging: you form a hypothesis, follow it, and the moment the evidence contradicts it you abandon that whole line of theories instead of stubbornly exploring a dead branch. That's pruning. Or planning a route through errands, ruling out a plan the instant it can't fit your lunch meeting — pruning again. Backtracking isn't an exotic algorithm. It's the disciplined, countable version of how a careful mind already searches. Learn to name the tree and the prune, and you can attack problems that have no formula at all.

The one-sentence takeaway
Recursion explores a tree of choices; backtracking is that exploration with an undo; pruning is the art of never entering the branches that can't win — and memoising the branches you'd otherwise re-solve is where backtracking becomes dynamic programming.

Backtracking searches when there's no formula. Next we return to a problem that does have fast algorithms — sorting — and meet a stunning result: a hard mathematical ceiling proving you can never sort by comparisons faster than n log n… and the clever trick that sneaks under it anyway. A beautiful impossibility, in the next chapter. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 41, in working code

Twelve tiny programs that never guess. Each one shrinks a problem to its floor, walks a tree of choices, or prunes it — printing the exact same counts on every machine on Earth, so you can watch recursion, backtracking, and the combinatorial explosion play out in plain integers.

Shrink until the answer is obvious
Recursion is solving a big problem by solving a smaller copy of itself, down to a base case that needs no thought. The call stack remembers where you were.
Every choice sprouts a branch
The moment a step offers more than one option, recursion draws a tree. Count the branches at each level and multiply — that product is why brute force dies.
Choose, explore, un-choose
Backtracking walks the choice tree depth-first: make a choice, dive as if it were final, then undo it so the sibling branch starts clean. That undo is the 'back'.
One if mows the tree down
You can't shrink the tree's size, but you can refuse to visit most of it. Check a partial solution before descending and whole hopeless subtrees vanish — that is pruning.
Backtracking + memory = dynamic programming
Walk a choice tree and the same subproblem often reappears on many branches. Cache each answer once and the exponential re-derivation collapses to linear — that mental move is DP.
end of chapter 41 · six sections
iolinked.com
Written by Ajai Raj