Backtracking in Python: Subsets, Permutations, Combination Sum and Word Search

Backtracking is a way of trying every possible answer without writing a separate loop for each choice. You build a solution one step at a time, and the moment a step cannot lead to a valid answer you undo it and try the next option. It is the standard approach for subsets, permutations, combinations, puzzles and path search in a grid. This guide gives you one reusable template and applies it to six problems, each with code that was run and checked against the sample inputs.

The choose, explore, undo template

Almost every backtracking solution has the same shape. You keep a path that holds the choices made so far. At each step you loop over the available choices, add one to the path, recurse, and then remove it again so the next choice starts from a clean state.

def backtrack(state):
    if is_complete(state):
        results.append(copy_of(state))   # always store a copy
        return
    for choice in available_choices(state):
        if not is_valid(choice, state):
            continue                      # prune early
        apply(choice, state)              # choose
        backtrack(state)                  # explore
        undo(choice, state)               # undo

Think of it as a depth-first search over a tree of decisions. The recursion depth is the number of decisions, and pruning cuts off whole branches that cannot succeed. If recursion on trees is new to you, read tree and graph traversals first.

Subsets (LeetCode 78)

Return every subset of a list of distinct numbers. Every node in the decision tree is itself a valid answer, so we record the path on entry rather than waiting for a base case. The start index makes sure we only move forward, which stops us from producing the same subset in a different order.

def subsets(nums):
    res, path = [], []

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

    backtrack(0)
    return res

For [1, 2, 3] the output is [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3], which is all 8 subsets. There are 2n subsets and copying each one costs up to n, so time is O(n · 2n). The recursion uses O(n) space apart from the output.

Permutations (LeetCode 46)

Here order matters, so a forward-only start index is wrong. Instead, any unused element can go in any position. A used list records which elements are already in the path.

def permutations(nums):
    res, path = [], []
    used = [False] * len(nums)

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

    backtrack()
    return res

For [1, 2, 3] you get all 6 orderings, from [1,2,3] to [3,2,1]. Time is O(n · n!) because there are n! results of length n.

Combination Sum (LeetCode 39)

Find all combinations of candidates that add up to a target, where each number may be reused. Reuse is handled by recursing with i instead of i + 1. Sorting the candidates lets us stop the loop as soon as a number is too large, which is a simple but effective prune.

def combination_sum(candidates, target):
    res, path = [], []
    candidates.sort()

    def backtrack(start, remaining):
        if remaining == 0:
            res.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:
                break                     # later numbers are even larger
            path.append(candidates[i])
            backtrack(i, remaining - candidates[i])
            path.pop()

    backtrack(0, target)
    return res

For [2, 3, 6, 7] and target 7 the answer is [2,2,3] and [7]. The running time is exponential in the worst case and depends on the target and the smallest candidate, so describe it as bounded by the size of the decision tree rather than quoting a tidy formula.

Subsets II: handling duplicates (LeetCode 90)

If the input contains repeated values, such as [1, 2, 2], the plain subsets code would output [1, 2] twice. Sort first, then skip a value when it equals the previous one at the same level of the tree.

def subsets_with_dup(nums):
    nums.sort()
    res, path = [], []

    def backtrack(start):
        res.append(path[:])
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue                  # same value already tried here
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return res

The condition i > start is the part people get wrong. It allows the second 2 to be used after the first 2 is already in the path, but blocks it from starting a new branch at the same position. The result for [1, 2, 2] is [], [1], [1,2], [1,2,2], [2], [2,2].

Generate Parentheses (LeetCode 22)

Produce every well-formed string of n pairs of parentheses. Instead of generating all strings and filtering, only allow moves that keep the string valid: add an opening bracket while fewer than n are used, and add a closing bracket only if it would not exceed the openings.

def generate_parentheses(n):
    res = []

    def backtrack(cur, opened, closed):
        if len(cur) == 2 * n:
            res.append(''.join(cur))
            return
        if opened < n:
            cur.append('(')
            backtrack(cur, opened + 1, closed)
            cur.pop()
        if closed < opened:
            cur.append(')')
            backtrack(cur, opened, closed + 1)
            cur.pop()

    backtrack([], 0, 0)
    return res

For n = 3 the five results are ((())), (()()), (())(), ()(()) and ()()(). The number of results is the nth Catalan number (5 for n = 3, 14 for n = 4), so the work grows quickly but far more slowly than the 22n strings you would check by brute force.

Word Search (LeetCode 79)

Backtracking also works on grids. Starting from each cell, follow the word letter by letter through the four neighbours. A cell may not be reused in the same path, so mark it as visited before exploring and restore it afterwards.

def exist(board, word):
    rows, cols = len(board), len(board[0])

    def dfs(r, c, i):
        if i == len(word):
            return True
        if r < 0 or c < 0 or r >= rows or c >= cols or board[r][c] != word[i]:
            return False
        tmp = board[r][c]
        board[r][c] = '#'                 # mark visited
        found = (dfs(r + 1, c, i + 1) or dfs(r - 1, c, i + 1)
                 or dfs(r, c + 1, i + 1) or dfs(r, c - 1, i + 1))
        board[r][c] = tmp                 # undo
        return found

    return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))

On the board [[A,B,C,E],[S,F,C,S],[A,D,E,E]], the word ABCCED is found and ABCB is not, because the second B would need the first B’s cell again. Time is O(m · n · 3L) for an m by n grid and a word of length L, since after the first letter each step has at most three new directions.

Choosing the right variant

Problem type Technique Time
Subsets, combinations Forward-only start index O(n · 2n) for subsets
Permutations used array O(n · n!)
Duplicates in input Sort and skip equal values at the same level Same as base problem
Constraint-driven strings Count-based pruning Catalan-number growth
Grid path search Mark and unmark cells O(m · n · 3L)

Common mistakes

  • Appending path itself to the results. The list keeps changing, so every stored answer ends up empty. Always append path[:] or list(path).
  • Forgetting to undo a choice, which leaves stale data for the next branch.
  • Using start for permutations, or skipping it for combinations, which gives wrong or repeated results.
  • Not sorting before skipping duplicates, so equal values are not adjacent.
  • Pruning too late. Check validity before recursing, not after.

Quick answers

What is backtracking in simple terms?

Backtracking builds an answer step by step and abandons a partial answer as soon as it cannot be completed. It is a depth-first search over the tree of possible choices, with an undo step after each recursive call.

What is the difference between backtracking and recursion?

Recursion is a technique where a function calls itself. Backtracking is a strategy that uses recursion to explore choices and undo them. Not every recursive function backtracks, but every backtracking solution is usually written recursively.

Why does backtracking have exponential time complexity?

Each decision multiplies the number of possible paths. Subsets double with every element and permutations grow factorially, so the number of leaves in the decision tree is exponential or worse. Pruning reduces the work in practice but not the worst case.

How is backtracking different from dynamic programming?

Backtracking lists every valid answer, while dynamic programming reuses results of overlapping subproblems to count or optimise. If a problem asks for all solutions, think backtracking. If it asks for a count or a best value, check whether dynamic programming fits.

Which backtracking problems should I practise first?

Begin with Subsets, then Permutations and Combination Sum. Next try Subsets II, Generate Parentheses and Word Search. Harder follow-ups include N-Queens and Sudoku Solver.

When in doubt

If the problem says all possible, every valid or list all, start with the template above and decide which of the three questions applies: do I move forward only, do I track what is used, or do I need to skip duplicates. If the problem only asks how many ways or the best value, look at dynamic programming instead. A weekly practice schedule is in the 30-day study plan.

Also see: bit manipulation tricks and tries for related patterns.