十二、背包问题

12.1 0/1 背包问题

12.1.1 题目模型

  • N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 v[i] ,价值是 cost[i]。求解将哪些物品装入背包可使价值总和最大。

12.1.2 基本思路

  • 这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
  • 用子问题定义状态:即 f[i][j] 表示前 i 件物品恰放入一个容量为 j 的背包可以获得的最大价值。则其状态转移方程便是:f[i][j]=max{f[i-1][j],f[i-1][j-v[i]]+cost[i]}
  • 这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。所以有必要将它详细解释一下:
    1. “将前 i 件物品放入容量为 j 的背包中”这个子问题,若只考虑第 i 件物品的策略(放或不放),那么就可以转化为一个只牵扯前 i-1 件物品的问题。
    2. 如果不放第 i 件物品,那么问题就转化为“前 i-1 件物品放入容量为 j 的背包中”,价值为 f[i-1][j]
    3. 如果放第 i 件物品,那么问题就转化为“前 i-1 件物品放入剩下的容量为 j-v[i] 的背包中”,此时能获得的最大价值就是 f[i-1][j-v[i]] + cost[i]

12.1.3 例题

Description

给定 n 种物品和一个容量为 V 的背包,物品 i 的体积是 \(v_i\) ,其价值为 \(c_i\)。问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?

Input

  • 第一行为两个正整数 nV ,表示有 n 件物品,背包容量为 V \(( 1\le n\le 1000, 1\le V\le 10000)\)
  • 接下来n 行,每行两个正整数 \(v_i, c_i\) 表示第i 件物品的体积和价值。

Output

  • 只有一行,为能放入背包的最大价值。

Sample Input

4 8
2 3
3 4
4 5
5 6

Sample Output

10
  • 分析思路:

    1. 定义 f[i][j] 表示前 i 件物品放入体积为 j 的背包中能获得的最大价值

    2. 初始化时,i==0 || j==0f[i][j]=0,显然,没有物品,或背包为空时,价值为0

    3. 我们从 1~n 枚举每一件物品,对当前的第 i 件物品进行分析:

      • 如果第 i 件物品的体积大于背包容量 j ,则当前的最优等价于前 i-1 件物品放入 j 的背包中,即f[i][j]=f[i-1][j]
      • 如果 v[i]<=j ,此时对第 i 件物品,我们有两种决策:
        1. i 件物品放入容量为 j 的背包,则前 i-1 件物品能使用的背包容量只有 j-v[i] ,此时:f[i][j]=f[i-1][j-v[i]] + cost[i]
        2. 不放入第i 件物品,有可能让背包多放几件前 i-1 件物品,此时:f[i][j]=f[i-1][j]
        3. 对上面两种方案都有可能是最优,所以我们取其较大者,即:f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+cost[i])
    4. 如图所示:

    5. 代码实现:

      #include <cstdio>
      #include <cstring>
      #include <algorithm>
      const int maxn=1000+5,maxv=10000+5;
      int v[maxn],c[maxn],f[maxn][maxv];
      void Bag(int n,int V){
          for(int i=1;i<=n;++i)//依次枚举前i件物品
              for(int j=1;j<=V;++j)//从1~V枚举背包容量
                  if(j<v[i])f[i][j]=f[i-1][j];//如果无法放进第i件物品
                              else f[i][j]=std::max(f[i-1][j],f[i-1][j-v[i]]+c[i]);
      }
      void Solve(){
          int n,V;scanf("%d%d",&n,&V);
          for(int i=1;i<=n;++i) scanf("%d%d",&v[i],&c[i]);
          Bag(n,V);
          printf("%d\n",f[n][V]);
      }
      int main(){
          Solve();
          return 0;
      }
    6. 时间效率:O(n*V) ,内存:n * V

12.1.4 空间优化

  • 以上方法的时间和空间复杂度均为 O(N*V) ,其中时间复杂度基本已经不能再优化了,但空间复杂度却可以优化到 O(V)

  • 先考虑上面讲的基本思路如何实现:

    • 有一个主循环 i=1..N,每次算出来二维数组f[i][0..V] 的所有值。
    • 那么,如果只用一个数组f[0..V] ,能不能保证第i 次循环结束后 f[j] 中表示的就是我们定义的状态f[i][j] 呢?
    • f[i][j] 是由f[i-1][j]f[i-1][j-v[i]] 两个子问题递推而来,能否保证在推 f[i][j] 时(也即在第 i次主循环中推 f[j]时)能够得到 f[i-1][j]f[i-1][j-v[i]] 的值呢?
    • 事实上,这要求在每次主循环中我们以 j=V..0 的顺序推 f[j],这样才能保证推 f[j]f[j-v[i]] 保存的是状态f[i-1][j-v[i]]的值。
  • 主要代码如下:

    void Bag(int n,int V){
        for(int i=1;i<=n;++i)//依次枚举前i件物品
            for(int j=V;j>=v[i];--j)//从V~v[i]枚举背包容量
                f[j]=std::max(f[j],f[j-v[i]]+c[i]);
    }
  • 其中的 f[j]=max{f[j],f[j-v[i]]+cost[i]}一句恰就相当于我们的转移方程f[i][j]=max{f[i-1][j],f[i-1][j-v[i]]+cost[i]},因为现在的 f[j-v[i]]就相当于原来的f[i-1][j-v[i]]

  • 如果将j的循环顺序从上面的逆序改成顺序的话,那么则成了 f[i][j]f[i][j-v[i]] 推知,与本题意不符,但它却是另一个重要的背包问题最简捷的解决方案,故学习只用一维数组解 01背包问题是十分必要的。

  • 时间效率:O(n*V) ,内存:V

12.1.5 0/1 背包初始化细节

  • 我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。
    1. 题目要求“恰好装满背包”时的最优解
      • 初始化时除了f[0]0其它f[1..V]均设为-∞,这样就可以保证最终得到的f[V]是一种恰好装满背包的最优解。
    2. 题目并不有要求必须把背包装满,只要能装下即可。
      • 初始化时应该将f[0..V]全部设为0
  • 为什么呢?可以这样理解:
    • 初始化的f数组事实上就是在没有任何物品可以放入背包时的合法状态。
    • 如果要求背包恰好装满,那么此时只有容量为0的背包可能被价值为0nothing“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是-∞了。
    • 如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。
    • 这个小技巧完全可以推广到其它类型的背包问题,后面也就不再对进行状态转移之前的初始化进行讲解。

12.2 完全背包

12.2.1 题目模型

  • N 物品和一个容量为 V 的背包,每种物品都有无限件可用,第 i 件物品的体积是 \(v_i\),价值是 \(c_i\) 。求解将哪些物品装入背包可使价值总和最大。

12.2.2 基本思路

  • 这个问题非常类似于01背包问题,所不同的是每种物品有无限件

  • 从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、取1件、取2件……等。

  • 按照解01背包时的思路,令 f[i][j] 表示前 i 种物品恰放入一个容量为 j 的背包的最大权值。

  • 状态转移方程:f[i][j]=max{f[i-1][j-k*v[i]]+k*c[i]}(0<=k*v[i]<=j)

  • 核心代码:

    void Bag(int n,int V){//n件物品,背包荣咯昂为V
        for(int i=1;i<=n;++i){//枚举物品
            for(int k=0;k*v[i]<=V;++k)//取0~V/v[i]件i物品,k=0相当与不去第i件,此时f[i][j]=f[i-1][j]
                for(int j=k*v[i];j<=V;++j){//枚举容量 
                    f[i][j]=std::max(f[i][j],f[i-1][j-k*v[i]]+k*c[i]);
            }
        }
    }
  • 时间效率:O(N*V*k)

12.2.3 优化

  • 简单优化
  1. 若两件物品 i,j满足 v[i]<=v[j]c[i]>=c[j],则将物品j去掉,不用考虑。

    • 显然任何情况下都可将价值小费用高得j换成物美价廉的i,得到至少不会更差的方案。
    • 对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。
    • 并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。
  2. 将费用大于V的物品去掉。

  3. 使用类似计数排序的做法,计算出费用相同的物品中价值最高的是哪个,可以O(V+N)地完成这个优化。

    注意:以上优化并不能从实质上提高时间效率,不过也是在数据比较大的情况下,特别是随机数据有很明显的提升。

  • 二进制拆分优化

    • 分拆方法:

      • 把第i种物品拆成费用为 \(v[i]*2^k\) 、价值为 $ c [i]*2^k$ 的若干件物品,其中k满足 \(v[i]*2^k<=V\)
      • 这是二进制的思想,因为不管最优策略选几件第 i 种物品,总可以表示成若干个\(2^k\) 件物品的和。
      • 这样把每种物品拆成 \(O(log(V/v[i]))\)件物品,是一个很大的改进。
      • 注意 :使用二进制拆分后不适合用二维数组表示,为啥呢?
    • 核心代码实现:

      void Bag(int n,int V){//n种物品,背包荣咯昂为V
          for(int i=1;i<=n;++i){//枚举物品
             for(int k=1;k*v[i]<=V;k<<=1)//枚举第i种物品个数
                 for(int j=V;j>=k*v[i];--j)//枚举容量
                      f[i][j]=std::max(f[i-1][j],f[i-1][j-k*v[i]]+k*c[i]);//此表达式有误
                                  //因为此种定义方式使第i种物品只能取2^1,2^2……中的一种,而改为一维即正确
                                  f[j]=std::max(f[j],f[j-k*v[i]]+k*c[i]);//正确,比较下两种写法的区别,自己思考        
              }
          }
      }多重背包问题
  • O(VN)的算法

    我们只需把01 背包的一维数组写法的容量枚举的顺序由倒序变为正序即可。

    • 核心代码

      void Bag(int n,int V){
          for(int i=1;i<=n;++i)//依次枚举前i件物品
              for(int j=v[i];j<=V;++j)//从v[i]~V枚举背包容量
                  f[j]=std::max(f[j],f[j-v[i]]+c[i]);
      }
      • 代码只有v的循环次序不同而已。为什么这样一改就可行呢?

      • 首先想想为什么0/1背包中要按照j=V..0的逆序来循环。这是因为要保证第i次循环中的状态f[i][j]是由状态f[i-1][j-v[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第 i件物品的子结果f[i-1][j-v[i]]

      • 完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果f[i][j-v[i]],所以就可以并且必须采用j=0..V的顺序循环。这就是这个简单的程序为何成立的道理。

      • 这个算法也可以以另外的思路得出。例如,基本思路中的状态转移方程可以等价地变形成这种形式:

        f[i][j]=max{f[i-1][j],f[i][j-v[i]]+c[i]}

        • f[i-1][j] :表示第i 种物品一件也不取
        • f[i][j-v[i]] 表示前i种物品,包括第i种已取若干的基础上再取一件第i种物品

12.3 多重背包问题

12.3.1 题目模型

  • N 物品和一个容量为 V 的背包,第i种物品最多有cnt[i]件可用,第 i 件物品的体积是 \(v_i\),价值是 \(c_i\) 。求解将哪些物品装入背包可使价值总和最大。

12.3.2 基本思路

  • 和完全背包问题很类似。基本的方程只需将完全背包问题的方程略微一改即可
  • 因为对于第i种物品有cnt[i]+1种策略:取0件,取1件……取cnt[i]件。
  • f[i][j]表示前i种物品恰放入一个容量为j的背包的最大权值,则有状态转移方程:
    • f[i][j]=max{f[i-1][j-k*v[i]]+k*c[i]} (0<=k<=n[i])
    • 时间复杂度:\(O(V*\sum_1^ncnt[i])\)

12.3.3 二进制拆分优化

  • 将第i种物品分成若干件物品,其中每件物品有一个系数

  • 这些系数分别为\(2^0,2^1,2^2,...,2^{k-1},cnt[i]-2^k+1\),且k是满足\(cnt[i]\ge 2^k\)的最大整数。

    • 例如,如果cnt[i]13,就将这种物品分成系数分别为1,2,4,6的四件物品。
    • 1,2,4,6 能组合成1~13 之间的任何一个数。
  • 这样就将第i种物品分成了O(log cnt[i])种物品,将原问题转化为了复杂度为 \(O(V*\sum_1^n log\ ctn[i])\)01背包问题,是很大的改进。

  • 核心代码实现:

    void Bag(int n,int V){
        for(int i=1;i<=n;++i){//枚举物品
            int tot=0;//统计第i种物品已经分解出tot件
            for(int k=1;tot+k<=cnt[i] && k*v[i]<=V;k<<=1){//要分解出k件第i物品
                tot+=k;
                for(int j=V;j>=k*v[i];--j)//对分解出来的k件i物品做01背包
                    f[j]=std::max(f[j],f[j-k*v[i]]+k*c[i]);
            }
            int x=cnt[i]-tot;//二进制分解剩下部分,x有可能很大
            if(x)//剩下部分不为0,再跑一次01背包
                for(int j=V;j>=x*v[i];--j)
                    f[j]=std::max(f[j],f[j-x*v[i]]+x*c[i]);
        }
    }

12.3.4 O(VN)的算法

  • 多重背包问题同样有O(VN)的算法。这个算法基于基本算法的状态转移方程,但应用单调队列的方法使每个状态的值可以以均摊O(1)的时间求解。
  • 由于用单调队列优化的DP 目前对大家有一定难度,以后再讲

12.4 混合三种背包问题

  • 有的物品只可以取一次(01背包),有的物品可以取无限次(完全背包),有的物品可以取的次数有一个上限(多重背包)。应该怎么求解呢?

  • 显然,枚举每件物品时根据物品的件数,选择相应的背包。

    • 代码实现

      #include <cstdio>
      #include <cstring>
      #include <algorithm>
      const int maxn=1000+5,maxv=10000+5,Inf=0x7fffffff;
      int f[maxv],v[maxn],c[maxn],cnt[maxn];
      void multi_bag(int i,int V){//多重背包
          int tot=0;//统计第i种物品已经分解出tot件
          for(int k=1;tot+k<=cnt[i] && k*v[i]<=V;k<<=1){//要分解出k件第i物品
              tot+=k;
              for(int j=V;j>=k*v[i];--j)//对分解出来的k件i物品做01背包
                  f[j]=std::max(f[j],f[j-k*v[i]]+k*c[i]);
              }
          int x=cnt[i]-tot;//二进制分解剩下部分
          if(x)//剩下部分不为0,再跑一次01背包
              for(int j=V;j>=x*v[i];--j)
                  f[j]=std::max(f[j],f[j-x*v[i]]+x*c[i]);
          }
      void zero_bag(int i,int V){//01背包
          for(int j=V;j>=v[i];--j)
              f[j]=std::max(f[j],f[j-v[i]]+c[i]);
          }
      void complete_bag(int i,int V){//完全背包
          for(int j=v[i];j<=V;++j)
              f[j]=std::max(f[j],f[j-v[i]]+c[i]);
          }
      void Solve(){
          int n,V;scanf("%d%d",&V,&n);
          for(int i=1;i<=n;++i){
              scanf("%d%d%d",&cnt[i],&v[i],&c[i]);
              if(cnt[i]==1) zero_bag(i,V);
              else if(cnt[i]>=V/v[i]) complete_bag(i,V);
              else multi_bag(i,V);
          }
          printf("%d\n",f[V]);
      }
      int main(){
          Solve();
          return 0;
      }

12.5 二维费用的背包问题

12.5.1 题目模型

  • 对于每件物品,具有两种不同的费用;选择这件物品必须同时付出这两种代价;对于每种代价都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。设这两种代价分别为代价1和代价2,第i件物品所需的两种代价分别为a[i]b[i]。两种代价可付出的最大值(两种背包容量)分别为VU。物品的价值为c[i]

12.5.2 基本思路

  • 费用加了一维,只需状态也加一维即可。

  • f[i][v][u]表示前i件物品付出两种代价分别为 vu 时可获得的最大价值。状态转移方程就是:

    f[i][v][u]=max{f[i-1][v][u],f[i-1][v-a[i]][u-b[i]]+w[i]}

  • 当前状态只跟上一行状态相关,所以我们可以省略第一维:

    1. 当每件物品只可以取一次时变量 vu 采用逆序的循环。
    2. 当物品有无数件时采用顺序的循环。
    3. 当物品有有限件时,拆分物品。

12.6 分组的背包问题

12.6.1 题目模型

  • N 件物品和一个容量为 V 的背包。第 i 件物品的体积v[i],价值是c[i]。这些物品被划分为若干组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

12.6.2 基本思路

  • 这个问题变成了每组物品有两种策略:

    1. 选择本组的某一件
    2. 一件都不选
  • 也就是说设f[k][v] 表示前 k 组物品用容量为 v的背包装, 能取得的最大权值,则有:

    f[k][V]=max{f[k-1][V],f[k-1][V-v[i]]+c[i]} 物品i属于第k

  • 使用一维数组的伪代码如下:

    for 所有的组k
        for v=V..0
            for 所有的i属于组k
                f[v]=max{f[v],f[v-v[i]]+c[i]}
    • 注意这里的三层循环的顺序。for v=V..0 这一层循环必须在for 所有的i属于组k 之外。这样才能保证每一组内的物品最多只有一个会被添加到背包中。

12.7 例题

12.7.1 HDU - 2546 饭卡

题目大意

电子科大本部食堂的饭卡有一种很诡异的设计,即在购买之前判断余额。如果购买一个商品之前,卡上的剩余金额大于或等于5元,就一定可以购买成功(即使购买后卡上余额为负),否则无法购买(即使金额足够)。所以大家都希望尽量使卡上的余额最少。
某天,食堂中有 \(n\) 种菜出售,每种菜可购买一次。已知每种菜的价格以及卡上的余额,问最少可使卡上的余额为多少。

样例
样例输入 1
10
1 2 3 2 1 1 2 3 2 1
50
样例输出 1
32
样例 1 说明

有 10 种菜,结果自己算吧

样例输入2
1
50
5
样例输出2
-45
样例 2 说明

只有一种菜,价格为 \(50\),卡上余额 \(5\) 元,此时买这个菜,剩余 \(-45\)

分析
  • 此题不难,先自己想想实际生活中,如果卡里余额不小于 5 块钱,而且什么都能买,但是只能买一件,你会怎么买?
  • 很显然,如果钱足够,所有东西都买;如果不够,肯定要用尽量用 \(money-5\) 这么多钱买东西,最后剩下的钱买最贵的,非常贪婪。
  • 怎么实现最后买最贵的?显然把物品排序,最贵的放最后,因为我们跑背包是按照物品逐个处理的,因此,可以把前 \(n-1\) 个物品跑 01 背包,看看 \(money\) 这么多钱最多能花多少,假设花了 \(x\),再计算 \(money-x-price[n]\) 即可。
  • 证明稍后再加
部分代码
暂时不想写了

12.7.2 POJ - 2184 Cow Exhibition 题解

题目大意

\(N(N \le 100)\) 头奶牛,没有头奶牛有两个属性 \(s_i\)\(f_i\),两个范围均为 \([-1000, 1000]\)
从中挑选若干头牛,\(TS = \sum s[choose], TF = \sum f[choose]\)
求在保证 \(TS\)\(TF\) 均为非负数的前提下,\(TS+TF\)最大值。

样例
有 5 头牛,下面分别是每头牛的两个属性
5
-5 7
8 -6
6 -3
2 1
-8 -5
选择第 1、3、4 三头牛为最优解
虽然加上 2 号,总和会更大,但是 TF 会变成负数,不合法
分析
  • 首先从问题入手,先搞特殊情况:如果两个属性均为负数,果断舍弃,因为它一直在做负贡献
  • 一个物品有两个属性,会很自然想到二维费用背包,每个物品的价值为两个属性的和,也就是两种费用的和,这样定义其实意义并不大,而且时间复杂度为 \(O(N*S*F)\),最大会到 \(10^8\),应该会超时。
  • 由于价值直接是两者的和,所以我们没必要单独构造一个价值,而是把其中的一维改成价值即可,即用 \(S_i\) 当作费用,\(F_i\) 当作价值,最后扫一遍求最大和就可以了
  • 另外一个棘手的问题就是负数的问题:
    • 对于价值来说,正负都不影响,直接正常跑背包求最大值即可
    • 当费用为非负数时,没什么影响,正常跑 01 背包求最值,背包容积倒叙处理即可,\(f[j] = max \{f[j], f[j-s_i]+f_i\}\)
    • 当费用为负数时,如果直接用上述的式子,\(j-S_i > j\),而背包容积倒叙的话,\(f[j-s_i]\) 会先于 \(f[j]\) 被计算。如果直接这样写,会变成完全背包的样子,不妥。因此只需要把容积改成正序循环即可。
    • 由于下标不能为负数,我们可以将 \(0\) 点改成 \(100*1000\),这样的话,即使所有物品的费用都为负数,下标也依旧处在合法的范围内。此时背包的容积也就相应变成了 \([0~200000]\)
    • 注意跑背包的时候的边界即可
    • 最后统计时,当费用不小于 \(100000\) 时才表示 \(TS\) 的和为非负数,找到所有价值为非负数的那些,最后求两者和的最大值即可。
部分代码
心情好的时候再加

12.7.3 HDU - 3591 Coins 题解

题目大意

\(N\) 种不同面值的硬币,分别给出每种硬币的面值 \(v_i\) 和数量 \(c_i\)。同时,售货员每种硬币数量都是无限的,用来找零。
要买价格为 \(T\) 的商品,求在交易中最少使用的硬币的个数(指的是交易中给售货员的硬币个数与找回的硬币个数之和)。
个数最多不能超过 \(20000\),如果不能实现,输出 \(-1\);否则输出此次交易中使用的最少的硬币个数。

样例

\(3\) 种硬币,面值分别为 \(5, 25 50\),个数分别为 \(5, 2, 1\),要买 \(70\) 的商品,不存在给小费的情况下,最少的硬币个数为 \(3\)
自己使用 \(25\)\(50\) 各一个,找回一个面值为 \(5\) 的硬币。

分析
  • 这个问题在普通背包的基础上,加入了找零的情况,很显然,如果自己拥有的硬币,即使恰好能购买商品,也不一定是使用硬币最少的,例如样例中,自己恰好买的话,使用硬币数为 \(4\),即 \(5\)\(4\) 个,\(50\)\(1\) 个,共 \(5\) 个。
  • 既然要求最后支出 \(pay_{T+i}\) 与找回 \(back_i\) 的硬币总和最少,即求 \(\min\{pay_{T+i} + back_i\}\)
  • 对于样例来说,我们还需要考虑:
    • \(75\) 使用的个数 + 找 \(5\) 的个数
    • \(80\) 使用的个数 + 找 \(10\) 的个数
    • ...
    • 其中有些数是达不到的,因此需要加判断。
  • 我们可以对自己的硬币跑多重背包,最大容量为 \(20000\)\(pay_i\) 表示恰好付钱为 \(i\) 的时候所需要的最好硬币个数;对售货员跑完全背包,\(back_i\) 表示找回 \(i\) 所需要的的最少硬币个数。最后扫一遍,最小化 \(\min\{pay_{T+i} + back_i\}\)
部分代码
还没顾上写;

猜你喜欢

转载自www.cnblogs.com/hbhszxyb/p/12232305.html