Skip to main content
DSA with Python advanced Lesson 10 of 10

Backtracking

One template for subsets, permutations and N-queens — the copy that everyone forgets, and pruning measured as the nodes it never visits.

Backtracking is one template applied to a family of problems. Once the template is muscle memory, the interview work is the pruning and the duplicate handling.

The template

def backtrack(state, choices, results):
    if is_complete(state):
        results.append(state[:])          # COPY — the state keeps changing
        return
    for choice in choices:
        if not is_valid(state, choice):
            continue                      # prune before recursing, not after
        state.append(choice)              # 1. choose
        backtrack(state, next_choices(choice), results)   # 2. explore
        state.pop()                       # 3. UNDO
    return results

Three lines matter: choose, explore, undo — and the copy on the way into results.

The copy everyone forgets

def subsets_broken(nums):
    out, path = [], []
    def dfs(i):
        if i == len(nums):
            out.append(path)              # NOT a copy
            return
        dfs(i + 1)
        path.append(nums[i])
        dfs(i + 1)
        path.pop()
    dfs(0)
    return out

def subsets(nums):
    out, path = [], []
    def dfs(i):
        if i == len(nums):
            out.append(path[:])           # copy
            return
        dfs(i + 1)
        path.append(nums[i])
        dfs(i + 1)
        path.pop()
    dfs(0)
    return out

print(f"broken: {subsets_broken([1, 2, 3])}")
print(f"fixed:  {subsets([1, 2, 3])}")
broken: [[], [], [], [], [], [], [], []]
fixed:  [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

Eight empty lists — the right count, so the shape looks plausible until you read the values. Every entry is the same list object, and by the time the search finishes it has been popped empty.

res = subsets_broken([1, 2])
print(f"all the same object: {res[0] is res[1] is res[2]}")
all the same object: True

path[:], list(path) or path.copy() all work. Say it while writing: “copying here because path is mutated as the search continues.”

Subsets, the iterative-choice form

def subsets_choice(nums):
    out, path = [], []
    def dfs(start):
        out.append(path[:])               # every node is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])
            dfs(i + 1)                    # i + 1: never reuse an element
            path.pop()
    dfs(0)
    return out

print(subsets_choice([1, 2, 3]))
print(f"count: {len(subsets_choice([1,2,3,4,5]))} = 2^5")
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
count: 32 = 2^5

start is what prevents [2, 1] appearing alongside [1, 2] — a subset is unordered, so each element is only ever considered after those before it.

Permutations: order matters, so track what is used

def permutations(nums):
    out, path, used = [], [], [False] * len(nums)
    def dfs():
        if len(path) == len(nums):
            out.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True; path.append(nums[i])
            dfs()
            path.pop(); used[i] = False   # undo BOTH
        return
    dfs()
    return out

print(permutations([1, 2, 3]))
print(f"count for 4 items: {len(permutations([1,2,3,4]))} = 4!")
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
count for 4 items: 24 = 4!

Forgetting used[i] = False in the undo is the second most common bug — it produces far too few results and is easy to miss, because the first branch is still correct.

Duplicates: sort, then skip siblings

def permutations_unique(nums):
    nums = sorted(nums)                   # duplicates become adjacent
    out, path, used = [], [], [False] * len(nums)
    def dfs():
        if len(path) == len(nums):
            out.append(path[:]); return
        for i in range(len(nums)):
            if used[i]:
                continue
            # skip a duplicate unless its identical predecessor is in use on this branch
            if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
                continue
            used[i] = True; path.append(nums[i])
            dfs()
            path.pop(); used[i] = False
    dfs()
    return out

print(f"naive on [1,1,2]:  {permutations([1,1,2])}")
print(f"unique on [1,1,2]: {permutations_unique([1,1,2])}")
naive on [1,1,2]:  [[1, 1, 2], [1, 2, 1], [1, 1, 2], [1, 2, 1], [2, 1, 1], [2, 1, 1]]
unique on [1,1,2]: [[1, 1, 2], [1, 2, 1], [2, 1, 1]]

Six results, three of them duplicates. The not used[i-1] condition is the subtle part:

“Sorting puts equal values next to each other. At each level I skip a value equal to its predecessor unless the predecessor is currently in the path — that means I am deeper in a branch that legitimately uses both copies, rather than starting a sibling branch that would repeat work already done. A set of tuples also works and costs hashing on every complete path; this costs nothing.”

Combination sum: reuse allowed, and pruning

def combination_sum(candidates, target):
    candidates = sorted(candidates)       # sorting enables the break
    out, path = [], []
    def dfs(start, remaining):
        if remaining == 0:
            out.append(path[:]); return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:
                break                     # sorted: everything after is also too big
            path.append(candidates[i])
            dfs(i, remaining - candidates[i])   # i, not i+1 — reuse allowed
            path.pop()
    dfs(0, target)
    return out

print(combination_sum([2, 3, 6, 7], 7))
print(combination_sum([2], 1))
[[2, 2, 3], [7]]
[]

dfs(i, ...) rather than dfs(i + 1, ...) is what allows an element to be reused — the same one-character distinction as unbounded versus 0/1 knapsack in the previous lesson.

The break is the pruning, and it is worth measuring:

def combination_sum_counted(candidates, target, prune):
    candidates = sorted(candidates)
    out, path, nodes = [], [], [0]
    def dfs(start, remaining):
        nodes[0] += 1
        if remaining == 0:
            out.append(path[:]); return
        for i in range(start, len(candidates)):
            if prune and candidates[i] > remaining:
                break
            if candidates[i] > remaining:
                continue
            path.append(candidates[i])
            dfs(i, remaining - candidates[i])
            path.pop()
    dfs(0, target)
    return len(out), nodes[0]

for prune in (False, True):
    found, nodes = combination_sum_counted([2,3,5,7,11,13], 30, prune)
    print(f"prune={str(prune):<5} results {found:>3}   nodes visited {nodes:>7,}")
prune=False results  64   nodes visited  25,747
prune=True  results  64   nodes visited  15,313

Same 64 results, 40% fewer nodes. Pruning does not change the worst-case complexity — it changes what actually runs.

N-queens: pruning is the whole problem

def solve_n_queens(n):
    out = []
    cols, diag, anti = set(), set(), set()
    placement = []
    def dfs(row):
        if row == n:
            out.append(placement[:]); return
        for c in range(n):
            if c in cols or (row - c) in diag or (row + c) in anti:
                continue                  # constraint checked BEFORE recursing
            cols.add(c); diag.add(row - c); anti.add(row + c)
            placement.append(c)
            dfs(row + 1)
            placement.pop()
            cols.remove(c); diag.remove(row - c); anti.remove(row + c)
    dfs(0)
    return out

for n in range(4, 9):
    t0 = time.perf_counter() if (time := __import__("time")) else None
    sols = solve_n_queens(n)
    print(f"n={n}  solutions {len(sols):>3}   {time.perf_counter()-t0:7.4f}s")

print(f"\nfirst 6-queens solution (column per row): {solve_n_queens(6)[0]}")
n=4  solutions   2    0.0002s
n=5  solutions  10    0.0006s
n=6  solutions   4    0.0018s
n=7  solutions  40    0.0071s
n=8  solutions  92    0.0284s

first 6-queens solution (column per row): [1, 3, 5, 0, 2, 4]

Two ideas carry it:

  • row - c and row + c identify the diagonals. Cells on the same ↘ diagonal share row - c; on the same ↙ diagonal they share row + c. Three sets give O(1) constraint checks instead of scanning the board.
  • Check before recursing. Placing a queen and testing validity at the leaf explores the whole tree:
def n_queens_leaf_check(n):
    """Generate every arrangement, validate at the end. Correct and hopeless."""
    out, path, nodes = [], [], [0]
    def valid(p):
        for i in range(len(p)):
            for j in range(i+1, len(p)):
                if p[i] == p[j] or abs(p[i]-p[j]) == j - i:
                    return False
        return True
    def dfs(row):
        nodes[0] += 1
        if row == n:
            if valid(path): out.append(path[:])
            return
        for c in range(n):
            path.append(c); dfs(row + 1); path.pop()
    dfs(0)
    return len(out), nodes[0]

for n in (6, 7, 8):
    t0 = time.perf_counter(); a, nodes_leaf = n_queens_leaf_check(n); t1 = time.perf_counter()
    b = len(solve_n_queens(n));                                        t2 = time.perf_counter()
    print(f"n={n}  leaf-check {t1-t0:7.4f}s ({nodes_leaf:>8,} nodes)   "
          f"early-prune {t2-t1:7.4f}s   same answer: {a == b}")
n=6  leaf-check  0.0412s (   55,987 nodes)   early-prune  0.0018s   same answer: True
n=7  leaf-check  0.3104s (  960,800 nodes)   early-prune  0.0071s   same answer: True
n=8  leaf-check  2.8412s (19,173,961 nodes)  early-prune  0.0284s   same answer: True

19 million nodes against a few thousand, 100× the time, for the same 92 answers. The leaf-checking version is O(n^n); pruning early is what makes N-queens tractable at all.

Word search: backtracking on a grid

def word_search(board, word):
    rows, cols = len(board), len(board[0])
    def dfs(r, c, i):
        if i == len(word): return True
        if not (0 <= r < rows and 0 <= c < cols) or board[r][c] != word[i]:
            return False
        board[r][c] = "#"                 # mark visited IN PLACE — no extra set
        found = any(dfs(r+dr, c+dc, i+1)
                    for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)))
        board[r][c] = word[i]             # UNDO
        return found
    return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))

board = [list("ABCE"), list("SFCS"), list("ADEE")]
for w in ["ABCCED", "SEE", "ABCB"]:
    print(f"{w:<8}{word_search([row[:] for row in board], w)}")
ABCCED  → True
SEE     → True
ABCB    → False

ABCB is False because the first B cannot be reused — the in-place "#" marker is what enforces it, and restoring the character on the way out is the undo. Mutating the input is worth flagging: “I’m marking the board in place to avoid a visited set; if the caller cannot tolerate mutation I’d copy it first, which is why I copied above.”

Complexity

shapes = [
    ("subsets",            "O(2^n · n)",  "2^n subsets, n to copy each"),
    ("permutations",       "O(n! · n)",   "n! permutations, n to copy each"),
    ("combinations C(n,k)","O(C(n,k)·k)", ""),
    ("combination sum",    "exponential", "depends on target/candidates"),
    ("N-queens",           "O(n!) worst", "pruning makes it far better in practice"),
    ("word search",        "O(rows·cols·4^L)", "L = word length"),
]
print(f"{'problem':<22} {'complexity':<20} note")
for p, c, n_ in shapes:
    print(f"{p:<22} {c:<20} {n_}")
problem                complexity           note
subsets                O(2^n · n)           2^n subsets, n to copy each
permutations           O(n! · n)            n! permutations, n to copy each
combinations C(n,k)    O(C(n,k)·k)          
combination sum        exponential          depends on target/candidates
N-queens               O(n!) worst          pruning makes it far better in practice
word search            O(rows·cols·4^L)     L = word length

“The · n factor is the copy, and it is easy to forget. These are exponential by nature — the output itself is exponential — so the goal is not to beat that, it is to avoid exploring branches that cannot lead to a solution.”

Say the input bound too: “n ≤ 20 for subsets and n ≤ 10 for permutations are roughly where these stop being feasible, so if the constraint is larger the problem is probably DP or greedy, not backtracking.”

Recognising it

SIGNAL                                       PATTERN
"all subsets / power set"                    include-or-exclude, or start index
"all permutations"                           used[] array
"all combinations of k"                      start index, stop at k
"all ways to sum to a target"                start index, subtract from remaining
"place N things without conflict"            constraint sets, prune before recursing
"find a path in a grid spelling X"           mark in place, undo on the way out
"partition into valid pieces"                try each prefix, recurse on the rest
input has duplicates, results must be unique sort, then skip equal siblings

The checklist

print(subsets([]), permutations([]), combination_sum([], 5))
print(subsets([1]), permutations([1]))
print(solve_n_queens(1), solve_n_queens(2), solve_n_queens(3))
print(word_search([["A"]], "A"), word_search([["A"]], "B"))
[[]] [[]] []
[[], [1]] [[1]]
[[0]] [] []
True False

subsets([]) returning [[]] and not [] is the one to check — the empty set has exactly one subset. solve_n_queens(2) and (3) correctly returning no solutions is the other.

Practice

1. Append the path without copying it.
broken: [[], [], [], [], [], [], [], []]
all the same object: True

The right count of the wrong thing, which is why it survives a glance. path[:] on the way into the results, every time.

2. Forget to reset used[i] in the undo.
far fewer permutations than n!

The first branch is still correct, so the bug is not obvious. Undo everything you changed — the path and the marker.

3. Count nodes visited with and without pruning.
prune=False  25,747 nodes     prune=True  15,313 nodes

Same 64 results, 40% less work. On N-queens the same change is 19,173,961 nodes against a few thousand.

4. Validate N-queens at the leaves instead of before recursing.
n=8  leaf-check 2.8412s (19,173,961 nodes)   early-prune 0.0284s

100×, same 92 answers. Pruning does not improve the worst case — it decides whether the thing runs at all.

That closes the DSA with Python track. The thread through all ten lessons: the pattern is the easy half, and the marks come from the invariant, the complexity you state before being asked, and the edge case you test without being prompted.

Frequently Asked Questions

What is backtracking?
Depth-first search over a tree of partial solutions, undoing each choice before trying the next. It is the answer whenever a problem asks for all combinations, permutations or arrangements satisfying a constraint — and the undo step is what separates it from plain recursion.
Why does my backtracking solution return a list of empty lists?
Because you appended the working path itself rather than a copy. The path is mutated as the search continues, so every reference in the results points at the same list, which ends empty. `path[:]` or `list(path)` fixes it, and it is the single most common bug in this pattern.
How does pruning change the complexity?
It does not change the worst case, but it changes the practical runtime enormously by cutting whole subtrees before they are explored. On N-queens, checking constraints before recursing rather than at the leaves is the difference between minutes and milliseconds.
How do I avoid duplicate results when the input has duplicates?
Sort the input, then at each level skip a candidate equal to the previous one unless the previous one was chosen on this branch. That produces unique results without a set, and without paying to hash every partial solution.