Memoisation: caching with a dictionary

Put a dictionary in front of the function: if the arguments have been seen, return the stored answer. That collapses an exponential call tree into a linear walk without changing the recurrence at all. functools.lru_cache is this, with a size bound and thread safety.

Overview

The technique

Cache a function's results keyed on its arguments. On a repeat call, return the stored value instead of recomputing.

The canonical demonstration is Fibonacci:

1Python
Output

fib(40) makes about 331 million calls, because fib(35) is recomputed millions of times. It is O(2ⁿ).

2Python
Output

O(n), and fib(40) returns instantly. One decorator.

nNaive callsMemoised calls
1017711
2021,89121
40331,160,28141
Dicts, sets & hashingCoding problemMedium

Step through it

What to watch

  • Call counts diverge fast — the gap is exponential against linear.
  • The recurrence is identical in both versions.
  • The cache is what turns repeated subproblems into one lookup.

Say this out loud

"Memoise it - a dict keyed on the arguments. The recursion is unchanged; it just stops descending into subtrees it has already solved. In Python that's @lru_cache, which also bounds the size so it can't grow forever."

Memoisation: caching with a dictionary

How would you speed up a recursive function that recomputes the same values? What does functools.lru_cache do?

Run it

The same recurrence three ways with call counts, then cache_info() showing the hit rate, then the two conditions broken on purpose — an impure function and an unhashable argument.

3Python
Output
4Python
Output
5Python
Output
6Python
Output
7Python
Output

The manual version, and why to know it

8Python
Output

Note memo=None rather than memo={}. A mutable default argument is created once, at function definition, and shared by every call that does not supply one — so results leak between unrelated invocations. It happens to work for a pure function like Fibonacci and is a genuine bug in general, and interviewers watch for it.

The manual version is worth knowing because it is what you write when the cache needs to be inspected, bounded by a custom rule, or shared explicitly between calls.

When memoisation applies

Three conditions, and all three are required:

The function is pure — same arguments always give the same result, with no side effects. Memoising a function that reads a database or the clock returns stale answers.

Arguments are hashable. Lists and dictionaries cannot be cache keys; convert to tuples or frozensets.

Subproblems repeat. Caching a function called once per distinct argument adds overhead for nothing.

That third point is the one that separates memoisation from a general speed-up. It is exactly the "overlapping subproblems" condition of dynamic programming — memoisation is top-down dynamic programming.

ProblemOverlapping subproblemsMemoisation helps
FibonacciHeavilyEnormously
Edit distanceYesYes
Merge sortNo — independent halvesNo
Binary searchNoNo
Grid pathsYesYes

Why it works at all

Naive recursion on overlapping subproblems recomputes the same values an exponential number of times. Memoisation does not make each call faster; it makes the repeated ones disappear, so the work drops to the number of distinct subproblems.

This is dynamic programming from the top down. The bottom-up table computes the same values in a loop with no call stack — same complexity, and see dynamic programming for both directions side by side.

The two conditions

The function must be pure. Same arguments, same answer, no side effects. Caching a function that reads a file or the clock returns a stale answer forever, and the bug looks like the data being wrong rather than the cache being wrong.

The arguments must be hashable. They become dictionary keys, so a list argument raises. That is why cached functions take tuples where you might expect lists — the constraint comes from the cache, not the algorithm.

What lru_cache adds

@lru_cache(maxsize=None) is an unbounded dictionary and is what you want for a recursion. A finite maxsize evicts least-recently-used entries, which matters when the key space is large and unbounded — an in-memory cache with no eviction is a memory leak with good manners.

It also gives you cache_info() for hits and misses, and cache_clear(). functools.cache is the unbounded alias added in 3.9. Mention that a manual dict is fine and the decorator is what you would actually ship.

lru_cache in practice

9Python
Output

cache_info() is the practically useful part: it tells you whether the cache is earning its place. A hit rate near zero means the arguments rarely repeat and the decorator is pure overhead.

Four constraints worth knowing:

Arguments must be hashable. lru_cache keys on the argument tuple, so a list argument raises TypeError.

Keyword and positional arguments key differently. f(1) and f(x=1) are separate cache entries, which doubles memory and halves the hit rate if callers are inconsistent.

maxsize=None never evicts. On unbounded input that is a memory leak; a bounded size is safer in long-running processes.

Methods keep self in the key, so instances are held alive by the cache — a real source of leaks. cachetools or an explicit per-instance dictionary avoids it.

Memoisation against tabulation

Two ways to write the same dynamic programming solution.

 Memoisation (top-down)Tabulation (bottom-up)
StyleRecursive plus a cacheIterative, fills a table
ComputesOnly the states reachedAll states
Recursion limitYes — about 1,000 framesNo
Space optimisationHarderOften easy
Easier to writeUsuallyUsually not

Memoisation's advantage is that only the subproblems actually needed are computed — which matters when the state space is large and sparsely visited. Tabulation's advantages are no recursion limit and easier space reduction.

Write memoisation first, because the recurrence is the part you have to think about. Convert to tabulation if you hit Python's recursion limit or need the O(1) space version.

Where it is used beyond interviews

  • Web response caching. Keyed on the request, with a TTL.
  • Database query caching. Same query, same result, until the data changes.
  • Expensive pure computations — parsing, compiling regular expressions (re.compile is itself cached), rendering templates.
  • Property caching — functools.cached_property computes once per instance.
  • API clients caching immutable lookups such as currency codes or country lists.

And the counterpart: invalidation is the hard part. A cache with no invalidation strategy eventually serves stale data, and "when does this become wrong?" is the question to ask before adding one. For pure functions the answer is never, which is why memoisation is the easy case.

Questions people ask

Why memo=None instead of memo={}? A mutable default is created once at definition and shared across calls.

Is lru_cache thread-safe? Yes, its bookkeeping is — and the wrapped function may still be called twice concurrently for the same key.

What if arguments are unhashable? Convert to tuples or frozensets, or hash a canonical serialisation.

How large should maxsize be? Large enough for the working set, small enough to bound memory. Use cache_info() to check the hit rate.

Does it help every recursive function? Only when subproblems repeat. Merge sort gains nothing.

What is cached_property? A decorator computing a property once per instance and storing the result on the instance.

Recap in one screen

  • Store results keyed on arguments; return the stored value on a repeat call.
  • @lru_cache or @cache turns exponential recursion into linear in one line.
  • Requires a pure function, hashable arguments, and genuinely repeating subproblems.
  • Never use a mutable default argument as the cache — it is shared across all calls.
  • Memoisation is top-down dynamic programming; tabulation is the iterative form without a recursion limit.

How the code works

The same recurrence three ways with call counts, then cache_info() showing the hit rate, then the two conditions broken on purpose — an impure function and an unhashable argument.

How the code works

  1. if n in cache: return cache[n]The entire optimisation. The recurrence below it is untouched — memoisation does not make calls faster, it removes the repeated ones.
  2. @lru_cache(maxsize=None)Unbounded, which is what a recursion wants. A finite size evicts least-recently-used entries and is what you want when the key space is large enough to be a leak.
  3. impure()Cached once and stale forever. The failure looks like wrong data rather than a wrong cache, which is why purity is stated as a precondition rather than a nicety.
  4. total([1, 2, 3])A TypeError: arguments become dictionary keys. That is why cached functions so often take tuples — the constraint comes from the cache, not the algorithm.

Change one thing

  • Call fib_plain(35). It is the same recurrence and it stops being a demonstration and starts being a wait.
  • Set maxsize=2 on fib_cached and check cache_info(). Eviction destroys the benefit for a recursion that revisits old values.

Where this runs

Real CPython, compiled to WebAssembly and running on your own machine — nothing is uploaded. The first run takes a few seconds while the interpreter downloads; after that it is immediate. Need more room, or want to paste your own attempt? Use the Python compiler.

Check yourself

0 of 3

Answer without scrolling back up.

  1. Memoisation speeds up recursion by:

  2. Caching a function that reads the current time gives you:

  3. Why must a cached function's arguments be hashable?

Cheat sheet

Memoisation: caching with a dictionary

Put a dictionary in front of the function: if the arguments have been seen, return the stored answer. That collapses an exponential call tree into a linear walk without changing the recurrence at all. functools.lru_cache is this, with a size bound and thread safety.

INTERVIEW · vizlearn.in/interview/memoisation-with-a-dictionary.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.