Intro To Dynamic Programming
Intro To Dynamic Programming
Dynamic Programming is a powerful technique that allows one to solve many different types of
problems in time O(n2 ) or O(n3 ) for which a naive approach would take exponential time. In this
lecture, we discuss this technique, and present a few key examples. Topics in this lecture include:
• Example: Knapsack.
1 Introduction
Dynamic Programming is a powerful technique that can be used to solve many problems in time
O(n2 ) or O(n3 ) for which a naive approach would take exponential time. (Usually to get running
time below that—if it is possible—one would need to add other ideas as well.) Dynamic Pro-
gramming is a general approach to solving problems, much like “divide-and-conquer” is a general
method, except that unlike divide-and-conquer, the subproblems will typically overlap. This lecture
we will present two ways of thinking about Dynamic Programming as well as a few examples.
There are several ways of thinking about the basic idea.
Basic Idea (version 1): What we want to do is take our problem and somehow break it down into
a reasonable number of subproblems (where “reasonable” might be something like n2 ) in such a way
that we can use optimal solutions to the smaller subproblems to give us optimal solutions to the
larger ones. Unlike divide-and-conquer (as in mergesort or quicksort) it is OK if our subproblems
overlap, so long as there are not too many of them.
S = ABAZDC
T = BACBAD
In this case, the LCS has length 4 and is the string ABAD. Another way to look at it is we are finding
a 1-1 matching between some of the letters in S and some of the letters in T such that none of the
edges in the matching cross each other.
For instance, this type of problem comes up all the time in genomics: given two DNA fragments,
the LCS gives information about what they have in common and the best way to line them up.
Let’s now solve the LCS problem using Dynamic Programming. As subproblems we will look at
the LCS of a prefix of S and a prefix of T , running over all pairs of prefixes. For simplicity, let’s
1
worry first about finding the length of the LCS and then we can modify the algorithm to produce
the actual sequence itself.
So, here is the question: say LCS[i,j] is the length of the LCS of S[1..i] with T [1..j]. How
can we solve for LCS[i,j] in terms of the LCS’s of the smaller problems?
Case 1: what if S[i] 6= T [j]? Then, the desired subsequence has to ignore one of S[i] or T [j] so we
have:
LCS[i, j] = max(LCS[i − 1, j], LCS[i, j − 1]).
Case 2: what if S[i] = T [j]? Then the LCS of S[1..i] and T [1..j] might as well match them up.
For instance, if I gave you a common subsequence that matched S[i] to an earlier location in
T , for instance, you could always match it to T [j] instead. So, in this case we have:
So, we can just do two loops (over values of i and j) , filling in the LCS using these rules. Here’s
what it looks like pictorially for the example above, with S along the leftmost column and T along
the top row.
B A C B A D
A 0 1 1 1 1 1
B 1 1 1 2 2 2
A 1 2 2 2 3 3
Z 1 2 2 2 3 3
D 1 2 2 2 3 4
C 1 2 3 3 3 4
We just fill out this matrix row by row, doing constant amount of work per entry, so this takes
O(mn) time overall. The final answer (the length of the LCS of S and T ) is in the lower-right
corner.
How can we now find the sequence? To find the sequence, we just walk backwards through
matrix starting the lower-right corner. If either the cell directly above or directly to the right
contains a value equal to the value in the current cell, then move to that cell (if both to, then chose
either one). If both such cells have values strictly less than the value in the current cell, then move
diagonally up-left (this corresponts to applying Case 2), and output the associated character. This
will output the characters in the LCS in reverse order. For instance, running on the matrix above,
this outputs DABA.
This algorithm runs in exponential time. In fact, if S and T use completely disjoint sets of characters
(so that we never have 1 S[n]==T[m]) then the number of times that LCS(S,1,T,1) is recursively
called equals n+m−2
m−1 . In the memoized version, we store results in a matrix so that any given
set of arguments to LCS only produces new work (new recursive calls) once. The memoized version
begins by initializing arr[i][j] to unknown for all i,j, and then proceeds as follows:
LCS(S,n,T,m)
{
if (n==0 || m==0) return 0;
if (arr[n][m] != unknown) return arr[n][m]; // <- added this line (*)
if (S[n] == T[m]) result = 1 + LCS(S,n-1,T,m-1);
else result = max( LCS(S,n-1,T,m), LCS(S,n,T,m-1) );
arr[n][m] = result; // <- and this line (**)
return result;
}
All we have done is saved our work in line (**) and made sure that we only embark on new recursive
calls if we haven’t already computed the answer in line (*).
In this memoized version, our running time is now just O(mn). One easy way to see this is as
follows. First, notice that we reach line (**) at most mn times (at most once for any given value of
the parameters). This means we make at most 2mn recursive calls total (at most two calls for each
time we reach that line). Any given call of LCS involves only O(1) work (performing some equality
checks and taking a max or adding 1), so overall the total running time is O(mn).
Comparing bottom-up and top-down dynamic programming, both do almost the same work. The
top-down (memoized) version pays a penalty in recursion overhead, but can potentially be faster
than the bottom-up version in situations where some of the subproblems never get examined at
all. These differences, however, are minor: you should use whichever version is easiest and most
intuitive for you for the given problem at hand.
More about LCS: Discussion and Extensions. An equivalent problem to LCS is the “mini-
mum edit distance” problem, where the legal operations are insert and delete. (E.g., the unix “diff”
command, where S and T are files, and the elements of S and T are lines of text). The minimum
edit distance to transform S into T is achieved by doing |S| − LCS(S, T ) deletes and |T | − LCS(S, T )
inserts.
In computational biology applications, often one has a more general notion of sequence alignment.
Many of these different problems all allow for basically the same kind of Dynamic Programming
solution.
1
This is the number of different “monotone walks” between the upper-left and lower-right corners of an n by m
grid.
3
4 Example #2: The Knapsack Problem
Imagine you have a homework assignment with different parts labeled A through G. Each part has
a “value” (in points) and a “size” (time in hours to complete). For example, say the values and
times for our assignment are:
A B C D E F G
value 7 9 5 12 14 6 12
time 3 4 2 6 7 3 5
Say you have a total of 15 hours: which parts should you do? If there was partial credit that was
proportional to the amount of work done (e.g., one hour spent on problem C earns you 2.5 points)
then the best approach is to work on problems in order of points-per-hour (a greedy strategy).
But, what if there is no partial credit? In that case, which parts should you do, and what is the
best total value possible?2
Exercise: Give an example where using the greedy strategy will get you less than 1% of the optimal
value (in the case there is no partial credit).
Definition 2 In the knapsack problem we are given a set of n items, where each item i is
specified by a size si and a value vi . We are also given a size bound S (the size of our knapsack).
The goal is to find the subset of items of maximum total value such that sum of their sizes is at
most S (they all fit into the knapsack).
We can solve the knapsack problem in exponential time by trying all possible subsets. With
Dynamic Programming, we can reduce this to time O(nS).
Let’s do this top down by starting with a simple recursive solution and then trying to memoize
it. Let’s start by just computing the best possible total value, and we afterwards can see how to
actually extract the items needed.
Right now, this takes exponential time. But, notice that there are only O(nS) different pairs of
values the arguments can possibly take on, so this is perfect for memoizing. As with the LCS
problem, let us initialize a 2-d array arr[i][j] to “unknown” for all i,j.
Value(n,S)
{
if (n == 0) return 0;
if (arr[n][S] != unknown) return arr[n][S]; // <- added this
2
Answer: In this case, the optimal strategy is to do parts A, B, F, and G for a total of 34 points. Notice that this
doesn’t include doing part C which has the most points/hour!
4
if (s_n > S) result = Value(n-1,S);
else result = max{v_n + Value(n-1, S-s_n), Value(n-1, S)};
arr[n][S] = result; // <- and this
return result;
}
Since any given pair of arguments to Value can pass through the array check only once, and in
doing so produces at most two recursive calls, we have at most 2n(S + 1) recursive calls total, and
the total time is O(nS).
So far we have only discussed computing the value of the optimal solution. How can we get
the items? As usual for Dynamic Programming, we can do this by just working backwards: if
arr[n][S] = arr[n-1][S] then we didn’t use the nth item so we just recursively work backwards
from arr[n-1][S]. Otherwise, we did use that item, so we just output the nth item and recursively
work backwards from arr[n-1][S-s n]. One can also do bottom-up Dynamic Programming.
Exercise: The fractional knapsack problem is the one where you can add δi ∈ [0, 1] fraction of task i
to the knapsack, using δi si space and getting δi vi value. The greedy algorithm adds items in decreasing
order of vi /si . Prove that this greedy algorithm produces the optimal solution for the fractional problem.
5
The second question is now: how long does it take to solve a given subproblem assuming you’ve
already solved all the smaller subproblems (i.e., how much time is spent inside any given recursive
call)? Answer: to figure out how to best multiply Ai × . . . × Aj , we just consider all possible middle
points k and select the one that minimizes:
This just takes O(1) work for any given k, and there are at most n different values k to consider, so
overall we just spend O(n) time per subproblem. So, if we use Dynamic Programming to save our
results in a lookup table, then since there are only O(n2 ) subproblems we will spend only O(n3 )
time overall.
If you want to do this using bottom-up Dynamic Programming, you would first solve for all sub-
problems with j − i = 1, then solve for all with j − i = 2, and so on, storing your results in an n
by n matrix. The main difference between this problem and the two previous ones we have seen is
that any given subproblem takes time O(n) to solve rather than O(1), which is why we get O(n3 )
total running time. It turns out that by being very clever you can actually reduce this to O(1)
amortized time per subproblem, producing an O(n2 )-time algorithm, but we won’t get into that
here.3
6
“Let’s take a word that has an absolutely precise meaning, namely dynamic, in the
classical physical sense. It also has a very interesting property as an adjective, and
that is it’s impossible to use the word, dynamic, in a pejorative sense. Try thinking of
some combination that will possibly give it a pejorative meaning. [. . .] Thus, I thought
dynamic programming was a good name. It was something not even a Congressman
could object to. So I used it as an umbrella for my activities.”
Bellman clearly had a way with words, coining another super-well-known phrase, “the curse of
dimensionality”.