Python Recursive Problem Solving

Decompose problems into choices and smaller subproblems, explore options with backtracking, speed up repeats with memoization and estimate growth.

Lesson 89 of 107Advanced13 min read

Many problems are easiest to solve by asking: what choice do I make now, and what smaller problem is left? Generating every badge design, every subset of a budget, every way to fill a timetable or every path through a maze all fit that shape. Three techniques turn the idea into working code:

  • Decomposition: express the answer for size n in terms of answers for smaller inputs.
  • Backtracking: try a choice, explore everything that follows from it, then undo the choice and try the next one.
  • Memoization: remember answers to subproblems so the same one is never solved twice.
Python
def badges(prefix, remaining):
    if remaining == 0:
        return [prefix]
    return [b for symbol in "AB" for b in badges(prefix + symbol, remaining - 1)]

print(badges("", 2))   # ['AA', 'AB', 'BA', 'BB']

This lesson builds on recursion, using a makerspace designing membership badges and planning workshop budgets.

Problem decomposition

Describe the problem as a current decision plus a smaller remaining problem, and identify the base case where nothing is left to decide:

ProblemCurrent choiceRemaining problemBase case
Badge codes of length nthe next symbolcodes of length n − 1length 0: the finished code
Subsets of a listinclude or skip the first itemsubsets of the restempty list: one empty subset
Paths in a gridstep right or downpaths from the new cellreached the target cell
Python
def subsets(items):
    if not items:
        return [[]]
    rest = subsets(items[1:])
    return rest + [[items[0]] + s for s in rest]

print(subsets(["laser", "lathe"]))   # [[], ['lathe'], ['laser'], ['laser', 'lathe']]

Every recursive call must make real progress towards the base case: one fewer position to fill, one item fewer to decide. If a call can be made with the same arguments it received, the recursion never ends. Writing the table above before the code is often the hardest and most useful step.

Backtracking

Backtracking builds a solution step by step in shared, mutable state. At each step it makes a choice, recurses, then undoes the choice before trying the next option:

Python
def plan(tools, budget, chosen, results):
    if not tools:
        results.append(list(chosen))         # record a copy
        return
    name, cost = tools[0]
    if cost <= budget:
        chosen.append(name)                  # choose
        plan(tools[1:], budget - cost, chosen, results)
        chosen.pop()                         # un-choose (backtrack)
    plan(tools[1:], budget, chosen, results) # skip this tool

The pop() is essential. Without it, items chosen in one branch remain in chosen when the next branch runs, and results become mixed up. Two more details matter: record a copy of the state (list(chosen)), since the list keeps changing afterwards, and prune branches early: the cost <= budget check stops exploring choices that can't lead to a valid answer, which can save enormous amounts of work.

Backtracking solves puzzles such as Sudoku and the N-queens problem, generates permutations and combinations, and finds schedules that satisfy constraints.

Memoization

Some recursions solve the same subproblem many times. Counting the ways to build a 30 cm shelf from 1 cm and 2 cm pieces calls ways(28) from both ways(29) and ways(30), and the repetition multiplies all the way down. Memoization stores each answer the first time it's computed:

Python
from functools import lru_cache

@lru_cache(maxsize=None)
def ways(length):
    if length <= 1:
        return 1
    return ways(length - 1) + ways(length - 2)

print(ways(30))   # 1346269, computed with 31 distinct calls

functools.cache (Python 3.9+) is shorthand for lru_cache(maxsize=None). The arguments must be hashable, so pass tuples rather than lists, and the function must be pure: its result must depend only on its arguments. Caching a function that reads changing global state, a file or the clock returns stale answers. Unbounded caches also keep every result in memory; set maxsize when inputs vary widely.

Memoization helps only when subproblems repeat. Backtracking searches that build different partial solutions each time, such as listing all subsets, have nothing to reuse.

Search growth

Branching recursion grows exponentially. If each step has b choices and there are d steps, there are bᵈ complete outcomes:

Badge length2 symbols4 symbols
416256
825665,536
1665,5364,294,967,296

Each call can be trivially simple and the whole search still impossible to finish. Recursion is a way of expressing an algorithm; it doesn't make one efficient.

Before writing a search, estimate the number of outcomes. If it's too large, look for ways to cut it: prune choices that violate constraints as early as possible, memoize repeated subproblems (turning many exponential searches into polynomial dynamic programming), or switch to a different algorithm, such as a greedy rule or a library solver. Generating outcomes lazily with yield instead of building one big list keeps memory under control even when you need to scan many results.

Independent practice and a worked check

Task: enumerate badge choices, add a constraint and explain when memoization can reduce repeated subproblems. Generate three-symbol badges from A, B and C with no two identical symbols next to each other:

Python
def badges(prefix, remaining):
    if remaining == 0:
        yield prefix
        return
    for symbol in "ABC":
        if prefix and prefix[-1] == symbol:
            continue                         # prune: constraint violated
        yield from badges(prefix + symbol, remaining - 1)

designs = list(badges("", 3))
print(len(designs), designs[:4])     # 12 ['ABA', 'ABC', 'ACA', 'ACB']

Pruning cuts the outcomes from 27 to 12. Memoization wouldn't help here, because each call builds a different prefix, but it would help to merely count designs, where count(last_symbol, remaining) is called repeatedly with the same arguments. For the worked check, mutable backtracking must restore the state changed for the current choice before exploring the next option.

Reference and next practice

The documentation covers functools.lru_cache and cache and itertools for ready-made combinatorics. Continue with introduction to machine learning or revisit recursion. Practise with the Python exercises and Python quiz.

Recursive Problem Solving Summary

  • Problem decomposition: Express a problem as a current choice and a smaller remaining problem.
  • Backtracking: Backtracking tries a choice, explores its consequences and restores state before trying alternatives.
  • Memoization: Caching results can avoid solving an identical subproblem repeatedly.
  • Search growth: A branching recursive search can grow exponentially even when each call is simple.

Recursive Problem Solving Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Recursive Problem Solving in a small program

Python
def badges(prefix, remaining):
    if remaining == 0:
        return [prefix]
    results = []
    for symbol in ["A", "B"]:
        results.extend(badges(prefix + symbol, remaining - 1))
    return results

print(badges("", 2))
Output
['AA', 'AB', 'BA', 'BB']
Line-by-line explanation
  1. Lines 2 to 3Base case: no positions left, so the prefix is a finished badge.
  2. Lines 5 to 6Each symbol is a choice; the recursion fills the remaining positions.
  3. Line 9Two positions with two symbols give 2² = 4 badges.
Example 2

Backtracking with pruning, and memoization

Python
from functools import lru_cache

tools = [("laser", 40), ("lathe", 35), ("printer", 25), ("router", 20)]

def within_budget(budget):
    results, chosen = [], []
    def explore(i, remaining):
        if i == len(tools):
            results.append(tuple(chosen))
            return
        name, cost = tools[i]
        if cost <= remaining:
            chosen.append(name)
            explore(i + 1, remaining - cost)
            chosen.pop()
        explore(i + 1, remaining)
    explore(0, budget)
    return results

plans = within_budget(60)
print(len(plans), "plans; largest:", max(plans, key=len))

calls = 0
def ways_plain(n):
    global calls
    calls += 1
    return 1 if n <= 1 else ways_plain(n - 1) + ways_plain(n - 2)

@lru_cache(maxsize=None)
def ways_cached(n):
    return 1 if n <= 1 else ways_cached(n - 1) + ways_cached(n - 2)

print(ways_plain(25), "plain calls:", calls)
print(ways_cached(25), "cached calls:", ways_cached.cache_info().misses)
Output
9 plans; largest: ('laser', 'router')
121393 plain calls: 242785
121393 cached calls: 26
Line-by-line explanation
  1. Line 9A tuple copy records each plan, so later changes to chosen don't affect it.
  2. Lines 12 to 15Choose a tool only if it fits the remaining budget, then undo the choice with pop().
  3. Lines 33 to 34Both functions give the same answer, but memoization cuts nearly a quarter of a million calls to 26 distinct computations.

Common Recursive Problem Solving Mistakes

Forgetting to undo a choice when backtracking

Don't
if cost <= remaining:
    chosen.append(name)
    explore(i + 1, remaining - cost)
explore(i + 1, remaining)          # name is still in chosen here
Do
if cost <= remaining:
    chosen.append(name)
    explore(i + 1, remaining - cost)
    chosen.pop()                   # restore the state before the next branch
explore(i + 1, remaining)

Caching a function that depends on changing state

Don't
@lru_cache
def price(tool):
    return catalogue[tool] * (1 + vat_rate)   # stale if vat_rate changes
Do
@lru_cache
def price(base_cost, vat_rate):
    return base_cost * (1 + vat_rate)          # depends only on its arguments

Recursive Problem Solving in Real Projects

Recursive search in real problems

Constraint solvers for timetables, Sudoku and configuration problems use backtracking with aggressive pruning. Dynamic programming, which is memoized recursion or its loop-based equivalent, powers spell checkers (edit distance), DNA sequence alignment, route planning and resource allocation. Compilers and query planners search over alternatives with careful pruning to stay fast.

For real workloads, check the standard library and specialised packages first: itertools.permutations, combinations and product generate arrangements in C, and solvers such as OR-Tools or PuLP handle large optimisation problems. Writing the recursion yourself teaches the structure these tools rely on.

Recursive Problem Solving FAQ

What is backtracking in Python?

A technique for searching through choices: make a choice, recurse to explore what follows, then undo the choice and try the next one. It's used for puzzles, permutations, subsets and constraint problems, often with pruning to skip impossible branches.

What is memoization?

Storing the result of a function call so a later call with the same arguments returns the stored result instead of recomputing it. In Python, decorate a pure function with @functools.lru_cache or @functools.cache.

What is the difference between memoization and dynamic programming?

Dynamic programming solves a problem by combining solutions to overlapping subproblems. Memoization is the top-down way to do it, with recursion plus a cache; tabulation is the bottom-up way, filling a table with a loop. Both avoid repeated work.

Why is my recursive solution so slow?

Usually because it explores an exponential number of branches or recomputes the same subproblems. Count the calls, add pruning for invalid branches, and memoize if subproblems repeat. If the outcome count itself is astronomical, a different algorithm is needed.

Should I use itertools instead of writing recursion?

For standard tasks, yes: itertools.permutations, combinations, product and combinations_with_replacement are fast and well tested. Write your own recursion when you need custom constraints and pruning that those functions can't express.

Recursive Problem Solving Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

What must mutable backtracking restore?

Multiple choicemedium

Which review concern is valid for recursive problem solving?

Recursive Problem Solving Quiz

2 questions to check what you learned.

  1. What must mutable backtracking restore?

    • Every global variable
    • The state changed for the current choice
    • Python ignores the distinction
  2. Which statement correctly describes memoization?

    • Caching results can avoid solving an identical subproblem repeatedly.
    • Only the example output matters, not the rule
    • Every operation has the same behavior