Part IV Advanced Design and Analysis IV.1
Techniques
Outline:
Chapter 15: Dynamic Programming applies to
optimization problems in which a set of
choices must be made to get the optimal
solution. The key idea is to store the
solution to a subproblem that can occur from
more than one set of choices. A dynamic
programming solution can sometimes change an
exponential-time algorithm into a polynomial
time algorithm.
Chapter 16: Greedy algorithms also apply to
optimization problems in which a set of
choices must be made to get the optimal
solution. The key idea here is to make each
choice in a locally optimal way. An example
is coin-changing: to minimize the number of
coins given as change, repeatedly select the
largest-denomination coin that is not
greater than the amount still owed.
Chapter 17: Amortized analysis is a tool for
analyzing algorithms that perform a
sequence of operations from a set of a few
operations. Instead of bounding the cost of
each operation separately, amortized
analysis gives a bound on the entire
sequence of operations.
Chapter 15 Dynamic Programming 15.0.1
Like divide-and-conquer, dynamic programming
solves problems by combining solutions to
subproblems ("programming" refers to a tabular
method, as also used in "linear programming",
not to writing computer code). In contrast to
divide-and-conquer, where the subproblems are
independent, dynamic programming is applicable
when subproblems share common subsubproblems.
Dynamic programming solves each subsubproblem
just once and saves its answer in a table.
Dynamic programming is typically applied to
optimization problems, which often have many
solutions. Each solution has a value, and we
wish to find a solution with the optimal value
(minimum or maximum; several such solutions
may exist). The four steps for developing a
dynamic-programming algorithm solution are:
1. Characterize the structure of an optimal
solution.
2. Recursively define the value of an optimal
solution.
3. Compute the value of an optimal solution in
a bottom-up fashion (may be desired result).
4. Construct an optimal solution from the
computed information (may be omitted).
15.1 Assembly-line Scheduling 15.1.1
Consider an automobile manufacturing problem
illustrated by Figure 15.1 (page 325) in which
cars are produced by two assembly lines. Each
assembly line i (= 1 or 2) has n stations
S_i,j for j = 1, 2, ..., n; S_1,j performs the
same assembly as S_2,j. Let a_i,j be the time
to do the assembly at station S_i,j. Since
the stations were built at different times,
the times a_1,j and a_2,j differ. Line i also
has an entry time e_i and exit time x_i.
Normally cars stay in one assembly line, but
occasionally there is a rush order to produce
a car quickly, in which case the car can be
switched from one line to the other. There is
a "transfer" time t_i,j to switch from line i
to the other line after going through state
S_i,j. The problem is to determine which
stations to pick from each of the lines to
minimize the total time through the factory.
Figure 15.2(a) (page 326) shows an example.
The "brute force" solution is to compute the
Theta(n) cost for each combination of choosing
stations 1, 2, ..., n. There are 2^n such
combinations, which would be too costly to
check when n is large. We can find a better
solution by using dynamic programming, as
explained below.
Step 1: The structure of the fastest 15.1.2
way through the factory
First consider the fastest possible way to
get from the start through S_1,j. If j = 1,
there is only one way, but for j = 2, 3,.., n,
there are two choices: the auto could have
-come from S_1,j-1 and gone directly to S_1,j
-come from S_2,j-1 & been transferred to S_1,j
In the first possibility above, the auto must
have taken a fastest way from the start point
through S_1,j-1, otherwise we could take a
faster way through S_1,j-1, and thus a faster
way through S_1,j, which is a contradiction.
In the second possibility above, by using the
same reasoning, the auto must have taken a
fastest way from the start through S_2,j-1.
In general, an optimal solution to a problem
(finding the fastest way through S_1,j),
contains within it an optimal solution to a
subproblems (finding the fastest way through
S_1,j-1 or S_2,j-1). This is called the
optimal substructure property, and is one of
the hallmarks of the applicability of dynamic
programming. We use optimal substructure to
find the fastest way through S_1,j -- it is:
15.1.3
- the fastest way through S_1,j-1 and then
directly through S_1,j, or
- the fastest way through S_2,j-1, transfer to
line 1, and then through S_1,j.
By symmetry, the fastest way through S_2,j is:
- the fastest way through S_2,j-1 and then
directly through S_2,j, or
- the fastest way through S_1,j-1, transfer to
line 2, and then through S_2,j.
So to find the fastest way through station j
of either line, we find the fastest way
through stations j - 1 on both lines. Thus we
can build an optimal solution to the problem
by building optimal solutions to subproblems.
Step 2: A recursive solution
Let f_i[j] denote the fastest possible time
from the starting point through S_i,j. The
fastest time through the factory, denoted f*,
is the value of the optimal solution that we
want to compute recursively. Thus, we have
f* = min( f_1[n] + x_1, f_2[n] + x_2 )
(Equation 15.1)
Also, there is only one way to 15.1.4
get through station 1 on either line, so:
f_1[1] = e_1 + a_1,1 and (Equation 15.2)
f_2[1] = e_2 + a_2,1 (Equation 15.3)
By using the observations of Step 1, we can
we can recursively compute f_1[j] for j = 2,
j = 2,3, ..., n as follows: if the fastest way
through S_1,j is first through S_1,j-1 then
directly to S_1,j, f_1[j] = f_1[j-1] + a_1,j,
otherwise if the fastest way through S_1,j is
first through S_2,j-1, a transfer to line 1,
and then through S_1,j, f_1[j] =
f_2[j-1] + t_2,j-1 + a_1,j. Therefore:
f_1[j] = min( f_1[j-1] + a_1,j ,
f_2[j-1] + t_2,j-1 + a_1,j )
(Equation 15.4)
Symmetrically:
f_2[j] = min( f_2[j-1] + a_2,j ,
f_1[j-1] + t_1,j-1 + a_2,j )
(Equation 15.5)
Equations 15.6 and 15.7 combine Equations 15.2
to 15.5 to recursively define the f_i[j]s.
Figure 15.2(b) shows the f_i[j]s for part (a)
along with the value of f*.
The f_i[j]s can be used to give the 15.1.5
_value_ of optimal solutions, but we must do
extra work to find an optimal solution itself.
To help do this, define l_i[j] to be the line
number (1 or 2) whose station j - 1 is used in
a fastest way through S_i,j, for j = 2, ..., n
and l* is the line whose station n is used in
a fastest way through the entire factory.
Using the values of l* and l_i[j] in Figure
15.2(b), we can trace a fastest way through
the factory as follows: starting with l* = 1,
we use station S_1,6. Then looking at l_1[6]
= 2, we use S_2,5. Continuing, we look at
l_2[5] = 2 (and use S_2,4), then l_2[4] = 1
(and use S_1,3), l_1[3] = 2 (use S_2,2), and
finally l_2[2] = 1 (use S_1,1).
Step 3: Computing the fastest times
Now we _could_ write a recursive algorithm
based on equations (15.1), (15.6) and (15.7).
But its running time is exponential in n. If
we let r_i(j) be the number of references made
to f_i[j] in a recursive algorithm, we have:
r_1(n) = r_2(n) = 1, and in general:
r_1(j) = r_2(j) = r_1(j + 1) + r_2(j + 1).
By Exercise 15.1-2, the solution to these
recurrences is r_i(j) = 2^(n-j), and the total
number of references is Theta(2^n).
We can do much better by computing 15.1.6
the f_i[j] values bottom-up rather than top-
down, as done by standard recursion -- i.e we
compute the f_i[j] in order of increasing j.
This way takes Theta(n) time. The algorithm
takes a_i,j, t_i,j, e_i, x_i, and n as input.
FASTEST-WAY(a,t,e,x,n)
1 f_1[1] <- e_1 + a_1,1
2 f_2[1] <- e_2 + a_2,1
3 for j <- 2 to n do
4 if f_1[j-1]+a_1,j <=f_2[j-1]+t_2,j-1+a_1,j
5 then f_1[j] <- f_1[j-1] + a_1,j
6 l_1[j] <- 1
7 else f_1[j] <- f_2[j-1] + t_2,j-1 +a_1,j
8 l_1[j] <- 2
9 if f_2[j-1]+a_2,j <=f_1[j-1]+t_1,j-1+a_2,j
10 then f_2[j] <- f_2[j-1] + a_2,j
11 l_2[j] <- 2
12 else f_2[j] <- f_1[j-1] + t_1,j-1 +a_2,j
13 l_2[j] <- 1
14 if f_1[n] + x_1 <= f_2[n] + x_2
15 then f* = f_1[n] + x_1
16 l* = 1
17 else f* = f_2[n] + x_2
18 l* = 2
One way to view this process is that we are
filling the tables of f_i[j] and l_i[j] from
left to right, as in Figure 15.2(b), since to
fill in f_i[j], we only need f_1[j - 1] and
f_2[j - 1], which we have already computed.
Step 4: Constructing the fastest way 15.1.7
through the factory
We showed above at the end of Step 2 how to
find the sequence of stations in Figure 15.2
producing a fastest way through the factory.
The following procedure prints the stations in
decreasing order; Exercise 15.1-1 suggests
that you can use recursion to print them in
increasing order.
PRINT-STATIONS(l,n)
1 i <- l*
2 print "line" i ", station " n
3 for j <- n downto 2
4 do i <- l_i[j]
5 print "line" i ", station " j - 1
In the example of Figure 15.2, this would
produce the following output:
line 1, station 6
line 2, station 5
line 2, station 4
line 1, station 3
line 2, station 2
line 1, station 1
Matrix-chain Multiplication 15.2.1
Suppose is a chain of
matrices to be multiplied to get A_1A_2...A_n.
Due to associativity of matrix multiplication,
we can compute this product in several ways -
we indicate the order in which to perform the
multiplications by fully parenthesizing it.
There are 5 ways to parenthesize if n = 4:
(A_1(A_2(A_3A_4)))
(A_1((A_2A_3)A_4))
((A_1A_2)(A_3A_4))
((A_1(A_2A_3))A_4)
(((A_1A_2)A_3)A_4)
(A_1(A_2(A_3A_4)))
The parenthesization can have a big impact
on the cost of the calculation. Here is the
standard way to multiply two matrices:
MATRIX-MULTIPLY(A,B)
1 If columns[A] not = rows[B] then
2 error "incompatible dimensions"
3 else for i <- 1 to rows[A] do
4 for j <- 1 to columns[B] do
5 C[i,j] <- 0
6 for k <- 1 to columns[A] do
7 C[i,j] <- C[i,j] + A[i,k]*B[k,j]
8 return C
The number of columns of A must = the number
of rows of B; if A is a pxq matrix and B is a
qxr matrix, C is a pxr matrix. The main cost
is the "*" in line 7, which is done pqr times.
E.g. consider the chain 15.2.2
with dimensions 10x100, 100x5, and 5x50. If
we parenthesize as ((A_1A_2)A_3), the cost is
10*100*5 = 5000 to compute A_1A_2 and
10*5*50 = 2500 to compute its product with A_3
for a total of 7500 scalar multiplications.
If we parenthesize as (A_1(A_2A_3)), the cost
is 100*5*50 = 25,000 to compute A_2A_3 and
10*100*5 = 50,000 to compute its product with
A_1, for a total of 75,000 scalar multiplies.
The matrix-chain multiplication problem:
given a chain of n matrices
where A_i has dimensions p_(i-1) by p_i, fully
parenthesize A_1A_2...A_n to minimize scalar
multiplications. Note that the cost to find
this parenthesization will be much less than
the cost of actually multiplying the matrices.
Counting the number of parenthesizations
Iterating through all parenthesizations is
not efficient. Let P(n) be the number of
parenthesizations of n matrices. Then P(1) is
1; if k>1 the number of ways to parenthesize
splitting between the k-th and (k+1)-st matrix
is P(k)*P(n-k), and since we can split at any
k = 1, 2, ..., n-1, the recurrence for P is
/ n-1
P(n) = < Sum P(k)P(n-k) if n > 1
\ k=1
P(n) = C(n-1), where C(n) = B(2n,n)/(n+1) ( =
Theta(4^n/n^1.5) ) is the n-th Catalan number
& B(2n,n) is the central binomial coefficient.
Step 1. Characterize the structure of 15.2.3
an optimal solution.
Let A_i..j denote the product A_i*...*A_j.
Then if A_1..n = (A_1..k)(A_k+1..n), is an
optimal parenthesization, A_1..k and A_k+1..n
are also optimally parenthesized, the first
hallmark of applicability of dynamic
programming.
Step 2. Recursively define the value of an
optimal solution.
Let m[i,j] = minimum number of scalar
multiplications to compute A_i..j, then:
/ 0 if i = j
m[i,j] = < min{m[i,k]+m[k+1,j]+p_i-1*p_k*p_j}
\i<=k, where
length[p] = n+1. Another table s[i,j] stores
the index k to split A_i..j to get least cost.
MATRIX-CHAIN-ORDER(p) 15.2.4
1 n <- length[p] - 1
2 for i <- 1 to n do
3 m[i,i] <- 0
4 for l <- 2 to n do |> l = length of chain
5 for i <- 1 to n - l + 1 do
6 j <- i + l - 1
7 m[i,j] <- infinity
8 for k = i to j - 1 do
9 q <- m[i,k] + m[k+1,j] + p_(i-1)p_kp_j
10 if q < m[i,j] then
11 m[i,j] <- q
12 s[i,j] <- k |> k = best split yet
13 return m and s
The minimum cost is m[1,n]. Figure 15.3 shows
an example when n = 6. Since we only use half
of each table, they are rotated 45 degrees
counter-clockwise. The outer loop of the
algorithm fills entries one line at a time
from the bottom (previously the main diagonal)
to the top vertices m[1,n] and s[1,n] (which
tells where to make the first split).
The nested loop structure gives a running
time of O(n^3) since each loop is executed at
most n times. A careful count shows that the
number of times the inner loop is executed is
(1/6)n^3 - n/6, so the running time is
actually Theta(n^3).
Step 4. Constructing an optimal 15.2.5
solution
Each entry s[i,j] tells where to split
A_i..j to obtain the minimal cost. So s[1,n]
tells where to make the first split, and then
recursively s[1,s[1,n]] tells where to split
the left half and s[s[1,n]+1,n] tells where to
split the right half, etc. The following
algorithm prints the optimal parenthesization
with initial call PRINT-OPTIMAL-PARENS(s,1,n).
PRINT-OPTIMAL-PARENS(s,i,j)
1 if i = j then
2 print "A"_i
3 else print "("
4 PRINT-OPTIMAL-PARENS(s, i, s[i,j])
5 PRINT-OPTIMAL-PARENS(s, s[i,j]+1, j)
6 print ")"
In the example of Figure 15.3, the call
PRINT-OPTIMAL-PARENS(s,1,6) prints out the
parenthesization ((A_1(A_2A_3))((A_4A_5)A_6))
Elements of Dynamic Programming 15.3.1
What is necessary in order to apply dynamic
programming? Answer:
(1) optimal substructure, and
(2) overlapping subproblems
We will also look at the memoization method.
Optimal substructure
Definition: A problem has optimal substructure
if an optimal solution contains optimal
solutions to subproblems.
This is one indication that a problem might
have a dynamic programming solution, though it
might also have a greedy algorithm solution.
We have seen optimal substructure in all the
problems solved by dynamic programming so far.
Here is a common pattern in finding optimal
substructure:
1. Show that the solution consists of making a
choice: choosing an assembly line station,
a "splitting index" for a matrix-chain, or
an intermediate vertex in a shortest path.
2. Assume that you are given the choice that
leads to an optimal solution.
3. Given this choice, determine the ensuing
subproblems and how to characterize the
space of subproblems.
4. Show that the solutions to sub- 15.3.2
problems within an optimal solution are also
optimal by using a "cut-and-paste" argument:
assume that a subsolution is non-optimal, by
replacing it with an optimal solution, one
would reduce (or increase) the value of the
of the whole solution, giving a better value
so that the original value was not optimal
after all - a contradiction.
To characterize the space of subproblems, a
good rule of thumb is to make it as simple as
possible. In the assembly line case, there
are just two subproblems to consider; in the
matrix-chain case, the subproblems are of the
form A_i..j, where we have to allow both i and
j to vary (giving a 2-dimensional space).
Optimal substructure varies in two ways:
(1) how many subproblems are used in an
optimal solution, and
(2) how many choices we have in determining
which subproblems to use in a solution.
In the assembly line case, one subproblem is
used, and we have two choices. In the matrix-
chain case to solve A_i..j, there are two
subproblems A_i..k, and A_(k+1)..j, and j - i
ways of picking k.
So the cost of a dynamic programming 15.3.3
algorithm is the product of the number of
subproblems times the number of choices for
each one. In the assembly line case, there
were 2n = Theta(n) subproblems and only two
choices for Theta(n) total cost. For the
matrix-chain case, there were Theta(n^2) sub-
problems and at most n-1 choices, for O(n^3)
total cost - actually the cost is Theta(n^3).
Dynamic programming produces a bottom-up
solution: first find optimal solutions to sub-
problems, then use them to make choices to
find an optimal solution to the whole problem.
So the cost is the cost of the subproblems
plus the cost of making the choice. In the
assembly line case, we first found the cost of
going through stations S_1,j-1 and S_2,j-1 and
then the "choice cost" included the cost a_i,j
of going through station S_i,j and a possible
transfer cost. In the matrix-chain case, the
choice cost was the term p_(i-1)*p_k*p_j.
Greedy algorithms (Chapter 16) have some
similarities to dynamic programming algorithms
- in particular they both have the optimal
substructure property. The difference is
that greedy algorithms work in a top-down way,
making the best (greedy) choice at the time
_before_ knowing the solutions to the sub-
problems (after making the choice they then
solve the subproblems).
Subtleties 15.3.4
We must use care in identifying optimal
substructure. Consider the following two
problems on a directed graph G = (V,E) and
vertices u and v:
Unweighted shortest path. Find a path from u
to v with the fewest edges (which must be
simple, otherwise we could remove a cycle to
get a shorter path).
Unweighted longest simple path. Find a simple
path from u to v with the most edges (we need
to exclude cycles, otherwise we could go
around them many times to get an arbitrarily
high edge count).
The unweighted shortest path problem has the
optimal substructure property by the usual
argument: if a subpath of an optimal path was
not optimal, it could be replaced by a shorter
subpath, giving a shorter total path than the
original "optimal" path.
However the unweighted longest simple path
problem does not have the optimal substructure
property, as shown by Figure 15.4: (q)<-->(r)
Now q-->r-->t is a longest path from ^ ^
q to t, but neither of its subpaths | |
q-->r or r-->t is a longest path v v
between their endpoints. (s)<-->(t)
There is no known good dynamic 15.3.5
programming solution to this problem - in fact
it is NP-complete, which means it probably
can't be solved in polynomial time.
The distinction between these two problems is
that subproblems of the unweighted shortest
path problem are independent - finding a
shortest path from q to r does not affect the
finding of a shortest path from r to t (if
they did share a vertex, we would have a cycle
which we have seen can't happen for a shortest
path). On the other hand if we have a longest
path from q to r, it would include all of the
vertices and there would be none left to use
for a longest path from r to t, so finding a
first longest subpath _does_ effect finding a
second subpath.
In the assembly line case, an optimal
solution to get to station S_1,j has only one
optimal subsolution: either getting to station
S_1,(j-1) or getting to to station S_2,(j-1),
and only having one subsolution is always
independent. In the matrix-chain case,
multiplying A_i..k is certainly independent of
multiplying A_(k+1)..j.
Overlapping subproblems 15.3.6
The second ingredient of a problem amenable
to dynamic programming is that it have
"overlapping subproblems" - the natural
recursive algorithm would have to solve the
same problem many times, as we saw in the
assembly line case, which required 2^(n-j)
accesses to compute f_i[j]. On the other hand
the dynamic programming solution is Theta(n).
For dynamic programming to be effective, the
space of subproblems is usually polynomial in
the input size. A dynamic programming
algorithm takes advantage of overlapping
subproblems by solving them once and storing
the result in a table, after which the result
can simply be looked up in constant time.
In the matrix-chain case, the smaller sub-
problems are looked up many times. Figure
15.5 shows the case of four matrices, in which
there are 10 subproblems and there would be 25
if we didn't use overlaps. Consider:
RECURSIVE-MATRIX-CHAIN(p,i,j)
1 if i = j then
2 return 0
3 m[i,j] <- infinity
4 for k <- i to j-1 do
5 q <- RECURSIVE-MATRIX-CHAIN(p,i,k)
+ RECURSIVE-MATRIX-CHAIN(p,k+1,j)
+ p_(i-1)*p_k*p_j
6 if q < m[i,j] then
7 m[i,j] <- q
8 return m[i,j]
We show RECURSIVE-MATRIX-CHAIN needs 15.3.7
Omega(2^n) time to compute m[1,n]. Letting
T(n) be the time to compute m[1,n], we have:
T(1) >= 1
n-1
T(n) >= 1 + Sum(T(k) + T(n-k) + 1) for n > 1
k=1
Note that each T(i) occurs twice in the sum,
once as T(i) and once as T(n - (n-i)), and 1
appears n times altogether, so we have:
n-1
T(n) >= n + 2*Sum T(i)
i=1
Now we prove T(n) >= 2^(n-1) by induction.
Certainly T(1) = 1 = 2^(1-1), so for n > 1
n-1
T(n) >= n + 2*Sum 2^(i-1)
i=1
n-2
= n + 2*Sum 2^i
i=0
= n + 2(2^(n-1) - 1) = n + 2^n - 2
>= 2^(n-1)
Thus T(n) = Omega(2^n) 15.3.8
The bottom-up dynamic programming solution is
more efficient because it takes advantage of
single solutions to the Theta(n^2) different
overlapping subproblems. The recursive
algorithm repeatedly solves the same problem
each time it occurs in the recursion tree. So
whenever the same subproblem occurs repeatedly
in the recursion tree and the total number of
different subproblems is small, there may be
a dynamic programming solution.
Reconstructing an optimal solution
Often it is possible to obtain the optimal
choices (Step 4) from the optimal costs in
Step 3. For example, in the assembly line
case if f_1[j] = f_1[j-1] + a_1,j then S_1,j-1
precedes S_1,j as the fastest way through
S_1,j, or if f_1[j] = f_2[j-1]+t_2,j-1+a_1,j
then S_2,j-1 precedes S_1,j as the fastest way
through S_1,j. Thus we can reconstruct the
fastest path in Theta(n) time.
This can also be done in the matrix-chain
case, but the same tests need to be done to
find the choices as to find the minimum cost,
so the reconstruction time is Theta(n^3). But
if we construct s as we go, we can find the
optimal parenthesization in Theta(n) time with
PRINT-OPTIMAL-PARENS().
Memoization 15.3.9
It is possible to make the natural recursive
solution to a dynamic programming problem as
efficient as the dynamic programming solution
by memoizing it. The idea is to maintain a
table as usual and to compute its entries the
first time a subproblem is encountered, but to
just look up the result in subsequent times.
Here is the memoized RECURSIVE-MATRIX-CHAIN():
MEMOIZED-MATRIX-CHAIN(p)
1 n <- length[p] - 1
2 for i <- 1 to n do
3 for j <- i to n do
4 m[i,j] <- infinity
5 return LOOKUP-CHAIN(p,1,n)
LOOKUP-CHAIN(p,i,j)
1 if m[i,j] < infinity then
2 return m[i,j]
3 if i = j then
4 m[i,j] <- 0
5 else |> We compute the minimum
for k <- i to j-1 do
6 q <- LOOKUP-CHAIN(p,i,k)
+ LOOKUP-CHAIN(p,k+1,j)
+ p_(i-1)*p_k*p_j
7 if q < m[i,j] then
8 m[i,j] <- q
9 return m[i,j]
15.3.10
Figure 15.5 shows how MEMOIZED-MATRIX-CHAIN
saves time compared to RECURSIVE-MATRIX-CHAIN.
Shaded subtrees are values that are looked up
rather than computed.
There are two kinds of calls to LOOKUP-CHAIN:
1. if m[i,j] = infinity, lines 3-9 are done
2. if m[i,j] < infinity, line 2 returns m[i,j]
There are Theta(n^2) calls of the first type,
one per table entry. And for each entry in
the table there are O(n) lookups from line 2,
for a total of O(n^3) time (really Theta(n^3))
which is the same asymptotically as the
dynamic programming solution. So memoization
converts an Omega(2^n) recursive algorithm to
a O(n^3) algorithm.
In general, if all the subproblems must be
solved at least once, a dynamic programming
solution beats a memoized recursive solution
by a constant factor since it is simpler and
doesn't have the overhead of recursive calls.
And there are some problems for which the
time or space requirements of the dynamic
programming solution can be further reduced.
On the other hand if not all subproblems
need be computed, the memoized algorithm
saves time by not computing them.