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 判断能否从数组中选出若干个数字,使它们的和恰好等于数组总和的一半。

相关推荐
Fuyo_111912 小时前
C++ AVL树保姆级详解:从平衡因子到四大旋转机制
java·c++·算法
jktantan13 小时前
每个节点 41 字节:把一棵百万节点树拆进 14 个 typed array
前端·javascript·vue.js·算法·typescript
揽星河..13 小时前
【C语言】猜数字游戏
c语言·算法·游戏
frjc13 小时前
分布式服务拆分实战指南:从底层逻辑到面试话术
微服务·面试·职场和发展
LabVIEW开发14 小时前
CTM-3 红外测温仪 + LabVIEW 温度怎么测?
算法·labview·测试·labview程序
纪念 22914 小时前
C++算法(六)
开发语言·c++·算法
Dumbness15 小时前
UIA 读微信群消息没有 id,怎么判断哪条是新的:后缀对齐在翻历史和撤回时整屏重报
python·算法
taka15 小时前
从 24x 到 141x:我用纯 Python 零依赖项目,扒开了 GPU 加速的性能黑洞
算法
UIU11415 小时前
P16237 [蓝桥杯 2026 省 B] 应急布线
数据结构·c++·算法·蓝桥杯
旖旎夜光15 小时前
LeetCode 315: 算右侧小于当前元素的个数(归并排序) —— 题解
c++·学习·算法·leetcode·排序算法