Backtracking Patterns — Comprehensive Guide

Backtracking is brute force with early termination. You explore a decision tree, and whenever a partial solution can’t possibly lead to a valid complete solution, you prune that branch and backtrack. The power of backtracking comes not from what it explores, but from what it doesn’t explore.


Quick Navigation: “I need to…”

I need to… Technique Section
Generate all permutations Swap-based / used-array backtracking 2
Generate all combinations (choose k from n) Index-based backtracking 3
Generate all subsets (power set) Include/exclude at each step 4
Handle duplicates in generation Sort + skip adjacent duplicates 5
Solve N-Queens / board placement Row-by-row with column/diagonal tracking 6
Solve Sudoku Cell-by-cell with constraint checking 6
Partition array into k equal subsets Bucket-filling backtracking 7
Generate parentheses / valid sequences Count-based constraints 8
Search for word in grid DFS with visited tracking 6
Optimize with pruning Feasibility + bound + symmetry pruning 9
Use bitmask for state Bitmask replaces boolean arrays 10

From-Scratch Idea: What Makes a Problem Backtracking

Before any template, the identification test. A problem is backtracking when both hold:

1. The answer is BUILT INCREMENTALLY — one decision at a time
   (pick the next element, place the next queen, add the next char).

2. A partial answer can be JUDGED before it's complete — you can tell
   "this prefix is already doomed" and abandon it (prune) instead of
   finishing it.

If only (1) holds and every prefix is always fine until the end, it’s plain enumeration. If the incremental decisions have overlapping subproblems you’d recompute, it’s DP, not backtracking. Backtracking is the case where you must walk the decision tree but can cut dead branches.

The one mental model: a decision tree you walk depth-first

Every backtracking problem is a tree. A node is a partial answer, an edge is one decision, a leaf is complete-or-pruned. Backtracking = DFS on that tree, undoing each decision on the way back up so the shared path/state is reused instead of copied at every node.

                    (empty answer)          ← root: nothing decided
                   /      |      \
              choice A  choice B  choice C   ← level 0: first decision
              /   \        |
           ...    ...     PRUNE              ← branch that can't lead to a
                                               valid/optimal leaf: skip it

The three lines of the template map exactly onto walking one edge:

apply(choice)     ── walk DOWN the edge (choose)
backtrack(...)    ── explore the subtree
undo(choice)      ── walk BACK UP the edge (unchoose)  ← the "back" in backtracking

Everything else — permutations, N-Queens, parentheses — is the same DFS with a different “what are my choices at this node?” and a different “is this branch doomed?” test.

★ Insight ─────────────────────────────────────

  • The reason we undo instead of passing a fresh copy down each edge is cost: one shared mutable path costs O(depth) memory total; copying at every node costs O(nodes × depth). The pop() after the recursive call is what makes the whole tree reuse a single path buffer.
  • “Can I judge a partial answer early?” is the entire value proposition. Two problems can have the same leaves (e.g. all 2ⁿ subsets) but wildly different runtimes purely from how early the doomed branches get cut. ─────────────────────────────────────────────────

Choices-per-node decides the technique

"pick the NEXT element, order matters, can't reuse"   → Permutations (§2)   choices = unused elements
"pick a SUBSET of size k, order doesn't matter"       → Combinations (§3)   choices = elements after index i
"include-or-skip each element"                        → Subsets (§4)        choices = {take, skip}
"same as above but input has repeats"                 → Duplicates (§5)     choices = distinct values at this level
"place one item per row/cell under constraints"       → Grid/Board (§6)     choices = safe positions
"cut the input into valid pieces"                     → Partition (§7)      choices = valid next segment
"append the next character under a rule"              → String (§8)         choices = legal next chars

LeetCode ↔ CP translation

The competitive-programming framing and the LeetCode framing are the same tree; only the vocabulary differs.

Competitive programming LeetCode Same underlying tree
“generate all permutations” LC 46 / 47 choices = unused elements
“enumerate subsets / power set” LC 78 / 90 choices = take/skip per index
“place N non-attacking queens” LC 51 / 52 choices = safe columns per row
“fill grid under row/col/box constraint” LC 37 Sudoku choices = legal digits per cell
“count / find one feasible assignment” LC 39/40 Combination Sum choices = candidates ≥ start
“split string into valid tokens” LC 131 / 93 choices = valid next segment

Master LeetCode Comparison Table

Six canonical problems, one per technique. Read the “choices at each node” and “prune when” columns — that pair is the algorithm.

LC # Problem Technique (§) Difficulty Choices at each node Prune / skip when
46 Permutations Permutations (§2) Medium every unused element element already in path (used[i])
78 Subsets Subsets (§4) Medium each element with index ≥ start (never prune — record every node)
90 Subsets II Duplicates (§5) Medium distinct values from start i > start and nums[i]==nums[i-1]
79 Word Search Grid/Board (§6) Medium 4 neighbors of current cell off-grid, wrong char, or visited
22 Generate Parentheses String (§8) Medium append ( or ) ( when open==n; ) when close==open
131 Palindrome Partitioning Partition (§7) Medium every prefix s[start:end+1] prefix isn’t a palindrome

★ Insight ─────────────────────────────────────

  • Notice §4 Subsets has an empty “prune when” column — it records at every node, not just leaves, and never cuts a branch. That’s the cheapest backtracking shape: pure enumeration of 2ⁿ nodes. Every other row earns its speed from the prune column.
  • LC 46 vs LC 78 differ only in what counts as a choice: 46 picks from unused elements (order matters, n! leaves); 78 picks from later indices (order fixed, 2ⁿ nodes). Same DFS skeleton, different candidate set — the whole family is one template with a swapped inner loop. ─────────────────────────────────────────────────

Table of Contents

  1. The Backtracking Framework
  2. Permutations
  3. Combinations
  4. Subsets
  5. Handling Duplicates
  6. Grid and Board Problems
  7. Partitioning Problems
  8. String Backtracking
  9. Pruning Techniques
  10. Backtracking + Bitmask
  11. Constraint Satisfaction Problems
  12. Common Patterns Collection
  13. Pattern Recognition Cheat Sheet

1. The Backtracking Framework

The Idea

Every backtracking problem follows the same skeleton:

1. Choose   — pick a candidate for the current position
2. Explore  — recurse with that choice
3. Unchoose — undo the choice (backtrack)

The Universal Template

def backtrack(state, choices):
    if is_complete(state):
        process_solution(state)
        return

    for candidate in choices:
        if is_valid(state, candidate):
            # 1. Choose
            apply(state, candidate)

            # 2. Explore
            backtrack(state, next_choices)

            # 3. Unchoose
            undo(state, candidate)

The Decision Tree

Every backtracking algorithm implicitly explores a tree. Each node is a partial solution, each edge is a decision, and leaves are complete (or pruned) solutions.

Generate all 3-bit binary strings:

                       ""
                   /        \
                 "0"        "1"          ← choose bit 0
               /    \      /    \
            "00"   "01"  "10"   "11"     ← choose bit 1
            / \    / \   / \    / \
         000 001 010 011 100 101 110 111  ← choose bit 2

Without pruning: visits all 2^3 = 8 leaves.
With constraint "no two consecutive 1s", we prune the "11" prefix
at level 2 — its whole subtree (110, 111) is never generated:

                       ""
                   /        \
                 "0"        "1"
               /    \      /    ╲
            "00"   "01"  "10"   "11"  ← PRUNE here (prefix has "11")
            / \    / \   / \      ✗   (110, 111 never expanded)
         000 001 010 011 100 101

Valid results: 000, 001, 010, 100, 101 — 5 strings.
The prune at the "11" prefix saved exploring 2 leaves.

Recursion vs State Space

Think of each recursive call as one level of the decision tree:

Level Decision State changes
0 Which element goes first? Add to path[0]
1 Which element goes second? Add to path[1]
… … …
n-1 Which element goes last? Add to path[n-1]

Implementation — Basic Template

def solve(n):
    result = []

    def backtrack(path):
        # Base case: solution is complete
        if len(path) == n:
            result.append(path[:])  # copy!
            return

        for candidate in get_candidates(path):
            path.append(candidate)       # choose
            backtrack(path)              # explore
            path.pop()                   # unchoose

    backtrack([])
    return result

⚠️ Critical: Copy the Path!

# WRONG — all entries point to the same list
result.append(path)

# CORRECT — snapshot the current state
result.append(path[:])
result.append(list(path))
result.append(tuple(path))

Since path is mutated during backtracking, you must copy it when recording a solution.


2. Permutations

Problem: Generate All Permutations of [1..n]

Each position chooses from the remaining unused elements.

Approach A: Used-Array

Track which elements are used with a boolean array.

Tree for [1, 2, 3]:

Level 0 (pick 1st):     1           2           3
Level 1 (pick 2nd):   2   3       1   3       1   2
Level 2 (pick 3rd):  3     2     3     1     2     1

Permutations: [1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]
def permutations_used_array(nums):
    n = len(nums)
    result = []
    used = [False] * n

    def backtrack(path):
        if len(path) == n:
            result.append(path[:])
            return
        for i in range(n):
            if not used[i]:
                used[i] = True
                path.append(nums[i])
                backtrack(path)
                path.pop()
                used[i] = False

    backtrack([])
    return result

Approach B: Swap-Based

Instead of a separate used array, swap elements into position. More memory efficient.

[1, 2, 3]
 ^  fix position 0: swap with 0, 1, 2

pos=0: [1, 2, 3]  →  recurse on [_, 2, 3]
       [2, 1, 3]  →  recurse on [_, 1, 3]
       [3, 2, 1]  →  recurse on [_, 2, 1]
def permutations_swap(nums):
    result = []

    def backtrack(start):
        if start == len(nums):
            result.append(nums[:])
            return
        for i in range(start, len(nums)):
            nums[start], nums[i] = nums[i], nums[start]  # choose
            backtrack(start + 1)                          # explore
            nums[start], nums[i] = nums[i], nums[start]  # unchoose

    backtrack(0)
    return result

Complexity

  • Number of permutations: n!
  • Time: O(n × n!) — n! permutations, each takes O(n) to copy
  • Space: O(n) recursion depth (excluding output)

Walkthrough — LC 46 Permutations

This template solves: LC 46 (Permutations), LC 47 (Permutations II — add the §5 duplicate skip), LC 60 (Permutation Sequence — same tree, count instead of collect).

Full statement. Given an array nums of distinct integers, return all possible permutations in any order. Example: nums = [1,2,3] → [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]].

The pattern-focused view: each tree level fixes one more position; the choices at a node are the elements not yet in path. The used array is the “what’s still available” set.

def permute(nums):
    n = len(nums)
    result = []
    used = [False] * n

    def backtrack(path):
        if len(path) == n:          # leaf: a full permutation
            result.append(path[:])  # copy — path keeps mutating
            return
        for i in range(n):
            if used[i]:             # prune: element already placed
                continue
            used[i] = True          # choose
            path.append(nums[i])
            backtrack(path)         # explore
            path.pop()              # unchoose
            used[i] = False

    backtrack([])
    return result
nums = [1,2,3]        path grows down, used = which elements are taken

[]  used=---
├─ pick 1  path=[1] used=T--
│   ├─ pick 2  [1,2] used=TT-
│   │   └─ pick 3  [1,2,3] ✓ LEAF
│   └─ pick 3  [1,3] used=T-T
│       └─ pick 2  [1,3,2] ✓ LEAF
├─ pick 2  path=[2] used=-T-
│   ├─ pick 1 → [2,1,3] ✓
│   └─ pick 3 → [2,3,1] ✓
└─ pick 3  path=[3] used=--T
    ├─ pick 1 → [3,1,2] ✓
    └─ pick 2 → [3,2,1] ✓

6 leaves = 3! permutations

★ Insight ─────────────────────────────────────

  • The used[i]=False on the way back up is the whole trick: after exploring “1 first”, we release element 1 so the “2 first” branch can use it. Forget that line and every permutation starts with 1.
  • LC 47 (duplicates) is this exact code plus one guard: sort, then if i>0 and nums[i]==nums[i-1] and not used[i-1]: continue (§5). The base algorithm never changes — you only tighten the candidate set. ─────────────────────────────────────────────────

3. Combinations

Problem: Choose k Elements from [1..n]

The key difference from permutations: order doesn’t matter, so we enforce an ordering (e.g., always pick in increasing order) to avoid duplicates.

The Trick: Start Index

C(4, 2) — choose 2 from [1, 2, 3, 4]:

          start=1
        /    |    \    \
       1     2     3    4
      /|\   /\     |
     2 3 4  3  4   4

Combinations: [1,2] [1,3] [1,4] [2,3] [2,4] [3,4]

Notice: we never go backward (e.g., no [2,1]).
def combinations(n, k):
    result = []

    def backtrack(start, path):
        if len(path) == k:
            result.append(path[:])
            return

        # Pruning: need (k - len(path)) more elements,
        # and only (n - start + 1) are available
        for i in range(start, n + 1):
            if n - i + 1 < k - len(path):  # not enough remaining
                break
            path.append(i)
            backtrack(i + 1, path)  # i+1 to avoid reuse
            path.pop()

    backtrack(1, [])
    return result

Why i + 1 and Not i?

  • backtrack(i + 1, ...) → each element used at most once (combinations)
  • backtrack(i, ...) → elements can repeat (combinations with repetition)
  • backtrack(start, ...) → ERROR: would repeat same combinations

Combination with Repetition

def combinations_with_repetition(candidates, k):
    """Choose k elements, each can be used multiple times."""
    result = []

    def backtrack(start, path):
        if len(path) == k:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            path.append(candidates[i])
            backtrack(i, path)     # i, NOT i+1 — allow reuse
            path.pop()

    backtrack(0, [])
    return result

Complexity

  • Number of combinations: C(n, k) = n! / (k!(n-k)!)
  • Time: O(k × C(n, k))
  • Space: O(k) recursion depth

4. Subsets

Problem: Generate All Subsets (Power Set)

Two classic approaches:

Approach A: Include/Exclude (Binary Decision)

At each element, make a binary choice: include it or skip it.

Array: [1, 2, 3]

                      {}
                   /      \
               {1}         {}           ← include/exclude 1
              /    \      /    \
          {1,2}   {1}  {2}    {}        ← include/exclude 2
          / \     / \   / \   / \
      {1,2,3}{1,2}{1,3}{1}{2,3}{2}{3}{} ← include/exclude 3
def subsets_include_exclude(nums):
    result = []

    def backtrack(index, path):
        if index == len(nums):
            result.append(path[:])
            return

        # Include nums[index]
        path.append(nums[index])
        backtrack(index + 1, path)
        path.pop()

        # Exclude nums[index]
        backtrack(index + 1, path)

    backtrack(0, [])
    return result

Approach B: Iterative Growth

At each level, choose any element with index ≥ start.

def subsets_iterative_growth(nums):
    result = []

    def backtrack(start, path):
        result.append(path[:])  # every partial path is a valid subset

        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

Approach C: Bitmask (Non-Recursive)

def subsets_bitmask(nums):
    n = len(nums)
    result = []
    for mask in range(1 << n):
        subset = []
        for i in range(n):
            if mask & (1 << i):
                subset.append(nums[i])
        result.append(subset)
    return result

Complexity

  • Number of subsets: 2^n
  • Time: O(n × 2^n)
  • Space: O(n) recursion depth

Walkthrough — LC 78 Subsets

This template solves: LC 78 (Subsets), LC 77 (Combinations — stop recording until len(path)==k), LC 39 (Combination Sum — recurse on i not i+1 to allow reuse, prune on remaining target).

Full statement. Given an array nums of unique integers, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Example: nums = [1,2,3] → [[],[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3]] (any order).

The pattern-focused view: unlike permutations/combinations, every node of the tree is a valid answer, not just leaves — so you record on entry, before the loop. The start index enforces increasing order, which is what stops [2,1] and [1,2] both appearing.

def subsets(nums):
    result = []

    def backtrack(start, path):
        result.append(path[:])          # record EVERY node, not just leaves
        for i in range(start, len(nums)):
            path.append(nums[i])        # choose nums[i]
            backtrack(i + 1, path)      # explore; i+1 = never look back
            path.pop()                  # unchoose

    backtrack(0, [])
    return result
nums = [1,2,3]     record at entry (before the loop)

start=0 path=[]              → record []
├─ i=0 [1]                   → record [1]
│   ├─ i=1 [1,2]             → record [1,2]
│   │   └─ i=2 [1,2,3]       → record [1,2,3]
│   └─ i=2 [1,3]             → record [1,3]
├─ i=1 [2]                   → record [2]
│   └─ i=2 [2,3]             → record [2,3]
└─ i=2 [3]                   → record [3]

8 records = 2^3 subsets

★ Insight ─────────────────────────────────────

  • backtrack(i+1, ...) — not i, not start — is the ordering lock. i+1 means “future choices come strictly after me,” which collapses the 3! = 6 orderings of {1,2,3} into the single subset [1,2,3]. Using i instead gives combinations-with-repetition; using start re-generates the same subset many times.
  • Subsets is the one family with no prune: it visits all 2ⁿ nodes by design. Turn it into LC 77 Combinations by only recording when len(path)==k, and into LC 39 Combination Sum by recursing on i (reuse allowed) and pruning once remaining < 0. ─────────────────────────────────────────────────

5. Handling Duplicates

The Problem

If the input has duplicates, naive backtracking generates duplicate solutions:

Input: [1, 1, 2]
Naive subsets: {}, {1}, {1}, {2}, {1,1}, {1,2}, {1,2}, {1,1,2}
                         ^                        ^
                     duplicate!               duplicate!

The Fix: Sort + Skip Adjacent

Sort the array first. Then at each decision level, skip a candidate if it equals the previous candidate at the same level.

Sorted: [1, 1, 2]

Level decision for "which element next?":
  candidates at start=0: [1, 1, 2]
    - pick 1 (index 0) ✓
    - pick 1 (index 1) ✗ SKIP (same as previous candidate at this level)
    - pick 2 (index 2) ✓

Subsets with Duplicates

def subsets_with_dup(nums):
    nums.sort()  # critical!
    result = []

    def backtrack(start, path):
        result.append(path[:])
        for i in range(start, len(nums)):
            # Skip duplicates at the same decision 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

Permutations with Duplicates

def permutations_with_dup(nums):
    nums.sort()  # critical!
    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: if nums[i] == nums[i-1] and nums[i-1] was NOT used
            # at this level, then we'd generate the same subtree
            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 result

Why not used[i-1]?

This is the tricky part. Consider [1a, 1b, 2]:

Without the check, both subtrees are identical:
  1a → 1b → 2   and   1b → 1a → 2

Rule: among equal elements, only pick them in the original order.
  - 1a before 1b: OK (original order)
  - 1b before 1a: SKIP

"not used[i-1]" means: the previous equal element was already returned
(not currently in the path), so we're trying to start a new branch with
this duplicate — skip it.

Combination Sum with Duplicates

def combination_sum_with_dup(candidates, target):
    """Each number used at most once; duplicates in input."""
    candidates.sort()
    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  # pruning: sorted, so all future too big
            if i > start and candidates[i] == candidates[i - 1]:
                continue  # skip duplicate at same level
            path.append(candidates[i])
            backtrack(i + 1, path, remaining - candidates[i])
            path.pop()

    backtrack(0, [], target)
    return result

The Duplicate-Handling Rules

Problem Type Sort? Skip Condition
Subsets with dups Yes i > start and nums[i] == nums[i-1]
Permutations with dups Yes i > 0 and nums[i] == nums[i-1] and not used[i-1]
Combinations with dups Yes i > start and nums[i] == nums[i-1]

Walkthrough — LC 90 Subsets II

This template solves: LC 90 (Subsets II), LC 40 (Combination Sum II — same skip + a remaining-target prune), LC 47 (Permutations II — the used[i-1] variant of the same idea).

Full statement. Given an integer array nums that may contain duplicates, return all possible subsets. The solution set must not contain duplicate subsets. Example: nums = [1,2,2] → [[],[1],[1,2],[1,2,2],[2],[2,2]].

The pattern-focused view: it’s LC 78 plus one line. Sort so equal values are adjacent, then at each level skip a value already tried as the first choice at this level. The guard is i > start (not i > 0) — the first occurrence at a level is always allowed; only re-picking an equal value as a sibling is banned.

def subsetsWithDup(nums):
    nums.sort()                          # equal values become adjacent
    result = []

    def backtrack(start, path):
        result.append(path[:])
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue                 # skip duplicate SIBLING at this level
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result
nums = [1,2,2]  (already sorted)

start=0 []                       → record []
├─ i=0 pick 1 → [1]              → record [1]
│   ├─ i=1 pick 2 → [1,2]        → record [1,2]
│   │   └─ i=2 pick 2 → [1,2,2]  → record [1,2,2]
│   └─ i=2  nums[2]==nums[1] and i(2)>start(1) → SKIP  ✗
├─ i=1 pick 2 → [2]              → record [2]
│   └─ i=2 pick 2 → [2,2]        → record [2,2]
└─ i=2  nums[2]==nums[1] and i(2)>start(0) → SKIP  ✗

6 records — the two pruned siblings would have re-made [1,2] and [2]

★ Insight ─────────────────────────────────────

  • Why i > start and not i > 0? The condition must fire only for siblings in the same loop, never across levels. At start=1 the first 2 (i=1) is the branch head and must run; the second 2 (i=2) is its sibling and is the real duplicate. i > start says exactly “this is not the first candidate at this level.”
  • Sort is load-bearing, not cosmetic: the skip test compares nums[i] to nums[i-1], so equal values must be adjacent for it to catch them. Skip the sort and [2,1,2] slips two identical subsets through. ─────────────────────────────────────────────────

6. Grid and Board Problems

N-Queens

Place N queens on an N×N board so no two attack each other.

N = 4 solution:

  . Q . .      . . Q .
  . . . Q      Q . . .
  Q . . .      . . . Q
  . . Q .      . Q . .

Key insight: Place one queen per row. Track which columns and diagonals are occupied.

Diagonals encoding:
  - Main diagonal (↘): row - col is constant
  - Anti-diagonal (↙): row + col is constant

Board for N=4, showing (row-col) values:
   0   -1   -2   -3       ← main diagonal IDs
   1    0   -1   -2
   2    1    0   -1
   3    2    1    0

Board showing (row+col) values:
   0    1    2    3       ← anti-diagonal IDs
   1    2    3    4
   2    3    4    5
   3    4    5    6
def solve_n_queens(n):
    result = []
    cols = set()
    diag1 = set()  # row - col
    diag2 = set()  # row + col

    def backtrack(row, queens):
        if row == n:
            # Build board representation
            board = []
            for r, c in sorted(queens):
                board.append('.' * c + 'Q' + '.' * (n - c - 1))
            result.append(board)
            return

        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue

            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            queens.append((row, col))

            backtrack(row + 1, queens)

            queens.pop()
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    backtrack(0, [])
    return result

Sudoku Solver

Fill empty cells so every row, column, and 3×3 box contains 1–9.

def solve_sudoku(board):
    """board: 9x9 list of lists, 0 = empty."""
    rows = [set() for _ in range(9)]
    cols = [set() for _ in range(9)]
    boxes = [set() for _ in range(9)]

    # Initialize constraint sets
    empty = []
    for r in range(9):
        for c in range(9):
            if board[r][c] != 0:
                val = board[r][c]
                rows[r].add(val)
                cols[c].add(val)
                boxes[(r // 3) * 3 + c // 3].add(val)
            else:
                empty.append((r, c))

    def backtrack(idx):
        if idx == len(empty):
            return True  # all cells filled

        r, c = empty[idx]
        box_id = (r // 3) * 3 + c // 3

        for val in range(1, 10):
            if val in rows[r] or val in cols[c] or val in boxes[box_id]:
                continue

            # Choose
            board[r][c] = val
            rows[r].add(val)
            cols[c].add(val)
            boxes[box_id].add(val)

            # Explore
            if backtrack(idx + 1):
                return True

            # Unchoose
            board[r][c] = 0
            rows[r].remove(val)
            cols[c].remove(val)
            boxes[box_id].remove(val)

        return False  # no valid digit for this cell

    backtrack(0)
    return board

Word Search in Grid

Find if a word exists in a grid by moving to adjacent cells (no revisiting).

def word_search(board, word):
    R, C = len(board), len(board[0])

    def backtrack(r, c, k):
        if k == len(word):
            return True
        if r < 0 or r >= R or c < 0 or c >= C:
            return False
        if board[r][c] != word[k]:
            return False

        # Mark visited (in-place trick)
        tmp = board[r][c]
        board[r][c] = '#'

        for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            if backtrack(r + dr, c + dc, k + 1):
                return True

        board[r][c] = tmp  # unchoose
        return False

    for r in range(R):
        for c in range(C):
            if backtrack(r, c, 0):
                return True
    return False

In-place visited trick: Temporarily replace board[r][c] with a sentinel character ('#'), then restore it when backtracking. Saves allocating a separate visited array.

This template solves: LC 79 (Word Search), LC 200 (Number of Islands — same grid DFS, no word to match), LC 212 (Word Search II — this DFS over a Trie of many words).

Full statement. Given an m × n grid of characters and a string word, return True if word exists in the grid. The word is built from sequentially adjacent cells (horizontal/vertical neighbors); the same cell may not be used twice. Example: board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED" → True; word = "ABCB" → False.

The pattern-focused view: the decision tree branches over the 4 neighbors at each step, matching one character deeper. The prune is aggressive — three fail-fast tests (off-grid, wrong char, already-visited) kill a branch before recursing. “Visited” is encoded by mutating the cell to '#' and restoring on the way back up.

def exist(board, word):
    R, C = len(board), len(board[0])

    def backtrack(r, c, k):
        if k == len(word):                       # matched every char
            return True
        if r < 0 or r >= R or c < 0 or c >= C:   # prune: off-grid
            return False
        if board[r][c] != word[k]:               # prune: wrong char / visited '#'
            return False

        tmp = board[r][c]
        board[r][c] = '#'                        # choose: mark visited
        for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            if backtrack(r + dr, c + dc, k + 1):  # explore neighbors
                return True
        board[r][c] = tmp                        # unchoose: restore
        return False

    return any(backtrack(r, c, 0) for r in range(R) for c in range(C))
word = "ABCCED", start at (0,0)='A'

(0,0)A k0 ✓  mark #
 └─ right (0,1)B k1 ✓  mark #
     └─ right (0,2)C k2 ✓  mark #
         ├─ right (0,3)E k3 ✗ (need 'C')  ← prune
         ├─ left  (0,1)# k3 ✗ (visited)   ← prune
         └─ down  (1,2)C k3 ✓  mark #
             └─ down (2,2)E k4 ✓  mark #
                 └─ left (2,1)D k5 ✓ (need 'D')  mark #
                     └─ k6 == len → TRUE
path A→B→C→C→E→D  ✓

★ Insight ─────────────────────────────────────

  • Setting board[r][c]='#' unifies the visited-check with the char-check: a visited cell now holds '#', which never equals word[k], so the single board[r][c] != word[k] line rejects both “wrong letter” and “already used.” One comparison, two prunes.
  • The restore board[r][c] = tmp must run on every exit path, not just success. If you return True early you skip it — that’s fine because you’re done — but on failure the cell must be freed so a different starting cell can legally step through it later. ─────────────────────────────────────────────────

7. Partitioning Problems

Partition into k Equal-Sum Subsets

Given an array and integer k, can you partition the array into k subsets with equal sum?

Input: [4, 3, 2, 3, 5, 2, 1], k = 4
Total = 20, target per bucket = 5

Solution: {5}, {4,1}, {3,2}, {3,2}  ✓
def can_partition_k(nums, k):
    total = sum(nums)
    if total % k != 0:
        return False
    target = total // k

    nums.sort(reverse=True)  # pruning: try big elements first
    if nums[0] > target:
        return False

    buckets = [0] * k

    def backtrack(index):
        if index == len(nums):
            return all(b == target for b in buckets)

        seen = set()  # avoid putting same value in equivalent empty buckets
        for i in range(k):
            if buckets[i] + nums[index] > target:
                continue
            if buckets[i] in seen:  # symmetry pruning
                continue
            seen.add(buckets[i])

            buckets[i] += nums[index]
            if backtrack(index + 1):
                return True
            buckets[i] -= nums[index]

        return False

    return backtrack(0)

Palindrome Partitioning

Split a string so every part is a palindrome.

Input: "aab"
Output: [["a","a","b"], ["aa","b"]]
def palindrome_partition(s):
    result = []

    def is_palindrome(l, r):
        while l < r:
            if s[l] != s[r]:
                return False
            l += 1
            r -= 1
        return True

    def backtrack(start, path):
        if start == len(s):
            result.append(path[:])
            return

        for end in range(start, len(s)):
            if is_palindrome(start, end):
                path.append(s[start:end + 1])
                backtrack(end + 1, path)
                path.pop()

    backtrack(0, [])
    return result

Optimization: Precompute Palindromes with DP

def palindrome_partition_optimized(s):
    n = len(s)
    # is_pal[i][j] = True if s[i..j] is palindrome
    is_pal = [[False] * n for _ in range(n)]
    for i in range(n):
        is_pal[i][i] = True
    for i in range(n - 1):
        is_pal[i][i + 1] = (s[i] == s[i + 1])
    for length in range(3, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            is_pal[i][j] = (s[i] == s[j] and is_pal[i + 1][j - 1])

    result = []

    def backtrack(start, path):
        if start == n:
            result.append(path[:])
            return
        for end in range(start, n):
            if is_pal[start][end]:
                path.append(s[start:end + 1])
                backtrack(end + 1, path)
                path.pop()

    backtrack(0, [])
    return result

Walkthrough — LC 131 Palindrome Partitioning

This template solves: LC 131 (Palindrome Partitioning), LC 93 (Restore IP Addresses — segments are “≤255, no leading zero” instead of “palindrome”), LC 842 (Split into Fibonacci — segment validity is a running numeric rule).

Full statement. Given a string s, partition it so that every substring of the partition is a palindrome. Return all possible partitionings. Example: s = "aab" → [["a","a","b"],["aa","b"]].

The pattern-focused view: partitioning is “where do I cut next?” The choices at a node are all prefixes s[start:end+1]; a prefix is only a valid choice if it passes the segment test (here: is-palindrome). Recurse from just past the cut. This is the universal partition shape — swap the validity test and it becomes IP-address or Fibonacci splitting.

def partition(s):
    result = []

    def is_palindrome(l, r):
        while l < r:
            if s[l] != s[r]:
                return False
            l += 1
            r -= 1
        return True

    def backtrack(start, path):
        if start == len(s):              # consumed the whole string
            result.append(path[:])
            return
        for end in range(start, len(s)):        # choose a cut point
            if is_palindrome(start, end):       # prune: non-palindrome prefix
                path.append(s[start:end + 1])   # choose the segment
                backtrack(end + 1, path)        # explore the rest
                path.pop()                      # unchoose

    backtrack(0, [])
    return result
s = "aab"

start=0
├─ cut "a"   (0..0 pal ✓)  path=["a"]
│   └─ start=1
│       ├─ cut "a"  (1..1 pal ✓)  path=["a","a"]
│       │   └─ start=2
│       │       └─ cut "b" (2..2 pal ✓) path=["a","a","b"]
│       │           └─ start=3 == len → RECORD ["a","a","b"]
│       └─ cut "ab" (1..2 pal ✗)  ← prune
├─ cut "aa"  (0..1 pal ✓)  path=["aa"]
│   └─ start=2
│       └─ cut "b" (2..2 pal ✓) path=["aa","b"]
│           └─ start=3 == len → RECORD ["aa","b"]
└─ cut "aab" (0..2 pal ✗)  ← prune

results: [["a","a","b"], ["aa","b"]]

★ Insight ─────────────────────────────────────

  • Partitioning problems all share this skeleton — the only thing that changes between LC 131, 93, and 842 is the is_valid(segment) test and, sometimes, a bound on how many pieces are allowed. Learn the shape once; the “prune when segment invalid” line is the pattern’s soul.
  • Recomputing is_palindrome on every prefix is O(n) per test → O(n·2ⁿ) overall. The DP-precompute version above fills is_pal[i][j] once so the test is O(1), a textbook “hoist the repeated sub-check out of the recursion” optimization — same trick DP memoization uses. ─────────────────────────────────────────────────

8. String Backtracking

Generate Parentheses

Generate all combinations of n pairs of well-formed parentheses.

n = 3:
((()))  (()())  (())()  ()(())  ()()()

Key constraint: at every prefix, #open ≥ #close
def generate_parentheses(n):
    result = []

    def backtrack(path, open_count, close_count):
        if len(path) == 2 * n:
            result.append(''.join(path))
            return

        if open_count < n:
            path.append('(')
            backtrack(path, open_count + 1, close_count)
            path.pop()

        if close_count < open_count:
            path.append(')')
            backtrack(path, open_count, close_count + 1)
            path.pop()

    backtrack([], 0, 0)
    return result

Walkthrough — LC 22 Generate Parentheses

This template solves: LC 22 (Generate Parentheses), LC 17 (Letter Combinations — same char-by-char build, choices from a fixed map), LC 784 (Letter Case Permutation — append lower/upper per char).

Full statement. Given n pairs of parentheses, generate all combinations of well-formed parentheses. Example: n = 3 → ["((()))","(()())","(())()","()(())","()()()"].

The pattern-focused view: build the string one character at a time; the choices are ( and ), but each is guarded by a counting invariant rather than a data structure. ( is legal while opens remain (open < n); ) is legal only while it wouldn’t outnumber opens (close < open). Those two guards make every generated string valid by construction — no post-filter needed.

def generateParenthesis(n):
    result = []

    def backtrack(path, open_count, close_count):
        if len(path) == 2 * n:                # used all n pairs
            result.append(''.join(path))
            return
        if open_count < n:                    # can still open
            path.append('(')
            backtrack(path, open_count + 1, close_count)
            path.pop()
        if close_count < open_count:          # closing stays balanced
            path.append(')')
            backtrack(path, open_count, close_count + 1)
            path.pop()

    backtrack([], 0, 0)
    return result
n = 2   (o=open used, c=close used)   guards cut invalid branches

""            o0 c0
├─ '('        o1 c0
│   ├─ '('    o2 c0
│   │   └─ ')' o2 c1 → ')' o2 c2  → "(())" ✓
│   └─ ')'    o1 c1
│       └─ '(' o2 c1 → ')' o2 c2  → "()()" ✓
└─ ')'  ✗  close_count(0) < open_count(0) is FALSE → branch never taken

results (n=2): ["(())","()()"]

★ Insight ─────────────────────────────────────

  • The two if guards are the pruning — there’s no separate “is this valid?” check at the leaf. close_count < open_count is what forbids ")(": you can never close what you haven’t opened. Constraint-by-construction beats generate-then-filter, which would waste time building the 2^(2n) raw strings.
  • This is the “build a string under a running rule” archetype. Swap the guards for a digit→letters map and it’s LC 17; swap for “lower or upper per alpha char” and it’s LC 784. The append/recurse/pop spine is identical. ─────────────────────────────────────────────────

Letter Combinations of Phone Number

2 → "abc", 3 → "def", ..., 9 → "wxyz"
Input: "23"
Output: ["ad","ae","af","bd","be","bf","cd","ce","cf"]
def letter_combinations(digits):
    if not digits:
        return []

    mapping = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
    result = []

    def backtrack(index, path):
        if index == len(digits):
            result.append(''.join(path))
            return
        for ch in mapping[digits[index]]:
            path.append(ch)
            backtrack(index + 1, path)
            path.pop()

    backtrack(0, [])
    return result

Restore IP Addresses

Input: "25525511135"
Output: ["255.255.11.135", "255.255.111.35"]
def restore_ip_addresses(s):
    result = []

    def backtrack(start, parts):
        if len(parts) == 4:
            if start == len(s):
                result.append('.'.join(parts))
            return

        remaining_digits = len(s) - start
        remaining_parts = 4 - len(parts)

        # Pruning: each remaining part needs 1-3 digits
        if remaining_digits < remaining_parts or remaining_digits > remaining_parts * 3:
            return

        for length in range(1, 4):
            if start + length > len(s):
                break
            segment = s[start:start + length]
            # No leading zeros (except "0" itself)
            if len(segment) > 1 and segment[0] == '0':
                break
            if int(segment) > 255:
                break
            parts.append(segment)
            backtrack(start + length, parts)
            parts.pop()

    backtrack(0, [])
    return result

Expression: Add Operators

Insert +, -, * between digits to reach a target.

def add_operators(num, target):
    result = []

    def backtrack(index, path, value, prev_operand):
        if index == len(num):
            if value == target:
                result.append(path)
            return

        for i in range(index, len(num)):
            # No leading zeros
            if i > index and num[index] == '0':
                break

            substr = num[index:i + 1]
            curr = int(substr)

            if index == 0:
                # First number, no operator
                backtrack(i + 1, substr, curr, curr)
            else:
                # Addition
                backtrack(i + 1, path + '+' + substr,
                         value + curr, curr)
                # Subtraction
                backtrack(i + 1, path + '-' + substr,
                         value - curr, -curr)
                # Multiplication (need to undo previous addition/subtraction)
                backtrack(i + 1, path + '*' + substr,
                         value - prev_operand + prev_operand * curr,
                         prev_operand * curr)

    backtrack(0, '', 0, 0)
    return result

Why track prev_operand? Multiplication has higher precedence. If the expression so far evaluates to 2 + 3 and we multiply by 4, the result should be 2 + 3*4 = 14, not (2+3)*4 = 20. So we undo +3 and redo +3*4.


9. Pruning Techniques

Pruning is what makes backtracking practical. Without it, you’re just doing brute force.

★ Insight ─────────────────────────────────────

  • The five prune types below attack the tree at different points: feasibility cuts a branch the moment it’s provably dead; bound cuts branches that can’t beat the best-so-far (needs a running best); symmetry cuts branches that are mirror images of one already tried; ordering doesn’t cut anything itself but makes the other prunes fire earlier; memoization cuts by never re-solving a state — which is the exact moment backtracking becomes DP.
  • Ordering is the cheap force-multiplier: sorting nums descending so big elements fail fast (partition, combination-sum) often turns a TLE into an instant answer with zero change to the logic — only the iteration order moves. ─────────────────────────────────────────────────

Type 1: Feasibility Pruning

Stop early when the current path can’t possibly lead to a solution.

# Combination Sum: if remaining < 0, stop
def backtrack(start, path, remaining):
    if remaining < 0:
        return  # PRUNE — overshot the target
    if remaining == 0:
        result.append(path[:])
        return
    ...

Type 2: Bound Pruning (Branch and Bound)

Estimate the best possible outcome from the current state. If it’s worse than the best known solution, prune.

# Traveling Salesman: prune if current_cost + estimated_remaining > best_known
def backtrack(current_city, visited, cost):
    lower_bound = cost + estimate_remaining(visited)
    if lower_bound >= best_known:
        return  # PRUNE — can't beat current best

    if len(visited) == n:
        best_known = min(best_known, cost + dist[current_city][0])
        return
    ...

Type 3: Symmetry Pruning

If multiple choices lead to equivalent states, try only one.

# N-Queens: the board has vertical symmetry
# Only place first queen in columns 0..n//2, mirror the rest
def backtrack(row, queens):
    if row == 0:
        end = (n + 1) // 2  # only half the columns
    else:
        end = n
    for col in range(end):
        ...
# Partition into k subsets: identical empty buckets are interchangeable
seen = set()
for i in range(k):
    if buckets[i] in seen:
        continue  # this bucket state already tried
    seen.add(buckets[i])
    ...

Type 4: Ordering Pruning

Process elements in a smart order to trigger other pruning earlier.

# Sort in decreasing order: large elements fail faster, pruning more branches
nums.sort(reverse=True)

# Sudoku: pick the cell with fewest candidates (MRV heuristic)
def find_best_cell(board):
    min_options = 10
    best = None
    for r in range(9):
        for c in range(9):
            if board[r][c] == 0:
                options = count_valid_digits(r, c)
                if options < min_options:
                    min_options = options
                    best = (r, c)
    return best

Type 5: Memoization (When Subproblems Repeat)

If the same state can be reached via different paths, cache the result. This turns backtracking into dynamic programming.

# Word Break: can we segment string into dictionary words?
from functools import lru_cache

@lru_cache(maxsize=None)
def can_break(start):
    if start == len(s):
        return True
    for end in range(start + 1, len(s) + 1):
        if s[start:end] in word_set and can_break(end):
            return True
    return False

Pruning Impact Summary

Without pruning:     O(n!)   or  O(2^n)   or  O(k^n)
With pruning:        Often orders of magnitude faster

Example — N-Queens:
  n=8:  Without pruning: 8^8 = 16M states
        With col/diag pruning: ~15K states
        Speedup: ~1000x

10. Backtracking + Bitmask

Why Bitmask?

A bitmask compresses a set of boolean flags into a single integer. Instead of used = [False, True, True, False], use used = 0b0110 = 6.

Benefits

Boolean Array Bitmask
O(n) to copy O(1) to copy
O(n) to compare O(1) to compare
Can’t hash easily Hash as integer
Mutable (must undo) Immutable operations

Permutations with Bitmask

def permutations_bitmask(nums):
    n = len(nums)
    result = []

    def backtrack(mask, path):
        if mask == (1 << n) - 1:  # all bits set
            result.append(path[:])
            return
        for i in range(n):
            if mask & (1 << i):  # already used
                continue
            path.append(nums[i])
            backtrack(mask | (1 << i), path)
            path.pop()

    backtrack(0, [])
    return result

Bitmask DP on Permutations (TSP-Style)

When you need to count or optimize over permutations, combine bitmask with memoization:

from functools import lru_cache

def shortest_hamiltonian_path(dist, n):
    """Find shortest path visiting all n cities."""
    @lru_cache(maxsize=None)
    def dp(mask, last):
        if mask == (1 << n) - 1:
            return 0  # or dist[last][0] for cycle

        best = float('inf')
        for nxt in range(n):
            if mask & (1 << nxt):
                continue
            best = min(best, dist[last][nxt] + dp(mask | (1 << nxt), nxt))
        return best

    return min(dp(1 << i, i) for i in range(n))

N-Queens with Bitmask

Track column and diagonal conflicts as bitmasks — checking and updating all three in O(1).

def n_queens_bitmask(n):
    count = 0

    def backtrack(row, cols, diag1, diag2):
        nonlocal count
        if row == n:
            count += 1
            return

        # Available positions: bits NOT set in any conflict mask
        available = ((1 << n) - 1) & ~(cols | diag1 | diag2)

        while available:
            # Pick lowest set bit
            pos = available & (-available)
            available &= available - 1

            backtrack(
                row + 1,
                cols | pos,
                (diag1 | pos) << 1,    # shift left: row-col increases
                (diag2 | pos) >> 1     # shift right: row+col decreases
            )

    backtrack(0, 0, 0, 0)
    return count

Why shift the diagonal masks? When moving to the next row:

  • A queen’s main diagonal (↘) threat shifts one column right (<< 1)
  • A queen’s anti-diagonal (↙) threat shifts one column left (>> 1)

This is faster than using sets because all operations are bitwise O(1).


11. Constraint Satisfaction Problems

Graph Coloring

Color a graph with at most m colors such that no two adjacent nodes share a color.

def graph_coloring(adj, n, m):
    """Can we color graph with m colors? adj = adjacency list."""
    colors = [0] * n  # 0 = uncolored

    def is_safe(node, color):
        for neighbor in adj[node]:
            if colors[neighbor] == color:
                return False
        return True

    def backtrack(node):
        if node == n:
            return True

        for color in range(1, m + 1):
            if is_safe(node, color):
                colors[node] = color
                if backtrack(node + 1):
                    return True
                colors[node] = 0

        return False

    return backtrack(0)

Hamiltonian Path

Find a path that visits every vertex exactly once.

def hamiltonian_path(adj, n):
    """Find a Hamiltonian path if one exists."""
    path = []
    visited = [False] * n

    def backtrack(node):
        path.append(node)
        if len(path) == n:
            return True

        visited[node] = True
        for neighbor in adj[node]:
            if not visited[neighbor]:
                if backtrack(neighbor):
                    return True
        visited[node] = False
        path.pop()
        return False

    for start in range(n):
        if backtrack(start):
            return path
    return None

Cryptarithmetic (SEND + MORE = MONEY)

def solve_cryptarithmetic():
    """Solve SEND + MORE = MONEY."""
    letters = 'SENDMORY'  # 8 unique letters
    leading = {'S', 'M'}  # can't be 0

    def backtrack(idx, assignment, used_digits):
        if idx == len(letters):
            # Check the equation
            s = lambda w: int(''.join(str(assignment[c]) for c in w))
            return s('SEND') + s('MORE') == s('MONEY')

        letter = letters[idx]
        start = 1 if letter in leading else 0

        for digit in range(start, 10):
            if digit in used_digits:
                continue
            assignment[letter] = digit
            used_digits.add(digit)
            if backtrack(idx + 1, assignment, used_digits):
                return True
            del assignment[letter]
            used_digits.discard(digit)

        return False

    assignment = {}
    if backtrack(0, assignment, set()):
        return assignment
    return None

12. Common Patterns Collection

Pattern A: “Generate All X” → Backtracking

Whenever a problem asks to generate all valid configurations:

Generate all:
  - Permutations       → Section 2
  - Combinations       → Section 3
  - Subsets            → Section 4
  - Palindrome splits  → Section 7
  - Valid parentheses  → Section 8
  - IP addresses       → Section 8

Pattern B: “Can We Achieve X?” → Backtracking with Early Return

When the answer is True/False, return as soon as you find one solution:

def can_achieve(state):
    if is_solution(state):
        return True  # found one!
    for choice in candidates:
        if is_valid(state, choice):
            apply(state, choice)
            if can_achieve(state):  # propagate True immediately
                return True
            undo(state, choice)
    return False  # exhausted all options

Pattern C: “Find Optimal X” → Backtracking with Bound

Track the best solution found so far and prune branches that can’t improve it:

best = float('inf')

def find_optimal(state, cost):
    global best
    if is_complete(state):
        best = min(best, cost)
        return
    if cost + lower_bound(state) >= best:
        return  # PRUNE
    for choice in candidates:
        apply(state, choice)
        find_optimal(state, cost + choice_cost)
        undo(state, choice)

Pattern D: Counting Solutions → Backtracking with Counter

def count_solutions(state):
    if is_complete(state):
        return 1
    total = 0
    for choice in candidates:
        if is_valid(state, choice):
            apply(state, choice)
            total += count_solutions(state)
            undo(state, choice)
    return total

Pattern E: Incremental Constraint Checking

Don’t re-validate the entire state from scratch. Instead, maintain constraint data structures and update them incrementally:

N-Queens:
  BAD:  for each new queen, scan entire board for conflicts → O(n) per check
  GOOD: maintain sets for cols, diag1, diag2 → O(1) per check

Sudoku:
  BAD:  for each digit, scan row + col + box → O(n) per check
  GOOD: maintain sets for each row, col, box → O(1) per check

13. Pattern Recognition Cheat Sheet

Decision Flowchart

Problem asks to...
   │
   ├── "Generate ALL valid ___"
   │     │
   │     ├── Order matters? → Permutations (Section 2)
   │     ├── Order doesn't matter, fixed size? → Combinations (Section 3)
   │     ├── Order doesn't matter, any size? → Subsets (Section 4)
   │     ├── Split/partition something? → Partitioning (Section 7)
   │     └── Build a string char by char? → String backtracking (Section 8)
   │
   ├── "Can we ___?" (yes/no)
   │     → Backtracking with early return (Pattern B)
   │
   ├── "Find the best ___"
   │     → Branch and bound (Pattern C)
   │
   ├── "Count how many ___"
   │     │
   │     ├── State space small? → Backtracking with counter
   │     └── Overlapping subproblems? → DP (memoized backtracking)
   │
   └── "Place items on grid with constraints"
         → Grid backtracking (Section 6)

Complexity Summary

Problem # Solutions Time
Permutations of n n! O(n × n!)
Permutations with dups n! / (k₁! × k₂! × …) O(n × result)
Combinations C(n,k) C(n,k) O(k × C(n,k))
Subsets of n 2^n O(n × 2^n)
N-Queens varies (~n!/nᵏ) O(n!) worst
Sudoku (9×9) 1 O(9^empty) worst
Graph coloring (m colors) varies O(m^n) worst
Hamiltonian path varies O(n!) worst

When Backtracking vs DP

Use Backtracking Use DP
Need all solutions Need count or optimal
State space has no overlap Subproblems overlap
Constraints are complex (hard to encode as DP state) State can be encoded as tuple/mask
Small input (n ≤ ~20) Larger input (n ≤ ~1000+)

★ Insight ─────────────────────────────────────

  • The dividing line is overlap. Backtracking and memoized-DP walk the same decision tree; DP wins only when different branches reach the same subproblem so the cache pays off. If every partial state is unique (all permutations, all subsets), there’s nothing to cache and backtracking is already optimal.
  • Practical tell: if you can name the state as a small tuple ((index, remaining), (mask, last)) and two different choice-sequences can land on it, add @lru_cache and you’ve upgraded to DP for free. If the state is the whole path (an ordering), it can’t collapse — stay with backtracking. ─────────────────────────────────────────────────

When Backtracking vs Greedy

Use Backtracking Use Greedy
Need to explore multiple paths Locally optimal = globally optimal
No greedy property exists Matroid / exchange argument works
Need exact answer Need approximate or provably optimal

Common Mistakes to Avoid

Mistake Fix
Forgetting to copy path when saving Use path[:] or list(path)
Not undoing the choice (backtrack step) Always pair choose with unchoose
Generating duplicate solutions Sort + skip (Section 5)
Not pruning → TLE Add feasibility / bound / symmetry pruning
Infinite recursion Ensure state progresses (index increases, mask grows)
Modifying iteration variable Use index-based loop, not iterator over mutable collection

Template Quick Reference

Pseudocode — shapes to recall, not runnable code.

# Permutations
def perms(nums):
    backtrack with used[] or swap

# Combinations
def combs(n, k):
    backtrack(start, path)  # start ensures no duplicates

# Subsets
def subs(nums):
    backtrack(start, path)  # record at every node, not just leaves

# With duplicates
nums.sort()
if i > start and nums[i] == nums[i-1]: continue

# Grid placement
for col in range(n):
    if valid(row, col): place and recurse on row+1

# String building
for candidate_char in options:
    if constraints_met: append and recurse on index+1

Backtracking is systematic trial-and-error. The “trial” part is choosing a candidate; the “error” part is recognizing dead ends and undoing the choice. Master the template, learn the pruning tricks, and know when to switch to DP — that covers 90% of backtracking problems.


The Backtracking Toolbox at a Glance

Every backtracking algorithm is one DFS. Only two things change per problem:

  CHOICES at each node          PRUNE / SKIP when
  ────────────────────          ─────────────────
  unused elements        (46)   already in path
  indices ≥ start        (78)   (none — record every node)
  distinct values        (90)   equals left sibling: i>start & a[i]==a[i-1]
  4 grid neighbors       (79)   off-grid / wrong char / visited
  '(' or ')'             (22)   open==n  /  close==open
  valid prefixes        (131)   segment fails the validity test

The spine never changes:
  choose  →  recurse  →  UNCHOOSE   (the pop() is the "back" in backtracking)

Then the heavy pruning, when the raw tree is too big:
  overshoot the target        → feasibility prune (remaining < 0)
  can't beat best-so-far      → bound prune (branch & bound)
  interchangeable branches    → symmetry prune (empty-bucket set)
  subproblems repeat          → memoize → it's now DP

LeetCode Practice Ladder (the 6 walkthroughs)

46  (Medium) ── permutations: choices = unused elements
    │
    ▼
78  (Medium) ── subsets: record every node, i+1 locks order
    │
    ▼
90  (Medium) ── subsets II: sort + skip duplicate sibling
    │
    ▼
22  (Medium) ── generate parens: constraint-by-construction
    │
    ▼
131 (Medium) ── palindrome partition: choices = valid prefixes
    │
    ▼
79  (Medium) ── word search: grid DFS + in-place visited

Then step up: 47/40 add the duplicate skip to permutations/combination-sum; 51 N-Queens adds diagonal pruning; 37 Sudoku adds MRV ordering; 39 Combination Sum swaps i+1→i for reuse.


When This Fails

Backtracking enumerates; that is both its purpose and its limit:

  • You only need a count or an optimum. Enumerating every solution to count them is exponential where DP is polynomial. The tell is subproblems recurring with the same state.
  • The search space is exponential with no effective pruning. Without a constraint that kills branches early, backtracking is brute force with extra steps. If you cannot prune, reconsider.
  • The recursion is too deep. Python’s default limit is 1000 frames.
  • State is not restored on the way back up. Every mutation on the way down needs an exact undo on the way up. A missed undo corrupts every sibling branch that follows — and it usually produces almost right answers, which is the hardest kind of bug to spot.
  • You appended the path without copying it. result.append(path) stores a reference that later mutations will change. Use path[:] — unless path is a string, which is immutable.

Self-Test

Answer these from memory, out loud or on paper, before looking. Recognition is not recall: rereading an explanation feels like knowing, and it is not. A question you cannot answer cold names the exact section to revisit — you do not need to reread the guide.

1. What are the three moves in every backtracking function?

Answer

Choose (apply a candidate to the state), explore (recurse), un-choose (undo exactly what you applied). If the undo does not mirror the choose precisely, sibling branches inherit corrupted state.

2. Why result.append(path[:]) and not result.append(path)?

Answer

path is a single list mutated throughout the search. Storing the reference means every recorded solution ends up pointing at the same object, showing whatever it holds at the end. The slice takes a snapshot. Strings need no copy because they are immutable.

3. How do you avoid duplicate results when the input has duplicates?

Answer

Sort first, then within a single recursion level skip a candidate equal to the previous one: if i > start and nums[i] == nums[i-1]: continue. The i > start is essential — it permits duplicates at different depths (a legitimate repeat) while blocking them at the same depth (the same set in a different order).

4. For permutations with duplicates, the skip condition adds and not used[i-1]. Why?

Answer

It enforces that among equal values, the earlier index is always placed first. Without it, swapping two identical values counts as a distinct permutation. The not used[i-1] check means “the previous equal element is not currently in the path, so using this one now would be an out-of-order repeat”.

5. Combinations use i + 1 in the recursive call; combination-sum-with-reuse uses i. Why?

Answer

i + 1 moves past the current element so it cannot be chosen again. i allows reusing the same element. That single character is the entire difference between LC 39 and LC 40.

6. Name three pruning techniques stronger than “check validity at the leaf”.

Answer

Check feasibility on the way down and cut immediately (bound pruning). Order candidates most-constrained-first so failures surface early (MRV, as in Sudoku). Break symmetry by fixing an arbitrary choice — for N-Queens, restricting the first row’s column to the left half halves the search.

7. When should backtracking become DP?

Answer

When the same state recurs with the same future. If two different paths reach an identical (position, resources-used) state, everything below is identical and should be computed once. Memoize; if the state is a subset, that is a bitmask DP.

8. How do you detect a missing undo?

Answer

Solutions come out subtly wrong rather than crashing — an extra element, a stale flag, a board that never fully resets. Print the state at the top of each call and check it matches the state at the same depth on the previous branch.


See Also

  • Dynamic Programming — the same decision tree, but when you only need to count or optimize rather than list solutions. Overlapping subproblems mean memoize, not enumerate.
  • Bitmask Techniques — replaces the used[] array with a single integer once n <= 20, which is what makes the state memoizable.
  • Bitmask DP — Subset Partition — where §10 leads: once the search repeats states, the recursion becomes a dp[mask] table.
  • Graph Patterns — backtracking on an explicit graph (Hamiltonian paths, colouring) sits on the DFS machinery there.
  • Pattern Decision Map — the router: which technique does a cold problem call for?
  • Pattern Mastery Program — the spaced-repetition schedule, mastery checklist, and drill formats that turn reading into recall.

Pattern mastered — walk the decision tree depth-first, undo each choice on the way back up, and cut every branch you can already prove is doomed. The savings live in the branches you never visit.