My背包九讲——01背包


背包问题中的常用变量说明

n表示有几种被装的物品
V表示背包可以装的最大容量或最大重量
v表示某件物品的所占的空间或重量
w某件物品的价值
vi第I件物品的所占的空间或重量
wi第I件物品的价值
dp与状态转移方程的有关的数组
I当前状态下我们正要放第i个物品
j当前状态下我们假定背包空间为j

题目

有 N件物品和一个容量是 V的背包。每件物品只能使用一次。第 i件物品的体积是 vi,价值是 wi。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且价值最大。输出最大价值。

解题思路

我们假设题目条件如下:
共有n = 4个分别为a,b,c,d物品,背包容积为V = 5

物品物品编号 i物品体积vi物品价值wi
a112
b224
c334
d445

我想要想理解最简单 01背包就是要理解👇面这个表格

对于这个表格我想先做一些说明:

1.为什么要看、填这个表格:因为在填这个表格的时候我们可以感受到动态规划的过程,我们在看这个表格的时候,一定要动脑子跟着填一下每个🈳️,去感受当前的最优,是怎么来的,(不断的根据根据填表格的思路,填🈳️,当前🈳️的最优根据之前填好的🈳️的最优值来填写)不断的找当前最优,最后🈳️(d,5)的值也能找出来,这个值 就是背包问题的 答案
2.看表格时的注意:

1 .在叙述中我会坐标来叙述表格中的某个空的位置,例如用(x,0)表示:当前背包状态是 没有物品可选背包空间为0的这个空的位置 ,用(b,3)表示当前背包的状态是 当前物品可选物品为b背包空间为3的这个空的位置。
2.表格中表格中填的每一个🈳️里面的数值均是在当前背包状态下存放物品所能达到的最大价值(这一点一定要明白)
3. 当背包空间为0或者x(没有物品可选)这两种状态时无论怎么样当前状态下背包中的最大价值一定都是0
4.每个物品只有两个状态:放入背包、不放入背包

3.填表格时的思路请记劳(参考图一): 在填表格的某个空时候我们一定要按这个思路来:举例:当我在填🈳️(b,3)(此时在这个空之前的空应该都已经被填好了)这个表格的时候,我们先考虑能不能 先把被物品当前的这个b物品是否可以被放入背包,如果由于当前状态下背包的空间限制不能把这个物品放入背包,则此事背包中的最大价值就是:b的前一件物品a,在背包空间空间为3时的最大值,即空格(a,3)里面的值;如果在当前状态下背包的空间是可以装下b物品,这时我们还需要再分两种情况去讨论该背包状态下的最优解:(1)装b物品,此时背包剩余空间为3 - 2 = 1,则此时我们这个🈳️的代表的当前状态下背包的最优价值为:4 + (🈳️(a,1)的最大 价值2)= 6 |(2)不装b物品,那该🈳️代表当前状态下最大价值为:b的前一件物品a,在背包空间空间为3时的最大值,即空格(a,3)里面的值2。
此时比较(1)与(2)此时情况我们当然要选择最优情况(1),所以该🈳️代表的当前状态下背包的最大价值为6

图一

空间足够
空间不足不当前放物品
当前放入当前物品
不放当前物品
当前某个物品在假定的背包空间下
是否放入背包分情况讨论
得到当前背包状态最优解
选择其中最优情况

————————————————

物品编号- 背包空间012345
0 –x(没有物品可选)000000
1 – a022222
2–b024666
3–c024668
4–d024668

根据以上的思路填完表格,你可能还有点懵,所以我还会再举一些有助于理解的例子🌰:

  • 例1:我们现在假定背包空间为1,只有物品a,可以选择放入背包、不放入背包,此时我们对应表格🈳️为(a,2)那么背包的最优解很显然就是把a物品放入背包此时背包的最大价值为2,那么我们假设情况的背包最优解就出来了
  • 例2:我们假设背包空间为2 此事有a、b两个物品可以选择放入或者不放入对应🈳️(b,2),此时先考虑 当前的b物品是否能放。由于空间不足,无法放b,此时背包最优解转化为 在背包空间为1,只有物品a可以选择放或者不放,此时例2的问题的最优解就转化成了例1问题的最优解,这时根据例1代表的🈳️(a,2)的值2就是 例2 的最优解
  • 。。。。。。。。。。。。。。。经过了n次假设以后到了我们最后以一个要求的🈳️(d,8),这时背包的空间为8,当前物品为d,首相我们要考虑d物品能不能放,显然可以放,那么我们在考虑要不要放d物品,情况1: 假定我们要放d那么背包的最大价值为 5 + 🈳️(c,1)的值的最大价值2 = 7,情况2,我们不放b物品这个时候我们所求的问题就转化为了 在背包空间为5,当前物品为c时的背包最优解,根据查表🈳️(c,5)的值为8,所以我们选择 情况2 作为最优解 所以🈳️(d,8)的最优值为8,所以背包问题的最优解为 8。

理解了上面👆的思路下面的就是代码讲解了。。。

代码思路:

其实我们可以 用一个二维数组dp[][],来模仿这个填表格🈳️的过程,怎么模仿?说简单点就是用一个两层for循环去遍历求解每一个空,最有直接输出dp[4][5],就是背包问题的解。

补充知识:状态转移方程动在态规划中本阶段的状态往往是上一阶段状态和上一阶段决策的结果若给定了第K阶段的状态Sk以及决策uk(Sk),则第K+1阶段的状态Sk+1也就完全确定。 也就是说Sk+1与Sk,uk之间存在一种明确的数量对应关系,记为Tk(Sk,uk),即有Sk+1= Tk(Sk,uk)。 这种用函数表示前后阶段关系的方程,称为状态转移方程。在上例中状态转移方程为 Sk+1= uk(Sk) 。(百科)


先讲状态转移方程

dp[i][j] = max( dp[i − 1][j] , dp[i − 1][j − vi] + wi);

  1. dp[i][j]是什么?,其实dp[i][j] 就是对应表格里面的🈳️(i,j)里面的最优值
  2. dp[i-1][j]是什么?,它表示的意思是,我们假如不把 编号为i的物品放入当前空间为j的背包中,那么此时背包中的最大价值就是空(i-1,j)的值
  3. dp[i-1][j - vi] 是什么意思?,它表示的意思是,假如在背包空间为j,当前物品要选的是i状态下,我们选择了编号为i的物品,则此时背包的剩余空间为 j-vi,那么背包的中的最大价值就为 wi + dp[i-1][j - vi] 的值。
  4. max()使用来干啥的,其实就是 在两种情况中(dpp[i-1][j]、dp[i-1][j - vi])选择最优的解赋值给dp[i][j]

这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。所 以有必要将它详细解释一下:“将前 i 件物品放入容量为 j 的背包中”这个子问题,若 只考虑第 i 件物品的策略(放或不放),那么就可以转化为一个只和前 i − 1 件物品相关 的问题。如果不放第 i 件物品,那么问题就转化为“前 i − 1 件物品放入容量为 j 的背 包中”,价值为 dp [i − 1, j];如果放第 i 件物品,那么问题就转化为“前 i − 1 件物品放 入剩下的容量为j - vi 的背包中”,此时能获得的最大价值就是 dp [i−1][j−vi]再加上 通过放入第 i 件物品获得的价值 Wi。


废话不多说上代码

include<iostream>
#include<algorithm>
using namespace std;
const int Len = 1005;

int dp[Len][Len];
int v[Len],w[Len];  //v表示物品所占的空间,w物品的价值
int n,V;

int main()
{
	//初始化dp数组 ,初始化的位置对应与 表格🀄️ 背包空间为0⃣️的那一列 和 当前没有物品 可选择的那一行
	for(int i = 0;i < Len;i ++)
	{
		dp[0][i] = 0;
		dp[i][0] = 0;
	}
    cin>>n>>V;	//n件物品,背包空间为 V
    for(int i = 1;i <= n;i ++)
        cin>>v[i]>>w[i];

    for(int i = 1;i <= n;i ++)
        for(int j = 0;j <= V;j ++)
        {
        	if(j < v[i])	//情况1:无法把物品i放入入背包
            dp[i][j] = dp[i-1][j];
            else if(j >= v[i])	//情况2:能够吧物品放入背包(能够把i物品放入背包还要分两种情况:放、不放(其实这里的能放物品却不放物品i 与 情况1 有重复后期我们会优化这里))
                dp[i][j] = max(dp[i-1][j],dp[i-1][j-v[i]] + w[i]);
        }
    cout<<dp[n][V]<<endl;	//背包空间为V,有n物品的最大值
    return 0;
}

优化空间复杂度

以上方法的时间和空间复杂度均为 O(Vn),其中时间复杂度应该已经不能再优化了,但空间复杂度却可以优化到 O(v)。
这里的原因太难叙述了😂,所以把大佬的思路直接搬过来的了

先考虑上面讲的基本思路如何实现,肯定是有一个主循环 i ← 1 . . . n ,每次算出来 二维数组 dp[i][0…V ]
的所有值。那么,如果只用一个数组 dp[0…V ],能不能保证第 i 次循环结束后 dp[j] 中表示的就是我们定义的状态 dp[i,j] 呢?
dp[i][j] 是由 dp[i − 1][j] 和 dp [i − 1][ j − vi] 两个子问题递推而来,能否保证在推 dp [i][ j ]
时(也即在第 i 次主循环中 推 dp[j] 时)能够取用 F[i−1,j] 和 dp[i−1][j−vi] 的值呢?
事实上,这要求在每次主循环中我们以 j ← V . . . 0 的递减顺序计算 dp [v],这样才 能保证计算 dp[v] 时
dp[j −vi] 保存的是状态 dp[j − 1][j − vi] 的值。
核心码如下:

int dp[Len]= {0}; //⚠️这里的初始化不省略,这里的初始化就对应了表格🀄️ 没有可选物品 的那一行(背包没有物品,最优价值为0⃣️)
int v[n+1],w[n+1];
for(int i = 1;i <= n;i ++)
    for(int j = V;j >= v[i];j --)
    {			//⚠️这里的j >= v[i] 是做了优化
       dp[j] = max(dp[j],dp[j-v[i]] +  wi)    }
    cout<<dp[V]<<endl;	//最优解

其中的 dp [j] = max(dp [j], dp [j − vi] + wi) 一句,恰就对应于我们原来的转移方程,因 为现在的 dp [v − Ci] 就相当于原来的 dp [i − 1][v − Ci]。如果将 j 的循环顺序从上面的逆 序改成顺序的话,那么则成了 dp [i, v] 由 dp [i, v − vi] 推导得到(这会造成一个物品会被重复使用,其实这就是 完全背包的原理),与本题意不符。


背包问题初始化细节

恰好把背包装满的时候的最大值

我们在求解背包问题的时候有主要有两种问法: 一种是求的是 “恰好把背包装满的时候的最大值”,而一种题目是并没有要求 “恰好把背包装满”时的最大值,如果是第一种问法,我们要把 dp[0]初始化为0,其它的dp[1~V]均设置为 - ∞,如果是第二种问法我们只需要把所有的dp[0~V]全部初始化为0就可以了。
解释原因:那么为什么题目问 :“恰好装满背包时的最大值”,要初始化的这么奇怪?,
其实

(1)占用空间为0的物品刚好可以装满空间为0的背包
(2)只有上一层恰好装满时,使用状态方程得到的下一层才能是正好装满, 因此初始值设为-∞,即上一层未装满时,下一层加入a[i]结果仍然是-INF即未装满。
同样的,初始化一维数组时,将v[0]设为0,其他设为-∞

初始化细节的另一个变种:恰好装满时的方案数

这种问法与 前边的说的“恰好把背包装满的时候的最大值”问法 略有不同(但是思想相同),我们这里说的是的是 将背包恰好装满的方案数,也就是说只有恰好装满的情况 才可以被算上, 也就是说 只有从 初始时dp[0](这里的意思是:可以用二维数组进行解释为 dp[0][0])开始 递推出来最后的结果的 情况才可以被计算上,而从 初识时dp[1~n-1](这里可以理解为二维情况下的 dp[0][1~V])递推过来的情况 均是背包没有放满的情况正确代码 ,所要把dp[1~V]设为 0 (这里的0 表示这种方案 不符合题意),dp[0] 初始化为 1 (这里的1表示 这种方案 符合题意) ** 再把状态转移方程稍加 调整变为: dp[i][j] = sum(dp[i] + dp[i - vi] + wi );**


背包问题最优解回溯

回溯过程理解

通过上面的方法可以求出背包问题的最优解,但还不知道这个最优解由哪些商品组成,故要根据最优解回溯找出解的组成,根据填表的原理可以有如下的寻解方式:

  1. dp [i][j] = dp [i-1][j]时,说明没有选择第i 个商品,则回到dp[i-1][j];
  2. dp [i][j] = dp [i-1][j-vi]+wi时,说明装了第i个商品,该商品是最优解组成的一部分,随后我们得回到装该商品之前,即回到dp[i-1][j-w(i)];
    一直遍历到i=0结束为止,所有解的组成都会找到。

让我们模拟结合这个背包题模拟一下这个过程:
1.首先因为dp[4][5] = dp[3][5] 所以没有选择编号为3物品 d ,这个时候回溯到dp[3][5].
2.然后因为 dp[3][5] != dp[2][5] 所以我们选择了编号为3物品 c,这个时候回溯到 dp [2][2 = 5 - v3].
3.又因为 dp [2][2] != dp [1][2] 所以我们选择了编号为2物品 b ,这个时候回溯到 dp [0][0]
4.遍历到 i = 0 结束 ,这个时候我们就得到了所有 选择的物品:b , c ### 回溯代码如下
沿着箭头方向回溯

#include<iostream>
#include<algorithm>
using namespace std;

const int Len = 1005;
int dp[Len];
int v[Len],w[Len];  //v表示物品所占的空间,w物品的价值
int n,V;

int main()
{
    cin>>n>>V;
    for(int i = 1;i <= n;i ++)
        cin>>v[i]>>w[i];

    int flag[Len][Len] = {0};   //这里我们用二维数组 flag[][]来标记 当前的物品i是否被选用
    for(int i = 1;i <= n;i ++)  //如果被选用我们认为 我们就把该🈳️(i,j)位置标记为 flag[i][j] = 1;否则该值为默认为 0
        for(int j = V;j >= v[i];j --)
        {
            dp[j] = max(dp[j],dp[j-v[i]] + w[i]);
            if(dp[j] == dp[j - v[i]] + w[i]) //如果成了说明我们选择了i物品,进行标记
                flag[i][j] = 1;
        }

    int i = n,j = V;
    while(i > 0)            //回溯过程
    {
        if(flag[i][j])
        {
            j -= v[i];
            cout<<i<<endl;  //选择了编号为 i 的商品
        }
        i --;
    }
    //cout<<dp[V]<<endl;
    return 0;
}

背包第K大值问题

首先看 01 背包求最优解的状态转移方程:F[i,v] = max{F[i − 1,v],F[i − 1,v − Ci] + Wi}。如果要求第 K 优解,那么状态 F [i, v] 就应该是一个大小为 K 的队列 F[i,v,1…K]。其中 F[i,v,k] 表示前 i 个物品中,背包大小为 v 时,第 k 优解的 值。这里也可以简单地理解为在原来的方程中加了一维来表示结果的优先次序。显然 f[i, v, 1 . . . K] 这 K 个数是由大到小排列的,所以它可看作是一个有序队列。然后原方程就可以解释为:F [i, v] 这个有序队列是由 F [i − 1, v] 和 F [i − 1,v − Ci] + Wi 这两个有序队列合并得到的。前者 F[i − 1][V ] 即 F[i − 1,v,1…K],后者 F[i − 1,v − Ci] + Wi 则理解为在 F[i − 1,v − Ci,1…K] 的每个数上加上 Wi 后得到的 有序队列。合并这两个有序队列并将结果的前 K 项储存到 f[i, v, 1 . . . K] 中的复杂度是 O(K)。最后的第 K 优解的答案是 F[N,V,K]。总的时间复杂度是 O(V NK)。16为什么这个方法正确呢?实际上,一个正确的状态转移方程的求解过程遍历了所有 可用的策略,也就覆盖了问题的所有方案。只不过由于是求最优解,所以其它在任何一 个策略上达不到最优的方案都被忽略了。如果把每个状态表示成一个大小为 K 的数组, 并在这个数组中有序地保存该状态可取到的前 K 个最优值。正确代码 那么,对于任两个状态的 max 运算等价于两个由大到小的有序队列的合并。另外还要注意题目对于“第 K 优解”的定义,是要求将策略不同但权值相同的两 个方案是看作同一个解还是不同的解。如果是前者,则维护有序队列时要保证队列里的 数没有重复的。——转自《背包九讲》

说说我的理解

我认为这个理解着还行,当我们在一维dp[]的基础上再增加一个维度来存储前k个最大值(因为题目让 求第k大所以我们只需要存储前k大就行了),则状态转移方程就变成了 dp[j][k]。
接下来是讨论这个dp[j][q],可以理解为它是表示的背包容量为j时的第q大值,而dp[j][ ]可以理解为所有背包容量为j时的值,这样就方便下面的理解了。
在解决一般背包问题的时候我们的 状态转移方程为:
dp[j] = max(dp[j] , dp[j - vi] + wi; 这其实是经过简化后的式子,当我么加上“第k大这个条件的时候” 我们某一个状态二维数组dp就变成了:
**dp[j][k] ={dp[j][1],dp[j-vi][1] + wi,dp[j][2],dp[j - vi][2] + wi,dp[j][3],dp[j - vi][3] + wi, …dp[j ][k],dp[j - vi] + wi}**这个集合,我们只需要每次对这个集合排序,把前 k大的值 按从小到大的顺序放入dp[j][1 ~ k] 中,当对每一次状态转移都进行这样相同的操作时,我们就可以求出最后的结果 :dp[V][k] //容量为V时的第k个大的值;

看代码吧,更容易理解

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define For(a,b) for(int a=0;a<b;a++)
#define mem(a,b) memset(a,b,sizeof(a))
using namespace std;
typedef long long ll;
const int maxn =  1005;
const int INF = 0x3f3f3f3f;
const int inf = 0x3f;
int v[maxn]; ///n
int w[maxn]; ///m
int a[maxn];
int b[maxn];
int dp[maxn][35];///n\m
int main()
{
    int t,n,m;		//数据说明:t组输入,n个物品,背包空间为m
    int k;			//让球第k大
    int x,y,z;		//最为数组下标变量用的
    cin >> t ;
    while(t--)
    {
        cin >> n >>m >>k;
        mem(dp,0);
        For(i,n)
        cin >> v[i];
        For(i,n)
        cin >> w[i];
        /*-------------------------------01背包第K大主代码----------------------------------*/
        For(i,n)
        for(int j=m;j>=w[i];j--)
        {
            For(q,k)
            {                           //多一重循环,存上一层的状态
                a[q] = dp[j][q];
                b[q] = dp[j-w[i]][q]+v[i];
            }
            a[k] = b[k] = -1;                   //只求到第K大就行了,这里设置上界为K
            x = y = z = 0;
            while((x<k||y<k)&&z<k)
            {
                //开始合并
                a[x]>b[y]?dp[j][z]=a[x++]:dp[j][z]=b[y++];

                //合并a[x]和b[y]中小的那一个
                if(z==0||dp[j][z-1]!=dp[j][z])//确保数组中没有重复的数字
                    z++;
            }
        }
        cout << dp[m][k-1] << endl;
    }
    return 0;
}

习题传送门

1.01背包问题
2.Bone Collector hdu2602
3.M - 湫湫系列故事——减肥记I
4.C - 饭卡
5.G - 最大报销额
6.L - Dividing 恰好装满问题
7.D - Bone Collector II 第k大问题
题解以后会总结的


总结:01背包时各种背包问题的铺垫一定要好好理解,只有把它学好了学习其它的背包都是很容易😊
————————————————————————
其它背包问题传送门
————————————————————————

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值