Find the Wasted Calls: the Skill Behind Every Memoisation
Memoisation is one of the easiest ideas in programming to explain: remember an answer instead of working it out twice. Almost every explanation shows the slow version, then the fast version, and the reader agrees โ and then cannot apply it to a problem of their own.
The reason is that the explanation skips the only hard part. Knowing what to do about a repeated subproblem is trivial. Noticing that a subproblem repeats is not, because in a recursion tree the repeats are not labelled. They look exactly like the work that has to happen.
That is a perceptual skill, and perceptual skills are trained by doing them.
Find them yourself
Every call below computes some fib(k). The first time a value appears, that is real work. Every
later appearance is recomputing something another branch already finished. Click the repeats.
Written the obvious way, fib(n) calls itself twice and each of those calls itself
twice again. For fib(6) that is 25 calls in total โ to compute only
7 distinct values, 0 through 6. The other 18 calls recompute something
another branch has already worked out.
The waste is not spread evenly, and the pattern is worth seeing: fib(6) is computed 1 time, fib(5) is computed 1 time, fib(4) is computed 2 times, fib(3) is computed 3 times, fib(2) is computed 5 times, fib(1) is computed 8 times, fib(0) is computed 5 times. Those counts are Fibonacci numbers themselves for every value down to 1 โ the recursion's own shape showing up in how often it repeats itself.
Remembering each answer the first time removes all of it. At fib(30) plain
recursion makes 2,692,537 calls; the same function that checks a
table first makes 59.
Click every call that is recomputing something an earlier branch already worked out. The first time a value appears is real work โ clicking that costs you. Finding the repeats is the actual skill in dynamic programming, and it is the part nobody practises.
- Repeats found
- 0/0
- Wrong clicks
- 0
- Calls in total
- 0
- Distinct values
- 0
The same tree, as counts
Computing
Times computed
At scale
Wrong clicks are counted, so clicking everything does not work. Use Bigger tree once you have the eye for it.
Clicking a repeat marks the call and tells you something stronger: everything underneath it is wasted too. A repeated call does not just cost one call, it costs its entire subtree โ which is where the explosion comes from.
The waste, counted
def fib_calls(n, counter):
"""Plain recursion. Every call recomputes both subproblems from scratch."""
counter[0] += 1
if n < 2:
return n
return fib_calls(n - 1, counter) + fib_calls(n - 2, counter)
def fib_memo(n, memo, counter):
"""Same recursion, except an answer already worked out is never worked out again."""
counter[0] += 1
if n in memo:
return memo[n]
memo[n] = n if n < 2 else fib_memo(n - 1, memo, counter) + fib_memo(n - 2, memo, counter)
return memo[n]
def distinct_subproblems(n):
return n + 1 if n >= 0 else 0
print(f"{'fib(n)':>7} {'plain calls':>13} {'memoised calls':>15} {'distinct':>9} {'wasted':>13}")
for n in (6, 10, 20, 30, 35):
plain, memo_calls = [0], [0]
fib_calls(n, plain)
fib_memo(n, {}, memo_calls)
wasted = plain[0] - distinct_subproblems(n)
print(f"{n:>7} {plain[0]:>13,} {memo_calls[0]:>15,} {distinct_subproblems(n):>9} {wasted:>13,}")
print()
print("Every value of n, and how many times plain recursion computes it, for fib(6):")
seen = {}
def trace(n):
seen[n] = seen.get(n, 0) + 1
if n < 2:
return n
return trace(n - 1) + trace(n - 2)
trace(6)
for value in sorted(seen, reverse=True):
bar = "#" * seen[value]
print(f" fib({value}) computed {seen[value]:>2} time(s) {bar}") fib(n) plain calls memoised calls distinct wasted
6 25 11 7 18
10 177 19 11 166
20 21,891 39 21 21,870
30 2,692,537 59 31 2,692,506
35 29,860,703 69 36 29,860,667
Every value of n, and how many times plain recursion computes it, for fib(6):
fib(6) computed 1 time(s) #
fib(5) computed 1 time(s) #
fib(4) computed 2 time(s) ##
fib(3) computed 3 time(s) ###
fib(2) computed 5 time(s) #####
fib(1) computed 8 time(s) ########
fib(0) computed 5 time(s) ##### At fib(35), plain recursion makes 29,860,703 calls to compute 36 distinct values. Every one of
those 29,860,667 extra calls is arithmetic that had already been done.
The distinct column is the one to watch, because it never leaves a straight line. There are only
ever n + 1 different questions to answer. The exponential growth is not the problem getting
harder โ it is the same small set of questions being asked over and over in different branches.
The counts are Fibonacci
Look at the histogram: 1, 1, 2, 3, 5, 8. The number of times fib(k) gets computed while
evaluating fib(6) is itself a Fibonacci number.
It falls out of the structure. fib(k) is reached from fib(k+1) and from fib(k+2), so its count
is the sum of theirs โ the Fibonacci rule, running down the tree instead of up it. The one that
breaks the pattern is fib(0), computed 5 times rather than 13, because fib(1) is a base case and
never recurses, so fib(0) is only ever reached from fib(2).
A reader who notices that has understood the shape of the recursion more concretely than one who has read a definition of it.
Where this generalises
Two conditions make a problem worth memoising, and the first is the one you now have practice spotting:
- Overlapping subproblems โ the same question gets asked more than once. That is what you were clicking.
- Optimal substructure โ an answer can be assembled from answers to smaller versions.
Both hold for Fibonacci, and for edit distance, shortest paths, knapsack, and most of what gets called dynamic programming. When only the second holds โ as in merge sort, where the two halves are never the same problem twice โ divide and conquer is the right shape and memoisation buys nothing.
The direction is also a choice rather than a rule. Memoising works downward from the question you were asked and computes only what it needs. A bottom-up table works upward from the base cases and computes everything, which is often faster in practice because it has no recursion and no hashing โ and occasionally much worse, because it computes subproblems the answer never needed.
What this page simplifies
- Calls, not seconds. Call counts are deterministic and identical on any machine. The real gap is larger, because each avoided call also avoids a stack frame.
- Fibonacci is the easy case. Its subproblems are indexed by one small integer, so the memo is a list. Real problems key on tuples, strings or sets, and choosing that key is most of the work.
- No recursion limit.
fib(35)recurses only 35 deep, so it is safe. Memoised recursion on a problem thousands deep will exhaust the stack in most languages, which is a common reason to rewrite it bottom-up. - The memo is never invalidated. Everything here is a pure function of its argument. Caching anything that can change is a different and much harder problem โ see what a cache has to guess.