DSA Unbounded Knapsack
Unbounded knapsack packs copies of items into a bag with a weight limit to gain the most total value. Any item may repeat as long as the total weight still fits.
The problem
Unbounded Knapsack packs copies of items into a bag with a weight limit to gain the most total value. Any item may repeat as long as the total weight still fits.
Each item has a weight and a value, and the supply of copies never runs out. The answer is the largest total value within the capacity, with the copy counts that earn it.
The cases used in this article
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
cases = [(5, 7), (7, 10), (10, 15), (0, 0)]
for capacity, expected in cases:
print("items:", items)
print("capacity", capacity, "best value:", expected)Capacity 10 is the case the 0/1 form cannot match. Five ropes weigh exactly 10 and give value 15. Taking each item at most once gives only 12 in the same bag.
Intuition: the once-each answer leaves value behind
The first idea reuses the 0/1 solution and takes each item at most once. It is a correct answer to a different problem. It refuses the copies that this problem allows.
Once each, against five copies of one item
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
capacity = 10
best = 0
for mask in range(2 ** len(items)):
group = [items[i] for i in range(len(items)) if mask & (1 << i)]
weight = sum(item[1] for item in group)
value = sum(item[2] for item in group)
if weight <= capacity and value > best:
best = value
print("best with each item once:", best)
print("five ropes value:", 5 * 3, "weight:", 5 * 2)The gap of 3 comes from repetition alone. No new item types appear. The method must let a packed item stay available for the rest of the bag.
Brute force: count the copies of each item
The brute force tries every copy count for every item. At capacity 7 the rope can appear 0 to 3 times and the lamp 0 to 2 times. Each pair of counts is one candidate load, weighed and priced in full.
Trying every pair of copy counts
capacity = 7
checked = 0
best = 0
best_counts = (0, 0)
for ropes in range(capacity // 2 + 1):
for lamps in range(capacity // 3 + 1):
checked += 1
weight = ropes * 2 + lamps * 3
value = ropes * 3 + lamps * 4
if weight <= capacity and value > best:
best = value
best_counts = (ropes, lamps)
print("loads checked:", checked)
print("best value:", best)
print("ropes, lamps:", best_counts)Two item types already give 12 candidate loads. Each added type multiplies the ranges together. Each added unit of capacity stretches every range.
The optimal solution: best value at each capacity, scanning upward
The state is best(c), the most value that fits within capacity c. That one sentence is the whole method.
The table fills capacities upward, from small to large. A cell reads smaller capacities that this same pass has already finished. An item used at a smaller capacity is therefore still available at a larger one, and copies appear on their own.
The upward table on every case
def unbounded(items, capacity):
dp = [0] * (capacity + 1)
pick = [-1] * (capacity + 1)
for c in range(1, capacity + 1):
for idx, (name, weight, value) in enumerate(items):
if weight <= c and dp[c - weight] + value > dp[c]:
dp[c] = dp[c - weight] + value
pick[c] = idx
counts = []
c = capacity
tally = {}
while c > 0 and pick[c] != -1:
name, weight, value = items[pick[c]]
tally[name] = tally.get(name, 0) + 1
c -= weight
for name, weight, value in items:
if name in tally:
counts.append((name, tally[name]))
return dp[capacity], counts
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
cases = [(5, 7), (7, 10), (10, 15), (0, 0)]
for capacity, expected in cases:
best, counts = unbounded(items, capacity)
print("capacity", capacity, "-> best", best, "expected:", expected, "match:", best == expected, "copies:", counts)All four capacities match. The recovered copy counts show exactly which copies earn each value. Capacity 10 is earned by the rope alone, five times over.
Walkthrough: filling the table up to capacity 7
The walk fills one capacity at a time. Each line prints the whole table after that capacity is finished. The index of each entry is its capacity.
The table after each capacity
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
capacity = 7
dp = [0] * (capacity + 1)
for c in range(1, capacity + 1):
for name, weight, value in items:
if weight <= c:
dp[c] = max(dp[c], dp[c - weight] + value)
print("after capacity", c, ":", dp)
print("best at capacity 7:", dp[7])The last cell holds 10. Tracing the picks backward gives two ropes and one lamp. Their weight is exactly 7 and their value is 10.
Complexity
Time is O(capacity times items). Each capacity cell tries every item once.
Space is O(capacity). One row holds the best value for each capacity, and one pick beside it.
Counting the item checks
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
for capacity in [5, 10, 20]:
dp = [0] * (capacity + 1)
checks = 0
for c in range(1, capacity + 1):
for name, weight, value in items:
checks += 1
if weight <= c:
dp[c] = max(dp[c], dp[c - weight] + value)
print("capacity", capacity, "- item checks:", checks, " best:", dp[capacity])The checks grow as a plain product. Doubling the capacity doubles the work. No factor from the copy counts ever appears, because the upward scan creates the copies inside the same cells.
Edge cases and pitfalls
Capacity zero and an empty item list both answer 0. An item heavier than the capacity is never picked. Leftover space is allowed, because the limit is a maximum, not an exact fill.
Edge cases, run
def unbounded(items, capacity):
dp = [0] * (capacity + 1)
pick = [-1] * (capacity + 1)
for c in range(1, capacity + 1):
for idx, (name, weight, value) in enumerate(items):
if weight <= c and dp[c - weight] + value > dp[c]:
dp[c] = dp[c - weight] + value
pick[c] = idx
counts = []
c = capacity
tally = {}
while c > 0 and pick[c] != -1:
name, weight, value = items[pick[c]]
tally[name] = tally.get(name, 0) + 1
c -= weight
for name, weight, value in items:
if name in tally:
counts.append((name, tally[name]))
return dp[capacity], counts
items = [("rope", 2, 3), ("lamp", 3, 4), ("book", 4, 5)]
print("no capacity:", unbounded(items, 0))
print("no items:", unbounded([], 5))
print("item too heavy:", unbounded([("boulder", 10, 100)], 5))
print("leftover space:", unbounded([("lamp", 4, 5)], 6))Scanning the one row downward is the classic mix-up between the two knapsack forms. A downward scan reads cells from before this item's pass, so each item enters only once.
Downward against upward for one item, run
weight = 2
value = 3
down = [0] * 7
for c in range(6, weight - 1, -1):
down[c] = max(down[c], down[c - weight] + value)
print("downward table:", down)
up = [0] * 7
for c in range(weight, 7):
up[c] = max(up[c], up[c - weight] + value)
print("upward table:", up)The downward table stops at value 3 for one rope. The upward table reaches 9 from three ropes. The scan direction alone decides whether copies exist.
Exercise
A one-row knapsack table must allow unlimited copies of each item. Which scan direction is correct?