力扣解题思路:分割数组/背包问题(动态规划)纠错记录

416. 分割等和子集


思路:题目:给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。举个例子:

Input: [1, 5, 11, 5]
Output: true
Explanation: The array can be partitioned as [1, 5, 5] and [11].

显然这就是一个0-1背包问题背包问题的讲解可以参考这里o( ̄▽ ̄)ブ首先我们的目的是确定背包的容量,也就是sum / 2,而且不多不少刚好sum / 2,当然sum也必须是个偶数,否则返回false,每个物品也就是数组里的每个元素,可以放也可以不放,那么我们用二位动态规划数组来记录更新过程,dp[m][n] 考虑是否将第m个数字放入容量为n的背包,那么状态转移方程:
dp[m][n] = dp[m-1][n] || dp[m-1][n-nums[m]]
当然我们还需要对数组进行初始化(这里写一个循环是为了避免nums[0]比c大的这种情况,但测试用例中似乎没有出现这种情况):

for(int i=0; i<=c; i++){
    if(i!=nums[0]){
        dp[0][i] = false;
    }else{
        dp[0][i] = true;
    }
}

当然初始化也可以这样:

        dp[0][0] = true;       
        dp[0][nums[0]] = true;

因为i=0时只有这两种可能,就是放一个nums[0]刚好满了,或者不放nums[0](什么都不妨)也能刚好满(因为背包大小为0)。完整代码如下:

public boolean canPartition(int[] nums) {
    //动态规划,背包问题,从nums中选择一部分数字组合,填满容量为sum/2的背包 
    int n=nums.length;
    if(n == 0){
        return false;
    }
    int sum = 0;
    for(int i=0; i<n; i++){
        sum+=nums[i];
    }
    int c = sum/2; 
    if(sum%2==1){
        return false;
    }
    boolean[][] dp = new  boolean[n][c+1];
    //状态初始化
    for(int i=0; i<=c; i++){
        if(i!=nums[0]){
            dp[0][i] = false;
        }else{
            dp[0][i] = true;
        }
    }
    //状态转移方程:dp[m][n] = dp[m-1][n] || dp[m-1][n-nums[m]]
    for(int i=1; i<n; i++){
        for(int j=0; j<=c; j++){
            if(nums[i]<=j){
                dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]];                    
            }else{
                dp[i][j] = dp[i-1][j];
            }
            
        }
    }
    return dp[n-1][c];       
}

另外我再解释一下为什么这里的数组长度为c+1而不是c,这是因为在遍历时要考虑背包中只剩0或者只剩c的情况,如果数组长度为c则数组下标最大值为c-1而不是c,所以这是不对的。
接下来进行空间优化,由dp[m][n] = dp[m-1][n] || dp[m-1][n-nums[m]]可知m-1就是上一个循环中的m,所以动态规划数组的第一个维度可以去掉,那么更新动态规划数组的更新方式可以改为:

for(int num:nums){
    for(int j=0;j<=W;j++){
        if(num<=j){
                dp[j] = dp[j] || dp[j-num];                    
            }else{
                dp[j] = dp[j];
            }
    }
}

然而这个答案并不对,这里错误原因就是当我们把dp矩阵压缩到一维时我们采用的正向更新,而正确的应该是采用逆向更新。因为dp[j] = dp[j] || dp[j - nums[i]] 可以理解为 dp[j] (新)= dp[j] (旧) || dp[j - nums[i]] (旧),如果采用正序的话 dp[j - nums[i]]会被之前的操作更新为新值。(为什么不可以用新值呢?首先这一题是0-1背包问题,而不是完全背包问题,也就是说每个元素只能使用一次,如果是完全背包问题则每个元素可以使用多次,如518. 零钱兑换 II,也可以通过画表看出,如果采用完全背包的方式,那么所有的格子里都是true,显然不符合题意!!!)

当时我还思考了,如果采用了逆序dp[j]需要用到之前的dp[j - nums[i]] 该怎么办,后来我发现dp[j]是依赖于上一轮的dp[j - nums[i]] ,所以第二层循环的顺序并不会影响结果,只不过因为我们用的一维数组所以会产生覆盖结果的情况,所以只能逆序,而我刚才的错误想法正是受了那些一维动态规划数组更新规则(没有状态压缩,一层循环,不会用到上一层的旧值)的影响,而这一题本身就是二维动态规划,只不过时进行了状态压缩(因为是有两层循环的)。

for (int num : nums) {                 // 0-1 背包一个物品只能用一次
    for (int i = W; i >= num; i--) {   // 从后往前,先计算 dp[i] 再计算 dp[i-num]
        dp[i] = dp[i] || dp[i - num];
    }
}

防止将 dp[i-1][j-num] 覆盖,需要先计算 dp[i][j] 再计算 dp[i][j-num],s所以在程序实现时需要按倒序来循环求解。另外,在初始化的时候只需要初始dp[0] = true即可,这样写也可以:

for(int num:nums){
    for(int j=W;j>=0;j--){
        if(num<=j){
                dp[j] = dp[j] || dp[j-num];                    
            }else{
                dp[j] = dp[j];
            }
    }
}

完整代码如下:

public boolean canPartition(int[] nums) {
    int sum = 0;
    for (int num : nums) {
        sum += num;
    }
    if (sum % 2 != 0) {
        return false;
    }
    int W = sum / 2;
    boolean[] dp = new boolean[W + 1];
    dp[0] = true;
    for (int num : nums) {                 // 0-1 背包一个物品只能用一次
        for (int i = W; i >= num; i--) {   // 从后往前,先计算 dp[i] 再计算 dp[i-num]
            dp[i] = dp[i] || dp[i - num];
        }
    }
    return dp[W];
}

注意,初始化的时候不需要初始化dp[nums[0]] = true(会导致结果错误),因为我们对数组时从nums[0]开始遍历的而不是像上一种解法一样从nums[1]开始的(如果遍历时从nums[1]开始的也可以初始化dp[nums[0]] = true啦o( ̄▽ ̄)o)。
还有一个类似的:

494. 目标和


思路:题目:给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

Input: nums is [1, 1, 1, 1, 1], S is 3.
Output: 5
Explanation:
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
There are 5 ways to assign symbols to make the sum of nums be target 3.

这一题最直观的方法就是DFS也是最简单的方法,代码比较简单就直接给出了:

int count = 0;
public int findTargetSumWays(int[] nums, int S) {
    int len = nums.length;
    dfs(nums,S,0,len);
    return count;
}
public void dfs(int[] nums,int s,int i,int len){
    if(i==len&&s==0){
        count++;
        return;
    }
    if(i==len){
        return;
    }
    int s1 = s - nums[i];
    int s2 = s + nums[i];
    dfs(nums,s1,i+1,len);
    dfs(nums,s2,i+1,len);
}

接下来讨论动态规划方法,可以将这组数看成两部分,P 和 N,其中 P 使用正号,N 使用负号,有以下推导:

sum(P) - sum(N) = target
sum(P) + sum(N) + sum(P) - sum(N) = target + sum(P) + sum(N)
2 * sum(P) = target + sum(nums)

因此只要找到一个子集,令它们都取正号,并且和等于 (target + sum(nums))/2,就证明存在解。

这仍然是一个背包问题,可以采用二维动态数组:

public int findTargetSumWays(int[] nums, int S) {
    int sum = 0;
    for(int i=0;i<nums.length;i++) sum += nums[i];
    if((S + sum)%2 == 1 || sum < S) return 0;
    int w = (S + sum)/2;
    int[][] dp = new int[nums.length+1][w+1];
    dp[0][0] = 1;
    //if(w >= nums[0]) dp[0][nums[0]] = 1;
    for(int i=1;i<=nums.length;i++){
        for(int j=0;j<=w;j++){//不可以写成j=nums[i-1]!!!因为小的j也需要被更新
            if(nums[i-1]<=j){
                dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
            }else{
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[nums.length][w];
}

注意最好这里把动态规划数组长度都设置多1,这样可以避免很多边界问题,最开始就是我设置成了int[nums.length][w+1]导致各种边界问题,这里测试用例中是很多S为0,或者数组元素为0的这种情况,所以w也不可以从1开始更新!!
但是要注意内层循环一定要从0开始!!

当动态数组设置为int[nums.length][w+1]我尝试了各种初始化的方法都没有成功。。。等我想出正确的方法再更新吧。。。

我们可以根据上面的二维动态规划改写成一维动态规划,更新方式为dp[i] = dp[i] + dp[i - num],这里注意内层循环一定要倒着更新噢~因为每个元素只能使用一次!!这里注意区分普通背包和完全背包问题!!

完整代码如下:

public int findTargetSumWays(int[] nums, int S) {
    int sum = 0;
    for (int num : nums) {
        sum += num;
    }
    if (sum < S || (sum + S) % 2 == 1) {
        return 0;
    }
    int W = (sum + S) / 2;
    int[] dp = new int[W + 1];
    dp[0] = 1;
    for (int num : nums) {
        for (int i = W; i >= num; i--) {
            dp[i] = dp[i] + dp[i - num];
        }
    }
    return dp[W];
}

再看一个完全背包问题:

518. 零钱兑换 II

思路:在这里插入图片描述
首先这是个完全背包问题,普通的一维数组可以解决,且内层循环无需逆序

public int change(int amount, int[] coins) {
    int dp[] = new int[amount+1];
    dp[0] = 1;
    for (int coin : coins) {
        for (int j = 1; j <= amount; j++) {
            if (j >= coin) {
                dp[j] = dp[j] + dp[j - coin];
            }
        }
    }
    return dp[amount];
}

然后我想写全两种方法,所以又写了个二维的动态规划:

public int change(int amount, int[] coins) {
    int[][] dp = new int[coins.length+1][amount+1];
    dp[0][0] = 1;
    for(int i=1;i<=coins.length;i++){
        for(int j=0;j<=amount;j++){//一定要从0开始!!!
            if(j>=coins[i-1]){
                dp[i][j] = dp[i-1][j] + dp[i-1][j-coins[i-1]];
            }else{
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[coins.length][amount];
}

注意内层循环一定要从0开始,但是居然还是错了!问题就在于

dp[i][j] = dp[i-1][j] + dp[i-1][j-coins[i-1]];

这句,因为完全背包问题物品可以重复使用,所以这里用dp[i][j-coins[i-1]](当前层)而不是dp[i-1][j-coins[i-1]](上一层),所以我在这里再次强调一遍要分清楚完全背包和普通背包!!

所以正确代码如下:

public int change(int amount, int[] coins) {
    int[][] dp = new int[coins.length+1][amount+1];
    dp[0][0] = 1;
    for(int i=1;i<=coins.length;i++){
        for(int j=0;j<=amount;j++){
            if(j>=coins[i-1]){
                dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]];//完全背包问题物品可以重复使用,所以这里用dp[i][j-coins[i-1]](当前层)而不是dp[i-1][j-coins[i-1]](上一层)
            }else{
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[coins.length][amount];
}

322. 零钱兑换

思路:在这里插入图片描述
类似上面思路,由于我们要求的是最小值,所以数组中的元素要初始化为最大值:

public int coinChange(int[] coins, int amount) {//二维
    int[][] dp = new int[coins.length][amount+1];
    for(int[] d : dp) Arrays.fill(d,Integer.MAX_VALUE);
    dp[0][0] = 0;
    //dp[0][coins[0]] = 1;
    for(int i=1;i<amount+1;i++){
        if(i%coins[0] == 0){
            dp[0][i] = i/coins[0];
        }
    }
    for(int i=1;i<coins.length;i++){
        for(int j=0;j<amount+1;j++){
            if(j>=coins[i] && dp[i][j - coins[i]] != Integer.MAX_VALUE){
                dp[i][j] = Math.min(dp[i][j-coins[i]]+1,dp[i-1][j]);
            }else{
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[coins.length-1][amount] == Integer.MAX_VALUE? -1:dp[coins.length-1][amount];
}

有几点需要注意的:

  1. dp[i][j] = Math.min(dp[i][j-coins[i]]+1,dp[i-1][j])中不可以写成 dp[i-1][j-coins[i]],原因和上一题一样,因为所有物品可以重复使用,是完全背包问题!!
  2. 注意初始化,因为我们在两层循环中是从i=1开始遍历的,并且声明的dp数组的行数为coins.length,所以dp数组的第一行需要我们提前初始化。

如果不想这么复杂的初始化,也可以将数组行数多加一行:

    public int coinChange(int[] coins, int amount) {//二维
        int[][] dp = new int[coins.length+1][amount+1];
        for(int[] d : dp) Arrays.fill(d,Integer.MAX_VALUE);
        dp[0][0] = 0;
        //dp[1][coins[0]] = 1;
        // for(int i=1;i<amount+1;i++){
        //     if(i%coins[0] == 0){
        //         dp[0][i] = i/coins[0];
        //     }
        // }
        for(int i=1;i<=coins.length;i++){
            for(int j=0;j<=amount;j++){
                if(j>=coins[i-1] && dp[i][j - coins[i-1]] != Integer.MAX_VALUE){
                    dp[i][j] = Math.min(dp[i][j-coins[i-1]]+1,dp[i-1][j]);
                }else{
                    dp[i][j] = dp[i-1][j];
                }
            }
        }
        return dp[coins.length][amount] == Integer.MAX_VALUE? -1:dp[coins.length][amount];
    }
  1. 除了初始化的数据外,因为我们所求是最少硬币数,所以需要将其他数设置为最大值。那么这就会出现一个问题,如果dp[i][j - coins[i]] == Integer.MAX_VALUE 则 dp[i][j-coins[i]]+1 会溢出,导致 dp[i][j-coins[i]]+1 才是最小值,这样显然是错的!!所以我们将 j>=coins[i] 判断写成 j>=coins[i] && dp[i][j - coins[i]] != Integer.MAX_VALUE 就可以啦~~

我们还可以进行空间优化写成一维数组,去掉第一维就可以啦:

int[] dp = new int[amount+1];
Arrays.fill(dp,Integer.MAX_VALUE);
dp[0] = 0;
for(int i=0;i<coins.length;i++){
    for(int j=0;j<amount+1;j++){
        if(j-coins[i] >= 0 && dp[j-coins[i]] != Integer.MAX_VALUE){
            dp[j] = Math.min(dp[j],dp[j-coins[i]]+1);
        }
    }
}
return dp[amount] == Integer.MAX_VALUE? -1 : dp[amount];
}

343. 整数拆分


思路:把一根绳子剪成多段,并且使得每段的长度乘积最大。

n = 2
return 1 (2 = 1 + 1)

n = 10
return 36 (10 = 3 + 3 + 4)

可以使用贪心算法,也可以使用动态规划,这里使用动态规划更简单,不用考虑太多的。每一个数都可以拆分成i + j的模式,其中i, j分别可选拆或这不拆,取其中可贡献的最大值即可:

public int integerBreak(int n) {
    int[] dp = new int[n + 1];
    dp[1] = 1;
    for (int i = 2; i <= n; i++)
        for (int j = 1; j < i; j++)
            dp[i] = Math.max(dp[i], Math.max(j * (i - j), dp[j] * (i - j)));
    return dp[n];
}

其中j * (i - j)表示不拆,dp[j] * (i - j)表示拆,当然也可以换种写法,初始化的时候都初始化成不拆的乘积,然后后续只需于dp[j] * (i - j)比较即可:

public int integerBreak(int n) {
    int[] dp = new int[n+1];
    for(int i=0;i<n;i++) dp[i] = i;
    for(int i=2;i<n+1;i++){
        for(int j=1;j<i;j++){
            dp[i] = Math.max(dp[i],dp[i-j]*j);
        }  
    }
    return dp[n];
}

贪心算法(参考这里~~):

尽可能多剪长度为 3 的绳子,并且不允许有长度为 1 的绳子出现。如果出现了,就从已经切好长度为 3 的绳子中拿出一段与长度为 1 的绳子重新组合,把它们切成两段长度为 2 的绳子。证明:当 n >= 5 时,3(n - 3) - n = 2n - 9 > 0,且 2(n - 2) - n = n - 4 > 0。因此在 n >= 5 的情况下,将绳子剪成一段为 2 或者 3,得到的乘积会更大。又因为 3(n - 3) - 2(n - 2) = n - 5 >= 0,所以剪成一段长度为 3 比长度为 2 得到的乘积更大。

public int integerBreak(int n) {
    if (n < 2)
        return 0;
    if (n == 2)
        return 1;
    if (n == 3)
        return 2;
    int timesOf3 = n / 3;
    if (n - timesOf3 * 3 == 1)
        timesOf3--;
    int timesOf2 = (n - timesOf3 * 3) / 2;
    return (int) (Math.pow(3, timesOf3)) * (int) (Math.pow(2, timesOf2));
}

还有杨辉三角这题也是需要倒着更新的:
在这里插入图片描述