Skip to main content
FreeAcademy

Recursion and Backtracking

Recursion Is Just a Function That Trusts Itself

Recursion confuses people because they try to trace it. They picture the whole call stack unwinding in their head, get three levels deep, and give up. Stop doing that. You don't trace recursion, you specify it.

Every recursive function is two things:

  • A base case: the input so small you know the answer without thinking.
  • A recursive case: reduce the problem to a smaller version, call yourself, and combine.

The trust is the hard part. When you write the recursive call, you assume it already works for the smaller input. You don't verify it. You just believe it and build on top.

def factorial(n):
    if n <= 1:          # base case: you know this answer cold
        return 1
    return n * factorial(n - 1)   # trust that (n-1)! is correct

If your base case is right and every call shrinks the input toward it, the whole thing works. That's the entire contract.

The Stack Is Doing the Bookkeeping

Here's what actually happens under the hood: every call gets pushed onto the call stack with its own local variables. factorial(4) pauses at the multiply, waits for factorial(3), which waits for factorial(2), and so on. When a call hits the base case and returns, its frame pops and the paused call resumes.

You don't manage this. Python does. That's the whole point, the stack remembers where you were so you don't have to.

Two consequences worth internalizing:

  • No progress toward the base case means infinite recursion. You'll hit RecursionError (Python caps the stack around 1000 frames). If you see that error, your recursive call isn't shrinking the input.
  • Deep recursion costs memory. A million-item linked list traversed recursively will blow the stack. Sometimes a loop is the right call. Recursion is a tool, not a religion.

Write the base case first, always. Most broken recursion is a missing or wrong base case.

Backtracking: Recursion That Explores Choices

Backtracking is recursion applied to problems where you build a solution one choice at a time, and undo choices that don't pan out. Think of it as walking a decision tree: at each node you pick an option, go deeper, and if that path fails or completes, you back up and try the next option.

The skeleton is always the same:

def backtrack(state):
    if is_complete(state):
        record(state)
        return
    for choice in options(state):
        make(choice)        # choose
        backtrack(state)    # explore
        undo(choice)        # un-choose

Choose, explore, un-choose. That undo line is what makes it backtracking and not just recursion. You mutate shared state on the way down and restore it on the way up, so each branch sees a clean slate.

Subsets and Permutations

These two show up constantly in interviews, and they're the cleanest way to internalize the pattern.

Subsets: for each element, you either include it or you don't. Two choices, and every leaf of the tree is a valid subset.

def subsets(nums):
    result = []
    def build(i, current):
        if i == len(nums):
            result.append(current[:])   # copy, not the live list
            return
        build(i + 1, current)           # skip nums[i]
        current.append(nums[i])         # take nums[i]
        build(i + 1, current)
        current.pop()                   # undo
    build(0, [])
    return result

Permutations: at each position you can place any element you haven't used yet.

def permutations(nums):
    result = []
    def build(current, remaining):
        if not remaining:
            result.append(current[:])
            return
        for i in range(len(remaining)):
            build(current + [remaining[i]],
                  remaining[:i] + remaining[i+1:])
    build([], nums)
    return result

The one bug that bites everyone: appending current instead of current[:]. You're mutating one list the whole time, so if you store a reference, every entry in result ends up pointing at the same (eventually empty) list. Copy when you record.

Prune Early, or Pay Exponentially

Backtracking explores an exponential space. Subsets of n items is 2^n. Permutations is n!. You cannot make that growth go away, but you can avoid exploring branches that are already dead.

The trick is to check constraints before you recurse, not after you've built a full candidate. Take the classic "place N queens so none attack each other." A naive version generates all placements then filters. A smart version refuses to place a queen on any square already threatened, so entire subtrees never get visited.

def solve_n_queens(n):
    result = []
    cols, diag1, diag2 = set(), set(), set()
    def place(row, board):
        if row == n:
            result.append(board[:])
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue                 # prune: this square is attacked
            cols.add(col); diag1.add(row - col); diag2.add(row + col)
            place(row + 1, board + [col])
            cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
    place(0, [])
    return result

Those three sets are your constraint check in O(1). Pruning is what turns backtracking from "theoretically correct, practically frozen" into something that solves a 12x12 board in a blink. Sudoku, word search, and constraint puzzles are all the same shape: valid move, recurse, undo, and skip anything that violates a rule up front.

How to Actually Get Fluent

Reasoning about the exponential cost of these searches is exactly the Big-O muscle from earlier chapters, and if you want more reps writing recursive Python cleanly, Advanced Python Programming drills the idioms, while DSA in Python walks these patterns interactively.

Your checklist for any recursive problem:

  • Name the base case before anything else.
  • Confirm every recursive call moves toward it.
  • For backtracking, verify you undo every change you make.
  • Copy mutable state when you record a result.
  • Ask where you can prune before the branch is fully built.

Write these ten problems by hand, not by reading: factorial, Fibonacci, reverse a string, subsets, permutations, combinations, N-Queens, Sudoku, generate parentheses, and word search on a grid. Once the choose-explore-undo rhythm is in your fingers, backtracking stops being a category of hard problems and becomes a template you fill in.