十二、背包问题
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]}
- 这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。所以有必要将它详细解释一下:
- “将前
i
件物品放入容量为j
的背包中”这个子问题,若只考虑第i
件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1
件物品的问题。 - 如果不放第
i
件物品,那么问题就转化为“前i-1
件物品放入容量为j
的背包中”,价值为f[i-1][j]
; - 如果放第
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
- 第一行为两个正整数
n
,V
,表示有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
-
分析思路:
-
定义
f[i][j]
表示前i
件物品放入体积为j
的背包中能获得的最大价值 -
初始化时,
i==0 || j==0
时f[i][j]=0
,显然,没有物品,或背包为空时,价值为0
。 -
我们从
1~n
枚举每一件物品,对当前的第i
件物品进行分析:- 如果第
i
件物品的体积大于背包容量j
,则当前的最优等价于前i-1
件物品放入j
的背包中,即f[i][j]=f[i-1][j]
- 如果
v[i]<=j
,此时对第i
件物品,我们有两种决策:- 第
i
件物品放入容量为j
的背包,则前i-1
件物品能使用的背包容量只有j-v[i]
,此时:f[i][j]=f[i-1][j-v[i]] + cost[i]
。 - 不放入第
i
件物品,有可能让背包多放几件前i-1
件物品,此时:f[i][j]=f[i-1][j]
。 - 对上面两种方案都有可能是最优,所以我们取其较大者,即:
f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+cost[i])
- 第
- 如果第
-
如图所示:
-
代码实现:
#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; }
-
时间效率:
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
背包初始化细节
- 我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。
- 题目要求“恰好装满背包”时的最优解
- 初始化时除了
f[0]
为0
其它f[1..V]
均设为-∞
,这样就可以保证最终得到的f[V]
是一种恰好装满背包的最优解。
- 初始化时除了
- 题目并不有要求必须把背包装满,只要能装下即可。
- 初始化时应该将
f[0..V]
全部设为0
。
- 初始化时应该将
- 题目要求“恰好装满背包”时的最优解
- 为什么呢?可以这样理解:
- 初始化的
f
数组事实上就是在没有任何物品可以放入背包时的合法状态。 - 如果要求背包恰好装满,那么此时只有容量为
0
的背包可能被价值为0
的nothing
“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是-∞
了。 - 如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为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 优化
- 简单优化
-
若两件物品
i,j
满足v[i]<=v[j]
且c[i]>=c[j]
,则将物品j
去掉,不用考虑。- 显然任何情况下都可将价值小费用高得
j
换成物美价廉的i
,得到至少不会更差的方案。 - 对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。
- 并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。
- 显然任何情况下都可将价值小费用高得
-
将费用大于
V
的物品去掉。 -
使用类似计数排序的做法,计算出费用相同的物品中价值最高的是哪个,可以
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]
。两种代价可付出的最大值(两种背包容量)分别为V
和U
。物品的价值为c[i]
。
12.5.2 基本思路
-
费用加了一维,只需状态也加一维即可。
-
设
f[i][v][u]
表示前i件物品付出两种代价分别为v
和u
时可获得的最大价值。状态转移方程就是:f[i][v][u]=max{f[i-1][v][u],f[i-1][v-a[i]][u-b[i]]+w[i]}
-
当前状态只跟上一行状态相关,所以我们可以省略第一维:
- 当每件物品只可以取一次时变量
v
和u
采用逆序的循环。 - 当物品有无数件时采用顺序的循环。
- 当物品有有限件时,拆分物品。
- 当每件物品只可以取一次时变量
12.6 分组的背包问题
12.6.1 题目模型
- 有
N
件物品和一个容量为V
的背包。第i
件物品的体积v[i]
,价值是c[i]
。这些物品被划分为若干组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
12.6.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\}\)。
部分代码
还没顾上写;