AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 10
Recursion and the Call Stack
Russian dolls, mirrors facing mirrors, and a definition that refers to itself without going in circles. The single idea that makes trees and graphs tractable.
By the end of this chapter you can
- Write a recursive function by naming its base case and recursive case first
- Trace a recursion by reading its call tree, and read the stack depth off that tree
- Explain why naive Fibonacci is exponential and memoised Fibonacci is linear
- Convert between recursion and iteration, and say which to prefer and why
- Diagnose
RecursionErrorcorrectly — missing base case versus genuine depth
1A definition that refers to itself
Recursion sounds circular and is not. The trick is that each reference is to a strictly smaller version.
“How many dolls are in this set?” You do not count them all at once. You open the outer doll, and the answer is one, plus however many are in the set you just found inside.
That is a definition in terms of itself. It is not circular because the inner set is strictly smaller, and because there is a smallest doll that does not open — the point where the question is answered directly rather than passed on.
Every recursive function has exactly two parts, and skipping either one is a bug:
- The base case — an input small enough to answer outright, with no recursion.
- The recursive case — reduce the problem to a smaller instance of itself, call yourself on it, and combine.
def countdown(n):
if n == 0: # BASE CASE — answer directly, stop descending
print("Liftoff!")
return
print(n) # do this level's work
countdown(n - 1) # RECURSIVE CASE — strictly smaller argument
def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case
# Two questions to ask of any recursive function:
# 1. Is there an input it can answer WITHOUT recursing?
# 2. Does every recursive call get STRICTLY closer to that input?
# Answer B to either and it never terminates.The hardest part of learning recursion is psychological. When you write
factorial(n - 1), you must assume it already works and move on.
Trying to trace the whole descent in your head is how people bounce off recursion. Instead: write the base case, assume the recursive call is correct, and check that you combine its result properly. If those three hold, the function is correct — that is induction, and it is a proof, not a hope.
2Reading the call stack
Recursion is not magic. It is a stack of paused function calls, and once you can see the stack the mystery evaporates.
Chapter 6 introduced the call stack: each call pushes a frame holding its local variables and where to return to. A recursive call is an ordinary call — it just happens to be to the same function.
Step through fib(5). Every box is a frame; the depth of the box is the stack depth at
that moment:
Two things to read off that picture:
- The depth is the memory cost. The deepest path is 5 frames, so peak stack usage
is
O(n)— even though nothing was allocated. - The width is the time cost. Fifteen calls to compute a number you could work out
on your fingers. Look at how many times
fib(2)appears.
def trace(n, depth=0):
"""The same recursion, printing its own stack depth."""
print(" " * depth + f"→ trace({n})")
if n <= 1:
print(" " * depth + f"← base case, return {n}")
return n
result = trace(n - 1, depth + 1) + trace(n - 2, depth + 1)
print(" " * depth + f"← trace({n}) returns {result}")
return result
trace(4)
# Run this in the playground. The indentation IS the stack.Python raises RecursionError at around 1,000 frames. There are exactly two
causes, and they need opposite fixes:
- A missing or unreachable base case. The recursion never stops. This is a bug — fix the logic.
- Genuine depth. Recursing once per element over a 10,000-element list. The recursion is correct but the approach is wrong — rewrite it as a loop, or with an explicit stack.
Raising sys.setrecursionlimit almost never fixes either one. It converts a clean
Python exception into a real stack overflow, which crashes the interpreter.
3Why naive recursion explodes
The Fibonacci example is famous for a reason: it is the shortest demonstration of an entire class of failure.
fib(n) = fib(n-1) + fib(n-2) is a perfect translation of the mathematical definition
into code. It is also unusable. fib(50) would take longer than this course.
| n | Calls made | Roughly |
|---|---|---|
| 10 | 177 | instant |
| 20 | 21,891 | instant |
| 30 | 2,692,537 | about a second |
| 40 | 331,160,281 | a couple of minutes |
| 50 | 40,730,022,147 | hours |
| 100 | ~1021 | longer than the universe has existed |
The reason is visible in the call tree: fib(3) is computed from scratch every time it
is needed, and it is needed many times. The tree has roughly 2n nodes, and almost all of
them are recomputing something already known.
The fix is one line. Remember answers as you compute them:
# Naive — O(2ⁿ)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
# Memoised — O(n). One decorator.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_fast(n):
if n <= 1:
return n
return fib_fast(n - 1) + fib_fast(n - 2)
# Or by hand, so you can see what the decorator does:
def fib_memo(n, cache=None):
if cache is None:
cache = {}
if n in cache:
return cache[n] # ← the entire optimisation
if n <= 1:
return n
cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
return cache[n]
# fib(35): about 5 seconds
# fib_fast(35): instant
# fib_fast(500): also instantTwo conditions together mean memoisation will help, and they have names:
- Overlapping subproblems — the same smaller instance is solved more than once.
- Optimal substructure — the answer is built from answers to subproblems.
When both hold, you are looking at a dynamic-programming problem. Chapter 25 is that observation, taken seriously.
4Recursion versus iteration
Anything you can write recursively you can write with a loop, and vice versa. Choosing well is a real skill.
The two are formally equivalent — a loop plus an explicit stack can simulate any recursion, which is exactly what the machine does anyway. So the choice is about clarity and cost.
| Recursion | Iteration | |
|---|---|---|
| Space | O(depth) stack frames | O(1) unless you build something |
| Speed | Function-call overhead per level | Usually faster in Python |
| Depth limit | ~1,000 in Python | None |
| Reads well for | Trees, graphs, divide and conquer, anything self-similar | Linear scans, accumulation |
| Reads badly for | Simple loops (factorial, summing a list) | Tree traversal (the code becomes an explicit stack) |
# Factorial: iteration is clearly better. No reason to recurse.
def factorial_iter(n):
result = 1
for i in range(2, n + 1):
result *= i
return result
# Tree traversal: recursion is clearly better. The iterative version
# needs an explicit stack and reads worse for no gain.
def tree_sum(node):
if node is None:
return 0
return node.value + tree_sum(node.left) + tree_sum(node.right)
# The rule of thumb:
# Is the DATA self-similar (a tree, a nested structure, a subdivided
# range)? → recursion.
# Is the data flat and the process just accumulation? → a loop.Tail recursion, and why it does not help in Python
A tail call is a recursive call that is the very last thing a function does — nothing is waiting to happen after it returns. Some languages optimise this into a jump, using no extra stack at all.
def count_tail(n, acc=0):
if n == 0:
return acc
return count_tail(n - 1, acc + n) # a tail call...
count_tail(10000)
# ...and Python still raises RecursionError. CPython does NOT eliminate
# tail calls, deliberately: keeping every frame means tracebacks stay
# complete and debuggable.
#
# So in Python, A is not an optimisation.
# B is.Towers of Hanoi is three lines recursively and a genuine puzzle iteratively. Tree traversal is four lines recursively and a stack-management exercise iteratively.
The pattern: recursion wins when the problem branches. One recursive call is usually better as a loop; two or more calls almost always want to stay recursive.
5Recursion patterns worth knowing
Four shapes that cover most recursive code you will read or write.
1. Linear recursion — one call per level
Depth n, width 1. Usually better as a loop, but the pattern underlies list processing in functional languages.
def list_sum(xs, i=0):
if i == len(xs):
return 0
return xs[i] + list_sum(xs, i + 1)
# Note: xs[i] with an index, NOT xs[1:]. Slicing copies the rest of
# the list on every call, turning O(n) into O(n²).2. Binary recursion — two calls per level
The divide-and-conquer shape. Depth log n, width n: this is merge sort, quicksort, and every tree traversal.
def max_of(xs, lo, hi):
if lo == hi:
return xs[lo]
mid = (lo + hi) // 2
return max(max_of(xs, lo, mid), max_of(xs, mid + 1, hi))3. Multiple recursion — one call per option
Exploring a decision space. This is backtracking, Chapter 27.
def subsets(xs, i=0, current=None):
if current is None:
current = []
if i == len(xs):
return [current[:]] # a complete choice — copy it
without = subsets(xs, i + 1, current) # do not take xs[i]
current.append(xs[i])
with_it = subsets(xs, i + 1, current) # take xs[i]
current.pop() # UN-CHOOSE — essential
return without + with_it
# 2ⁿ subsets, so 2ⁿ leaves. Exponential is correct here: the OUTPUT is
# exponential. That is a different situation from fib, where the output
# was one number.4. Mutual recursion — functions calling each other
def is_even(n):
return True if n == 0 else is_odd(n - 1)
def is_odd(n):
return False if n == 0 else is_even(n - 1)
# A silly example of a serious pattern: recursive-descent parsers are
# built this way — parse_expression calls parse_term calls parse_factor,
# which calls parse_expression again for a bracketed sub-expression.- Base case? Is there an input answered without recursing?
- Progress? Does every call get strictly closer to it?
- Depth? Could it exceed ~1,000 on real input?
- Repeats? Is the same subproblem solved more than once? If so, memoise.
- Cleanup? If you mutate shared state on the way down, do you undo it on the way back up?
What to carry forward
- Every recursion needs a base case and a recursive case that makes strict progress toward it. Missing either means it never terminates.
- Take the leap of faith: assume the recursive call works. Checking the base case and the combination step is a complete proof by induction.
- The call tree's depth is the memory cost and its width is the time cost. Recursion n deep uses O(n) stack even when it allocates nothing.
- Overlapping subproblems make naive recursion exponential. Memoisation fixes it in one line and is the doorway to dynamic programming.
- Recursion wins when the problem branches — trees, divide and conquer, decision spaces. One recursive call is usually better as a loop.
- Python does not eliminate tail calls. 'Make it tail recursive' is not an optimisation here; 'make it a loop' is.
>_Playground
Watch recursion trace itself, then measure what memoisation buys.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 11 — Elementary Sorts and What They Teach
Nobody ships bubble sort. Everybody should watch it once — the slow sorts make the invariant visible, and the invariant is the transferable idea.