背包算法简介

一、概述

   动态规划(DP)算法是初学者的一个难点。思考DP问题时,核心思路仍和其它算法类似,将复杂问题分解为相对更简单的问题。简单说,一个问题规模N的问题是否能分解成N-1的问题(递归)?或者能否从规模1开始推导得到规模N(递推和DP)。

    背包算法就是一种典型的从规模1推导到规模N的算法,是最常见的一种DP算法。它的核心要素有三个:背包容量,物品重量(或体积),物品价值,题目一般会要求在背包容量限制下获取最大价值。最简单的背包问题,有N件物品,每件物品都有一个重量和价值,在背包空间有限情况下如何选择物品,使得价值最大。

    这类问题显然不能用贪心思想解决。可以把物品编号从1......N,通过从物品1开始推导的方式解决。(1)只有物品1,背包够大时最大价值就是物品1的价值。(2)物品2拿过来,此时背包空间够大显然应该拿取物品1和2,如果不够大就要做一个取舍。(3)当物品3拿过来时,此时就有4种前置状态:空包、只有物品1,只有物品2,物品1和物品2。但如果这样处理,就演变成一个搜索问题,复杂度为2^N。搜索算法会计算大量无效状态,效率极低。背包算法的巧妙之处在于:通过计算“不同的背包空间"下拿取前i个物品的最大价值,避免了大量无效的搜索,从而把复杂度降为N^2。

    背包三要素可能表现为多种形式,比如背包容量是时间(以下题目均在洛谷)(P1048 采药),体力(P1510 精卫填海),数值(P1734 最大约数和)。物品重量和背包容量在同一题目中的概念总是一致的。

    背包问题主要有四种基础类型:01背包、完全背包、分组背包、多重背包。在此之上有针对背包容量进行升级的二维背包(有两个不同的容量限制)、有针对物品种类进行升级的混合背包(比如有些物品01,有些物品多重),有存在依赖关系的背包,比如拿B物品必须拿A物品。

   从本文作者的经验看,只要掌握了01背包的二维写法,其他所有背包问题都可以通过此算法扩展出来。换句话说,所有的背包问题都是01背包的拓展。比如说有依赖关系的物品A和B(拿B必须拿A),可以想象成是两件物品做01背包,一件是A,一件是(A+B)。

二、01背包

    01背包指的是n个物品都是唯一的,我们对每一件物品只有选择或者不选择两种操作。01背包是所有其他背包问题的基础。通过转换思维方式,可以用01背包的二维数组解法解出所有的背包问题。 01背包的基础作法与其他动规类题目类似,每一次循环完成一个物品的计算工作。用二维数组dp[i][j]表示拿完第i件物品时j背包容量的最大值或方案数。

    在物品价值方面,如果题目给了价值或者价值计算方法(P1060 开心的金明),那么我们写dp方程时按题目要求添加即可。如果没有给物品价值,只是让我们求能填充的最大容量或者求方案数,我们可以将dp数组的0号单元置1,dp[0]=1,便于推导最大容量或方案数。

#include <bits/stdc++.h>
using namespace std;
int t,m,dp[105][1005],ti[105],val[105];
int main()
{
    int i,j;
    cin>>t>>m;
    for(i=1; i<=m; i++)
        cin>>ti[i]>>val[i];
    for(i=1; i<=m; i++)
    {
        for(j=0; j<=t; j++)
        {
            if(j<ti[i]) 
                dp[i][j]=dp[i-1][j];
            else /**< dp[i-1][j]不选择i物品,dp[i-1][j-ti[i]]+val[i]选择 */
                dp[i][j]=max(dp[i-1][j],dp[i-1][j-ti[i]]+val[i]);
        }
    }
    cout<<dp[m][t];
    return 0;
}

    01背包问题完全体现了dp算法的特色,在计算第i行时只考虑第i-1行的值。01背包有一种优化技巧,即可以通过滚动数组的方法减少存储空间。此时只需使用一个一维数组即可完成背包计算。学习此类算法可以通过每一次循环打印dp数组来加深理解。

#include <bits/stdc++.h>
using namespace std;
int t,m,dp[1005],ti[105],val[105];
int main()
{
    int i,j;
    cin>>t>>m;
    for(i=1; i<=m; i++)
        cin>>ti[i]>>val[i];
    for(i=1; i<=m; i++)/**< 特别注意滚动的方向必须从大到小 */
        for(j=t; j>=ti[i]; j--) /**< dp[j]不选择i物品,dp[j-ti[i]]+val[i]选择i */
            dp[j]=max(dp[j],dp[j-ti[i]]+val[i]);
    cout<<dp[t];
    return 0;
}

三、完全背包

    完全类似于01背包问题,所不同的是每种物品有无限件,如果V是背包容量,c是物品重量,那么从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、取1件、取2件……取[V/c]件等多种方案。如果仍然按照解01背包时的思路,每次解决一种物品的价值计算问题,我们仍可以采用二维数组来推导,但是在推导第i件物品的价值时,我们需要用0件、1件、2件……[V/c]件依次去更新dp[i][0]......dp[i][V],这样显然效率比较差。下面为信息学奥赛一本通(C++版)在线评测系统【例9.12】完全背包问题的基础代码。

#include <bits/stdc++.h>
using namespace std;
int dp[1000][1000],w[1000],v[1000],m,n,ans=0;
int main() /**< m是背包容量,n是物品数量,w数组是重量,v数组是价值 */
{
    int i,j,k;
    cin>>m>>n;
    for(i=1;i<=n;i++)
        cin>>w[i]>>v[i];
    for(i=1;i<=n;i++)
    {
        for(j=0;j<=m;j++)
        {/**< 对于空间大小为j,尝试拿去第i件物品,拿0件就是dp[i-1][j] ,最多拿件数j/w[i]*/
            for(k=0;k<=j/w[i];k++)
                dp[i][j]=max(dp[i][j],
                        dp[i-1][j-k*w[i]]+k*v[i]);
        }
    }
    cout<<"max="<<dp[n][m];
    return 0;
}

可以采用滚动计算的方式来降低复杂度为平方级。

#include <bits/stdc++.h>
using namespace std;
int i,j,T,n,t[101],p[101],dp[101][1001];
int main()
{
    scanf("%d%d",&T,&n);
    for(i=1;i<=n;i++)
        scanf("%d%d",&t[i],&p[i]);
    for(i=1;i<=n;i++)
    {
        for(j=0;j<=T;j++) /**< 先获取上一层数据,作为不选择i物品的基础数据 */
            dp[i][j]=dp[i-1][j];
        for(j=t[i];j<=T;j++) /**< 滚动方式试探拿去i物品是否能获取更大价值 */
            dp[i][j]=max(dp[i][j],dp[i][j-t[i]]+p[i]);
    }
    printf("max=%d",dp[n][T]);
    return 0;
}

    对于背包问题的代码理解,个人建议每次背包计算之后都输出dp数组,观察dp数组变化的过程。从上面代码看很明显每次dp[i][j]都先从dp[i-1][j]获取值,因此完全背包同样可以使用一维数组滚动计算,此时正好和01背包循环次序相反。

#include <bits/stdc++.h>
using namespace std;
int v,t,m,dp[100005],ti[10005],val[10005];
int main() /**< P1616 疯狂的采药 */
{
    int i,j;
    cin>>t>>m;
    for(i=1; i<=m; i++)
        cin>>ti[i]>>val[i];
    for(i=1; i<=m; i++) /**< 为实现1件,2件....t/ti件物品,完全背包从小到大滚动 */
        for(j=ti[i]; j<=t; j++)
            dp[j]=max(dp[j],dp[j-ti[i]]+val[i]);
    cout<<dp[t];
    return 0;
}

四、分组背包

    分组背包和多重背包实际上属于同一类问题,也可以看成是有条件(同组只能选一个)的01背包。从理解问题的角度看,二维dp数组是解决背包问题的万能钥匙。和前面不同的是,同一组物品我们认为是一件物品,一样有01两种选择。dp[i][j]表示的是背包容量为j时拿完第i组物品后的最大价值,当我们计算dp[i][j]时,需要找到所有组号为i的物品分别尝试。

#include <bits/stdc++.h>
using namespace std; /**< 一本通1272:【例9.16】分组背包 */
int v,n,t,a[1005],b[1005],c[1005],f[15][205];
int main()
{
    int i,j,k,minn=INT_MAX;
    cin>>v>>n>>t;
    for(i=1; i<=n; i++)
        cin>>a[i]>>b[i]>>c[i];
    for(i=1; i<=t; i++) /**< 分组的01背包,一组一组拿 */
    {
        for(j=v; j>=0; j--)
            f[i][j]=f[i-1][j];/**< 初始化第k组价值为前一组,有可能这一组不能拿物品(价值太低) */
        for(k=1; k<=n; k++) /**< 循环n件物品 */
            if(c[k]==i) /**< 物品属于第i组 */
                for(j=v; j>=a[k]; j--) /**< 用第i件物品去试探能否获得更大价值 */
                    f[i][j]=max(f[i][j],f[i-1][j-a[k]]+b[k]);

    }
    cout<<f[t][v];
    return 0;
}

同样,分组背包也可以用一维数组。

#include <bits/stdc++.h>
using namespace std;
int n,m,dp[100005],a[10005],b[10005],c[10005];
int main() /**< P1757 通天之分组背包,80分代码 */
{
    int i,j,k;
    cin>>m>>n;
    for(i=1; i<=n; i++)
        cin>>a[i]>>b[i]>>c[i];
    for(k=1; k<=n; k++) /**< k表示组号 */
        for(j=m;j>=0;j--) /**< 对于每一个dp[j],我们用k组的全部物品计算
            因为是01背包,切记j必须从大到小*/
            for(i=1;i<=n;i++)
                if(c[i]==k&&a[i]<=j)
                    dp[j]=max(dp[j],dp[j-a[i]]+b[i]);

    cout<<dp[m];
    return 0;
}

五、多重背包

    多重背包,物品的数量既不是一个,也不是无限,在拿取物品的时候只能选择0到K个。可以把多重背包看成是分组背包,每一件物品都是一组,把选取1个,2个....K个看成是同一组的不同的物品,这样多重背包就和分组背包有极其相似的代码。

    多重背包在暴力枚举第i件物品的每一种可能数量时复杂度较高,它还有一种极为巧妙的二进制优化方法,比如第i件物品最多可以拿20个,把这20件物品拆分成1、2 、4、 8、 5,这样我们得到5件不同的物品,对其做01背包。读者可以思考下,1、2、4、8对应二进制后4位,可以搭配出1...15间任意数量,再加上一个5,可以搭配出1......20间任意数量。用01背包的方法计算时,最终的数量选择(最优值),一定会被二进制拆分的数量组合出来。  

#include <bits/stdc++.h>
using namespace std;
int n,m,a[1005],dp[1005];
int main() /**< P1077 摆花 */
{
    cin>>n>>m;
    int i,j,k;
    for(i=1; i<=n; i++)
        cin>>a[i];
    dp[0]=1;
    for(i=1; i<=n; i++) /**< i件物品 */
    {
        for(j=m; j>=0; j--)
        { /**< 认为第i种物品拿1,2...ai盆属于不同的物品 */
            for(k=1;k<=a[i]&&k<=j;k++)
                dp[j]=(dp[j]+dp[j-k])%1000007;
        }
    }
    cout<<dp[m];
    return 0;
}

六、背包价值

    前面提到过背包问题一般都是求最大价值,还有一些求能填充的最大容量或者求方案数问题。两类问题最大的区别在于求dp的代码。 求价值:dp[j]=max(dp[j],dp[j-a[i]]+b[i]);用空间换价值。 求容量一般(设置dp[0]=1),只需标注是否能到达某个容量dp[j]=dp[j]+dp[j-a[i],如果能到达dp[j]值非0。求方案数(设置dp[0]=1),当前方案累加前一种情况方案,dp[j]=(dp[j]+dp[j-a[i])%1000007;现有的dp[j]方案数累加上由i物品新增的方案数,方案数问题很容易超范围,一般都会要求求余,为稳妥起见,建议使用longlong类型。

七、小结

    背包问题的学习需要大量练习,才能掌握发现背包三要素的诀窍。 例如P2066 机器分配,我们可以把每个公司的不同使用时间看成是分组背包,因为这一组里面只能每个公司只能选一种机器使用时间,确定背包类型后,用分组的一维算法还是二维算法都可以解决这一问题,解决思路会比较清晰。

下面有一个习题读者可以看看是这是什么背包问题(答案为本文概述中彩色文字)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值