Dynamic Programming contents
Knapsack Problem
0-1, complete and bounded knapsack in one array, with the loop directions that make each version work, and a big-integer bitset trick for subset sum.
Read first: Introduction to Dynamic Programming
You have items. Item has weight and value , and you carry a knapsack of capacity . Choose items to maximize the total value while the total weight stays at most . The variants differ in how many copies of each item may be taken.
| Variant | Copies of item |
|---|---|
| 0-1 knapsack | at most 1 |
| complete (unbounded) knapsack | unlimited |
| multiple (bounded) knapsack | at most |
0-1 knapsack
State. = the best value using only the first items with capacity .
Transition. Skip item , or take it (if it fits):
Row depends only on row , so one array is enough — provided we loop the capacity downwards, so that f[j - w] still holds the value from the previous item (not from this item taken twice):
def knapsack_01(items, capacity):
"""items: list of (weight, value). Return the maximum total value."""
f = [0] * (capacity + 1)
for w, v in items:
for j in range(capacity, w - 1, -1): # descending: each item used at most once
f[j] = max(f[j], f[j - w] + v)
return f[capacity]
items = [(1, 15), (3, 20), (4, 30)]
assert knapsack_01(items, 4) == 35 # items 1 and 2: weight 4, value 35
assert knapsack_01(items, 5) == 45
assert knapsack_01(items, 0) == 0The time is and the memory .
Compare with brute force:
from itertools import product
import random
def brute_01(items, capacity):
best = 0
for pick in product((0, 1), repeat=len(items)):
weight = sum(w for (w, _), p in zip(items, pick) if p)
if weight <= capacity:
best = max(best, sum(v for (_, v), p in zip(items, pick) if p))
return best
random.seed(1)
for _ in range(300):
its = [(random.randint(1, 8), random.randint(1, 20)) for _ in range(random.randint(0, 8))]
cap = random.randint(0, 20)
assert knapsack_01(its, cap) == brute_01(its, cap)Complete (unbounded) knapsack
With unlimited copies, taking item again is allowed, so use the same row: . In one array the only change is to loop the capacity upwards, so f[j - w] may already include this item:
def knapsack_unbounded(items, capacity):
f = [0] * (capacity + 1)
for w, v in items:
for j in range(w, capacity + 1): # ascending: item can be reused
f[j] = max(f[j], f[j - w] + v)
return f[capacity]
assert knapsack_unbounded([(2, 3), (3, 5)], 7) == 11 # two items of weight 2 and one of weight 3: 3+3+5
assert knapsack_unbounded([(5, 10)], 4) == 0Ascending vs descending is the one thing to remember: descending forbids reuse, ascending allows it.
Multiple (bounded) knapsack
Item can be taken up to times. The simplest approach treats every copy as a separate 0-1 item: .
Binary grouping
Replace copies by bundles of size and a remainder. Any count from to can be formed as a sum of distinct bundles, so the 0-1 algorithm on the bundles gives the right answer:
def knapsack_bounded(items, capacity):
"""items: list of (weight, value, count)."""
bundles = []
for w, v, k in items:
size = 1
while k > 0:
take = min(size, k)
bundles.append((w * take, v * take))
k -= take
size *= 2
return knapsack_01(bundles, capacity)
assert knapsack_bounded([(2, 3, 2), (3, 5, 1)], 7) == 3 + 3 + 5 # two of item 1, one of item 2
assert knapsack_bounded([(1, 1, 100)], 10) == 10
def brute_bounded(items, capacity):
expanded = [(w, v) for w, v, k in items for _ in range(k)]
return knapsack_01(expanded, capacity)
random.seed(2)
for _ in range(200):
its = [(random.randint(1, 6), random.randint(1, 10), random.randint(1, 5)) for _ in range(random.randint(0, 5))]
cap = random.randint(0, 25)
assert knapsack_bounded(its, cap) == brute_bounded(its, cap)The time is . An even faster solution uses a monotone queue optimization (see the minimum queue).
Subset sum and a Python trick
Subset sum asks only: can some subset of the numbers reach exactly the sum ? It is a knapsack with boolean values. The direct DP is a table reachable[s].
Python has a beautiful shortcut: use one big integer as a bitset, where bit is set when sum is reachable. Adding an item means "every reachable sum makes reachable", which is a single shift and OR:
def subset_sums_mask(numbers):
mask = 1 # bit 0: the empty subset has sum 0
for a in numbers:
mask |= mask << a
return mask
def can_make(numbers, target):
return (subset_sums_mask(numbers) >> target) & 1 == 1
assert can_make([3, 34, 4, 12, 5, 2], 9) # 4 + 5
assert not can_make([3, 34, 4, 12, 5, 2], 30)
assert can_make([], 0) and not can_make([], 1)
def reachable_table(numbers, target):
reachable = [False] * (target + 1)
reachable[0] = True
for a in numbers:
for s in range(target, a - 1, -1):
reachable[s] = reachable[s] or reachable[s - a]
return reachable
random.seed(3)
for _ in range(200):
nums = [random.randint(1, 15) for _ in range(random.randint(0, 8))]
target = random.randint(0, 60)
assert can_make(nums, target) == reachable_table(nums, target)[target]Each shift-and-OR processes the whole bitset in C, a machine word at a time. In our test, 1000 random numbers up to 1000 (total about ) took 0.013 s with the big-integer mask, while the table version needed about 3.7 s for only the first 100 of them (roughly 40 s for all 1000).