总的思路是这样的(如下图),我们今天来解决第二个问题中的第一个问题——背包(其实之前有出过相关专题,但是不是说,书读百遍其意自现嘛)我觉得我自己是每次学习一遍都会有新的体会(比如期末复习时这种感觉尤为强烈,我觉得总有些新的领悟,正是这些平常没有get到的领悟帮助我在期末考试中取得较好成绩!)废话不多说,我们从背包开始!
背包问题我们将从如下几个方面切入,多的用不到嗷!(之前为了求大而全反而把自己搅糊涂了,其实用的时候往往是一些小而精的点)
01背包
理论基础
重点是01背包,以及如何将问题转化为01背包
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
此处两个for循环的顺序是可以颠倒的,因为我们所要的结果在右下角,都是由左方和上方的结果推导而来,所以先遍历物品或是先遍历背包已经无所谓。
悟了悟了,接下来看看怎么优化。
滚动数组
为什么可以将二维变成一维?
有个因素叫 “时间” ,如果空间维度没有压缩空间,就要考虑其他维度。
如何利用时间来压缩空间呢?
细看上面那个二维数组,我们可以发现,每一行其实是考虑了同列上一行的数值(就是自己正上方的那个数字,也即没有放自己这个物品)和放自己这个物品,哪一个价值更大来填的空。
我们实际上是利用了之前填过的结果,也就是说,将第(i-1)行复制到第 i 行,然后再用更大的新值覆盖。
如果我们还是按照物品顺序来遍历背包重量,并且从后往前更新,那么更新时,我们可以拿到之前的结果,因为这一行前面的结果还是第(i - 1)个物品时填的旧值。
什么 “把上一行复制到下一行” 也好,什么 “只与上一行相关” 也罢。我们都是利用了上一行暂存了历史的结果,如果我们可以只用一行,同样也可以做到暂存历史结果,那么就可以只用一行。倒序就机智地解决了这个问题!
我在想这个问题的隐喻:
想要获得某些方面的进步,就必须对现有的解决方式做出改变(eg:逆序)。紧紧围绕我们需要的是什么(eg:历史数据)来采取改变。先将其保留,为我所用。那么就只有先不动它。那么就需要逆序。
怎样才能获得历史的结果?只有站在现在。怎样获得现在的结果?只有站在未来。
vector<int> dp(bagweight+1, 0);
for(int i = 0; i < weight.size(); i++){
for(int j = bagweight; j >= weight[i]; j--){
dp[j] = max(dp[j], dp[j-weight[i]] + value[i]);
}
}
return dp[bagweight];
此处的for循环必须先遍历物品,dp[j]的含义是装满 j 的背包的最大价值;如果先遍历背包,每次只取一个物品,就没有意义了,我们本来就是拿不同的物品去试。
分割等和子集
这个题我做过的,顺手就凭着直觉写了。反正每种物品只能选一次,且有选和不选两种选项,有一个目标重量,这不就是01背包嘛!
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int i = 0; i < nums.size(); i++) sum += nums[i];
if(sum & 1) return false;
else{
int tar = sum / 2;
vector<int> dp(tar+1, 0);
for(int i = 0; i < nums.size(); i++){
for(int j = tar; j >= nums[i]; j--){
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
if(dp[tar] == tar) return true;
else return false;
}
}
};
最后一块石头的重量
被推导过程误导了,这个题实际上就是上一题,只不过最后对 dp[tar] 的处理稍有变化。
说得天花乱坠,但还是要把握本质。
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int s = stones.size();
if(s == 1) return stones[0];
else if(s == 2){
if(stones[0] == stones[1]) return 0;
else return stones[0] < stones[1] ? stones[0] : stones[1];
}
else{
int sum = 0;
for(int i = 0; i < s; i++) sum += stones[i];
int tar = sum / 2;
vector<int> dp(tar+1, 0);
for(int i = 0; i < s; i++){
for(int j = tar; j >= stones[i]; j--){
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[tar];
}
}
};
目标和
这题问方法数,不是问最大价值,人傻了,方寸大乱。
怀疑过要不要重新定义dp数组,但是不敢妄加定论,解法就是需要重新定义dp数组!
后面那个goal就是我自己想出来的,用 (sum - target) / 2的结果。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int s = nums.size();
int sum = 0;
for(int i = 0; i < s; i++) sum += nums[i];
if(sum < target || (sum - target)&1) return 0;
int goal = (sum - target) / 2;
vector<int> dp(goal+1, 0);
dp[0] = 1;
for(int i = 0; i < s; i++){
for(int j = goal; j >= nums[i]; j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[goal];
}
};
一和零
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int s = strs.size();
int num[s][2];
for(int i = 0; i < s; i++){
int num1 = 0, num0 = 0;
for(int j = 0; j < strs[i].size(); j++){
if(strs[i][j] == '1') num1++;
else num0++;
}
num[i][0] = num0, num[i][1] = num1;
}
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for(int i = 0; i < s; i++){
for(int j = m; j >= num[i][0]; j--){
for(int k = n; k >= num[i][1]; k--){
dp[j][k] = max(dp[j][k], dp[j-num[i][0]][k-num[i][1]] + 1);
}
}
}
return dp[m][n];
}
};
我想经过了01背包,大家对从二维数组到一维滚动数组的推导过程、如何将一堆数尽量均衡地分成两堆、如何找到组合成某个数字的方法数、二维物品重量等题目有了充分的认识。其中最令我印象深刻的还是如何找到组合成某数的方法数,踌躇了好久要不要换dp数组,但是不换根本死路一条啊,换了还有一线生机,足见我的变通还是不够,有些害怕变化,这是不行的。
继续继续!
完全背包
理论基础
01背包是为了避免同一个物品添加多次,才采用的从后往前遍历,此处我们就是拥有无限多的物品,所以不再care顺序。(顺序即可)
纯的完全背包可以交换内外循环的顺序。
零钱兑换Ⅱ
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount+1, 0);
dp[0] = 1;
for(int i = 0; i < coins.size(); i++){
for(int j = coins[i]; j <= amount; j++){
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
求组合数:外层遍历物品
求排列数:外层遍历背包
组合总和Ⅳ
说来就来,“顺序不同被视作不同组合”,这不就是求排列数嘛!
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target+1, 0);
dp[0] = 1;
for(int j = 0; j <= target; j++){
for(int i = 0; i < nums.size(); i++){
if(j >= nums[i] && dp[j] < INT_MAX - dp[j-nums[i]]) dp[j] += dp[j-nums[i]];
}
}
return dp[target];
}
};
下面对组合和排列进行了总结:
可见外层循环很重要。如果固定物品,则是组合(无重复);如果是遍历背包,则是排序(有重复)
再一次爬楼梯(完全背包爬楼梯)
这题想必大家都再熟悉不过,不过我们今天一步不止可以爬 1 级和 2 级,我们可以一步可以跨上 1 级、2 级、3 级…m级,那么想上到 n 楼,有多少种方法呢?
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n+1, 0);
dp[0] = 1;
for(int i = 1; i <= n; i++){ //遍历背包
for(int j = 1; j <= m; j++){ //遍历物品
if(i >= j) dp[i] += dp[i - j];
}
}
return dp[n];
}
};
要装满容量为n的背包,物品有1、2、3… m 的大小,一共有多少种方法?(不同的组合算作不同的方法哦)
因为你要上三个台阶,先跨一步再跨两步,和先跨两步再跨一步这是两种不同的方法,所以需要用到我们上面所说的排列数的求法(即外层循环应该为背包而不是物品)
再一次兑换零钱
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, 0x3f3f3f);
dp[0] = 0;
for(int i = 0; i < coins.size(); i++){
for(int j = coins[i]; j <= amount; j++){
dp[j] = min(dp[j], dp[j-coins[i]]+1);
}
}
if(dp[amount] == 0x3f3f3f) return -1;
else return dp[amount];
}
};
后面看题解发现这个题用组合和排序都可以,不影响硬币的最小个数(5 5 1和1 5 5都是三个,不影响结果),因为不是求方法数,只要初始化记得最大,还有递推公式写对就行了,但是我的第一反应还是组合。
完全平方数
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1, 0x3f3f3f);
int x = sqrt(n) + 1;
dp[0] = 0, dp[1] = 1;
for(int i = 1; i <= x; i++){
for(int j = i * i; j <= n; j++){
dp[j] = min(dp[j], dp[j - i * i] + 1);
}
}
return dp[n];
}
};
上道了,上道了,事实证明,只要有一本好书 / 一个好老师,我会是个很出色的学生呢。
单词拆分
傻眼了,我得乖乖去做字符串的题了。。看看题解呜呜
这个题解太妙了呜呜呜,只有拜读的份儿啦!
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size()+1, false);
dp[0] = true;
for(int i = 1; i <= s.size(); i++){
for(int j = 0; j < i; j++){
string str = s.substr(j, i - j);
if(wordSet.find(str) != wordSet.end() && dp[j]) dp[i] = true;
}
}
return dp[s.size()];
}
};
已经做了重点标记,等我刷完字符串再过来看看;
妙处有三: 对容器、字符串等函数调用很妙;(此为基础) 对dp数组的定义很妙; 递推公示很妙。
还要好好学习呀!尾巴放下来,不可以在天上了~
多重背包理论基础
每件物品最多有M件可用
方法一:
把M件摊开,转化为01背包;
方法二:
二进制优化,将每种物品的数量按照二进制方式,打包成一个个独立的包。
大总结
两天!终于写完了!不得不说这些问题处理起来,已经上道了,变得得心应手了,接下来要做的就是更加熟练 快速捡起字符串等基本知识。
五步曲中最关键的两步:递推公式和递推顺序(当然也不要忘记了初始化)
其实有了套路,DP也显得不🚹了。