What memoization is
Memoization means remembering the result of a function call so that the next call with the same arguments can return the stored answer instead of computing it again. It applies to functions that are pure, meaning the same input always gives the same output, and that get called with the same input many times. Recursive functions that split a problem into overlapping subproblems are the classic case.
The standard example is the Fibonacci sequence, where F(0) = 0, F(1) = 1 and F(n) = F(n - 1) + F(n - 2). The plain recursive version follows the definition directly:
public int Fib(int n) {
if (n < 2) {
return n;
}
return Fib(n - 1) + Fib(n - 2);
}
It is correct and slow. Fib(5) calls Fib(4) and Fib(3), then Fib(4) calls Fib(3) again, and so on. The same values are recomputed over and over, and the number of calls roughly multiplies by 1.6 each time n grows by one. For n = 30, the plain version makes 2,692,537 calls. The time is exponential in n.
Using a Dictionary as the cache
A cache needs to map an argument to its answer, and Dictionary<TKey, TValue> (from System.Collections.Generic) does exactly that. For Fibonacci the key is n and the value is F(n), so the type is Dictionary<int, int>.
The three operations you need are:
cache.ContainsKey(n)returnstrueif an answer fornis already stored.cache[n] = value;stores an answer. If the key is missing, the assignment adds it; if it exists, the assignment replaces the old value.cache[n]reads an answer. If the key is missing, this throws aKeyNotFoundException, so check first or useTryGetValue.
The pattern is always the same: check the cache, compute and store on a miss, then return the stored value. Every recursive call has to receive the same dictionary, so it is passed along as a parameter.
The exercise, step by step
The lesson asks for Fibonacci(int n), with 0 <= n <= 30, running in O(n) time and O(n) extra space. The starting code already creates the dictionary inside Fibonacci.
- Write a helper,
Fib(int n, Dictionary<int, int> cache), aboveFibonacci. The cache has to be a parameter. A default parameter value in C# must be a compile-time constant, and a new dictionary is not one, soFibonaccicreates it and passes it in. - Handle the base case. If
n < 2, returnn, becauseF(0) = 0andF(1) = 1. This also stops the recursion. - Check for a miss with
if (!cache.ContainsKey(n)). - Compute and store. Inside the
if, setcache[n]toFib(n - 1, cache) + Fib(n - 2, cache). Each value ofnreaches this line at most once. - Return
cache[n]after theif. At that point the key is always present, either from an earlier call or from the line you just ran. - Call the helper from
Fibonacciwithreturn Fib(n, cache);.
class Solution {
public int Fib(int n, Dictionary<int, int> cache) {
if (n < 2) {
return n;
}
if (!cache.ContainsKey(n)) {
cache[n] = Fib(n - 1, cache) + Fib(n - 2, cache);
}
return cache[n];
}
public int Fibonacci(int n) {
Dictionary<int, int> cache = new Dictionary<int, int>();
return Fib(n, cache);
}
}
Running it, Fibonacci(2) is 1, Fibonacci(3) is 2, Fibonacci(4) is 3 and Fibonacci(30) is 832040.
Why it is O(n)
Take Fib(5, cache). It calls Fib(4, cache) first, which calls Fib(3, cache), and so on down to the base cases. As the calls return, F(2), F(3) and F(4) are stored. When Fib(5) then calls Fib(3, cache), the answer is already in the dictionary and it returns immediately.
Each value from 2 to n is computed once, and each computation does a constant amount of work besides the recursive calls. The total is O(n) time. The dictionary holds up to n - 1 entries, and the recursion goes about n calls deep, so the extra space is O(n).
Common mistakes
- Creating the dictionary inside the recursive method. Each call would get a fresh, empty cache and nothing would ever be reused. The cache must be created once and shared.
- Reading before storing.
return cache[n];without the check throwsKeyNotFoundExceptionfor anynnot yet stored. - Calling the cached method with the wrong arguments. Inside
Fib, the recursive calls must beFib(n - 1, cache), notFib(n - 1). The two-argument signature is the only one that exists. - Memoizing impure functions. If the result depends on something other than the arguments, such as the current time or a changing field, a stored answer can be wrong later.
- Overflow for large inputs. This lesson stops at
n = 30. In anint, the sequence overflows afterF(46), and by default C# wraps the result silently instead of reporting an error.
Key takeaways
- Memoization stores a function's results by argument, so repeated calls cost a lookup.
- A
Dictionary<int, int>passed through every recursive call is the usual cache in C#. - Check with
ContainsKeybefore reading, because the indexer throws on a missing key. - Memoizing Fibonacci turns exponential time into
O(n)time withO(n)extra space.