datarekha

Backtracking

Searching a combinatorial space by building solutions one piece at a time and pruning dead branches early — the engine behind N-Queens, Sudoku, and constraint satisfaction.

7 min read Advanced Data Structures & Algorithms Lesson 28 of 32

What you'll learn

  • What backtracking is: depth-first search over a decision tree, with an undo on failure
  • How the choose-explore-unchoose template fits every backtracking problem
  • Why pruning is the line between tractable and exponential
  • Where constraint satisfaction and combinatorial search appear in data and ML work

Before you start

Backtracking is building a solution one piece at a time, and the moment a partial solution cannot possibly grow into a valid one, abandoning it and trying something else.

It is depth-first search over a decision tree — each level is one choice to make, each branch one option, each node a partial state, and each leaf either a complete answer or a dead end. The “backtrack” is simply undoing the last choice and trying the next sibling. Suppose you want every subset of {A, B, C}: at each element you decide include or skip, and the eight leaves are the eight subsets.

[ ]include Askip A[A][ ][A,B][A][B][ ][A,B,C][A,B][A,C][A][B,C][B][C][ ]left branch = include • right branch = skip • highlighted = the first depth-first dive

DFS dives left-first to [A,B,C], then backtracks. The moment a partial state is invalid, its whole subtree is skipped — that is pruning.

The template never changes

Every backtracking algorithm is the same three-move loop — choose, explore, un-choose:

def backtrack(state):
    if is_complete(state):
        record(state); return
    for choice in choices(state):
        if is_valid(state, choice):       # prune before recursing
            make(state, choice)           # choose
            backtrack(state)              # explore
            undo(state, choice)           # un-choose

The undo is what makes it backtracking rather than plain recursion: it restores the shared state exactly, so the next iteration starts from a clean slate. The only parts you ever swap are the validity check and what counts as complete. And that validity check — pruning — is everything: skip a branch the instant it cannot lead anywhere, and an exponential search becomes explorable; skip nothing, and you are brute-forcing every candidate.

Subsets: no pruning, genuinely 2ⁿ

Subsets are the case with no pruning, because every partial subset is valid — so the work is honestly 2ⁿ:

def all_subsets(nums):
    result = []
    def backtrack(start, current):
        result.append(list(current))          # every prefix is a valid subset
        for i in range(start, len(nums)):
            current.append(nums[i])           # choose
            backtrack(i + 1, current)         # explore
            current.pop()                     # un-choose
    backtrack(0, [])
    return result

print("subsets of [1,2,3,4]:", len(all_subsets([1, 2, 3, 4])))
for n in range(1, 6):
    print(f"  n={n}: {len(all_subsets(list(range(n))))} subsets  (2^{n} = {2**n})")
subsets of [1,2,3,4]: 16
  n=1: 2 subsets  (2^1 = 2)
  n=2: 4 subsets  (2^2 = 4)
  n=3: 8 subsets  (2^3 = 8)
  n=4: 16 subsets  (2^4 = 16)
  n=5: 32 subsets  (2^5 = 32)

N-Queens: where pruning earns its keep

Place n queens on an n×n board with none attacking another. The tree has n rows and n columns per row — nⁿ placements without pruning. But reject a column the instant it shares a column or diagonal with a queen already placed, and the search collapses to something you can run in milliseconds. Three sets give O(1) conflict checks:

def count_queens(n):
    cols, diag1, diag2 = set(), set(), set()
    count = 0
    def place(row):
        nonlocal count
        if row == n:
            count += 1; return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue                                  # prune: under attack
            cols.add(col); diag1.add(row - col); diag2.add(row + col)   # choose
            place(row + 1)                                # explore
            cols.discard(col); diag1.discard(row - col); diag2.discard(row + col)  # un-choose
    place(0)
    return count

for n in range(4, 11):
    print(f"{n}-Queens: {count_queens(n)} solutions")
4-Queens: 2 solutions
5-Queens: 10 solutions
6-Queens: 4 solutions
7-Queens: 40 solutions
8-Queens: 92 solutions
9-Queens: 352 solutions
10-Queens: 724 solutions

For 8 queens there are just 92 solutions among 16,777,216 raw placements — pruning lets you skip almost all of the dead ones. Worst-case backtracking is still exponential (you cannot beat an exponential answer space), but tight, early validity checks shrink the effective branching factor enormously. The same skeleton, with a constraint check swapped in, solves Sudoku (only legal digits per cell) and word search (in-bounds, unvisited, next letter matches).

Practice

Quick check

0/3
Q1What is the purpose of the 'un-choose' step?
Q2An N-Queens solver checks for conflicts before recursing and skips any attacked column. This is:
Q3Generating all subsets of a 20-element set with backtracking produces how many?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
Generate all subsets of a set — the power set — using backtracking.

At each step of a recursive walk through the input, you make a binary choice: include the current element or skip it. Recording the current path at every node of the recursion tree (not just the leaves) collects all 2^n subsets. A `start` index prevents duplicates by ensuring elements are only considered left-to-right.

Find all unique combinations of candidates that sum to a target, where each candidate may be used an unlimited number of times.

Use backtracking with a running total. At each step, try adding a candidate to the current path. If the total equals the target, record the path. If it exceeds the target, prune. Passing the same start index (not i+1) back into the recursion allows unlimited reuse of the same element.

Generate all permutations of a list of distinct integers using backtracking.

At each recursion level, swap one of the remaining (unused) elements into the current position, recurse to fill the rest, then swap back to restore the state. Alternatively, track a 'used' set and build the permutation in a separate path list. The result is all n! orderings.

Implement binary search correctly — and explain the off-by-one traps.

Binary search halves the search space each iteration to find a target in O(log n). The tricky part is not the idea but the boundary conditions: closed vs. half-open intervals, how to update lo/hi, and when to use lo < hi vs. lo <= hi. One clean template eliminates all the classic bugs.

Related lessons

Explore further

Skip to content