What memoization is
Memoization means remembering the answer to a function call the first time you compute it, and returning the remembered answer whenever the same call comes up again. It applies to functions where the same arguments are requested many times and the answer depends only on the arguments. Recursive functions that split a problem into overlapping subproblems are the classic case.
The Fibonacci numbers show the problem clearly. They are defined by F(0) = 0, F(1) = 1 and F(n) = F(n - 1) + F(n - 2). The direct recursive translation is a single line:
def fibPlain(n: Int): Int =
if (n < 2) n else fibPlain(n - 1) + fibPlain(n - 2)
It is correct but slow. Computing F(5) calls F(4) and F(3), and F(4) calls F(3) again, so the same work repeats down the tree. The number of calls grows exponentially: fibPlain(30) makes 2,692,537 calls to produce 832040.
The idea: a cache shared by every call
Keep a map from n to F(n). Before computing a value, check the map. If the answer is there, return it. If not, compute it, store it, and return it. Each distinct n is then computed once, and every other request is a lookup. With memoization, the same fib(30) needs only 59 calls.
The cache must be shared by all the recursive calls, so it is created once in the outer method and passed down as a parameter. A cache created inside the recursive function would be empty on every call and save nothing.
The exercise: fibonacci with a cache
The task is to write fibonacci(n), returning F(n) for 0 <= n <= 30 with a recursive function and memoization, in O(n) time and O(n) extra space. The starting code already creates the cache in fibonacci:
val cache = mutable.Map[Int, Int]()
Step by step
- Declare the helper
fib(n: Int, cache: mutable.Map[Int, Int]): Intabovefibonacci. It takes the cache as a parameter so every call shares one map. - Handle the base case. For
n < 2, returnnitself, sinceF(0) = 0andF(1) = 1. This also stops the recursion. - Check the cache with
!cache.contains(n). - Compute and store. When the key is missing, set
cache(n)tofib(n - 1, cache) + fib(n - 2, cache). - Return
cache(n). After theif, the key is always present. - Call the helper from
fibonacciwith the cache that is already declared there.
The solution in Scala
class Solution {
def fib(n: Int, cache: mutable.Map[Int, Int]): Int = {
if (n < 2) {
return n
}
if (!cache.contains(n)) {
cache(n) = fib(n - 1, cache) + fib(n - 2, cache)
}
return cache(n)
}
def fibonacci(n: Int): Int = {
val cache = mutable.Map[Int, Int]()
return fib(n, cache)
}
}
Calling fibonacci(10) returns 55 after 19 calls to fib, and fibonacci(30) returns 832040 after 59.
Scala details
- Mutable versus immutable maps. Scala's default
Mapis immutable, so it can't be updated in place. A memo table needsscala.collection.mutable.Map, which is whatmutable.Maprefers to here. - Indexing and updating use parentheses.
cache(n)reads a value, andcache(n) = valueis shorthand for calling the map'supdatemethod. - Reading a missing key throws.
cache(6)on a map without key 6 throwsjava.util.NoSuchElementException: key not found: 6. That is safe in this solution because the value is stored before it is read. To look up without risk,cache.get(6)returns anOption(Nonehere) andcache.getOrElse(6, -1)returns a default. - Explicit
return. The lesson writesreturnfor clarity. In idiomatic Scala the last expression of a block is its value, so the final line could be justcache(n). Intis 32 bits.F(30)is 832040, far below the limit of about 2.1 billion.F(46)is the last Fibonacci number that fits in anInt, which is why the lesson capsnat 30; larger values would needLongorBigInt.
Complexity
Time: O(n). Each value from 2 to n is computed once. Every further call that asks for it finds it in the map. mutable.Map is a hash map, so a lookup or insertion takes constant time on average.
Space: O(n). The map holds at most n - 1 entries, and the recursion goes about n calls deep before the first value is stored, so the call stack is O(n) too.
Common mistakes
- Creating the cache inside
fib. Each call would get its own empty map, and the running time would stay exponential. - Caching the base cases. It isn't wrong, but it isn't needed: returning
ndirectly is just as fast. - Using the immutable
Map.Map[Int, Int]()without themutableprefix has noupdate, socache(n) = ...doesn't compile. - Forgetting to pass the cache in the recursive calls, which gives the same repeated work as plain recursion.
Key takeaways
- Memoization trades memory for time: store each answer once, then look it up.
- The cache must be shared by every recursive call, so pass it as a parameter.
- In Scala, use
mutable.Mapfor the cache, andcontainsbefore reading withcache(key). - Memoizing Fibonacci reduces millions of calls to a few dozen: from exponential time to
O(n).
