一开始接触背包问题,总会有些困扰,无法完全理解,或者说不断地忘记所谓的公式,特神说分享,可以让许多人少走弯路,我觉得颇有道理,其实我想做的,只是努力让让算法变得看上去简单易懂一些,因为我也曾是个迷惘无助,没有方向的初学者。
才疏学浅,言语颇显幼稚,如有纰漏请指教。
允许我摆脱背包单调的题面,给题目一些我喜欢的模样:
01背包问题
浙工大的向日葵地现在需要种植一些新的向日葵,现在有一些向日葵,你知道每株向日葵需要占用的位置大小,以及它们能产生的美丽值,告诉你最多能占用的位置。请你求出最大的美丽值。
如果没有接触过背包问题,可能有些人会有性价比的思想,就像淘宝购物时都会货比三家。
这种思想很单纯,也很符合常理。将美丽度除以占用位置大小,按照比值排序,这样子,每次就可以选到性价比最高的向日葵,这种想法在ACM中也有一席之地,我们称之为贪心算法。
但是显然,有些情况下,这是不成立的,为什么呢?因为空缺位置的浪费。如果有刚好可以填满位置的高性价比向日葵那么当然这是最优解,但是如果按照比值排序选取一些最优向日葵后,剩下的空位,放不下别的向日葵,但是别的比值较低的向日葵组合选取,可以获得更多的美丽值,当然后者的做法可以让这片向日葵田更加地美丽。就像,“买买买”也不是什么好事情,做出正确的选择,更为重要。
那么如何去正确选择需要种植的向日葵呢?答案是动态规划。动态规划的特点在于考虑每个阶段的最优情况。每个状态是从上一个状态中获取,而不是凭借单个的最优情况自以为是地作出错误判断。
现在给出状态方程,记dp[i][j]给出空位置大小为j,在对第i株向日葵做出决策时的最大美丽值,w[i]为第i株向日葵的位置占用,v[i]为第i株向日葵所能够产生的美丽值。
能够得到动态规划方程,dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]),就是每一次,对于不同的已有情况,选取新的向日葵是更优的,那么就保留这个更优值,否则,保留原来的值,那么对于每个dp[i-1][j],在对第i株向日葵判断时,都是最优的,那么所得到的dp[i][j]也均是最优的,最后得到的dp[i][j],也必然是最优的,最后获得的dp[i][j]中的最大值,当然就是最大的美丽值了。
那么,这个方程一定要占用那么大的空间么?答案是,不需要。
我们从这个方程可以得到,每次dp[i][j]都是从dp[i-1][j]中推导出来的,那么之前所记录的dp[0..i-2][j]在当前的步骤都是没有作用了的,那么,所占用的空间便可以节省了,所以最后得到的核心代码是
for(int i=1;i<=n;i++)
for(int j=V;j>=w[i];j--)
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
最后dp[0..V]中的最大值就是答案,那么为什么j的循环是倒序的呢,我想一定有人有这样的困惑,那是因为每株向日葵是独一无二的,在一种情况中只可以被选取一次,如果正序的话,因为每个dp[j]是从dp[0..j-1]中推导出来的,那么在从前往后推导的过程中,会出现反复选取同一株向日葵的情况了,当然,这也是一种题型,在后文中会提到。
那么为什么答案不是dp[V],而是dp[0..V]呢?因为,位置占用刚好的时候未必是最大值,或者更糟糕的时候,无法占用全部的位置。但是如果说,我们要用向日葵恰好填满所有空缺,dp[V]就是最终答案了,差之毫厘,就是完全不同的情况。
当然在这段核心代码和下文的代码中需要注意dp[i][j]的初始值都为0。
以上就是所谓的01背包的模型和其优化,当然,美丽值这种东西我随便写写,美丽是不可以被丈量的,艺术也一样,一个人的努力更是。这个社会的浮夸所导致的结果主义,我们未必需要跟随。
完全背包问题
现在,让我们来换一种情况,向日葵不是单株给出所占位置大小和美丽值,而是现在有许多不同种类的向日葵,我们来选取一些,使得美丽值最大。
让我来再明确一下这种情况和刚才那种情况的差别,就是每种给出的向日葵是可以无限使用的,要是你喜欢这种样子的向日葵,你不用担心它只有一株,你可以一直去用这种向日葵来装点这个校园,那么也许你猜到了动态规划的核心代码,没错,就是第一个给出的核心代码把循环改为正序后产生的代码
for(int i=1;i<=n;i++)
for(int j=w[i];j<=V;j++)
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
这种情况,就是完全背包类型的题目了。
是不是对背包系列的问题有了那么一些感觉呢?
多重背包问题
那么趁热打铁,再来看一种新的情况,我们对第二种情况加一个限制条件:每种种类的向日葵只能选取有限的个数,这个时候应该怎么做呢?
用c[i]表示第i种向日葵最多可以选取的次数。
其实很简单,在01背包的核心代码加入一重循环,即增加选取该向日葵的株数c[i]的循环,就可以完成这种类型的正确选取:
for(int i=1;i<=n;i++)
for(int j=V;j>=w[i];j--)
for(int k=1;k<=c[i];k++)
if(k*w[i]<=j)dp[j]=max(dp[j],dp[j-k*w[i]]+k*v[i]);
那么一定要每种情况都枚举么?一种类型的向日葵选取1株,2株,3株都尝试过去是否过于麻烦?这个时候二进制拆分的思想就可以起优化作用了,我们可以将向日葵的可选株数,进行拆分,比如7=4+2+1,那么在完全背包的模式下,只要枚举4,2,1三种情况,就可以了,因为在枚举选取两株是否可行的时候,会调用到之前的1株情况,那么就可以产生选取1,2,3株的不同情况下的最优值,当4被枚举到的时候,1至7株的情况就全部被枚举过去了,这个优化在执行的时候,效果还是很显著的,代码如下:
for(int i=1;i<=n;i++){
for(int k=1;k<=c[i];k<<=1){
for(int j=V;j>=k*w[i];j--)
dp[j]=max(dp[j],dp[j-k*w[i]]+k*v[i]);
c[i]-=k;
}
if(c[i]){
for(int j=V;j>=c[i]*w[i];j--)
dp[j]=max(dp[j],dp[j-c[i]*w[i]]+c[i]*v[i]);
}
}
这种背包模型就是多重背包了,二进制拆分处理的时候注意最后剩余的部分不要忘记。
二维费用背包问题
接下来,再看一下更为复杂的问题,如果,向日葵不仅需要占用有限的位置,还需要占据土地中有限的水资源,这个时候应该如何选取向日葵使得美丽值最大呢?也许理解了上面几种情况,你可以很容易地想出方法,增加一重循环,同时dp时增加一维,这样就可以完成这个任务了。代码如下:
for(int i=1;i<=n;i++)
for(int j=w[i];j<=V;j++)
for(int k=u[i];k<=W;k++)
dp[j][k]=max(dp[j][k],dp[j-w[i]][k-u[i]]+v[i]);
上面的代码明显是二维费用完全背包的核心代码,至于01背包和多重背包我想应该可以轻易类推出来。
分组背包问题
现在,增加一些有意思的元素,采购向日葵的时候,店家说,我这些向日葵都有一个分组,你所选取的向日葵都分别从属于不同的组,这样子搭配才美丽,也就是说,现在,在每个组中,我们至多只能选取一株向日葵,然后在这个前提下,使得向日葵的美丽值最大。
先来看一下核心代码:
for 所有的组k
for j=V..0
for 所有的i属于组k
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
在这个问题中,最为重要的就是如何处理在一个组中只选取一株向日葵的问题,我们对比一下01背包的核心代码和现在的核心代码,微妙的差别就在于循环的前后位置,01背包是在枚举每株向日葵的时候,枚举不同的位置分配,而现在是在枚举不同的位置分配的时候,枚举每个组的向日葵。因为先枚举分配的位置大小可以防止在同一个位置分配的最优情况中,同一组的向日葵有多个被选中。现在这个循环的顺序可以避免这种情况的出现。上述模型就是所谓的分组背包问题了。
树形背包问题
接下来来看另一种情况,如果店家说,有些向日葵必须要在种植了一些向日葵后种植才会好看,不然就显得很糟糕,现在告诉你他们的依赖关系,请你选取一些向日葵使得他们的美丽值最大,这种情形就比较麻烦了,就像技能树一样,要开启上面的技能,才能够继续开启下面的技能,这种问题的解决方法,就是树形DP,首先根据依赖情况构建一棵树,你可能会说,那不是森林么?因为有些物品没有依赖关系呀。这个时候我们就建立一个虚根节点,将所有的树根作为它的儿子节点。
做法是,在DFS的同时,进入每个儿子节点计算在不同分配位置大小的情况下获得的最大美丽值,最后上传最优值。注意在进入每个儿子节点的时候,该节点是必须选取的。可能这么说还是让人很迷糊,那么还是看一下搜索的核心代码再结合理解一下,会比较好:
void dfs(int x){
vis[x]=1;
for(int i=w[x];i<=m;i++)dp[x][i]=v[x];
for(int i=1;i<=n;i++)if(vis[i]==0&&map[x][i]){
dfs(i); for(int j=m;j>=w[x];j--)
for(int k=1;k<=j-w[x];k++)
dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[i][k]);
}
}
在代码中,我用map这个数组记录了x和i节点是否是父子关系,同时标记节点是否被访问,减少运算量。那么搜索一遍根节点就可以获得这个问题的最优值了。这种问题,我们称为树形背包问题。
背包问题的种类并不是我不断改变选取向日葵的方式就可以枚举得完的,关键是如何在掌握了基础的几种背包问题之后,在一个复杂的问题中能够快速找到模型,针对问题想出解决方案,这是一个ACMer所需要拥有的素质。
前段时间,一些事情闹的很不愉快,很多人把ACM说的一无是处,其实,我并不反对算法无用论,但是,ACM所给我带来并不仅仅是算法和数据结构而已,而是,在ACM这个圈子里,我很开心可以认识许多积极向上,对生活充满信心,即使被难题所困扰,仍然不会放弃希望的人,那些,并不能被所有人看到的东西,弥足珍贵。我深为自己身体里所流淌着的一些激昂而温和的情感,感到骄傲。
好像,开始一些伤感的话题了,我们回归正题,来看几道ACM的题目,来更好地理解一下背包问题的运用:
HDU - 5410 CRB and His Birthday
题目大意:CRB生日,妈妈要给他买礼物,妈妈有M元钱,这家店有N种礼物,因为店长和妈妈是熟人,所以若第i种礼物买x件的话,店长会给妈妈Ai*x+Bi颗糖果,现给出每种礼物的单价、Ai值与Bi值,问妈妈最多能拿到多少颗糖果。
题解:这是刚在暑假多校联赛时做过的题目,很显然这是一种背包问题,关键在于处理第一个特殊的礼物,这就相当于是一种特惠,跟现在的淘点点每日首单打折的差不多,b[i]的贡献仅在于买该种礼物的第一件,所以引入一维参数,表示是否买过这个物品,然后做完全背包即可。代码见下方:
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N=1005;
int T,m,n,dp[N*N][2],w[N],a[N],b[N];
int main(){
scanf("%d",&T);
while(T--){
scanf("%d%d",&m,&n);
memset(dp,0,sizeof(dp));
for(int i=1;i<=n;i++)scanf("%d%d%d",&w[i],&a[i],&b[i]);
for(int i=1;i<=n;i++)for(int j=0;j<=m;j++){
dp[j][0]=max(dp[j][0],dp[j][1]);
if(j>=w[i])dp[j][1]=max(dp[j-w[i]][0]+a[i]+b[i],dp[j-w[i]][1]+a[i]);
else dp[j][1]=0;
}printf("%d\n",max(dp[m][0],dp[m][1]));
}return 0;
}
POJ - 1742 Coins
题目大意: 有n种硬币,这n种硬币的价值为a[i],第i种硬币的个数为c[i]个,问有多少种方案,使这些硬币加起来等于m。
题解:这种题型可能有人说,叫背包方案数,但是其实就相当于是一个布尔型的多重背包问题,但是对于布尔型的多重背包问题,除了二进制拆分之外,有一种更为有效的方法,在每次更新方案时,记录某个硬币使用的次数,当次数大于给出的个数时就不进行方案更新。除此之外就是普通的多重背包了,代码见下:
#include <cstdio>
#include <cstring>
const int N=100005;
int dp[N],tot[N],A[105],C[105],n,m,ans;
int main(){
while(~scanf("%d%d",&n,&m)&&n+m){
for(int i=1;i<=n;i++)scanf("%d",&A[i]);
for(int i=1;i<=n;i++)scanf("%d",&C[i]);
memset(dp,0,sizeof(dp));dp[ans=0]=1;
for(int i=1;i<=n;i++){
memset(tot,0,sizeof(tot));
for(int j=A[i];j<=m;j++){
if(!dp[j]&&dp[j-A[i]]&&tot[j-A[i]]<C[i]){
tot[j]=tot[j-A[i]]+(dp[j]=1);ans++;
}
}
}printf("%d\n",ans);
}return 0;
}
HDU - 5445 Food Problem
题目大意:有n种食物,m种车,每种食物都有提供的能量值,占的空间,和数量,每种车有价值,可用空间,和数量,求获得一定能量最少需要花费多少代价。
题解:这是今年长春赛区网络赛的题目,乍一看,似乎是颇为复杂的题目,主要在于车和食物两边都有限制,没有一个固定的可供代价可以分配,所以显然一遍背包问题是不够的,那么做两遍背包问题呢?首先用背包计算出要获取一定数目的能量,最少需要占用多少空间,然后计算出获得题目所需能量,最少需要多少空间,同理,接下来,第二次使用背包计算出使用不同数目的租金,最大可以获得多少空间,那么大于空间的最小租金就是答案,同时多重背包可以用二进制拆分优化。
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int M=50105;
const int INF=1e9+7;
int T,V,u,v,w,n,m,p,t,x,y,z,f[M];
#define rep(i,m,n) for(int i=m;i<=n;i++)
#define red(i,m,n) for(int i=m;i>=n;i--)
void dpf(int t,int u){red(i,p+100,t)f[i]=min(f[i],f[i-t]+u);}
void dpg(int w,int v){red(i,50000,w)f[i]=max(f[i],f[i-w]+v);}
int main(){
scanf("%d",&T);
while(T--){
scanf("%d%d%d",&n,&m,&p);
memset(f,0x3f,sizeof(f)); f[0]=0;
while(n--){
scanf("%d%d%d",&t,&u,&v);
for(int k=1;k<=v;k<<=1){dpf(k*t,k*u);v-=k;}
if(v)dpf(v*t,v*u);
}V=INF;
rep(i,p,p+100)V=min(V,f[i]);
memset(f,0,sizeof(f));
while(m--){
scanf("%d%d%d",&x,&y,&z);
for(int k=1;k<=z;k<<=1){dpg(k*y,k*x);z-=k;}
if(z)dpg(z*y,z*x);
}int ans=INF;
rep(i,0,50000)if(f[i]>=V){ans=i;break;}
if(ans==INF)puts("TAT");else printf("%d\n",ans);
}return 0;
}
HDU - 3339 In Action
题目大意: 有n个电站,每个电站都有一定的电量,电站之间有一定距离,我们要从0点出发去占领一些电站,使得占领的电站电量之和超过总电量的一半,求达到条件所要走的最短距离。如果可能的话,输出距离,否则输出不可能。
题解:这道题涉及到了背包问题和其它算法的组合,个人感觉非常巧妙,首先计算所有点到起点的最短路,就得到了代价,然后又有价值,那么显然就是一个01背包问题,于是得解。
#include <cstdio>
#include <cstring>
#include <utility>
#include <queue>
#include <algorithm>
using namespace std;
const int N=20005;
const int INF=~0U>>1;
typedef pair<int,int>seg;
priority_queue<seg,vector<seg>,greater<seg> >q;
int sumv,sum,va[N],f[N],begin,end,d[N],head[N],u[N],v[N],w[N],next[N],n,m,a,b,c;
bool flag,vis[N];
void build(){
memset(head,-1,sizeof(head));
for(int e=1;e<=m;e++){
scanf("%d%d%d",&u[e],&v[e],&w[e]);
u[e+m]=v[e]; v[e+m]=u[e]; w[e+m]=w[e];
next[e]=head[u[e]]; head[u[e]]=e;
next[e+m]=head[u[e+m]]; head[u[e+m]]=e+m;
}
}
void Dijkstra(int src){
memset(vis,0,sizeof(vis));
for(int i=0;i<=n;i++) d[i]=INF;
d[src]=0;
q.push(make_pair(d[src],src));
while(!q.empty()){
seg now=q.top(); q.pop();
int x=now.second;
if(vis[x]) continue; vis[x]=true;
for(int e=head[x];e!=-1;e=next[e])
if(d[v[e]]>d[x]+w[e]){
d[v[e]]=d[x]+w[e];
q.push(make_pair(d[v[e]],v[e]));
}
}
}
void ZeroOnePack(int n,int v){
memset(f,0,sizeof(f));
for(int i=0;i<=n;i++)
for(int j=v;j>=d[i];j--)
f[j]=max(f[j],f[j-d[i]]+va[i]);
}
int main(){
scanf("%d",&n);
while(~scanf("%d%d",&n,&m)){
int sum=sumv=0;
build();
Dijkstra(0);
for(int i=1;i<=n;i++)scanf("%d",&va[i]),sumv+=va[i];
for(int i=1;i<=n;i++)if(d[i]!=INF)sum+=d[i];
ZeroOnePack(n,sum);
flag=false;
if ((sumv&1)==0) sumv=sumv>>1;
else sumv=(sumv>>1)+1;
for(int i=0;i<=sum;i++){
if(f[i]>=sumv){
printf("%d\n",i);
flag=true; break;
}
}if(!flag)puts("impossible");
}return 0;
}
暂且枚举这么几题我觉得比较典型的题目,加深一下对背包系列问题的理解。
背包问题的解法在许多动态规划的题目中都有一定的参考意义,熟练掌握,能举一反三,对其它类型的动态规划也有一定的帮助,所以,认真学习一下背包问题还是很有必要的。
向日葵的季节已经过去,ACM的路还长。