Recursion & Backtracking
Trust the recursion. Build the decision tree for subsets, permutations, and combinations, and prune it to stay fast.
Recursion is what you use when a problem is naturally defined in terms of smaller copies of itself. A tree’s height is one plus the height of its taller subtree. A factorial is n times the factorial of n - 1. The code mirrors the idea directly: solve the tiny case, then reduce the big case until it becomes tiny.
Backtracking is recursion with memory of a partial choice. You build one candidate, explore everything that follows from it, then undo the choice so the next branch starts clean. That choose → explore → un-choose rhythm is the core of subsets, permutations, combinations, and many constraint-search problems.
The mental model
Every recursive function needs two pieces:
- Base case — the smallest input you can answer immediately.
- Recursive case — shrink the input and trust that the recursive call solves the smaller problem.
def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case
The runtime keeps unfinished calls on the call stack. factorial(4) waits for factorial(3), which waits for factorial(2), which waits for factorial(1). When the base case returns, the stack unwinds in reverse order. That is why missing base cases crash as recursion-depth errors: the stack never gets a chance to unwind.
The most useful mindset is trust the recursion. Do not mentally inline every call forever. Define what the function promises to return for a smaller input, assume it keeps that promise, and write the one layer that combines those answers.
Backtracking adds a decision tree. Each level represents one decision, each edge is one choice, and each root-to-leaf path is a candidate answer.
def backtrack(state, choices):
if is_complete(state):
answers.append(state.copy())
return
for choice in choices:
if not is_valid(choice, state):
continue
make(choice, state) # choose
backtrack(state, next_choices(choice, choices))
undo(choice, state) # un-choose
Pruning is just refusing to walk branches that cannot lead to a valid answer. If the remaining sum is negative, stop. If a permutation already used a number, skip it. Good backtracking is not brute force with recursion syntax; it is systematic search plus early exits.
fib(5)15 callsPattern recognition
Variations
Worked problems
Each solution below is the same skeleton wearing a different set of choices. Name the decision at each level before you write code.
Subsets
Given distinct integers, return every subset of the array. The empty subset and the full array both count, and the answer can be in any order.
Approach. At index i, the decision is binary: either skip nums[i] or take it. A cleaner iterative-feeling backtrack is to append the current path as an answer at every node, then try adding each later element. Copy the path when you record it because the same list is mutated throughout the search.
Show solution
def subsets(nums):
ans = []
path = []
def backtrack(start):
ans.append(path.copy())
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return ansComplexity. O(n · 2^n) time and O(n) auxiliary stack/path space, not counting the output. There are 2^n subsets, and copying each path costs up to O(n).
Permutations
Given distinct integers, return every ordering that uses each integer exactly once.
Approach. Each level chooses the next unused number. Track used positions so you never place the same element twice in one permutation. When the path length matches len(nums), it is a complete ordering and should be copied into the answer.
Show solution
def permute(nums):
ans = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
ans.append(path.copy())
return
for i, x in enumerate(nums):
if used[i]:
continue
used[i] = True
path.append(x)
backtrack()
path.pop()
used[i] = False
backtrack()
return ansComplexity. O(n · n!) time and O(n) auxiliary space, excluding output. There are n! permutations, and each complete one is copied.
Combination Sum
Given distinct positive candidates and a target, return every combination whose sum equals the target. You may reuse the same candidate any number of times, and combinations with the same numbers in a different order should not be duplicated.
Approach. Sort first so you can stop a loop once a candidate is larger than the remaining sum. Pass a start index to keep combinations nondecreasing; that prevents [2, 3, 2] from appearing separately from [2, 2, 3]. Reuse is allowed by recursing with i, not i + 1.
Show solution
def combination_sum(candidates, target):
candidates.sort()
ans = []
path = []
def backtrack(start, remaining):
if remaining == 0:
ans.append(path.copy())
return
for i in range(start, len(candidates)):
x = candidates[i]
if x > remaining:
break
path.append(x)
backtrack(i, remaining - x)
path.pop()
backtrack(0, target)
return ansComplexity. Exponential in the target and candidates because the output can be exponential. The recursion depth is at most target // min(candidates) for positive candidates; sorting costs O(n log n) and enables pruning.