Grade by Grade
A. Programming Concepts and Skills · Algorithm Design and Analysis

Thinking Recursively: Base Cases and the Call Stack

A3.6 C2.4 C2.1About 70 minutes
Learning goals
  • I can identify the base case and the recursive case of a recursive algorithm.
  • I can trace a recursive function with the call stack, showing how calls wind up and unwind.
  • I can write simple recursive functions: factorial, sum of a list, power, string reversal.
  • I can design an efficient recursion (such as fast power) and compare it with iteration.

Words to know

recursion
Solving a problem by having a function call itself on a smaller version of the same problem.
base case
A case simple enough to answer directly, without another recursive call; it stops the recursion.
recursive case
The case where the function calls itself on a smaller input and uses that result.
call stack
The stack of active function calls; each call gets a frame with its own parameters and local variables.
stack frame
The memory for one function call: its parameters, local variables and where to return to.
recursion depth
How many calls are on the stack at once; Python's default limit is about 1000.

How many people are in line?

You are in a long line for skates at the outdoor rink and cannot see the front. You ask the person ahead of you, "How many people are in front of you?" They do not know either, so they ask the person ahead of them, and so on. The person at the very front says "Zero!" Then the answers come back down the line: 1, 2, 3, … until someone tells you, and you add 1.

Nobody counted the whole line, yet everyone got the right answer. Each person solved a smaller version of the same problem, and one person (the front) knew the answer directly. That is recursion. In MCR3U (Sequences and Series) you wrote recursion formulas like tₙ = tₙ₋₁ + 3 with t₁ = 2; now you will write recursive algorithms.

The two parts of every recursive function

  1. Base case: an input so simple you answer it directly. (The person at the front: "zero".)
  2. Recursive case: reduce the problem to a smaller one of the same kind, call the function on it, and use the answer. (Ask the person ahead, then add 1.)

The recursive case must always move toward the base case. If it does not, the calls never stop (the topic of Recursion Pitfalls: Infinite Recursion and Exponential Growth).

The classic first example is the factorial: n! = 1 × 2 × 3 × … × n, and 0! = 1. Notice that 4! = 4 × 3!. In general n! = n × (n − 1)!, which is a recursive definition.

def factorial(n):
"""Return n! = 1 x 2 x ... x n.
Precondition: n is an integer >= 0."""
if n == 0: # base case
return 1
return n * factorial(n - 1) # recursive case: a smaller problem
print(factorial(4), factorial(0), factorial(10))

Output: 24 1 3628800. The precondition matters: with a negative n, the calls would never reach 0.

What really happens: the call stack

Every time a function is called, Python creates a stack frame holding that call's parameters and local variables, and pushes it on the call stack. When the call returns, its frame is popped off and its value is handed back to the caller.

So factorial(4) does not finish right away. It needs factorial(3), which needs factorial(2), and so on. There are five separate frames at the deepest point, each with its own n. This is the winding phase. When factorial(0) returns 1, the frames finish one by one in reverse order: the unwinding phase.

Winding: calls pile upUnwinding: values returnfactorial(4): 4 × factorial(3)factorial(4) returns 24factorial(3): 3 × factorial(2)factorial(3) returns 6factorial(2): 2 × factorial(1)factorial(2) returns 2factorial(1): 1 × factorial(0)factorial(1) returns 1factorial(0): return 1factorial(0) returns 1stack grows upwardfactorial(4) waits for factorial(3), which waits for factorial(2), … until the base case answers.
The call stack for factorial(4). Left: each call waits for the one above it. Right: once the base case returns 1, each waiting call multiplies and returns: 1, 1, 2, 6, 24.
def factorial(n, depth=0):
indent = " " * depth
print(indent + "factorial(" + str(n) + ") called")
if n == 0:
print(indent + "base case: return 1")
return 1
result = n * factorial(n - 1, depth + 1)
print(indent + "factorial(" + str(n) + ") returns " + str(result))
return result
factorial(3)

Its checked output is shown in the next box. Indenting by call depth makes the stack visible. Each factorial(n) call prints when it starts and when it returns; the deeper calls finish first.

factorial(3) called
factorial(2) called
factorial(1) called
factorial(0) called
base case: return 1
factorial(1) returns 1
factorial(2) returns 2
factorial(3) returns 6

Output of the program above (checked by running it).

Designing a recursive function: three questions

  1. What is the smallest input, and what is its answer? (Empty list, 0, a string of length 0 or 1.)
  2. How do I make the problem smaller? (n − 1; the list without its first element; the string without its ends; half the input.)
  3. If a call on the smaller problem gives me the right answer, how do I build my answer from it?

Question 3 is the "leap of faith": you do not trace every call in your head; you trust the smaller call, exactly as the person in line trusted the answer from ahead. If the base case is right and each step is right, the whole recursion is right. (This is the same reasoning as mathematical induction, which you will meet in university math.)

def total(a):
"""Return the sum of the numbers in list a (recursively)."""
if len(a) == 0: # smallest input: empty list
return 0
return a[0] + total(a[1:]) # first element + sum of the rest
def reverse(s):
"""Return the string s reversed."""
if len(s) <= 1: # "" or one character
return s
return reverse(s[1:]) + s[0] # reverse the rest, then put the first last
def is_palindrome(s):
"""True if s reads the same forwards and backwards."""
if len(s) <= 1:
return True
if s[0] != s[-1]:
return False
return is_palindrome(s[1:-1]) # check the inside
print(total([3, 1, 4, 1, 5]), reverse("Oshawa"), is_palindrome("racecar"), is_palindrome("canoe"))

Output: 14 awahsO True False. Each function answers the three questions. (The slices a[1:] and s[1:-1] copy part of the list or string, which costs extra time; the next lessons show how to avoid that with index parameters.)

Simple and efficient recursion

A3.6 asks for recursive algorithms that are simple and efficient. Compare two ways to compute xⁿ:

  • power: xⁿ = x × xⁿ⁻¹. It makes n + 1 calls: O(n).
  • fast_power: if n is even, xⁿ = (x^(n/2))²; if n is odd, xⁿ = (x^(n//2))² × x. Each call halves n, so it makes only about log₂ n + 2 calls: O(log n).

The key is to compute fast_power(x, n // 2) once, store it in half, and use it twice. Writing fast_power(x, n // 2) * fast_power(x, n // 2) would make two calls at every level and throw the advantage away (you will see why in Recursion Pitfalls: Infinite Recursion and Exponential Growth).

def power(x, n):
"""x to the power n, for an integer n >= 0. Makes n recursive calls."""
if n == 0:
return 1
return x * power(x, n - 1)
def fast_power(x, n):
"""x to the power n, for an integer n >= 0. Halves n each call."""
if n == 0:
return 1
half = fast_power(x, n // 2) # one recursive call, used twice
if n % 2 == 0:
return half * half
return half * half * x
print(power(2, 10), fast_power(2, 10), fast_power(3, 5))

Output: 1024 1024 243. Both give 2¹⁰ = 1024, and fast_power(3, 5) = 243. For n = 1000, power needs 1001 nested calls, which goes past Python's default limit of 1000 and raises RecursionError, while fast_power needs just 11.

power(2, 10): n goes down by 1 each call (11 calls)109876543210fast_power(2, 10): n is halved each call (5 calls)10// 25// 22// 21// 20Each box is one call, showing its n. Yellow: the base case n = 0.
Call chains for n = 10. Halving reaches the base case in 5 calls instead of 11. For n = 1 000 000 that is about 21 calls instead of 1 000 001.
Worked example

Tracing a recursive function

Trace mystery(5) and say what the function computes.

def mystery(n):

    if n <= 1:

        return n

    return mystery(n // 2) + n % 2

  1. Winding: mystery(5) needs mystery(2) + 5 % 2 = mystery(2) + 1. mystery(2) needs mystery(1) + 2 % 2 = mystery(1) + 0. mystery(1) is a base case: it returns 1.
  2. Unwinding: mystery(2) = 1 + 0 = 1. mystery(5) = 1 + 1 = 2.
  3. What it computes: each call adds the last binary digit (n % 2) and recurses on n with that digit removed (n // 2). So it counts the 1s in the binary form of n. 5 is 101 in binary: two 1s.
  4. Check: mystery(7) = 3 (111), mystery(8) = 1 (1000). It makes about log₂ n calls: O(log n).

mystery(5) returns 2; the function counts the 1 bits in n's binary representation.

Recursion or a loop?

Anything a loop can do, recursion can do, and the reverse is also true (sometimes with an explicit stack). In Python, a simple loop is usually faster and has no depth limit, so for factorial or summing a list, a loop is the practical choice. Recursion shines when the problem itself is recursive: splitting in halves (merge sort, binary search, fast power), shapes made of smaller copies of themselves (fractals), and nested data (folders inside folders, XML elements inside elements). The next three lessons are about exactly those.

Just for fun

To understand recursion, you must first understand recursion.

(The joke only works because it has no base case. Neither will your evening, if you forget yours.)

Hands-on activity

Nesting boxes and a stack of sticky notes

You need: 4 or 5 boxes or containers that fit inside each other (or nested plastic cups), a small object to hide in the smallest one, a pad of sticky notes, a pen, and a computer with Python.

  1. Hide the object in the smallest box and nest them all. "Algorithm open(box): if the box holds the object, return it (base case); otherwise, open(the box inside)." Act it out and count the calls.
  2. Sticky-note call stack. Trace factorial(4) by hand: each time a call starts, write a sticky note "factorial(n): waiting for factorial(n − 1)" and stick it on top of the previous one. At the base case, write the return value. Then peel the notes off one at a time from the top, writing each return value. The notes you peel off first were the last ones added.
  3. Write recursive functions for: count_down(n) (prints n down to 1, then "Liftoff!"), count_vowels(s), and maximum(a) (the largest element of a non-empty list). For each, write the three design answers as comments first.
  4. Run power(2, 1000) and read the error message. Then run fast_power(2, 1000).

What to look for: every function has a base case that is reached for every valid input; the sticky notes come off in the reverse of the order they went on.

Your turn

1

Name the base case and the recursive case of factorial.

2

Write a recursive function sum_to(n) that returns 1 + 2 + … + n (and 0 for n = 0).

3

How many stack frames for factorial are on the call stack at the deepest point when computing factorial(6)?

4

Trace reverse("abc").

5

Why is fast_power O(log n) while power is O(n)?

6

What error do you get if recursion goes more than about 1000 calls deep in Python?

More practice: warm-up

1

What is a base case?

2

What is 5! ?

3

Each recursive call has its own copy of which things?

4

In the winding phase, are calls being made or values being returned?

5

What does total([]) return?

More practice: on your own

1

Write count(a, x): the number of times x appears in list a, recursively.

2

Predict the output:

def f(n):

    if n == 0:

        return ""

    return f(n - 1) + str(n)

print(f(4))

3

Predict the output:

def g(n):

    if n == 0:

        return ""

    return str(n) + g(n - 1)

print(g(4))

4

How many calls does fast_power(5, 16) make, and what are the values of n?

5

Write a recursive digit_sum(n) for n ≥ 0 (e.g. digit_sum(4096) = 19).

Challenge

1

You wrote Euclid's algorithm with a loop in Algorithms Built on Remainders: Digits, Bases, GCD and Check Digits. Now write a recursive gcd(a, b) using Euclid's rule gcd(a, b) = gcd(b, a % b), with gcd(a, 0) = a. Trace gcd(84, 36). Why does it always stop?

2

total(a) uses a[1:], which copies the rest of the list. How many element copies does it make in total for a list of n elements, and what is its Big-O? How could you fix it?

3

Write binary(n) that returns the binary representation of n ≥ 1 as a string, recursively (e.g. binary(6) = "110").

Check your understanding

Quiz

1.

What must every correct recursive function have?

2.

What does factorial(5) return?

3.

During the unwinding phase of factorial(4), which call returns first?

4.

Why is fast_power(x, n) more efficient than power(x, n)?

5.

What does this return for n = 3?

def h(n): return 0 if n == 0 else 2 + h(n - 1)

6.

What happens in Python when recursion goes about 1000 calls deep?

For parents and teachers

Big idea: A3.6 asks students to design simple and efficient recursive algorithms (the curriculum's first example is calculating a factorial). This lesson builds the core skills: base case, recursive case, the call stack, and the three design questions, and it contrasts an inefficient O(n) recursion with an efficient O(log n) one. The RecursionError and the cost of slicing preview C2.4 (recursion pitfalls).

How to help: Act out the "how many people are in line?" story at the dinner table, or the nested boxes. Ask your teen, "What's the base case? How does each call get smaller?" If they can answer both, they understand the function.

Common difficulties:

  • Missing or unreachable base cases (for example, n == 0 when n can be negative or can skip over 0).
  • Forgetting to return the result of the recursive call (writing factorial(n - 1) instead of return n * factorial(n - 1)).
  • Trying to trace every call mentally instead of trusting the smaller call.
  • Thinking all calls share one n: each call has its own frame.