When I first started this blog in 2019, one of my first posts
was about solving the 0-1 knapsack problem via dynamic programming.
In case you need a refresher, my past post contains a succinct description of the problem.
Looking back at that post now, however, I have regrets about some of the technical
aspects I presented. In this post, I attempt to reconcile those regrets by presenting a cleaner
implementation of the dynamic programming method. I also discuss an interesting variant of the
standard 0-1 knapsack problem in which the number of items in the backpack must be even, a
generalization of that variant, and how to solve both.
Standard 0-1 Knapsack
In this post, we consider the “binary” or “0-1” variant of the knapsack problem:
In this formulation we consider n items, which we enumerate from 0 to n−1 to mirror Python’s
0-based indexing. For given ai≥0, b≥0, and ci≥0, the optimal objective value
may be found by computing Z(n−1,b) where
represents the total value that may be garnered from items 0 through i
with j space remaining in the knapsack. Note that Z(i,j) is a recurrence relation.
The first case represents choosing the better option
between putting item i aside and putting item i in the knapsack. The second case represents the
base case in which all items have been considered and no further value may be gleaned. The third
and final case represents the base case in which the space remaining in the knapsack is negative.
Of course, this is indicative of the knapsack having been overfilled (i.e., an infeasible
solution), so the value is accordingly negative infinity.
This scheme may be implemented quite easily in Python. For dynamic programming in general, it is
typically useful to incorporate memoization. In
Python, memoization may be implemented simply using functools.cache (available in Python 3.9+)
as a decorator:
1from functools import cache
We implement the recurrence relation as a nested function inside an outer function that takes as
its arguments the item sizes (as a list), the knapsack capacity (as a scalar), and the item values
(as a list).
1
2
3
4
5
6
7
8
9
10
11
12
defknapsack_obj_only(a, b, c):
@cachedefZ(i, j):
if i >=0and j >=0:
return max(Z(i -1, j), Z(i -1, j - a[i]) + c[i])
elif j <0:
return-float('inf')
else:
return0return Z(len(a) -1, b)
Of course, knowing the optimal objective value is not terribly useful without also knowing an
optimal solution that attains it. Below, we improve our function by incorporating a generator
function X(i,j) that churns out the 0-1 solution based on the relative value of omitting or
retaining an item. We then modify knapsack to return both the optimal objective value and
identified optimal solution:
defknapsack(a, b, c):
@cachedefZ(i, j):
if i >=0and j >=0:
return max(Z(i -1, j), Z(i -1, j - a[i]) + c[i])
elif j <0:
return-float('inf')
else:
return0defX(i, j):
if i >=0and j >=0:
if Z(i -1, j) > Z(i -1, j - a[i]) + c[i]:
yield from [*X(i -1, j), 0]
else:
yield from [*X(i -1, j - a[i]), 1]
Z_opt = Z(len(a) -1, b)
X_opt = X(len(a) -1, b)
return Z_opt, list(X_opt)
Because we memoized the value function Z(i,j), computing X_opt is remarkably inexpensive
given that all pertinent Z(i,j) are already cached from having computed Z_opt. Now let’s give
it a try.
A few years ago, I came across an exercise involving a variant of the
standard 0-1 knapsack problem with an additional stipulation – the number of items put in the
knapsack must be even. Tricky! If you search for the solution to this variant on the Internet,
most answers involve defining a pair of value functions
Here, Zeven(i,j) represents the value that may be garnered from items 0 through i
given a knapsack already holding an even number of items and j capacity remaining. The function
Zodd(i,j) represents the same but for a knapsack already holding an odd number of items.
In each max function, the value of omitting an item requires an evaluation of the same value
function since the number of items already in the knapsack remains the same. The value of putting
an item in the knapsack, however, requires an evaluation of the alternate function since the number
of items changes from even to odd or vice versa.
Aside from there being two value functions, the main difference between this variant and the
standard knapsack problem is that Zodd(i,j)=−∞ if i<0. That is, if there are
an odd number of items in the knapsack and no items left to consider to make the number even, then
the solution is infeasible and the objective value is negative infinity.
Clearly, the best objective value for a knapsack that must contain an even number of items is
Zeven(n−1,b). Though less clear, the best objective value for a knapsack that must
contain an odd number of items is Zodd(n−1,b). Note that between these two value
functions, the only base case with a value of zero (corresponding to a feasible solution) is when
the knapsack contains an even number of items, the knapsack is not overfilled, and there are no
items left to consider. So computing Zodd(n−1,b) is like pretending there is already
an item in the knapsack before considering items 0 through n−1. As such, an odd number of items
0 to n−1 must be placed into the knapsack to make the total items (including the pretend item)
an even quantity.
defknapsack_even_or_odd(a, b, c, even=True):
@cachedefZe(i, j):
if i >=0and j >=0:
return max(Ze(i -1, j), Zo(i -1, j - a[i]) + c[i])
elif j <0:
return-float('inf')
else:
return0@cachedefZo(i, j):
if i >=0and j >=0:
return max(Zo(i -1, j), Ze(i -1, j - a[i]) + c[i])
elif j <0:
return-float('inf')
else:
return-float('inf')
defXe(i, j):
if i >=0and j >=0:
if Ze(i -1, j) > Zo(i -1, j - a[i]) + c[i]:
yield from [*Xe(i -1, j), 0]
else:
yield from [*Xo(i -1, j - a[i]), 1]
defXo(i, j):
if i >=0and j >=0:
if Zo(i -1, j) > Ze(i -1, j - a[i]) + c[i]:
yield from [*Xo(i -1, j), 0]
else:
yield from [*Xe(i -1, j - a[i]), 1]
ifnot isinstance(even, bool):
raiseValueError('keyword argument \'even\' must be True or False')
Z_opt = Ze(len(a) -1, b) if even else Zo(len(a) -1, b)
X_opt = Xe(len(a) -1, b) if even else Xo(len(a) -1, b
return Z_opt, Noneif Z_opt ==-float('inf') else list(X_opt)
Note that xi=0,i=0,…,n−1 is always a feasible “even” solution.
The existence of an “odd” solution, however, is not guaranteed. In such a case, our function
returns None as the solution rather than a list of zeros and ones. Anyway, let’s give our method
a whirl.
We see that the optimal “even” solution is marginally worse than the “odd” solution, and that the
“odd” solution is also the optimal solution to the standard 0-1 knapsack problem.
0-1 Knapsack with Generalized Modulo Stipulation
Consider that requiring the knapsack to contain either an even or odd number of items is equivalent
to requiring, respectively, either
(∑i=0n−1xi)mod2=0
or
(∑i=0n−1xi)mod2=1.
As it turns out, the implementation may be easily modified to accommodate the general condition
(∑i=0n−1xi)modp=q by incorporating a third state variable k to
keep count of the number of items (modulo p) in the knapsack. This gets us back to a single value
function that is more similar to that of the standard 0-1 knapsack problem:
Of course, there are zero items in the knapsack to start, so the optimal objective value to the
formulation above is Z(n−1,b,0). Now let’s implement this variant in Python.
For the given condition, the solution must involve putting 1, 4, 7, 10, or 13 items in the
knapsack. Indeed, we see that the optimal solution places 7 items in the knapsack with a combined
value of 86.
Summary
Dynamic programming is a powerful tool for solving many types of decision problems. In this post,
we showed how dynamic programming may be used to efficiently solve the standard 0-1 knapsack
problem as well as a variant in which the number of items in the knapsack is subject to a modulo
condition. Have you encountered similar variants of the 0-1 knapsack problem? Would you approach
the variants I presented differently? Let me know in the comments!
About Me
My name is
Brent Austgen, PhD
Decision Scientist & Power Systems Analyst Read More