1. 动态规划专题训练概览
今天我们要深入探讨动态规划算法中两个经典问题:01背包问题的理论基础和分割等和子集问题。作为算法训练营第35天的内容,这部分知识是动态规划从入门到精通的必经之路。很多同学在初次接触背包问题时都会感到困惑,其实只要掌握正确的思考方式,这些看似复杂的问题都能迎刃而解。
动态规划的核心在于状态定义和状态转移。在背包问题中,我们需要明确什么是"状态",如何通过前一个状态推导出当前状态。这与我们之前解决的爬楼梯、不同路径等问题有着本质区别——背包问题引入了"物品"和"容量"两个维度,使得状态转移变得更加立体。
2. 01背包问题理论基础
2.1 问题定义与基本思路
01背包问题的经典描述是:给定一组物品,每种物品都有自己的重量和价值。在限定的总重量内,我们如何选择物品,使得物品的总价值最大。这里的"01"意味着每种物品只能选择拿或不拿,不能分割。
举个例子,假设我们有一个容量为4的背包,有以下物品:
- 物品1:重量1,价值15
- 物品2:重量3,价值20
- 物品3:重量4,价值30
我们需要建立一个二维DP数组,其中dp[i][j]表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和的最大值。
2.2 二维DP数组解法
我们先来看最直观的二维数组解法。定义dp[i][j]表示考虑前i件物品,在背包容量为j时的最大价值。状态转移方程需要考虑两种情况:
- 不放入第i件物品:dp[i][j] = dp[i-1][j]
- 放入第i件物品:dp[i][j] = dp[i-1][j-weight[i]] + value[i]
取两者的最大值即可。初始化时,dp[0][j]表示放入第0号物品时的最大价值,需要特殊处理。
python复制def bag_problem(weight, value, bag_size):
rows = len(weight)
cols = bag_size + 1
dp = [[0]*cols for _ in range(rows)]
# 初始化第一行
for j in range(cols):
if j >= weight[0]:
dp[0][j] = value[0]
for i in range(1, rows):
for j in range(1, cols):
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[-1][-1]
2.3 一维DP数组优化
观察二维DP解法可以发现,当前状态只依赖于上一行的状态,因此可以将空间复杂度从O(n×bag_size)优化到O(bag_size)。这就是滚动数组的思想。
一维数组的定义是:dp[j]表示容量为j的背包,所背物品的最大价值。状态转移方程为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
这里有个关键点:内层循环必须倒序遍历!这样才能保证每个物品只被添加一次。
python复制def bag_problem(weight, value, bag_size):
dp = [0]*(bag_size + 1)
for i in range(len(weight)):
for j in range(bag_size, weight[i]-1, -1):
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
return dp[bag_size]
注意:一维DP解法中,必须先遍历物品再遍历背包容量,且背包容量必须倒序遍历,这是保证每个物品只被添加一次的关键。
3. 分割等和子集问题
3.1 问题分析与转化
LeetCode 416题分割等和子集问题描述为:给定一个只包含正整数的非空数组,判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
这个问题可以转化为背包问题:数组的和sum必须为偶数,否则直接返回false。然后问题转化为:能否从数组中选出一些数,使它们的和为sum/2。这就变成了一个容量为sum/2的01背包问题。
3.2 DP解法实现
我们定义dp[j]表示容量为j的背包,所装物品的最大价值(这里价值和重量都是数组元素的值)。如果dp[target] == target,说明可以找到和为target的子集。
python复制def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [0]*(target + 1)
for num in nums:
for j in range(target, num-1, -1):
dp[j] = max(dp[j], dp[j - num] + num)
return dp[target] == target
3.3 优化与边界条件
在实际实现时,可以加入一些优化:
- 如果最大元素大于sum/2,直接返回false
- 可以在遍历过程中提前返回,一旦找到解就终止循环
python复制def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
max_num = max(nums)
if max_num > target:
return False
dp = [False]*(target + 1)
dp[0] = True
for num in nums:
for j in range(target, num-1, -1):
if dp[target]:
return True
dp[j] = dp[j] or dp[j - num]
return dp[target]
4. 常见问题与调试技巧
4.1 初始化陷阱
在背包问题中,初始化非常关键。对于二维DP:
- 第一行需要单独初始化
- 第一列(背包容量为0)应该全部初始化为0
对于一维DP:
- 初始化为0,表示初始时背包中没有物品
- 如果题目要求恰好装满背包,则dp[0]=0,其余初始化为负无穷
4.2 遍历顺序问题
很多同学容易混淆遍历顺序,这里总结规律:
- 二维DP:
- 外层遍历物品,内层遍历背包容量
- 背包容量正序倒序都可以
- 一维DP:
- 外层遍历物品,内层倒序遍历背包容量
- 必须倒序才能保证物品只被添加一次
4.3 调试技巧
当DP结果不符合预期时,可以:
- 打印DP表格,检查每个状态的值
- 检查边界条件是否处理正确
- 验证状态转移方程是否写对
- 对于分割等和子集问题,先确认总和是否为偶数
5. 复杂度分析与变种问题
5.1 时间复杂度分析
对于标准的01背包问题:
- 二维DP:O(n×m),n是物品数量,m是背包容量
- 一维DP:同样为O(n×m),但空间复杂度降为O(m)
分割等和子集问题:
- 时间复杂度O(n×target),target为数组和的一半
- 空间复杂度O(target)
5.2 常见变种问题
- 完全背包问题:每个物品可以取无限次
- 多重背包问题:每个物品有数量限制
- 混合背包问题:包含01背包和完全背包的物品
- 二维费用背包问题:背包有两个维度的限制(如重量和体积)
- 分组背包问题:物品分组,每组只能选一个
6. 实战训练建议
为了真正掌握这些知识,建议:
- 先手动推导几个小例子,画出DP表格
- 实现基础版本后,尝试优化为一维DP
- 在LeetCode上练习相关题目:
-
- 分割等和子集
-
- 最后一块石头的重量 II
-
- 目标和
-
- 一和零
-
我在实际刷题中发现,很多动态规划问题都可以转化为背包问题的变种。关键是要培养将问题抽象为"物品"和"容量"的能力。例如在分割等和子集问题中,我们把数组元素看作物品,sum/2看作背包容量,问题就迎刃而解了。