Dynamic Programming

Virahanka (Fibonacci) numbers

There is a famous sequence of numbers that apply to many phenomena found in both nature and art. For each non-negative integer n, we let F_n := \begin{cases} 0 &\text{if $n = 0$}\\ 1 &\text{if $n = 1$}\\ F_{n - 1} + F_{n - 2} &\text{otherwise} \end{cases}

This sequence was first described by Virahanka around 800 CE while writing commentary about Pingala’s study of classical Sanskrit poetry. It was also described by Fibonacci in 1202.

Let’s say we want to compute Finonacci numbers1 The recurrence immediately suggests a recursive algorithm for doing so.

1

Yes, we’ll stick with Fibonacci. It’s mainly to avoid confusion and having to rewrite Erickson’s pseudocode. I’m kind of surprised he went with Fibonacci in stead of Virahanka, to be honest.

\text{RecFibo}(n):

  • if n = 0
    • return 0
  • else if n = 1
    • return 1
  • else
    • return \text{RecFibo}(n - 1) + \text{RecFibo}(n - 2)

If we try to analyze this algorithm, or even run it for moderate values of n, we’ll see that it’s horrendously slow. Up to constant factors, it follows the recurrence T(0) = 1, T(1) = 1, and T(n) = T(n - 1) + T(n - 2) + 1 for all n \geq 2. That looks like the Fibonacci recurrence again, and indeed, T(n) = 2F_{n + 1} - 1 = \Theta(\phi^n) = O(1.62^n). A quick look at the recursion tree shows what’s going wrong here. In this case, the value of each node is the number it computes.

The recursion tree for RecFibo(7)

That is a lot of recursive calls. In fact, we can infer that there are exactly F_n leaves with value F_1, because that’s the only non-zero base case and they add up to F_n. Which then implies there are at least F_{n + 1} - 1 internal nodes with many many many of them with value F_2 and slightly fewer with value F_3… There is a LOT of repeated work!

Donald Michie proposed one solution to this problem in 1967 when describing how programming languages should support “memo functions” that remember the results of previous function calls. We now call adding this feature to a function “memoization”. Basic memoization uses a “global” data structure to store the return values of function calls the first time they are computed so every time after that the value can be retrieved more quickly.

So what’s a good data structure to use? We could use a hash table, but that’s overkill.1 For the current problem, we’ll instead use an array F[1~..~n] indexed by which Fibonacci number we’re computing. We want to call \text{MemFibo}(n).

\text{MemFibo}(i):

  • if i = 0
    • return 0
  • else if i = 1
    • return 1
  • else
    • if F[i] is undefined
      • F[i] \gets \text{MemFibo}(i - 1) + \text{MemFibo}(i - 2)
    • return F[i]
1

Arrays will run faster in practice than hash tables, and they help us avoid technical analysis issues like the fact that hash tables aren’t actually O(1) time per operation in the worst case (they’re O(1) expected time). CS 473 and some other courses (maybe the current iteration of 225?) spend time on what’s really going on when analyzing hash tables.

Intuitively, the memoized version of the previous algorithm should be a lot faster, but it introduces another issue. Analyzing this thing is no longer as “straightforward” as writing the recurrence and solving it (using techniques I don’t expect you to know). Let’s take another look at that recursion tree, but with some green arrows added to represent storing answers in the global array and some red arrows added to represent retrieving them.

The recursion tree and memoization array for MemFibo(7)

This new recursion tree + memoization array reveals something new about how our algorithm is behaving. Every entry in the array is written to before it is used (as one would hope), and the writing is done entry by entry from left to right/least index to largest. We can even imagine there are two more entries for F[0] and F[1] (depicted above in gray boxes) that are written to before we start and read from just like any values. It takes O(1) additions to compute each entry, and there are O(n) entries to fill in, so the algorithm must use O(n) additions total.2

That process almost sounds iterative. So why don’t we but out the middle-Fairy and make the iteration explicit?

\text{IterFibo}(n):

  • F[0] \gets 0
  • F[1] \gets 1
  • for $i \gets \text{2 to n}
    • F[i] \gets F[i - 1] + F[i - 2]
  • return F[n]

Now the algorithm very clearly uses O(n) additions total.

2

I’m trying to carefully talking about number of additions here instead of time. Despite earlier claims that we’re working almost entirely in the word RAM model of computation (and I believe we will be doing so for the rest of the algorithms unit), computing Fibonacci numbers is another place where it makes sense to consider what it is that our computers are actually storing. The Fibonacci numbers F_n grow exponentially in n, so the time to perform these additions grow very very quickly. Physical computers only work with a constant 64–256 bits at a time, so individual additions F[i - 1] + F[i - 2] really take O(i) time. TODO: Add more about practical concerns below.

Dynamic programming

We’ve just seen our first example of dynamic programming, popularized by Richard Bellman in the mid 1950s even though others like Virahanka and Fibonacci had been using similar ideas for centuries already.

A Retro Wave PhotoFunia image saying “Dynamic Programming”

It’s not just about applying memoization to a recursive algorithm. And it’s especially not about ignoring recursion and jumping straight to filling in tables. (Seriously, that mindset leads to incorrect algorithms, frustrated graders, and 0s on your graded work.)

Dynamic programming a whole process described in the textbook and on the second page of Lab 7b and in the default dynamic programming grading rubric. Also, there’s a reminder in scary red text about the rubric on the top of Homework 6.

Seriously, please please please read one or preferably all of these descriptions and take them to heart. I recommend the lab handout and the default rubric. I’ll fill in the details as we go through our second example today, but the process has two main stages:

  1. Formulate the problem recursively including defining your subproblems carefully so we (and you!) can tell you’re solving them correctly. This is the hard part.
  2. Deliberately build subproblem solutions from the bottom up, typically through an iterative algorithm that records results in an array. This should be the easy part, but you will mess it up if you don’t do the first stage first.

Text segmentation, but fast this time

OK, time to cap off all we’ve done this week. For our final problem in the previous lecture, we saw text segmentation: Given an array A[1~..~n] of letters and a subroutine \text{IsWord} that tells you if a string is a “word”, is A the concatenation of words? We solved this problem using a recursive backtracking approach. By what is obviously not at all a coincidence, we did the first stage in the dynamic programming process.

  1. Formulate the problem recursively. There are two steps in this stage of the process:
  1. Build solutions to your recurrence from the bottom up. This stage has several steps. Fortunately, they’re all completely mechanical and should become second nature given “sufficient practice”. Finding a recursive solution is the hard but necessary part. Speeding it up should be (or become) the easy part.

We could have analyzed running time as early as identifying the subproblems, because all it comes down to is adding up all the running times of the possible subproblems (ignoring the recursive calls). That sum doesn’t depend on the evaluation order or the exact pseudocode you write. Instead, the sum is (often) just # subproblems * time per subproblem. In this case, we have O(n) subproblems but we need O(n) calls to \mathit{IsWord} per subproblem to evaluate the giant OR in the recurrence. Sure enough, O(n) \cdot O(n) = O(n^2). The reason you may want to do the analysis before picking a data structure and evaluation order is so you can determine whether your recursive strategy will lead to an acceptable running time before grinding through the rest of the process.

The lab before this lecture has you design some backtracking algorithms for what we expect to be 3 out of 5 problems over a sequence. The next lab, has you finish the dynamic programming process for all 5 problems. It goes along with what was written above: making correct backtracking algorithms is the hard part, and speeding them up is the easy part. But if you don’t do the hard part first, what should be the easy part will actually end up being the wrong part.

There are two whole lecture remaining on dynamic programming. We’ll look at different patterns you may encounter when designing backtracking algorithms in the first half of the process.

TODO: SubsetSum