Learning Goals
3 minBy the end of this lesson you can:
- Write factorial, fibonacci, and power recursively.
- Read the equivalent iterative loop and pick the right one for the job.
- Spot exponential time blow-up in naïve fibonacci.
- Cache repeated subproblems with
functools.lru_cache.
Warm-Up · Factorial
5 min# Iterative def factorial_loop(n): result = 1 for i in range(2, n + 1): result *= i return result # Recursive def factorial(n): if n <= 1: return 1 return n * factorial(n - 1) print(factorial(5)) # 5*4*3*2*1 = 120 print(factorial_loop(5)) # same
Same answer. Same speed. For factorial, both are fine — pick whichever reads better.
Recursion sometimes shines (Fibonacci-style tree problems, trees of files, fractal art). Sometimes a loop is simpler. And sometimes naïve recursion is a disaster waiting for one tiny fix.
New Concept · Three Classics
14 min1 · Factorial — a clean linear recursion
def factorial(n): if n <= 1: return 1 return n * factorial(n - 1)
One call per step. n deep. factorial(1000) hits Python's recursion limit; for big factorials the loop wins.
2 · Fibonacci — branching recursion
# Naïve def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(10)) # 55 print(fib(35)) # 9227465 -- but this took seconds!
For fib(35), the function calls itself ~30 million times. Why? Each call branches into two. fib(35) calls fib(34) and fib(33); fib(34) also calls fib(33); both fib(33)s recompute everything from scratch. Exponential blow-up.
Fix #1 · Iterative Fibonacci
def fib_loop(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a print(fib_loop(100)) # 354224848179261915075 -- instant!
No branching. One pass. fib_loop(100) is milliseconds.
Fix #2 · Memoised recursion
Keep the recursive shape; cache results. functools.lru_cache does it in one decorator.
from functools import lru_cache @lru_cache(maxsize=None) def fib_memo(n): if n < 2: return n return fib_memo(n - 1) + fib_memo(n - 2) print(fib_memo(100)) # 354224848179261915075 -- also instant
lru_cache remembers every (args) → result pair. The second time fib_memo(33) is called, it returns instantly from the cache. The exponential blow-up vanishes.
3 · Power — divide and conquer
# Naïve recursion — linear in n def power(base, n): if n == 0: return 1 return base * power(base, n - 1) # Smart — divide and conquer def fast_power(base, n): if n == 0: return 1 if n % 2 == 0: half = fast_power(base, n // 2) return half * half return base * fast_power(base, n - 1) print(power(2, 10)) # 1024 print(fast_power(2, 10)) # 1024 print(fast_power(2, 1000)) # huge — works fine
Naïve power(2, 1000) needs 1000 recursive calls. fast_power needs only ~10 (each step roughly halves n). Divide-and-conquer turns linear into logarithmic.
The big lesson
Problem Naïve recursion Better factorial(n) O(n) same as loop fib(n) naïve O(2ⁿ) — bad! O(n) with memo or loop power(b, n) O(n) O(log n) with halving
Recursion is a tool. Use the right shape for the problem.
The recursion limit
import sys print(sys.getrecursionlimit()) # default ~1000 sys.setrecursionlimit(10_000) # raise it if you really need to
If you need more than 10k deep, you almost certainly want iteration instead. Python is not optimised for deep recursion (no tail-call optimisation).
Worked Example · Timing All Three
12 minSave as recursion_bench.py:
# recursion_bench.py — race naïve vs smart for each classic import time from functools import lru_cache # --- Factorial --- def factorial(n): if n <= 1: return 1 return n * factorial(n - 1) def factorial_loop(n): r = 1 for i in range(2, n + 1): r *= i return r # --- Fibonacci --- def fib_naive(n): if n < 2: return n return fib_naive(n - 1) + fib_naive(n - 2) @lru_cache(maxsize=None) def fib_memo(n): if n < 2: return n return fib_memo(n - 1) + fib_memo(n - 2) def fib_loop(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a # --- Power --- def power(base, n): if n == 0: return 1 return base * power(base, n - 1) def fast_power(base, n): if n == 0: return 1 if n % 2 == 0: h = fast_power(base, n // 2) return h * h return base * fast_power(base, n - 1) # --- Bench --- def time_call(fn, *args, label=""): start = time.perf_counter() result = fn(*args) elapsed = time.perf_counter() - start print(f" {label:<22} {elapsed * 1000:>8.3f} ms → {str(result)[:50]}...") print("=== factorial(100) ===") time_call(factorial, 100, label="recursive") time_call(factorial_loop, 100, label="iterative") print("\n=== fibonacci(30) ===") time_call(fib_naive, 30, label="naive recursion") time_call(fib_memo, 30, label="memoised") time_call(fib_loop, 30, label="iterative") print("\n=== fibonacci(35) — naive only ===") time_call(fib_naive, 35, label="naive recursion") # noticeably slower! print("\n=== power(2, 100) ===") time_call(power, 2, 100, label="naive recursion") time_call(fast_power, 2, 100, label="divide-and-conquer")
Sample output (times will vary)
=== factorial(100) === recursive 0.060 ms → 93326215443944152681699238856266700490715968264381... iterative 0.018 ms → 93326215443944152681699238856266700490715968264381... === fibonacci(30) === naive recursion 210.000 ms → 832040 memoised 0.020 ms → 832040 iterative 0.005 ms → 832040 === fibonacci(35) — naive only === naive recursion 2200.000 ms → 9227465 === power(2, 100) === naive recursion 0.045 ms → 1267650600228229401496703205376 divide-and-conquer 0.008 ms → 1267650600228229401496703205376
Read the diff
Factorial: same speed either way. Fibonacci naïve is millions of times slower than memo or loop. Power doubling is ~6x faster — and the gap widens with bigger exponents. Three different lessons from three classic functions.
Try It Yourself
13 minCall your factorial(20). The expected answer is 2432902008176640000.
Make a memoised version of the naïve power(base, n) and confirm it's faster for large n.
Hint
from functools import lru_cache @lru_cache(maxsize=None) def power_memo(base, n): if n == 0: return 1 return base * power_memo(base, n - 1) # But notice — for a single call power(2, 1000), memo doesn't help # because there are no repeated subproblems. Memo helps when you # call power(2, ...) for many different n values in the same run.
Memoisation only helps when there are repeated subproblems. Linear recursion like power has none from a single call.
Print the moves to solve Tower of Hanoi for n disks. Use recursion: to move n disks from A to C using B as helper:
- Move n-1 disks from A to B (using C).
- Move disk n from A to C.
- Move n-1 disks from B to C (using A).
Hint
def hanoi(n, src, helper, dest): if n == 0: return hanoi(n - 1, src, dest, helper) print(f"Move disk {n} from {src} to {dest}") hanoi(n - 1, helper, src, dest) hanoi(3, "A", "B", "C") # Move disk 1 from A to C # Move disk 2 from A to B # Move disk 1 from C to B # Move disk 3 from A to C # Move disk 1 from B to A # Move disk 2 from B to C # Move disk 1 from A to C
2ⁿ - 1 moves for n disks. The recursion structure mirrors the puzzle exactly.
Mini-Challenge · Memo Race
8 minBuild memo_race.py. Two recursive functions where memo dramatically helps:
climb(n)— number of ways to climb n stairs, taking 1 or 2 steps at a time.climb(n) = climb(n-1) + climb(n-2). Naïve is exponential.paths(rows, cols)— number of paths from top-left to bottom-right of a grid, moving only right or down.paths(r, c) = paths(r-1, c) + paths(r, c-1). Also exponential naïvely.
Implement both naïve and memoised. Time the naïve versions for climb(35) and paths(15, 15) vs memoised. Print speedup ratios.
Show one possible solution
from functools import lru_cache import time # Naïve def climb(n): if n <= 1: return 1 return climb(n - 1) + climb(n - 2) def paths(r, c): if r == 0 or c == 0: return 1 return paths(r - 1, c) + paths(r, c - 1) # Memoised @lru_cache(maxsize=None) def climb_memo(n): if n <= 1: return 1 return climb_memo(n - 1) + climb_memo(n - 2) @lru_cache(maxsize=None) def paths_memo(r, c): if r == 0 or c == 0: return 1 return paths_memo(r - 1, c) + paths_memo(r, c - 1) def race(label, naive, memo, *args): s = time.perf_counter() naive(*args) t1 = time.perf_counter() - s s = time.perf_counter() memo(*args) t2 = time.perf_counter() - s print(f"{label:<20} naive {t1*1000:>9.2f} ms memo {t2*1000:>7.3f} ms speedup {t1/t2:>6.0f}x") race("climb(30)", climb, climb_memo, 30) race("paths(12, 12)", paths, paths_memo, 12, 12)
Non-negotiables: two naïve + two memoised functions, a timing helper that prints both, speedup ratios. climb is "staircase" — a classic interview question; paths is a classic dynamic-programming warmup.
Recap
3 minThree classic recursive functions. Factorial is a clean linear recursion — same as a loop. Fibonacci naïve is exponentially slow because it recomputes overlapping subproblems; @lru_cache fixes it. Power can be sped up dramatically with divide-and-conquer halving — from O(n) to O(log n). The pattern: any recursion that revisits subproblems benefits hugely from memoisation; any recursion that can split a problem in half benefits from divide-and-conquer.
Vocabulary Card
- overlapping subproblems
- When a recursive call would compute the same answer many times. Solved by memoisation.
- memoisation
- Caching results so a repeated call returns instantly.
@lru_cacheis the easy way. - divide and conquer
- Split a problem into two halves, solve each, combine. Often turns O(n) into O(log n).
- tail call
- A recursive call as the final action of a function. Python doesn't optimise these.
- RecursionError
- Default depth limit ~1000. Don't fight it with
setrecursionlimit— switch to iteration.
Homework
4 minWrite four functions:
gcd(a, b)— Euclidean greatest common divisor. Base: whenb == 0, returna. Recursive:gcd(b, a % b).binary_string(n)— recursively convert a non-negative integer to its binary string. Base:n < 2returns str(n). Recursive:binary_string(n // 2) + str(n % 2).count_chars(s, ch)— count occurrences ofchinsrecursively.multiply(a, b)— multiply two non-negative integers using only addition.a + multiply(a, b - 1)with baseb == 0 → 0.
Sample · four recursive helpers
def gcd(a, b): if b == 0: return a return gcd(b, a % b) def binary_string(n): if n < 2: return str(n) return binary_string(n // 2) + str(n % 2) def count_chars(s, ch): if s == "": return 0 return (1 if s[0] == ch else 0) + count_chars(s[1:], ch) def multiply(a, b): if b == 0: return 0 return a + multiply(a, b - 1) print(gcd(48, 18)) # 6 print(binary_string(13)) # '1101' print(count_chars("banana", "a")) # 3 print(multiply(4, 7)) # 28
Non-negotiables: four recursive functions, each with a clean base case. gcd is Euclid's algorithm from 300 BC — still optimal today.