Conference: Week 6

Table Of Contents

1. Midterm Review

2. Making Big Lists

Calling the following function makeList with parameter \(n\) creates a list of numbers \(n, \ldots, 2, 1\):

- fun makeList 0 = []
    | makeList n = n::makeList(n-1);
val makeList = fn : int -> int list
- makeList 10000;
val it = [10000,9999,9998,9997,9996,9995,9994,9993,9992,9991,9990,9989,...]
  : int list

This implementation is not tail recursive. Implement a second version makeListTailRec that is tail recursive. It should have the same behavior as above:

- makeListTailRec 10000;
val it = [10000,9999,9998,9997,9996,9995,9994,9993,9992,9991,9990,9989,...]
  : int list

You will need a helper function. Conceal that function inside of makeListTailRec to prevent the client of your code from seeing it.

Time the two versions by repeatedly creating a large list with each. You can use the following code to do that:

  Time repeated calls to f.  The iterations parameter
  is the number of calls to make.

  Example: Calling

     time 100000000 (fn () => abs(5))

  measures the time to evaluate "abs(5)" 100,000,000 times.
fun time iterations f = 
  let fun timeHelper 0 f = ()
        | timeHelper n f = (f(); (timeHelper (n-1) f));
    val cycles = Timer.startCPUTimer();
    val _ = timeHelper iterations f

val originalTime  = time 100 (fn () => makeList 100000);
val tailrecTime   = time 100 (fn () => makeListTailRec 100000);

What differences do you see?

3. Folding, Redux…

Here are the definitions of our folding operations in ML again:

fun foldr f v nil =     v
  | foldr f v (x::xs) = f (x, foldr f v xs);

fun foldl f v nil =     v
  | foldl f v (x::xs) = foldl f (f(x, v)) xs;

If you have a function that works with either version, which one should you always use?

4. Tail Recursion (Submit this one with your HW!)

You have git repositories for HW 6 with a starter for this question — you can share that between partners, or just submit your own copy and include the name of your partner at the top.

  1. The dot product of two vectors \([a_1,\ldots,a_n]\) and \([b_1,\ldots,b_n]\) is the sum \(a_1 b_1 + a_2 b_2 + \cdots + a_n b_n\). For example, \[[1,2,3] \cdot [-1,5,3] = 1 \cdot -1 + 2 \cdot 5 + 3 \cdot 3 = 18\] Implement the function

    dotprod: int list -> int list -> int

    to compute the dot product of two vectors represented as lists. You should write this using tail-recursion, so your dotprod function will probably just be a wrapper function that calls a second function that does all the work. If passed lists of different length, your function should raise a DotProd exception. You will need to declare this type of exception, but you need not catch it.  

    - dotprod [1,2,3] [~1,5,3];
    val it = 18 : int
    - dotprod [~1,3,9] [0,0,11];
    val it = 99 : int
    - dotprod [] [];
    val it = 0 : int
    - dotprod [1,2,3] [4,5];
    uncaught exception DotProd
  2. The numbers in the Fibonacci sequence are defined as: \[\begin{array}{rcl} F(0) & = & 0\\ F(1) & = & 1\\ F(n) & = & F(n-1) + F(n-2) \end{array}\] Thus, the sequence is 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, etc.

    The following defines a function that returns the n-th Fibonacci number.

    fun slow_fib(0) = 0
        | slow_fib(1) = 1
        | slow_fib(n) = slow_fib(n-1) + slow_fib(n-2);

    Unfortunately, computing slow_fib(n) requires \(O(2^n)\) time.

    Define a tail recursive function fast_fib that can compute \(F(n)\) in \(O(n)\) time by using tail recursion. (As above, fast_fib will most likely be a wrapper that calls a tail-recursive function.) The tail-recursive function should have only one recursive call in its definition.

    - fast_fib 0
    val it = 0 : int
    - fast_fib 1
    val it = 1 : int
    - fast_fib 5
    val it = 5 : int
    - fast_fib 10
    val it = 55 : int

    Hint: When converting sumSquares to tail-recursive form, we introduced one auxiliary parameter to accumulate the result of the single recursive call in that function. How many auxiliary parameters do you think we will need for fibtail?

    Use the time function to see how much faster the tail recursive function can be, even for relatively small \(n\).

5. Memoization (optional)

Our tail recursive fast_fib takes linear time, but it requires a quite different approach to the problem. An alternative is to use memoization, a general technique that works for many algorithms. Memoization is similar to dynamic programming, which you will see in algorithms.

The idea is to have your algorithm keep a table of previously-computed results that it can consult. For example if we must compute \(F(15)\), we first consult the table to see if we already know the answer before doing any additional work.

You will complete a memoized memo_fib using this technique. For the table, we’ll use our basic table definition from a couple weeks ago. Tables are have type ''a * 'b list, where ''a is the key type and 'b is the value type. Here’s our lookup function that returns a 'b option of NONE or SOME(v):

fun get (k, nil) = NONE
  | get (k, (key,value)::rest)  =
    if k = key then SOME(value)
    else get(k, rest);

(Of course, our tables have linear-time lookup. They get the job done for illustration purposes, but for a better implementation, you would choose a data structure with constant-time lookups, such as a hash table.) Here’s the general structure of our memoized function:

fun memo_fib n = 
  let val table = ref nil;  (* tabled -- shared among all calls to the helper *)
  fun memo_fib_helper n = 
    case get(n, !table) of  (* check if already in table *)
        SOME(v) => v        (* Yep! Just return the value we found *)
      | NONE =>             (* Nope! *)
          ...  (* Compute r = F(n), insert into table, return r. *)
    memo_fib_helper n

memo_fib 5;
memo_fib 50;

We must ensure that all calls to memo_fib_helper use the same memoization table. Thus, we create a mutable reference table that is visible only within the scope of memo_fib. Complete the definition by filling in the NONE case.