Naive recursive fib(50) makes over 40 billion calls to compute a value the recurrence defines at only 51 points, because the plain recursion has no memory that it already solved fib(48) the last time it needed it. Memoization gives it that memory: cache each call’s result keyed on its arguments, and every repeat returns the stored value instead of re-entering the subtree beneath it. fib(50) collapses from O(2ⁿ) to O(n) — one computation per distinct argument, the rest cache hits.

The technique is narrow and mechanical: wrap a pure function — same inputs always produce the same output, no observable side effects — so its first call for a given argument computes and stores, and later calls with that argument read the store. It is the top-down face of Dynamic Programming: write the natural recurrence, then bolt a cache onto it. The difference from bottom-up tabulation is when and what gets computed — memoization is lazy and recursion-driven, evaluating only the states the recursion actually reaches; tabulation is eager and iterative, filling every cell in dependency order.

Memoization only pays when calls repeat — the same overlapping-subproblems condition DP needs. On a function whose every call has distinct arguments (a plain divide-and-conquer split, an already-unique key), the cache never gets a second hit and adds pure overhead. The technique reaches beyond algorithms, too: caching an expensive pure computation, a Lazy<T> field, a UI framework’s memoised render — all the same idea.

Core shape: pure function + a cache keyed on the full argument set → first call computes and stores, repeats read the store → time drops to (distinct arguments) × (work per call) when calls actually repeat.

Visualization pending

Planned StepTrace: a recursion-tree card where each node’s first evaluation writes a cache cell and every later occurrence of that argument returns immediately, greying out the entire subtree it would have re-expanded. No matching renderer exists in engine.js yet.

Mechanism — what the cache keys on and what it needs

The cache is a map from arguments to result. Three properties have to hold or the cache silently returns wrong answers:

  • Purity. The function’s output must depend only on its arguments, with no side effects a caller could observe. Memoise a function that reads mutable global state or the clock, and a cache hit returns a value computed under conditions that no longer hold.
  • A complete, hashable key. The key must capture every input that affects the result. Omit one — memoise a two-argument recurrence on only the first argument — and two genuinely different calls collide on one cache slot, and the second read is stale. This is exactly DP’s state-design problem: the key is the state.
  • Equality and hashing that match value identity. For a struct or record key the default value equality is right; for a reference type, a GetHashCode/Equals that reflects the meaningful fields is required, or logically-equal arguments miss the cache.

For a recursive function, the recursion must call through the memoised entry point, not the raw function — otherwise the inner calls bypass the cache and the exponential tree returns. That is why the idiomatic form nests a local function that calls itself and shares one memo dictionary across the whole call graph.

Where memoization breaks or costs

  • Unbounded cache growth. A long-lived memoised function accumulates one entry per distinct argument forever — a memory leak dressed as an optimisation. Bounded caches evict; an LRU Cache is the standard policy, and .NET’s MemoryCache adds size and time limits. The trade is that an eviction can turn a would-be hit back into a recompute.
  • The overlap has to be real. No repeated arguments means no hits, so the cache is dead weight — the Divide and Conquer regime, where subproblems are disjoint by construction.
  • Recursion depth. Top-down memoization inherits the call stack of the underlying recursion; a chain-shaped dependency 100k deep overflows the stack where a bottom-up loop would not. This is the main reason to convert a hot memoised recurrence to tabulation.
  • Concurrency. A plain Dictionary cache corrupts under concurrent writes. ConcurrentDictionary.GetOrAdd is thread-safe for the store but can run the factory more than once for the same key under a race; Lazy<T> values in the dictionary fix that when the computation must run exactly once.

Reference drawer

Comparison

Memoization sits next to the other ways of not redoing work; the axis is when results are computed and what is kept.

TechniqueWhen computedWhat is storedReaches only used statesStack costFails when
Memoization (top-down DP)Lazily, on first callOne entry per distinct argumentYesRecursion depthCache grows unbounded; recursion too deep
Tabulation (bottom-up DP)Eagerly, in dependency orderFull table (or rolling window)No — fills every cellO(1) loopMany states are never needed
Plain result cachingOn demand, then reusedWhatever the app decides to keepYesNoneKeys aren’t pure; staleness on data change
No cacheEvery callNothingRecursion depthOverlapping subproblems recompute exponentially

Memoization is the fit when the recurrence is natural to write recursively, the reachable state space is a small fraction of the whole table, and stack depth is bounded — it evaluates only what’s needed and mirrors the maths directly. Tabulation wins when nearly all states are visited anyway (so laziness buys nothing), when a rolling array can shrink memory, or when the recursion would be too deep for the stack. Plain caching is memoization’s generalisation outside recurrences — an expensive pure call cached for reuse — carrying the same purity and key-completeness obligations plus an invalidation problem when the underlying data changes.

Questions

References

  • Memoization (Wikipedia) — definition, the purity requirement, and the distinction from general caching.
  • Dynamic programming (MIT 6.006) — frames DP as “recursion plus memoisation” and works through top-down versus bottom-up on the same recurrences.
  • Lazy<T> class (Microsoft Learn) — thread-safe compute-once-and-cache, the single-value case of memoisation, with the initialisation-race modes that matter under concurrency.