[刷题]背包问题

递归问题特性

①问题有最优子结构:问题存在最优解,且与其子问题最优解重合

②无后效:前后状态值只和值本身有关,和问题无关。

解决思路:

①将原问题分解为子问题

②确定状态

③确定初始状态值

④确定状态转移方程(由最优子结构推到其父结构,直到目标状态)

四个背包问题

01背包

题目:

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

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

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

输入格式

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

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

输出格式

输出一个整数,表示最大价值。

数据范围

0<N,V≤1000
0<vi,wi≤1000

朴素做法

既然是动态规划,我们就要找到 i 和 i-1 之间的关系嘛。观察会发现,

对于第 i 件物品,只有选或不选两种选择:

  • 不选:f[i][j] = f[i-1][j] 即最优解和前 i-1 个物品的情况一样。
  • 选:f[i][j] = f[i-1][j-v[i]]+w[i] 即最优解包含了选择第 i 个物品的情况。

这样的话dp的关键:状态转移方程 就成型了:

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

暴力dp,因为有两个限定条件 : 前N件物品和前M个重量,所以空间设为二维数组。

建立一个二维数组 N [ i ] [ j ],含义为前i件物品和j重量时的最优解。

这里还需要两个一维数组 v [n] 和 w [n] 来分别存储第 i 个物品的 体积和价值。

#include<iostream>
using namespace std ;

const int N = 1010;
int a[N][N];
int v[N],w[N]; 
int n,m;

int dp()
{
    for(int i=1;i<=n;i++){
        for(int j = 1;j<=m;j++){
            a[i][j]=a[i-1][j];
            if(j>=v[i])
                a[i][j] = max(a[i][j],a[i-1][j-v[i]]+w[i]);
        }
    }
    return a[n][m];
}
int main(void)
{
    cin>>n>>m;
    for(int i = 1;i<=n;i++){
        cin>>v[i]>>w[i];
    }
    cout << dp();
    return 0;
}

空间优化(一维数组)

两个关键点:

  1. 优化原因:朴素做法中,二维数组只用到了 a[i][j]a[i-1][j] ,这里可以优化一下,利用一个一维数组实现状态更新。

    你可以理解为二维数组 a[N][N] 变成了 a[1][N]

  2. 倒序更新:使用一维数组,则必然要在更新数据时使用倒序。

    1.正序为啥不行?

    二维状态转移方程为 a[i][j] = max(a[i][j],a[i-1][j-v[i]+w[i])

    一维数组,正序更新则变为 a[i][j] = max(a[i][j],a[i][j-v[i]+w[i])

    因为没有二维数组记录,导致一维的数据由第i-1层,跳到了第i层。

    (你也可以这样理解,正序会导致前i个数据里的某个数据由于满足条件而重复使用。!想到了完全背包没有?)

    2.倒序为啥行?

    计算 a[i][j] 需要 a[i-1][j-v[i]],而倒序的情况下,a[j-v[i]] 恰好是“未被污染的”,我们所需要的数据。

#include<iostream>
using namespace std ;

const int N = 1010;
int a[N];
int v[N],w[N]; 
int n,m;

int dp()
{
    for(int i=1;i<=n;i++){
        for(int j = m;j>=v[i];j--){
            a[j] = max(a[j],a[j-v[i]]+w[i]);
        }
    }
    return a[m];
}
int main(void)
{
    cin>>n>>m;
    for(int i = 1;i<=n;i++){
        cin>>v[i]>>w[i];
    }
    cout << dp();
    return 0;
}

完全背包

问题同上,只多了一个条件: 每种物品都有无限件可用。

1679574367514

作者:yxc 链接:https://www.acwing.com/video/945/

  • 请教一个问题,A式中的dp[i−1] [j],和实际上B式替换后中的dp[i−1] [j−kv]+(k−1)w后面应该还有一项dp[i−1] [j−(k+1)v]+kw,在转换到C式时,这两项怎么处理的。视频中没有看明白。

答:

B式仅从数学上来看,好像末尾缺少了一项 dp[i-1] [j-v-kv] + kw 。

换元后要考虑取值范围的变化

因为两者的 j 是一样的,对应了 1⩽k⩽T,让 j 变成 j−v 就等同于让 k 的范围缩小为 0⩽k⩽T−1 了。

简而言之就是原来的背包容量 j 最多可以承受 T 个 v,而替换成 j−v 后,最多就只能承受 T−1 个 v 了,把式子最后一项化简消去,就成这样了。

  • B式最后一项少了一个 w 怎么理解?

B式的第一项和A式第二项对应,而 w 前的系数也就差 1,直到最后一项。所以B式 + w 才能替换A项。

代码示例:

朴素做法

   for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++) {
            int t = j / v[i];
            for (int k = 0; k <= t; k++)
                dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i]] + k * w[i]);
        }
————————————————
原文链接:https://blog.csdn.net/raelum/article/details/128996521

一维优化

#include <iostream>
#include <algorithm>

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 ++ )
        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;
}

番外:

1.为什么一维背包的 j ,可以从v[i]开始,“跳过”j<v[i]的情况?

img
关键在这里。二维不能从v[i]开始,因为有一个赋值语句为j<v[i]的数据赋初值。

2.一维的情况下,赋值语句从f[i] [j] = f[i-1] [j] 变成了 f[ j ] = f[ j ] ,两者等价,但右式可以消掉。 判断语句挪到循环里,结束。

3.省略的f[i] = f[i] 等价于 f[i][j] = f[i-1][j]

因为赋值语句里的 f[i] = f[i] ,右值为上一层循环里的 f[i] ,即 i - 1 。

这个f[j]还没有在第 i 层的循环里被更新过。

多重背包

多重背包增加的条件: 第 i 种物品最多有 si 件,(每件体积是 vi,价值是 wi)。

朴素做法

这时需要增加一层循环,即枚举第 i 种物品的 【0,si】件时的最优解,再从最优解中找到更优解。

(注意:一个限制条件是,选择k个物品势必会引起背包体积问题,需要在该循环中增加体积限制条件)

#include<iostream>
using namespace std; 

const int N = 110;

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

int main(void)
{
    int n,m;
    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=0;j<=m;j++){      //
            for(int k=0;k<=s[i] && k*v[i]<=j;k++){ // k*v[i]<=j 防止下标为负数,即装不下的情况出现
            f[i][j] = max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
            }   
        }
    }
    cout<<f[n][m];
    return 0;
}

k从0开始:k=0 时,包含了不选择第 i 件物品的情况。

一维优化

#include <bits/stdc++.h>

using namespace std;

const int N = 110;

int v[N],w[N],s[N];

int dp[N];

int main()

{
    int n, m;

    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 = m; j >= v[i] ; j--)   //
        {
            for(int k = 1; k <= s[i] && k * v[i] <= j; k ++)
            {
                dp[j] = max(dp[j], dp[j- k * v[i]] + k * w[i]);
            }
        }
    }

    cout << dp[m] << endl;

    return 0;

}

这里有两个点:

  • 倒序遍历,直到 v[ i ] 。

同上

  • k=1开始

实际上,k=0开始和k=1开始,结果是一样的。把k=0带入,发现赋值语句变成了:

dp[j] = max(dp[j],dp[j])

所以你是否想起了完全背包里被省略的那个 dp[j] = dp[j]?

二进制优化

数据范围增强了:

0<N≤1000
0<V≤2000
0<vi,wi,si≤2000

思路:

这是0—1背包的,原来你解决多重背包是把n份物品分为n个1份,现在是分成2^0 2^1 2^2 2^3 24…2n个,分好后把它们重新看做一个整体,这些整体相加可以等于[1,n]中任何物品一份份总和的集合,
简单讲就是 n = 1+1+1+1 …(执行n次) = 1 2 4 8 …(执行log n 次)

#include<iostream>
#include<vector>

using namespace std ;
struct good
{
    int v;
    int w;
};
vector<good> goods;

const int N = 2023;
int f[N];


int main(void)
{
    int n,m;
    cin>>n>>m;
    for(int i = 1;i<=n;i++){
        int v,w,s; //不需要数组,而是转化成二进制数统一存到vector中去
        cin>>v>>w>>s;
        for(int k=1;k<=s;k*=2 )
        {
            s-=k;
            goods.push_back({v*k,w*k});
        }
        if(s>0) goods.push_back({v*s,w*s});
    }
    
    for(auto good:goods){   //不需要 i / n 了。传统笨蛋线性数目已经没用了
        for(int j=m;j>=good.v;j--){
            f[j] = max(f[j],f[j-good.v]+good.w);
        }
    }
    cout<<f[m]<<endl;
    
    return 0;
}

分组背包

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

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

分组背包实际上是多重背包的变种。多重背包的解决思路是,将k*v[i](k=0,1,2…) 打包起来,形成v[ i ]集合中的一部分,我们要做的就是在v [ i ] 集合中选择一个最优解。

这不正是分组择其一的过程吗?

结论:我们可以把分组背包看成多重背包
理由:我们把每组看做一个“物品”,而这个物品我们可以选择0到si个;
通过从后向前的遍历顺序来确保,我们对组的决策只有一种:要么选这个组,要么不选;
然后在通过枚举组内的情况,来对组内进行决策:要么选0个,选1个…;

来自 acwing:@WZ

朴素做法

#include <iostream>

using namespace std;

const int N = 110;

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

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 = 0; j <= m; j++)
        {
            for(int k = 0; k <= s[i]; k++)
            {
                if(j >= v[i][k])
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i][k]] + w[i][k]);
            }
        }
    }

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

    return 0;
}

一维优化

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

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

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

    for (int i = 1; i <= n; i ++ )
    {
        cin >> s[i];
        for (int j = 0; j < s[i]; j ++ )
            cin >> v[i][j] >> w[i][j];
    }

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

一维再优化

一个都不选的方案在状态优化成1维的时候就可以省略了,因为本层的f[j]就是上一层的f[j]。

#include<iostream>
using namespace std ;
const int N = 110;
int n,m;
int f[N],v[N],w[N];

int main(void)
{
    cin>>n>>m;
    for(int i =0;i<n;i++)
    {
		int s;
        cin>>s;
        for(int j = 0;j<s;j++) cin>>v[j]>>w[j];
        for(int j = m;j>=0;j--)
    		for(int k=0;k<s;k++)
                if(j>=v[k])
                    f[j] = max(f[j],f[j-v[k]]+w[k]);
    }
    cout<<f[m]<<endl;
    return 0;
}

总结几个关键点

正序/逆序问题:

优化成一维数组的情况,只有完全背包会正序遍历 j 。

因为一维数组的情况下,正序遍历如果背包剩余容积足够(j>v[i]),会重复装入一个物品,这是完全背包的思路。

逆序遍历则不会有重复的现象,因为前面的数据都为 0 。

从左往右更新有脏数据。

从右往左更新都是 0 。

小规律:

  • 四种背包的朴素做法,也就是二维状态都是正序,一维都是倒序。

下面是一个很有参考价值的例子和过程模拟。


例子:假设有3件物品,背包的总体积为10

物品 体积 价值
i = 1 4 5
i = 2 5 6
i = 3 6 7
因为 f[0][j] 总共0件物品,所以最大价值为 0, 即 f[0][j] == 0 成立

如果 j 层循环是递增的: 
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[0] = 0;  f[1] = 0;  f[2] = 0;  f[3] = 0;  f[4] = 0;  
f[5] = 0;  f[6] = 0;  f[7] = 0;  f[8] = 0;  f[9] = 0; f[10] = 0;

当进入循环 i == 1 时:
f[4] = max(f[4], f[0] + 5); 即max(0, 5) = 5; 即f[4] = 5;
f[5] = max(f[5], f[1] + 5); 即max(0, 5) = 5; 即f[5] = 5;
f[6] = max(f[6], f[2] + 5); 即max(0, 5) = 5; 即f[6] = 5;
f[7] = max(f[7], f[3] + 5); 即max(0, 5) = 5; 即f[7] = 5;

重点来了!!!
f[8] = max(f[8], f[4] + 5); 即max(0, 5 + 5) = 10; 即f[8] = 10;
这里就已经出错了
因为此时处于 i == 1 这一层,即物品只有一件,不存在单件物品满足价值为10
所以已经出错了。

如果 j 层循环是逆序的:
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]);
    }
}

模拟过程如下:

当还未进入循环时:
f[0] = 0;  f[1] = 0;  f[2] = 0;  f[3] = 0;  f[4] = 0;  
f[5] = 0;  f[6] = 0;  f[7] = 0;  f[8] = 0;  f[9] = 0; f[10] = 0;

当进入循环 i == 1 时:w[i] = 5; v[i] = 4;
j = 10:f[10] = max(f[10], f[6] + 5); 即max(0, 5) = 5; 即f[10] = 5;
j = 9 :f[9] = max(f[9], f[5] + 5); 即max(0, 5) = 5; 即f[9] = 5;
j = 8 :f[8] = max(f[8], f[4] + 5); 即max(0, 5) = 5; 即f[8] = 5;
j = 7 :f[7] = max(f[7], f[3] + 5); 即max(0, 5) = 5; 即f[7] = 5;
j = 6 :f[6] = max(f[6], f[2] + 5); 即max(0, 5) = 5; 即f[6] = 5;
j = 5 :f[5] = max(f[5], f[1] + 5); 即max(0, 5) = 5; 即f[5] = 5;
j = 4 :f[6] = max(f[4], f[0] + 5); 即max(0, 5) = 5; 即f[4] = 5;

当进入循环 i == 2 时:w[i] = 6; v[i] = 5; 
j = 10:f[10] = max(f[10], f[5] + 6); 即max(5, 11) = 11; 即f[10] = 11;
j = 9 :f[9] = max(f[9], f[4] + 6); 即max(5, 11) = 5; 即f[9] = 11;
j = 8 :f[8] = max(f[8], f[3] + 6); 即max(5, 6) = 6; 即f[8] = 6;
j = 7 :f[7] = max(f[7], f[2] + 6); 即max(5, 6) = 6; 即f[7] = 6;
j = 6 :f[6] = max(f[6], f[1] + 6); 即max(5, 6) = 6; 即f[6] = 6;
j = 5 :f[5] = max(f[5], f[0] + 6); 即max(5, 6) = 6; 即f[5] = 6;

当进入循环 i == 3 时: w[i] = 7; v[i] = 6; 
j = 10:f[10] = max(f[10], f[4] + 7); 即max(11, 12) = 12; 即f[10] = 12;
j = 9 :f[9] = max(f[9], f[3] + 6); 即max(11, 6) = 11; 即f[9] = 11;
j = 8 :f[8] = max(f[8], f[2] + 6); 即max(6, 6) = 6; 即f[8] = 6;
j = 7 :f[7] = max(f[7], f[1] + 6); 即max(6, 6) = 6; 即f[7] = 6;
j = 6 :f[6] = max(f[6], f[0] + 6); 即max(6, 6) = 6; 即f[6] = 6;

就模拟一下发现没有错误,即逆序就可以解决这个优化的问题了。

从1开始/从v[i]开始

  • 从1开始的都是朴素二维数组做法。因为二维数组在j<v[i]时也要有数据,供后续状态转移时使用。

  • 一维数组不需要。因为一维的情况下,赋值语句从f[i] [j] = f[i-1] [j] 变成了 f[ j ] = f[ j ] ,两者等价,但右式可以消掉。 判断语句挪到循环里,结束。

同理,省略的f[i] = f[i] 等价于 f[i][j] = f[i-1][j]

因为赋值语句里的 f[i] = f[i] ,右值为上一层循环里的 f[i] ,即 i - 1 。

这个f[j]还没有在第 i 层的循环里被更新过。

k的出现

k在多重背包、分组背包(特殊多重背包)、完全背包的朴素形式出现。

01背包只有选或不选两种情况,不需要 k 的引入。

完全背包一维形式在循环中已经包含了“重复调用,向上更新”的目的,不需要再引入 k。

这一点可以参考 正序/逆序问题。


实战演练

最大数字(蓝桥杯2022国赛-D)

【问题描述】
给定一个正整数 N。你可以对 N 的任意一位数字执行任意次以下 2 种操作:
1.将该位数字加 1。如果该位数字已经是 9,加 1 之后变成 0。
2.将该位数字减 1。如果该位数字已经是 0,减 1 之后变成 9。
你现在总共可以执行 1 号操作不超过 A 次,2 号操作不超过 B 次。
请问你最大可以将 N 变成多少?
【输入格式】
第一行包含 3 个整数:N, A, B。
【输出格式】
一个整数代表答案。
【样例输入】
123 1 2
【样例输出】
933
【样例说明】
对百位数字执行 2 次 2 号操作,对十位数字执行 1 次 1 号操作。
【评测用例规模与约定】
对于 30% 的数据,1 ≤ N ≤ 100; 0 ≤ A, B ≤ 10
对于 100% 的数据,1 ≤ N ≤ 1017; 0 ≤ A, B ≤ 100

有A和B两种选择;

对于第 i 位数字,

  • 选 j 次 A ,相当于一件物品: 花费是 j ,价值是 10^n-1 * num (num:当前位的数字)

  • 选 k 次 B,相当于一件物品: 花费是 k,价值是 10^n-1 * num

数据结构:f[i] [j] [k] : j<=A , k<=B 。含义为第 i 位数字拥有 j 次 A 和 k 次 B的最大值。

状态转移:

f[i] [j] [k] = max(f[i] [j] [k] , f[i][j-x][k]);
f[i] [j] [k] = max(f[i] [j] [k] , f [i][j][k-y]);

下面是代码:

#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>

using namespace std ;

const int N = 20 ;
const int M = 110 ;

typedef long long LL ;

LL f[N][M][M] ; //f(i,j,k)  :  第 n 位 拥有 j 次 选择A 和 k 次 选择B 的最大值
char s[N] ;
int a , b ;
long long f10[20] ; //10的几次方 

int get(int x , int mod){   //取模 
	return (x % mod + mod) % mod ;
}
int main(void){
	scanf("%s" , s + 1) ;
	scanf("%d%d" , &a , &b) ;
	f10[0] = 1 ;
	for(int i = 1 ; i <= 18 ; i ++)
		f10[i] = f10[i - 1] * 10 ;
	
	int n = strlen(s + 1) ;
	for(int i = 1 ; i <= n ; i ++)
		for(int p = 0 ; p <= 9 ; p ++)
		{
			int t = s[i] - '0' ;  //当前位的数字 
			int x = get(p - t , 10) ; // 通过选择A从t取到p位的花费 
			int y = get(t - p , 10) ; // 通过选择B从t取到p位的花费 
			for(int j = 0 ; j <= a ; j ++)   //拥有的价值a,b 
				for(int k = 0 ; k <= b ; k ++){
					if(j >= x) f[i][j][k] = max(f[i][j][k] , f[i - 1][j - x][k] + f10[n - i] * p) ;
					if(k >= y) f[i][j][k] = max(f[i][j][k] , f[i - 1][j][k - y] + f10[n - i] * p) ;
				}
		}
	printf("%lld" , f[n][a][b]) ;
	return 0 ;
}

为什么背包里,

if(j >= x) f[i][j][k] = max(f[i][j][k] , f[i - 1][j - x][k] + f10[n - i] * p) ;
if(k >= y) f[i][j][k] = max(f[i][j][k] , f[i - 1][j][k - y] + f10[n - i] * p) ;

不写成下面的形式?

 max(f[i-1][j][k] , f[i - 1][j - x][k] + f10[n - i] * p) ;
//		↑

因为 j 和 k 从0开始遍历,已经把不选择A或B的情况包括了。

这就好比多重背包里,选择 0 个背包(k==0) 时,已经囊括了 f[i][j] = f[i-1][j] 的情况。

f[i-1] [j-k* v[i]]+k*w[i]); 在 k = 0 时不正是那种情况?

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值