leetcode 416分割等和子集

分割等和子集(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];

这道题最重要的四句话:

  1. 外层遍历数字:依次考虑每个数字。

  2. 内层遍历目标和:更新加入当前数字后能够凑出的和。

  3. DP 保存历史结果:dp[j] 记录能否凑出目标和 j。

  4. 倒序保证只选一次:每个数字最多使用一次,符合 0-1 背包要求。

一句话总结:把分割等和子集问题转换成 0-1 背包问题,通过 DP 判断能否从数组中选出若干个数字,使它们的和恰好等于数组总和的一半。

相关推荐
云淡风轻~窗明几净1 小时前
角谷猜想的进展之三 移动了群山/沸騰的群山 2026-10-10
人工智能·算法·dubbo·图论
ForeverYang20151 小时前
一种无需训练缺陷数据生成算法--O2MAG
人工智能·深度学习·算法
Pniubi2 小时前
Agent开发:记忆系统中向量存储的合理安排
职场和发展
xuxigifxfh2 小时前
牛客:HJ170 01序列
java·开发语言·算法·华为机考
朝朝辞暮i2 小时前
C++ 第 33 课:事件循环——spin() 到底在干什么
开发语言·c++·算法
殷色玫瑰2 小时前
AVL树:从平衡因子到四种旋转的完整实现
c语言·数据结构·c++·算法·visualstudio
jidaowansui3 小时前
B3643 图的存储———>数组模拟邻接矩阵
算法
y1su4 小时前
Leetcode 二分模板
java·数据结构·算法·leetcode·排序算法
youshi66684 小时前
平衡车——PID算法优化
stm32·单片机·嵌入式硬件·算法·pid·平衡车