Memoization from First Principles: Building and Tracing a Cache
The Problem Memoization Solves
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
For fib(5), count the calls:
fib(5)
โโโ fib(4)
โ โโโ fib(3)
โ โ โโโ fib(2)
โ โ โ โโโ fib(1) = 1
โ โ โ โโโ fib(0) = 0
โ โ โโโ fib(1) = 1
โ โโโ fib(2)
โ โโโ fib(1) = 1
โ โโโ fib(0) = 0
โโโ fib(3)
โโโ fib(2)
โ โโโ fib(1) = 1
โ โโโ fib(0) = 0
โโโ fib(1) = 1
fib(2)is computed 3 times.fib(1)is computed 5 times.fib(0)is computed 3 times.
For fib(50) this becomes catastrophically slow. Memoization solves this by caching results and returning them immediately on repeated calls.
Manual Implementation
def memoized_fib(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
return n
cache[n] = memoized_fib(n - 1, cache) + memoized_fib(n - 2, cache)
return cache[n]
Trace the cache state during memoized_fib(5):
| Call | Cache before | Returns | Cache after |
|---|---|---|---|
| fib(5) | {} | (computing) | |
| fib(4) | {} | (computing) | |
| fib(3) | {} | (computing) | |
| fib(2) | {} | (computing) | |
| fib(1) | {} | 1 | {} |
| fib(0) | {} | 0 | {} |
| fib(2) resolves | {} | 1 | {2: 1} |
| fib(1) | {2:1} | 1 | {2: 1} |
| fib(3) resolves | {2:1} | 2 | {2:1, 3:2} |
| fib(2) | {2:1, 3:2} | 1 (cache hit) | {2:1, 3:2} |
| fib(4) resolves | {2:1, 3:2} | 3 | {2:1, 3:2, 4:3} |
| fib(3) | {2:1, 3:2, 4:3} | 2 (cache hit) | {2:1, 3:2, 4:3} |
| fib(5) resolves | {2:1, 3:2, 4:3} | 5 | {2:1, 3:2, 4:3, 5:5} |
The cache hits on fib(2) and fib(3) eliminate the redundant subtree computations entirely.
Building a General Memoize Decorator
def memoize(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10))
print(fibonacci.__closure__[0].cell_contents)
Output:
55
{(1,): 1, (0,): 0, (2,): 1, (3,): 2, (4,): 3, (5,): 5, (6,): 8, (7,): 13, (8,): 21, (9,): 34, (10,): 55}
The cache is a dictionary inside the closure. Keys are argument tuples. Each unique input is computed exactly once.
functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10))
print(fibonacci.cache_info())
Output:
55
CacheInfo(hits=8, misses=11, maxsize=128, currsize=11)
lru_cache uses a Least Recently Used eviction policy when the cache reaches maxsize. The cache_info() method shows how many hits and misses occurred.
The space-time trade-off in memoization is worth understanding deeply for interview discussions: you are trading memory for computation time. For problems with exponential time complexity and polynomial unique subproblems, memoization converts an intractable problem to a tractable one.
Practice memoization tracing problems at pycodeit.com.
Comments
No comments yet. Start the discussion.