【动态规划】背包问题详解

动态规划(Dynamic Pogramming,简称dp)是运筹学的一个分支,是求解决策过程最优化的数学方法。背包问题则是dp问题里很常见的一类。本篇文章来详解一下背包问题。

1、基础知识

动态规划的理解方式有很多种,这里讲述的是yxc老师的闫氏dp法,个人认为是最好的理解方式并且非常好用。
在这里插入图片描述
遇到dp问题,首先考虑状态表示,即如何(用什么、怎么样)把一个状态表示出来,区分两个不同状态的指标数量维度,我们要把相同的状态放入一个集合里面去,并且规定这个集合的属性(可能是最大值、最小值、元素数量等)。在此之后,我们要考虑状态计算,即如何把当前状态的集合计算出来。在这一步,我们一般采用划分集合的方式,即将一个大的集合划分为很多小的集合,这些小的集合可以计算出来,那么大的集合也就能计算出来了。一般要求是:划分集合时不重不漏

听起来很抽象,确实是这样。dp问题需要不断做题积累经验,才能真正体会到其中的精髓。下面用背包问题来走一遍这个流程,方便理解。

背包问题是动态规划问题里的一种很常见题型,具体来说就是,给一个容量确定的背包,给一些体积确定、价值确定的物品,问怎样带才能带走价值最大的物品。好比说打游戏时,打怪掉落了很多物资,但是背包有限,就要想想带走什么才对自己最有用。我们用闫氏dp法来走一遍流程。

首先,考虑如何表示一个背包的状态:1.背包容量;2.放哪些物品。因此,背包问题集合的维度是二维的。具体表示方法不同的题目不同。因为问题需要,所以集合的属性自然就是价值的最大值
然后就要考虑状态的计算了,即如何计算 f ( i , j ) f(i,j) f(i,j)的值。这个过程我们采取划分集合的方式,不同的题目有不同的考虑方式,下面用一些例题来实战。

2、01背包

01背包具体是:一个物品只有选(1)或不选(0)两种状态,固称为01背包。首先看一下例题 acwing 01背包

有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。第 i 件物品体积是 v i v_i vi,价值是 w i w_i wi
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最大价值。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 v i v_i vi, w i w_i wi,用空格隔开,分别表示第 i 件物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤1000
0< v i v_i vi, w i w_i wi≤1000
输入样例

4 5
1 2
2 4
3 4
4 5

输出样例:

8

我们可以 f ( i , j ) f(i,j) f(i,j)来表示只在前 i i i个物品里面选,且背包容量为 j j j

划分集合时可以将只在前 i i i个物品里选划分为两个小集合:

  1. 所有选了 i i i的选法: f ( i − 1 , j − v i ) + w i f(i-1,j-v_i)+w_i f(i1,jvi)+wi
  2. 所有不选 i i i的选法: f ( i − 1 , j ) f(i-1,j) f(i1,j)

这样就能不重不漏的把 f ( i , j ) f(i,j) f(i,j)分为两个小的集合了。同时由于 f ( i , j ) f(i,j) f(i,j)的属性是最大值,那么就可以表示为:
f ( i , j ) = m a x ( f ( i − 1 , j − v i ) + w i , f ( i − 1 , j ) ) f(i,j)=max(f(i-1,j-v_i)+w_i, f(i-1,j)) f(i,j)=max(f(i1,jvi)+wi,f(i1,j))
这样就得到了dp问题最重要的状态计算方程

得到了这个,这个题就可以直接做出来了:

#include <iostream>

using namespace std;

const int N = 1010;

int f[N][N];
int v[N], w[N];
int n, m;

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];

	//因为i等于0的时候f(i,j)一定是0(选0个物品),所以直接从1开始
    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ )
        {
            f[i][j] = f[i - 1][j];
            //只有装得下物品i时,才考虑选了i的选法
            if (j >= v[i]) f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
        }

    cout << f[n][m];

    return 0;
}

dp问题就是这样神奇,看似很难,其实只要得到状态计算方程,很简洁的代码就能通过。

接下来,还能对代码进行优化。注意到 f ( i , j ) = m a x ( f ( i − 1 , j − v i ) + w i , f ( i − 1 , j ) ) f(i,j)=max(f(i-1,j-v_i)+w_i, f(i-1,j)) f(i,j)=max(f(i1,jvi)+wi,f(i1,j))每个 i i i状态都是由 i − 1 i-1 i1状态得到的,因此可以用滚动数组的方式来更新。意思是:一个二维数组,每行都是由上一行得到的,那么就可以优化为一个一维数组,每次用新的值覆盖当前值,也即相当于由上一行得到下一行。那么核心部分代码:

f[i][j] = f[i - 1][j];

就可以优化为:

f[j] = f[j];

恒等式可以省略;其次第二个核心代码:

if (j >= v[i]) f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);

可以优化为:

if (j >= v[i]) f[j] = max(f[j], f[j - v[i]] + w[i]);

而由于循环是递增的,所以直接从v[i]开始即可:

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

但是这里有一个问题:注意到 f [ j − v [ i ] ] f[j-v[i]] f[jv[i]]一定小于 f [ j ] f[j] f[j],所以它一定会在 f [ j ] f[j] f[j]之前更新,那么更新 f [ j ] f[j] f[j]时,就相当于用本层的 f [ j − v [ i ] ] f[j-v[i]] f[jv[i]]来更新,但计算公式要求用上一层的 f [ j − v [ i ] ] f[j-v[i]] f[jv[i]]来更新,为解决这个问题,只需要把第二层循环逆向进行即可。这样能保证更新 f [ j ] f[j] f[j]时用到的是上一层的 f [ j − v [ i ] ] f[j-v[i]] f[jv[i]]

那么得到01背包问题的终极版代码:

#include <iostream>

using namespace std;

const int N = 1010;

int f[N];
int v[N], w[N];
int n, m;

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];

    for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= v[i]; j -- )
            f[j] = max(f[j], f[j - v[i]] + w[i]);

    cout << f[m];

    return 0;
}

3、完全背包

完全背包就是给出一些物品,但是物品数量不限例题 acwing完全背包

有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。
第 i 种物品的体积是 v i v_i vi,价值是 w i w_i wi
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。
接下来有 N 行,每行两个整数 v i v_i vi, w i w_i wi,用空格隔开,分别表示第 i 种物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤1000
0< v i v_i vi, w i w_i wi≤1000
输入样例

4 5
1 2
2 4
3 4
4 5

输出样例

10

还可以 f ( i , j ) f(i,j) f(i,j)来表示只在前 i i i个物品里面选,且背包容量为 j j j
但是划分集合的时候,就和01背包略有不同了。因为物品是无限使用,所以划分为:

  1. i i i件物品选了0个的选法: f ( i − 1 , j ) f(i-1,j) f(i1,j)
  2. i i i件物品选了1个的选法: f ( i − 1 , j − v i ) + w i f(i-1,j-v_i)+w_i f(i1,jvi)+wi
  3. i i i件物品选了2个的选法: f ( i − 1 , j − 2 v i ) + 2 w i f(i-1,j-2v_i)+2w_i f(i1,j2vi)+2wi
    ……
    i i i件物品选了s个的选法: f ( i − 1 , j − s v i ) + s w i f(i-1,j-sv_i)+sw_i f(i1,jsvi)+swi,( s × v i ≤ j s×v_i≤j s×vij);

这样就能做到不重不漏地把 f ( i , j ) f(i,j) f(i,j)划分为了 s s s个集合。所以可以得到状态计算方程
f ( i , j ) = m a x ( f ( i − 1 , j − k v i ) + k w i ) , ( k = 0 , 1 , 2 , … , s ) f(i,j)=max(f(i-1,j-kv_i)+kw_i),(k=0,1,2,…,s) f(i,j)=max(f(i1,jkvi)+kwi)(k=0,1,2,,s)

那么题解就有了:

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];

    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ )
            for (int k = 0; 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][m] << endl;

    return 0;
}

可以进行进一步优化。这里注意到一个神奇的事情:
在这里插入图片描述
也即, v i ≥ j v_i≥j vij时,有 f ( i , j ) = f ( i , j − v i ) + w i f(i,j)=f(i,j-v_i)+w_i f(i,j)=f(i,jvi)+wi
有了这个,就可以对完全背包进行优化了:

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];

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

    cout << f[n][m] << endl;

    return 0;
}

这样少了一重循环,时间复杂度就变成O(mn)了。
再和01背包一样进行一维优化,得到最终版代码:

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];

    for (int i = 1; i <= n; i ++ )
    	//注意这里的区别是j递增,和01背包相反,一定要想清为什么
        for (int j = v[i]; j <= m; j ++ )
            f[j] = max(f[j], f[j - v[i]] + w[i]);

    cout << f[m] << endl;

    return 0;
}

j j j是递增的原因:公式中 f ( i , j ) f(i,j) f(i,j)的更新就是用本层的 f ( i , j − v i ) f(i,j-v_i) f(i,jvi)

4、多重背包问题

完全背包就是给出一些物品,但是物品数量有限例题 acwing多重背包

有 N 种物品和一个容量是 V 的背包。
第 i 种物品最多有 s i s_i si 件,每件体积是 v i v_i vi,价值是 w i w_i wi
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品种数和背包容积。
接下来有 N 行,每行三个整数 v i v_i vi, w i w_i wi, s i s_i si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。
输出格式
输出一个整数,表示最大价值。
数据范围
0<N≤1000
0<V≤2000
0< v i v_i vi, w i w_i wi, s i s_i si≤2000
输入样例

4 5
1 2 3
2 4 1
3 4 3
4 5 2

输出样例

10

f ( i , j ) f(i,j) f(i,j)来表示只在前 i i i个物品里面选,且背包容量为 j j j
划分集合的时候,因为物品是有限使用,所以划分为:

  1. i i i件物品选了0个的选法: f ( i − 1 , j ) f(i-1,j) f(i1,j)
  2. i i i件物品选了1个的选法: f ( i − 1 , j − v i ) + w i f(i-1,j-v_i)+w_i f(i1,jvi)+wi
  3. i i i件物品选了2个的选法: f ( i − 1 , j − 2 v i ) + 2 w i f(i-1,j-2v_i)+2w_i f(i1,j2vi)+2wi
    ……
    i i i件物品选了 s i s_i si个的选法: f ( i − 1 , j − s v i ) + s w i f(i-1,j-sv_i)+sw_i f(i1,jsvi)+swi

这样就能做到不重不漏地把 f ( i , j ) f(i,j) f(i,j)划分为了 s s s个集合。所以可以得到状态计算方程
f ( i , j ) = m a x ( f ( i − 1 , j − k v i ) + k w i ) , ( k = 0 , 1 , 2 , … ) f(i,j)=max(f(i-1,j-kv_i)+kw_i),(k=0,1,2,…) f(i,j)=max(f(i1,jkvi)+kwi)(k=0,1,2,)
注意这里的 k v i kv_i kvi一定是小于 j j j的。

那么就可以得到题解:

#include <iostream>

using namespace std;

const int N = 110;

int f[N][N];
int v[N], w[N], s[N];
int n, m;

int main()
{
    cin >> n >> m;

    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 <= m; j ++ )
            for (int k = 0; 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][m] << endl;

    return 0;
}

但是浅看一下时间复杂度,三重循环,最坏 1000 × 2000 × 2000 1000×2000×2000 1000×2000×2000,大约是四十亿,明显超时了(C++每秒大概运算一亿次),所以就要优化。这里的优化是一个技巧,叫二进制优化

假设有1023个物品,那么进行分组:20,21,22,23,24,…,29。也即1,2,4,8,…,512。用这几个数,就能组合出0 ~ 1023中所有的数。那么原来的1024(0 ~ 1023)个数,就变成了10个数进行组合。假如物品数不是2n-1个,例如45,那么就分组:20,21,22,23,24,45-24。也就是1,2,4,8,16,14。用这几个数就能组合出0 ~ 45所有的数了。

把分的每个组想成一个物品,其实就转化成了01背包问题,因为每组只能是选或不选。因此用01背包的代码即可解决,重点是分组。这时看一下时间复杂度,也就是 V × N × l o g s i V×N×logs_i V×N×logsi,差不多 1000 × 2000 × l o g 2000 1000×2000×log2000 1000×2000×log2000,大概两千万,可以接受。那么给出优化的多重背包问题:

#include <iostream>

using namespace std;

//物品总数大概是N(组数)*log si(每组里的小组数)
//1000*log2000≈11000
const int N = 11010, M = 2010;

int f[N];
int v[N], w[N];
int n, m;

int main()
{
    cin >> n >> m;
    
    //分组
    int cnt = 0;
    for (int i = 1; i <= n; i ++ )
    {
        int a, b, s;
        cin >> a >> b >> s;
        int k = 1;
        while (k <= s)
        {
            cnt ++ ;
            v[cnt] = k * a;
            w[cnt] = k * b;
            s -= k;
            k *= 2;
        }
        if (s > 0)
        {
            cnt ++ ;
            v[cnt] = s * a;
            w[cnt] = s * b;
        }
    }
    
    //分组后的物品数
    n = cnt;
    
    for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= v[i]; j -- )
            f[j] = max(f[j], f[j - v[i]] + w[i]);
            
    cout << f[m] << endl;
    
    return 0;
}

5、分组背包问题

分组背包问题简单来说就是每组只能选一个物品或不选
例题 acwing分组背包

有 N 组物品和一个容量是 V 的背包。
每组物品有若干个,同一组内的物品最多只能选一个。
每件物品的体积是 v i j v_{ij} vij,价值是 w i j w_{ij} wij,其中 i 是组号,j 是组内编号。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行有两个整数 N,V,用空格隔开,分别表示物品组数和背包容量。
接下来有 N 组数据:
每组数据第一行有一个整数 S i S_i Si,表示第 i 个物品组的物品数量;
每组数据接下来有 S i S_i Si 行,每行有两个整数 v i j v_{ij} vij, w i j w_{ij} wij,用空格隔开,分别表示第 i 个物品组的第 j 个物品的体积和价值;
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤100
0< S i S_i Si≤100
0< v i j v_{ij} vij, w i j w_{ij} wij≤100
输入样例

3 5
2
1 2
2 4
1
3 4
1
4 5

输出样例

8

f ( i , j ) f(i,j) f(i,j)来表示只在前 i i i组里面选,且背包容量为 j j j
划分集合的时候,因为物品是有限使用,所以划分为:

  1. i i i组物品不选的选法: f ( i − 1 , j ) f(i-1,j) f(i1,j)
  2. i i i组物品选第1个的选法: f ( i − 1 , j − v i 1 ) + w i 1 f(i-1,j-v_{i1})+w_{i1} f(i1,jvi1)+wi1
  3. i i i组物品选第2个的选法: f ( i − 1 , j − v i 2 ) + w i 2 f(i-1,j-v_{i2})+w_{i2} f(i1,jvi2)+wi2
    ……
    i i i组物品选第 s i s_i si个的选法: f ( i − 1 , j − v i s i ) + w i s i f(i-1,j-v_{is_i})+w_{is_i} f(i1,jvisi)+wisi

由于思路较为简单,所以直接给出一维优化后的代码:

#include <iostream>

using namespace std;

const int N = 110;

int v[N][N], w[N][N], s[N];
int f[N];
int n, m;

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ )
    {
        cin >> s[i];
        for (int j = 1; j <= s[i]; j ++ )
            cin >> v[i][j] >> w[i][j];
    }
    
    for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= 1; j -- )
            for (int k = 0; k <= s[i]; k ++ )
                if (v[i][k] <= j)
                    f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
            
    cout << f[m] << endl;
    
    return 0;
}
  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值