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.
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:
| Problem | Current choice | Remaining problem | Base case |
|---|---|---|---|
| Badge codes of length n | the next symbol | codes of length n − 1 | length 0: the finished code |
| Subsets of a list | include or skip the first item | subsets of the rest | empty list: one empty subset |
| Paths in a grid | step right or down | paths from the new cell | reached the target cell |
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:
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 toolThe 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:
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 callsfunctools.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 length | 2 symbols | 4 symbols |
|---|---|---|
| 4 | 16 | 256 |
| 8 | 256 | 65,536 |
| 16 | 65,536 | 4,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:
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.
Recursive Problem Solving in a small program
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))['AA', 'AB', 'BA', 'BB']Line-by-line explanation
- Lines 2 to 3Base case: no positions left, so the prefix is a finished badge.
- Lines 5 to 6Each symbol is a choice; the recursion fills the remaining positions.
- Line 9Two positions with two symbols give 2² = 4 badges.
Backtracking with pruning, and memoization
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)9 plans; largest: ('laser', 'router')
121393 plain calls: 242785
121393 cached calls: 26Line-by-line explanation
- Line 9A tuple copy records each plan, so later changes to
chosendon't affect it. - Lines 12 to 15Choose a tool only if it fits the remaining budget, then undo the choice with
pop(). - 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
if cost <= remaining:
chosen.append(name)
explore(i + 1, remaining - cost)
explore(i + 1, remaining) # name is still in chosen hereif 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
@lru_cache
def price(tool):
return catalogue[tool] * (1 + vat_rate) # stale if vat_rate changes@lru_cache
def price(base_cost, vat_rate):
return base_cost * (1 + vat_rate) # depends only on its argumentsRecursive 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.
What must mutable backtracking restore?
Which review concern is valid for recursive problem solving?
Recursive Problem Solving Quiz
2 questions to check what you learned.
What must mutable backtracking restore?
- Every global variable
- The state changed for the current choice
- Python ignores the distinction
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