C++算法 —— 动态规划(8)01背包问题

文章目录

  • 1、动规思路简介
  • 2、模版题:01背包
    • 第一问
    • 第二问
    • 优化
  • 3、分割等和子集
  • 4、目标和
  • 5、最后一块石头的重量Ⅱ


背包问题需要读者先明白动态规划是什么,理解动规的思路,并不能给刚接触动规的人学习。所以最好是看了之前的动规博客,才能看背包问题的所有博客,或者你本人就已经懂得动规了。

1、动规思路简介

动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。

状态表示
状态转移方程
初始化
填表顺序
返回值

动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。

状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定。

状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。

要确定方程,就从最近的一步来划分问题。

初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。

第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护,也就是下标的变化。

填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。

返回值,要看题目要求。

背包问题有很多种分类,此篇是关于01背包问题的。背包问题在写完代码都需要再做优化。所以比起之前的动规算法博客,背包问题的几篇博客都在最后加上一步优化。但优化方法只在模版题写,其它不写了。

2、模版题:01背包

DP41 【模板】01背包

C++算法 —— 动态规划(8)01背包问题_第1张图片
C++算法 —— 动态规划(8)01背包问题_第2张图片

第一问

先定dp[i]为从前i个物品选,选出最大价值的,这个其实是不行的,如果要选第i个物品,就需要看看背包满没满,但是现在的状态没法表示体积,所以不行。那就用二维数组,dp[i][j]表示从前i个物品中挑选,总体积不超过j,所有选法中,能选出的最大价值。

状态转移方程。根据最后一步来分析。我们可以选择第i个物品,也可以不选。如果不选,就是在0到i- 1中选,这个的结果放在dp[i -1][j]中;如果选择i位置的元素,也就是体积加上vi,那为了不超过背包,就得选择dp[i - 1][j - vi]位置的值加上wi,这个情况还有考虑,可能vi大于j了,所以j - vi需要>= 0。所以dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j - vi] + wi)。

初始化,要加上一行一列,里面的值全都为0。返回值是最大值。

第二问

在第一问基础上来做改动。之前的dp[i][j]要从不超过j改成正好等于j。状态转移方程中,有可能选上一个后,仍然不够j。如果不选第i个物品,那么就是dp[i - 1][j],意思是在前i - 1个物品中选,体积等于j的最大价值,但有可能是达不到j的,我们先定义dp[i][j] = -1来表示没有这种情况,也就是前i个物品中没有能达到体积为j的选法。如果dp[i - 1][j]是-1,那么不选i位置的物品还是体积达不到j,所以不选的话就不用考虑了。选第i个物品的话,就要先看看dp[i - 1][j - vi]存不存在,不等于-1,也就是说前i - 1个位置正好达到了j - vi的体积,那么加上第i个物品就可行了。

初始化。新增行列,第一列整体都是0,第一行其余都是-1。

#include 
#include 
using namespace std;

const int N = 1010;

int n, V, v[N], w[N];
int dp[N][N];

int main()
{
    cin >> n >> V;
    for(int i = 1; i <= n; i++)
    {
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i++)
    {
        for(int j = 1; j <= V; j++)
        {
            dp[i][j] = dp[i - 1][j];
            if(j >= v[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }
    }
    cout << dp[n][V] << endl;//第一问
    memset(dp, 0, sizeof(dp));
    for(int j = 1; j <= V; j++) dp[0][j] = -1;
    for(int i = 1; i <= n; i++)
    {
        for(int j = 1; j <= V; j++)
        {
            dp[i][j] = dp[i - 1][j];
            if(j >= v[i] && dp[i - 1][j - v[i]] != -1) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }
    }
    cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;
    return 0;
}

优化

数组很大,用滚动数组的思路来优化。仔细看一下分析,dp[i][j]由dp[i - 1][j]和dp[i - 1][j - vi]来决定,也就是这一行的一个元素由上一行的两个元素来决定,和其它行都没有关系,那么我们仅需要两行其实就是可以完成dp表的填写,根据第一行填完第二行后,第一行作为原先的第二行,第二行作为新的第二行继续更新数据。这就是滚动数组。但还可以减少空间。只用一行来进行操作。dp[j]由dp[j]和dp[j - vi]来决定,然后更新dp[j],用一行的话填表顺序就不能是从左到右,因为dp[j - vi]会把dp[j - vi]给换掉,所以填表顺序应当是从右到左,才能保证每个值都正确更新。

#include 
#include 
#include 
using namespace std;

const int N = 1010;

int n, V, v[N], w[N];
int dp[N];

int main()
{
    cin >> n >> V;
    for(int i = 1; i <= n; i++)
    {
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i++)
    {
        for(int j = V; j >= v[i]; j--)//原本是j >= 1,在下面判断,但其实可以直接放在for括号里,减少循环次数
        {
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    cout << dp[V] << endl;//第一问
    memset(dp, 0, sizeof(dp));
    for(int j = 1; j <= V; j++) dp[j] = -1;
    for(int i = 1; i <= n; i++)
    {
        for(int j = V; j >= v[i]; j--)
        {
            if(dp[j - v[i]] != -1)
                dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    cout << (dp[V] == -1 ? 0 : dp[V]) << endl;
    return 0;
}

3、分割等和子集

416. 分割等和子集

C++算法 —— 动态规划(8)01背包问题_第3张图片

可以转化成挑选一个数来达到是整体数组和的一半这个条件。并且,其实就是从前i个数中选,所有的选法中,能否凑齐j这个数,类型为bool,j就是sum / 2。

状态转移方程。如果不选i,那就看dp[i - 1][j];如果选i,那就是拿前面值加上i位置的值,假设前面的值是numi,那么这个位置应当在j - numi位置,但是j - numi必须存在才行。

初始化新增一行一列,第一列都是true,第一行其余位置都是false。

返回值是dp[n][sum / 2]。

    bool canPartition(vector<int>& nums) {
        int n = nums.size(), sum = 0;
        for(auto x : nums) sum += x;
        if(sum % 2) return false;
        int aim = sum / 2;
        vector<vector<bool>> dp(n + 1, vector<bool>(aim + 1));
        for(int i = 0; i <= n; i++) dp[i][0] = true;
        for(int i = 1; i <= n; i++)
        {
            for(int j = 1; j <= aim; j++)
            {
                dp[i][j] = dp[i - 1][j];
                if(j >= nums[i - 1])
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
            }
        }
        return dp[n][aim];
    }

但是这种做法实在不妥,按照滚动数组优化一下。

    bool canPartition(vector<int>& nums) {
        int n = nums.size(), sum = 0;
        for(auto x : nums) sum += x;
        if(sum % 2) return false;
        int aim = sum / 2;
        vector<bool> dp(aim + 1);
        dp[0] = true;
        for(int i = 1; i <= n; i++)
        {
            for(int j = aim; j >= nums[i - 1]; j--)
            {
                dp[j] = dp[j] || dp[j - nums[i - 1]];
            }
        }
        return dp[aim];
    }

4、目标和

494. 目标和

C++算法 —— 动态规划(8)01背包问题_第4张图片
根据题目,这些数字会被分为正数和负数,假设正数是a,负数绝对值是b,也就是说a + b = sum,a - b = target,所以a = (t + s) / 2,此时b就不见了。那么只用看a,也就是说选一些数,和正好是a,有多少种选法,就是答案。dp[i][j]就表示从前i个数中选,总和正好等于i,一共有多少种选法。

状态转移方程。如果选i,那么就看dp[i - 1][j nums[i]],因为是方法个数,所以不需要+nums[i];不选择i的话,就看dp[i - 1][j]。

初始化时,新增行列都是0,除了dp[0][0]是1。

返回值是最后一个值。优化部分直接写到代码上。

    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for(auto x: nums) sum += x;
        int aim = (sum + target) / 2;
        if(aim < 0 || (sum + target) % 2) return 0;
        int n = nums.size();
        vector<int> dp(aim + 1);
        dp[0] = 1;
        for(int i = 1; i <= n; i++)
        {
            for(int j = aim; j >= nums[i - 1]; j--)
            {
                dp[j] += dp[j - nums[i - 1]];
            }
        }
        return dp[aim];
    }

5、最后一块石头的重量Ⅱ

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

C++算法 —— 动态规划(8)01背包问题_第5张图片

分析题目。每次拿到两个数字,按照题目去操作,其实就相当于一个正数和一个负数相加,得到一个值,是0就都没了,那么元素多了的话,就和上一题相似,一堆正数和一堆负数之间的运算。假设正数是a,负数的绝对值和是b,那么a + b = sum,求最小的a - b的值。从sum角度看,一个数字可以分成两个数字相加,如果这两个数字越接近sum的一半,那么就差值就越小。所以最终这个问题就转换成在数组中选择一些数,让这些数的和尽可能地接近sum / 2。

让dp[i][j]表示从前i个数中选,总和不超过j,此时的最大和。这个j就是sum / 2。如果选i,那就是dp[i - 1][j - nums[i]] + nums[i],如果不选i,那就是dp[i - 1][j],然后选出最大值。

初始化,新增行列都是0就可。

返回值返回sum - 2* dp[n][sum / 2]。优化直接写出来。

    int lastStoneWeightII(vector<int>& stones) {
        int sum = 0;
        for(auto x : stones) sum += x;
        int n = stones.size(), m = sum / 2;
        vector<int> dp(m + 1);
        for(int i = 1; i <= n; i++)
        {
            for(int j = m; j >= stones[i - 1]; j--)
            {
                dp[j] = max(dp[j], dp[j - stones[i - 1]] + stones[i - 1]);
            }
        }
        return sum - 2 * dp[m];
    }

结束。

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