LeetCode 416.分割等和子集
1. 问题描述
这是一道经典的0-1背包问题变种,但是更简单一些。首先我们回顾一下背包问题:
给你N个物品和一个可装载重量为W的背包,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,能装的最大价值是多少?
那么我们这道题如何转换成上述的背包问题呢?
- N个数相当于N个物品;
- nums[i]相当于第i个物品的重量;
- 子集相当于从前i个物品中选取出的物品集合;
- 子集中元素之和相当于背包能装的重量。
转换后我们重新描述一下问题:
给你一个能装载
sum/2
重量的背包(sum为所有元素之和),给你N个物品(即原题中的N个元素),问能否从N个物品中选出一些物品(这些物品即为一个子集)使得背包 刚好装满(装满=子集元素和刚好等于某个值) ?
这样一看,是不是就是明显的背包问题?接下来我们来说如何求解。
2. 解法分析
2.1 状态和选择
状态:【背包容量】和【可选的物品】
选择:【装进背包】or【不装进背包】
2.2 dp数组的定义
经典0-1背包问题的dp数组定义是 dp[ i ][ j ],即对于前 i 个物品,放进容量为 j 的背包中,能够得到的最大价值。那么对于这道题目,可以解释为:
从前 i 个数字中,能否选取出一个子集,使得子集中的元素和刚好是 j 。那么dp[ i ][ j ]的取值就只有两种:true或false。
所以我们最终要求的答案就是dp[N][sum/2]
。接下来确定一下base case:
- dp[0][…]为false,因为没有物品可选,所以背包肯定装不满,无法装满就为false;
- dp[…][0]为true,因为背包空间是0,0就是无法再装了,即装满了,为true。
2.3 状态转移的逻辑
根据上面dp数组的定义,通过进行【选择】,可得出以下状态转移:
注意i从1开始,故第i个物品的重量为nums[i-1]
- 如果不把 nums[i-1] 放入子集,或者说不把第 i 个物品放入背包中,那么背包能否刚好装满取决于上一个状态 dp[i-1][j],使用上次的结果;
- 如果把 nums[i-1] 放入子集,或者说把第 i 个物品放入背包中,那么背包能否装满,取决于状态 dp[i-1][j-nums[i-1]]。可以这么理解,如果背包的剩余容量 j-nums[i-1] 能被装满,那么把第i个物品放进去肯定也能装满,否则则是无法装满的。
2.4 处理边界情况
可以先对一些答案明显不能成立的情况进行处理,减少不必要的运算。(总和的一半记为target = sum/2
)
- 数组元素个数少于2个。(无法分割成两个子集,明显false)
- 数组元素总和为奇数。(奇数无法由两个相等的数相加得到,即无法分割成等和子集)
- 数组中的最大元素大于target,那么肯定无法得到两个 元素总和都为target的子集。
2.5 初步代码
public boolean canPartition(int[] nums) {
int n=nums.length;
if(n<2)
return false;
int sum=0,maxNum=Integer.MIN_VALUE;
for(int i:nums){
sum+=i;
maxNum=Math.max(maxNum,i);
}
if(sum%2!=0)
return false;
int target=sum/2;
if(maxNum>target)
return false;
boolean dp[][]=new boolean[n+1][target+1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= target; j++) {
if (j - nums[i - 1] < 0) {
// 背包容量不足,无法装入第 i 个物品
dp[i][j] = dp[i - 1][j];
} else {
// 不装入或装入背包
dp[i][j] = dp[i - 1][j] || dp[i - 1][j-nums[i-1]];
}
}
}
return dp[n][target];
}
3. 状态压缩/代码优化
仔细观察上面的代码,是否可以发现,dp[i][j]都是通过上一行的状态dp[i-1][…]转移而来的,上一行的状态只会被使用一次。因此我们可以进行状态压缩,使用一维数组来保存状态即可,降低空间复杂度。压缩之前空间复杂度为O(N*target),压缩后为O(target)。
还需注意的一点是 j 应该从后往前遍历,因为如果从前往后遍历的话,可能会把上一行的状态更新到,影响了这一行的状态转移。也就是说,当这一行的状态转移还未完成,需要使用到的上一行的状态就已经被改变,影响到最终结果。
如果还无法理解的话,可记 j-num 为 j’,当进行到 dp[j’] 时,dp[j’] 被更新;而进行到 dp[j] 时,dp[j] = dp[j] | dp[j’],而dp[j’]已不是上一行的状态了,所以会造成错误的结果。
boolean dp[]=new boolean[target+1];
dp[0]=true;
for(int i=0;i<n;i++){
int num=nums[i];
for(int j=target;j>=num;j--){
dp[j]=dp[j]||dp[j-num];
}
}
除此之外,还可以再进一步优化,最外层循环可以少走一遍,循环n-1遍即可。为什么呢?
如果最后一个元素等于target,那么前面n-1个元素和肯定也是target;如果最后一个元素小于target,那么最终结果在前面n-1次计算就能得出:
- 如果某个子集需要最后一个元素才能构成target,那么另一个子集肯定就是target,而这个子集中的元素肯定都在前n-1个中,结果为true
- 如果某个子集(元素都在前n-1个中)无法构成target,那么另一个子集肯定也构造不了target,结果为false。
最终代码如下:
public boolean canPartition(int[] nums) {
int n=nums.length;
if(n<2)
return false;
int sum=0,maxNum=Integer.MIN_VALUE;
for(int i:nums){
sum+=i;
maxNum=Math.max(maxNum,i);
}
if(sum%2!=0)
return false;
int target=sum/2;
if(maxNum>target)
return false;
boolean dp[]=new boolean[target+1];
dp[0]=true;
for(int i=0;i<n-1;i++){
int num=nums[i];
for(int j=target;j>=num;j--){
dp[j]=dp[j]||dp[j-num];
}
}
return dp[target];
}
到此问题就解决啦,时间复杂度为O(n*target),空间复杂度为O(target)。