CS302 Lecture Notes - Dynamic Programming
Example Program #1: Fibonacci Numbers


You can make the programs in this lecture with "make fib".

This is one of the simplest and cleanest dynamic programming problems.

Fibonacci numbers have a recursive definition:

Fib(0) = 1
Fib(1) = 1
If n > 1, Fib(n) = Fib(n-1) + Fib(n-2)


Step One

This definition maps itself to a simple recursive function, which you've seen before in CS202. However, we'll go through it again. This is Step 1: writing a recursive answer to a problem. I bundle this into a class because it makes steps 2 and 3 easier. It's in src/fib1.cpp:

#include <cstdlib>
#include <iostream>
using namespace std;

class Fib {               // Step 1 in calculating Fibonacci numbers with Dynamic Programming:
  public:                 // Find a recursive solution, which may be very slow.
    long long find_fib(long long n);
};

long long Fib::find_fib(long long n)  // Classic recursive implementation straight from the definition.
{
  if (n == 0 || n == 1) return 1;
  return find_fib(n-1) + find_fib(n-2);
}

int main(int argc, char **argv)
{
  Fib f;

  if (argc != 2) { cerr << "usage: fib n\n"; exit(1); }

  cout << f.find_fib(atoi(argv[1])) << endl;
  return 0;
}

The problem with this is that its performance blows up exponentially:

If you continue with the pattern, what you'll see is that find_fib(n-i) is called Fib(i) times. And the Fibonacci numbers blow up exponentially. When you run bin/fib1, you'll see it start to bog down when n gets to the 40's:
UNIX> bash
UNIX> i=0
UNIX> while [ $i -lt 50 ]; do
> echo $i `( time bin/fib1 $i ) 2>&1`     # This squashes the output of "time" onto one line.
> i=$(($i+1))                             # You can ask me about it class or wait until CS360.
> done
0 1 real 0m0.003s user 0m0.001s sys 0m0.001s
1 1 real 0m0.003s user 0m0.001s sys 0m0.001s
                                          # .......  Skipping lines
40 165580141 real 0m0.272s user 0m0.270s sys 0m0.001s
41 267914296 real 0m0.439s user 0m0.437s sys 0m0.001s
42 433494437 real 0m0.697s user 0m0.694s sys 0m0.001s
43 702028733 real 0m1.114s user 0m1.111s sys 0m0.001s
44 1134903170 real 0m1.812s user 0m1.809s sys 0m0.002s
45 1836311903 real 0m2.934s user 0m2.931s sys 0m0.002s
46 2971215073 real 0m4.756s user 0m4.752s sys 0m0.002s
47 4807526976 real 0m7.800s user 0m7.790s sys 0m0.005s
48 7778742049 real 0m13.135s user 0m13.103s sys 0m0.014s
49 12586269025 real 0m21.007s user 0m20.971s sys 0m0.016s
UNIX> 

Step Two

When we teach this in CS202, we turn the recursion into a for() loop that starts with Fib(0) and Fib(1) and builds up to Fib(n).

However, with dynamic programming, we proceed to step two: memoization. We accept the recursive definition, but simply create a cache for the answers to that after the first time find_fib(i) is called for some i, it returns its answer from the cache. We implement it in src/fib2.cpp below. We initialize the cache with -1's to denote empty values.

class Fib {  // This is step 2 of dynamic programming: Add a cache.
  public:
    long long find_fib(int n);
    vector <long long> cache;
};

long long Fib::find_fib(int n)   // Create the cache if this is the first call.
{                                // Otherwise, return the answer from the cache if it's there.
  if (cache.size() == 0) cache.resize(n+1, -1);
  if (cache[n] != -1) return cache[n];

  if (n == 0 || n == 1) {        // If it's not in the cache, do the recursion, and put
    cache[n] = 1;                // the answer into the cache before returning.
  } else {
    cache[n] = find_fib(n-1) + find_fib(n-2);
  }
  return cache[n];
}

Now find_fib(n) is linear in n, which is a HUGE difference. The call that took 21 seconds below takes a couple of milliseconds:

UNIX> time bin/fib2 49
12586269025

real	0m0.003s
user	0m0.001s
sys	0m0.001s
UNIX> 

Step Three

Next, we perform Step 3, which makes the observation that whenever we call find_fib(n), it only makes recursive calls to values less than n. That means that we can build the cache from zero up to n with a for loop (src/fib3.cpp):

class Fib {              // This is step three -- remove the recursion and build the cache.
  public:
    long long find_fib(int n);
    vector <long long> cache;
};

long long Fib::find_fib(int n)
{
  int i;
  if (n == 0 || n == 1)  return 1;

  cache.resize(n+1, -1);

  /* Because all of our recursive calls were to smaller values of n, we can
     build the cache from small to big. */

  cache[0] = 1;
  cache[1] = 1;
  for (i = 2; i <= n; i++) cache[i] = cache[i-1] + cache[i-2];

  return cache[n];
}

It's not appreciably faster, because fib2 is already smoking fast, but it does demonstrate step three:

UNIX> time bin/fib3 49
12586269025

real	0m0.003s
user	0m0.001s
sys	0m0.001s
UNIX> 

Step Four

Finally, when we reflect that we only ever look at the last two values in the cache, we can turn the cache into just two elements.. This is Step 4 (in src/fib4.cpp). Again, when we teach this in CS202, we reduce the program to three variables: the values for n, n-1 and n-2. What I'm going to do here, though is keep the cache as a vector, but now with just two elements. Fib(n) for even values of n will go into cache[0], and odd values of n will go into cache[1]. In the end, we return the proper value of the cache, depending on whether n is even or odd.

Keep this technique in mind -- sometimes you can do step 4 simply by looking at how you access the cache in step 3.

class Fib {    // Step four: removing (or minimizing) the cache.
  public:
    long long find_fib(int n);
};

long long Fib::find_fib(int n)
{
  vector <long long> cache;
  int i;

  if (n == 0 || n == 1) return 1;

  /* The key observation here is that we only need the last two entries of the cache.
     So, we'll limit the cache to two entries.  We'll keep the odd ones in cache[0],
     and the even ones in cache[1]. */
     
  cache.resize(2, 1);
  for (i = 2; i <= n; i++) {
    cache[i%2] = cache[0] + cache[1];
  }

  /* Return cache[0] if n is even, and cache[1] if n is odd. */

  return cache[n%2];
}