本蒟蒻写二进制优化开始的时候写昏了,并且昏了一下午。但好在有神犇救命,这篇博客才得以面世——躲着人群
一、01背包
概述:
其常见的问题形式为:给出n个物品,每个物品有对应的价值和体积。给出背包容量后求不超过背包容量的条件下能获得物品的价值总和的最大/最小值
实现:
定义一个二维数组,dp[i][j] 表示在考虑前 i
个物品,且背包容量为 j
的情况下,能够获得的最大价值。那么,我们就能得到如下的状态转移方程:
首先,对于每个物品,我们都有选和不选两种选择
其中,dp[i-1][j]表示不选择第i个物品,dp[i][j-volume[i]]+value[i]表示选择第i个物品。这两者之中我们取值较大的那个。于是,我们就可以根据这个状态转移方程写出程序:
#include<bits/stdc++.h>
using namespace std;
int f[2000][2000],w[2000],p[2000],m,v;
int main(){
cin>>v>>m;//m:物品数量 v:背包容积
for(int i=1;i<=m;i++){
cin>>w[i]>>p[i];
}
for(int i=1;i<=m;i++){
for(int j=1;j<=v;j++){
if(w[i]<=j){
f[i][j]=max(f[i-1][j],f[i-1][j-w[i]]+p[i]);//在 w[i]<=当前背包容量时(也就是可以选),来考虑选不选的问题
}
else{
f[i][j]=max(f[i][j],f[i-1][j]);//超出容积,选不了
}
}
}
cout<<f[m][v];
return 0;
}
优化:
其中,我们发现其实f数组的第一维没有实际用处,因为i最后都会等于物品数。于是我们进行一个空间上的优化:把f换成一维数组,f[j]表示背包容量为j的时候所能获得的最大价值。
状态转移方程:
于是,我们得到以下代码:
#include<bits/stdc++.h>
using namespace std;
//w;重量 p;价值
int f[200000],w[2000],p[2000],m,v;
int main(){
cin>>v>>m;
for(int i=1;i<=m;i++){
cin>>w[i]>>p[i];
}
for(int i=1;i<=m;i++){
for(int j=v;j>=w[i];j--){//从背包容积大小递减到当前物品体积
f[j]=max(f[j],f[j-w[i]]+p[i]);
}
}
cout<<f[v];
return 0;
}
//#include<bits/stdc++.h> // 引入标准库,包括输入输出、算法、容器等
//using namespace std; // 使用标准命名空间,避免每次使用标准库时都需要加上std::前缀
//
定义全局变量
w: 物品的重量数组
p: 物品的价值数组
m: 物品的数量
v: 背包的容量
//int f[200000], w[2000], p[2000], m, v;
//
//int main() {
// // 输入背包的容量v和物品的数量m
// cin >> v >> m;
//
// // 读取每个物品的重量和价值,并存储到对应的数组中
// for (int i = 1; i <= m; i++) {
// cin >> w[i] >> p[i];
// }
//
// // 初始化动态规划数组f,这里虽然没有显式初始化,但C++的局部变量默认会初始化为0
// // f[j]表示当背包容量为j时,可以得到的最大价值
//
// // 外层循环遍历每个物品
// for (int i = 1; i <= m; i++) {
// // 内层循环逆序遍历背包的容量,从最大容量v递减到当前物品的重量w[i]
// // 逆序遍历是为了保证在计算f[j]时,f[j-w[i]]是未考虑当前物品i时的状态
// for (int j = v; j >= w[i]; j--) {
// // 更新f[j]的值,考虑是否将当前物品i放入容量为j的背包中
// // 如果放入,则总价值为f[j-w[i]](未放入当前物品时的最大价值)加上p[i](当前物品的价值)
// // 如果不放入,则总价值仍为f[j]
// // 取两者中的较大值作为新的f[j]
// f[j] = max(f[j], f[j - w[i]] + p[i]);
// }
// }
//
// // 输出当背包容量为v时的最大价值,即f[v]
// cout << f[v];
//
// return 0; // 程序正常结束
//}
值得注意的是,我们是对f数组进行“倒着遍历”的。因为01背包动态规划的基本原则就是现在循环枚举到的容量之前的必须是已经确定了的。如果我们正序遍历,f[j-w[i]]有可能是没有被状态转移过的值,进而使得答案错误。
二、完全背包
概述:
完全背包就是在01背包的基础上,引入了一个“物品数量”的概念,并且使得每个物品的数量为无限大。
实现:
同样,我们定义一个二维数组,让dp[i][j]表示考虑第i个物品并且背包容量为j时的最大价值。
#include<bits/stdc++.h>
using namespace std;
const int N=1e3+10;
int f[N][N],w[N],p[N],m,v;
int main(){
cin>>v>>m;
for(int i=1;i<=m;i++){
cin>>w[i]>>p[i];
}
for(int i=1;i<=m;i++){
for(int k=1;k<=v;k++)//循环物品的数量,因为背包的容积为v,我们最多就循环到v
for(int j=1;j<=v;j++){
if(w[i]*k<=j){
f[i][j]=max(f[i][j],f[i-1][j-w[i]*k]+k*p[i]);
}
else{
f[i][j]=max(f[i][j],f[i-1][j]);
}
}
}
cout<<"max="<<f[m][v]<<endl;
return 0;
}
但是请注意!!!!这并不是标准的完全背包的实现方式。大家也不要用这种方式,因为不稳定,它实际上是基于多重背包来实现的。下面,我们来看标准的方法:
#include<bits/stdc++.h>
using namespace std;
inline int read(){
char w=getchar();
int fl=1,sum=0;
while(w>'9'||w<'0'){if(w=='-')fl=-1;w=getchar();}
while(w<='9'&&w>='0'){sum=(sum<<1)+(sum<<3)+(w^48);w=getchar();}
return fl*sum;
}
int t,m;
int ti[10010],va[10010],f[10010];
int main(){
t=read();//背包容积
m=read();//物品数量
for(int i=1;i<=m;i++){
ti[i]=read();
va[i]=read();
}
for(int i=1;i<=m;i++){//循环每个物品
for(int j=1;j<=t;j++){//循环每个背包容量,注意是正序
if(j>=ti[i]){
f[j]=max(f[j],f[j-ti[i]]+va[i]);//状态转移
}
}
}
cout<<"max="<<f[t]<<"\n";
return 0;
}
同样值得注意的是,在使用一维数组解决完全背包问题时,我们对f数组进行正序遍历。为什么呢?
在完全背包问题中,如果我们对f
数组进行逆序遍历(就像01背包那样),我们可能会遇到一个问题:当我们尝试更新f[j]
时,实际上我们可能已经使用了f[j]
来更新f[j+w[i]]
等更大的容量值。这会导致我们错误地多次计入同一个物品的价值,因为f[j]
可能已经被更新为包含当前物品的状态。
然而,如果我们使用正序遍历,这个问题就不会发生。当我们遍历到f[j]
时,我们还没有更新任何f[j']
(其中j' > j
),这意味着f[j-w[i]]
仍然代表不包含当前物品时的最优解。因此,我们可以安全地将f[j-w[i]] + p[i]
(即不选当前物品的价值加上选择当前物品一个的价值)与f[j]
(即不选当前物品的价值)进行比较,以决定是否更新f[j]
。
三、多重背包
概述:
和完全背包类似,只不过物品数量不是无限大,而是通过输入获取的
实现:
第一种请参考上述完全背包中的二维数组的实现方式,这里就不过多赘述了
例题:
让我们根据一道例题,来深刻领悟下完全背包。
洛谷传送门:P1776 宝物筛选 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
如果直接使用上述完全背包中二维数组的方式实现,会超时。那么我们来考虑一种优化的方式(也就是让我昏了一下午最后被神犇救活的东西)
<二进制优化>
首先,我们先不管到底是怎么优化的。我们先来分析以下为什么按照原来那么写会超时
我们通过观察代码可以得出,
对于每个物品的数量,我们会从1开始对这个数量(设为s[i])进行累加,直到超过背包容量或者超过s[i]。
这个过程中,由于每次循环的步长为1,我们就一共会循环s[i]次。
然而对于同种物品,无论我们选择的是第一个,还是最后一个,本质上因为物品的体积和价值是一样的,那么我们可以认为这两种选择方案是等价的。
于是,我们可以通过减少循环次数(也就是不按照原来每一次加上1来遍历),而是通过对物品的数量、体积和价值进行预处理,将某个物品所有的组合方式用最基础的数量表示出来。而上述“最基础的”组合方式就是当前物品数量(s[i])转化为二进制数的一部分。这是因为二进制表示能够以最少的数字组合表示任何数量的物品。(任何数都可以由若干个2的幂次项组合而成,这些幂次项就构成了“最基础的”组合方式。)
代码实现(AC代码):
#include<bits/stdc++.h>
using namespace std;
inline int read(){
char w=getchar();
int fl=1,sum=0;
while(w>'9'||w<'0'){if(w=='-')fl=-1;w=getchar();}
while(w<='9'&&w>='0'){sum=(sum<<1)+(sum<<3)+(w^48);w=getchar();}
return fl*sum;
}
int n,m,v[10010],w1[10010],w[10010],s[20000],f[40010],cnt;
int v1[10010];
int main(){
n=read();m=read();
for(int i=1;i<=n;i++){
v[i]=read();
w[i]=read();
s[i]=read();//num
}
for(int i=1;i<=n;i++){
int nw=1,sum=1;
while(sum<s[i]){
w1[++cnt]=nw*w[i];
v1[cnt]=nw*v[i];
nw<<=1;
sum+=nw;
}
sum>>=1;
w1[++cnt]=(s[i]-sum)*w[i];//生成新物品的体积
v1[cnt]=(s[i]-sum)*v[i];//生成新物品的价值
}
//01背包的问题
for(int i=1;i<=cnt;i++){
for(int j=m;j>=w1[i];j--){
f[j]=max(f[j],f[j-w1[i]]+v1[i]);
}
}
cout<<f[m]<<"\n";
return 0;
}
代码二进制优化部分解释(即第一个while):
我们的想法是通过二进制编码的方式,对于每个物品数量s[i]进行一次编码(也就是分组),并且把每次分组的结果看成一个整体:
即假如对于9个物品,我们将其分组,通过二进制分为1,2,4,2(实际上我们就是在将s[i]转化为二进制数的形式来进行分组,只不过在转化过程中,我们并不是直接得到一个单一的二进制数,而是将这个二进制数展开成若干个2的幂次项(即二进制中的每一位代表的数),以及一个可能的余数(如果s[i]不是2的幂次方的和的话)。到第四位的时候,9-1-2-3=2,很明显不符合2的三次方(即8),于是,我们单独把"余数"放进去),这样我们就得到了4个数,但是这四个数字通过加法运算可以得出1-9的任意一个数字。到这里,我们就完成了“分组”
最后,我们通过将每一组看作一个整体的方式,将新生成的物品(比如两个一组就把这两个看作一个新的物品,价值为2乘上原来物品的价值,体积为2乘上原来物品的体积)保存到新数组里面,然后通过对每一组物品 选与不选 的方式进行01背包的算法,得出最后的最优解。
其实二进制优化的核心就是通过对物品分组,达到将很多物品的多重背包问题转化为01背包问题的目的。
最后,再次感谢神犇的帮助(鞠躬)