背包慢慢讲

一、原始背包(01背包)

问题:

有n个不同的物品,每一个物品的体积是c[i],价值是v[i],有一个背包最大容量为sum。现在在这n个物品里面任意选物品放入背包,使得背包里面物品的总价值最高。

思路:

现在我们把这n个物品一字排开,我们拖着个容量为v的背包从头走到尾去收物品。每走到一个物品面前我们就要想到底要不要这个物品。显然,我们的选择就只有两个:要或是不要(也就是01背包这个名字当中的选1个和选0个)。那我们怎么知道要不要呢?显然,我们就要比较这两个选择哪一个的收益高。如果不要的话,我们走到第i个物品面前的时候就相当于只是看看热闹,不进行任何操作,所以我们背包里的状态和走到i-1物品面前的时候是一样的。所以dp[i][sum]=dp[i-1][sum]。(dp[i][v]指的是从头走到第i个物品的时候,背包容量为v时能取得的最大价值,这里v就是sum。对于每一个i和v 的组合,dp[i][v]储存的都是一种状态,即前i个物品中在总体积在v以内的前提下能达到的最大价值。)如果要的话,我们就相当于在上一个物品的背包的状态基础之上放进这个物品,于是背包的容量就会减少c[i],价值增加v[i],即dp[i][sum]=dp[i-1][sum-c[i]]+v[i]。对于这两个方案,根据定义,显然我们需要选择其中值大的那一个,即dp[i][sum]=max(dp[i-1][sum], dp[i-1][sum-c[i] ]+v[i]);  显然我们在求dp[i][sum]的时候,我们要先知道dp[i-1][sum]的值,所以我们刷新dp状态的顺序的时候,i应该是从小到大更新。同时,我们也要知道dp[i-1][sum-c[i] ]的值。所以dp[i-1][sum ]到dp[i-1][0]的值我们都要先知道。综合之前说的,我们在计算dp[i][sum]的时候,dp[ 1 — i-1 ][0 — sum]都是应该知道的。所以,状态更新代码如下(我们最终的目的是通过这个更新代码一点一点得到dp[n][sum]

时间复杂度为O(n*sum)

for(int i=1; i<=n; i++)//第i物品
  {
    for(int j=0; j<=sum; j++) //目前容量为j
      {
        if(j<c[i])//如果背包容量比第i个物体体积还小的话无论如何也放不进去,所以只能不要它。
          dp[i][j]=dp[i-1][j];
        else
          dp[i][j]=max(dp[i-1][j],dp[i-1][j-c[i]]+v[i]);

      }
  }

printf("max value=%d\n",dp[n][sum]);



改进:

我们发现,每一次对i物品刷新时要比较两种选择的收益:dp[i-1][j]和dp[i-1][j-c[i]]+v[i],无论是哪一种选择,需要查询的一定都是上一个物品的状态,即dp[i-1][],且只是上一个物品,不会用到更前面的。而解决我们的问题只需要得到最后一步的dp[n][sum]就行了,所以我们是不是就可以在更新的时候就直接把上一层的覆盖掉(因为每一次更新只会用到上一层的),只有dp[v]呢?这样就能把dp[i][v]的二维空间变成一维空间,节省空间发复杂度。于是,新的代码产生了:

for(int i=1; i<=n; i++)//第i个物品
  {
    for(int j=sum; j>=c[i]; j--) //目前容量为j
      {
          dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
      }
  }

时间复杂度为O(n*sum)

这样每一次更新的时候,等号右边的dp就是上一层的状态。


这里有两个问题:

1、为什么j的值是c[i]~sum,而不是0~sum呢?

因为之前提过,如果背包容量比第i个物体体积还小的话无论如何也放不进去,所以只能不要它。这里我们是去覆盖的,如果j<c[i],就只有不选这一种方案,那么就把上一个的状态延续下来,也就是不操作,所以就可以省略掉了。当然,为了便于理解,你也可以写成下面这样:

for(int i=1; i<=n; i++)//第i个物品
  {
    for(int j=sum; j>=0; j--) //目前容量为j
      {
        if(j<c[i]) continue;
        
        dp[j]=max(dp[j],dp[j-c[i]]+v[i]);

      }
  }


2、为什么j循环是从大到小,可以从小到大吗?

因为涉及到一种方案是dp[j-c[i]]+v[i],也就是说dp[j]要由dp[j-c[i]]决定,显然j>j-c[i],即大角标由小角标决定。因为我们现在的更新操作是具有覆盖性的,如果我们的j循环是从小到大,那么小角标dp[j-c[i]]就会先更新,更新的时候就有可能会出现要选择当前i物品的情况。当我们循环走到大角标dp[j]的时候,用到的dp[j-c[i]]已经是此轮更新过的了,之前提到过,有可能小角标在之前更新的时候已经选择了当前物品,那么它的值就已经不是i-1时候的值了。而我们需要的一定要是i-1的状态,所以为了避免小角标先更新,我们就从大到小循环,这样就能解决这个问题了。





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




二、完全背包

问题:

有n种不同的物品,每一种物品的数量是无限的,每一种物品的体积是c[i],价值是v[i]。有一个背包最大容量为sum。现在在这n种物品里面任意选物品放入背包,使得背包里面物品的总价值最高。

思路1:

这与01背包的唯一差别就是每一种物品可以任意取,而01背包只能取一次(因为只有一个)。前面我们提到,对于01背包,j循环的顺序必须是从大到小才能保证当前所查询的dp数据是上一次更新的。如果我们从小到大循环,就有可能出现小角标dp[j-c[i]]在之前更新的时候已经选了当前的第i个物品,现在更新大角标dp[j]就会出问题。但是对于完全背包,每一种物品是数量无限,你想拿多少拿多少。那么对于更新大角标dp[j]的时候,你dp[j-c[i]]之前选没选当前第i个物品对我现在更新dp[i]没有一点影响,因为数量是无限的,就算你选了,我也还能选。而当当前的dp[j]成为之后的dp"小角标"的时候,对于那个大角标来说,这个"小角标"选了多少也无所谓,因为那个大角标也能继续选,所以这是满足可以取无限多个相同种类的物品的特性的。所以对于代码就只是j的循环顺序变了。代码如下:

for(int i=1; i<=n; i++)
  {
    for(int j=c[i]; j<=sum; j++) 
      {
        dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
      }
  }

时间复杂度为O(n*sum),此法为最优



思路2:

在面对01背包的时候,对于每一种物品,我们有两个选择,选和不选,也就是1和0,故称01背包。对于多重背包,面对每一种物品的时候,我们的选择有:选0个(即不选),选1个,选2个……一直选到背包装不下为止,所以我们每一次比较收益的时候就不只是在选和不选之间进行选择了,而是在这若干个选择中选出收益最大的。故代码如下:

scanf("%d%d",&sum,&x);

      for(int i=1; i<=x; i++)
        {
          scanf("%d%d%d",&c[i],&v[i],&n[i]);
        }

      for(int i=1; i<=x; i++)//x种物品
        {//如果背包容量比当前种类物品1个的体积还小,就不能选当前物品,那么dp就和上一层是一样的了,也就不用再来讨论了
          for(int j=sum; j>=c[i]; j--)
            {
              for(int k=1; k<=n[i]; k++)//k==0的时候就是不选当前物品,那么dp就和上一层是一样的了,也就不用再来讨论了
                {
                  if(j-k*c[i]>=0)
                    dp[j]=max(dp[j],dp[j-k*c[i]]+k*v[i]);
                }
            }
        }

      printf("%d\n",dp[sum]);
因为多了一层个数循环,每一次都要乘j/c[i]次的循环,所以时间复杂度大于O(n*sum)


思路3(二进制粗糙版,先看多重背包的二进制思想再看这个)

对于每一种物品,我们构造子物品数为1、2、4……2^k的新物品,其中k为满足c[i]*2^k<=sum的最大值。为什么说粗糙版呢?因为这k个物品的任意组合中可能会出现超过n[i]的情况,所以也是一直选,选到背包装不下就不装,有点像是上面的思路2和二进制思想的结合版,通过二进制化把上面的每一次的j/c[i]缩减成log(j/c[i])级的。但是时间复杂度仍然不如思路1,就暂时不贴代码了。





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





三、多重背包

问题:

有n种不同的物品,每一种物品的数量是n[i],每一种物品的体积是c[i],价值是v[i]。有一个背包最大容量为sum。现在在这n种物品里面任意选物品放入背包,使得背包里面物品的总价值最高。

思路1(雾):

我的思路是用一个数组来存当前物品用了多少次,如果j从c[i]到sum更新的时候,某一次选择当前i物品的收益大,也就是说要装入i物品,那我们就去看看这个物品用的次数超过它的个数没有,如果没有,就装它;如果已经用完了,就没办法装入了,就不选它。因为不同于完全背包,多重背包的一种物品个数是有限的,如果用不完就罢了,如果需要的次数超过它的个数的时候我们就要面临一个问题:该怎么把有限的该种物品分配给需要它的地方?我最初的想法是这无所谓,先需要的先满足,后面满足不了就算了。因为你每加进来一个我收益就提高一次,所以需要一个我就装进来一个嘛。代码如下:

      for(int i=1; i<=x; i++)//x个物品
        {
          memset(num,0,sizeof(num));//num为已使用个数
          
          for(int j=c[i]; j<=sum; j++)
            {
              if(num[j]<n[i]&&dp[j-c[i]]+v[i]>dp[j])//n[i]为i物体的个数
                {
                  dp[j]=dp[j-c[i]]+v[i];
                  
                  num[j]=num[j-c[i]]+1;
                }
            }
        }
       printf("%d\n",dp[sum]);

事实上这种想法是错误的。的确,每加进来一个物体就会带来一次增益。但是我忽略了一个问题,我们最终要求的是dp[i][sum],它的值是由dp[i-1][sum-c[i]]决定的,然后这个dp[i-1][sum-c[i]]又是由它自己的“dp[i-1][sum-c[i]]”决定的 ,然后以此类推 (当然,我知道这串回溯中可能有些是方案不选当前物体的,即dp[i][sum]=dp[i-1][sum],不过这不重要,只要出现了两重的回溯我的思路就有问题了)。那么问题就来了,我们的目的是使dp[i][sum]值最大,所以我们应该使这个回溯链中的每一环的收益最大,即应该满足回溯链中每一环应该优先选取第i个物品。但是我的思路就有个问题,对于dp[i][sum],它的dp[i-1][ ]在刷新的时候,可能在dp[i-1][sum-c[i]]的时候,当前的i-1物品已经在前面就被分完了,到它的时候已经没了,这样这一环就不是最优的,从而就不能保证整个回溯链最优了。于是乎,我就去看背包九讲的思路了。



思路2:

既然每种物品是有限件,那么我们为什么不可以把这一类的n个物品看成是n类数量都是1的c[i]和v[i]都一样的物体呢?这样的话就转换成01背包问题了。于是乎,代码如下:(时间复杂度为O(sum*Σn[i]))

scanf("%d%d",&sum,&x);//x种物品

      cnt=1;

      while(x--)
        {
          scanf("%d%d%d",&cost,&value,&num);//每一种物品的体积,价值和数量

          for(int j=cnt; j<cnt+num; j++)
            {
              c[j]=cost;
              v[j]=value;
            }

          cnt+=num;
        }


      for(int i=1; i<cnt; i++)
        {
          for(int j=sum; j>=c[i]; j--)
            {
              dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
            }
        }

      printf("%d\n",dp[sum]);



思路3:

在面对01背包的时候,对于每一种物品,我们有两个选择,选和不选。对于多重背包,面对每一种物品的时候,我们的选择有:选0个(即不选),选1个,选2个……选n[i]个,所以我们每一次比较收益的时候就不只是在选和不选之间进行选择了,而是在这 n[i]+1 个选择中选出收益最大的。于是代码如下:时间复杂度O(sum*Σn[i]))

scanf("%d%d",&sum,&x);

      for(int i=1; i<=x; i++)
        {
          scanf("%d%d%d",&c[i],&v[i],&n[i]);
        }

      for(int i=1; i<=x; i++)//x种物品
        {//如果背包容量比当前种类物品1个的体积还小,就不能选当前物品,那么dp就和上一层是一样的了,也就不用再来讨论了
          for(int j=sum; j>=c[i]; j--)
            {
              for(int k=1; k<=n[i]; k++)//k==0的时候就是不选当前物品,那么dp就和上一层是一样的了,也就不用再来讨论了
                {
                  if(j-k*c[i]>=0)
                    dp[j]=max(dp[j],dp[j-k*c[i]]+k*v[i]);
                }
            }
        }

      printf("%d\n",dp[sum]);

1、01背包是选0个(不选)和选1个相比,多重背包是选0个(不选)和用选1个,选2个……到选n[i]个依次相比,所以只需要多一层物品个数的循环。

2、因为更新dp[j]的时候用的dp角标都是比自己小的,所以这里更新的时候也一定是大角标先更新小角标后更新。且能明显看出,这里大角标在更新的时候小角标(dp[j-k*c[i]])的值只是用来查询了一下,本身并不会改变,值变的只有大角标(即当前的dp[j])

3、和原始背包不一样,这里的dp下标因为有k的循环有可能会变成负的,无法仅仅在j循环条件那里添加范围限制,于是只能在k循环里面添加限制条件。和上一种转换成01背包的思想相比,也正是这个地方可以带来一些时间上的节约。所以虽然时间复杂度是都是O(sum*Σn[i]),不过这是最坏的情况。对于这种思想方法,很多时候在k的循环里面都是能够节约时间的,所以这个种方法通常比转换成01背包更优。



思路4:

我们来说说一个神奇的东西,1、2、4、8……2^n这几个数在每个数只用一次的情况下能够表示1~2^n之间的所有整数。根据这个神奇的现象,我们可以构造出一些新物品:我们把同一种类的几个物品绑在一起作为一个新物品,这个新物品的的c[i]和v[i]自然是单个物品相应的值乘上组成这个新物品的原物品的个数。我们构造出新物品所含原物品个数分别为1、2、4、……、2^(k-1)、n[i]-2^k+1,其中k是满足n[i]-2^k+1>0的最大整数。这组数也有之前提到的神奇的性质,即这几个数在每个数最多只用一次的情况下可以表示1~n[i]的所有整数。例如:如果n[i]为13,就将这种物品构造出字物品个数分别为1,2,4,6的4个新物品。于是我们就能用这几个数字每个最多用一次地条件下构造出1~13之间的所有整数,如:1=1,2=2,3=1+2,4=4,5=1+4,6=6,7=1+6,8=2+6,9=1+2+6,10=4+6,11=1+4+6,12=2+4+6,13=1+2+4+6。这样的话,我们就可以转化成01背包来做了。我们在取这些新物品的时候,根据01背包的性质,每个物品都最多取1次,那么它们任意组合的值都是在1~n[i]之间的(刚刚已经证明了)。我们把这些物品一字排开,从头走到尾对于每一个新物品的选与不选带来的所有情况就完全等价于我们对于子物品个数在0~n[i]的不同选择。这种做法就把第i种物品个数复杂度O(n[i])变成了O(log n[i]),总复杂度就变成了O(sum*Σlog n[i])。代码如下:

memset(dp,0,sizeof(dp));

      scanf("%d%d",&sum,&x);

      for(int i=1; i<=x; i++)
        {
          scanf("%d%d%d",&cost,&value,&num);

          k=0;

          while(num-pow(2,k)+1>0)
            {
              k++;
            }

          k--;//注意k要-1,因为while循环是不满足条件才跳出来

          cnt=1;
          
          for(int i=cnt; i<cnt+k; i++)
            {
              c[i]=cost*pow(2,i-cnt);
              v[i]=value*pow(2,i-cnt);
            }

          c[cnt+k]=cost*(num-pow(2,k)+1);
          v[cnt+k]=value*(num-pow(2,k)+1);

          cnt+=k+1;
        }

      for(int i=1; i<cnt; i++)//x种物品
        {
          for(int j=sum; j>=c[i]; j--)//如果背包容量比当前种类物品1个的体积还小,那么dp就和上一层是一样的了,也就不用再来讨论了
            {
              dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
            }
        }

      printf("%d\n",dp[sum]);



我们下面举个例子把多重背包的几种方法都应用一下:

题目:HDU 2191

Problem Description
假设你一共有资金n元,而市场有m种大米,每种大米都是袋装产品,其价格不等,并且只能整袋购买。
请问:你用有限的资金最多能采购多少公斤粮食呢? 

Input
输入数据首先包含一个正整数C,表示有C组测试用例,每组测试用例的第一行是两个整数n和m(1<=n<=100, 1<=m<=100),分别表示经费的金额和大米的种类,然后是m行数据,每行包含3个数p,h和c(1<=p<=20,1<=h<=200,1<=c<=20),分别表示每袋的价格、每袋的重量以及对应种类大米的袋数。

Output
对于每组测试数据,请输出能够购买大米的最多重量,你可以假设经费买不光所有的大米,并且经费你可以不用完。每个实例的输出占一行。

Sample Input
  
  
1 8 2 2 100 4 4 100 2
 
Sample Output
  
  
400




代码(直接转换01背包):

#include<stdio.h>//直接转换01背包
#include<string.h>
#include<math.h>
#include<stdlib.h>
#include<vector>
#include<queue>
#include<stack>
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;

#define MaxSize 2005
#define inf 0x3f3f3f3f

int main()
{
  int T,x,sum,cnt;
  int cost,value,num;
  int v[MaxSize],c[MaxSize];//注意v数组c数组和大小,应至少开到MAX_x*MAX_num
  int dp[105];

  scanf("%d",&T);

  while(T--)
    {
      memset(dp,0,sizeof(dp));

      scanf("%d%d",&sum,&x);

      cnt=1;

      while(x--)
        {
          scanf("%d%d%d",&cost,&value,&num);

          for(int j=cnt; j<cnt+num; j++)
            {
              c[j]=cost;
              v[j]=value;
            }

            cnt+=num;
        }


      for(int i=1; i<cnt; i++)//cnt个物品
        {
          for(int j=sum; j>=c[i]; j--)
            {
              dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
            }
        }

      printf("%d\n",dp[sum]);
    }
  return 0;
}//FROM CJZ


代码(直接思路):

#include<stdio.h>//直接思路
#include<string.h>
#include<math.h>
#include<stdlib.h>
#include<vector>
#include<queue>
#include<stack>
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;

#define MaxSize 105
#define inf 0x3f3f3f3f

int main()
{
  int T,sum,x;
  int n[MaxSize],v[MaxSize],c[MaxSize];
  int dp[MaxSize];

  scanf("%d",&T);

  while(T--)
    {
      memset(dp,0,sizeof(dp));

      scanf("%d%d",&sum,&x);

      for(int i=1; i<=x; i++)
        {
          scanf("%d%d%d",&c[i],&v[i],&n[i]);
        }

      for(int i=1; i<=x; i++)
        {
          for(int j=sum; j>=c[i]; j--)
            {
              for(int k=1; k<=n[i]; k++)
                {
                  if(j-k*c[i]>=0)
                    dp[j]=max(dp[j],dp[j-k*c[i]]+k*v[i]);
                }
            }
        }

      printf("%d\n",dp[sum]);
    }
  return 0;
}//FROM CJZ



代码(二进制思想 -> 01背包):

#include<stdio.h>//二进制思想 -> 01背包
#include<string.h>
#include<math.h>
#include<stdlib.h>
#include<vector>
#include<queue>
#include<stack>
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;

#define MaxSize 2005
#define inf 0x3f3f3f3f

int main()
{
  int T,sum,x,cnt,k;
  int cost,value,num;
  int v[MaxSize],c[MaxSize];
/*注意v数组c数组和大小,应至少开到MAX_x*log(MAX_num)。这里我懒得算log(MAX_num)了,就用MAX_num代替了。因为我觉得一般来说动态规划主要卡时间,不怎么卡空间。*/
  int dp[105];

  scanf("%d",&T);

  while(T--)
    {
      cnt=1;
      memset(dp,0,sizeof(dp));

      scanf("%d%d",&sum,&x);//x种物品

      for(int i=1; i<=x; i++)
        {
          scanf("%d%d%d",&cost,&value,&num);

          k=0;

          while(num-pow(2,k)+1>0)//这个式子要记住
            {
              k++;
            }

          k--;//注意k要-1,因为while循环是不满足条件才跳出来

          for(int i=cnt; i<cnt+k; i++)
            {
              c[i]=cost*pow(2,i-cnt);
              v[i]=value*pow(2,i-cnt);
            }

          c[cnt+k]=cost*(num-pow(2,k)+1);
          v[cnt+k]=value*(num-pow(2,k)+1);

          cnt+=k+1;
        }

      for(int i=1; i<cnt; i++)//cnt个物品
        {
          for(int j=sum; j>=c[i]; j--)//如果背包容量比当前种类物品1个的体积还小,那么dp就和上一层是一样的了,也就不用再来讨论了
            {
              dp[j]=max(dp[j],dp[j-c[i]]+v[i]);
            }
        }

      printf("%d\n",dp[sum]);
    }
  return 0;
}//FROM CJZ

由上面的几个代码我们可以看出,对于每种方法,数组的大小是不一样的,所以开数组的时候一定要注意大小。因为oj在judge的时候并不是所有越界都会显示re,有时候是wa,如果直接是wa的话就很难意识到是数组大小的问题了。




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




  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值