Data Structures & Algorithms · Lesson 9 of 15
Recursion & Backtracking — Subsets, Permutations and the Choose–Explore–Unchoose Template
Recursion is a function that solves a problem by calling itself on a smaller version of the same problem. Backtracking is recursion used to explore all choices: try an option, explore further, then undo it and try the next one.
Recursion in two rules
- Base case — the smallest input you can answer directly (stops the recursion).
- Recursive case — reduce the problem and trust the function to solve the smaller one.
def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # smaller problem
When factorial(4) runs, the calls wait on the call stack: factorial(4) → factorial(3) → factorial(2) → factorial(1). Then the answers return back up: 1 → 2 → 6 → 24. The depth of that stack is the space complexity — here O(n). Forget the base case and you get a stack overflow.
Leap of faith: don't trace every call in your head. Assume
f(n−1)works, and only ask: how do I build f(n) from it?
The recursion tree
Calls that branch form a tree. Fibonacci fib(n) = fib(n−1) + fib(n−2) branches twice per call, so naive recursion does about 2ⁿ calls — which is why it's slow, and why dynamic programming (memoisation) exists. Drawing the tree is how you calculate a recursive algorithm's time complexity: (number of nodes) × (work per node).
Backtracking: choose → explore → unchoose
Backtracking builds a solution step by step. At each step:
- Choose an option and add it to the current path.
- Explore — recurse to make the next choice.
- Unchoose — remove it, so the next option starts from a clean state.
Subsets (power set)
Each element is either in or out — 2ⁿ subsets.
def subsets(nums):
res, path = [], []
def dfs(i):
if i == len(nums):
res.append(path[:]) # copy! path keeps changing
return
path.append(nums[i]) # choose: include nums[i]
dfs(i + 1) # explore
path.pop() # unchoose
dfs(i + 1) # explore without nums[i]
dfs(0)
return res
List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
dfs(nums, 0, new ArrayList<>(), res);
return res;
}
void dfs(int[] nums, int i, List<Integer> path, List<List<Integer>> res) {
if (i == nums.length) { res.add(new ArrayList<>(path)); return; }
path.add(nums[i]); // choose
dfs(nums, i + 1, path, res); // explore
path.remove(path.size() - 1); // unchoose
dfs(nums, i + 1, path, res);
}
Time O(n · 2ⁿ) (2ⁿ subsets, copying each takes up to n).
Permutations
Order matters, so at each step choose any unused element.
def permute(nums):
res, path, used = [], [], [False] * len(nums)
def dfs():
if len(path) == len(nums):
res.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True; path.append(nums[i]) # choose
dfs() # explore
path.pop(); used[i] = False # unchoose
dfs()
return res
n! permutations → O(n · n!) time.
Combination Sum (reuse allowed)
Pick numbers (each can be reused) that add up to target. Pass a start index so you don't produce the same combination in a different order, and prune when the sum goes over.
def combination_sum(candidates, target):
res, path = [], []
def dfs(start, remaining):
if remaining == 0:
res.append(path[:])
return
for i in range(start, len(candidates)):
c = candidates[i]
if c > remaining:
continue # prune this branch
path.append(c)
dfs(i, remaining - c) # i (not i+1): same number can repeat
path.pop()
dfs(0, target)
return res
Handling duplicates (Subsets II, Combination Sum II): sort first, and skip nums[i] when it equals nums[i−1] at the same level of the loop (i > start).
Bigger example: N-Queens
Place N queens so that no two attack each other. Go row by row; for each column, check whether the column and both diagonals are free; place, recurse to the next row, then remove.
Track used columns and diagonals in sets: a diagonal is identified by row − col, the anti-diagonal by row + col. Each check is then O(1), and pruning makes the search fast enough for typical N.
How to recognise backtracking
- "Generate all subsets / permutations / combinations / partitions."
- "Find every way to…", "is there any arrangement…" with small input (n ≤ ~20).
- Puzzles: N-Queens, Sudoku, word search in a grid.
Common mistakes
- Appending
pathitself instead of a copy — every result ends up as the same (empty) list. - Forgetting to unchoose — state leaks between branches.
- Missing the base case → infinite recursion / stack overflow.
- Producing duplicates: use a
startindex for combinations, sort + skip for duplicate inputs.
Key takeaways
- Recursion = base case + smaller subproblem; its space = maximum call depth.
- Backtracking template: choose → explore → unchoose.
- Expect exponential time (2ⁿ, n!); prune early to stay fast.