【动态规划】背包问题
背包问题
简介
简单来讲,你有一个背包,它的容量为 \(W\),你同时有若干个物品,它们有各自的体积和价值,将哪些物品装入背包可以使得它们的总体积和不超过背包的容量而总价值和最大?
背包问题的分类:
问题分类 | 区别 |
---|---|
0-1背包 | 每种物品的数量是一件 |
完全背包 | 每种物品的数量是无限件 |
多重背包 | 每种物品的数量是有限件 |
0-1背包问题
题目
P2871 [USACO07DEC]Charm Bracelet S
有 \(N\) 件物品和一个容量为 \(W\) 的背包。第 \(i\) 件物品的重量是 \(w_i\) ,价值是 \(v_i\) 。求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。
用例1:
输入:
W = 10, w =[5, 6, 2, 3, 4], v = [6, 12, 5, 6, 6]
输出:
18
分析
假设 \(dp[i][j]\) 表示对于前 \(i\) 个物品,当前背包容量为 \(j\) 时,背包可以装下的最大价值。
边界条件
显然,当物品数量为零时,不管背包的容量是多少,最大价值都是零,同理,当背包容量为零时,不管有多少个物品,最大价值也都是零。
因此,边界条件:
我们以题目中的用例为例,将初始状态,填入表格中,即:
状态转移方程
由于每种物品只有一件,我们可以选择放或者不放。因此,对于第 \(i\) 个物品,有两种选择:
- 放入背包,对应的价值就是: \(y_1 = dp[i - 1][j - w[i - 1]] + v[i - 1]\) ;
- 不放入背包,对应的价值就是: \(y_2 = dp[i - 1][j]\) 。
注意:第 \(i\) 个物品的价值为 \(v[i - 1]\),体积为 \(w[i - 1]\)。
那么,最大价值,就是这两种状态中选择一个价值最大的方案即可,即:
我们以 \(dp[2][6]\) 为例,状态的转移过程如下:
因此,就有 \(dp[2][6] = \max(dp[1][6], dp[0][0] + v[0]) = \max(0, 0 + 6) = 6\) 。
代码实现
from typing import List
def knapsack(w: int, weight: List[int], value: List[int]):
n = len(weight)
dp = [[0] * (w + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, w + 1):
if j < weight[i - 1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1])
return dp[n][w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6]))
由于背包容积小于当前物品的体积时,能装下的物品价值为零,所以,上述代码也等价于:
def knapsack(w: int, weight: List[int], value: List[int]):
n = len(weight)
dp = [[0] * (w + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, w + 1):
if j >= weight[i - 1]:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1])
return dp[n][w]
复杂度
- 时间复杂度:\(O(W \times N)\)
- 空间复杂度:\(O(W \times N)\)
0-1背包的优化
分析
通过观察,我们可以发现,对于 \(dp[i]\) 有影响的,只有 \(dp[i - 1]\) ,所以,可以直接去掉第一维,直接用 \(dp[i]\) 来表示处理到当前物品时的背包容量为 \(i\) 的最大价值,即
因为,当前状态 \(dp[i][j]\) 会被 \(dp[i][j - w_i]\) 影响,所以,循环的时候,背包的容量 \(j\) 只能逆向枚举(从大到小枚举)。
我们以 \(dp[2][10]\) 为例,状态的转移过程如下:
因此,就有 \(dp[2][10] = \max(dp[1][10], \ dp[0][4] + v[1]) = \max(6, \ 0 + 12) = 12\) ,即:
压缩状态之后,需要将 \(dp[2][10]\) 填到 \(dp[10]\) 的位置,即:
可以看出 \(dp[10]\) 会被 \(dp[4]\) 影响,如果从小到大遍历,\(dp[4]\) 的值在遍历后,就会被刷新为新的值,导致 \(dp[10]\) 的计算有误。
对于当前处理的物品个数为 \(i\),如果从小到大遍历,当背包的容量 \(j\) 大于等于当前物品的体积 \(w_i\) 时,那么,当前状态 \(dp[i][j]\),会被 \(dp[i][j - w_i]\) 影响,相当于一个物品被多次放入背包,与题意不符合。(事实上,这正是完全背包问题的解法)
如果我们对背包的容量 \(j\) 进行逆向枚举,依次减少背包的容量,这样就保证了 \(dp[i][j]\) 总是在 \(dp[i][j - w_i]\) 之前更新。
代码实现
from typing import List
def knapsack(w: int, weight: List[int], value: List[int]):
n = len(weight)
dp = [0] * (w + 1)
for i in range(1, n + 1):
for j in range(w, -1, -1):
if j >= weight[i - 1]:
dp[j] = max(dp[j], dp[j - weight[i - 1]] + value[i - 1])
return dp[w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6]))
复杂度
- 时间复杂度:\(O(W \times N)\)
- 空间复杂度:\(O(N)\)
完全背包问题
题目
有 \(N\) 种物品和一个容量为 \(W\) 的背包。第 \(i\) 种物品的重量是 \(w_i\) ,价值是 \(v_i\) ,每种物品的数量都有无限个。求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。
分析
假设 \(dp[i][j]\) 表示 对于前 \(i\) 个物品,当前背包容量为 \(j\) 时,背包可以装下的最大价值。
边界条件
显然,当物品数量为零时,不管有多少个物品,最大价值都是零,同理,当背包容积为零时,不管背包的体积是多大,最大价值都是零。
因此,边界条件:
状态转移方程
由于每种物品,我们可以选择放入多次或者不放。因此,对于第 \(i\) 种物品:
- 不放入的情况
与前面的 0-1背包 的场景类似,即:\[dp[i][j] = dp[i - 1][j] \] - 放入的情况
我们可以考虑最朴素的做法:对于第 \(i\) 种物品, 枚举其选择了多少个来转移,即:\[dp[i][j] = \max^{+\infty}_{k = 0} (dp[i - 1][j - w_{i-1} \times k] + v_{i-1} \times k), \quad j \ge w_{i-1} \times k \]注意,这里需要保证 \(j \ge w_{i-1} \times k\) ,这种方式的的时间复杂度是 \(O(n^3)\)。
我们换一个思路考虑,由于 \(dp[i][j - w_i]\) 已经由 \(dp[i][j - 2 \times w_i]\) 更新过了。那么 \(dp[i][j - w_i]\) 就是充分考虑了 第 \(i\) 种物品所选次数之后得到的最优结果,所以,状态转移方程可以优化为:\[dp[i][j] = dp[i][j - w[i - 1]] + v[i - 1] \]换言之,我们通过局部最优子结构的性质重复使用了之前的枚举过程,优化了枚举的复杂度。
综上,对于上述两种情况,状态转移方程就可以优化为:
也就是说,由于每种物品有无限个,所以,状态不应该从 \(dp[i - 1][j - w_{i-1}]\) 转移,而应该从 \(dp[i][j - w_{i-1}]\) 转移,即装入第 \(i\) 种物品后,还可以继续再次装入这种物品。
这里,我们通过公式,简单地说明推导过程:
而根据递推公式,可以计算\(dp[i][j - w_{i-1}]\):
可以看出,上述两个等式中,花括号标记的部分,相差 \(v_i\),所以:
我们以 \(dp[2][6]\) 为例,状态转移过程如下:
因此,就有 \(dp[2][6] = \max(dp[1][6], dp[2][6-w[1]] + v[1]) = \max(6, 0 + 12)= 12\) 。
代码实现
from typing import List
def knapsack(w: int, weight: List[int], value: List[int]):
n = len(weight)
dp = [[0] * (w + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, w + 1):
if j < weight[i - 1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i - 1]] + value[i - 1])
return dp[n][w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6]))
复杂度
- 时间复杂度:\(O(W \times N)\)
- 空间复杂度:\(O(W \times N)\)
完全背包的优化
分析
与 0-1背包相同,我们可以将第一维去掉来优化空间复杂度。
注意,去掉第一维之后,对背包容量 \(j\) 的遍历需要正向遍历(从小到大遍历)。
如果理解了 0-1背包 的优化方式,就不难明白压缩后的循环是正向的(也就是上文中提到的错误优化)。
代码实现
from typing import List
def knapsack(w: int, weight: List[int], value: List[int]):
n = len(weight)
dp = [0] * (w + 1)
for i in range(1, n + 1):
for j in range(1, w + 1):
if j >= weight[i - 1]:
dp[j] = max(dp[j], dp[j - weight[i - 1]] + value[i - 1])
return dp[w]
多重背包问题
题目
有 \(N\) 种物品和一个容量为 \(W\) 的背包。第 \(i\) 种物品的重量是 \(w_i\) ,价值是 \(v_i\) ,每种物品的数量有 \(k_i\) 个。求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。
分析
假设 \(dp[i][j]\) 表示 对于前 \(i\) 个物品,当前背包容量为 \(j\) 时,背包可以装下的最大价值。
边界条件
显然,当物品数量为零时,不管有多少个物品,最大价值都是零,同理,当背包容积为零时,不管背包的体积是多大,最大价值都是零。
因此,边界条件:
状态转移方程
由于第 \(i\) 种物品,我们可以选择 \(k_{i-1}\) 次。因此,很朴素的做法就是,将每种物品选择 \(k_{i-1}\) 次等价转换为:有 \(k_{i-1}\) 个物品,每个物品选择一次。这样,就将多重背包问题转换为0-1背包模型,那么,我们就可以得到状态转移方程:
压缩状态
通过压缩状态的方式,对空间复杂度进行优化:
代码实现
from typing import List
def knapsack(w: int, weight: List[int], value: List[int], nums: List[int]):
n = len(weight)
dp = [[0] * (w + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(w, -1, -1):
for k in range(1, nums[i - 1] + 1):
if j >= weight[i - 1] * k:
dp[i][j] = max(
dp[i - 1][j],
dp[i - 1][j - weight[i - 1] * k] + value[i - 1] * k
)
return dp[n][w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6], [1, 2, 2, 2, 2]))
复杂度
- 时间复杂度:\(O(W \times \sum^{N-1}_{i=0} k_i)\)
- 空间复杂度:\(O(W \times N)\)
通过压缩状态,优化空间复杂度的实现:
from typing import List
def knapsack(w: int, weight: List[int], value: List[int], nums: List[int]):
n = len(weight)
dp = [0] * (w + 1)
for i in range(1, n + 1):
for j in range(w, -1, -1):
for k in range(1, nums[i - 1] + 1):
if j >= weight[i - 1] * k:
dp[j] = max(dp[j], dp[j - weight[i - 1] * k] + value[i - 1] * k)
return dp[w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6], [1, 2, 2, 2, 2]))
复杂度
- 时间复杂度:\(O(W \times \sum^{N-1}_{i=0} k_i)\)
- 空间复杂度:\(O(W)\)
多重背包的优化
分析
在多重背包问题中,我们假设 \(A(i,j)\) 表示第 \(i\) 种物品的第 \(j\) 个物品,很明显,对于前面的算法中,对于任意的 \(j \le k_i\),\(A(i,j)\) 均表示相同的物品,导致我们进行了大量的重复计算。
例如,对于第 \(i\) 种物品,选择 \(2\) 个物品的场景,选择 \(A(i, 1)\)、\(A(i, 2)\) 与 选择 \(A(i, 2)\)、\(A(i, 3)\) 这两个方案,就是完全等效的。
我们知道,对于任意的十进制数,都可以转换成二进制形式,即:
多重背包问题,可以通过 二进制分组 的方式进行优化。
例如,假设物品的数量是 \(15\),那么对于 \(15\) 可以写成 \(15 = 2^0 + 2^1 + 2^2 + 2^3\),也就是说,我们可以四个二进制位来表示 \(15\) ,所以,我们可以 将\(15\) 个物品,分成 \(4\) 组:
分组 | 0 | 1 | 2 | 3 |
---|---|---|---|---|
数量 | 1 | 2 | 4 | 8 |
我们将每一组都看成是一个新的物品,那么,多重背包 就可以转换成 0-1 背包 的模型了。
分组的时候,有些数字,并不能刚好分成若干个 \(2\) 的幂的和,那么此时,只需要将剩余的部分单独分成一个组就行了。
例如,\(18 = 2^0 + 2^1 + 2^2 + 2^3 + 3\),我们只需将最后一个 \(3\) 单独成一分组就行了。
代码实现
from typing import List, Tuple
def binary_divide(w: int, v: int, count: int) -> List[Tuple]:
""" 二进制分组
:param w: 物品的体积
:param v: 物品的价值
:param count: 物品的数量
:return: 分组后的结果, (物品的体积之和,物品的价值之和,该组物品的数量)
"""
divide = list()
current = 0
while count:
current = 1 << current
if count >= current:
count -= current
divide.append((w * current, v * current, current))
else:
divide.append((w * count, v * count, count))
break
return divide
def knapsack(w: int, weight: List[int], value: List[int], nums: List[int]):
items = list()
for i in range(len(weight)):
items.extend(binary_divide(weight[i], value[i], nums[i]))
n = len(items)
dp = [[0] * (w + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(w, -1, -1):
if j >= items[i - 1][0]:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - items[i - 1][0]] + items[i - 1][1])
return dp[n][w]
if __name__ == "__main__":
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6], [1, 1, 1, 1, 1]))
print(knapsack(10, [5, 6, 2, 3, 4], [6, 12, 5, 6, 6], [2, 2, 2, 2, 2]))
复杂度
- 时间复杂度:\(O(W \times \sum^{N-1}_{i=0}\log_{2}k_i)\)
- 空间复杂度:\(O(W \times N)\)
进一步对其压缩状态,优化后的的代码实现:
def knapsack(w: int, weight: List[int], value: List[int], nums: List[int]):
items = list()
for i in range(len(weight)):
items.extend(binary_divide(weight[i], value[i], nums[i]))
n = len(items)
dp = [0] * (w + 1)
for i in range(1, n + 1):
for j in range(w, -1, -1):
if j >= items[i - 1][0]:
dp[j] = max(dp[j], dp[j - items[i - 1][0]] + items[i - 1][1])
return dp[w]
复杂度
- 时间复杂度:\(O(W \times \sum^{N-1}_{i=0}\log_{2}k_i)\)
- 空间复杂度:\(O(W)\)
总结
遍历顺序
0-1 背包的代码优化后,内层循环的顺序要从大到小的遍历,为了保证每个物品仅被添加一次。
完全背包的的代码优化后,内层循环的顺序要从小到大遍历,保证物品可以多次添加。
遍历嵌套方式
一般我们求背包所能容纳的最大价值的时候,都是在求物品的组合,而不关注物品的顺序,如果要关注物品的顺序,就需要求排列数。
总结:
如果求组合数就是外层 for 循环遍历物品,内层 for 遍历背包的容积。
如果求排列数就是外层 for 遍历背包的容积,内层 for 循环遍历物品。
参考: