07. Recursion and backtracking¶
Time: ~3 hours Prerequisites: 01, 06
1. In plain words¶
Recursion is a function that solves a problem by calling itself on a smaller version of the
same problem, and stopping when the problem is small enough to answer directly. You know the
mechanics from C: each call gets its own frame on the stack with its own locals; return
pops it. What trips people up is not the mechanics but the trust: you have to write the
function assuming the recursive call already works, and only make sure of two things — the
base case is right, and every call moves toward it.
Backtracking is recursion used as a search: build a candidate solution one choice at a time, and when a choice cannot lead to a valid solution, undo it ("back track") and try the next one. Think of walking a maze with a piece of chalk: at every fork you mark a path, walk it, and if it dead-ends you walk back to the fork and take the next one. Permutations, subsets, N-queens, Sudoku and "all combinations that sum to k" are all backtracking.
Memoisation is the trick that turns exponential recursion into polynomial time when subproblems repeat: write the answer down the first time, look it up afterwards. It is the bridge to dynamic programming (module 14).
2. The real thing¶
2.1 Anatomy of a recursive function¶
R solve(State s) {
if (base_case(s)) return direct_answer(s); // 1. stop
R sub = solve(smaller(s)); // 2. trust the smaller call
return combine(s, sub); // 3. build the answer from it
}
Rules that prevent 90% of bugs:
- Write the base case first. Test it alone.
- Every recursive call must strictly reduce something (size, index, remaining budget).
- Decide what the function returns versus what it accumulates in a parameter passed by reference. Mixing the two in one function is the usual source of confusion.
- Think about the depth: it is the space complexity and, past ~100k frames, a crash.
2.2 Call stack mechanics¶
On x86-64 Linux, a call pushes the return address (8 bytes), the callee saves rbp and any
callee-saved registers it uses, then subtracts space for locals. A frame for int f(int n)
with one local is around 32 to 48 bytes; with a std::string local or an array it is far more.
The default main-thread stack is 8 MB (ulimit -s), secondary threads get 8 MB by default on
glibc (2 MB on some systems, 1 MB on Windows). So a plain recursive function overflows at
roughly 100 000 to 200 000 frames, less if frames are big. Tail-call optimisation (compiler
turns return f(n-1) into a jump) exists at -O2 but is not guaranteed in C++ — do not
depend on it. When depth can be large, convert to an explicit stack (section 2.6).
2.3 Classic recursions and their shapes¶
| Problem | Recurrence | Time | Depth |
|---|---|---|---|
| factorial, sum of list | T(n) = T(n-1) + O(1) | O(n) | n |
| binary search | T(n) = T(n/2) + O(1) | O(log n) | log n |
| merge sort | T(n) = 2T(n/2) + O(n) | O(n log n) | log n |
| naive Fibonacci | T(n) = T(n-1) + T(n-2) + O(1) | O(phi^n) ~ O(1.618^n) | n |
| subsets of n items | 2^n leaves, O(n) each to copy | O(n 2^n) | n |
| permutations of n | n! leaves | O(n n!) | n |
| tower of Hanoi | T(n) = 2T(n-1) + 1 | O(2^n) | n |
The Fibonacci row is why memoisation exists: the recursion tree has 2^n nodes but only n distinct arguments.
2.4 Backtracking template¶
void backtrack(State& state, Path& path, Results& out) {
if (is_solution(state)) { out.push_back(path); return; }
for (Choice c : candidates(state)) {
if (!valid(c, state)) continue; // prune: do not even recurse
apply(c, state, path); // choose
backtrack(state, path, out); // explore
undo(c, state, path); // un-choose: restore EXACTLY the previous state
}
}
Three things make or break it:
- Choose / explore / un-choose must be symmetric. If
applypushes and sets a flag,undopops and clears the flag. The state afterundomust be bit-for-bit what it was beforeapply. - Pruning (
valid) is where the speed is. N-queens without pruning tries n^n placements; with column/diagonal checks it visits a tiny fraction. - Avoid duplicates by construction, not by filtering at the end: for subsets, only consider
candidates with index > the last chosen index; for combinations with repeated values, sort
first and skip
v[i] == v[i-1]wheni > start.
Complexity is "number of nodes in the search tree times work per node". Say it that way.
2.5 The three canonical problems¶
Subsets (power set). At each index decide include/exclude. 2^n leaves.
void subsets(const std::vector<int>& v, std::size_t i, std::vector<int>& cur,
std::vector<std::vector<int>>& out) {
if (i == v.size()) { out.push_back(cur); return; }
subsets(v, i + 1, cur, out); // exclude v[i]
cur.push_back(v[i]); // include v[i]
subsets(v, i + 1, cur, out);
cur.pop_back(); // undo
}
Alternative "for-loop" form, which is what you use for combinations and k-subsets:
void subsets2(const std::vector<int>& v, std::size_t start, std::vector<int>& cur, Out& out) {
out.push_back(cur); // every prefix is a subset
for (std::size_t i = start; i < v.size(); ++i) {
cur.push_back(v[i]);
subsets2(v, i + 1, cur, out); // i + 1: only later elements, no duplicates
cur.pop_back();
}
}
Permutations. Choose which unused element goes in position pos. Swap-based in-place
version: swap v[pos] with each v[i], i >= pos, recurse on pos + 1, swap back. n!
leaves, O(n) to copy each: O(n * n!).
void permute(std::vector<int>& v, std::size_t pos, Out& out) {
if (pos == v.size()) { out.push_back(v); return; }
for (std::size_t i = pos; i < v.size(); ++i) {
std::swap(v[pos], v[i]); // choose v[i] for position pos
permute(v, pos + 1, out);
std::swap(v[pos], v[i]); // un-choose
}
}
N-queens. Place one queen per row. Track used columns and the two diagonal families:
row - col (constant on "\" diagonals) and row + col (constant on "/" diagonals). A
placement is valid if none of the three is taken. Three boolean arrays make the check O(1).
void queens(int row, int n, std::vector<int>& col_of, std::vector<char>& cols,
std::vector<char>& d1, std::vector<char>& d2, int& count) {
if (row == n) { ++count; return; }
for (int c = 0; c < n; ++c) {
int a = row - c + n - 1, b = row + c; // shift a to be non-negative
if (cols[c] || d1[a] || d2[b]) continue; // prune
cols[c] = d1[a] = d2[b] = 1; col_of[row] = c;
queens(row + 1, n, col_of, cols, d1, d2, count);
cols[c] = d1[a] = d2[b] = 0; // undo
}
}
n = 8 gives 92 solutions; n = 12 gives 14 200. Without the diagonal arrays you would scan all previous rows for each placement — O(n) per check instead of O(1).
2.6 Recursion to explicit stack¶
Any recursion can be made iterative by pushing what the frame holds onto a std::vector.
The mechanical translation: a struct with the parameters and a "where was I" marker; a loop
that pops, does the work, pushes continuations. In practice you rarely need the general
form; you need the two common cases:
- Tree/graph DFS: push the root; loop: pop node, process, push children. (Module 10, 13.)
- Tail recursion (
return f(smaller)): becomes awhileloop directly.
Do this when depth may exceed ~10^4 to 10^5, or in production code where a stack overflow is an unacceptable failure mode.
2.7 Memoisation¶
Cache results keyed by the arguments. For integer arguments a std::vector<long long> with a
sentinel; for composite arguments a std::unordered_map or a 2D vector.
long long fib(int n, std::vector<long long>& memo) {
if (n < 2) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}
O(n) instead of O(1.6^n). When to memoise: the recursion has overlapping subproblems (the same arguments recur) and optimal substructure (the answer for the whole is built from answers for parts). When the subproblems are all distinct (permutations, subsets), memoisation buys nothing — the search tree really is that big. Module 14 turns memoised recursion into bottom-up tables.
3. Code¶
Files: code/m07_backtracking.cpp (subsets both forms, permutations with and without
duplicates, combination sum, N-queens count and boards, letter combinations of a phone
number, word search in a grid), code/m07_memo_fib.cpp (naive vs memoised vs iterative
Fibonacci with timing and call counts, climbing stairs, grid paths with memo, and the
recursion-to-explicit-stack conversion of a tree DFS).
Highlights from m07_backtracking.cpp:
// Combination sum: unlimited reuse of candidates; pass `start` = i (not i+1) to allow reuse,
// and sort so that we can break (not continue) when a candidate exceeds the remaining target.
void combination_sum(const std::vector<int>& c, int start, int remaining,
std::vector<int>& cur, std::vector<std::vector<int>>& out) {
if (remaining == 0) { out.push_back(cur); return; }
for (int i = start; i < static_cast<int>(c.size()); ++i) {
if (c[i] > remaining) break; // sorted: everything after is bigger too
cur.push_back(c[i]);
combination_sum(c, i, remaining - c[i], cur, out);
cur.pop_back();
}
}
// Permutations with duplicate values: sort, and at each position only use the FIRST unused
// copy of a value. The `used[i-1]` test is the standard duplicate guard.
if (used[i] || (i > 0 && v[i] == v[i-1] && !used[i-1])) continue;
Highlights from m07_memo_fib.cpp:
// Naive: counts calls so you can see the explosion. fib(30) makes 2.7 million calls.
long long fib_naive(int n, long long& calls) {
++calls;
return n < 2 ? n : fib_naive(n - 1, calls) + fib_naive(n - 2, calls);
}
// Memoised: 2n-1 calls.
// Iterative: two variables, O(1) space, the bottom-up version from module 14.
4. Under the hood (senior-level)¶
4.1 What a frame costs¶
g++ -O2 inlines small leaf functions and converts simple tail recursion to loops, so
fib_iterative and a tail-recursive sum compile to the same code. But any function that
makes two recursive calls (Fibonacci, merge sort, tree traversal) cannot be a loop without an
explicit stack, and the compiler will not invent one. For those, the cost per call is: call
(push return address, ~1 cycle but a branch), prologue (save registers, adjust rsp), the
work, epilogue, ret (a predicted indirect branch; return-address prediction is good unless
recursion is deep). Roughly 5 to 15 cycles overhead per call. A backtracking search that makes
10^8 calls spends ~1 second in call overhead alone. Iterative versions of hot inner recursions
pay off; for depth-20 searches it does not matter.
4.2 Stack overflow in a crash dump¶
The symptom is SIGSEGV with a faulting address just below the stack's guard page, and a
backtrace with thousands of identical frames. gdb: bt -10 shows the bottom. In WinDbg
!analyze -v reports STACK_OVERFLOW and kb shows the repetition. Causes in production
code: unbounded recursion on malicious input (a JSON parser given 100 000 nested [), a
recursive destructor on a long list, mutual recursion between two callbacks. Defence: depth
limits on recursive parsers, iterative destructors, explicit stacks for DFS.
4.3 Why memoisation changes the complexity class¶
The recursion tree for fib(n) has ~1.6^n nodes but only n+1 distinct labels. Memoisation
makes each distinct label compute once, so the total work is (number of distinct states) x
(work per state excluding recursive calls). This "states x transitions" formula is how you
compute DP complexity in module 14, and it is the same number for memoised top-down and
tabulated bottom-up.
4.4 Backtracking and pruning in real engines¶
A SQL query planner choosing a join order for 10 tables faces 10! orderings; PostgreSQL's standard planner does dynamic programming over subsets (2^10 states) with pruning, and switches to a genetic algorithm (GEQO) above 12 tables. SAT solvers are backtracking with clever pruning (unit propagation, clause learning). Regular expression engines that backtrack (PCRE, Java's) can hit exponential time on pathological patterns — the reason RE2 and Rust's regex use automata instead. If you ever see a regex take seconds, that is backtracking without memoisation.
5. In production¶
- Recursive descent parsers for message formats and config files: each grammar rule is a function that calls the functions for its sub-rules. Clean, but put a depth counter in and fail at, say, 1000 to defend against nested-bracket attacks.
- Directory walking, tree serialisation, dependency resolution are recursions over trees and DAGs. For DAGs, memoise ("visited") or you re-walk shared subtrees exponentially.
- Retry with backtracking in distributed transactions: try a plan, on conflict undo and try the next. Same choose/explore/undo shape; the undo is the hard part to get right there too.
- Debugging a backtracking bug. The state after
undois not identical to the state beforeapply: a counter not decremented, a flag not cleared, a vector not popped because an earlyreturnskipped the cleanup line. Put the cleanup in an RAII guard (a small struct whose destructor undoes) or structure the code soundocannot be skipped. In an interview, walk through oneapply/undopair out loud. - Memoisation as caching in a server: a function
price(account, date)called a million times a day with 10 000 distinct arguments is a memoisation opportunity, with the extra questions production adds: invalidation, bounded size (LRU, module 05) and thread-safety.
6. Try it¶
- (easy) Write recursive
sum,max, andreverseon astd::vector<int>by index. Then write the loop versions. Done when you can state the recurrence and depth for each. - (easy) Generate all binary strings of length n. Done when n = 3 prints 8 strings in lexicographic order and you can say why the order comes out that way.
- (medium) Combinations of k out of n (
C(n,k)results) using the for-loop subset template withstart. Add the prune "if remaining slots > remaining candidates, return". Done whenC(20,10)runs instantly and the count is 184 756. - (medium) Generate all valid parentheses strings for n pairs. Hint: track
openandclosecounts; you may add(ifopen < n,)ifclose < open. Done when n = 3 gives 5 strings. - (hard) Sudoku solver with backtracking and the "fewest candidates first" heuristic for cell order. Done when it solves a hard puzzle from any newspaper in under 100 ms and you can report how many placements it tried with and without the heuristic.
- (hard) Convert the recursive
subsetsfunction to an iterative one with an explicit stack of(index, cur_size)frames. Done when both produce identical output for n = 15 and the iterative version handles n = 10 000 with a single subset request without overflowing (generate lazily — do not store 2^10000 results).
7. Interview questions¶
Q: How do you approach writing a recursive function? Define what it returns for a given input, write the base case, make the recursive call on a strictly smaller input, trust its result and combine. Check the depth for stack safety.
Q: Permutations vs subsets — what is the structure of each search tree? Subsets: depth n, two branches per level (include/exclude), 2^n leaves. Permutations: depth n, n-i branches at level i, n! leaves. Both are O(n) work per leaf to copy out the answer, so O(n 2^n) and O(n n!).
Q: How does N-queens prune? One queen per row by construction; before placing in column c, O(1) checks on three boolean arrays: column c, diagonal row-c, anti-diagonal row+c. Any conflict skips the recursive call. This cuts the search from n^n to a tiny fraction.
Q: How do you avoid duplicate results in combination problems with repeated values?
Sort the input and, within one level of the loop, skip a value equal to the previous value
(i > start && v[i] == v[i-1]). For permutations use !used[i-1] so only the first unused
copy is chosen at each position.
Q: When does memoisation help and when does it not? It helps when the same subproblem arguments recur (Fibonacci, grid paths, edit distance): exponential becomes polynomial. It does not help when every call has distinct arguments (enumerating all permutations): the output itself is exponential.
Q: What is the space complexity of recursive backtracking? O(depth) frames plus the current path; for subsets and permutations that is O(n), not counting the output.
Q: Your recursive function crashes with SIGSEGV on large input. Why and what do you do? Stack overflow: each frame uses stack space and the default stack is 8 MB. Convert to an iterative version with an explicit heap-allocated stack, or bound the depth, or (rarely) enlarge the thread stack when creating the thread.
8. Recap¶
- Recursion: base case first, strictly smaller call, trust and combine; depth = space.
- Default stack is 8 MB; ~10^5 frames overflow it; convert deep recursion to an explicit stack.
- Backtracking = choose, explore, un-choose; undo must restore the exact prior state.
- Pruning is where speed comes from; duplicates are avoided by construction (start index,
sorted skip,
used[i-1]). - Subsets: include/exclude or for-loop with
start; 2^n leaves. - Permutations: swap in place; n! leaves. Combination sum: pass
ito reuse,i+1otherwise. - N-queens: one per row; column and two diagonal arrays give O(1) validity checks.
- Memoise when subproblems overlap; complexity becomes (distinct states) x (work per state).
- Naive Fibonacci is O(1.6^n); memoised is O(n); iterative is O(n) time, O(1) space.
- Depth-limit recursive parsers; backtracking regex engines are exponential on bad input.