理论基础
有 n 件物品和一个最多能背重量为w 的背包,已知第 i
件物品的重量是 weight[i]
,得到的价值是 value[i]
。每件物品只能用一次,求解将哪些物品装入背包里,可以使得物品价值总和最大。
本题太过于经典,以至于第一反应肯定都是动态规划。本题中,每个物品的状态就是“选”或“不选”,回溯算法可以很好地解决这样的组合问题。假设本题中的物品有 n 个,则叶子节点一共应该有 2 n 2^n 2n 个。毫无疑问,指数级别的复杂度肯定会超时。
dp 数组下标的含义:
dp[i][j]
:给定下标为 [0, i]
的物品和重量为 j
的背包,能够获得的最大物品价值总和dp 递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
[0, i]
的物品,可以向前推一步dp[i][j] = dp[i-1][j]
dp[i][j] = dp[i-1][j - weight[i]] + value[i]
dp 数组的初始化:
遍历顺序:使用二维数组时,很显然有两个维度(物品价值和物品重量)
(i, j)
计算需要左上角的矩形区域都先完成计算dp[i][j] = dp[i-1][j]
,否则会报错def test_2_wei_bag_problem1(weight, value, bagweight):
# 二维数组
dp = [[0] * (bagweight + 1) for _ in range(len(weight))]
# 初始化
for j in range(weight[0], bagweight + 1):
dp[0][j] = value[0]
# weight数组的大小就是物品个数
for i in range(1, len(weight)): # 遍历物品
for j in range(bagweight + 1): # 遍历背包容量
if j < weight[i]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
return dp[len(weight) - 1][bagweight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bagweight = 4
result = test_2_wei_bag_problem1(weight, value, bagweight)
print(result)
理论基础
在二维数组中,dp 公式为 dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
,我们认为当前 dp[i, j]
依赖于左上角的那个矩形区域。实际上,dp[i, j]
仅仅依赖于上一层的左侧。如下图,绿色区域的值只取决于黄色区域。
这样对上一层的依赖决定了我们其实没有必要保存整个矩阵,只需要保存一个一维数组即可。
dp[j]
:给定重量为 j
的背包,能够获得的最大物品价值总和dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
,还是有两个选择
dp[j - weight[i]] + value[i]
dp[j]
[i-1]
,这也意味着在进行递推的时候,所使用的值都是没有经历过物品 i 的更新的(也就是上一层考虑过物品 i-1 之后的值)dp[0] = 0
。其余的值会在第一次更新之后被改写。由于涉及到了对当前值的比较中取 max,所以初始化的值不能影响 max 的结果,考虑到题目中的值都是正整数,初始化均为 0 即可。 for i in range(len(weight)): # 遍历物品
for j in range(bagWeight, weight[i] - 1, -1): # 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
dp[j]
之前改变当前的 dp[k], k <= j
的值,只能后序遍历。
dp[j]
就能记录背包容量为 j 时能够获得的一件物品的最大价值,因为初始值总是固定的。def test_1_wei_bag_problem(weight, value, bagWeight):
# 初始化
dp = [0] * (bagWeight + 1)
for i in range(len(weight)): # 遍历物品
for j in range(bagWeight, weight[i] - 1, -1): # 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
return dp[bagWeight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bagweight = 4
result = test_1_wei_bag_problem(weight, value, bagweight)
print(result)
以上总结了两种01背包的解法:二维数组在清楚定义的情况下,更为简单明了,收到的限制也更少,但空间复杂度较高;一维滚动数组思路巧妙,需要特定的遍历顺序(内外层、倒序),但有超低的空间复杂度,代码简洁。
其中的难点(思维点)在于
只有明白不同方法中以上难点的回答,才能真正理解01背包!
题目链接 | 理论基础
本题乍一看是典型的组合题:选取当前集合的子集,满足特定条件(和为一半)即可。经典的组合题自然应该想到回溯,然而回溯是暴力搜索(N叉树),拥有至少指数级的复杂度。当看到本题的数组长度,就该意识到回溯必然会超时。
从选取元素的角度看,每个元素最多只能使用一次,除了组合问题,也很符合01背包的设定。进行抽象后可以得到如下条件:
sum(nums) // 2
nums[i]
dp[i][j]
的含义是,给定元素 nums[:i+1]
,背包容量为 j
时,能否正好装满当前背包
dp[i][j]
是 booleandp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i]]
nums[:i]
可以正好填满重量为 j
的背包,即是 dp[i-1][j]
nums[:i]
可以正好填满重量为 j - nums[i]
的背包,即是 dp[i-1][j-nums[i]]
nums[:i+1]
填满容量为 j
的背包;否则不可能nums
进行了从小到大的排序
or
的结果,所有值都先初始化为 False
nums[0]
,dp[0][nums[0]] = True
j=0
的背包,任何情况都能装满,所以 dp[i][0] = 0
(这对之后取一个元素的情况很重要)[1, 5, 11, 5]
,dp 数组如下idx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
dp = [[T, T, F, F, F, F, F, F, F, F, F, F],
[T, T, F, F, F, T, T, F, F, F, F, F],
[T, T, F, F, F, T, T, F, F, F, T, T],
[T, T, F, F, F, T, T, F, F, F, T, T]]
class Solution:
def canPartition(self, nums: List[int]) -> bool:
nums.sort() # sort in advance makes things easier
if sum(nums) % 2:
return False
target_sum = sum(nums) // 2
# dp[i][j] represents whether nums[:i+1] can exactly make the sum of j
dp = [[False] * (target_sum + 1) for _ in range(len(nums))]
# initialize the dp for nums[0]
if nums[0] <= target_sum:
dp[0][nums[0]] = True
# initialize the dp for sum j=0
for i in range(len(nums)):
dp[i][0] = True
# dp formula
for i in range(1, len(nums)):
for j in range(target_sum + 1):
if j < nums[i]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i]]
return dp[-1][-1]
根据二维 dp 数组压缩成的滚动数组,注意遍历顺序要先物品后重量,同时重量的遍历要后序进行(由于对 nums
进行了排序,所以重量的遍历结束点可以直接确定)。
class Solution:
def canPartition(self, nums: List[int]) -> bool:
nums.sort() # sort in advance makes things easier
if sum(nums) % 2:
return False
target_sum = sum(nums) // 2
# dp[i][j] represents whether nums[:i+1] can exactly make the sum of j
# initialization to be False, so that it does not make effect when operating "or"
dp = [False] * (target_sum + 1)
# initialize the dp for sum j=0
dp[0] = True
# dp formula
for i in range(len(nums)): # traverse nums from left to right
for j in range(target_sum, nums[i]-1, -1): # traverse sum j from right to left
dp[j] = dp[j] or dp[j - nums[i]]
return dp[-1]
以上我的思路(二维 dp 和滚动数组是同一种思路,不同的实现)是“严格要求正好装满重量 j
”的基础上进行的,所以 dp 的状态转移也是通过 boolean 操作,相当于对经典的 01 背包做了一个变种。实际上本题不需要进行这么大的改写。
以下是正常01背包的滚动数组解法。
dp[j]
的含义是,给定容量为 j
的背包,能够得到的最大物品价值dp[j] = max(dp[j], dp[j-nums[i]] + nums[j])
dp[j]
dp[j-nums[i]] + nums[i]
nums
提前排序
dp[0] = 0
class Solution:
def canPartition(self, nums: List[int]) -> bool:
if sum(nums) % 2:
return False
target_sum = sum(nums) // 2
dp = [0] * (target_sum + 1)
for i in range(len(nums)):
for j in range(target_sum, -1, -1):
if j >= nums[i]:
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
return dp[-1] == target_sum