The 0/1 knapsack problem three ways: brute force, dynamic programming, greedy
How the same optimization problem goes from exponential to pseudo-polynomial to n log n time, what you give up at each step, and how to read the chosen items back out of a dynamic programming table.
University lab From my coursework in the MSc in Statistics and Machine Learning at Linköping University, rewritten as a note to review later.
The knapsack problem is where “exponential time” stops being a phrase and turns into seconds on a clock. In my Advanced R course we wrote an R package (a pair project) with three solvers for the knapsack problem, and timed them. The problem is easy to say. You have items. Item has weight and value . Your bag holds total weight . Pick the set of items with the largest total value that still fits. The three solvers make a nice ladder: exact but slow, exact and much faster under one condition, and fast but only approximate.
Written out, with meaning “item is in the bag”:
1. Brute force: try every subset
The simplest idea: there are possible subsets, so check each one and keep the best that fits. That’s subsets (times up to work each). It’s always correct. A neat trick for listing all the subsets: the binary digits of the integers are exactly all the non-empty subsets (bit set = item included). In R, intToBits(i) gives you those bits.
The catch is how fast grows. With 16 items our R code already took about a second, and every extra item doubles that.
2. Dynamic programming: reuse answers to smaller problems
The idea here is to stop solving the same small problem again and again. If the weights are integers, let be the best value you can get using only the first items with capacity . Now think about item . Either you leave it out, or you put it in:
with for all . The answer is . The table has cells, and each one takes constant time to fill, so the cost is .
One subtle point that our report got slightly wrong: is called pseudo-polynomial, not polynomial. Input size is measured in bits, and only takes about bits to write down. Add one more bit to and the table doubles. So the running time is exponential in the length of the input. In practice: DP is great when is small (like 2000 in our tests), and useless if, say, weights are in grams and is in the millions. Knapsack is still NP-hard.
Reading back which items were chosen. The table gives you the best value, but not the items. To get those, walk backwards from . At row with remaining capacity : if , item was used, so write it down and set . Otherwise move up a row without taking it.
knapsack_dp <- function(w, v, W) {
n <- length(w)
m <- matrix(0, n + 1, W + 1) # row i+1 = first i items, col c+1 = capacity c
for (i in 1:n) for (c in 0:W) {
m[i + 1, c + 1] <- m[i, c + 1]
if (w[i] <= c) m[i + 1, c + 1] <- max(m[i + 1, c + 1], m[i, c + 1 - w[i]] + v[i])
}
items <- c(); c <- W
for (i in n:1) if (m[i + 1, c + 1] != m[i, c + 1]) { items <- c(i, items); c <- c - w[i] }
list(value = m[n + 1, W + 1], elements = items)
}
knapsack_dp(w = c(3, 4, 2), v = c(4, 5, 3), W = 6) # value 8, items 2 and 3
Our original version had two bugs worth remembering. The first was an off-by-one. R counts from 1 but capacity starts at 0, and our loop only filled the capacity columns up to W instead of W + 1. So the answer was really for capacity . Writing the “+1” out explicitly, as above, avoids that. The second bug was in the backtracking. It checked whether the current value appeared anywhere in the previous row (currValue %in% m[N-1, ]) instead of comparing with the cell directly above, at the current capacity. A value can show up in that row by coincidence, so items could be skipped or wrongly included. You have to keep track of the remaining capacity.
3. Greedy: sort by value per weight
The fast idea: sort items by (value per kilo, so to speak), highest first, and add them while they fit. The cost is for the sort. A million items took well under a second.
The catch: it’s not exact, and the plain version can be arbitrarily bad. Here’s a tiny example. Item A has (ratio 2). Item B has (ratio 1). Greedy takes A first, then B doesn’t fit, so it returns 2. The best answer is . Our report claimed greedy always gets at least 50% of the best value. That guarantee only holds for a small change to the algorithm: return the better of (a) the greedy solution, stopping at the first item that doesn’t fit, and (b) the single most valuable item that fits on its own. That modified version is a 1/2-approximation.
What the timing experiments taught
- Data frames in hot loops are slow in R. Profiling showed most of the time went into
x$w[j]lookups inside loops. Converting to a matrix (or plain vectors) first cut the brute-force time from about 1.4 s to about 0.25 s for 16 items, and DP from about 2 s to about 0.34 s for 500 items. - Parallel isn’t automatically faster. Our parallel brute force with
parLapplywas slower (about 3.2 s vs 1.4 s). Starting worker processes and copying data to them cost more than the work itself at , and our parallel version built all combinations in memory. Also, we calledstopCluster()afterreturn(), so it never ran and the workers were left behind.on.exit(stopCluster(cl))right after creating the cluster fixes that.
Key takeaways
- Brute force is : exact, only for tiny .
- DP is , pseudo-polynomial: exact and fast when integer capacities are small.
- Recover chosen items by walking the DP table backwards, comparing each cell with the one directly above and tracking the remaining capacity.
- Greedy by is but has no guarantee unless you also compare with the best single item.
- Profile before optimizing, and measure before assuming parallel helps.
Resources
- 732A94 Advanced R Programming, Linköping University, lab 6 (2019).
- Knapsack problem, Wikipedia: 0/1 DP and the greedy approximation.
- Cormen, Leiserson, Rivest, Stein, Introduction to Algorithms, 3rd ed., Chapter 15 (dynamic programming) and Section 16.2 (why greedy fails for 0/1 knapsack).