Introduction to Programming, Aug-Dec 2006 Lecture 20, Thursday 09 Nov 2006 Memoization ----------- Most of the functions we have written have natural inductive definitions. However, naive evaluating an inductive definition is often computationally wasteful because we repeatedly compute the same value. A typical example of this is the function to compute the nth Fibonacci number. fib :: Int -> Int fib 0 = 1 fib 1 = 1 fib n = (fib (n-1)) + (fib (n-2)) Let us look at the computations involved in, say, evaluating fib 4. fib 4 / \ fib 3 fib 2 / \ / \ fib 2 fib 1 fib 1 fib 0 / \ | | | fib 1 fib 0 1 1 1 | | 1 1 Observe that fib 2 is evaluated twice, independently. Now, suppose we compute fib 6: fib 6 / \ fib 5 fib 4 / \ / \ fib 4 ... ... .... Here, fib 6 produces two independent computations of the entire tree we saw above for fib 4, each of which duplicates the computation for fib 2. In this way, as we compute fib n for larger and larger, more and more evaluations are duplicated. The result is that computing fib n becomes exponential in n. However, we know that we can naively enumerate the nth Fibonacci number in n steps by counting upwards from the first two as follows : 1,1,2,3,5,8,13,21,.... One way to get around the wasteful recomputation we saw above is to remember the values we have computed and not recompute any value that is already known. We maintain a table, or a "memo", where we note down all known values. Before computing a fresh value, we look up this memo. If the value already exists in the memo, we use it. Otherwise, we use the inductive definition to calculate the value and write it into the memo for future reference. This process is called "memoization". How do we maintain the memo? One simple way is to just maintain an association list of (value,function) pairs. Here is a how a simple memoized version of fib would look: fib :: Int -> Int fib n = memofib n [] The function memofib takes an argument for which the function is to be computed, together with the current memo, and returns the function value with an updated memo. memofib :: Int -> [(Int,Int)] -> (Int,[(Int,Int)]) memofib n l | elem n [x | (x,y) <- l] -- value found in memo, return it with the memo unchanged = (head [y | (x,y) <- l, x == n],l) | n = 0 -- base case, add it to memo = (1,(0,1):l) | n = 1 -- base case, add it to memo = (1,(1,1):l) | otherwise -- compute the new value and update the memo = (valc,memoc) where -- need to sequentially compute the inductive values so -- updates to the memo are preserved (vala,memoa) = memofib (n-1) l (valb,memob) = memofib (n-2) memoa valc = vala + valb memoc = (n,valc):memob Note that each value fib n is computed exactly once. Computing a value involves looking up two values in the memo and updating one value into the memo. To count the total number of memo lookups, we note that each value fib n is looked up twice in the memo after it is computed, when computing fib (n+1) and fib (n+2). Thus, the overall complexity is 2n*(memo lookup cost) + n*(memo update cost. In the naive list based memo, the look up cost for the memo is O(n) while the update cost is O(1). Thus, the complexity of this implementation is O(n^2). We can improve the complexity by making the memo more efficient. It is clear that to compute fib n, we only need to look at values between fib 0 and fib n. We can thus store the memo in an array indexed by (0,n). This would reduce the lookup cost to log n and bring down the overall complexity to O(n log n). Dynamic programming ------------------- Recall that we said we could compute the nth fibonacci number in linear time by enumerating the list of values from fib 0 as 1,1,2,3,5,... This amounts to filling out the memo table systematically from the bottom up, using the inductive definition of the function to update the memo table. This bottom up approach to computing a memoized function is called "dynamic programming". In other words, if we have understood the structure of the memo sufficiently, we can "directly" fill it up, without making inductive calls to make each update. In an ideal world, both the top down memoized approach, where updates to the memo table happen whenever the inductive call hits an uncomputed value, and the bottom up dynamic programming approach, which updates the memo directly using the inductive definition, should be equally efficient. However, in standard implementations, making recursive function calls incurs some overheads where we have to remember the state of values in the calling function etc, so the top down approach is NOT as efficient as the bottom up approach. However, it is to be emphasized that almost *any* function can be memoized in reasonably uniform way (the list based memo given above should work for any function whose argument satisfies Eq a), but implementing a memoized function using dynamic programming requires understanding the dependency of the memo values on each other and filling them up appropriately. Modularizing the memo --------------------- Ideally, we should make our memoized function independent of the choice of memo. For this, we first define an abstract datatype Table that stores values of functions from a to b and supports the following operations :: emptytable :: returns an empty memo table memofind :: tells whether a value exists in the memo table memolookup :: looks up a value in the memo table memoupdate :: adds a new entry to the memo table Here is an implementation of this datatype using lists, as we have done above. Note the requirement (Eq a) for the domain type of the function. module Memo(Table,emptytable,memofind,memolookup,memoupdate) where data (Eq a) => Table a b = T [(a,b)] deriving (Eq,Show) -- emptytable n v creates a table to hold n values with initial -- "undefined" value v. This is required, for instance, if we -- use an array to represent the table. emptytable :: (Eq a) => Int -> b -> (Table a b) emptytable n v = T [] memofind :: (Eq a) => (Table a b) -> a-> Bool memofind (T []) _ = False memofind (T ((y,m):l)) x | x == y = True | otherwise = memofind (T l) x memolookup :: (Eq a) => (Table a b) -> a -> b memolookup (T ((y,m):l)) x | x == y = m | otherwise = memolookup (T l) x memoupdate :: (Eq a) => (Table a b) -> (a,b) -> (Table a b) memoupdate (T l) (x,n) = T ((x,n):l) Now, we can write our memoized version of the fib function a little more "abstractly", using the Memo module. import Memo fib :: Int -> Int fib n = memofib n emptytable memofib :: Int -> [Table Int Int] -> (Int,Table Int Int) memofib n t | memofind t n = (memolookup t n, t) | n = 0 = (1, memoupdate t (0,1)) | n = 1 = (1, memoupdate t (1,1)) | otherwise = (valc,memoc) where (vala,memoa) = memofib (n-1) l (valb,memob) = memofib (n-2) memoa valc = vala + valb memoc = memoupdate memob (n,valc) The advantage is that if we decide to modify the memo table implementation to be more efficient, we need not change anything in the definition of fib/memofib. ======================================================================