Learn / DS & Algo / Algorithms / Recursion

Algorithms · Lesson 3 of 6

Recursion

Base cases, recursive cases, the call stack and when to avoid recursion.

  • Intermediate
  • 15 min read
  • 3 objectives

Before this lessonLesson 2: Sorting

What you will learn

  • Identify base and recursive cases
  • Trace the call stack
  • Convert recursion to iteration

A recursive function solves a problem by calling itself on a smaller version of the same problem. It feels odd at first, but many problems (trees, nested folders, divide-and-conquer) are naturally recursive, and the code ends up shorter than a loop.

Solving a problem by shrinking it

Recursion means a function that calls itself on a smaller version of the same problem. Russian nesting dolls are a good picture: to count the dolls, open the outer one, count what is inside, add one. Every recursive solution needs two parts: a base case (the smallest problem, answered directly) and a recursive case (reduce the problem and trust the function to solve the smaller one). Forget the base case and the function never stops.

Two required parts

  • Base case: the smallest problem, answered directly. Without it, the function never stops.
  • Recursive case: reduce the problem and call yourself, trusting that the call returns the right answer for the smaller input.
def factorial(n):
    if n <= 1:               # base case
        return 1
    return n * factorial(n - 1)   # recursive case

print(factorial(5))
Output
120

The call stack

Each call waits for the one it started. factorial(3) becomes 3 * factorial(2), which becomes 3 * (2 * factorial(1)); when the base case returns 1, the stack unwinds multiplying as it goes. Each waiting call uses memory, so Python limits depth (about 1000) and raises RecursionError if you exceed it.

More examples

def sum_list(a):
    return 0 if not a else a[0] + sum_list(a[1:])

def reverse(s):
    return s if len(s) <= 1 else reverse(s[1:]) + s[0]

def power(x, n):                    # fast exponent, O(log n)
    if n == 0:
        return 1
    half = power(x, n // 2)
    return half * half * (x if n % 2 else 1)

print(sum_list([1, 2, 3]), reverse("abc"), power(2, 10))
Output
6 cba 1024

Recursion on nested data

Recursion shines when data has no fixed depth, like a nested list or a directory tree.

def flatten(x):
    out = []
    for item in x:
        if isinstance(item, list):
            out.extend(flatten(item))
        else:
            out.append(item)
    return out

print(flatten([1, [2, [3, 4]], 5]))
Output
[1, 2, 3, 4, 5]

The Fibonacci trap

Naive fib(n) = fib(n-1) + fib(n-2) recomputes the same values over and over and takes exponential time. Caching results fixes it, which leads straight into dynamic programming.

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))
Output
12586269025

Recursion vs iteration

Anything recursive can be written with a loop and an explicit stack. Prefer a loop when depth could be large or the loop is just as clear; prefer recursion when it mirrors the structure of the problem.

Factorial, traced

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

print(factorial(5))
Output
120

Trace it: factorial(3) waits for factorial(2), which waits for factorial(1). That returns 1, then 2 * 1 = 2, then 3 * 2 = 6. Each call waits on the call stack until the one below finishes.

Watch the calls happen

def countdown(n, depth=0):
    print("  " * depth + f"countdown({n})")
    if n == 0:
        print("  " * depth + "liftoff")
        return
    countdown(n - 1, depth + 1)
    print("  " * depth + f"back in countdown({n})")

countdown(2)
Output
countdown(2)
  countdown(1)
    countdown(0)
    liftoff
  back in countdown(1)
back in countdown(2)

Worked example: summing a nested list

def total(node):
    if isinstance(node, int):
        return node
    return sum(total(child) for child in node)

print(total([1, [2, 3], [4, [5, 6]]]))
Output
21

Making Fibonacci fast with memoization

The obvious version recomputes the same values over and over, taking exponential time. Remembering answers (memoization) turns it linear.

from functools import lru_cache

calls = 0

def slow(n):
    global calls
    calls += 1
    return n if n < 2 else slow(n - 1) + slow(n - 2)

@lru_cache(None)
def fast(n):
    return n if n < 2 else fast(n - 1) + fast(n - 2)

print(slow(20), "calls:", calls)
print(fast(20), "cache size:", fast.cache_info().currsize)
Output
6765 calls: 21891
6765 cache size: 21

Key takeaways

  • Every recursive function needs a base case and a step that moves toward it.
  • Calls stack up and unwind; a missing base case causes a stack overflow.
  • Recursion suits nested and tree-shaped data; memoize when subproblems repeat.
# Write your solution here
Up next · Lesson 4Greedy AlgorithmsMake the locally best choice at each step, and know when that is enough.