Backtracking (Subsets/Permutations)
The Intuition
Backtracking is depth-first enumeration of a decision tree, with the recursion stack acting as the record of choices made so far. Each frame represents one decision; returning from the frame undoes that decision. The standard skeleton is choose, recurse, unchoose: append to the current path, recurse, pop after the call returns. The pop is the backtrack.
Two foundational shapes cover most problems. Subsets: at each element, decide include or exclude. The decision tree has 2^n leaves. Permutations: at each position, pick any element not already used. The decision tree has n! leaves. Almost every backtracking problem reshapes into one of these two with extra constraints layered on.
Pruning is what makes backtracking tractable in practice. The instant a partial solution violates a constraint, return without exploring further. For N-Queens, if a placement attacks an existing queen, skip the entire subtree of size (n-row)! that would have been explored. For Sudoku, an invalid candidate value cuts off a 9^(remaining-cells) subtree. The earlier the prune, the larger the savings.
The handling of duplicates is its own subpattern. Sort the input first, then at each recursion level, skip nums[i] if nums[i] == nums[i-1] and the previous duplicate was not chosen at this level. For subsets the check is i > start && nums[i] == nums[i-1]. For permutations the check is i > 0 && nums[i] == nums[i-1] && !used[i-1]. The logic differs because subsets advance via a start index while permutations use a used array.
Generating subsets, permutations, combinations, N-Queens, Sudoku solver, word search, palindrome partitioning, or any constraint-satisfaction problem.
- Problem has optimal substructure (use DP instead)
- Input is too large for exponential exploration (need polynomial algorithm)
- Only need count not enumeration (use DP or math)
- Empty input array
- Single element in the input
- No valid solution exists for the given constraints
- Input contains duplicate elements
Key Points
- •Explore all candidates, backtrack when constraint is violated
- •For subsets: at each index, choose to include or exclude
- •For permutations: swap elements or use a visited array
- •Prune early to avoid unnecessary exploration
- •Use a path/current list, add to result when complete, then undo
Code Template
def subsets(nums):
"""Generate all subsets."""
result = []
def backtrack(start, path):
result.append(path[:]) # copy current path
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path)
path.pop() # backtrack
backtrack(0, [])
return result
def permutations(nums):
"""Generate all permutations."""
result = []
def backtrack(path, used):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
path.append(nums[i])
backtrack(path, used)
path.pop()
used[i] = False
backtrack([], [False] * len(nums))
return result
def combination_sum(candidates, target):
"""Find combinations that sum to target (reuse allowed)."""
result = []
def backtrack(start, path, remaining):
if remaining == 0:
result.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break
path.append(candidates[i])
backtrack(i, path, remaining - candidates[i])
path.pop()
candidates.sort()
backtrack(0, [], target)
return result
def subsets_with_dup(nums):
"""Subsets II: generate all unique subsets when input has duplicates.
Key idea: sort first, then skip nums[i] if nums[i] == nums[i-1]
and i-1 was not picked at this recursion level."""
nums.sort()
result = []
def backtrack(start, path):
result.append(path[:])
for i in range(start, len(nums)):
# Skip duplicates at the same level
if i > start and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
def permutations_with_dup(nums):
"""Permutations II: generate all unique permutations when
input has duplicates. Sort first, then skip nums[i] if
nums[i] == nums[i-1] and nums[i-1] was not used."""
nums.sort()
result = []
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
# Skip duplicate: same value as previous,
# and previous was not used at this level
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return resultCommon Mistakes
- Forgetting to undo the choice (backtrack) after recursion
- Not skipping duplicates when input has repeated elements
- Modifying the result list reference instead of copying it