Memoization from First Principles: Building and Tracing a Cache
DEV Community

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.