背包问题是一个经典的动态规划模型。熟练掌握背包问题,对于动态规划的理解与应用有较大的提升。
笔者也是在读了dd engi的《背包九讲V1.1》后受到启发。本文的算法思路与解题技巧基本上属于搬运,可以算是笔者的读后笔记与个人总结。同时本文提供了较多篇幅的模板与源代码供读者借鉴参考、学习交流,相比于《背包九讲V1.1》,笔者在分组背包、多重背包的O(VN)算法单调队列实现等问题均给出了模板。
01背包问题
问题描述:有N件物品和容量为V的背包,放入第i件物品耗费费用是Ci,得到价值是Wi,求将哪些物品装入可使价值总和最大。
由题意可得,状态转移方程为:F[i,v]=max{F[i−1,v],F[i−1,v−Ci]+Wi}
进一步分析不难得到,可以将O(VN)的空间复杂度缩小至O(V)。以下是01背包模板。
void ZeroOnePack(int cost,int weight)
{
for(int i=V;i>=cost;i--)
dp[i]=MAX(dp[i-cost]+weight,dp[i]);
}
for(int i=1;i<=N;i++)
ZeroOnePack(c[i],w[i]);
完全背包问题
问题描述:有N种物品和一个容量为V的背包,每种物品都有无限件可用。放入第i种物品的费用是Ci,价值是Wi。求将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大。
与01背包问题类似,只不过每一件物品都能使用1件、2件……直至⌊V/Ci⌋件。我们可以按照01背包问题的思路来写状态转移方程,其时间复杂度为O(VN):F[i,v]=max{F[i−1,v−kCi]+kWi|0≤kCi≤v}
我们可以做一个简单优化,若两件物品i、j满足Ci≤Cj且Wi≥Wj,则将可以不用考虑物品j。得到的时间复杂度为O(N^2)。
二进制优化法。可以将物体拆分成费用为2^kCi、价值为2^kWi的若干件物品,其中k取遍满足2^kCi≤V的非负整数,得到的时间复杂度为O(log⌊V/Ci⌋)。
下面将介绍O(VN)的算法模板。
void CompletePack(int cost,int weight)
{
for(int i=cost;i<=V;i++)
dp[i]=MAX(dp[i-cost]+weight,dp[i]);
}
for(int i=1;i<=N;i++)
Complete(c[i],w[i]);
多重背包问题
问题描述:有N种物品和一个容量为V的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci,价值是Wi。求解将哪些物品装入背包可使这些物品的耗费的空间总和不超过背包容量,且价值总和最大。
和01背包、完全背包类似,状态转移方程:F[i,v]=max{F[i−1,v−k∗Ci]+k∗Wi|0≤k≤Mi} ,复杂度是O(VΣMi)。
当VN>10^7左右时会超时,下面介绍一种二进制优化方法,能将时间复杂度缩小为O(VΣlogMi)。
void MutiplePack(int cost,int weight,int amount)
{
if(cost*amount>V)//可用数量装入时超过背包容量,即可视为完全背包
{
Complete(cost,weight);
return;
}
int k=1;
while(k<amount)
{
ZeroOnePack(k*cost,k*weight);//将物品拆分成多个01背包
amount-=k;
k*=2;
}
ZeroOnePack(cost*amount,weighr*amount);
}
for(int i=1;i<=N;i++)
MutiplePack(c[i],w[i],m[i]);
使用二进制优化虽然可以较大地提高时间效率,但是对于更高要求的数据规模仍然乏力。我们可以使用单调队列优化,使多重背包达到O(VN)的时间复杂度。
//MAX_V 背包的体积上限值
void MutiplePack(int cost, int weight, int amount)
{
if(amount==0||cost==0) return;
if(amount==1) //01背包
{
for(int i=V;i>=cost;i--)
if(dp[i]< f[i - v] + w) f[i] = f[i - v] + w;
return;
}
if(amount*cost>=V-cost+1) //完全背包(amount>= V/cost)
{
for(int i=cost;i<=V;i++)
if(dp[i]<dp[i-cost]+weight)
dp[i]=dp[i-cost]+weight;
return;
}
int va[MAX_V],vb[MAX_V]; //va/vb: 主/辅助队列
for(int j=0;j<cost;j++)
{
int *pb=va,*pe=va-1; //pb/pe分别指向队列首/末元素
int *qb=vb,*qe=vb-1; //qb/qe分别指向辅助队列首/末元素
for(int k=j,i=0;k<=V;k+=v,++i)
{
if(pe==pb+n) //若队列大小达到指定值,第一个元素X出队
{
if(*pb==*qb) ++qb;
++pb; //若辅助队列第一个元素等于X,该元素也出队。
}
int tt=dp[k]-i*w;
*++pe=tt; //元素X进队
while(qe>=qb&&*qe<tt) --qe;
*++qe=tt //元素X也存放入辅助队列
dp[k]=*qb+i*w;
}
}
}
混合三种背包问题
//p[]为判断第i件物品时何种背包问题。设p[]=1为01背包,p[]=2为完全背包,p[]=3为多重背包
for(i=1;i<=N;i++)
{
if(p[i]==1)
ZeroOnePack(c[i],w[i]);
if(p[i]==2)
CompletePack(c[i],w[i]);
if(p[i]==3)
MutiplePack(c[i],w[i],m[i]);
}
二维费用背包问题
问题描述:对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种费用。对于每种费用都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。设第i件物品所需的两种费用分别为Ci和Di。两种费用可付出的最大值(也即两种背包容量)分别为V和U,物品的价值为Wi。
由题意易得,只要将费用加上一维,就能得到状态转移方程,其中u和v为两种不同的费用:
F[i,v,u]=max{F[i−1,v,u],F[i−1,v−Ci,u−Di]+Wi}
分组背包问题
问题描述:有N件物品和一个容量为V的背包。第i件物品的费用是Ci,价值是Wi。这些物品被划分为K组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
设F[k,v]为前k组取得的最大价值,策略是可以选择第k组中的某一件物品或者不选。可得状态转移方程为:
F[k,v]=max{F[k−1,v],F[k−1,v−Ci]+Wi|itemi∈groupk}
我们可以优化空间复杂度,用一维数组给出模板。
//为每件物品新增参数g[i],表示第i件物品所在的组别
void GroupPack(int group)//group表示组别
{
for(int i=V;i>=0;i--)
{
int j=1;
while(j++<=N&&g[j]==group) //对所有属于第k组的物品枚举
dp[i]=MAX(dp[i-c[j]]+w[j],dp[i]);
}
}
for(int i=1;i<=k;i++)//调用每个组
GroupPack(i);
需要注意的是,for(i=V;i>=0;i--)这层循环必须写在while(j)循环的外围,这样才能保证每一组内的物品最多只有一个会被添加到背包中。
有依赖背包问题
这种背包问题的物品间存在某种“依赖”的关系。也就是说,i依赖于j,表示若选物品i,则必须选物品j。为了简化起见,我们先设没有某个物品既依赖于别的物品,又被别的物品所依赖;另外,没有某件物品同时依赖多件物品。
这里给出一道例题,是NOIP上的经典题目《金明的预算》。说实话,笔者在发稿时对于有依赖的背包问题仍不求甚解,《金明的预算方案》此题笔者虽然AC了,但是并没有用“标准”的做法。笔者是将主件和附件的关系表示为:主件、主件+一个附件、主件+两个附件。也就是说一个(主)物品可以拆分成三个物品,其花费与价值另算。
对于部分有依赖背包问题都可以采用笔者这种方法,但是由于附件数量的增加,其物品拆分量也是指数级增涨,当增多附件数较大时,不宜采用。
就一般来说,有依赖的背包问题大部分可以用树形DP来做。以下给出了树形DP的解法,采用了泛化背包的解法,复杂度为O(VN)。
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 210;
struct edge {
int v, c;
edge* next;
} *V[MAXN], ES[MAXN * 2];
int EC, dp[MAXN][MAXN], val[MAXN];
bool vis[MAXN];
void addedge(int u, int v)
{
ES[++EC].next = V[u];
V[u] = ES + EC; V[u]->v = v;
}
void initdata(int n)
{
EC = 0;
memset(V, 0, sizeof(V));
memset(vis, false, sizeof(vis));
for (int u = 1; u <= n; ++u)
{
int v;
scanf("%d %d", &v, &val[u]);
addedge(v, u);
}
}
void treedp(int u, int vol)
{
vis[u] = true;
for (edge* e = V[u]; e; e = e->next)
{
if (vis[e->v])
continue;
for (int i = vol; i >= 0; --i)
dp[e->v][i] = dp[u][i];
treedp(e->v, vol - 1);
for (int i = vol; i >= 1; --i)
dp[u][i] = max(dp[u][i], dp[e->v][i-1] + val[e->v]);
}
}
int main()
{
int n, m;
while (scanf("%d %d", &n, &m) && n && m)
{
initdata(n);
memset(dp[0], 0, sizeof(dp[0]));
treedp(0, m);
printf("%d\n", dp[0][m]);
}
return 0;
}
泛化物品
一个物品组可以看作一个泛化物品h。对于一个0..V中的v,若物品组中不存在费用为v的的物品,则h(v)=0,否则h(v)为所有费用为v的物品的最大价值。如果面对两个泛化物品h和l,要用给定的费用从这两个泛化物品中得到最大的价值,怎么求呢?事实上,对于一个给定的费用v,只需枚举将这个费用如何分配给两个泛化物品就可以了。同样的,对于0..V的每一个整数v,可以求得费用v分配到h和l中的最大价值dp(v)。也即 dp(v)=max{h(k)+l(v−k)} 0<=k<=v
泛化物品的定义表明:在一个背包问题中,若将两个泛化物品代以它们的和,不影响问题的答案。事实上,对于其中的物品都是泛化物品的背包问题,求它的答案的过程也就是求所有这些泛化物品之和的过程。设此和为s,则答案就是s[0..V]中的最大值。
背包问题问法变式
初始化
如果题目要求恰好装满背包,那么在初始化时除了F[0]为0,其它F[1..V]均设为−∞,这样可以保证得到的F[V]是一种恰好装满背包的最优解。如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将F[0..V]全部设为0。这个细节可以推广到其他背包问题。
求方案总数
解法一般只需将状态转移方程中的max改成sum即可。若每件物品均是完全背包中的物品,转移方程为:f[i][v]=sum{f[i−1][v],f[i][v−c[i]]},初始条件f[0][0]=1。
事实上,这样做可行的原因在于状态转移方程已经考察了所有可能的背包组成方案。