背包DP学习笔记

01背包

由于01背包太过经典,所以一定要把每一个细节理解透彻!

nn 个物品和一个容量为 mm 的背包,每个物品有体积 wiw_i 和价值 viv_i,求用这个背包所能装下的最大价值。

fi,jf_{i,j} 表示只考虑前 ii 个物品,体积不超过 jj 的最大价值。如果我们算完了前 i1i-1 个物品的所有结果,那么第 ii 个物品有选和不选两种情况。如果不选,则结果为 fi1,jf_{i-1,j};如果买,则:由于选了 ii 后体积不超过 jj,那么选 ii 之前体积就不能超过 jwij-w_i,而选了 ii 之后获得的价值就多了 viv_i,所以结果为 fi1,jwi+vif_{i-1,j-w_i}+v_i。这样,我们就得出了经典的状态转移方程:fi,j=max(fi1,j,fi1,jwi+vi)f_{i,j}=max(f_{i-1,j},f_{i-1,j-w_i}+v_i)

接下来,我们再考虑一些细节。对于 ff 数组的初始化,我们可以将所有的方案全部赋值为 00,因为无论考虑多少物品,无论体积不超过多少,都一定有一种符合要求的方案:一个也不选。这时的价值就是 00

然后就到了经典的空间优化:我们可以发现 fif_i 这一行只与 fi1f_{i-1} 这一行有关,所以我们可以将 ii 这一维省略。这样,当我们准备求 fjf_j 时,我们要求 fjf_jfjwif_{j-w_i} 都还没有被更新过。因为我们正准备更新 fjf_j,所以第一个要求可以保证,那么怎么保证 fjwif_{j-w_i} 没有被更新过呢?答案就是倒序更新(这样就在更新 fjf_j 之后才会更新到 fjwif_{j-w_i} 了)。

接下来还有一个经典的常数优化:因为当 j<wij<w_i 时,转移方程中 fjwif_{j-w_i} 不存在,不需要考虑更新,所以 jj 必须大于等于 wiw_i,也就是倒序枚举时 jj 只需要从 mm 枚举到 wiw_i

所以,我们就得到了经典的01背包代码:

1
2
3
4
for(int i=1;i<=n;i++)
for(int j=m;j>=w_i;j--)
f[j]=max(f[j],f[j-w[i]]+v[i]);
cout<<f[m]<<endl;

例题:采药

正好填满的01背包

是一个01背包经典的变形,题意基本与01背包相同,但要求背包必须填满。

这时,ff 数组的意义发生了一点变化:fi,jf_{i,j} 表示只考虑前 ii 个物品,体积恰好为 jj 的最大价值。

但是,仔细推理一下,就会发现,状态转移方程和01背包一模一样,空间优化和常数优化也都通用。那不一样的地方在哪里呢?答案是初始化。由于要求体积恰好为 jj,所以当 j0j\ne 0 时,不允许一个也不选,所以初始化为负无穷(表示目前没有任何方案满足条件),当 j=0j=0 时,才存在一个也不选的方案,这时才能初始化为 00

代码:

1
2
3
4
5
6
memset(f,-0x3f,sizeof(f));
f[0]=0;
for(int i=1;i<=n;i++)
for(int j=m;j>=w[i];j--)
f[j]=max(f[j],f[j-w[i]]+v[i]);
cout<<f[m]<<endl;

二维费用背包

有两维费用(如:一个事件既要消耗时间也要消耗金钱,获得一定价值)的01背包。

将01背包多开两维费用,其他完全相同。

代码:

1
2
3
4
5
6
//背包第一维容量为m,背包第二维容量为t
for(int i=1;i<=n;i++)
for(int j=m;j>=w1[i];j--)
for(int k=t;k>=w2[i];k--)
f[j][k]=max(f[j][k],f[j-w1[i]][k-w2[i]]+v[i]);
cout<<f[m][t]<<endl;

例题:榨取kkksc03

完全背包

又是一个经典模型,也必须要理解透彻。题意与01背包基本相同,但每个物品能选无数遍。

同样设 fi,jf_{i,j} 表示只考虑前 ii 种物品,体积不超过 jj 的最大价值。这时如何更新呢?如果我们不选这个物品,那么结果为 fi1,jf_{i-1,j};如果选,那么结果为 fi,jwi+vif_{i,j-w_i}+v_i。这是为什么呢?在01背包中,我们当前要选 ii,那么选这个 ii 之前,只能考虑前 i1i-1 个物品,所以要从 fi1,jwif_{i-1,j-w_i} 转移,但是在完全背包中,每个物品可以选无数次,所以选这个 ii 之前,ii 也是可以选的,所以要从 fi,jwif_{i,j-w_i} 转移而来。这样,我们就得到了最终的转移方程:fi,j=max(fi1,j,fi,jwi+vi)f_{i,j}=max(f_{i-1,j},f_{i,j-w_i}+v_i)

同01背包一样,我们也可以省略掉 ii 这一维。这时,当我们求 fjf_j 时,要求变成 fjf_j 还没有更新,而 fjwif_{j-w_i} 已经更新过了(因为我们要用的是 fi,jwif_{i,j-w_i} 而不是 fi1,jwif_{i-1,j-w_i})。同样,第一个要求能直接满足,对于第二个要求,我们只需要正序枚举 jj 即可。所以,最终转移方程与01背包一样,但 jj 的枚举顺序变成了正序。

代码:

1
2
3
4
for(int i=1;i<=n;i++)
for(int j=w[i];j<=m;j++)
f[j]=max(f[j],f[j-w[i]]+v[i]);
cout<<f[m]<<endl;

例题:疯狂的采药

多重背包

题意与01背包基本相同,但每种物品能选 xix_i 次。

一个很容易想到的思路为将一种物品选 xx 次转换成 xx 个完全相同的物品,再做01背包。

这样的复杂度显然不够优秀,所以我们考虑优化。我们希望将每个物品选 xx 次转换成若干个物品,使得无论想选多少次都能用这若干个物品凑出来。一个较为明显的做法就是二进制分解。例如,我们有一个物品能选20次,我们就将它分解成一个 11 倍物品、一个 22 倍物品、一个 44 倍物品、一个 88 倍物品和一个 55 倍物品(几倍物品指的是体积和价值都为原物品的几倍)。易证,这一定满足我们的条件。这样,我们就将一个物品选 xx 次分解成了 log(x)\log(x) 个物品,然后再跑一遍01背包即可。

代码:

1
2
3
4
5
6
7
8
9
for(int i=1;i<=n;i++)
for(int tmp=1;x[i];tmp*=2) {
int num=min(tmp,x[i]);
int wt=w[i]*num,vt=v[i]*num;
for(int j=m;j>=wt;j--)
f[j]=max(f[j],f[j-wt]+vt);
x[i]-=num;
}
cout<<f[m]<<endl;

例题:宝物筛选

混合背包

将01背包、完全背包和多重背包缝合在一起的问题。

思路很简单,无需多讲解,分别考虑即可。形式为:

1
2
3
4
5
6
7
8
for(枚举物品)  {
if(01背包)
01背包代码
else if(完全背包)
完全背包代码
else
多重背包代码
}

实际上,01背包和多重背包可以共用多重背包的代码,因为01背包可以当成只能取一次的多重背包。

例题:樱花

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for(int i=1;i<=n;i++) {
if(x[i]==0) {//完全背包
for(int j=w[i];j<=m;j++)
f[j]=max(f[j],f[j-w[i]]+v[i]);
}
else {//01背包和多重背包
for(int tmp=1;x[i];tmp*=2) {
int num=min(tmp,x[i]);
int wt=w[i]*num,vt=v[i]*num;
for(int j=m;j>=wt;j--)
f[j]=max(f[j],f[j-wt]+vt);
x[i]-=num;
}
}
}
cout<<f[m]<<endl;

至此,基本模型已经讲完,其他变种模型以后有空再更新。