Close
Register
Close Window

| About   «  10.6. Minimal Cost Spanning Trees   ::   Contents   ::   12.1. Glossary  »

11.1. Dynamic Programming

11.1.1. Dynamic Programming

Dynamic programming is an algorithm design technique that can improve the efficiency of any inherently recursive algorithm that repeatedly re-solves the same subproblems. Using dynamic programming requires two steps:

  1. You find a recursive solution to a problem where subproblems are redundantly solved many times.

  2. Optimize the recursive algorithm to eliminate re-solving subproblems. The resulting algorithm may be recursive or iterative. The iterative form is commonly referred to by the term dynamic programming.

We will see first how to remove redundancy with a simple problem, computing Fibonacci numbers. Then we introduce the knapsack problem, and show how it can be solved efficiently with dynamic programming.

11.1.2. The Knapsack Problem

We will next consider a problem that appears with many variations in a variety of commercial settings. Many businesses need to package items with the greatest efficiency. One way to describe this basic idea is in terms of packing items into a knapsack, and so we will refer to this as the Knapsack Problem. We will first define a particular formulation of the knapsack problem, and then we will discuss an algorithm to solve it based on dynamic programming. There are many other versions for the problem. Some versions ask for the greatest amount that will fit, others introduce values to the items along with size. We will look at a fairly easy-to-understand variation.

Assume that we have a knapsack with a certain amount of space that we will define using integer value \(K\). We also have \(n\) items each with a certain size such that that item \(i\) has integer size \(k_i\). The problem is to find a subset of the \(n\) items whose sizes exactly sum to \(K\), if one exists. For example, if our knapsack has capacity \(K = 5\) and the two items are of size \(k_1 = 2\) and \(k_2 = 4\), then no such subset exists. But if we add a third item of size \(k_3 = 1\), then we can fill the knapsack exactly with the second and third items. We can define the problem more formally as: Find \(S \subset \{1, 2, ..., n\}\) such that

\[\sum_{i \in S} k_i = K.\]

If you tried solving these examples, you probably found yourself doing a lot of trial-and-error and a lot of backtracking. To come up with an algorithm, we want an organized way to go through the possible subsets. Is there a way to make the problem smaller, so that we can apply recursion? We essentially have two parts to the input: The knapsack size \(K\) and the \(n\) items. It probably will not do us much good to try and break the knapsack into pieces and solve the sub-pieces (since we already saw that knowing the answer for a knapsack of size 163 did nothing to help us solve the problem for a knapsack of size 164).

So, what can we say about solving the problem with or without the \(n\)’th item? This seems to lead to a way to break down the problem. If the \(n\)’th item is not needed for a solution (that is, if we can solve the problem with the first \(n-1\) items) then we can also solve the problem when the \(n\)’th item is available (we just ignore it). On the other hand, if we do include the \(n\)’th item as a member of the solution subset, then we now would need to solve the problem with the first \(n-1\) items and a knapsack of size \(K - k_n\) (since the \(n\)’th item is taking up \(k_n\) space in the knapsack).

To organize this process, we can define the problem in terms of two parameters: the knapsack size \(K\) and the number of items \(n\). Denote a given instance of the problem as \(P(n, K)\). Now we can say that \(P(n, K)\) has a solution if and only if there exists a solution for either \(P(n-1, K)\) or \(P(n-1, K-k_n)\). That is, we can solve \(P(n, K)\) only if we can solve one of the sub problems where we use or do not use the \(n\) th item. Of course, the ordering of the items is arbitrary. We just need to give them some order to keep things straight.

Continuing this idea, to solve any subproblem of size \(n-1\), we need only to solve two subproblems of size \(n-2\). And so on, until we are down to only one item that either fills the knapsack or not.

Continuing this idea, to solve any subproblem of size \(n-1\), we need only to solve two subproblems of size \(n-2\). And so on, until we are down to only one item that either fits the knapsack or not. Assuming that \(P(i, S)\) represents the problem for object i and after, and with size s still free in the knapsack, the following algorithm expresses the ideas.

if \(P(n-1, K)\) has a solution,
then \(P(n, K)\) has a solution
else if \(P(n-1, K-k_n)\) has a solution
then \(P(n, K)\) has a solution
else \(P(n, K)\) has no solution.

Although this algorithm is correct, it naturally leads to a cost expressed by the recurrence relation \(\mathbf{T}(n) = 2\mathbf{T}(n-1) + c = \Theta(2^n)\). That can be pretty expensive!

But… we should quickly realize that there are only \(n(K+1)\) subproblems to solve! Clearly, there is the possibility that many subproblems are being solved repeatedly. This is a natural opportunity to apply dynamic programming. If we draw the recursion tree of this naive recursive algorithm and derive its corresponding dependency graph, we notice that all the recursive calls can be laid out on an array of size \(n \times K+1\) to contain the solutions for all subproblems \(P(i, k), 0 \leq i \leq n-1, 0 \leq k \leq K\).

As mentioned above, there are two approaches to actually solving the problem. One is memoization, that is, to start with our problem of size \(P(n, K)\) and make recursive calls to solve the subproblems, each time checking the array to see if a subproblem has been solved, and filling in the corresponding cell in the array whenever we get a new subproblem solution. The other is tabulation. Conceivably we could adopt one of several computation orders, although the most “natural” is to start filling the array for row 0 (which indicates a successful solution only for a knapsack of size \(k_0\)). We then fill in the succeeding rows from \(i=1\) to \(n\).

In other words, a new slot in the array gets its solution by looking at most at two slots in the preceding row. Since filling each slot in the array takes constant time, the total cost of the algorithm is \(\Theta(nK)\).

   «  10.6. Minimal Cost Spanning Trees   ::   Contents   ::   12.1. Glossary  »

Close Window