Memoization (Top-Down DP)
Write the natural recursion, then cache every result by its arguments so each distinct subproblem is computed once.
Overview
Memoization turns a recursive function into a DP by adding a lookup table keyed on the arguments. Before computing, check the table; after computing, store. The recursion itself is unchanged, which is why memoization is the fastest route from "I see the recurrence" to a working, polynomial-time solution.
It is lazy: only the states actually reachable from the top query are evaluated. In problems where the reachable region is a small fraction of the full table (sparse knapsack capacities, digit DP with tight prefixes), memoization can be asymptotically cheaper than filling the whole table. The dependency order is discovered implicitly by the call stack — you never have to think about it.
Costs: recursion depth is bounded by the language stack (Python defaults to ~1000 frames; n = 10^5 states in a chain will overflow), function-call overhead is 2–5× a tight loop, and a hash-map memo is slower than an array. See Tabulation (Bottom-Up DP) for the bottom-up alternative and Dynamic Programming for the full framework.
Intuition
A mental model before the formal terms.
A student solving homework keeps a notebook. Each time a sub-question comes up they first flip to the notebook; if the answer is there they copy it, otherwise they work it out and write it down. The notebook is the memo; the flipping is the O(1) lookup that replaces an exponential recomputation.
For fib(6) the plain recursion tree has 25 calls. With a memo, the tree is pruned to one full path down the left spine plus one cheap lookup per node: 11 calls, and in general 2n - 1 calls instead of ≈ 1.6^n.
How it works
- Write the brute-force recursive function
solve(args)that returns the answer for a subproblem. Make sure it is a pure function of its arguments — no reliance on outside mutable state. - Choose the memo container: an array (or 2D array) indexed by the arguments when they are small dense integers; a hash map keyed on a tuple when they are sparse or non-integer.
- At the top of
solve: ifmemo[args]is set, return it. At the bottom: store the result inmemo[args]before returning. - Use a sentinel that cannot be a legitimate answer (
-1for counts,None/undefined, a separateseenboolean array) so a stored0is not mistaken for "not computed". - If the recursion could be deep (> ~10^4 frames), either raise the recursion limit and use an explicit stack, or switch to Tabulation (Bottom-Up DP).
Why it works
Each distinct argument tuple triggers at most one real computation; every later call is an O(1) lookup. Total work is therefore (number of distinct states reached) × (cost of one transition), exactly the DP bound.
Correctness is identical to the plain recursion because the memo only substitutes an already-computed value for a value that would have been recomputed identically — the function is pure.
Recognition
How to tell a problem wants this.
- You have a recurrence with a clear "smaller argument" structure and want the fastest correct implementation.
- The state space is large but the reachable part is small (queries hit few states, or many
(i, j)pairs are unreachable). - The natural dependency order is awkward to express as loops (intervals with irregular splits, tree children, digit DP with
tightflags).
Interactive visualization
Play, step, change the input. ← → and space work too.
Showing the closely related Fibonacci Numbers visualization.
| n | fib(n) |
|---|
1fib(n):2 if n <= 1: return n3 if n in memo: return memo[n]4 memo[n] = fib(n-1) + fib(n-2)5 return memo[n]Pseudocode
1memo = empty map2solve(state):3 if state is a base case: return base value4 if state in memo: return memo[state]5 best = combine over choices of solve(smaller state)6 memo[state] = best7 return bestImplementations
1# Representative problem: n-th Fibonacci number with memoized recursion2from functools import lru_cache3import sys4 5sys.setrecursionlimit(10_000) # default ~1000 frames is too small for fib(2000)6 7 8def fib(n: int) -> int:91 · Memo storage10 memo: dict[int, int] = {} # key: k, value: fib(k)11 12 def solve(k: int) -> int:132 · Base cases14 if k <= 1:15 return k # fib(0) = 0, fib(1) = 1163 · Cache lookup17 if k in memo:18 return memo[k] # O(1) hit194 · Compute and store20 memo[k] = solve(k - 1) + solve(k - 2)21 return memo[k]22 235 · Reset the memo and run24 return solve(n) # memo is fresh per top-level call25 26 27# Idiomatic Python: the decorator does the same thing automatically.28@lru_cache(maxsize=None)29def fib_cached(n: int) -> int:30 return n if n <= 1 else fib_cached(n - 1) + fib_cached(n - 2)31 32 33if __name__ == "__main__":34 print(fib(50)) # 1258626902535 print(fib_cached(50)) # 12586269025sys.setrecursionlimit(10_000)raises the default ~1000-frame cap; without itfib(1500)raisesRecursionError.- The hand-written version uses a
dict[int, int]closed over by the nestedsolve—k in memois the presence test, so a cached0is safe. memo[k] = solve(k - 1) + solve(k - 2)stores before returning; Python ints never overflow, sofib(500)is exact.@lru_cache(maxsize=None)onfib_cacheddoes exactly the same bookkeeping automatically, keyed on the argument tuple.- Both functions share the same recurrence and base cases; only the caching mechanism differs.
Python frames are heavy (~500 bytes+) and slow; ~10^4–10^5 frames is the practical ceiling even after raising the limit.
functools.lru_cache(maxsize=None)(orfunctools.cachein 3.9+) memoizes any function whose arguments are hashable — ints, strings, tuples; lists and dicts are not hashable.- For multi-dimensional states pass several ints or a tuple; the cache key is the argument tuple, so
f(i, j)andf(j, i)are distinct entries. - Call
f.cache_clear()between independent inputs when the cached function reads outer state, otherwise it returns stale answers. - Raising the recursion limit does not raise the C stack; extremely deep recursion can still segfault. Use
threading.stack_sizeor tabulation for chains of 10^5+.
- Forgetting
sys.setrecursionlimitand gettingRecursionErroron moderately sized inputs. - Using
if memo.get(k):— a cached0is falsy and gets recomputed. - Decorating a method that depends on mutable
selfstate withlru_cache— the cache key does not include that state. - Passing a list as an argument to an
lru_cached function (TypeError: unhashable type).
- Built-in memoization: Python has
functools.lru_cache/functools.cache; C++, JS and TS have nothing built in — you write the lookup by hand. - Memo key for multi-dimensional states: Python hashes tuples natively; JS/TS
Mapcompares arrays by reference, so build a string key (i + "," + j) or pack integers; C++ uses a nested vector, a packed 64-bit key, orstd::map<std::pair<...>>. - Recursion depth: Python defaults to ~1000 frames (raise with
sys.setrecursionlimit); JS/TS engines allow roughly 10^4; C++ depends on the OS stack (often ~10^5 small frames). - Value range:
fib(93)overflows C++long long; JS/TS numbers lose exactness past 2^53 (fib(79)); Python ints are unbounded.
Complexity
Same asymptotic time as tabulation; constant factor higher (calls, hashing). Only reachable states are computed.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- You want a correct polynomial solution quickly from a known recurrence.
- Only a fraction of the state space is reachable, so lazy evaluation saves real work.
- The dependency order is irregular (interval splits, tree structure, digit-DP flags) and loops would be error-prone.
- Recursion depth would exceed the stack (chains of 10^5+ states) — use Tabulation (Bottom-Up DP).
- Tight time limits where the 2–5× overhead of calls and hashing matters; a loop over an array is faster.
- You need the space optimization (rolling rows) — that requires the explicit iteration order of bottom-up DP.
Alternatives
Common mistakes
- Using
0as the "not computed" sentinel when0is a real answer — the cache never hits. - Keying the memo on fewer arguments than the function actually depends on (e.g. ignoring a mutable global), returning stale values.
- Memoizing a function with side effects or that mutates its arguments (lists as keys, arrays modified during recursion).
- Forgetting to raise Python's recursion limit, or ignoring stack limits entirely in deep chains.
- Creating a fresh memo per top-level call in a loop of queries — reuse it across queries when the subproblems are shared.
Interview patterns
- "Write brute force, then memoize" is the standard progression interviewers expect — narrate the recurrence, then the cache.
- Use
@lru_cachein Python for speed of writing; be ready to explain what it stores and why the key must be hashable. - Memoized DFS on a grid/graph with a "remaining budget" argument (word break with start index, longest increasing path in a matrix).
- Deciding whether O(n²) can be improvedIntermediate
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Recursion versus iterationIntermediate
- When space complexity mattersIntermediate
- Coin ChangeIntermediate