题目描述
给你一个只包含正整数 的非空数组 nums。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:
- 输入:
nums = [1,5,11,5]→ 输出:true(数组可以分割成[1, 5, 5]和[11]) - 输入:
nums = [1,2,3,5]→ 输出:false(数组不能分割成两个元素和相等的子集)
解题思路总览
| 方法 | 思路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 动态规划(0-1背包) | 转化为是否能从数组中选取若干元素使其和等于 sum/2 | O(n × sum/2) | O(sum/2) |
| DFS + 剪枝 | 从数组中尝试选取元素,看能否凑到目标值 | O(2^n) | O(n) |
| 位运算优化 | 用 bitset 优化空间,适合 sum 较小的情况 | O(n × sum/32) | O(sum/32) |
本题采用**动态规划(0-1背包)**方法。
核心思路转化
将原问题转化为:是否可以从数组中选取若干元素,使得它们的和等于 sum/2?
- 如果能找到一个子集的和为 sum/2,那么剩下的元素和也是 sum/2,问题得解
- 这就是一个「0-1背包」问题:每个数只能用一次,问能否装满容量为 sum/2 的背包
完整代码
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (sum % 2 == 1) return false;
vector<int> dp(10001, 0);
for (int i = 0; i < nums.size(); i++) {
for (int j = sum / 2; j >= nums[i]; j--) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
if (dp[sum / 2] == sum / 2) return true;
return false;
}
};
算法流程图
输入: nums = [1, 5, 11, 5]
计算总和:
sum = 1 + 5 + 11 + 5 = 22
sum % 2 == 0? 是
target = sum / 2 = 11
初始化:
dp[0...11] = 0
容量为 11 的背包
遍历数组:
i = 0, nums[0] = 1:
j = 11: j >= 1, dp[11] = max(dp[11], dp[10]+1) = 1
j = 10: j >= 1, dp[10] = max(dp[10], dp[9]+1) = 1
...
j = 1: j >= 1, dp[1] = max(dp[1], dp[0]+1) = 1
dp[1] = 1
i = 1, nums[1] = 5:
j = 11: j >= 5, dp[11] = max(dp[11], dp[6]+5) = 6
j = 10: j >= 5, dp[10] = max(dp[10], dp[5]+5) = 6
...
j = 5: j >= 5, dp[5] = max(dp[5], dp[0]+5) = 5
dp[5] = 5
i = 2, nums[2] = 11:
j = 11: j >= 11, dp[11] = max(dp[11], dp[0]+11) = 11
dp[11] = 11
i = 3, nums[3] = 5:
j = 11: j >= 5, dp[11] = max(dp[11], dp[6]+5) = 11 (不更新)
...
最终 dp[11] = 11
dp[11] == target == 11? 是
返回 true
逐行解析
cpp
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
含义: 计算数组所有元素的总和。
cpp
if (sum % 2 == 1) return false;
含义: 如果总和是奇数,无法平分成两个和相等的子集,直接返回 false。
cpp
vector<int> dp(10001, 0);
含义: 创建背包容量数组。dp[j] 表示容量为 j 的背包最多能装多少重量的物品(元素和)。这里 dp 大小设为 10001 是因为 sum <= 200 × 100 = 20000,所以 sum/2 <= 10000。
cpp
for (int i = 0; i < nums.size(); i++)
含义: 遍历数组中的每个元素(物品)。
cpp
for (int j = sum / 2; j >= nums[i]; j--)
含义: 0-1 背包的关键:内层循环倒序遍历背包容量。这样可以保证每个元素只被使用一次(正序会导致完全背包问题)。
cpp
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
含义: 状态转移方程。对于当前元素 nums[i],要么不放入背包(保持 dp[j]),要么放入背包(dp[j - nums[i]] + nums[i])。取较大值更新。
cpp
if (dp[sum / 2] == sum / 2) return true;
含义: 遍历结束后,如果 dp[sum/2] 恰好等于 sum/2,说明可以找到一个子集的和为 sum/2,返回 true。
cpp
return false;
含义: 无法找到和为 sum/2 的子集,返回 false。
为什么内层循环要倒序?
以 nums = [1, 5] 为例,target = 3
正序(错误):
i=0, num=1: dp[1] = max(dp[1], dp[0]+1) = 1
dp[2] = max(dp[2], dp[1]+1) = 2 <- 用到了本轮刚更新的 dp[1]
dp[3] = max(dp[3], dp[2]+1) = 3 <- 用到了本轮刚更新的 dp[2]
结果:num=1 被使用了多次!变成完全背包了
倒序(正确):
i=0, num=1: dp[3] = max(dp[3], dp[2]+1) dp[2] 还是旧值
dp[2] = max(dp[2], dp[1]+1) dp[1] 还是旧值
dp[1] = max(dp[1], dp[0]+1) = 1
结果:num=1 只被使用一次
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n × sum/2) | 外层循环 n 次,内层循环 sum/2 次 |
| 空间复杂度 | O(sum/2) | dp 数组大小为 sum/2 + 1 |
面试追问 FAQ
| 问题 | 答案 |
|---|---|
| 为什么能转化成 0-1 背包问题? | 如果能找到和为 sum/2 的子集,剩下的元素和也是 sum/2,所以问题等价于「能否从数组中选若干元素使其和为 sum/2」 |
dp[j] 的含义是什么? |
容量为 j 的背包最多能装多少重量的物品(即最多能达到多大的和) |
为什么不直接用布尔数组 dp[j] 表示能否凑到 j? |
因为 dp[j] 表示「最多」能装多少,可以用来判断是否恰好装满;布尔数组需要额外记录具体方案 |
dp[sum/2] == sum/2 为什么能判断「恰好装满」? |
dp[sum/2] 是背包最多能装到的重量,如果它等于 sum/2 说明可以恰好装满 |
| 进阶:如何输出具体的分割方案? | 额外记录每个状态的选择路径,从 dp[sum/2] 开始回溯,找出被选中的元素 |
| 进阶:如何优化空间? | 使用 bitset:`bitset<10001> bits; bits[0] = 1; bits |
相关题目
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 416 | 分割等和子集 | 中等 | 0-1 背包 |
| 322 | 零钱兑换 | 中等 | 完全背包 |
| 279 | 完全平方数 | 中等 | 动态规划 |
| 494 | 目标和 | 中等 | 0-1 背包(计数) |
| 1049 | 最后一块石头的重量 II | 中等 | 0-1 背包 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 将分割等和子集转化为 0-1 背包:能否从数组中选若干元素使其和为 sum/2 |
| 状态定义 | dp[j] = 容量为 j 的背包最多能装的重量(元素和) |
| 状态转移 | dp[j] = max(dp[j], dp[j-nums[i]] + nums[i]) |
| 关键点 | 内层循环倒序,保证每个元素只使用一次 |
| 边界条件 | sum 为奇数时直接返回 false |
| 结果判断 | dp[sum/2] == sum/2 表示恰好装满 |