Functions · about 45 min

Recursion

Why this matters

Some problems are naturally described in terms of smaller versions of themselves: a folder contains files and other folders; an expression like (2 + 3) * (4 - 1) contains smaller expressions; to sort a list you can sort two halves and merge them. A recursive function solves such problems by calling itself on the smaller pieces. Recursion is also the best way to really understand the call stack, which you met in lesson 1, and you will need it later for tree-shaped data such as JSON documents and directory trees.

A function that calls itself

def countdown(n):
    if n == 0:
        print("liftoff")
        return
    print(n)
    countdown(n - 1)

countdown(3)
3
2
1
liftoff

Every recursive function has two parts:

Each recursive call must move toward the base case. If it does not, the recursion never ends.

Recursion that returns values

Factorial is the classic example: 5! = 5 * 4 * 3 * 2 * 1, and by definition 0! = 1. Notice that 5! = 5 * 4!. That observation is the recursive case:

def factorial(n):
    if n == 0:                       # base case
        return 1
    return n * factorial(n - 1)      # recursive case

print(factorial(5))
120

What happens underneath: the stack grows and shrinks

Recall that each call gets its own frame with its own local variables. That is what makes recursion work: each active call of factorial has its own n. Here is the stack at its deepest point for factorial(3):

  factorial(0)   n=0   -> returns 1            <- top
  factorial(1)   n=1   waiting for factorial(0)
  factorial(2)   n=2   waiting for factorial(1)
  factorial(3)   n=3   waiting for factorial(2)
  <module>             waiting for factorial(3)

Then the stack unwinds, each frame finishing its multiplication with the value returned from above:

  factorial(0) returns 1
  factorial(1) returns 1 * 1 = 1
  factorial(2) returns 2 * 1 = 2
  factorial(3) returns 3 * 2 = 6

The work of multiplying happens on the way back, after the inner calls return. A helpful way to trace recursive code by hand is to write each call on its own line, indented, as above.

You can make the stack visible by printing with indentation proportional to depth:

def factorial(n, depth=0):
    print("  " * depth + f"factorial({n})")
    if n == 0:
        result = 1
    else:
        result = n * factorial(n - 1, depth + 1)
    print("  " * depth + f"-> {result}")
    return result

factorial(3)
factorial(3)
  factorial(2)
    factorial(1)
      factorial(0)
      -> 1
    -> 1
  -> 2
-> 6

The recursion limit

Frames take memory, and CPython's frames for Python calls also use part of the C stack. To fail cleanly rather than crash the whole interpreter, CPython caps recursion depth:

import sys
print(sys.getrecursionlimit())
1000

Exceed it and you get:

Traceback (most recent call last):
  File "demo.py", line 4, in <module>
    countdown(5000)
  File "demo.py", line 3, in countdown
    countdown(n - 1)
  File "demo.py", line 3, in countdown
    countdown(n - 1)
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

You can raise the limit with sys.setrecursionlimit(5000), but that treats the symptom. If you hit it, either the base case is missing or wrong, or the problem is too deep for recursion in Python and a loop is better. Unlike some languages, Python does not do tail-call optimization: even a recursive call in the last position of a function uses a new frame.

Recursion versus loops

Anything written recursively can be written with a loop, and vice versa. For simple linear problems in Python, loops are usually better: no depth limit, and less overhead per step (each call has to create a frame).

def factorial_loop(n):
    result = 1
    for k in range(2, n + 1):
        result *= k
    return result

Recursion shines when the problem branches: each step leads to several smaller subproblems. For example, counting the ways to climb n stairs taking 1 or 2 steps at a time:

def ways(n):
    if n <= 1:
        return 1
    return ways(n - 1) + ways(n - 2)

print(ways(10))
89

This is short and mirrors the reasoning ("the last step was either 1 or 2 stairs"). But beware: ways(n - 1) and ways(n - 2) recompute the same smaller values again and again, so the number of calls grows exponentially; ways(35) takes seconds. The fix (caching results, called memoization) appears in the standard library tour with functools.cache.

Recursion can also make a problem cheaper when each step shrinks the input a lot. Halving instead of subtracting one, as in the fast exponentiation exercise, turns 1000 steps into about 15. That idea, "divide and conquer", underlies binary search and the fastest sorting algorithms.

How to write a recursive function

  1. Find the base case(s): the smallest inputs, answered directly (0, 1, empty string, single digit).
  2. Assume the function already works for smaller inputs (this is the leap of faith). How would you use the answer for n - 1 (or n // 10, or half the input) to answer for n?
  3. Check that every path reaches a base case: the argument must shrink toward it on every call.
  4. Test small cases by hand: 0, 1, 2.

Common mistakes

Recap

Going further: write a recursive fib(n) and count how many calls fib(25) makes using a global counter. Then read about functools.cache in the standard library docs and watch the count collapse.

Exercises

Exercise 1 · quiz

Trace it

What does mystery(4) return?

def mystery(n):
    if n == 0:
        return 0
    return n + mystery(n - 1)

Type just the number.

Answer

mystery(4) is 4 + mystery(3) = 4 + 3 + mystery(2) = 4 + 3 + 2 + 1 + mystery(0) = 4 + 3 + 2 + 1 + 0 = 10. Each call waits on the stack for the smaller call to return, then adds its own n. It computes the sum 0 + 1 + ... + n.

Exercise 2 · quiz

Missing base case

What happens when you call countdown(3)?

def countdown(n):
    print(n)
    countdown(n - 1)
Answer
  • It prints about a thousand numbers, then fails with RecursionError: maximum recursion depth exceeded

There is no base case, so every call makes another call. Each call adds a frame to the call stack, and CPython limits the depth (by default about 1000, see sys.getrecursionlimit()) to avoid exhausting memory. When the limit is hit, it raises RecursionError. An infinite loop would run forever; infinite recursion runs out of stack.

Exercise 3 · write code

Factorial

Define a recursive function factorial(n) returning n! for any integer n >= 0, where 0! is 1 and n! = n * (n - 1)!.

Your function must call itself (no loops).

File: solution.py

Starting point

def factorial(n):
    return n * factorial(n - 1)
Hint 1

The starter never stops: it needs a base case.

Hint 2

Add if n == 0: return 1 before the recursive line.

Reference solution
def factorial(n):
    """Return n! for n >= 0."""
    if n == 0:
        return 1
    return n * factorial(n - 1)

Exercise 4 · write code

Sum of digits

Define a recursive digit_sum(n) that returns the sum of the decimal digits of a non-negative integer.

Use arithmetic, not strings: the last digit of n is n % 10 and the rest is n // 10. No loops.

File: solution.py

Starting point

def digit_sum(n):
    return 0
Hint 1

Base case: a single digit (n < 10) is its own digit sum.

Hint 2

Recursive case: last digit plus the digit sum of the remaining digits: n % 10 + digit_sum(n // 10).

Reference solution
def digit_sum(n):
    """Return the sum of the decimal digits of n (n >= 0)."""
    if n < 10:
        return n
    return n % 10 + digit_sum(n // 10)

Exercise 5 · write code

Fast exponentiation

Define a recursive power(base, exp) for integer exp >= 0 that uses at most about 2 * log2(exp) recursive calls, using these facts:

Compute the half power once and square it by multiplying it with itself. Do not use the ** operator or pow(). The test calls power(2, 1000) with a recursion limit of 100, so the naive one-step-at-a-time recursion will fail.

File: solution.py

Starting point

def power(base, exp):
    if exp == 0:
        return 1
    return base * power(base, exp - 1)
Hint 1

For even exp, store half = power(base, exp // 2) and return half * half. Calling power twice would undo the speedup.

Hint 2

For odd exp, use one ordinary step: base * power(base, exp - 1); the next call has an even exponent.

Reference solution
def power(base, exp):
    """Return base raised to the non-negative integer exp."""
    if exp == 0:
        return 1
    if exp % 2 == 0:
        half = power(base, exp // 2)
        return half * half
    return base * power(base, exp - 1)