目录
DP的本质
- dp的本质就是递推,通过状态转移方程和初始条件得到最终结果
斐波那契数列
- 以斐波那契数列为例解释状态转移方程和初始条件
状态定义
- 我们定义f[n]为数列第i项的值,这个值的含义称为它的属性,例如NUM、MAX、MIN
- 显然有f[1]=1,f[2]=1
状态转移方程
- 当n>=3时,第n项都可以由第n-1项和第n-2项求出。因此,我们可以得出f[n]=f[n-1]+f[n-2],即状态转移方程。
初始条件
- 显然,第1项和第2项是已经给出的,不需要使用状态转移方程推出,即初始条件。
背包问题简介
- NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。一般来讲,背包问题有以下几种分类:
1.01背包问题
2.完全背包问题
3.多重背包问题
01背包
- 对应二进制中的0和1,代表不选和选
例题
有 n 个物品和一个容量为 W 的背包,每个物品有重量 w 和价值 v 两种属性,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。
解释
状态定义
- 我们定义dp[i][j]为从前i个物品中选,总重量不超过j的集合;根据题目dp[i][j]的值的属性为MAX
- 与斐波那契数列相比,01背包的状态定义多了一维,这是为什么?
- 因为01背包问题中多了一个限制:总重量。所以,状态定义每多一维可以看作是多了一个限制。
状态转移方程
假设当前已经处理好了前i-1个物品的状态,对于第i个物品,如果不选,则最大值仍然是f[i-1][j];如果选,则背包剩余容量减少v[i],总价值增加w[i],此时最大值为f[i-1][j-v[i]]+w[i]。由此,我们可以得出状态转移方程:
dp[i][j]=max(dp[i-1][j], dp[i-1][j-v[i]]+w[i]
初始条件
- 无
核心代码
for(int i=1;i<=N;i++){
for(int j=0;j<=V;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]);
}
}
思考
- 如果物品数量n=1e5,背包最大体积m=1e5怎么办?
答案
- 我们发现,在第i个物品的时候,只有i-1的状态被用到了,<i-1的状态是无用的。因此,我们可以采取滚动数组来减小空间
优化后代码
for(int i=1;i<=n;i++){
for(int j=V;j>=v[i];j--){
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
思考
- 为什么j从V开始枚举而不是0开始枚举?
答案
- 观察方程,我们发现:每次转移的时候只会使用到容量比当前容量小的状态。如果从容量小的开始修改,就会出现一件物品选两次的问题,不符合题意。
完全背包问题
- 完全背包模型与 0-1 背包类似,与 0-1 背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。
例题
- 与01背包相同,但每个物品所选次数不限。
解释
- 状态定义和初始条件与01背包相同
状态转移方程
- 考虑一个朴素的做法:枚举
for (int k = 0; k * v[i] <= j; k++) { f[i][j]=max(f[i][j],f[i - 1][j-v[i] * k]+w[i] * k); }
- 显然这种做法的时间复杂度为O(n^3),当n比较大时容易TLE
- 优化:
- 通过上面的分析容易看出,除了f[i-1][j]以外,上式右边两两差值为w[i],因此可以推出状态转移方程
f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
- 通过上面的分析容易看出,除了f[i-1][j]以外,上式右边两两差值为w[i],因此可以推出状态转移方程
核心代码
for(int i=1;i<=N;i++){
for(int j=0;j<=V;j++){
f[i][j]=f[i-1][j];
if (j-v[i]>=0)
f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
}
}
优化后的代码
- 很显然,完全背包也可以使用滚动数组进行优化
-
for(int i=1;i<=N;i++){ for(int j=v[i];j<=V;j++){ f[j]=max(f[j],f[j-v[i]]+w[i]); } }
多重背包问题
- 在完全背包问题的基础上多加了一个限制:物品数量s
解释
- 状态定义和初始条件与完全背包相同
状态转移方程
- 可以使用完全背包问题中的枚举做法,但是需要多加一个限制条件
k<=s[i]
for(int k=0;k<=s[i]&&k*v[i]<=j;k++){ f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]); }
核心代码
for(int i=1;i<=n;i++){
for(int j=V;j>=0;j--){
for(int k=0;k<=s[i]&&k*v[i]<=j;k++){
f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
}
}
}
优化
- 与之前遇到的问题类似,这样写在数据范围较大时会TLE和MLE
- 假定对于物品i,我们需要x个(x<s[i])从而取得最大值,我们能否将x拆分为x1、x2……xn呢?我们可以将x拆分为1、2、4、8……、2^n中的某几个数。换一种说法,我们想要从1、2、4、8……中选取几个数,使得它们之和恰好为x,这个问题就是01背包问题。显然,1、2、4、8……可以组成任何正整数(需要证明,但略)。因此,我们可以将s[i]个物品拆分成1、2、4……等一组新物品,最后剩下的单独一个物品(简单的说就是将一些物品打包为一个新物品)。这时候问题已经从多重背包转化为01背包。
- 为什么不能采取和完全背包相同的优化方法呢?可以尝试自行推导(与完全背包推导方式类似)。
- 完整代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N=1010,M=35000;
int v[M],w[M],s[M];
int f[2*N];
int main()
{
int n,V;
cin>>n>>V;
int cnt=0,a,b,c;
for(int i=1;i<=n;i++) {
//a:物品体积 b:物品价值 c:物品数量
cin>>a>>b>>c;
//1、2、4、8……2^n拆分
int k=1;
while(k<=c){
v[++cnt]=a*k;
w[cnt]=b*k;
c-=k;
k*=2;
}
if(c>0){
v[++cnt]=a*c;
w[cnt]=b*c;
}
}
//问题变成01背包问题(滚动数组)
for(int i=1;i<=cnt;i++){
for(int j=V;j>=v[i];j--){
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[V];
return 0;
}
优化2
- 本次还可以使用单调队列进行优化,读者可以自行查阅资料学习,这里只给出代码
-
#include <iostream> #include <algorithm> #include <cstring> using namespace std; const int N = 20010; int dp[N], last[N]; int v[N], w[N], s[N]; int n, m; int q[N], hh, tt = -1; void solve() { cin >> n >> m; for (int i = 1; i <= n; i++) { cin >> v[i] >> w[i] >> s[i]; } for (int i = 1; i <= n; i++) { memcpy(last, dp, sizeof last); for (int j = 0; j < v[i]; j++) { hh = 0, tt = -1; for (int k = j; k <= m; k += v[i]) { if (hh <= tt && q[hh] < k - s[i] * v[i]) hh++; while (hh <= tt && last[q[tt]] - (q[tt] - j) / v[i] * w[i] < last[k] - (k - j) / v[i] * w[i]) tt--; q[++tt] = k; dp[k] = last[q[hh]] + (k - q[hh]) / v[i] * w[i]; } } } cout << dp[m]; } int main() { ios::sync_with_stdio(false); cin.tie(0), cout.tie(0); solve(); return 0; }