动态规划 (DP) 是一种算法技术,它将大问题分解为更简单的子问题,对整体问题的最优解决方案取决于子问题的最优解决方案。
某些问题往往有 2个特征:重叠子问题、最优子结构。而用动规(DP)可以高效率地处理具有这 2个特征的问题。
处理 DP 的大问题和小问题,有两种实现方式 ——自顶向下与记忆化递归 / 自下而上与制表递推(两种实现方式的复杂度相同,但是第二种更为常用)。
以斐波那契为例,两种实现方式的代码分别如下:
// 自顶向下与记忆化递归
int memoize[maxn]; //保存结果
int fib (int n){
if (n==1 || n==2)
return 1;
if(memoize[n] != 0) //直接返回保存的结果,不再递归
return memoize[n];
memoize[n]= fib(n-1) + fib(n-2); //递归计算结果,并记忆
return memoize[n];
}
// 自下而上与制表递推
const int maxn = 255;
int dp[maxn];
int fib (int n){
dp[1] = dp[2] =1;
for (int i=3; i<=n; i++)
dp[i] = dp[i-1] + dp[i-2];
return dp[n];
}
01背包问题最暴力的一个解法:二维动态规划
准备工作:
设第 i个物品的体积是 v[i],价值是 w[i],f[i][j]表示只看前 i个物品,“可用(剩余)的 ”总体积是j的情况下,总价值最大是多少。
result = max{f[n][0~v]}
状态的转移:
每个 i对应的 f[i][j],可以看作一个集合的划分,也就是当前拿与不拿对应两种状态:
当前拿了的话,状态就是:dp[i-1][j-v[i]]+w[i] (+之前的是前i-1个物品对应的最大价值) ; 没拿的话,状态就是:dp[i-1][j]。
f[i][j] = max{1,2} (1和 2分别表示两种状态)
合法的初始化: f[0][0] = 0 一个物品都不选的情况下,总体积和价值都为 0
集合如何划分
一般原则:
不重不漏,不重不一定都要满足 (一般求个数时要满足)。
如何将现有的集合划分为更小的子集,使得所有子集都可以计算出来。
//AC代码
#include
#pragma GCC optimize(3 , "Ofast" , "inline")
using namespace std;
const int N = 1010;
int n,m;
int f[N][N];
int v[N],w[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(j>=v[i])
f[i][j] = max(f[i][j],f[i-1][j-v[i]]+w[i]);
}
}
int res = 0;
for(int i=0; i<=m; i++) res = max(res,f[n][i]);
cout<<res<<endl;
return 0 ;
}
01背包的优化:
每一个状态只与它的前一个状态有关,不需要把所有的状态记录下来,所以可以用一个 通用的优化方式——滚动数组 进行优化 (针对这道题,可以用一维数组去优化) 。尽量对代码进行 等价代换 。
优化过的转移方程:f[j] = max(f[j], f[j-w[i]] + v[i])
1. f[i] 仅用到了f[i-1]层,
2. j与j-v[i] 均小于j
3. 若用到上一层的状态时,从大到小枚举, 反之从小到大
#include
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=m; j>=v[i]; j--)
f[j] = max(f[j], f[j-v[i]]+w[i]);
cout<<f[m]<<endl;
return 0;
}
可以发现,完全背包和01背包的题目很相似。其实优化处理后,它们的代码也很相似,但是其中蕴含的道理大相径庭。
两者的状态转移方程:
- 01背包: f[i][j] = max( f[i-1][j], f[i-1][j-v]+w )
- 完全背包: f[i][j] = max( f[i-1][j], f[i][j-v]+w )
相似体现在:1.代码 —> 01背包优化后,第二重循环的 j 由从大到小改为从小到大之后,就能直接应用AC。2.原理 —> 状态表示这一部分(往下看)并无大异,大差不离。
两者在题目中的不同主要体现在:01背包每种物品只能用一次,而完全背包的每种物品都有无限件可以使用。
而他们的内在区别主要体现在:状态计算,即集合的划分(在01背包里,是以第 i 个物品选或者不选为界分成两个集合; 而在完全背包里,因为第 i 个物品有无限件可以选择,所以在枚举时,要划分成若干个子集,而不是两个子集)。
完全背包的步骤(结合下方图片理解):
1.状态表示:化零为整,用一个状态表示一类东西。需要搞清楚两件事:
第一个是 f(i,j) 表示哪个集合
第二个是 f(i,j) 这个集合存的是哪个数、哪种属性
2.状态计算:
枚举 f(i,j) 里所有选择了 0~k 个物品的方案数的集合
// 朴素做法
#include
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=1; j<=m; 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][m]<<endl;
return 0;
}
//优化做法 (优化成一维的之后,对于体积会有限制)
#include
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;
}
这里数据量很小,可以直接暴力去做。可以发现,和01背包例题的主要区别就是每种物品不是固定的一件,而是最多不超过s[i]件,就是所说的 “多重背包是01背包的一个扩展”(在下方的优化方法中会有相应的解释/证明),所以体积在枚举时要从大到小枚举 。
下面讲一下比较重要的三处:
状态表示 f[i] 的含义:总体积是 i 的情况下,最大价值是多少
原始 &未经处理的核心步骤:
for(int i=0; i{
for(int j=m; j>=v[i]; j- -)
f[j] = max( f[j], f[j - v[i]] +w[i], f[j -2* v[i]] +2*w[i]…);
}
初始化时有两种操作:
- 如果把所有状态都初始化为 0了,那么f[m]就是答案
- 若只令f[0]=0,其他的都是负无穷,则从f[0~m]中选一个最大值 即:
2.1. f[i]=0。答案:f[m]
2.2. f[0]=0, f[i]=-inf & i!=0。答案:max{f[0… m]}
// AC代码
#include
using namespace std;
const int N = 110;
int n,m,f[N];
int main()
{
cin >> n >> m;
for(int i=0; i<n; i++)
{
int v,w,s;
cin >> v >> w >> s;
for(int j=m; j>=0; j--)
for(int k=1; k<=s && k*v<=j; k++)
f[j] = max(f[j], f[j-k*v]+k*w);
}
cout << f[m] << endl;
return 0;
}
当数据量变大时,再去暴力的话,10^9会超时。 需要用到多重背包的二进制优化方法。
下方为数据量扩大后的一个经典基础例题:
为了帮助理解这种优化方法,我们先来看一下 “怎么把多重背包问题转化为01背包问题”。
假设第 i 个物品的体积是 v,价值是 w,件数是 s。实际上可以把这个物品拆开,直接拆成s份,每份的体积是 v’,价值是 w’,放到物品堆中去,每份只能用一次,这就变成了一个01背包问题 。
但是这种拆法的复杂度是十分高的,也会达到1e9。那么我们可以考虑如何使得划分的份数减少的同时,还能保证…
二进制优化方法的由来:
既然每种物品的每一个都去枚举会超时,那么我们考虑把第 i 种物品分成 logS(以2位底上取整)份 ,使得需要枚举的数据量变小【时间复杂度:1000 x logS x 2000 = 2 x 1e7,不会超时 】。
下面用一个例子来说明这样分为何可行:给定任意一个数s,问最少可以把s分成多少个数,使得这些数能组成1~n 的所有数。分成的这些数有2种选法,选和不选,而且它们要能够组成 1~s 的所有数(并且不能凑到>s,即这些数的和不能>s)。
用特例的比较来说明如何取数和放数才具备普适性:
s=7,log7上取整为3。2^0=1 , 2^1=2 , 2^2=4, 1 2 4可以组成1~7所有的数
s=10, log10上取整为4…1 2 4 8。但这4个数的和>10,放不下这么多。【这个时候就在 s 够用的情况下,不断减去前面的数,即得到1 2 4 3 这四个数,验证后可以发现符合要求】
结合这个例子就不难推知这种分法是可行的了。
// AC代码
#include
using namespace std;
const int N = 2010;
int n,m,f[N];
struct Good {
int v,w;
};
int main()
{
vector<Good> goods;
cin >> n >> m;
for(int i=0; i<n; i++)
{
int v, w, s;
cin >> v >> w >> s;
for(int k=1; k<=s; k*=2)
{
s -= k;
goods.push_back({v*s,w*s});
}
if(s>0) goods.push_back({v*s,w*s});
}
for(auto good:goods)
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;
}
给出分组背包的一个经典基础例题:
下面讲一下比较重要的三处:
状态表示f[i][j]的含义:前i组物品,体积是j的情况下,最大价值是多少。
原始 &未经处理的核心步骤:
for(int i=0; i{
for(int j=m; j>=v; j- -) //从大到小枚举体积
f[j] = max( f[j], f[j-v[0]]+w[0], f[j-v[1]]+w[1] ,…, f[j-v[s-1]+w[s-1]] ); //枚举每种物品的s+1种决策
}
最后的f[m]即答案
每种物品所需的决策数目的解释:
假设某种物品有s个,则它的决策一共有s+1种:一个都不选->选第0个->…->选第s-1个。
背包问题之间的联系:
分组背包也是01背包的一种特殊情况,而多重背包问题可以看作是分组背包的一种特殊情况。
// AC代码(这种背包问题的大分类似乎没有优化方法...)
#include
using namespace std;
const int N = 110;
int n,m;
int f[N],v[N],w[N];
int main()
{
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;
}