Data Structures & Algorithms · Data Structures & Algorithms
Recursion and Backtracking
Recursive problem solving and backtracking to explore permutations, subsets and combinations.
Eight concepts. Recursion needs a stopping base case and pays O(d) stack for depth d; backtracking is the choose–explore–unchoose loop that builds permutations, subsets and combinations — and pruning is what keeps N-Queens from drowning in n! dead boards.
- Data Structures & Algorithms
- Medium level
- 8 concepts
- 5 practice questions
1Recursion needs a base case
A recursive function solves a problem by calling itself on a strictly smaller input. That only terminates if some inputs hit a base case that returns without another call. A missing or wrong base case is the usual cause of infinite recursion and stack overflow — write the base case before the recursive branch.
Figure. Each call waits on a smaller one. The chain ends at fact(1) — that base case is what stops the stack from growing forever.
How a recursive call works
- ShrinkIdentify a smaller instance of the same problem (n−1, a shorter suffix, a child subtree).
- BaseWhen the input is small enough that the answer is immediate (n ≤ 1, empty list, null node), return it — no further call.
- CombineUse the recursive result to finish the current frame (multiply by n, append a node, merge sorted halves).
Factorial
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)Factorial of 4
Compute 4! with the recurrence n! = n · (n−1)! and base 0! = 1! = 1.
- 1! (base)1
- 2! = 2 · 1!2
- 3! = 3 · 2!6
- 4! = 4 · 3!24
Pro tip. If you cannot name the base case in one line, the recursion is not ready to code — interviews fail more often on a wrong stop than on a wrong combine.
What is the most common cause of stack overflow in a recursive solution?
- Using a language without tail-call optimisation
- A missing or incorrect base case that never stops the calls
- Returning a value from the base case
- Calling the function on a smaller input
Without a correct stop, every call spawns another call and the stack grows without bound. Smaller inputs and returning from the base case are what make recursion safe.
2Call stack costs O(d) space
Every live recursive call occupies a stack frame. If the deepest chain of calls has depth d, recursion uses O(d) stack space on top of any heap structures you allocate. Deep trees, long linked lists and large n in a linear recursion can overflow the language's call-stack limit even when the algorithm is asymptotically fine in time — peak space tracks the longest chain, not how many calls already returned.
Figure. Four frames live at once for fact(4). Space tracks depth, not how many factorials you will eventually finish.
What the stack holds
- PushEntering a call stores locals, arguments and the return address — one frame.
- DepthWhile that call waits on a child, both frames stay live. Peak space tracks the longest chain, not the number of completed calls.
- PopReturning frees the frame. After the top-level return, stack cost is gone; heap allocations may remain.
Peak depth for fact(4)
Trace stack depth while computing fact(4) with the usual one-call-per-level recurrence.
- enter fact(4)depth 1
- enter fact(3)depth 2
- enter fact(2)depth 3
- enter fact(1) basedepth 4
- peak frames before any return4 = O(d)
Pro tip. When depth may hit tens of thousands, rewrite with an explicit stack or a loop — the algorithm can stay the same while the call-stack limit no longer applies.
A recursive walk down a linked list of length n makes one call per node. Stack space is:
- O(1) because each call returns immediately after the next
- O(n), matching the length of the call chain
- O(\log n) like binary search
- O(n^2) because frames multiply
The chain is n frames deep before the null base case returns. Frames do not multiply into n^2; they stack linearly.
3Backtracking abandons dead partials
Backtracking builds a candidate solution one choice at a time and abandons a partial as soon as it cannot lead to a valid complete answer. That is search with undo: explore a branch, and if a constraint fails — or the branch finishes — revert the last choice and try the next option. Generate-all-subsets, permutations, combinations, N-Queens and Sudoku share this shape.
Figure. Backtracking drops a partial as soon as a constraint fails, then undoes that choice and tries the next option.
What backtracking does
- ExtendAppend one legal choice to the current partial (a number into a subset, a queen into a row, a digit into a cell).
- TestIf the partial already violates a constraint, do not recurse — that whole subtree is dead.
- UndoAfter exploring (or rejecting) a choice, remove it so the next sibling option sees a clean partial.
Backtracking differs from plain brute-force enumeration mainly because it:
- Never uses recursion
- Abandons a partial candidate as soon as it cannot be completed validly
- Always runs in linear time
- Only works on sorted arrays
The defining move is early abandon (and undo). Recursion is common but not required; runtime is often exponential in the search space.
4Choose, explore, unchoose
The core backtracking loop is three lines of intent: make a choice, recurse to explore every completion of that choice, then undo the choice so the next option can try. Interviews phrase this as choose / explore / unchoose. Getting the undo wrong — forgetting to pop, clear a bit, or free a column — is how solutions silently corrupt later branches.
Figure. Backtracking is choose → explore → unchoose: undo the mutation before trying the sibling branch.
One iteration of the loop
- ChooseMutate the shared state: push into path, mark a column used, place a digit.
- ExploreRecurse to the next index / row / cell. Record a solution when the base case says the partial is complete.
- UnchooseExactly reverse the mutation before trying the next candidate at this level.
Template
def backtrack(path, options):
if is_solution(path):
record(path)
return
for choice in options:
if not valid(path, choice):
continue
path.append(choice) # choose
backtrack(path, next_options)
path.pop() # unchooseAfter recursing on a choice in backtracking, the next sibling option is safe only if you:
- Allocate a brand-new path list on every call and never mutate shared state
- Undo the mutation (pop / unmark) before trying the next choice
- Sort the options array in place
- Double the recursion depth
Shared mutable state must be restored. Copying the path every time also works but is a different (often slower) style — the classic template relies on undo.
5Subsets: include or exclude each element
The power set of n elements has 2^n members. Backtracking generates them with a binary decision at each index: include nums[i] in the current subset, recurse, then exclude it and recurse. When the index reaches n, record a copy of the path. Time is \Theta(2^n) to list every subset (more if you count the cost of copying each path).
Figure. Include/exclude tree for nums = [1, 2]: four leaves, one per subset. The ledger's n = 3 tree is the same shape one level deeper — eight leaves.
Include / exclude
- Index iDecide the fate of nums[i] — in or out — before moving to i+1.
- Include branchAppend nums[i], recurse on i+1, then pop (unchoose).
- Exclude branchRecurse on i+1 with the path unchanged. At i == n, append path[:] to the answer.
All subsets
def subsets(nums):
ans, path = [], []
def bt(i):
if i == len(nums):
ans.append(path[:])
return
path.append(nums[i])
bt(i + 1) # include
path.pop()
bt(i + 1) # exclude
bt(0)
return ansPower set of [1, 2, 3]
Count the subsets generated by include/exclude on nums = [1, 2, 3].
- decisions at index 02 branches
- each extends at index 12 × 2 = 4 partials
- each extends at index 24 × 2 = 8 leaves
- 2^n with n = 38 subsets
Pro tip. Subset generation is the canonical backtracking drill: every 'generate all subsets / combinations under constraints' prompt is this tree with extra validity checks.
How many subsets does include/exclude generate for an array of length 4 (no duplicate-skipping)?
- 4
- 8
- 16
- 24
2^4 = 16. Each element independently in or out.
6Permutations cost n!
A permutation is an ordering of all n elements. There are n! of them. Backtracking builds one by choosing any unused element for the next position, recursing, then unchoosing. Writing each permutation of length n costs O(n) work to copy, so listing them all is O(n \cdot n!) — that factorial blow-up overtakes the subset tree once n grows.
Same include/exclude tree shape as Subsets: include or exclude each element, except every level picks the next unused element — n choices, then n-1, …, for n! leaves.
Fill the next slot
- Unused poolAt depth k, any of the remaining n−k elements may occupy path[k].
- Choose onePlace it, mark used (or swap into position), recurse to k+1.
- BaseWhen k == n, record path. Unchoose before the next candidate at this depth.
All permutations
def permute(nums):
ans, path, used = [], [], [False] * len(nums)
def bt():
if len(path) == len(nums):
ans.append(path[:])
return
for i, x in enumerate(nums):
if used[i]:
continue
used[i] = True
path.append(x)
bt()
path.pop()
used[i] = False
bt()
return ansPermutations of [1, 2, 3]
Count the complete orderings of nums = [1, 2, 3].
- choices for position 03
- then position 12 per branch
- then position 21
- 3 × 2 × 1 = 3!6 permutations
Pro tip. If the prompt says 'all orderings' or 'next permutation' territory at generation scale, reach for the used-set / swap template — not include/exclude.
For n = 5 distinct items, the number of permutations is:
- 25
- 32
- 120
- 125
5! = 120. 2^5 = 32 is the subset count, a different tree.
7Pruning: N-Queens
Pruning cuts a branch the moment a constraint fails, before recursing. In N-Queens you place one queen per row and skip any column whose column or either diagonal is already occupied. Without pruning the search is worst-case O(n!) row–column assignments; with set-based conflict checks, whole invalid subtrees disappear — for n = 4 only two boards survive.
Figure. One of the two n = 4 solutions: queens in columns [1, 3, 0, 2] (0-based). No shared column or diagonal — every other column choice at some row was pruned.
Place a queen
- Row rTry each column c in 0…n−1.
- PruneReject c if c, (r−c), or (r+c) is already in the occupied sets — do not recurse.
- Descend / undoMark the three sets, recurse to r+1; on return, unmark. When r == n, count a solution.
Count N-Queens
def total_n_queens(n):
cols, d1, d2 = set(), set(), set()
def bt(r):
if r == n:
return 1
count = 0
for c in range(n):
if c in cols or (r - c) in d1 or (r + c) in d2:
continue
cols.add(c); d1.add(r - c); d2.add(r + c)
count += bt(r + 1)
cols.remove(c); d1.remove(r - c); d2.remove(r + c)
return count
return bt(0)N = 4 solutions
How many ways can four queens sit on a 4×4 board with no two attacking?
- place row by row with col + diagonal setsinvalid cols skipped
- complete boards found2
- column lists (0-based)[1,3,0,2] and [2,0,3,1]
Pro tip. Track occupied columns and both diagonals in sets for O(1) conflict checks — pruning is only as cheap as the test you run before each recurse.
Coding lab. Four queens, two boards runs in the app, with checks on your output.
For N-Queens with n = 4, the number of distinct solutions is:
- 0
- 1
- 2
- 24
Exactly two boards: column sequences [1,3,0,2] and [2,0,3,1]. Twenty-four is 4!, the unpruned assignment count scale, not the answer count.
8Combinations, and when depth is the danger
Combinations of k out of n ignore order: backtracking still chooses or skips, but stops once the path length is k (or once remaining elements cannot fill the quota). There are \binom{n}{k} results. Separately, any deep recursion — even a correct one — can blow the call stack; convert that walk to an explicit stack or a loop when depth may exceed the runtime limit.
Read the comparison table: combinations stop at path length k for \binom{n}{k} results; any deep recursion that may exceed the call stack should move to an explicit stack or loop.
Two takeaways
- CombinationsStart index s; either take nums[i] and advance with k−1 left, or skip to i+1 with k unchanged. Record when k hits 0.
- CountLeaves equal \binom{n}{k}, not 2^n and not n! — order does not matter and size is fixed.
- Depth escapeIf the call chain can be long (linked-list recursion, skewed tree), keep the same logic on a heap-allocated stack or rewrite as iteration.
| Goal | Count | Decision |
|---|---|---|
| Subsets (all sizes) | 2^n | Include / exclude each |
| Permutations | n! | Next unused element |
| Combinations C(n,k) | \binom{n}{k} | Take / skip with budget k |
| N-Queens boards | sequence A000170 | Row←column with prune |
C(4, 2)
How many 2-element combinations from {1, 2, 3, 4}?
- pairs starting with 1{1,2},{1,3},{1,4}
- pairs starting with 2{2,3},{2,4}
- pairs starting with 3{3,4}
- binom(4,2) = 4!/(2!2!)6
Pro tip. Any 'generate all subsets / permutations / combinations' prompt is backtracking — pick the row of this table first, then drop in the choose/recurse/unchoose template.
You need every unordered pair from n distinct items. The number of results is:
- 2^n
- n!
- \binom{n}{2}
- n^2
Unordered pairs are combinations of size 2. Subsets count all sizes; permutations order them; n^2 counts ordered pairs with replacement-style overcount.
Notes
- Recursion Basics: A function calls itself on smaller inputs; a correct base case stops recursion and prevents infinite loops or stack overflow.
- Call Stack: Each recursive call adds a frame, so recursion depth d uses O(d) stack space - deep recursion risks stack overflow.
- Backtracking: Build a candidate solution incrementally and abandon (backtrack) a partial choice as soon as it cannot lead to a valid solution.
- Choose-Explore-Unchoose: The core backtracking loop makes a choice, recurses, then undoes the choice to try the next option.
- Pruning: Cutting branches that violate constraints early (e.g., in N-Queens) dramatically reduces the search space.
Formulas
- Subsets of n elements: 2^n total, generated in O(2^n) time.
- Permutations of n elements: n! total, generated in O(n \cdot n!) time.
- Recursion space: O(d) for depth d call stack.
- N-Queens: worst-case O(n!) without pruning; pruning cuts it substantially.
- Combinations C(n,k): \binom{n}{k} candidates explored via backtracking.
Exam traps & shortcuts
- Any 'generate all subsets/permutations/combinations' problem is backtracking - use the choose/recurse/unchoose template.
- Always define the base case first; a missing or wrong base case is the most common cause of stack overflow.
- Prune early: check constraints before recursing to skip whole invalid branches (huge speedup in N-Queens/Sudoku).
- Convert deep recursion to iteration with an explicit stack when depth may exceed the language's stack limit.
Reference tables
Interview staples from the recursion / backtracking roadmap. n elements; combinations of size k; N-Queens on an n×n board.
| Pattern | Results | Typical time | Stack / depth |
|---|---|---|---|
| Subsets (power set) | 2^n | O(2^n) listing | O(n) depth |
| Permutations | n! | O(n \cdot n!) | O(n) depth |
| Combinations C(n,k) | \binom{n}{k} | O(\binom{n}{k} \cdot k) copy | O(k) depth |
| N-Queens (count) | A000170(n) | O(n!) worst; prune helps | O(n) depth |
| Linear recursion (fact) | 1 value | O(n) time | O(n) stack |
Recap
Carry these into a timed coding round.
- Base first
- Write the stopping case before the recursive branch — missing bases cause stack overflow.
- Stack space
- Depth-d recursion uses O(d) call-stack space; convert to an explicit stack when depth may explode.
- Template
- Backtracking is choose → explore → unchoose; undo shared mutations before the next sibling.
- Counts
- Subsets 2^n, permutations n!, combinations C(n,k) — pick the tree before coding.
- Prune early
- Reject invalid partials before recursing (N-Queens columns and diagonals in sets).
Practise Recursion and Backtracking
Reading is free and needs no account. Practice, mocks and progress live in the app.
- 5 exam-style questions on this topic, with explanations
- A 6-question practice set that ends the chapter
- Timed mocks scored with the real marking scheme
- Readiness tracked per topic, kept on your device