Memoization (Top-Down Dynamic Programming)
When to Use
- A recursive function is exponentially slow (e.g. naive Fibonacci, naive alignment-path counting) and you need to speed it up without rewriting the algorithm.
- You need to count/enumerate combinatorial possibilities (alignments, RNA foldings, coin combinations, climbing-stairs style paths) that have a recurrence relation.
- You're prototyping before writing an explicit O(nm) DP table (see
algo-tabulation) and want to reason about the recurrence first. - Someone asks to "add caching to this recursive function" or "why is my recursive solution slow."
- You're building toward alignment/HMM/RNA-folding algorithms and want to understand the DP mindset those algorithms rely on.
Version Compatibility
Pure Python standard library — functools.lru_cache / functools.cache (Python ≥ 3.9). No third-party dependencies. Works identically on Python 3.10–3.13.
Prerequisites
- Comfort writing recursive functions and base cases.
algo-complexity-analysis(recognizing O(2^n) vs O(n)) is a useful prior skill.- No packages to install — everything here is stdlib.
DP Applicability Checklist
Ask these three questions; if all are "yes," memoization applies:
- Recursive substructure — the answer to the big problem is built from answers to smaller instances (a recurrence relation exists).
- Overlapping subproblems — the same smaller instance is solved more than once during naive recursion.
- Base cases — the smallest instances have known, directly-returnable answers.
Goal: turn an exponential-time naive recursion into linear/polynomial time.
Approach: cache each (arguments) -> result mapping the first time it's computed, and return the cached value on every subsequent call with the same arguments — either manually with a dict, or automatically with @lru_cache.
import time
from functools import lru_cache
def fib_naive(n):
"""Pure recursive Fibonacci - O(2^n) time. Recomputes fib(k) exponentially many times."""
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
def fib_memoized(n, memo=None):
"""Top-down Fibonacci with a manual dict cache - O(n) time, O(n) space."""
if memo is None:
memo = {}
if n <= 1:
return n
if n in memo:
return memo[n]
memo[n] = fib_memoized(n - 1, memo) + fib_memoized(n - 2, memo)
return memo[n]
@lru_cache(maxsize=None)
def fib_cached(n):
"""Same recurrence, cached automatically by functools - the preferred idiom."""
if n <= 1:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
if __name__ == "__main__":
# naive is fine up to ~30, then blows up; memoized handles n=200+ instantly
# (n much above ~950 needs sys.setrecursionlimit() or tabulation - see Pitfalls)
start = time.perf_counter()
assert fib_memoized(200) == fib_cached(200)
fib_cached.cache_clear()
fib_cached(200)
print(f"fib(200) via lru_cache: {fib_cached(200)} in {time.perf_counter() - start:.4f}s")
print(fib_cached.cache_info()) # CacheInfo(hits, misses, maxsize, currsize)
Goal: count the number of possible pairwise-alignment paths between two sequences (the same recurrence structure underlying Needleman-Wunsch/Smith-Waterman, but counting paths instead of scoring them).
Approach: at each position, either consume one base from each sequence (match/mismatch), or a gap in one sequence; recurse and cache on (m, n).
from functools import lru_cache
@lru_cache(maxsize=None)
def count_alignments(m, n):
"""Count possible alignments between sequences of length m and n.
Same recurrence as the Needleman-Wunsch alignment matrix, but summing
path counts instead of scores. Grows combinatorially with m, n.
"""
if m == 0 or n == 0:
return 1 # only gap insertions remain
return (
count_alignments(m - 1, n - 1) # match/mismatch: consume both
+ count_alignments(m - 1, n) # gap in sequence 2
+ count_alignments(m, n - 1) # gap in sequence 1
)
for m, n in [(3, 3), (5, 5), (10, 10)]:
count_alignments.cache_clear() # clear so timing/counts aren't skewed across cache-shared calls
print(f"lengths {m},{n}: {count_alignments(m, n):,} possible alignments")
Goal: find the minimum number of coins (or, biologically, minimum edit operations / minimum resource units) to reach a target amount. Approach: try every coin as the "last one used," recurse on the remainder, cache on the remaining amount.
def min_coins(coins, amount, memo=None):
"""Minimum coins needed to make `amount`; returns float('inf') if impossible."""
if memo is None:
memo = {}
if amount == 0:
return 0
if amount < 0:
return float("inf")
if amount in memo:
return memo[amount]
best = float("inf")
for coin in coins:
result = min_coins(coins, amount - coin, memo)
if result != float("inf"):
best = min(best, result + 1)
memo[amount] = best
return best
coins = [1, 5, 10, 25]
for amount in [11, 30, 41, 63]:
print(f"{amount} cents: {min_coins(coins, amount)} coins minimum")
Pitfalls
- Python's default recursion limit is ~1000 — deep memoized recursion (e.g.
fib_cached(5000)) raisesRecursionError; raise the limit withsys.setrecursionlimit()or switch to bottom-up tabulation (algo-tabulation) for largen. - Mutable default arguments (
memo={}in the function signature) persist across separate top-level calls and silently leak stale entries — usememo=Noneand initialize inside the function body, as shown above. @lru_cacherequires all arguments to be hashable — it fails onlist/dict/setarguments; convert totuple/frozensetfirst, or pass a manual dict cache instead.- Forgetting
.cache_clear()between benchmarking runs on@lru_cache-decorated functions makes timings meaningless (later calls hit the cache from earlier ones). - Memoization only saves time if subproblems actually overlap — if every call has unique arguments (no repeated subproblems), caching adds overhead with no benefit.
See Also
algo-tabulation— bottom-up DP with explicit tables, avoids recursion-depth limits.algo-knapsack— classic 2D memoization/tabulation problem (weight/value trade-off).algo-sequence-alignment— the real Needleman-Wunsch/Smith-Waterman scoring DP thatcount_alignmentsmirrors.algo-complexity-analysis— reasoning about why naive recursion is O(2^n) and memoized is O(n).