Skip to content

Memoization

coreCommon atGoogleAmazonMetaAnthropicRead firstRecursionHash mapsPractise in the Sandbox →

Memoization stores the result of a function call in a cache keyed by its arguments, and returns the stored value on the next call with the same arguments. Applied to a recursive solution with overlapping subproblems, it turns exponential time into (number of distinct states) × (work per state). It’s top-down DP: same recurrence as the bottom-up table, filled on demand.

fib(5) calls fib(4) and fib(3). But fib(4) also calls fib(3). And both call fib(2), over and over. The recursion tree doubles at every level even though there are only six different questions (fib(0) to fib(5)).

Memoization is the obvious fix once you see it: write down every answer the first time you compute it, and look it up before computing again. The recursion tree collapses into a chain.

Three requirements:

  • Pure function. Same arguments, same result, no side effects that matter. Caching a function that reads a clock or a mutable global gives stale answers.
  • Hashable key. The cache key must capture every argument that affects the result.
  • Overlap. If subproblems never repeat (e.g. merge sort), caching only adds overhead.

Start with the cache off: yellow nodes are subproblems already solved on another branch. Then turn it on and watch whole subtrees disappear into single cache hits.

fib(n): the recursion tree, before and after caching
step through
f5
calls1
cache hits0
depth1
current callreturnedrepeated subproblem
Call fib(5): it needs fib(4) and fib(3).
1 / 31

The gap grows exponentially. Drag n:

Calls made by fib(n)
drag the slider
Plain recursion
242,785
Memoized
49
Bars use a log scale, otherwise the memoized bar would be invisible. Plain recursion makes 2·F(n+1) − 1 calls; with a cache it's 2n − 1. At n = 45 that's about 3.7 billion calls against 89.
The situation… Why memoization fits
You have a correct but slow recursive solution Add a cache; often the fastest route to an accepted answer.
The recursion has a few small integer/string arguments They make a clean cache key; the state space is their product.
Only a fraction of states are reachable Top-down visits only what’s needed; bottom-up fills everything.
The fill order for a bottom-up table is awkward (trees, DAGs, intervals) Recursion discovers the order for you.
Search / game trees with transpositions (same position via different moves) Cache by position.
  1. Base cases first, so they never touch the cache.
  2. Look up the key. Return the cached value if present.
  3. Compute from recursive calls, store, then return.

Fibonacci three ways: hand-written cache, the standard library decorator, and the bottom-up loop it’s equivalent to.

fib.py
from functools import cache
def fib_memo(n: int, memo: dict[int, int] | None = None) -> int:
"""Top-down: plain recursion plus a dictionary of answers already known."""
if memo is None:
memo = {}
if n < 2:
return n # 1. base case
if n in memo:
return memo[n] # 2. seen before: O(1)
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo) # 3. compute once, store
return memo[n]
@cache # the stdlib does steps 2-3 for you
def fib_cached(n: int) -> int:
return n if n < 2 else fib_cached(n - 1) + fib_cached(n - 2)
def fib_bottom_up(n: int) -> int:
"""Bottom-up: same recurrence, smallest subproblems first, no recursion depth limit."""
a, b = 0, 1 # fib(0), fib(1)
for _ in range(n):
a, b = b, a + b
return a
if __name__ == "__main__":
assert fib_memo(50) == fib_cached(50) == fib_bottom_up(50) == 12586269025
assert [fib_bottom_up(i) for i in range(8)] == [0, 1, 1, 2, 3, 5, 8, 13]
assert fib_bottom_up(5000) > 0 # fine; fib_memo(5000) would hit the recursion limit
print("ok")

With more than one argument, the key is the tuple of all of them. Python and C# hash tuples by value; JavaScript Map compares object keys by identity, so encode the tuple as a string or a number.

binomial.py
def binomial(n: int, k: int, memo: dict[tuple[int, int], int] | None = None) -> int:
"""C(n, k) by Pascal's rule. The cache key is the full set of arguments."""
if memo is None:
memo = {}
if k < 0 or k > n:
return 0
if k == 0 or k == n:
return 1 # 1. base cases: edges of the triangle
key = (n, k) # 2. key = every argument that changes the answer
if key not in memo:
memo[key] = binomial(n - 1, k - 1, memo) + binomial(n - 1, k, memo) # 3. Pascal
return memo[key]
if __name__ == "__main__":
memo: dict[tuple[int, int], int] = {}
assert binomial(5, 2, memo) == 10
assert binomial(30, 15, memo) == 155117520 # about 3 * 10^8 calls without the memo
assert len(memo) <= 30 * 15 # at most one entry per (n, k)
assert binomial(4, 7) == 0
print("ok")
Top-down (memoization) Bottom-up (tabulation)
Code shape Recursion + cache Loops over a table
Order of evaluation Discovered automatically You must choose it
States computed Only reachable ones All of them
Overhead Function calls + hashing Array indexing
Stack depth Up to the longest dependency chain (can overflow) None
Space optimisation (keep 2 rows) Hard Easy
Best when Sparse or awkward state spaces; prototyping Dense, regular tables; tight limits
Time Space
Plain recursion with overlap (Fibonacci) O(φⁿ) ≈ O(1.6ⁿ) O(n) stack
Memoized O(states × work per state): O(n) for Fibonacci O(states) cache + O(depth) stack
Bottom-up equivalent Same Same table, or less with rolling rows

A useful check: count the distinct argument combinations (the state space). For f(i, j) with i < n and j < m, that’s n · m states. Multiply by the loop inside each call. That’s your bound, regardless of how scary the recursion looks.

  • Language helpers. Python: functools.cache (unbounded) and functools.lru_cache(maxsize=…). Arguments must be hashable, so pass tuples, not lists. JS/TS: no built-in; write a Map. C#: Dictionary<TKey, TValue> with a value-tuple key; ConcurrentDictionary.GetOrAdd for thread-safe caches.
  • Bounded caches. When memory matters, evict least-recently-used entries. That’s the bridge from algorithmic memoization to caching and eviction in systems.
  • Memoizing on objects. Key by a stable id or an immutable value, not by object identity (which defeats sharing) or by a mutable object (whose hash can change).
  • Cache on the instance. Class methods that memoize should keep the cache per instance (or clear it), or results leak between inputs.
  • Memoized DFS on graphs. Fine on DAGs; on graphs with cycles you need a “visiting” mark to avoid infinite recursion, and the answer may not be well defined.
  • Incomplete key. If the result depends on an argument (or a global) that isn’t in the key, different calls collide. Classic: memoizing dfs(i) when the answer also depends on remaining.
  • Mutable default cache in Python: def f(n, memo={}) shares one dict across all calls forever. Use None and create it inside, or @cache.
  • Unhashable arguments. Lists and dicts can’t be Python dict keys. Convert to tuples or frozensets.
  • JS object keys. map.set([1, 2], v) then map.get([1, 2]) is undefined: different array objects.
  • Falsy cached values. if (memo.get(k)) misses cached 0. Test !== undefined or has.
  • Caching across test cases. A module-level @cache keeps results between unrelated inputs that share argument values. Clear it (f.cache_clear()) or scope the cache.
  • Stack overflow on deep chains (see above).
  • Write the plain recursion first, then say: “f(i, j) is called with the same arguments from many places, and there are only n·m distinct pairs, so caching makes it O(n·m).”
  • Name the key explicitly and check that it includes everything the result depends on.
  • Mention the stack depth limit and offer to convert to bottom-up if inputs are large.
  • Expect: “what’s the space?”, “convert it to iterative”, “can you reduce memory?”, “what if this cache is shared by many threads or must be bounded?” (a design question: LRU, TTL, locking).
Check yourself
pick one
A recursive function f(i, remaining) is memoized with a dictionary keyed only by i. What happens?
Check yourself
pick one
A memoized function has arguments i in [0, 1000) and j in [0, 1000), and each call does a loop of length 1000. What's the time bound?
Check yourself
pick one
In TypeScript: `const memo = new Map<number[], number>(); memo.set([2, 3], 7); memo.get([2, 3])` returns…

Classic exercises (not gym questions):

  • Count the ways to climb n stairs with steps of 1, 2 or 3, top-down.
  • Number of paths in an m×n grid from the top-left corner, top-down, then compare with the bottom-up version.
  • Can the first player win a game where players alternately take 1-3 stones and the one who takes the last wins? (memoize on stones left)
  • Count binary trees with n nodes (Catalan numbers) via memoized recursion.
  • Longest path in a DAG with memoized DFS.