LeetCode 416.分割等和子集 【超详细解答】

1. 问题描述

题目
这是一道经典的0-1背包问题变种,但是更简单一些。首先我们回顾一下背包问题:

给你N个物品和一个可装载重量为W的背包,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,能装的最大价值是多少?

那么我们这道题如何转换成上述的背包问题呢?

  1. N个数相当于N个物品;
  2. nums[i]相当于第i个物品的重量;
  3. 子集相当于从前i个物品中选取出的物品集合;
  4. 子集中元素之和相当于背包能装的重量。

转换后我们重新描述一下问题:

给你一个能装载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:

  1. dp[0][…]为false,因为没有物品可选,所以背包肯定装不满,无法装满就为false;
  2. dp[…][0]为true,因为背包空间是0,0就是无法再装了,即装满了,为true。

2.3 状态转移的逻辑

根据上面dp数组的定义,通过进行【选择】,可得出以下状态转移:
注意i从1开始,故第i个物品的重量为nums[i-1]

  1. 如果不把 nums[i-1] 放入子集,或者说不把第 i 个物品放入背包中,那么背包能否刚好装满取决于上一个状态 dp[i-1][j],使用上次的结果;
  2. 如果把 nums[i-1] 放入子集,或者说把第 i 个物品放入背包中,那么背包能否装满,取决于状态 dp[i-1][j-nums[i-1]]。可以这么理解,如果背包的剩余容量 j-nums[i-1] 能被装满,那么把第i个物品放进去肯定也能装满,否则则是无法装满的。

2.4 处理边界情况

可以先对一些答案明显不能成立的情况进行处理,减少不必要的运算。(总和的一半记为target = sum/2

  1. 数组元素个数少于2个。(无法分割成两个子集,明显false)
  2. 数组元素总和为奇数。(奇数无法由两个相等的数相加得到,即无法分割成等和子集)
  3. 数组中的最大元素大于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次计算就能得出:

  1. 如果某个子集需要最后一个元素才能构成target,那么另一个子集肯定就是target,而这个子集中的元素肯定都在前n-1个中,结果为true
  2. 如果某个子集(元素都在前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)。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值