题目描述
给你一个只包含正整数的非空数组 nums,判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
例如:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1,5,5] 和 [11]
最初思路
这道题一开始可以抽象成一个“选或不选”的问题:
每个 nums[i] 有两种选择:
1. 选它,让目标和减少 nums[i]
2. 不选它,目标和不变
如果数组总和是 sum,想分成两个和相等的子集,本质上就是判断能不能选出一个子集,使它的和等于 sum / 2。
所以最初想到的是 DFS:从下标 0 开始,每个数都分成“选 / 不选”两个分支。
问题出在哪里
第一版 DFS 里有两个容易忽略的点。
第一个是把 res 当成参数传进递归:
dfs(nums, target, 0, res)
但 boolean 是值传递,递归内部即使执行了 res = true,也不会改变 canPartition 里的那个 res。
第二个是边界条件:当 i == nums.length 时,数组已经没有当前数字了。如果这时继续访问 nums[i],就会越界。
修正 DFS 后,还会遇到超时,因为纯 DFS 会反复搜索同一个状态。
例如:
dfs(nums, 10, 8)
可能从很多条不同的选择路径走到这个状态,但它后面的搜索结果其实是一样的。
正确思路
这道题可以看成 0/1 背包问题:
每个数字只能使用一次,判断是否能凑出 target = sum / 2。
如果 sum 是奇数,两个子集不可能平分,直接返回 false。
如果 sum 是偶数,就令:
target = sum / 2
接下来只需要判断能不能从 nums 中选出一些数字,使它们的和刚好等于 target。
重复子问题
如果沿着 DFS 的方向继续优化,可以定义状态:
dfs(i, t)
含义是:从第 i 个数字开始选,还需要凑出和 t,最终能不能成功。
这个状态只和 i 与 t 有关,和前面具体怎么选出来没有关系,所以可以用记忆化避免重复搜索。
进一步改成自底向上的动态规划后,可以直接用一维数组保存状态。
状态定义
定义:
d[j] 表示:在已经处理过的数字中,能不能凑出和 j。
例如:
d[0] = true
表示什么都不选时,可以凑出和 0。
最终答案就是:
d[target]
状态转移
处理当前数字 x 时,对于目标和 j,有两种情况:
1. 不选 x:d[j] 保持原来的值
2. 选 x:如果之前能凑出 j - x,那么现在就能凑出 j
所以状态转移是:
d[j] = d[j] || d[j - x]
其中:
d[j] -> 不选当前 x
d[j - x] -> 选当前 x
初始化与遍历顺序
初始化:
d[0] = true
因为不选任何数时,和为 0 一定可以凑出来。
内层循环必须倒序:
for i from target down to x
原因是这道题每个数字只能用一次。倒序更新可以保证 d[i - x] 还是上一轮数字处理完后的状态,不会把当前这个 x 重复使用。
如果正序遍历,问题会变成类似完全背包。
例如:
target = 2
d[0] = true
当前 x = 1
如果正序更新:
i = 1: d[1] = d[1] || d[0] -> d[1] 变 true
i = 2: d[2] = d[2] || d[1] -> 这里的 d[1] 是刚刚用同一个 x 更新出来的
这样相当于一个 1 在同一轮里被用了两次。
伪代码
下面的伪代码与后面的 Java 实现一一对应:
计算 nums 的总和 sum
如果 sum 是奇数:
返回 false
target = sum / 2
创建布尔数组 d,长度为 target + 1
d[0] = true
对于 nums 中的每个数字 x:
从 target 倒序遍历到 x:
d[i] = d[i] 或 d[i - x]
返回 d[target]
Java 代码
class Solution {
public boolean canPartition(int[] nums) {
int s = 0;
for (int i = 0; i < nums.length; i++) {
s += nums[i];
}
if (s % 2 != 0) {
return false;
}
int t = s / 2;
boolean[] d = new boolean[t + 1];
d[0] = true;
for (int x : nums) {
for (int i = t; i >= x; i--) {
d[i] = d[i] || d[i - x];
}
}
return d[t];
}
}
易错点
1. 递归结果不能靠普通 boolean 参数带回
如果 DFS 中写:
res = true
这只会改变当前递归栈里的局部变量,不会改变外层的 res。
更合适的写法是让 dfs 返回 boolean,由返回值把“是否找到答案”带回上一层。
2. DFS 要先处理数组越界边界
当 i == nums.length 时,说明所有数字都已经处理完了。如果 t 还不是 0,就应该返回失败,不能继续访问 nums[i]。
边界可以这样理解:
t == 0 -> 已经凑出目标,返回 true
t < 0 -> 超过目标,返回 false
i == nums.length -> 数字用完,返回 false
3. 记忆化要区分“没算过”和“算过为 false”
如果用记忆化 DFS,状态是 (i, t)。
boolean[][] 只能表示 true / false,不方便表示“这个状态还没算过”。可以用 Boolean[][] memo,其中:
null -> 没算过
true -> 这个状态能成功
false -> 这个状态不能成功
4. 一维 0/1 背包必须倒序遍历
当前数字 x 只能使用一次,所以更新 d[i] 时,d[i - x] 应该是处理当前数字之前的状态。
倒序遍历可以做到这一点:
for i from target down to x
正序遍历会让当前数字在同一轮里被重复使用。
建议测试用例
nums = [1,5,11,5]
输出:true
nums = [1,2,3,5]
输出:false
nums = [1,1]
输出:true
nums = [1,2,5]
输出:false
复杂度分析
- 时间复杂度:
O(n * target),其中target = sum / 2。 - 空间复杂度:
O(target),使用了一维布尔数组保存可达状态。
总结
这道题的关键是把“能不能分成两个和相等的子集”转成“能不能凑出 sum / 2”。
最开始用 DFS 的“选或不选”方向是对的,但需要注意递归返回值、数组边界和重复状态。
最终的一维动态规划写法中,最重要的是理解:
d[j] = d[j] || d[j - x]
以及为什么内层循环必须倒序。倒序不是为了防止下标越界,而是为了防止同一个数字在同一轮里被重复使用。
2622



被折叠的 条评论
为什么被折叠?



