代码随想录第四十三天 | 0-1背包的应用:让石头分成重量相同的两堆 转化 为 416.分割等和子集(1049);排列组合问题(背包/回溯 494);物品重量有两个维度的01背包(474)

1、让石头分成重量相同的两堆 转化 为 416.分割等和子集

1.1 leetcode 1049:最后一块石头的重量II

0-1背包又没有思路,这也没涉及到取得价值最大,而且虽然对一块石头是取与不取,但是主要是关心取的顺序

经过代码随想录提示,其实最终就是要尽量让石头分成重量相同的两堆相撞之后剩下的石头最小,这样就化解成01背包问题了,这两堆的差就是最后留下的最小石头,即按照之前的leetcode 416:分割等和的子集的思路,使尽量靠近sum / 2, 最后使用min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));来计算剩下的最小重量的石头

第一遍代码

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int sum = 0;
        for(int i = 0; i < stones.size(); i++) {
            sum += stones[i];
        }
        vector<int> dp(sum + 1, 0);
        for(int i = 0; i < stones.size(); i++) {
            for(int j = sum; j >= stones[i]; j--) {
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));
    }
};

思路
动规五步曲
1、确定dp数组以及下标的含义
dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包最多可以背 最大重量 为dp[j],可以回忆一下01背包中,dp[j]的含义,容量为j的背包,最多可以装的价值为 dp[j]

相对于 01背包,本题中,石头的重量是 stones[i]石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”

2、确定递推公式
01背包滚动矩阵递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
本题则是:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
根据1的分析很容易得到

3、dp数组如何初始化
既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和
因为提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000,而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了
当然也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小第一遍代码直接整了一个石头总重量+1 的dp数组

接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);dp[j]不会初始值所覆盖

4、确定遍历顺序
如果使用一维dp数组物品遍历的for循环放在外层遍历背包的for循环放在内层,且内层for循环倒序遍历

代码如下:

for (int i = 0; i < stones.size(); i++) { // 遍历物品
    for (int j = target; j >= stones[i]; j--) { // 遍历背包
        dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
    }
}

循环只要从sum / 2开始就行了,不需要从sum开始

5、举例推导dp数组
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
代码随想录第四十三天 | 0-1背包的应用:让石头分成重量相同的两堆 转化 为 416.分割等和子集(1049);排列组合问题(背包/回溯 494);物品重量有两个维度的01背包(474)_第1张图片
最后dp[target]里是容量为target的背包能背的最大重量

那么分成两堆石头一堆石头的总重量是dp[target]另一堆就是sum - dp[target]
在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]
那么相撞之后
剩下的最小石头重量就是 (sum - dp[target]) - dp[target]

个人感觉sum / 2向上取整的也需要考虑,所以第一遍代码最后返回的值是 return min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));

代码随想录C++代码如下:

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        vector<int> dp(15001, 0);
        int sum = 0;
        for (int i = 0; i < stones.size(); i++) sum += stones[i];
        int target = sum / 2;
        for (int i = 0; i < stones.size(); i++) { // 遍历物品
            for (int j = target; j >= stones[i]; j--) { // 遍历背包
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return sum - dp[target] - dp[target];
    }
};

1.2 leetcode 1049:总结

本题其实和 leetcode 416:分割等和子集 几乎是一样的,只是最后对dp[target]的处理方式不同(即对返回值的处理),leetcode 416:分割等和子集 相当于是求背包是否正好装满,而本题求背包最多能装多少

2、排列组合问题

2.1 leetcode 494:代码随想录思路 与 自己动规 / 回溯实现

第一遍代码答案错误
0-1背包,1为加法,0为减法dp[target]结果为target - sum为了包含负数)有几种可能

vector<int> dp(2*sum + 1, 0);

[-sum, sum] 一共可能获得的区间为2*sum - 1
因为下标又有加又有减,所以没办法避免重复

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        //0-1背包,1为加法,0为减法,dp[target]为结果为target - sum(为了包含负数)有几种可能
        int sum = 0;
        for(int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        vector<int> dp(2*sum + 1, 0);//[-sum, sum]一共可能获得的区间为2*sum - 1
        dp[sum - nums[0]] = 1;
        dp[sum + nums[0]] = 1;
        for(int i = 1; i < nums.size(); i++) {
            for(int j = 2*sum; j >= 0; j--) {//因为下标又有加又有减,所以没办法避免重复
                if(j >= nums[i] && j <= 2*sum - nums[i]) {
                    dp[j] = dp[j - nums[i]] + dp[j + nums[i]];
                }
                else if(j >= nums[i]) {
                    dp[j] = dp[j - nums[i]];
                }
                else if(j <= 2*sum - nums[i]) {
                    dp[j] = dp[j + nums[i]];
                }
            }
        }
        return dp[target + sum];
    }
};

代码随想录思路
这道题目咋眼一看和动态规划背包啥的也没啥关系,本题要如何使表达式结果为target

既然为target,那么就一定有 left组合 - right组合 = target,相当于把 前面放加法 的数字加在一起left组合前面放减法 的数字加在一起right组合,而自然left + right = sum,而所有数字的和sum是固定的,right = sum - left

公式来了, left - (sum - left) = target 推导出 left = (target + sum)/2

target是固定的,sum是固定的,left就可以求出来,此时问题就是在集合nums中找出和为left的组合,就和 1049.最后一块石头的重量II 在动态规划部分一致,就是0-1背包问题

还有初始化问题,这个初始化不好想,根据思路写第二遍代码:
不能整除就返回0
target超过了和的绝对值也是不行的
注意这个初始化,比较难想,可以想最开始的j = nums[0]的情况,那只有一个,要让dp[j - nums[i]] = 1才行
递归公式累加dp[j - nums[i]]加上nums[i]可以达到j了,所以是累加

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for(int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        if((target + sum) % 2 != 0) {//不能整除就返回0
            return 0;
        }
        if(abs(target) > sum) return 0;
        //target超过了和的绝对值也是不行的
        int left = (target + sum) / 2;//目标就是找left
        vector<int> dp(left + 1, 0);//都 >= 0
        dp[0] = 1;//注意这个初始化,比较难想,可以想最开始的j = nums[0]的情况,那只有一个,要让dp[j - nums[i]] = 1才行
        for(int i = 0; i < nums.size(); i++) {
            for(int j = left; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            //递归公式是累加,dp[j - nums[i]]加上nums[i]就可以达到j了,所以是累加
            }
        }
        return dp[left];
    }
};

还有对于在集合nums中找出 和为left的组合 的问题还可以使用回溯,根据思路写第三遍代码:
先判断等不等,因为curSum已经更新这个if需要在下一个if之前
回溯一定要先排序,因为有判断curSum + nums[startIndex] > target就退出了

class Solution {
public:
    vector<int> path;
    vector<vector<int>> res;
    void backTracking(vector<int>& nums, int target, int startIndex, int curSum) {
        if(curSum == target) {
            //先判断等不等,因为curSum已经更新,这个if需要在下一个if之前
            res.push_back(path);
        }
        if(startIndex >= nums.size() || curSum + nums[startIndex] > target) {
            return;
        }
        for(int i = startIndex; i < nums.size(); i++) {
            curSum += nums[i];//在这里curSum更新过了
            path.push_back(nums[i]);
            backTracking(nums, target, i + 1, curSum);
            path.pop_back();
            curSum -= nums[i];
        }
    }
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for(int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        if((target + sum) % 2 != 0) {
            return 0;
        }
        if(abs(target) > sum) return 0;
        int left = (target + sum) / 2;
        sort(nums.begin(), nums.end());
        //回溯一定要排序,因为有判断curSum + nums[startIndex] > target就退出了
        backTracking(nums, left, 0, 0);
        return res.size();
    }
};

2.2 leetcode 494:代码随想录的实现

2.2.1 回溯算法

这是回溯中的组合总和问题,当然,也可以转变成序列区间选+ 或者 -,使用回溯法,那就是另一个解法

第三遍代码判断条件的位置上有点区别,思路完全一致

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
        }
        // 如果 sum + candidates[i] > target 就终止遍历
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i + 1);
            sum -= candidates[i];
            path.pop_back();

        }
    }
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];
        if (S > sum) return 0; // 此时没有方案
        if ((S + sum) % 2) return 0; // 此时没有方案,两个int相加的时候要各位小心数值溢出的问题
        int bagSize = (S + sum) / 2; // 转变为组合总和问题,bagsize就是要求的和

        // 以下为回溯法代码
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 需要排序
        backtracking(nums, bagSize, 0, 0);
        return result.size();
    }
};

2.2.2 动态规划

如何转化为01背包问题呢,假设加法的总和为x,那么减法对应的总和就是sum - x
所以我们要求的是 x - (sum - x) = targetx = (target + sum) / 2,此时问题就转化为,装满容量为x(就相当于之前的left,完全一致)的背包有几种方法

这里的x,就是bagSize,也就是我们后面要求的背包容量,大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响,这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:

(C++代码中,输入的S 就是题目描述的 target)
if ((S + sum) % 2 == 1) return 0; // 此时没有方案

同时如果 S的绝对值已经大于sum,那么也是没有方案

(C++代码中,输入的S 就是题目描述的 target)
if (abs(S) > sum) return 0; // 此时没有方案

再回归到01背包问题,为什么是01背包呢?
因为每个物品(题目中的1)只用一次(核心特征)
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包最多能装多少本题则是装满有几种方法。其实这就是一个组合问题

1、确定dp数组以及下标的含义
dp[j] 表示:填满j(包括j)这么大容积的包有dp[j]种方法
其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法
下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来

2、确定递推公式
哪些来源可以推出dp[j] 呢?
只要搞到nums[i]凑成dp[j]有dp[j - nums[i]] 种方法

例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来

所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]

3、dp数组如何初始化
递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0(第二遍代码出现困难的地方,因为递归公式的下标是减,所以需要初始化第0项
这里有录友可能认为从dp数组定义来说 dp[0] 应该是0,也有录友认为dp[0]应该是1
其实不要硬去解释它的含义,咱就把 dp[0]的情况带入本题看看应该等于多少

如果数组[0] ,target = 0,那么 bagSize = (target + sum) / 2 = 0dp[0]也应该是1, 也就是说给数组里的元素 0 前面无论放加法还是减法,都是 1 种方法
所以本题我们应该初始化 dp[0] 为 1
可能有同学想了,那 如果是 数组[0,0,0,0,0] target = 0
其实 此时最终的dp[0] = 32,也就是这五个零 子集的所有组合情况,但此dp[0]非彼dp[0]dp[0]能算出32,其基础是因为dp[0] = 1 累加起来的

dp[j]其他下标对应的数值也应该初始化为0,从递推公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来

4、确定遍历顺序
对于01背包问题一维dp的遍历nums放在外循环target在内循环,且内循环倒序

5、举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
代码随想录第四十三天 | 0-1背包的应用:让石头分成重量相同的两堆 转化 为 416.分割等和子集(1049);排列组合问题(背包/回溯 494);物品重量有两个维度的01背包(474)_第2张图片
代码随想录C++代码如下:

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];
        if (abs(S) > sum) return 0; // 此时没有方案
        if ((S + sum) % 2 == 1) return 0; // 此时没有方案
        int bagSize = (S + sum) / 2;
        vector<int> dp(bagSize + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < nums.size(); i++) {
            for (int j = bagSize; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[bagSize];
    }
};

时间复杂度:O(n × m),n为正数个数,m为背包容量
空间复杂度:O(m),m为背包容量

2.3 leetcode 494:总结

此时 大家应该不禁想起,我们之前讲过的回溯算法:39. 组合总和 是不是应该也可以用dp来做?
是的,如果仅仅是求个数的话,就可以用dp,但回溯算法:39. 组合总和 要求的是把所有组合列出来,还是要使用回溯法爆搜

本题还是有点难度,大家也可以记住,在求装满背包有几种方法的情况下,递推公式一般为:dp[j] += dp[j - nums[i]];

3、物品重量有两个维度的01背包

3.1 leetcode 474:一和零

第一遍代码
使用的动态规划背包存储数组dp[i][j]表示strs最大子集长度初始值为0
其中最多有i个0,j个1循环顺序方面外层每次循环一个元素获取其0,1的数量
然后递归式就把之前的减去其0,1数量的数组元素加一,当然要比大小,判断是不是把那个元素加进去内层循环还是从大到小避免重复

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        /*
        使用的动态规划背包存储数组dp[i][j]表示strs最大子集长度
        其中最多有i个0,j个1,循环顺序方面外层每次循环一个元素,获取其0,1的数量
        然后递归式就把之前的减去其0,1数量的数组元素加一,当然要比大小,判断是不是把那个元素加进去
        */
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
        for(string str : strs) {
            int zNum = 0;
            int oNum = 0;
            for(char c : str) {
                if(c == '0') zNum++;
                else oNum++;
            }
            for(int i = m; i >= zNum; i--) {
                for(int j = n; j >= oNum; j--) {
                    dp[i][j] = max(dp[i][j], dp[i - zNum][j - oNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
};

代码随想录思路
多重背包每个物品数量不同的情况。本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包两个维度的背包
理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包,但本题其实是01背包问题

只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品

动规五部曲
1、确定dp数组(dp table)以及下标的含义
dp[i][j]最多有i个0和j个1strs的最大子集的大小dp[i][j]

2、确定递推公式
dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串zeroNum个0,oneNum个1dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1,然后我们在遍历的过程中,取dp[i][j]的最大值
所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

此时大家可以回想一下01背包递推公式dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])
这就是一个典型的01背包,只不过物品的重量有了两个维度而已

3、dp数组如何初始化
01背包的dp数组初始化为0就可以,因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖

4、确定遍历顺序
01背包一定是外层for循环遍历物品内层for循环遍历背包容量从后向前遍历,本题也是,物品就是strs里的字符串背包容量就是题目描述中的m和n

代码如下:

for (string str : strs) { // 遍历物品
    int oneNum = 0, zeroNum = 0;
    for (char c : str) {
        if (c == '0') zeroNum++;
        else oneNum++;
    }
    for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
        for (int j = n; j >= oneNum; j--) {
            dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
        }
    }
}

那个遍历背包容量两层for循环先后顺序有没有什么讲究?没讲究,都是物品重量的一个维度,先遍历哪个都行

5、举例推导dp数组
以输入:[“10”,“0001”,“111001”,“1”,“0”],m = 3,n = 3为例
最后dp数组的状态如下所示:
代码随想录第四十三天 | 0-1背包的应用:让石头分成重量相同的两堆 转化 为 416.分割等和子集(1049);排列组合问题(背包/回溯 494);物品重量有两个维度的01背包(474)_第3张图片
代码随想录C++代码如下:

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>> dp(m + 1, vector<int> (n + 1, 0)); // 默认初始化0
        for (string str : strs) { // 遍历物品
            int oneNum = 0, zeroNum = 0;
            for (char c : str) {
                if (c == '0') zeroNum++;
                else oneNum++;
            }
            for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
};

时间复杂度: O(kmn),k 为strs的长度
空间复杂度: O(mn)

3.2 leetcode 474:总结

至此讲解了0-1背包的多种应用

纯0-1背包 是求 给定背包容量 装满背包 的最大价值是多少
416. 分割等和子集 是求 给定背包容量能不能装满这个背包
1049. 最后一块石头的重量II 是求 给定背包容量,尽可能装,最多能装多少
494. 目标和 是求 给定背包容量装满背包有多少种方法
本题是求 给定背包容量装满背包最多有多少个物品

你可能感兴趣的:(leetcode,c++,动态规划,算法,c++,leetcode)