What memoization is
Memoization means saving the result of a function call so that the next call with the same input returns the saved answer instead of computing it again. It works for functions that always return the same output for the same input, and it pays off when a recursive function keeps solving the same smaller problems. In C++ the saved answers usually live in an unordered_map or a vector that the recursive calls share.
The lesson applies it to the Fibonacci numbers: F(0) = 0, F(1) = 1 and F(n) = F(n - 1) + F(n - 2) for larger n. The method fibonacci(int n) returns F(n) for 0 <= n <= 30. The examples: F(2) = 1, F(3) = 2 and F(4) = 3. The statement asks for O(n) time and O(n) extra space.
Why plain recursion is too slow
The direct translation of the formula calls itself twice per call:
int fib(int n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
It is correct, but fib(5) calls fib(3) twice, fib(2) three times, and so on. The number of calls grows about as fast as the Fibonacci numbers themselves, which is exponential. For n = 30 this version makes 2,692,537 calls to produce 832040. Almost all of them repeat work that was already done.
Approach: compute each F(k) once
Only 31 different values of k exist between 0 and 30. If each is computed once and remembered, the rest of the calls are map lookups. The recursion stays the same shape; a shared cache map goes in front of the recursive step. With the cache, fibonacci(30) makes 59 calls in total.
The cache must be shared by every recursive call. The lesson does this by creating the map in fibonacci and passing it down by reference to a helper, fib.
Step by step
- Declare the helper
int fib(int n, unordered_map<int, int> &cache)abovefibonacci. The&matters: without it, each call would receive its own copy of the map, and nothing computed deeper in the recursion would be visible to the caller. - Handle the base case. If
n < 2, returnn, becauseF(0) = 0andF(1) = 1. This also ends the recursion. - Check whether
nis missing from the cache with!cache[n]. The map stores 0 for a key that was just created, and 0 can only mean "not computed yet" here: everyF(n)withn >= 2is at least 1. - Compute and store it. Inside the
if, setcache[n]tofib(n - 1, cache) + fib(n - 2, cache). The first call,fib(n - 1, cache), fills the cache for everything below, sofib(n - 2, cache)is already stored when it runs. - Return
cache[n]after theif. At that point it always holds the answer, whether it was just computed or found. - In
fibonacci, declare an emptyunordered_map<int, int>namedcache. - Return
fib(n, cache)to start the recursion.
The solution in C++
class Solution {
public:
int fib(int n, unordered_map<int, int> &cache) {
if (n < 2) {
return n;
}
if (!cache[n]) {
cache[n] = fib(n - 1, cache) + fib(n - 2, cache);
}
return cache[n];
}
int fibonacci(int n) {
unordered_map<int, int> cache;
return fib(n, cache);
}
};
Two details are worth knowing:
operator[]inserts. On astd::unordered_map,cache[n]for a missing key adds the key with a value of 0 and returns a reference to it. That is why!cache[n]works as a "not computed" test, and also why the test has a side effect. If 0 could be a real answer, the check would recompute it every time. A check that never inserts iscache.count(n) == 0, orcache.find(n) == cache.end().- Take a reference to the cache.
unordered_map<int, int> &cachepasses the original map. Passing it by value would copy the whole map on every call, and the copies would be thrown away, so no call would benefit from the others' work.
Complexity
Time: O(n). Each value from 2 to n is computed once, with one addition. Every later request for it is an average O(1) lookup. The total is about 2n calls, and for n = 30 that is 59.
Space: O(n). The cache holds one entry per value from 2 to n, 29 entries for n = 30. The recursion goes n levels deep before the first return, so the call stack also takes O(n) space. That is harmless for n <= 30, but it is why a recursive solution with a much larger n would need a loop instead.
Edge cases and common mistakes
n = 0andn = 1. Both returnnfrom the base case without touching the cache. The condition must ben < 2. Withn < 1,F(1)would be computed asF(0) + F(-1)and come out as -1.- Forgetting the
&. The code still compiles and returns correct values. It just has no memoization: every call works on a private copy of the map, so the running time goes back to exponential. - Creating the cache inside
fib. A new empty map per call remembers nothing. The map has to be created once, outside the recursion. - Overflow for large
n.intholds up to 2,147,483,647.F(46) = 1836311903fits, butF(47) = 2971215073does not, and signed overflow is undefined behaviour. The lesson stops at 30, well inside the range. For largern, uselong longand stop atF(92). - Using the cache for a value that can be 0. The
!cache[n]shortcut is safe only because the base cases are handled first. In another problem, test withcountorfind.
Testing
fibonacci(0)gives0, andfibonacci(1)gives1.fibonacci(2)gives1,fibonacci(3)gives2andfibonacci(4)gives3.fibonacci(10)gives55.fibonacci(30)gives832040, and returns instantly. The version without a cache needs over two and a half million calls for the same input.
