Notice Period
week 5 · lesson

Backtracking

Try it, undo it, try the next thing.

Backtracking is structured brute force: explore every candidate solution by building it one choice at a time, and the moment a partial build can't possibly work, rip the last choice out and try another. One template — choose, explore, un-choose — generates subsets, permutations, sudoku solvers, and N-Queens. Learn the skeleton once and every "generate all…" problem becomes a fill-in-the-blanks exercise.

01When the constraints scream backtracking

Look at the input size before you look at anything else. Backtracking explores an exponential space — 2^n subsets, n! permutations — so it's only viable when n is tiny.

ConstraintWhat it whispers
n <= 10^5O(n) or O(n log n) — hash maps, sorting, two pointers
n <= 1000O(n²) is fine
n <= ~20exponential is expected — backtracking / bitmask

2^20 ≈ 10^6 — comfortably fast. 2^40 is a trillion — dead. So when you see nums.length <= 20, or "return all subsets / permutations / combinations / valid boards", the interviewer is telling you to enumerate exhaustively. The skill being tested isn't cleverness; it's whether you can enumerate without missing anything and without repeating anything.

Tell: the word "all" in the output ("return all…") plus a small n is the loudest backtracking signal in interviews.

02The decision tree: every solution is a root-to-leaf path

Picture the search as a tree. The root is the empty partial solution. Each edge is one choice (include this number, place a queen in this column, cut the string here). Each leaf is a complete candidate.

nums = [1, 2]            []
                  include 1 /  \ skip 1
                        [1]    []
               include 2 / \    / \ include 2
                   [1,2] [1]  [2]  []

Backtracking is just depth-first search on this tree — except the tree is never built in memory. You walk it: descend by making a choice, and when you return, undo the choice so the shared path list is clean for the next branch.

Two questions design the whole tree:

  1. What are the choices at this node? (which elements / positions / cuts are still legal)
  2. When is a node a leaf? (the partial solution is complete — or provably hopeless)

Answer those two and the code writes itself.

nums = [1, 2, 3] · enumerate all 2³ = 8 subsetsstep 1/23
{}{1}{2}{3}{1,2}{1,3}{2,3}{1,2,3}
current path = [] · recorded subsets
{}

Enter the root with an empty path — record {} first; the empty set is a subset too.

pseudocode
def backtrack(start, path):
record(path[:]) # every node is a subset
for i in range(start, len(nums)):
path.append(nums[i]) # CHOOSE
backtrack(i + 1, path) # EXPLORE
path.pop() # UN-CHOOSE
# 2^3 = 8 subsets recorded

03The universal template: choose → explore → un-choose

Every backtracking solution you will ever write is this function wearing a different costume:

def solve(problem):
    results = []
    path = []                          # the partial solution, shared & mutated

    def backtrack(state):
        if is_complete(state):         # leaf: record a snapshot
            results.append(path[:])    # copy! path keeps mutating after this
            return
        for choice in legal_choices(state):
            path.append(choice)        # 1. CHOOSE
            backtrack(next_state)      # 2. EXPLORE
            path.pop()                 # 3. UN-CHOOSE — restore exactly

    backtrack(initial_state)
    return results

Three rules that prevent 90% of bugs:

  • Snapshot at the leaf. results.append(path) stores a reference to a list you're about to mutate — every result ends up empty. Always path[:] or list(path).
  • Undo must mirror do. Whatever append/mark/set you did before recursing, reverse it after — exactly once, unconditionally.
  • Pass the frontier, not the history. A start index (combinations) or a used set (permutations) tells each node which choices remain. start enforces "only look rightwards," which is what kills duplicate orderings of the same combination.

04Pruning: stop digging when the hole is hopeless

Raw backtracking visits every node. Pruning chops entire subtrees the moment a partial solution becomes impossible — and it's the difference between "times out" and "instant."

def backtrack(start, remaining):
    if remaining == 0:
        results.append(path[:]); return
    for i in range(start, len(candidates)):
        if candidates[i] > remaining:   # sorted input ⇒ everything after
            break                       # is bigger too: prune the rest
        path.append(candidates[i])
        backtrack(i, remaining - candidates[i])
        path.pop()

The classic pruning moves:

  • Sort first, then break when a candidate overshoots — every later candidate overshoots too.
  • Check constraints on the way down, not at the leaf. In N-Queens, test column and diagonal conflicts before placing — don't build a full board and validate it at the end.
  • Skip duplicate branches: with sorted input, if i > start and nums[i] == nums[i-1]: continue ensures only the first copy of a value starts a branch at each tree level. Same subtree, explored once.

Say the word "prune" in the interview. Naming the optimization is worth as much as coding it.

05Reading a problem into the template

The template has four slots. Filling them is solving the problem:

ProblemChoice at each nodeLeaf conditionPrune
Subsetsinclude/skip nums[i]considered every index— (every node is also a result)
Permutationsany unused elementlen(path) == nskip used elements
Combination sumany candidate from startremaining hits 0candidate > remaining
Palindrome cutsnext cut positionconsumed whole stringprefix isn't a palindrome
N-Queenscolumn for current rowplaced n queenscolumn/diagonal under attack

Notice the complexity is the size of the output times the cost per leaf — O(n · 2^n) for subsets, O(n · n!) for permutations. You can't beat the output size; when asked "can you do better?", that's the answer.

Interview script: state the choices, state the leaf, write the skeleton, then ask yourself "what branches can I prove dead early?" — out loud.

Prove it — checkpoint quiz

1

A problem says nums.length <= 18 and asks for all valid combinations. What's the intended approach?

2

Inside backtrack(), why is results.append(path) a bug while results.append(path[:]) is correct?

3

In combination problems, what does passing a start index to the recursion accomplish?

4

You sort candidates ascending in combination-sum. Why does that unlock `break` (not just `continue`) when candidates[i] > remaining?

Now forge it

Reading is scaffolding. These drills are the building. Easiest first: