分割等和子集(0-1 背包 DP 总结)
一、完整代码(精简注释)
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
// 计算数组总和
for (int i = 0; i < n; i++) {
sum += nums[i];
}
// 总和为奇数,无法平分
if (sum % 2 == 1) return false;
sum /= 2; // 目标和为总和的一半
// dp[j]:能否从已遍历数字中凑出和为 j 的子集
vector<bool> dp(sum + 1, false);
dp[0] = true;
// 外层遍历每个数字
for (int i = 0; i < n; i++) {
// 内层倒序遍历目标和,保证每个数字只使用一次
for (int j = sum; j >= nums[i]; j--) {
// 不选当前数字 或 选择当前数字
dp[j] = dp[j] || dp[j - nums[i]];
}
}
// 判断能否凑出总和的一半
return dp[sum];
}
};
二、动态规划五步总结
1. 确定 DP 数组及其含义
vector<bool> dp(sum + 1, false);
dp[j] 表示:从已经遍历过的数字中,能否选出若干个数字,使它们的和恰好等于 j。
-
true:能够凑出目标和j。 -
false:目前无法凑出目标和j。
这里的 j 表示目标和(背包容量),不是 nums 数组的下标。
2. 确定递推公式(核心)
dp[j] = dp[j] || dp[j - nums[i]];
对于当前数字 nums[i],有两种选择:
情况一:不选择当前数字
dp[j]
如果之前已经能够凑出 j,那么不选择当前数字,依然可以凑出 j。
情况二:选择当前数字
dp[j - nums[i]]
如果之前能够凑出 j - nums[i],那么加上当前数字,就能够凑出 j。
例如:
nums[i] = 5;
j = 6;
dp[6] = dp[6] || dp[1];
如果之前 dp[1] = true,说明可以通过 1 + 5 凑出 6,因此 dp[6] = true。
|| 的作用:保留之前的可行方案,同时尝试选择当前数字产生的新方案。
3. DP 数组初始化
vector<bool> dp(sum + 1, false);
dp[0] = true;
-
dp[0] = true:不选择任何数字,就能凑出 0。 -
其他位置初始化为
false:暂时无法凑出对应的目标和。
dp[0] = true 是整个递推的基础。
4. 确定遍历顺序
for (int i = 0; i < n; i++) {
for (int j = sum; j >= nums[i]; j--) {
dp[j] = dp[j] || dp[j - nums[i]];
}
}
外层循环:遍历数字
依次考虑数组中的每个数字。
内层循环:更新 DP 数组
遍历目标和,判断加入当前数字后,能否凑出新的目标和。
这里有两个关键点:
-
j--:倒序遍历,保证每个数字最多使用一次,避免重复选择。 -
j >= nums[i]:只有目标和不小于当前数字,才能选择它,同时避免数组下标越界。
记忆:0-1 背包倒序遍历,完全背包通常正序遍历。
5. 举例推导 DP 数组
nums = {1, 5, 11, 5};
总和为 22,目标和为 11。
| 遍历情况 | 能够凑出的目标和 |
|---|---|
| 初始化 | 0 |
| 遍历 1 | 0、1 |
| 遍历 5 | 0、1、5、6 |
| 遍历 11 | 0、1、5、6、11 |
| 遍历最后一个 5 | 0、1、5、6、10、11 |
最终:
dp[11] = true;
说明可以选出一个和为 11 的子集,剩余元素的和也必然为 11,因此能够分割数组。
三、为什么只需要判断 dpsum?
题目要求将数组分成两个和相等的子集。
假设原数组总和为 \(S\),那么只需要找到一个子集,使其和为:
\ target=\\frac{S}{2} \\
剩余数字的和自然也是 \(S/2\)。
所以最终:
return dp[sum];
只需要判断是否能够凑出目标和,不需要真正构造两个子集。
另外,一旦 dp[sum] 变为 true,就可以提前返回,因为已经找到可行方案。
四、时间与空间复杂度
设数组长度为 \(n\),目标和为 \(S\)。
-
时间复杂度: \(O(nS)\),外层遍历 \(n\) 个数字,内层最多遍历 \(S\) 个目标和。
-
空间复杂度: \(O(S)\),只使用一个一维 DP 数组。
五、易错点总结
| 易错点 | 正确理解 |
|---|---|
忘记 sum /= 2 |
目标是凑出总和的一半 |
外层从 i = 1 开始 |
应从 i = 0 开始,考虑所有数字 |
dp[j] 的含义 |
能否从已遍历数字中凑出和为 j 的子集 |
认为 dp[j] 一直是 false |
它会随着遍历不断更新为 true |
| 内层正序遍历 | 0-1 背包必须倒序,避免重复使用当前数字 |
j > nums[i] |
应使用 >=,允许单独选择当前数字 |
只更新 dp[sum] |
其他 DP 状态也要更新,因为目标状态依赖它们 |
六、最终记忆模板
0-1 背包:判断能否凑出目标和
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int i = 0; i < n; i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j] = dp[j] || dp[j - nums[i]];
}
}
return dp[target];
这道题最重要的四句话:
-
外层遍历数字:依次考虑每个数字。
-
内层遍历目标和:更新加入当前数字后能够凑出的和。
-
DP 保存历史结果:
dp[j]记录能否凑出目标和j。 -
倒序保证只选一次:每个数字最多使用一次,符合 0-1 背包要求。
一句话总结:把分割等和子集问题转换成 0-1 背包问题,通过 DP 判断能否从数组中选出若干个数字,使它们的和恰好等于数组总和的一半。