Pattern 09 of 20

Backtracking

Build candidates one choice at a time and undo dead ends - permutations, combinations, and constraint puzzles.

What backtracking is

Backtracking is DFS over a decision tree: at each step, try a choice, recurse deeper with that choice applied, then undo it and try the next. The undo is the defining move - one shared, mutable partial solution is threaded through the whole search instead of copying state at every node, and 'unchoose' restores it exactly as it was.

It is the honest tool for problems whose output is exponential (all subsets, all permutations) or whose structure resists anything smarter (N-Queens, Sudoku). You cannot beat exponential output size - the craft is in not exploring branches that cannot succeed.

How to recognize it

Signals: 'all', 'generate every', 'enumerate' - all subsets, all permutations, all valid combinations, all palindromic partitions. Also constraint-satisfaction puzzles asking for any valid arrangement under interacting rules.

The counterpart signal: if the ask is 'count the ways' or 'find the best one' without listing them, dynamic programming often replaces the enumeration at polynomial cost. 'List them' is backtracking; 'count them' deserves a DP attempt first.

The choose-explore-unchoose template

Every backtracking solution is the same skeleton: a recursive function carrying the partial solution and a position marker; a base case that records a completed candidate (copy it! it is shared state); a loop over available choices, each wrapped in choose -> recurse -> unchoose. Design questions per problem: what is a choice, what makes the candidate complete, and what lets you rule a branch out early.

pseudocode
def backtrack(start, path):
    if complete(path):
        results.append(copy(path))     # copy - path is shared
        return
    for choice in options(start):
        if not viable(choice): continue   # prune
        path.append(choice)               # choose
        backtrack(next_position, path)    # explore
        path.pop()                        # unchoose

Pruning and duplicates

Pruning is what makes exponential search usable: check constraints at choice time, not at leaf time. In N-Queens, testing column and diagonal conflicts before placing prunes almost the entire tree; validating full boards at the leaves is astronomically slower.

Duplicate outputs come from equal elements being chosen in different orders. The standard cure: sort the input, then at each depth skip an element equal to its unused predecessor ('if i > start and a[i] == a[i-1]: continue'). That yields each distinct candidate exactly once without a dedupe set.

Common pitfalls

Appending the shared path itself instead of a copy at the base case - every recorded result later mutates into the same empty list.

Forgetting the unchoose step, or unchoosing a different amount than was chosen - the state corrupts silently and later branches see ghosts.

Wrong duplicate-skip condition: 'i > start' (skip within the same depth) not 'i > 0' (which also bans legitimate reuse across depths).

Pruning too late: any check performable before the recursive call must happen there.

Passing index-1 style mistakes: subsets/combinations advance the start index (order does not matter); permutations track a used set instead (order matters). Mixing the two produces duplicates or omissions.

Worked examples

1. Subsetsmedium

Given an array of distinct integers, return every possible subset.

  1. Frame each element as a binary choice: in or out. The decision tree has 2^n leaves - every subset appears at exactly one node when you record the path at every call, not just at leaves.
  2. Use a start index so combinations are generated in one canonical order (never look backwards), which is what prevents duplicates like {1,2} and {2,1}.
  3. At each call: record a copy of the current path, then loop i from start to end, choosing a[i], recursing with i+1, unchoosing.
  4. O(n x 2^n) total output work - optimal, since that is the output's size.
pseudocode
def backtrack(start, path):
    results.append(copy(path))     # every node is a subset
    for i in start .. n-1:
        path.append(a[i])
        backtrack(i + 1, path)
        path.pop()
backtrack(0, [])

Remember: For subsets, every node in the decision tree is an answer - record on entry, and the start index keeps each subset canonical.

2. Permutationsmedium

Given an array of distinct integers, return all possible orderings.

  1. Order matters now, so a start index is wrong - every unused element is a valid next choice at every depth. Track used-ness instead.
  2. Recurse: for each element not yet used, mark used, append, recurse, then undo both. Complete when the path length reaches n.
  3. The used array and the path must stay in lockstep through choose/unchoose - this is where sloppy undo code dies.
  4. n! leaves, O(n x n!) total work; nothing polynomial can list them, and saying so is part of a good answer.
pseudocode
def backtrack(path):
    if len(path) == n:
        results.append(copy(path)); return
    for i in 0 .. n-1:
        if used[i]: continue
        used[i] = true; path.append(a[i])
        backtrack(path)
        path.pop(); used[i] = false
backtrack([])

Remember: Subsets advance a start index because order is irrelevant; permutations sweep all unused elements because order is the point - pick the mechanism to match.

3. N-Queenshard

Place n queens on an n x n board so that no two attack each other; return all distinct arrangements.

  1. Place one queen per row - that alone kills row conflicts and shrinks choices to 'which column in this row'.
  2. Track attacked columns and both diagonal families in sets: cells share a diagonal exactly when row - col matches (one family) or row + col matches (the other).
  3. At each row, try only columns whose column and two diagonal keys are all free: choose (add to three sets), recurse to the next row, unchoose (remove from all three).
  4. Reaching row n means a full valid placement - record it. The constraint sets prune so hard that n = 8 explores a few thousand nodes instead of billions.
pseudocode
def place(row):
    if row == n: record(board); return
    for col in 0 .. n-1:
        if col in cols or (row-col) in diag1 or (row+col) in diag2: continue
        add col, row-col, row+col to the sets; board[row] = col
        place(row + 1)
        remove them                      # unchoose all three
place(0)

Remember: Encode each constraint as an O(1) set lookup keyed by an invariant (col, row-col, row+col) - pruning at choice time is what collapses the search.

Check yourself

Try to answer before revealing - retrieving from memory is what makes it stick.

1Why must the path be copied when recording a completed candidate?
  • Copying is faster than referencing
  • The path is shared mutable state that the ongoing search will keep changing - stored references would all end up identical
  • To keep results sorted
  • Because recursion frames are deallocated

One list is threaded through the entire search and unwound by unchoose steps. Every appended reference points at that same list, which is empty again when the search finishes.

2Subsets use a start index while permutations use a used-set. Why the difference?
  • Permutations are larger, needing more memory
  • When order is irrelevant, forcing one canonical order (never look back) prevents duplicates; when order matters, every unused element must be reachable at every depth
  • The start index is an optimization that permutations cannot afford
  • There is no real difference - either works for both

A start index generates each combination exactly once in sorted position order. Applying it to permutations would forbid choosing earlier elements later, missing most orderings; the used-set is the order-sensitive equivalent.

3Where should constraint checks live for effective pruning?
  • At the leaves, validating complete candidates
  • Before each recursive call, so a doomed branch is never entered
  • After the search, filtering results
  • In a separate validation pass

A conflict detected at depth k kills the entire subtree below it. Leaf-time validation explores that whole subtree first - for N-Queens the difference is billions of nodes versus thousands.

4With sorted input containing duplicates, what is the correct skip to avoid duplicate combinations?
  • if a[i] == a[i-1]: continue, always
  • if i > start and a[i] == a[i-1]: continue
  • if i > 0 and a[i] == a[i-1]: continue
  • Deduplicate the results with a set afterwards

The rule targets siblings at the same depth: among equal elements, only the first may start a branch at this level. 'i > 0' over-skips legitimate consecutive equal picks across depths; a results-set works but concedes the exponential duplicate work.

Frequently asked questions

How is backtracking different from plain DFS?

Backtracking is DFS over a constructed decision tree rather than a given graph, with shared mutable state that is explicitly undone on the way back up. The graph never exists in memory - only the current path does.

When should I abandon backtracking for dynamic programming?

When the question shifts from listing solutions to counting or optimizing them. Overlapping subproblems that a DP table can cache are wasted effort to re-enumerate; if the output itself is exponential, though, backtracking is already optimal.

How do I estimate whether the search will finish in time?

Bound the tree: branching factor to the power of depth, cut by your pruning. Subsets 2^n, permutations n!, so n around 20 for subsets and 10-11 for permutations are the practical ceilings interviewers have in mind when they choose the constraints.

Keep this pattern in memory

Reading it once won't stick. Sign in and CoderFlow will bring it back right before you'd forget it.

Start reviewing