What memoization is
Memoization means remembering the result of a function call so the same call never has to be computed twice. It pays off when a recursive function keeps asking the same questions, which is exactly what the naive recursive Fibonacci does.
The plain version computes F(n) = F(n - 1) + F(n - 2) with two recursive calls. Those calls overlap: F(n - 1) itself needs F(n - 2), so the same values are recomputed again and again. For n = 30 the plain recursion makes 2,692,537 calls to produce 832040. The number of calls grows exponentially with n.
With a cache, each value F(k) is computed once, stored under the key k, and read back on every later request. That leaves about n distinct computations, so the time drops to O(n), with O(n) extra space for the cache and the recursion.
The exercise
The function fibonacci takes n between 0 and 30 and returns F(n), where F(0) = 0, F(1) = 1 and every later value is the sum of the two before it. So fibonacci(2) is 1, fibonacci(3) is 2 and fibonacci(4) is 3. The solution must be recursive and use memoization.
Step by step
- Declare a helper
fib(n, cache)abovefibonacci. The cache is a&mut HashMap<i32, i32>: a mutable borrow lets every recursive call insert into the same map without taking ownership of it. - Handle the base cases. For
n < 2the answer isnitself, sinceF(0) = 0andF(1) = 1. This also stops the recursion. - Look in the cache with
if let Some(&v) = cache.get(&n)and return the value if it is there. - Compute on a miss:
Self::fib(n - 1, cache) + Self::fib(n - 2, cache). Both calls share the same cache. - Store and return. Insert the result under
n, then makevthe final expression. - Create the cache in
fibonacciwithlet mut cache = HashMap::new(). The key and value types are inferred from the call tofib. - Start the recursion with
Self::fib(n, &mut cache)as the final expression.
The solution in Rust
impl Solution {
fn fib(n: i32, cache: &mut HashMap<i32, i32>) -> i32 {
if n < 2 {
return n;
}
if let Some(&v) = cache.get(&n) {
return v;
}
let v = Self::fib(n - 1, cache) + Self::fib(n - 2, cache);
cache.insert(n, v);
v
}
fn fibonacci(n: i32) -> i32 {
let mut cache = HashMap::new();
Self::fib(n, &mut cache)
}
}
The Rust details worth a closer look:
- The import.
HashMaplives instd::collections. In a program of your own, adduse std::collections::HashMap;at the top. getreturns anOption.cache.get(&n)givesSome(&i32)when the key exists andNonewhen it doesn't. The patternSome(&v)matches the reference and copies the number intov.- Why copying matters. After the
if letblock the borrow fromgetis over, so the next lines may usecachemutably. The recursive calls andcache.insertwould not be allowed while a reference into the map was still alive. - Passing the cache along. Inside
fibthe parametercacheis already a&mut HashMap, so the recursive calls pass it by name. Only the first call, infibonacci, writes&mut cache. - Associated functions.
fibtakes noself, so it is called asSelf::fibfrom inside theimplblock. - Integer size. The values are
i32.F(46) = 1836311903is the largest Fibonacci number that fits, andF(47)is bigger thani32::MAX. With the limitn <= 30this is never a problem. Au64would reach much further.
What the cache holds
Base cases are never stored. After fibonacci(10), the cache has 9 entries, one for each key from 2 to 10. Each of those values was computed exactly once. The first call to reach a key does the work. Any later request for that key, such as the second call in F(k) asking for F(k - 2) after F(k - 1) has already computed it, finds it ready.
The recursion is at most n calls deep, because the call for n - 1 runs first and goes all the way down to the base case. That is no problem for the limit of 30.
Common mistakes
- Creating the cache inside
fib. A new map on every call remembers nothing. The cache must be created once and passed down. - Checking the cache after computing. Looking up the value after the two recursive calls saves no work.
- Forgetting to insert. A cache that is only read stays empty, and the function is as slow as the plain version.
- Keeping the borrow from
getalive. Writinglet v = cache.get(&n)and usingvafter a mutable use ofcacheis rejected by the borrow checker. Copying the number out withSome(&v)avoids it. - Forgetting
mut.let cache = HashMap::new()can't be borrowed with&mut.
Key takeaways
- Memoization turns a recursion with overlapping subproblems from exponential time into linear time.
- The recipe has three parts: return the cached answer if there is one, otherwise compute it, then store it before returning.
- In Rust the cache travels as a
&mut HashMap, and the value read from it is copied out before the cache is changed.
