Skip to content

Memoization and Recursion

Memoization caches function results to avoid redundant computations. It transforms exponential-time recursive algorithms into polynomial-time solutions.

Mental Model

Memoization is a lookup table for function calls: before computing, check whether you've already solved this exact subproblem. If yes, return the cached answer; if no, compute it, store it, then return it. This single optimization can turn an exponential algorithm into a linear one by ensuring each unique subproblem is solved only once.


The Problem: Redundant Computation

```python def fibonacci(n): '''Naive fibonacci: exponential time O(2^n)''' if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(5) calls fibonacci(3) three times, fibonacci(2) five times!

Time complexity: O(2^n)

import time start = time.time() result = fibonacci(35) elapsed = time.time() - start print(f"fibonacci(35) = {result}, took {elapsed:.2f} seconds") ```

lru_cache Decorator

```python from functools import lru_cache

@lru_cache(maxsize=None) def fibonacci_cached(n): '''Memoized fibonacci: linear time O(n)''' if n <= 1: return n return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

Now O(n) instead of O(2^n)!

import time start = time.time() result = fibonacci_cached(35) elapsed = time.time() - start print(f"fibonacci_cached(35) = {result}, took {elapsed:.6f} seconds")

View cache statistics

print(fibonacci_cached.cache_info()) ```

Manual Memoization

```python def fibonacci_memo(n, cache=None): '''Manual memoization with dictionary''' if cache is None: cache = {}

if n in cache:
    return cache[n]

if n <= 1:
    return n

cache[n] = fibonacci_memo(n - 1, cache) + fibonacci_memo(n - 2, cache)
return cache[n]

print(fibonacci_memo(30)) # Nearly instant ```

Example: Longest Common Subsequence

```python from functools import lru_cache

@lru_cache(maxsize=None) def lcs(text1, text2): '''Longest common subsequence using memoization''' if not text1 or not text2: return 0

if text1[0] == text2[0]:
    return 1 + lcs(text1[1:], text2[1:])

return max(
    lcs(text1[1:], text2),
    lcs(text1, text2[1:])
)

print(lcs("abcde", "ace")) # 3 print(lcs.cache_info()) # Show cache hits/misses ```

Cache Strategies

```python from functools import lru_cache

Limited cache size (evicts least recently used)

@lru_cache(maxsize=128) def expensive_function(x): return x ** 2

Unbounded cache (use with caution)

@lru_cache(maxsize=None) def mathematical_function(n): return n * (n + 1) // 2

Clear cache when needed

expensive_function.cache_clear() ```

Performance Impact

Memoization provides dramatic speedup for recursive algorithms with overlapping subproblems:

  • Without memoization: O(2^n)
  • With memoization: O(n)

Exercises

Exercise 1. Write a memoized recursive function coin_change(amount, coins) that returns the minimum number of coins needed to make the given amount. Use a dictionary for manual memoization. Test with coin_change(11, (1, 5, 6)) (expected: 3, using 5+5+1 or 6+5).

Solution to Exercise 1
def coin_change(amount, coins, memo=None):
    if memo is None:
        memo = {}
    if amount == 0:
        return 0
    if amount < 0:
        return float("inf")
    if amount in memo:
        return memo[amount]

    min_coins = float("inf")
    for coin in coins:
        result = coin_change(amount - coin, coins, memo)
        min_coins = min(min_coins, result + 1)

    memo[amount] = min_coins
    return min_coins

print(coin_change(11, (1, 5, 6)))   # 3
print(coin_change(15, (1, 5, 10)))  # 2

Exercise 2. Implement the climb_stairs(n) problem (number of ways to reach step n by taking 1 or 2 steps at a time) using @lru_cache. Compute climb_stairs(30) and print the cache statistics.

Solution to Exercise 2
from functools import lru_cache

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

print(climb_stairs(30))              # 1346269
print(climb_stairs.cache_info())

Exercise 3. Write a manual memoization decorator @memoize that stores results in a dictionary. Apply it to a recursive fibonacci(n) function. Compare performance by timing fibonacci(35) with and without the decorator.

Solution to Exercise 3
import time

def memoize(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

# Without memoization
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# With memoization
@memoize
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n - 1) + fib_fast(n - 2)

start = time.time()
print(fib_slow(35))
print(f"Without memo: {time.time() - start:.3f}s")

start = time.time()
print(fib_fast(35))
print(f"With memo:    {time.time() - start:.6f}s")