PATTERN 13 OF 22

Backtracking

Systematically explore all possible solutions by building candidates incrementally, abandoning paths that fail constraints (backtracking), and collecting valid results. Master the choose-explore-unchoose template for subsets, permutations, combinations, and constraint satisfaction.

Time: O(2^n) for subsets/combinations, O(n!) for permutations, O(n · 4^L) for grid search
Space: O(n) for recursion stack depth, plus O(k) per solution stored

Learn & Reference

Understanding the Pattern

Backtracking Pattern

Backtracking is a general algorithmic technique that builds solutions incrementally, one piece at a time, and abandons ("backtracks") a path as soon as it determines the path cannot lead to a valid solution. It's essentially a refined brute-force search with early pruning.

Decision tree for subsets of [1, 2, 3]:

                    []
              /            \
          [1]                []
        /      \           /     \
     [1,2]    [1]       [2]      []
     /  \    /  \      /  \    /  \
  [1,2,3] [1,2] [1,3] [1] [2,3] [2] [3] []

Core Concept

Every backtracking problem follows the same template: choose, explore, unchoose.

function backtrack(state, choices):
    if state is a valid solution:
        record solution
        return

    for each choice in choices:
        if choice is valid:
            make choice          ← CHOOSE
            backtrack(new state) ← EXPLORE
            undo choice          ← UNCHOOSE (backtrack)

The key insight is that we're traversing a decision tree where each node represents a partial solution and each branch represents adding one more element. We prune entire subtrees when we detect that a path can't lead to a valid solution.

Key Techniques

1. Subsets (Include/Exclude)

For each element, decide: include it or skip it. This generates all 2^n subsets.

function subsets(nums):
    result = []
    function backtrack(index, current):
        if index == nums.length:
            result.add(copy of current)
            return
        // Include nums[index]
        current.push(nums[index])
        backtrack(index + 1, current)
        current.pop()  ← backtrack
        // Exclude nums[index]
        backtrack(index + 1, current)
    backtrack(0, [])

2. Combinations (With Constraints)

Like subsets, but with a target constraint (e.g., sum = target). Prune when the remaining sum is impossible. Elements can sometimes be reused (start from same index instead of next).

function combinationSum(candidates, target):
    function backtrack(index, current, remaining):
        if remaining == 0: record solution
        if remaining < 0: return (prune)
        for i from index to end:
            current.push(candidates[i])
            backtrack(i, current, remaining - candidates[i])  ← same index for reuse
            current.pop()  ← backtrack

3. Permutations (Ordering Matters)

Generate all orderings. Track which elements are "used" to avoid duplicates.

function permute(nums):
    function backtrack(current, used):
        if current.length == nums.length: record solution
        for i from 0 to end:
            if not used[i]:
                used[i] = true
                current.push(nums[i])
                backtrack(current, used)
                current.pop()    ← backtrack
                used[i] = false  ← backtrack

4. Grid-Based Backtracking

DFS on a grid with visited tracking. Each cell explores 4 directions and backtracks by unmarking.

function exist(board, word):
    function dfs(row, col, index):
        if index == word.length: return true
        if out of bounds or board[row][col] != word[index]: return false
        temp = board[row][col]
        board[row][col] = '#'  ← mark visited
        found = dfs(row+1,col,index+1) or dfs(row-1,col,index+1) or ...
        board[row][col] = temp ← backtrack
        return found

5. Constraint Satisfaction (N-Queens)

Place items under constraints. Track which positions are invalid and prune aggressively.

function nQueens(n):
    function backtrack(row, cols, diag1, diag2):
        if row == n: record solution
        for col in 0..n-1:
            if col not in cols and (row-col) not in diag1 and (row+col) not in diag2:
                place queen, add constraints
                backtrack(row + 1, ...)
                remove queen, remove constraints  ← backtrack

Common Mistakes

  1. Forgetting to undo state changes — The "unchoose" step is critical. If you push to an array but forget to pop after recursion, all branches share corrupted state.
  2. Passing references instead of copies — When recording a solution, you must copy the current array (e.g., result.push([...current])). Otherwise, all recorded solutions point to the same mutated array.
  3. Wrong loop start index — For combinations without duplicates, start the loop at the current index (not 0). Starting at 0 produces duplicate combinations like [1,2] and [2,1].
  4. Missing base case or pruning — Without proper termination, backtracking becomes brute force. Prune early: if remaining sum < 0, if index out of bounds, if constraints are violated.
  5. Not restoring grid cells after DFS — In grid-based problems, if you mark a cell visited but don't unmark it after returning, other paths can't use that cell. Always restore: board[r][c] = temp.

When to Use

The problem asks you to generate **all subsets, combinations, or permutations**
You need to find **all solutions** that satisfy certain constraints
The problem involves **placing items** under rules (N-Queens, Sudoku)
You need to **search all paths** in a grid or graph with backtracking on dead ends
The brute-force solution is exponential but can be pruned with constraints
Keywords: all possible, generate all, find every, return all valid

Template

1def backtrack(result, current, choices, start):
2 # Base case: valid solution found
3 if is_complete(current):
4 result.append(current[:]) # Copy before recording!
5 return
6
7 for i in range(start, len(choices)):
8 # Choose
9 current.append(choices[i])
10
11 # Explore
12 backtrack(result, current, choices, i + 1)
13
14 # Unchoose (backtrack)
15 current.pop()
16
17def solve(choices):
18 result = []
19 backtrack(result, [], choices, 0)
20 return result
{ }

Syntax Reference

1# Backtracking Template
2# No built-in — implement the choose-explore-unchoose pattern
3
4def backtrack(result, current, choices, start):
5 """
6 result: list to collect valid solutions
7 current: partial solution being built
8 choices: available elements to choose from
9 start: index to avoid duplicate combinations
10 """
11 if is_valid_solution(current):
12 result.append(current[:]) # COPY before recording
13 return
14
15 for i in range(start, len(choices)):
16 current.append(choices[i]) # CHOOSE
17 backtrack(result, current, choices, i + 1) # EXPLORE
18 current.pop() # UNCHOOSE
19
20# Key Python helpers:
21# current[:] or list(current) — shallow copy a list
22# set() — O(1) lookup for constraint checking
23# sorted() — sort candidates to enable pruning
📋

Common Recipes

1# Recipe 1: Generate all subsets
2def subsets(nums):
3 result = []
4 def backtrack(start, current):
5 result.append(current[:])
6 for i in range(start, len(nums)):
7 current.append(nums[i])
8 backtrack(i + 1, current)
9 current.pop()
10 backtrack(0, [])
11 return result
12
13# Recipe 2: Generate all permutations
14def permute(nums):
15 result = []
16 def backtrack(current, used):
17 if len(current) == len(nums):
18 result.append(current[:])
19 return
20 for i in range(len(nums)):
21 if not used[i]:
22 used[i] = True
23 current.append(nums[i])
24 backtrack(current, used)
25 current.pop()
26 used[i] = False
27 backtrack([], [False] * len(nums))
28 return result
29
30# Recipe 3: Combination sum (elements reusable)
31def combination_sum(candidates, target):
32 result = []
33 candidates.sort()
34 def backtrack(start, current, remaining):
35 if remaining == 0:
36 result.append(current[:])
37 return
38 for i in range(start, len(candidates)):
39 if candidates[i] > remaining:
40 break
41 current.append(candidates[i])
42 backtrack(i, current, remaining - candidates[i])
43 current.pop()
44 backtrack(0, [], target)
45 return result
46
47# Recipe 4: Grid word search (DFS + backtrack)
48def exist(board, word):
49 rows, cols = len(board), len(board[0])
50 def dfs(r, c, idx):
51 if idx == len(word):
52 return True
53 if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[idx]:
54 return False
55 temp = board[r][c]
56 board[r][c] = '#'
57 found = dfs(r+1,c,idx+1) or dfs(r-1,c,idx+1) or dfs(r,c+1,idx+1) or dfs(r,c-1,idx+1)
58 board[r][c] = temp
59 return found
60 for r in range(rows):
61 for c in range(cols):
62 if dfs(r, c, 0):
63 return True
64 return False
O(n)

Complexity

Problem TypeTimeSpaceNotes
SubsetsO(n · 2^n)O(n)2^n subsets, O(n) to copy each
Combinations (sum)O(2^(t/m))O(t/m)t = target, m = min candidate
PermutationsO(n · n!)O(n)n! permutations, O(n) to copy each
Word SearchO(m · n · 4^L)O(L)L = word length, 4 directions per cell
N-QueensO(n!)O(n)Pruning reduces branching factor each row

Watch Out

  • Forgetting to undo state changes — The "unchoose" step is critical. If you push to an array but forget to pop after recursion, all branches share corrupted state.
  • Passing references instead of copies — When recording a solution, you must copy the current array (e.g., `result.push([...current])`). Otherwise, all recorded solutions point to the same mutated array.
  • Wrong loop start index — For combinations without duplicates, start the loop at the current index (not 0). Starting at 0 produces duplicate combinations like [1,2] and [2,1].
  • Missing base case or pruning — Without proper termination, backtracking becomes brute force. Prune early: if remaining sum < 0, if index out of bounds, if constraints are violated.
  • Not restoring grid cells after DFS — In grid-based problems, if you mark a cell visited but don't unmark it after returning, other paths can't use that cell. Always restore: `board[r][c] = temp`.