代码随想录1刷—动态规划篇(二):背包问题

0-1背包问题 基础理论

问题

n n n件物品和一个最多能背重量为 w w w的背包。第 i i i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

暴力解法

每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 O ( 2 n ) O(2^n) O(2n),这里的 n n n表示物品数量。所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化。

动态规划

为便于理解,先规定背包最大重量为4,物品重量和价值如下,问背包能背的物品最大价值是多少?

重量价值
物品0115
物品1320
物品2430

1、dp[i][j] 表示从下标为 [0-i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。

动态规划-背包问题1

2、递推公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

  • 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  • 放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

3、初始化:

  • 如果背包容量j0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0

  • 从状态转移方程dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);可以看出i 是由i-1推导出来,那么i0的时候就一定要初始化。

    dp[0][j],即:i0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

    • j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
    • j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
动态规划-背包问题10

4、遍历顺序:根据递推公式可以发现 dp[i][j] 所需要的数据就是左上角( dp[i - 1][?] ),所以先遍历背包重量还是先遍历物品都不会不影响 dp[i][j] 公式的推导。两种遍历方式代码如下:

// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j]; 
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

    }
}//先遍历物品,然后遍历背包重量

// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
}//先遍历背包,再遍历物品

5、举例推导 dp 数组

动态规划-背包问题4
void test_2_wei_bag_problem1() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagweight = 4;
    // 二维数组
    vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
    // 初始化
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }	//因为定义二维数组时已经全部初始化为0,所以第一列需要为0,及第一行背包容量无法装下物品1所以容量为0的这部分初始化由于本身就是0了所以可以省略。
    // weight数组的大小 就是物品个数
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
            if (j < weight[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

        }
    }
    cout << dp[weight.size() - 1][bagweight] << endl;
}

int main() {
    test_2_wei_bag_problem1();
}
滚动数组(二维dp降为一维dp的优化)

对于背包问题其实状态都是可以压缩的。在使用二维数组的时候,如果递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

为便于理解,先规定背包最大重量为4,物品重量和价值如下,问背包能背的物品最大价值是多少?

重量价值
物品0115
物品1320
物品2430

1、dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]

2、dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

3、如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。这样才能在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。

4、遍历顺序(和二维dp有很大不同!!):

  • 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]);
        }
    }
    
  • 二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

    倒序遍历是为了保证物品i只被放入一次!但如果一旦正序遍历了,那么物品0就会被重复加入多次!

    其实该遍历本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。也就是倒序遍历的原因所在。

    举一个例子:

    物品0的重量weight[0] = 1,价值value[0] = 15

    正序遍历:

    • dp[1] = dp[1 - weight[0]] + value[0] = 15

    • dp[2] = dp[2 - weight[0]] + value[0] = 30

    此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

    为什么倒序遍历,就可以保证物品只放入一次呢?

    • 倒序就是先算dp[2]

    • dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

    • dp[1] = dp[1 - weight[0]] + value[0] = 15

    所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

    为什么二维dp数组历的时候不用倒序呢?

    • 对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

    两个嵌套for循环的顺序,那可不可以先遍历背包容量嵌套遍历物品呢?

    不可以!因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:最终背包里只放入了一个物品。

5、举例推导dp数组

动态规划-背包问题9
void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;
    // 初始化
    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]);
        }
    }
    cout << dp[bagWeight] << endl;
}

int main() {
    test_1_wei_bag_problem();
}

一维dp 的01背包,要比二维简洁的多! 初始化 和 遍历顺序相对简单了,空间复杂度还降了一个数量级!

416. 分割等和子集

集合划分问题:回溯法(超时)
class Solution {
public:
    bool eSum(vector<int>& nums, int i, int remain) {
        if (remain == 0) return true;
        if (remain < 0 || i == nums.size()) return false;
        return eSum(nums, i+1, remain-nums[i]) || eSum(nums, i+1, remain);
                            //分到子集1        或者             分到子集2
    }
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for(int i = 0; i < nums.size(); i++){
            sum += nums[i];
        }
        if (sum % 2 == 1)
            return false;
        return eSum(nums, 0, sum / 2);
    }
};
//超出时间限制
类似题目:473. 火柴拼正方形
class Solution {
public:
    bool makesquare(vector<int>& matchsticks) {
        int totalLength = accumulate(matchsticks.begin(),matchsticks.end(),0);  //求和
        if( totalLength % 4 != 0 ) return false;
        int edgeLength = totalLength /4;    //边长为总和除以4
        vector<int> edges(4,0); 			//记录4边的长度
        sort(matchsticks.begin(),matchsticks.end(),greater<int>()); 
        									//排序,先放长的,提高效率
        return dfs(0,edges,matchsticks,edgeLength);

    }
    bool dfs(int index,vector<int> &edges,vector<int>& matchsticks,int edgeLength)
    {
        if( index == matchsticks.size() ) return true;  //边界条件,所有火柴放完了
        for( int i = 0;i < 4; ++ i )    				//做选择
        {
            if( edges[i] + matchsticks[index] > edgeLength ) 
                continue;  								//第 i 条边不能放
            if( i > 0 && edges[i] == edges[i-1] ) 
                continue;  
            	//第 i 条边长度和 i-1 长度相等,第 i-1 条边不可以放则第 i 条边也不可以放
            edges[i] += matchsticks[index];     //放在第 i 条边
            if( dfs(index+1,edges,matchsticks,edgeLength) )  
                return true;                    //  第 i 条边可以放
            edges[i] -= matchsticks[index];     //  回溯
        }
        return false;
    }
};
类似题目:698. 划分为k个相等的子集

用集合 n u m s nums nums中所有元素之和 s u m sum sum除以 k k k得到每个子集中的元素和:target = sum/k。那么这道题目就变为了:将集合 n u m s nums nums划分为 k k k个子集,使每个子集中的的元素和为 t a r g e t target target.

遍历所有数字,每个数字需要遍历k个子集,并选择放入某个子集中。如果某个数字放入子集会导致子集元素之和大于 t a r g e t target target,那么这个数字就放入下一个子集,直到可以放入为止。如果某个数字不能放入任何一个子集,那么就返回 f a l s e false false

class Solution {
public:
    bool canPartitionKSubsets(vector<int>& nums, int k) {
        if(k > nums.size()) return false;
        int sum = accumulate(nums.begin(), nums.end(), 0);// 求和
        if(sum % k != 0) return false;
        sort(nums.rbegin(), nums.rend());// 排序
        bucket.resize(k);       // 记录每个子集中数字之和
        int target = sum/k;     // 每个桶中的数字之和应该为target
        return backtrack(nums, 0, target);   // index = 0, 表示从0号元素开始遍历
    }

private:
    vector<int> bucket;     // 记录每个子集中数字之和
    bool backtrack(vector<int> &nums, int index, int target){
        if(index == nums.size()){        // 如果所有数字遍历完了 也就意味着全部放入桶中了
            return true;
        }
        for(int i = 0; i < bucket.size(); i++){     // 注意:i 表示第i个子集,index 表示第index个数字
            // 如果这个数字放入子集i中使子集i中元素和超出target了
            if(bucket[i] + nums[index] > target){
                continue;
            }
            if(i > 0 && bucket[i] == bucket[i-1]){
                continue;
            } // 如果 当前子集的元素和 与 前一个子集的元素和 是一样的,那前一个放不了 这个也放不了 跳过
            bucket[i] += nums[index];   // 将数字放入子集i中
            if(backtrack(nums, index + 1, target)){  // 递归穷举下一个数字的情况
                return true;
            }
            // 撤销选择 回溯
            bucket[i] -= nums[index]; 
        }
        // 如果 nums[index] 放入哪个子集都不行
        return false;
    }
};
0-1背包解法

确定了如下四点,可以将0-1背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
  • 背包中每一个元素是不可重复放入。

过程:

1、dp[j]表示 背包总容量是j,最大可以凑成j的子集总和为dp[j]

2、本题相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

3、首先dp[0]一定是0。如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

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

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;

        // dp[i]中的i表示背包内总和
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 总和不会大于20000,背包最大只需要其中一半,所以10001大小
        vector<int> dp(10001, 0);
        for (int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        if (sum % 2 == 1) return false;
        int target = sum / 2;

        // 开始 01背包
        for(int i = 0; i < nums.size(); i++) {
            for(int j = target; j >= nums[i]; j--) { 
                // 每一个元素一定是不可重复放入,所以从大到小遍历
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        // 集合中的元素正好可以凑成总和target
        if (dp[target] == target) return true;
        return false;
    }
};

1049. 最后一块石头的重量 II

核心:尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小。

本题物品重量为 s t o r e [ i ] store[i] store[i],物品价值为 s t o r e [ i ] store[i] store[i]。对应0-1背包里物品重量 w e i g h t [ i ] weight[i] weight[i]和物品价值 v a l u e [ i ] value[i] value[i]

1、dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背dp[j]这么重的石头。

2、dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);

3、提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000 。而要求的target其实是最大重量的一半,所以dp数组开到15000大小就可以了。也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。因为重量都不会是负数,所以dp[j]都初始化为0就可以了。

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

最后,dp[target]里是容量为target的背包所能背的最大重量。那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。计算target时target = sum / 2 是向下取整,所以sum - dp[target] ≥ dp[target]。那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int sum = accumulate(stones.begin(), stones.end(), 0);
        int target = sum / 2;
        vector<int> dp(target + 1, 0);
        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];
    }
};
//时间复杂度:O(m × n) , m是石头总重量(准确的说是总重量的一半),n为石头块数;空间复杂度:O(m)

494. 目标和

本题要如何使表达式结果为target,既然为target,那么就一定有 left组合 - right组合 = target

left+right=sum,sum是固定的。所以 left-(sum-left) = target->left = (target+sum)/2

target是固定的,sum是固定的,left就可以求出来。此时问题就是在集合nums中找出和为left的组合。

回溯算法

类似于 39. 组合总和 ,代码可套用。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
            //所有的方案组合都要记下来,所以不能直接return;
        }
        // 如果 sum + nums[i] > target 就终止遍历
        for (int i = startIndex; i < nums.size() && sum + nums[i] <= target; i++) {
            sum += nums[i];
            path.push_back(nums[i]);
            backtracking(nums, target, sum, i+1);
            sum -= nums[i];
            path.pop_back();
        }
    }
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = accumulate(nums.begin(),nums.end(),0);
        if (target > sum) return 0;         // 此时没有方案
        if ((target + sum) % 2) return 0; 
        int left = (target + sum)/2;
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end());     // 需要排序
        backtracking(nums, left, 0, 0);
        return result.size();
    }
};
动态规划

假设加法总和为 x ,那么减法总和是 sum - x 。所以要求的是 x - (sum-x) = S x = (S+sum) / 2 ,此时问题就转化为,装满容量为x背包,有几种方法?

看到(S + sum) / 2 应该担心计算的过程中向下取整有没有影响。例如sum 是5,S是2就是无解的,所以:

if ((S + sum) % 2 == 1) return 0; // 此时没有方案

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

if (abs(S) > sum) return 0; // 此时没有方案

1、 dp[j] 表示:填满 j(包括j) 这么大容积的包,有 dp[j] 种方法

2、不考虑 nums[i] 的情况下,填满容量为 j - nums[i] 的背包,有 dp[j - nums[i]] 种方法。那么只要有 nums[i] 的话,凑成 dp[j] 就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

  • 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]
  • 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]
  • 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
  • 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
  • 已经有一个5(nums[i]) 的话,有 dp[0]中方法 凑成 dp[5]

那么凑整dp[5]有多少方法呢,也就是把 所有的dp[j - nums[i]] 累加起来。

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

3、从递归公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]0的话,递归结果将都是0dp[0] = 1,理论上也很好解释,装满容量为0的背包,有1种方法,就是装0件物品。dp[j]其他下标对应的数值应该初始化为0,从递归公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。

4、一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = accumulate(nums.begin(), nums.end(), 0);
        int bagsize = (target + sum) / 2;
        if(abs(target) > sum) return 0;    
        if((target + sum) % 2 == 1) return 0;
        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];
    }
};

记住:在求装满背包有几种方法的情况下,递推公式一般为: dp[j] += dp[j - nums[i]];

474. 一和零

本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是两个维度的背包。

1、dp[i][j]:最多有i个0和j个1strs的最大子集的大小为dp[i][j]

2、dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1

dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。然后在遍历的过程中,取dp[i][j]的最大值。

所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

而0-1背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i])字符串本身的个数相当于物品的价值(value[i])。也印证了这就是一个典型的0-1背包!只不过物品的重量有了两个维度而已!

3、因为物品价值不会是负数,初始为0即可。

4、0-1背包中外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。那遍历背包容量的两层for循环先后循序有没有什么讲究?没讲究,都是物品重量的一个维度,先遍历那个都行!

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>> dp(m+1,vector<int>(n+1,0));
        for(string str:strs){
            int oneNum = 0, zeroNum = 0;
            for(char c:str){
                if(c == '0') zeroNum++;
                else if(c == '1') 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];
    }
};

完全背包理论基础

N N N件物品和一个最多能背重量为 W W W的背包。第 i i i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i]每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。完全背包和01背包问题唯一不同的地方就是,每种物品有无限件

分析

0-1背包和完全背包唯一不同就是体现在遍历顺序上。

为便于理解,先规定背包最大重量为4。物品重量及价值如下表,每件商品都有无限个(意味着可以放入背包多次),问背包能背的物品最大价值是多少?

重量价值
物品0115
物品1320
物品2430

回顾一下0-1背包的核心代码:

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]);
    }
}

前文已经介绍了0-1背包内嵌的循环是从大到小遍历,这是为了保证每个物品仅被添加一次。而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { 			// 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { 	// 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}

0-1背包中 二维dp数组的两个for遍历的先后循序是可以颠倒的;

一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量;

在完全背包中,对于一维dp数组来说,两个for循环嵌套顺序可以颠倒! 因为 d p [ j ] dp[j] dp[j] 是根据 下标 j j j 之前所对应的 d p [ j ] dp[j] dp[j] 计算出来的。 只要保证下标 j j j 之前的 d p [ j ] dp[j] dp[j] 都是经过计算的就可以了。

在这里插入图片描述

void test_CompletePack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品	//两个for循环嵌套顺序可以颠倒
        for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_CompletePack();
}

518. 零钱兑换 II

纯完全背包是能否凑成总金额,而本题是要求凑成总金额的个数!

同时需要注意题目描述中是凑成总金额的硬币组合数,组合不强调元素之间的顺序,排列强调元素之间的顺序

例如: 5 = 2 + 2 + 1 ; 5 = 2 + 1 + 2

这是一种组合,都是 2 2 1。也是两种排列。

1、dp[j]:凑成总金额j的货币组合数为dp[j]

2、dp[j] (考虑coins[i]的组合总和) 就是所有的dp[j - coins[i]](不考虑coins[i])相加。所以递推公式:dp[j] += dp[j - coins[i]]; ,和 494.目标和 题目中是一个道理。求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];

3、dp[0] = 1(凑成总金额0的货币组合数为1),下标非 0 0 0dp[j]初始化为 0 0 0

4、完全背包的两个for循环的先后顺序都是可以的。因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!所以纯完全背包是能凑成总和就行,不用管怎么凑的。

而本题要求的是凑成总和的组合数(方案个数),元素之间要求没有顺序。只能采用外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),分析如下:

假设:coins[0] = 1,coins[1] = 5。

外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。所以这种遍历顺序中 d p [ j ] dp[j] dp[j]里计算的是组合数!

外层for循环遍历背包(金钱总额),内层for遍历物品(钱币),背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。此时 d p [ j ] dp[j] dp[j]里算出来的就是排列数!(如果要求的是排列就必须要用这个顺序的for嵌套循环,但本题是组合数,所以不能用)

总结:

  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包

  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品

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];
    }
};

377. 组合总和 Ⅳ

题干中:请注意,顺序不同的序列被视作不同的组合。因此本题实际就是求排列问题。

回溯篇中 39.组合总和、40.组合总和II 和本题 377. 组合总和 Ⅳ 看起来很像,但实际上,本题是求排列总和的个数,并不是把所有的排列都列出来。如果要把排列都列出来的话,只能使用回溯算法爆搜,也就是题目39和题目要40的解法。

1、dp[i]: 凑成目标正整数为i的排列个数为dp[i]

2、dp[i] += dp[i - nums[j]];

3、题干中说明了给定目标值是正整数, 所以 d p [ 0 ] dp[0] dp[0] 是没有意义的,但 d p [ 0 ] dp[0] dp[0] 是递推公式推导的根基,所以需要进行初始化dp[0] = 1非0下标的dp[i] = 0

4、由于是排列,target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。排列和组合在for嵌套循环遍历顺序上的不同和分析可以看上面518题内的分析。

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        vector<int> dp(target + 1);
        dp[0] = 1;
        for(int i = 0; i <= target; i++){
            for(int j = 0; j < nums.size(); j++){
                if(i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]){  
                    //测试用例有两个数相加超过int的数据,所以要进行判断dp[i] < INT_MAX - dp[i - nums[j]]
                    dp[i] += dp[i - nums[j]];
                }
            }
        }
        return dp[target];
    }
};

70. 爬楼梯

题目升级

一步一个台阶,两个台阶,三个台阶,…,m个台阶。有多少种不同的方法可以爬到楼顶呢?

1阶,2阶,… m阶就是物品,楼顶就是背包。每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。问跳到楼顶有几种方法其实就是问装满背包有几种方法。这就是一个完全背包问题了!

1、dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法。

2、dp[i] += dp[i - j]

3、dp[0] = 1下标非0的dp[i] = 0

4、完全背包求排列问题,所以需将 t a r g e t target target放在外循环,将 n u m s nums nums放在内循环,内循环需要从前向后遍历。

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 >= 0) dp[i] += dp[i - j];
            }
        }
        return dp[n];
    }
};	//代码中m表示最多可以爬m个台阶,代码中把m改成2就可以AC题 70.爬楼梯 了。

322. 零钱兑换

1、dp[j]:凑足总额为j所需钱币的最少个数为dp[j]

2、dp[j] = min(dp[j], dp[j - coins[i]] + 1);

3、dp[0] = 0; 下标非 0 0 0 d p [ j ] dp[j] dp[j] 必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖,dp[j] = INT_MAX

4、钱币有顺序和没有顺序都不影响钱币的最小个数,所以本题并不强调集合是组合还是排列,也就无所谓内外层的遍历顺序,随意就行。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;
        for(int i = 0;i < coins.size(); i++){
            for(int j = coins[i]; j <= amount; j++){
                if(dp[j - coins[i]] != INT_MAX){  
                    			//如果dp[j-coins[i]] == INT_MAX则跳过。
                    dp[j] = min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }
        if(dp[amount] == INT_MAX) return -1;
        return dp[amount];
    }
};

279. 完全平方数

分析:完全平方数就是物品(可以无限件使用),凑正整数n就是背包,问凑满这个背包最少有多少物品?

1、dp[j]:和为j的完全平方数的最少数量为dp[j]

2、dp[j] = min(dp[j - i * i] + 1, dp[j]);

3、dp[0] = 0; dp[j] = INT_MAX

4、内外层循环随意就行,因为求的是最少数量,和排列组合没关系

class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        for(int i = 0; i <= n;i++){
            for(int j = 1; j * j <= i;j++){
                dp[i] = min(dp[i - j * j] + 1, dp[i]);
            }
        }
        return dp[n];
    }
};

139. 单词拆分

131. 分割回文串:是枚举分割后的所有子串,判断是否回文。

本题是枚举分割所有字符串,判断是否在字典里出现过。

回溯解法(超时)
class Solution {
public:
    bool backtracking(const string& s, const unordered_set<string>& wordSet,int startIndex){
        if(startIndex >= s.size()){
            return true;
        }
        for(int i = startIndex; i < s.size();i++){
            string word = s.substr(startIndex, i - startIndex + 1);
            if(wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, i + 1)){
                return true;
            }
        }
        return false;
    }
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        	// wordDict = ["leet", "code"]
        	// wordSet: {[0] = "code", [1] = "leet"}
        return backtracking(s, wordSet, 0);
    }
};
记忆化递归(看不懂的玩意)

递归的过程中有很多重复计算,可以使用数组保存一下递归过程中计算的结果。

使用 m e m o r y memory memory数组保存每次计算的以 s t a r t I n d e x startIndex startIndex起始的计算结果,如果 m e m o r y [ s t a r t I n d e x ] memory[startIndex] memory[startIndex]里已经被赋值了,直接用 m e m o r y [ s t a r t I n d e x ] memory[startIndex] memory[startIndex]的结果。

举例:"aab",["a","aa"]

程序运行顺序是word:a -> a -> b(memeoy[2] = false) -> ab(memeoy[1] = false) ->aab(memeoy[0] = false)

class Solution {
public:
    bool backtracking (const string& s, const unordered_set<string>& wordSet, vector<bool>& memory, int startIndex) {
        if (startIndex >= s.size()) {
            return true;
        }
        // 如果memory[startIndex]不是初始值了,直接使用memory[startIndex]的结果
        if (!memory[startIndex]) {
            return memory[startIndex];
        }
        for (int i = startIndex; i < s.size(); i++) {
            string word = s.substr(startIndex, i - startIndex + 1);
            if (wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, memory, i + 1)) {
                return true;
            }
        }
        memory[startIndex] = false; // 记录以startIndex开始的子串是不可以被拆分的
        return false;
    }

    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<bool> memory(s.size(), 1);           // -1 表示初始化状态
        return backtracking(s, wordSet, memory, 0);
    }
};	//可以AC啦
背包解法

1、dp[i] : 字符串长度为i的话,dp[i]true,表示可以拆分为一个或多个在字典中出现的单词。

2、如果确定dp[j] = true,且[j, i]这个区间的子串出现在字典里,那么dp[i] = true。(j < i )。

所以递推公式是if([j, i] 这个区间的子串出现在字典里 && dp[j] = true)那么 dp[i] = true。、

3、dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0] = true,否则递归下去后面都都是false了。下标非0的dp[i] = false,只要没有被覆盖说明都是不可拆分。

4、拆分为一个或多个在字典中出现的单词,说明可以重复,所以这是完全背包。本题最终要求的是是否出现过,所以对出现单词集合里的元素是组合还是排列并不在意!那么 使用求排列的方式,还是求组合的遍历方式都可以 。但本题因为是 求子串,因为分割子串的特殊性,最好是遍历背包放在外循环,将遍历物品放在内循环比较合适 。如果要是外层for循环遍历物品,内层for遍历背包,就需要把所有的子串都预先放在一个容器里。

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 word = s.substr(j, i - j);   // substr(起始位置,截取的个数)
                if (wordSet.find(word) != wordSet.end() && dp[j]) {
                    dp[i] = true;
                }
            }
        }
        return dp[s.size()];
    }
};
  • 时间复杂度: O ( n 3 ) O(n^3) O(n3),因为 s u b s t r substr substr返回子串的副本是 O ( n ) O(n) O(n)的复杂度(n = substring的长度)
  • 空间复杂度: O ( n ) O(n) O(n)

多重背包理论基础

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

例如:

背包最大重量为10。

重量价值数量
物品01152
物品13203
物品24302

和如下情况有区别么?

重量价值数量
物品01151
物品01151
物品13201
物品13201
物品13201
物品24301
物品24301

毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。

void test_multi_pack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    vector<int> nums = {2, 3, 2};
    int bagWeight = 10;
    for (int i = 0; i < nums.size(); i++) {
        while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
            weight.push_back(weight[i]);
            value.push_back(value[i]);
            nums[i]--;
        }
    }

    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]);
        }
        for (int j = 0; j <= bagWeight; j++) {
            cout << dp[j] << " ";
        }
        cout << endl;
    }
    cout << dp[bagWeight] << endl;

}
int main() {
    test_multi_pack();
}
//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量

也有另一种实现方式,就是把每种商品遍历的个数放在01背包里面在遍历一遍。

void test_multi_pack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    vector<int> nums = {2, 3, 2};
    int bagWeight = 10;
    vector<int> dp(bagWeight + 1, 0);


    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            // 以上为01背包,然后加一个遍历个数
            for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
            }
        }
        // 打印一下dp数组
        for (int j = 0; j <= bagWeight; j++) {
            cout << dp[j] << " ";
        }
        cout << endl;
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_multi_pack();
}
//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
//从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。

背包问题总结

步骤
  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组
常见递推公式

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:

遍历顺序
0-1背包
  • 二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。
完全背包
  • 纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包
  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品

相关题目如下:

如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

97Marcus

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值