Ch 10 / 30 Recursion and the Call Stack 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 10

Part 3 · Recursion and Sorting · 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.

Reading
CLRS Ch. 4.1
Focus
Self-reference, safely
Cost
depends on the tree
Unlocks
Ch. 12–27

By the end of this chapter you can

  1. Write a recursive function by naming its base case and recursive case first
  2. Trace a recursion by reading its call tree, and read the stack depth off that tree
  3. Explain why naive Fibonacci is exponential and memoised Fibonacci is linear
  4. Convert between recursion and iteration, and say which to prefer and why
  5. Diagnose RecursionError correctly — 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.

Russian dolls

“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.
two_parts.py
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 leap of faith

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:

  1. The depth is the memory cost. The deepest path is 5 frames, so peak stack usage is O(n) — even though nothing was allocated.
  2. 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.
trace.py
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.
Diagnosing RecursionError

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.

nCalls madeRoughly
10177instant
2021,891instant
302,692,537about a second
40331,160,281a couple of minutes
5040,730,022,147hours
100~1021longer 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:

memoisation.py
# 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 instant
This is the doorway to dynamic programming

Two 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.

RecursionIteration
SpaceO(depth) stack framesO(1) unless you build something
SpeedFunction-call overhead per levelUsually faster in Python
Depth limit~1,000 in PythonNone
Reads well forTrees, graphs, divide and conquer, anything self-similarLinear scans, accumulation
Reads badly forSimple loops (factorial, summing a list)Tree traversal (the code becomes an explicit stack)
choosing.py
# 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.

tail_calls.py
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.
When recursion genuinely wins

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.

linear.py
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.

binary.py
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.

multiple.py
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

mutual.py
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.
Linear recursion
O(n) time, O(n) stack
Binary (balanced)
O(n log n), O(log n) stack
Naive fib
O(2ⁿ)
Memoised fib
O(n)
Subsets
O(2ⁿ) — and correct
The checklist for any recursive function
  1. Base case? Is there an input answered without recursing?
  2. Progress? Does every call get strictly closer to it?
  3. Depth? Could it exceed ~1,000 on real input?
  4. Repeats? Is the same subproblem solved more than once? If so, memoise.
  5. 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →