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

概念引入

什么是动态规划

动态规划(Dynamic Programming,简称DP)是运筹学的一个分支,是一种求解决策过程最优化的数学方法。它也是一种解决问题的思想,通过把原问题分解为相对简单的子问题的方式来求解复杂问题的方法。

具体来说,动态规划是通过拆分问题,定义状态及状态转移方程,使得问题能够以递推(或者说分治)的方式去解决。它将一个复杂的问题分解成若干个子问题(阶段),子问题和原问题是结构相同或类似的,只不过规模不同。通过解决子问题,再合并子问题的解决方案,从而达到解决原问题的目的。同时,在解决各个阶段的问题时,这些问题的解决方案通常被保存起来,以便后续的阶段可以直接使用前面的结果,从而避免了大量的重复计算。

什么是背包问题

背包问题(Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中。相似问题经常出现在商业、组合数学,计算复杂性理论、密码学和应用数学等领域中。也可以将背包问题描述为决定性问题,即在总重量不超过W的前提下,总价值是否能达到V?它是在1978年由Merkle和Hellman提出的。

背包问题分为:01背包、完全背包、多重背包、分组背包

什么是01背包

01背包问题是一种组合优化的DP完全问题,也是经典的动态规划问题之一。问题描述为:有M件物品和一个容量为W的背包,每种物品都有各自的体积(W1, W2,…, Wn)和价值(P1, P2,…,Pn)。在不超过背包容量的情况下,如何选择装入背包的物品以使得总价值最大。

需要注意的是,“01”这个名字来源于每个物品只有两种选择状态——取或者不取,即要么放入背包中(用“1”表示),要么不放入背包中(用“0”表示)。此外,每种物品只能选择一次,不能重复选取。

解题步骤

  1. 确定dp数组及其下标含义
  2. 确定递推公式
  3. dp数组初始化
  4. 确定出dp数组的循环条件

01背包解题方式

二维数组

思路分析

  1. 确定dp数组下标含义:

    dp[i][j]即为在0到i-1个物品放入背包容量为j的背包中,价值总和最大为多少

  2. 确定递推公式:

    由dp数组的含义可知,dp[i][j]会有两种方式得到,即放第i个物品与不放第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得到的最大价值

    因此可得出dp[i][j]的递推公式为:

    dp[i][j] = fmax(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    
  3. dp数组如何初始化:

    dp数组的含义为:dp[i][j]即为在0到i-1个物品放入背包容量为j的背包中,价值总和最大为多少

    当j为0时,即背包的容量为0时,所装物品最大价值也为0;

    当i为0时,即只有一个物品装入背包时,所装物品的最大价值为value[0];但注意背包容量要大于等于第1个物品的重量;

    代码如下:

    memset(dp, 0, sizeof(int) * weight.size() * (bagweight + 1));
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }
    
  4. 确定遍历顺序:

    代码如下:

    // 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] = fmax(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] = fmax(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
        }
    }
    
     /*其中j < weight[i]是因为,当背包容量小于物品的容量时,这个物品就放不进去
       此时它的最大值就为dp[i - 1][j];
       其中j以1开始是因为j为0的情况已经初始化了*/
    

    由递推公式可知,dp[i][j]与左上角的数据有关,无论先遍历那个都不会影响dp数组的推导。但先遍历物品比较好理解。

例题理解

原题链接:

416. 分割等和子集 - 力扣(LeetCode)
请添加图片描述
代码如下:

bool canPartition(int* nums, int numsSize) {
    int i, j, sum = 0;
    for(i = 0; i < numsSize; i++)
	{
		sum += nums[i];
	} //先求出所有数字之和
    if(sum % 2)
    {
         return false;
    }// 若元素总和为奇数,则不可能得到两个和相等的子数组
    int target = sum / 2;
    // 初始化dp数组
    int dp[numsSize][target + 1];
    memset(dp, 0, sizeof(int) * numsSize * (target + 1));
    // 当背包重量j大于nums[0]时,可以在dp[0][j]中放入元素nums[0]
    for(j = nums[0]; j <= target; ++j) {
        dp[0][j] = nums[0];
    }
    for(i = 1; i < numsSize; ++i) {
        for(j = 1; j <= target; ++j) {
            // 若当前背包重量j小于nums[i],则其值等于只考虑0到i-1物品时的值
            if(j < nums[i])
                dp[i][j] = dp[i - 1][j];
            // 否则,背包重量等于在背包中放入num[i]/不放入nums[i]的较大值
            else
                dp[i][j] = fmax(dp[i - 1][j], dp[i - 1][j  - nums[i]] + nums[i]);
        }
    }
    // 判断背包重量为target,且考虑到所有物品时,放入的元素和是否等于target
    return dp[numsSize - 1][target] == target;
}

一维数组(滚动数组)

思路分析

在二维数组的解法当递推公式为dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);我们也可以理解为将i-1层的数据先复制到第i层,再与dp[i - 1][j - weight[i]] + value[i]进行比较,选出最大值,复制后的递推公式就变成了dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);因此选择不停的复制不如直接使用一维数组去解决。即将这个一维数组不停的进行更新(滚动)。

  1. 确定dp数组的下标含义:

    在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]

  2. 一维dp数组的递推公式:

    我们可知一维数组即将二维数组的拷贝过程去掉,即与i没有关系,因此一维数组的递推关系为将i这个维度去掉,递推公式为:

    dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    
  3. dp数组初始化:

    当dp[0]时,即背包可容纳的物品的价值为0时,此时背包应初始化为0,再有递推公式可知,每次都取最大值,假设物品的价值最小为0,此时我们只需将数组全部初始化为0。

  4. 一维数组的遍历顺序:

    代码如下:

    for(int i = 0; i < weight.size(); i++) 
    { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) 
        { // 遍历背包容量
            dp[j] = fmax(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    //其中内层循环的遍历条件也是因为当背包容量小于物品的容量时,这个物品就放不进去
    
    • 我们发现一维数组与二维数组的遍历过程中,背包的遍历顺序反了,这是为什么呢?

      先让一维数组正序遍历:则有可能在背包容量为j的时候,在之前背包容量小于j的时候,已经放过了。以上题为例:我们知道背包容量最大为11,背包正序遍历,当j为1时,物品0放进去了,此时dp[1] = 1,当j为2时,dp[j - weight[i]] + value[i] = 2,取最大值,即物品0放了2次,以此类推。即一维数组正序遍历会使物品重复放入。

      当一维数组倒序遍历时:先将这个东西在最大容量时放入(此时容量比它小的时候放入的为i - 1的物品),此时就不会出现重复放入。

      二维数组为什么无需倒叙:每一层都是由上一层得来,对i在i层物品只考虑一次。

    • 一维数组能否先遍历容量,再遍历背包呢?

      不可以!!!!!

      我们假设交换了遍历的顺序,即外层循环为背包的容量,内层循环为物品,仍以上题为例,我们已将数组初始化为0,由以上分析可知背包应该倒叙遍历,j为11时,i从0开始遍历,dp[j] = 0,dp[j - nums[i]] + nums[i] = 1,代表将数字1放进去了;i为1时,dp[j] = 1,dp[j - nums[i]] + nums[i] = 5,数字5放进去了,可没有数字1,因此如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

    倒序遍历的原因:其实一维数组是将二维数组进行了压缩,我们需要用到二维数组左上的数据,此数据为上一次循环的数据,因此不可破坏,因此采用倒序遍历方式。即从右向左进行覆盖。

例题解析

题目如上,代码如下:

bool canPartition(int* nums, int numsSize) {
    int i, j, sum = 0;
	for(i = 0; i < numsSize; i++)
	{
		sum += nums[i];
	} //先求出所有数字之和
	if(sum % 2 != 0)
	{
		return false;
	}
	sum = sum / 2; //当数字之和不能整除于2时,由于数组为整数数组所以不可能出现等分情况,直接返回false
                   //否则代表有可能则使sum除以2,此题变成任取数组中的元素相加能否为sum
	int *dp = (int *)malloc(sizeof(int) * (sum + 1));
	for(i = 0; i <= sum; i++)
	{
		dp[i] = 0;
	} //dp[i]所代表的为容量为j所能包含的最大数字之和,为典型的01背包问题
	for(i = 0; i < numsSize; i++)
	{
		for(j = sum; j >= nums[i]; j--)
		{
			dp[j] = fmax(dp[j], dp[j - nums[i]] + nums[i]);
		}
	} //进入双层循环先遍历物品,再遍历背包。注意内层循环的终止条件是因为,当nums[i]大于j时,它已经放不进去了
	return dp[sum] == sum; //当dp[sum]即背包容量为sum时是否装满即等于sum
}
  • 39
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值