背包问题
问题定义:背包问题
输入:物品价值;物品大小;背包容量(所有的值均为正整数)。
输出:一个物品子集 ,具有最大的价值之和,但必须满足总大小 不超过 。
考虑下面这个背包问题的实例,背包的容量C= 6并且有4件物品
| 物品 | 价值 | 大小 |
|---|---|---|
| 1 | 3 | 4 |
| 2 | 2 | 3 |
| 3 | 4 | 2 |
| 4 | 4 | 3 |
最优解决方案的总价值是多少呢?
为了在背包问题中应用动态规划,我们必须推断出正确的子问题集合。
为了实现这个目标,我们需要推断出最优解决方案的结构,并确认从更小子问题的最优解决方案构造更大子问题解决方案的不同方式。
这种操作的另一个成果是一个推导公式,它可以从两个更小子问题的解决方案中计算出一个更大子问题的解决方案。
这个最优解决方案看上去应该是怎么样的呢? 我们可以从一个思路出发: 要么包含了最后一件物品(物品 ),要么不包含它。
背包问题的最优子结构
设是具有件物品、物品价值、物品大小、背包容量的背包问题的最优解决方案。 则必为下面两者之一:
(a)由前件物品组成的背包容量为的子问题的最优解决方案。
(b)由前件物品组成的背包容量为的子问题的最优解决方案再加上最后一件物品。
(a)的解决方案总是最优解决方案的选项之一。(b)的解决方案当且仅当时才是选项之一。
在这种情况下,可以有效地预先为物品保留个单位的容量。具有更大总价值的那个选项就是最优解决方案,从而形成下面的推导公式。
背包问题的推导公式
根据背包问题的最优子结构的假设和说明,设表示总大小不超过的前件物品所组成的子集的最大总价值 (当时,可以看成)。对于每个和:
由于和物品的大小都是整数,因此第二个表达式中的剩余容量也是整数。
下一个步骤是定义相关子问题的集合,并使用背包问题的推导公式系统地解决这些子问题。至于现在,我们把注意力集中在计算每个子问题的最优解决方案的总价值上。
对于背包问题,子问题应该由两个索引进行参数化:前几个物品的长度和可用的背包容量。对两个参数所有相关的值均加以考虑,我们就可以得到子问题。
背包问题的子问题
计算前个物品和背包容量为的最优背包解决方案的总价值。
最大子问题就与原问题相同。由于所有物品的大小和背包容量都是正整数,并且由于容量总是会减去某个物品的大小(为它保留空间),因此剩下的容量只可能在。
明确了子问题和推导公式之后,我们立即就能想到背包问题的一种动态规划算法。
算法:背包问题
输入:物品价值,物品大小和背包容量(均为正整数)。
输出:具有最大总价值的子集 ,满足总大小。
// 子问题的解决方案(索引从0开始)
二维数组
// 基本情况()
for c = 0 to C do
//系统性地解决所有的子问题
for i = 1 to n do
for c = 0 to C do
// 使用背包问题的推导公式
if > c then
else
return
//最大子问题的解决方案
def knapsack(weights, values, capacity):
assert len(weights) == len(values), "物品价值数组长度必须等于物品大小数组"
n = len(weights)
A = [[0 for _ in range(capacity+1)] for _ in range(n+1)]
for c in range(capacity+1):
A[0][c] = 0
for i in range(1, n+1):
for c in range(0, capacity+1):
if weights[i-1] > c:
A[i][c] = A[i-1][c]
else:
A[i][c] = max(A[i-1][c], A[i-1][c-weights[i-1]] + values[i-1])
return A[n][capacity]
现在,数组A是一个二维数组,反映了对子问题进行参数化时所使用的索引和。
当双重for循环的一次迭代必须计算子问题解决方案时,
两个相关的更小子问题的值和在外层循环之前的一次迭代时(或作为基本情况)已经计算出来了。
我们可以得出结论,这个算法花费的时间解决个子问题中的每一个,因此总体运行时间是。
示例
考虑下面这个背包问题的实例,背包的容量C= 6并且有4件物品
| 物品 | 价值 | 大小 |
|---|---|---|
| 1 | 3 | 4 |
| 2 | 2 | 3 |
| 3 | 4 | 2 |
| 4 | 4 | 3 |
最优解决方案的总价值是多少呢?
由于且,因此背包算法中的数组可以用一个列(对应于)行(对应于)的表格形象地说明。 最终的数组值如图所示。
背包算法(按照从左到右的顺序)可以计算这些项。在同一列中,背包算法则是按照从下到上的顺序进行计算。
为了填充第列的某一项,算法把它左边紧邻的那个项(对应于情况1)和“与左侧向下行的那一项之和”进行比较,
并取两者中较大的那个。
对于而言,更好的选择是跳过后者直接选择左边紧邻的“”。
但对于而言,更好的选择是包含物品,也就是选择()加上(左侧向下行的那一项,即)。
# 示例
weights = [4, 3, 2, 3] # 物品重量
values = [3, 2, 4, 4] # 物品价值
capacity = 6 # 背包容量
max_value = knapsack(weights, values, capacity)
print(f"最大价值为: {max_value}") # 输出: 最大价值为: 8
最大价值为: 8
重建
背包算法只计算最优解决方案的总价值,并不产生最优解决方案本身。
我们可以通过回溯填充数组A的过程来重新构建一个最优解决方案。
这个重建算法以右上角的最大子问题为起点,确认使用推导公式的某种情况来计算。
如果是第一种情况,算法就忽略物品,并从这一项继续重建过程。
如果是第二种情况,算法就在它的解决方案中包含物品,并从这一项继续重建过程。
背包问题的重建算法
输入:背包算法为物品价值是,物品大小是和背包容量是的背包问题所计算产生的数组。
输出:一个最优的背包问题解决方案。
// 最优解决方案中的物品
// 剩余的容量
for i = n downto 1 do
if and then
// 第一种情况获胜,包含
// 为它保留空间
// else跳过i,容量保持不变
return
def knapsack_with_items(weights, values, capacity):
assert len(weights) == len(values), "物品价值数组长度必须等于物品大小数组"
n = len(weights)
A = [[0 for _ in range(capacity+1)] for _ in range(n+1)]
for c in range(capacity+1):
A[0][c] = 0
for i in range(1, n+1):
for c in range(0, capacity+1):
if weights[i-1] > c:
A[i][c] = A[i-1][c]
else:
A[i][c] = max(A[i-1][c], A[i-1][c-weights[i-1]] + values[i-1])
# 回溯找出选择的物品
S = []
c = capacity
for i in range(n, 0, -1):
if weights[i-1] <= c and A[i-1][c-weights[i-1]] + values[i-1] > A[i-1][c]:
S.append(i) # 添加物品索引,这里是从1开始计算的索引
c -= weights[i-1]
return A[n][capacity], S
对上图所示的数组进行回溯产生最优解决方案{3,4},如图所示。
# 示例
weights = [4, 3, 2, 3] # 物品重量
values = [3, 2, 4, 4] # 物品价值
capacity = 6 # 背包容量
max_value, selected_items = knapsack_with_items(weights, values, capacity)
print(f"最大价值为: {max_value}") # 输出: 最大价值为: 8
print(f"选择的物品索引: {selected_items}") # 输出: 选择的物品索引: [4, 3]
最大价值为: 8
选择的物品索引: [4, 3]
参考文档
《算法详解(卷3)——贪心算法和动态规划》:4.5 背包问题