动态规划-背包问题

0-1背包问题

题目背景

有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。

第 i 件物品的体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

算法描述

在教科书上会是这么分析捏:

动态规划的基本要素

首先我们给出该问题的形式化描述:给定V>0,wi>0,vi>0,1≤i≤N,要找出n源0-1向量(x1,x2,……,xn),xi∈{0,1},w1x1+w2x2+……wnxn≤V,且达到最大。

最优子结构性质:0-1背包问题具有最有最结构性质,假设(y1,y2……,yn)是所给0,1问题的最优解,那么(y2,y3……yn)是下面相应问题的一个最优解:w2x2+……wnxn≤V-w1y1,xi∈{0,1},2≤i≤n

什么意思呢,这里分两种情况:

1.如果没有物品1,那么y1=0,那么(y2,y3……yn)是背包容量为V对应的最优解。
2.如果选了物品1,那么y1=1,那么(y2,y3……yn)是背包容量V-w1对应的最优解。

子问题重叠性质:这里的子问题重叠性质没有经典动态规划问题连乘矩阵那样明显,这里如果你i相同,容量j会不同;如果容量j相同,i就会不同。

构建递归关系:用m(i,j)表示当前背包容量为j,可选的物品为i,i+1……n时的最优解,这样:
m(i,j)=max{m(i+1,j),m(i+1,j-vi)+wi} j≥vi以及m(i,j)=m(i+1,j) j<vi,分别代表可以放得下和放不下物品i的情况。

我们可以发现,最后会递归到i=n,这时候我们就要给出一个结束递归的条件:m(n,j)=wn(j≥vn)以及m(n,j)=0(j<vn)

这样,我们就可以根据递归关系,自底向上求解

闫式DP

闫式DP的思想呢,是没有注重于动态规划问题的这些性质上面,是将0-1背包这一类问题转化为两方面考虑:

状态表示

其中,状态表示又包括:

集合

对于f(i,j)是指满足条件 :
①只从编号小于i的物品当中选择
②选择的物品总体积≤j

属性

对于f(i,j)表示满足上述条件的:
①最大值
②最小值
③数量

因而,状态表示可以用一句话概括为:f(i,j)表示满足条件的最大值(最小值/数量)

状态计算

状态计算是一种集合划分的思想,即将f(i,j)表示的集合划分为两部分(以表示的属性为最大值为例):

①不包含物品i:那么此时f(i,j)=f(i-1,j)
②包含物品i:那么此时f(i,j)=f(i-1,j-vi)+wi

这是因为无论包不包含物品i,对于编号1:i-1的选择都要是最优的,只不过对应的容量不一样。这样,f(i,j)=Max{f(i-1,j),f(i-1,j-vi)+wi}

而注意最后结束递归的条件,最后是不是一定可以计算到f(0,j),但是无论j是什么,我已经没有物品可以选择了,这样返回f(0,j)=0但需要注意的是,正是因为j什么值都可以取,所以我们令f[0][0:V]=0.但对于我们作为全局变量来说,初始值是0,所以不去设初值也没有关系~。

特别需要注意的是,f(i,j)=f(i-1,j)是没有判断条件的,而f(i,j)=f(i-1,j-vi)+wi必须要满足j-vi≥0才能够去计算。

而我们最终要得到的是f(n,v).

我们就可以根据这个递归关系去求解。

当然了,如果我们要和像教材那样表示的话,即f(i,j)表示满足条件从编号大于等于i的物品中选择且总体积小于V的价值最大值。那么:

不包含物品i时,f(i,j)=f(i+1,j);
包含物品i时,f(i,j)=f(i+1,j-vi)+wi

这样,f(i,j)=Max{f(i+1,j),f(i+1,j-vi)+wi}

这里最后递归的条件就要写了,当i=n时,f(n,j)=0(j<=vn)以及f(n,j)=wn(j>vn)。所以将f[n][vn+1:V]初始化为wn即可。

在这里,输出的是f[1][v],从≥1编号的物品,即全部物品中选择嘛。

代码实现

对于用f(i,j)表示编号小于i的物品:

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
const int VI=1010;
int f[N][VI];//f(i,j)函数
int V[N],W[N];//容量和价值
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>W[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=v;j++){
            f[i][j]=f[i-1][j];
            if(j>=V[i]){
                f[i][j]=max(f[i][j],f[i-1][j-V[i]]+W[i]);
            }
        }
    }
    cout<<f[n][v];
}

f(i,j)表示编号大于i的物品:

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
const int VI=1010;
int f[N][VI];//f(i,j)函数
int V[N],W[N];//容量和价值
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>W[i];
    }
    for(int i=V[n]+1;i<=v;i++){
        f[n][i]=W[n];
    }
    for(int i=n;i>=1;i--){
        for(int j=v;j>=1;j--){
            f[i][j]=f[i+1][j];
            if(j>=V[i]){
                f[i][j]=max(f[i][j],f[i+1][j-V[i]]+W[i]);
            }
        }
    }
    cout<<f[1][v];
}

代码优化:一维数组实现

我们观察这个式子:f(i,j)=Max{f(i-1,j),f(i-1,j-vi)+wi},可以看到,每次在考虑要不要取物品i的时候,只用到了i-1这个值,因而像i-2i-3……的值我们都用不到了(滚动数组),因而我们就可以把i这一维删去,直接替换

这样,f[j]表示当前容量小于j的价值的最大值。

我们直接看这个代码:

for(int i=1;i<=n;i++){
	for(int j=v;j>=vi;j--){
		 f[j]=max(f[j],f[j-vi]+wi);
	}
}

当遍历到i时,此时f[j]存储的是我们取前i-1个物品,容量小于j的价值之和的最大值明白这点很重要!!

这样,在遍历到第i个物品时:

1.若我不取物品i,那我取前i个物品,f[j]还是和取i-1个是一样的,就不用更新。

2.若我取物品i,这个时候我就要去比较,究竟是不取f[j]更大,还是取f[j-vi]+wi谁更大,那需要注意的是:

①我们j要从后往前开始遍历,这是因为,如果我从前往后遍历,我求出了f[1],f[2]……,那我在后面更新f[j-vi]不是会用前面的结果吗,那已经是考虑物品i的结果了。所以我们从后往前遍历,f[j-vi]就一定不会用到第i次遍历得到的结果。

②我们可以看到,第i-1次遍历,jv遍历到v[i-1],那么我在第i遍,jv遍历到v[i],可是我们注意到,我但凡要更新f[j-v[i]],那这样范围就变为了[0:v-v[i]],也就是说,我比较max(f[j],f[j-vi]+wi)的时候,我一定是已经计算好了

③还有一个问题,假设说考虑物品i的时候,我要用到f[0],但是我在第i-1遍遍历的时候,时从v遍历到v[i-1],并没有更新f[0]呀。

那你要换一种角度理解,事实上,我们每一次遍历的时候,把j0v都遍历了一遍:

for(int j=v;j>=0;j--){
	if(j<v[i]) f[j]=f[j];
	else f[j]=max(f[j],f[j-vi]+wi);
}

只是说if(j<v[i]) f[j]=f[j];这一句我们就给省略了,所以fj]0≤j≤v上的意义都是完整的。

这样,我们输出f[v]即可。

代码实现

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
int f[N];//f(i,j)函数
int V[N],W[N];//容量和价值
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>W[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=v;j>=V[i];j--){
                f[j]=max(f[j],f[j-V[i]]+W[i]);
        }
    }
    cout<<f[v];
}

完全背包问题

题目背景

有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用

第 i 种物品的体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

算法描述

相较于0-1背包问题,这里每一个物品都可以取无限件,那这样我们的分析有什么不同呢?

我们仍然用f(i,j)表示满足条件从编号小于i的物品中选择以及总容量≤j的价值最大值。但是我们现在考虑第i个物品时,第i个物品不再是只有两个选择,即0:不选;1:选物品i。

这时物品i有若干组选择,在满足总体积n*v[i]≤j的情况下,可以选择0个,1个……k个。也就是说在状态计算中,我们将集合会划分成k个,取其中的最大值

这样,f[i][j]=Max{f[i-1][j],f[i-1][j-v[i]]+w[i],f[i-1][j-2*v[i]]+2*w[i]……,f[i-1][j-k*v[i]]+k*w[i]};

唯一要满足的条件就是:j-k*v[i]≤j,当然,f[i][j]=f[i-1][j]是没有条件满足的,表示一个都装不下。

代码实现

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
const int V1=1010;
int f[N][V1];
int V[N],w[N];
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>w[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=v;j++){
            f[i][j]=f[i-1][j];
            for(int k=1;k*V[i]<=j;k++){
                f[i][j]=max(f[i][j],f[i-1][j-k*V[i]]+k*w[i]);
            }
        }
    }
    cout<<f[n][v];
}

代码优化 1.0

我们可以看到,朴素的完全背包问题是用了三层循环的,这样时间复杂度会比较高的。

这样,我们考虑下面的式子:

f[i][j]=Max{f[i-1][j],f[i-1][j-v[i]]+w[i],f[i-1][j-2*v[i]]+2*w[i]……,f[i-1][j-k*v[i]]+k*w[i]};

f[i][j-v[i]]=Max{f[i-1][j-v[i]],f[i-1][j-2*v[i]]+w[i],……f[i-1][j-k*v[i]]+(k-1)*w[i]}.

所以f[i][j-v[i]]+w[i]=Max{f[i-1][j-v[i]]+w[i],f[i-1][j-2*v[i]]+2w[i],……f[i-1][j-k*v[i]]+k*w[i]}

这样,f[i][j]=Max{f[i-1][j],f[i][j-v[i]]+w[i]}!!

所以我们现在只需要将集合划分成两个即可,注意上面的公式是f[i][j-v[i]]
,因为我是从前往后计算的,结果一定是算好了的。

代码实现

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
const int V1=1010;
int f[N][V1];
int V[N],w[N];
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>w[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=v;j++){
            f[i][j]=f[i-1][j];
            if(j>=V[i]) f[i][j]=max(f[i][j],f[i][j-V[i]]+w[i]);
        }
    }
    cout<<f[n][v];
}

代码优化2.0

同样的,类似于0-1背包问题,我们这里同样可以用一维数组实现。

注意到:f[i][j]=Max{f[i-1][j],f[i][j-v[i]]+w[i]}第i次遍历只会用到第i-1次遍历以及第i次遍历的结果,这样我们就可以把[i]删掉,即在第i次遍历时,f[j]表示取前i-1个物品且总体积≤j物品价值最大值。

但是这里非常巧妙,f[i][j-v[i]]恰好是用的第i遍遍历的结果,所以反而这里的j我们要从前往后遍历。反而如果从后往前遍历的话,用的是i-1次遍历的结果,对应的表达式应该是f[i][j]=Max{f[i-1][j],f[i-1][j-v[i]]+w[i]},就不对了。

代码实现

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
int f[N];
int V[N],w[N];
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>w[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=V[i];j<=v;j++){
            if(j>=V[i]) f[j]=max(f[j],f[j-V[i]]+w[i]);
        }
    }
    cout<<f[v];
}

多重背包问题

题目背景

有 N 种物品和一个容量是 V 的背包。

第 i 种物品最多有 si 件,每件体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。

输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。

接下来有 N 行,每行三个整数 vi,wi,si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。

算法描述

在多重背包问题中,每一个物品既不是只能选一件,也不是可以无限选,而是最多只能选si个。

那其实理解了完全背包问题的话,只需要对其代码作出修改:

for(int k=1;k*v[i]<=j;k++) f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i];

改为:for(int k=1;k*v[i]<=j && k<=s[i]) f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i];

代码实现

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
const int V1=1010;
int f[N][V1];
int V[N],w[N],s[N];
int main(){
    int n,v;
    cin>>n>>v;
    for(int i=1;i<=n;i++){
        cin>>V[i]>>w[i]>>s[i];
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=v;j++){
            f[i][j]=f[i-1][j];
            for(int k=1;k*V[i]<=j && k<=s[i];k++){
                f[i][j]=max(f[i][j],f[i-1][j-k*V[i]]+k*w[i]);
            }
        }
    }
    cout<<f[n][v];
}

同样的,我们可以对代码进行优化:

代码优化

我们会想,能不能继续沿用完全背包问题的优化方法呢?

首先,f[i][j]=Max{f[i-1][j],f[i-1][j-v[i]]+w[i],f[i-1][j-2*v[i]]+2*w[i]……,f[i-1][j-s[i]]*v[i]]+s[i]*w[i]};

这样,我们考虑f[i][j-s[i]]=Max{f[i-1][j-s[i]],f[i-1][j-2*v[i]],……,f[i-1][j-s[i]*v[i]],f[i-1][j-(s[i+1]+1)*v[i]]+(s[i+1]+1)*w[i].

很长啊,但是我们注意看,在完全背包问题里面,由于我是无限取物品,这样我最终都是到达f[i-1][j-k*v[i]],也就是j≥k*v[i].但这里就不一样啦,我可以多取一件的:[i-1][j-(s[i+1]+1)*v[i]]+(s[i+1]+1)*w[i],如果j-(s[i+1]+1)*v[i]≥0,那就可以选,你也不知道这个会不会更大对不对。

多重背包的二进制优化方法

对于每一个有s件的物品i,我们可以把这s件拆分成1+2+4+……+(2k)+c的组合,其中c=s-2k+1,且c<2k+1,有下面几个问题:

①这样的拆分是否可以表示[0:s]中的所有数?
答案是肯定的。每一个数都可以用二进制表示,1即为二进制的最后一位,2为二进制的倒数第二位……,所以1,2,4……2k的0,1组合可以表示0~2k+1-1中的所有数,而加上c之后就可以表示c-c+2k-1即c-s中的所有数。由于c<2k+1,这样2k+1-1和c中间是没有任何数的,是可以拼接起来的,因而1+2+4+……+(2k)+c可以表示0-s中的所有数。

这样,我们就可以把物品i都按照这样拆分,那么第二个问题来了:

②我们将物品这样拆分,然后按照0-1背包问题来求解,的确无论物品i多少件,都有这样拆分后的0-1组合与之对应,但是这会和原问题等价吗?

事实上,这个不难理解拟。回忆我们0-1背包问题,说每一个物品有体积vi和价值wi。那现在问题也是,每一个物品体积v[i]*2k,有价值w[i]*2k,然后说对于一个容量V来说,最大的一种装入的方式是什么?举个例子来说,假如下现在物品i有6个,那6=1+2+3,对应的二进制编码_ _ _就有八种组合咯,如果说在这8种组合里面选择了010,也就是五件里面选2件,价值总和最大,那是不是意味着其他7种组合,也就是选0、1、3、4、5件价值都没有2件大也就是说,在拆分过后选择的物品件数,其实相当于在1-s种选择的物品件数

只能解释到这个亚子啦,应该可以理解的把=w=。

代码实现

首先,我们要知道如何拆分:

对于我们每个输入的a(v),b(w),s:

int cnt=0;//用来记录拆分后所有物品的数量
int k=1;//表示1,2,4……
while(s>=k){
	cnt++;
	v[cnt]=a*k;
	w[cnt]=b*k;
	s-=k;//更新s
	k=k*2;//更新k
}
if(s>0){
	//在结束循环后,若s还大于0,这样就是那个常数c
	cnt++;
	v[cnt]=s*a;
	w[cnt]=s*b;
}

这样,物品数量变为了cnt,这样我们再用0-1背包的解法就可以。

还有一个问题,例如给定N≤1000,s≤2000,我们怎么确定我们需要多大的v和w的范围,即物品数量?

S=2000=1024+976,起码需要11位对不对,这样100个,f数组起码要开到11000.而且最好是不要开二维数组,因为可能f[11000][2000]就超过容量了。

具体代码

#include<iostream>
#include<algorithm>
using namespace std;
//假设每个物品都是100件,100=1+2+4+8+16+32+37,这样需要7个,总共1000个物品,10000足矣
int const N=12000;
int f[2010];
int v[N],w[N];
int main(){
    int n,value;
    cin>>n>>value;
    //下面对于每一个输入的v,w,s,我们都要把它拆分成。
    int cnt=0;//拆分之后总的物品数量
    for(int i=1;i<=n;i++){
        int a,b,s;//a代表容量,b代表容量,s就代表它的数量
        cin>>a>>b>>s;
        int k=1;//k=1,2,4……
        while(s>=k){
            cnt++;
            v[cnt]=k*a;//总的容量
            w[cnt]=k*b;//总的价值
            s-=k;//减去k
            k=2*k;
        }
        if(s>0){//如果此时s还大于0,说明还有c
            cnt++;
            v[cnt]=s*a;
            w[cnt]=s*b;
        }
    }
    //这样,就打包好了,一共有cnt个物品,下面用0-1背包就好了
    for(int i=1;i<=cnt;i++){
        for(int j=value;j>=v[i];j--){
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
    }
    cout<<f[value];
}

分组背包问题

题目背景

有 N 组物品和一个容量是 V 的背包。

每组物品有若干个,同一组内的物品最多只能选一个
每件物品的体积是 vij,价值是 wij,其中 i 是组号,j 是组内编号。

求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。

输出最大价值。

算法描述

在这个0-1背包问题的变形中,每一组的物品中只能选一个,我们用闫式DP分析:

状态表示:我们仍然用f(i,j)表示满足条件:只选择编号≤i的物品且总体积≤j的价值最大值。

状态计算,我们将f(i,j)划分为若干集合:
①第i组物品一个都不取:f(i,j)=f(i-1,j)
②第i组物品只取第一个:f(i,j)=f(i-1,j-v[i][k])+w[i][k],(那注意,这里要用二维数组去表示了)
……
③第i组物品知趣最后一个:f(i,j)=f(i-1,j-v[i][kmax])+w[i][kmax]

这样,我们可以得到:f[i][j]=max(f[i-1][j]),f[i-1][j-v[i][k]]+w[i][k],……f[i-1][j-v[i][kmax]]+w[i][kmax]

这样,在只需要把0-1背包问题的循环改成:

for(int i=1;i<=n;i++){
        for(int j=1;j<=value;j++){
            f[i][j]=f[i-1][j];
            for(int k=1;k<=s[i];k++){
                if(j>=v[i][k]) f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]);
            }
        }
    }

完整代码为:

#include<iostream>
#include<algorithm>
using namespace std;
const int N=110;//100个物品
int f[110][110];//容量
int v[N][N],w[N][N];
int s[N];
int main(){
    int n,value;
    cin>>n>>value;
    for(int i=1;i<=n;i++){
        cin>>s[i];//表示第i组物品有s[i]个
        for(int j=1;j<=s[i];j++){
            scanf("%d %d",&v[i][j],&w[i][j]);
        }
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=value;j++){
            f[i][j]=f[i-1][j];
            for(int k=1;k<=s[i];k++){
                if(j>=v[i][k]) f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]);
            }
        }
    }
    cout<<f[n][value];
}

代码优化

同样的,我们可以类比于0-1背包问题,对代码进行优化:我们省去一维,在第i次循环时,f[j]表示选择i-1组前的物品且容量小于j的价值的最大值。并且这里的通式是: f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]),是i-1,我们从后往前遍历

    for(int i=1;i<=n;i++){
        for(int j=value;j>=0;j--)
        {
            for(int k=1;k<=s[i];k++)
            {
                if(j>=v[i][k]) f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);
            }
        }
    }

这个代码和0-1背包问题稍有些不同哦,但是还是很好理解的应该。就是说我在第i次遍历的时候,比如到第二个物品了,那其实我是在和max(取第一个物品,不取第一个物品)相比较了,总之最后我就能得到,考虑第i组物品,各个容量对应的价值的最大值,只是说因为k要放里面,这样j肯定不能写:j>=v[i][k]了。

如果反过来写会怎么样呢?

for(int k=1;k<=s[i];k++){
	for(int j=value;j>=v[i][k];j--)
		f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);
}

一个物品对应所有的容量。。那当然是不行的呀,试想一下,在遍历第1遍的时候,我把f[j]都更新好了,是各个容量对应的,考虑第i组第一个物品价值的最大值。那我在考虑第2个物品的时候,要是j很大,j-v[i][k]还会≥0,那这时候我就又更新了,也就是说这个时候f[j]是选了第i组的第1个物品,也同时选了第2个物品!!这与每一组只能选一个是相违背的。

感谢AcWing平台

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值