动态规划背包问题之01背包

510 阅读5分钟

一、背包问题概述

一个背包有它的最大容量,然后有一些物品,物品有它的价值和体积,这是前提。背包问题就是寻找不同的方案来填满背包,从而使得背包内的物品价值最大。

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。这就是典型的01背包问题。

完全背包和01背包的区别就是,在完全背包中,所有的物品都可以无限次选取,默认物品的数目是无数个。而在01背包中,每个物品只能选取一次。

二、01背包问题思路

2.1 二维数组的01背包

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

然后确定递推公式,可以有两个方向推出来dp[i][j]:

  • 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。
  • 放物品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得到的最大价值。

所以递归公式为: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

关于初始化

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。

当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。

当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

在01背包的二维数组中,先遍历背包容量或者先遍历物品都是可以的。

C++代码:

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

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

2.2 一维数组的01背包

在背包问题中,使用二维数组和一维数组时间复杂度没有区别,但是一维数组(滚动数组)的空间复杂度小了一个级别。下面介绍一维数组的01背包写法。

在使用二维数组的时候,递推公式: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](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

递推公式是dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);,那么初始化的时候,dp[0]初始化为0,因为容量是0,价值肯定是0. 其他位置也直接都初始化为0就可以了。因为后面都是取的最大值,那么保证能被新的值覆盖就可以了,就都初始化为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]);

    }
}

注意,这里的遍历顺序中,在遍历背包容量j时,遍历顺序是从后往前的。和二维01背包的遍历顺序相反。这是因为,倒序遍历是为了保证物品i只被放入一次!但如果一旦正序遍历了,那么物品就会被重复加入多次!

为什么正序遍历物品就会被加入多次,而倒序遍历就不会呢?

是因为:倒序遍历的本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。 只有这样,才能保证本层的数值都是上一层的。如果是正序遍历,我们看到递推公式dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]),就没办法保证max括号里面的这个dp[i][j - weight[i]]到底是上一层的值还是本层的了。

这就是必须倒序遍历的原因!!!

滚动数组遍历01背包的时候,必须先遍历物品种类,再遍历背包容量!!!

01背包滚动数组写法测试代码:

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

01背包典型题目

1、分割等和子集

题目链接416. 分割等和子集 - 力扣(LeetCode)

**题目要求:**给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for(auto i:nums)
            sum += i;
        if(sum%2)
            return false;
        int val = sum/2;    
        vector<int>dp(val + 1,0);
        for(int i = 0;i < nums.size();i ++)
        {
            for(int j = val;j >= nums[i];j --)
            {
                dp[j] = max(dp[j],dp[j - nums[i]] + nums[i]);//就是尽可能装的更多,看看最后能不能装满
            }
        }
        return dp[val] == val;//看看能不能装满
    }
};

2、目标和

题目链接:494. 目标和 - 力扣(LeetCode)

题目要求: 给你一个整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。 返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

image.png

解题思路:

这是用背包思想解决组合问题,dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法

其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。

确定递推公式:当前的物品可以选择要或者不要,要的话,有dp[j - nums[i]]种组合可以拼凑出来,不要的话,那就是dp[j]种方式(这里的dp[j]是上一层的),所有递推公式是dp[j] += dp[j - nums[i]];

c++代码:

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        //组合问题
        int sum = 0;
        for(auto i:nums)
            sum += i;
        if(target > sum || (sum - target)%2)
            return 0;
        int val = (sum - target)/2;
        vector<int>dp(val + 1,0);
        dp[0] = 1;//这里要初始化为1
        for(int i = 0;i < nums.size();i ++)
        {
            for(int j = val;j >= nums[i];j --)
            {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[val];
    }
};

3、一和零

题目链接: 474. 一和零 - 力扣(LeetCode)

题目要求: 给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

解析:

这是一个二维01背包问题,即物品的“价值”是二维的,之前遇到的题目物品都只有一种价值。

其他部分的思路和普通01背包问题是一样的。

C++代码如下:

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>>dp(m + 1,vector<int>(n + 1));
        //dp[i][j]的含义就是子集最多含有i个0和j个1的最大子集的长度
        for(int i = 0;i < strs.size();i ++)
        {
            int count_0 = 0,count_1 = 0;
            for(auto i : strs[i])
            {
                if(i == '0')
                    count_0 ++;
                else
                    count_1 ++;
            }
            for(int j = m;j >= count_0;j --)
            {
                for(int k = n;k >= count_1;k --)
                {
                    dp[j][k] = max(dp[j][k],dp[j - count_0][k - count_1] + 1);
                }
            }
        }
        return dp[m][n];
    }
};