文章目录
递归问题特性
①问题有最优子结构:问题存在最优解,且与其子问题最优解重合
②无后效:前后状态值只和值本身有关,和问题无关。
解决思路:
①将原问题分解为子问题
②确定状态
③确定初始状态值
④确定状态转移方程(由最优子结构推到其父结构,直到目标状态)
四个背包问题
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;
}
空间优化(一维数组)
两个关键点:
-
优化原因:朴素做法中,二维数组只用到了
a[i][j]
和a[i-1][j]
,这里可以优化一下,利用一个一维数组实现状态更新。你可以理解为二维数组
a[N][N]
变成了a[1][N]
-
倒序更新:使用一维数组,则必然要在更新数据时使用倒序。
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;
}
完全背包
问题同上,只多了一个条件: 每种物品都有无限件可用。
作者: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]
的情况?
关键在这里。二维不能从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 时不正是那种情况?