[OI] DP
温馨提示:Contest 大字的右侧有目录可以快速跳转
Contest
- 背包DP
- 线性DP
- 区间DP
- 坐标DP
- 树形DP
- 状压DP
- 概率与期望DP
- 计数DP
- 单调优化DP
背包DP
背包DP可以理解为“从若干物品中选择特定物品”的问题的统称. 对背包问题开dp数组,通常需要考虑以下几个维度
- 已选物品数量
- 背包容量
数组中通常存放该条件下的最大价值.
下面是一些常见的背包DP模型:
Ⅰ 0-1背包
Ⅱ 完全背包
Ⅲ 多重背包
Ⅳ 混合背包
Ⅴ 分组背包
Ⅵ 有依赖的背包
Ⅶ 背包方案数
Ⅷ 求背包方案
Ⅸ 背包问题第k优解
Ⅰ 0-1背包
背包问题基础状转公式: f[j]=f[j-v[i]]+w[i]
01背包第二层循环倒序执行
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){
if(f[j]<f[j-v[i]]+w[i]){
f[j]=f[j-v[i]]+w[i];
}
}
}
cout<<f[m];
Ⅱ 完全背包
完全背包=01背包的第二层循环正序执行
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){
if(f[j]<f[j-v[i]]+w[i]){
f[j]=f[j-v[i]]+w[i];
}
}
}
cout<<f[m];
Ⅲ 多重背包
关于多重背包一些坑点
①第二层循环正序和倒序确实都行
②二进制拆分循环为 while(c>base)
,写 \(\ge\) 会小概率导致有空物品
③第一层循环是 \(1-now\) 不是 \(1-n\) ,这个不能按印象写,这个问题我已经调过两遍了
cin>>m>>n;
for(int i=1;i<=n;++i){
int a,b,c,base=1;
cin>>a>>b>>c;
while(c>base){
v[++now]=a*base;
w[now]=b*base;
c-=base;
base*=2;
//或base<<=1;
}
v[++now]=a*c;
w[now]=b*c;
}
for(int i=1;i<=now;++i){
for(int j=v[i];j<=m;++j){
//或for(int j=m;j>=v[i];--j){
if(f[j]<f[j-v[i]]+w[i]){
f[j]=f[j-v[i]]+w[i];
}
}
}
cout<<f[m]<<endl;
Ⅳ 混合背包
①思路一伪代码:
if(是01背包) 01背包处理
if(是完全背包) 完全背包处理
if(是多重背包) 多重背包处理
②思路二:
将01背包拆成 \(k=1\) 的多重背包,将完全背包拆成 \(k=maxv/v[i]\) 的多重背包,然后跑多重背包
思路二实现:
cin>>t>>n;
for(int i=1;i<=n;++i){
int base=1,p,b,c;
cin>>p>>b>>c;
if(c==0){
c=t/p;
}
while(c>base){
c-=base;
a[++anow].w=base*b;
a[anow].v=base*p;
base*=2;
}
a[++anow].w=b*c;
a[anow].v=p*c;
}
for(int i=1;i<=anow;++i){
for(int j=t;j>=a[i].v;--j){
if(f[j-a[i].v]+a[i].w>f[j]){
f[j]=f[j-a[i].v]+a[i].w;
}
}
}
cout<<f[t];
Ⅴ 分组背包
Ⅴ-Ⅰ 分组背包一:每组中的物品互相冲突,最多选一件
思路:把每组看成一个01背包,遍历每组求最优解.
也就是将01背包中的 “选择几个物品” 改为 “选择几个背包”,并且在每组背包中选择一个尽可能最优的.
cin>>mv>>n>>t;
for(int i=1;i<=n;++i){
int x,y,z;
cin>>x>>y>>z;
size[z]++;
v[z][size[z]]=x;
w[z][size[z]]=y;
}
for(int i=1;i<=t;++i){//zushu
for(int j=1;j<=mv;++j){//v
f[i][j]=f[i-1][j];
for(int k=1;k<=size[i];++k){//member
if(j>=v[i][k]&&f[i-1][j-v[i][k]]+w[i][k]>f[i][j]){
f[i][j]=f[i-1][j-v[i][k]]+w[i][k];
}
}
}
}
cout<<f[t][mv];
Ⅴ-Ⅱ 分组背包二:每组中的物品不冲突,每组至少选一件
Ⅵ 有依赖的背包
有依赖的背包也叫树上背包,详见 树上背包问题.
Ⅶ 背包方案数
对方案数问题,我们可以考虑开一个 \(vis\) 数组,然后在每次更新成功后,把当前数值标记. 最后统计未标记的数字即可.
Ⅷ 求背包方案
思路:开 \(pre\) 数组, \(pre\) 数组记录每个dp状态的前驱节点,若状转成功,则更新 \(pre\) 数组的值,最后倒着遍历
伪代码①:
if(状态转移成功) pre[j]=i;
...
while(最后的点t){
...
t-=v[pre[t]];
}
伪代码②:
if(状态转移成功) pre[j]=i;
...
while(最后的点t){
...
t=pre[t];
}
Ⅸ 背包问题第k优解
例题-Bone Collector II
思路:增加一维,用来记录第k优解。每次状态转移时都将计算所得全部的值(k个数状态转移后的全部nk种情况)合并后取前k个合并,这样就可以维护一个k优解数组。
以 Bone Collector II 中 01 背包第k优解为参考:
cin>>n>>m>>k;
for(int i=0;i<n;i++){
cin>>w[i];
}
for(int i=0;i<n;i++){
cin>>v[i];
}
for(int i=0;i<n;i++){
for(int j=m;j>=v[i];j--){
int p,x,y,z;
for(p=1;p<=k;p++){ //对k个数进行状态转移
a[p]=f[j-v[i]][p]+w[i];
b[p]=f[j][p];
}
a[p]=b[p]=-1; //二分合并
x=y=z=1;
while(z<=k&&(a[x]!=-1||b[y]!=-1)){
if(a[x]>b[y]){
f[j][z]=a[x++];
}
else{
f[j][z]=b[y++];
}
if(f[j][z]!=f[j][z-1]){
z++;
}
}
}
}
cout<<f[m][k];
线性DP
线性DP较为灵活,无法系统分为几大类,但大体有下面几种模型:
Ⅰ 最长序列问题
Ⅱ 带捆绑的最长序列问题
Ⅲ 求最少拆链问题
Ⅳ 最长公共问题
Ⅰ 最长序列问题
Ⅰ-Ⅰ 概述
设有由 \(n\) 个不相同的整数组成的数列,记为: \(b(1)、b(2)、……、b(n)\) ,若 \(b\) 中存在以 \(i1<i2<i3< … < ie\) 为下标组成的序列,且具有单调性 则称其为长度为 \(e\) 的单调序列
最长序列问题的基本公式:f[i]=f[j]+1 (a[i] \operator\ a[j],i from n to 1,j from 1 to n)
关于符号:
①最长上升 \(<\)
②最长下降 \(>\)
③最长不上升 \(\ge\)
④最长不下降 \(\le\)
另外,为了方便说明,我们把在这里的①③,②④分别叫做相反的最长序列
原理:假如在 \(i\) 之前有一个 \(j\) 可以满足单调性,并且 \(j\) 的最长序列已知,那么 \(j\) 的单调序列一定在 \(i\) 的单调序列内.
这里以最长上升序列给出代码:
for(int i=n;i>=1;--i){
next=0;
for(int j=i+1;j<=n;++j){
if((a[j]>a[i])&&(f[j]+1)>f[i]){
f[i]=f[j]+1;
next=j;
}
}
if(maxn<=f[i]){
maxn=f[i];
head=i;
}
pre[i]=next;//pre用于记录路径,不需要时可不加
}
cout<<f[head]<<endl;
Ⅰ-Ⅱ 更优的最长上升子序列问题
上述做法为 \(n^{2}\) 做法,效率显然不是太高,事实上,根据贪心思路,我们可以将最长上升子序列问题的求解优化至 \(n\log n\)
考虑到对于每次转移,我们都可以记录下当前序列最末尾的数字,可以想到的是,当前序列末尾数字越小,则可能被填入子序列的数字就越多,因此末尾更小的序列可能就是决策更优的,因此我们每次都保留这样的序列向后拓展.
也就是,我们使用数组 \(f_{i}\) 表示当枚举到第 \(i\) 位时,全部最长上升子序列中,最小的末尾元素值,则可以发现:
- 当新的元素值更大的时候,符合上升条件,直接填到末尾
- 当新的元素值更小的时候,虽然不符合上升条件,但是符合更新条件,可能会导致更优的决策,因此我们向前找到第一个能放当前值的 \(i\) 进行更新.
代码如下:
#include<bits/stdc++.h>
using namespace std;
int n;
int a[100001];
int f[100001];
int main(){
ios::sync_with_stdio(false);
cin>>n;
for(int i=1;i<=n;++i){
cin>>a[i];
}
memset(f,0x3f,sizeof f);
f[1]=a[1];
int len=1;
for(int i=2;i<=n;++i){
int l=0,r=len;
if(a[i]>f[len]){
f[++len]=a[i];
}
else{
f[lower_bound(f+1,f+len+1,a[i])-f]=a[i];
}
}
cout<<len<<endl;
}
Ⅱ 带捆绑的最长序列问题
有两组数列一一对应,选出一些数让两边数列都具有单调性,求序列最大值
友好城市
这类问题的统一解决方式:
①存成结构体按一个变量sort
②对另外的变量执行最长序列
sort(a+1,a+n+1);
cout<<upperlenth(1,n);
其中:
int upperlenth(int l,int r){
for(int i=l;i<=r;++i){
f[i]=1;
}
int maxn=1,head=0,next=0;
for(int i=r;i>=l;--i){
next=0;
for(int j=i+1;j<=r;++j){
if((a[j].r>a[i].r)&&(f[j]+1)>f[i]){
f[i]=f[j]+1;
next=j;
}
}
if(maxn<=f[i]){
maxn=f[i];
head=i;
}
}
return f[head];
}
Ⅲ 求最少拆链问题
有一组数列,求最少能把它们分成几组有单调性的数列
例题-拦截导弹Ⅱ
有一定理:最少拆链=该序列的相反最长序列
Ⅳ 最长公共问题
最长公共问题有最长公共子串、最长公共子序列和最长公共前缀三类
Ⅳ-Ⅰ 最长公共子串问题
定义:字串是一个字符串中连续的一段,公共子串即为几个字符串都含有的子串.
我们求的是两个字符串的公共子串,所以应该意识到这个 dp 方程是个二维数组 \(dp[i][j]\) ,代表字符串 \(x\) 的前 \(i\) 个字符与字符串 \(y\) 的前 \(j\) 个字符的最长公共子串.
对于 dp[i][j] 来说,它的值有两种可能,取决于字符 x[i] 和 y[j] 这两个字符是否相等
如果两个字符相等,则 \(dp[i][j] = dp[i-1][j-1] + 1\), \(1\) 代表 \(x\) 的第 \(i\) 个字符与 \(y\) 的第 \(j\) 个字符相等,根据 \(dp[i][j]\) 的定义,那么它等于 \(x\) 的前 \(i-1\) 个字符与 \(y\) 的 前 \(j-1\) 个字符的最长公共子串加一.
如果 \(x\) 的第 \(i\) 个字符与 \(y\) 的第 \(j\) 个字符不相等,那么显然 \(dp[i][j] = 0\) ,因为 \(dp[i][j]\) 定义为最长公共子串,所以只要有一个字符不相等,就说明不满足最长公共子串这个定义.
综上,我们的状态转移方程如下:
Ⅳ-Ⅱ 最长公共子序列问题
定义:字序列是一个字符串中有序的一段,即序列中的每个数在原序列内都从左到右排列,公共子序列即为几个字符串都含有的子序列.
类似最长公共子串,但不一样的是,假如 \(x[i] \neq y[j]\),不要急着清零,因为最长公共子序列并不需要严格相邻. 此时应该跳过不相等的内容,那么如何跳过呢? 我们采用了继承相邻状态的方法.
Ⅳ-Ⅲ 排列的最长公共子序列问题
假如序列中的每个数都仅仅出现过一次,那么我们可以考虑对上述 \(n^{2}\) 算法进行优化.
A:3 2 1 4 5
B:1 2 3 4 5
我们不妨给它们重新标个号:把 \(3\) 标成 \(a\),把 \(2\) 标成 \(b\),把 \(1\) 标成 \(c\),以此类推,目的是将 \(A\) 变为一个递增的序列,于是变成:
A: a b c d e
B: c b a d e
这样标号之后,最长公共子序列长度显然不会改变. 但是出现了一个性质:
两个序列的子序列,一定是 \(A\) 的子序列. 而A本身就是单调递增的,因此这个子序列是单调递增的
换句话说,只要这个子序列在 \(B\) 中单调递增,它就是 \(A\) 的子序列
哪个最长呢?当然是 \(B\) 的最长上升子序列最长
因此,我们只需要在 \(n\log n\) 的时间复杂度内求出 \(B\) 的最长上升子序列即可
区间DP
区间DP主要用于能将大问题划分成小段区间的问题当中,一般来说,区间DP有这样几种维度可以枚举.
- 区间长度
- 起点位置
- 断开位置
给出一个大致模板:
//首先预处理(区间DP中预处理真的很重要,特别是对于长度为 1 或 n 的特殊区间)
for(int len=2;len<=n;++len){ //从小到大枚举区间长度,len=2因为已经预处理了
for(int i=1;i+len-1<=n;++i){ //枚举起点
int j=i+len-1;
for(int k=i;k<=j=1;++k){ //枚举划分点
dp[i][j]=max(dp[i][j],dp[i][k]+dp[k+1][j]+cost);
//从k点合并,cost是合并费用
}
}
}
可以看出,区间DP就是一个不断合并的过程. 下面列举一些区间DP的经典例题:
Ⅰ 石子合并
Ⅱ 环形问题的处理
Ⅲ 整数划分
Ⅳ 凸多边形的三角剖分
Ⅰ 石子合并
Ⅰ-Ⅰ 题目描述
有 \(n\) 堆石子排成一排( \(n\le 100\) ),现要将石子有次序地合并成一堆,规定每次只能选相邻的两堆合并成一堆,并将新的一堆的石子数,记为改次合并的得分,编一程序,由文件读入堆数 \(n\) 及每堆石子数( \(\le 200\) ).
(1)选择一种合并石子的方案,使得做 \(n-1\) 次合并,得分的总和最少
(2)选择一种合并石子的方案,使得做 \(n-1\) 次合并,得分的总和最多
这是一道需要我们合并的区间DP题,常规做法求解即可,在这里提到一个小知识:
Ⅰ-Ⅱ 前缀和求区间和
前缀和 \(s[k]\) 是数列里从起点一直到第 \(k\) 个数的所有数字之和,假如我们想求从第 \(i\) 个数到第 \(j\) 个数的所有数字之和,我们可以直接用 \(s[j]-s[i]\),这样我们只需要 \(O(N)\) 的预处理就可以求区间和了.
Ⅰ-Ⅲ 代码实现
点击查看代码
for(int i=1;i<=n;++i){
cin>>a[i];
s[i]=s[i-1]+a[i];
}
memset(f[1],0x3f,sizeof(f[1]));
for(int i=1;i<=n;++i){
f[1][i][i]=0;
}
for(int len=2;len<=n;len++){
for(int i=1;i<=n-len+1;i++){
int j=i+len-1;
for(int k=i;k<j;k++){
f[1][i][j]=min(f[1][i][j],f[1][i][k]+f[1][k+1][j]+s[j]-s[i-1]);
f[0][i][j]=max(f[0][i][j],f[0][i][k]+f[0][k+1][j]+s[j]-s[i-1]);
}
}
}
cout<<f[1][1][n]<<endl<<f[0][1][n];
Ⅱ 环形问题的处理
现在我们想一个问题,假如石子排列成一个环形,那我们要怎么处理.
环形数据结构的正确解法应该是:将原数组复制一份,然后接到原数组后面
这样做为什么是对的,因为环形虽然首尾相接,但不会出现绕了超过一圈被更新的情况.
访问或建立时,我们可以直接使用模 \(n\) 运算.
Ⅲ 整数划分
用 \(f[i][j]\) 表示区间 \([1,i]\) 划分了 \(j\) 次之后的最大乘积. 那么我们有:
其中 \(a[k+1][i]\) 表示区间 \([k+1,i]\) 对应的数字,如在 “321” 中,\(a[1][2]=32\) , \(a[2][3]=21\) .
那么现在我们仍然有一个问题没有解决:如何求 \(a\) 的值?
其实很简单,只需要这样就行
点击查看代码
void ton(const string &x){
for(int len=1;len<=x.length();++len){
for(int i=1;i<=x.length()-len+1;++i){
for(int j=i;j<=i+len-1;++j){
a[i][i+len-1]=a[i][i+len-1]*10+x[j-1]-'0';
}
}
}
}
Ⅳ 凸多边形的三角剖分
给定一具有 \(N\) 个顶点(从 \(1\) 到 \(N\) 编号)的凸多边形,每个顶点的权均已知。问如何把这个凸多边形划分成 \(N-2\) 个互不相交的三角形,使得这些三角形顶点的权的乘积之和最小?
根据区间DP的思想,我们规定区间 \([i,j]\) 在凸多边形里表示从顶点 \(i\) 到顶点 \(j\) (字典序) 的全部边与边 \(IJ\) 围成的封闭图形,那么,我们就可将一个这样的多边形划分成三部分:若中间有一个以 \(i,j\) 为顶点的三角形,则还存在左边,右边各一个多边形,而这三个部分中,三角形的顶点权乘积可以直接求出,而其余两个多边形又可以继续DP. 这样我们就能用区间DP来解决这道题. 注意边界条件,两条边的多边形无意义.
另外,本题的初始化应该从三角形的顶点权乘积可求来下手,即:
再说一句,矩阵连乘这道题与本题目代码一致,它的题目如下:
一个 \(nm\) 矩阵由 \(n\) 行 \(m\) 列共 \(nm\) 个数排列而成。两个矩阵 \(A\) 和 \(B\) 可以相乘当且仅当 \(A\) 的列数等于 \(B\) 的行数。一个 \(NM\) 的矩阵乘以一个 \(MP\) 的矩阵等于一个 \(NP\) 的矩阵,运算量为 \(nmp\) 。 矩阵乘法满足结合律,\(ABC\) 可以表示成 \((AB)C\) 或者是 \(A(BC)\) ,两者的运算量却不同。例如当 \(A=23 ,B=34 ,C=45\) 时,\((AB)C=64\) 而 \(A(BC)=90\) 。显然第一种顺序节省运算量。 现在给出 \(N\) 个矩阵,并输入 \(N+1\) 个数,第 \(i\) 个矩阵是 \(a[i-1]\times a[i]\) ,求最优的运算量.
坐标DP
坐标DP本身和区间DP有许多相似之处,比如都用一些类似坐标的信息来定义DP数组. 但是两者不同的是,区间DP是通过小区间判断大区间,而坐标DP是通过其他坐标判断当前坐标. 总的来说,坐标DP有下面几种经典的问题.
Ⅰ 传纸条-路线往返求最大价值
Ⅱ 晴天小猪历险记之Hill-三角形的最大价值路线
Ⅲ 免费馅饼-时空坐标轴
Ⅳ 盖房子-阻碍联通的分型图最大面积
Ⅴ 矩阵取数游戏
Ⅰ 传纸条-路线往返求最大价值
对于此类问题,我们不妨将两次行动一起DP,只需要判断两个轨迹不重复即可,因为是二维地图,很容易想到二维dp数组,但实际上还要再加两维(因为是两次行动一起DP),状态转移方程为
但是这样做很麻烦,注意到若设步数为 \(k\) ,总有 \(i+j=k\) ,因此压缩为三维 \(f[k][i_{i}][i_{2}]\) ,这样根据 \(j=k-i\) 即可算出两点坐标,状态转移方程变为
实际上,因为第一维全都是 \(k-1\) ,我们还可以用滚动数组将其直接优化掉. 这里不再展开,给出三维代码:
点击查看代码
for(int k=1;k<=m+n-3;++k){
for(int i=0;i<=k;++i){
for(int j=0;j<=k;++j){
if(i==j){
continue;
}
f[k][i][j]=
max(
max(f[k-1][i][j],f[k-1][i-1][j]),
max(f[k-1][i][j-1],f[k-1][i-1][j-1])
)
+h[i][k-i]+h[j][k-j];
}
}
}
cout<<f[m+n-3][m-1][m-2];
Ⅱ 晴天小猪历险记之Hill-三角形的最大价值路线
对于上图这样的经典三角形,求从下到上的最大价值路线.
和其他三角图形题的做法类似,我们先将三角形进行左对齐.
我们发现,每一个上层节点都可以由其下层节点而来,即:
这样我们从下向上遍历(以坐标来看是从大到小dp)就可以求出最大价值路线
实际上,所有这样的问题都可以使用最短路解决
Ⅲ 免费馅饼-时空坐标轴
对于这样带有时间维度的题目,分时间DP似乎不是明智之举,那么我们为什么不算出每个物品到达的时刻,然后以时刻为纵坐标进行坐标DP呢.
点击查看代码
while(cin>>a>>b>>c>>d){
if((h-1)%c==0){
s[a+(h-1)/c][b]+=d;
}
}
for(int i=1;i<=100;++i){
for(int j=1;j<=w;++j){
f[i][j]=max({f[i-1][j],f[i-1][j-1],f[i-1][j+1],f[i-1][j-2],f[i-1][j+2]})+s[i][j];
}
}
cout<<f[100][1];
Ⅳ 盖房子-阻碍联通的分型图最大面积
此类问题我在其他文章中已说明,详见 盖房子 hzoi-tg#262
另外,给出一道三角形分型的此类问题解析 三角蛋糕 hzoi-tg#261
Ⅴ 矩阵取数游戏
矩阵取数游戏是坐标DP一道十分经典的题目,详见 矩阵取数游戏<简单版> hzoi-tg-906-2
树形DP
树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的.
下面给出树形DP的基本框架:
void dfs(int s){
for(遍历全部子节点){
dfs(子节点);
进行本层dp;
}
}
树形DP的结构不算太难,主要难点只集中于两个方面.
Ⅰ 存图
Ⅱ 树上背包问题
Ⅰ 存图
树形DP的存图一般都是存树,但是有时候不好判断父子关系,所以我们存无向图,假如以无向图形式存储,那么我们只需要保证dfs时,当前节点不会返回它来的地方即可. 我们给dfs加一个参数 \(last\) 表示上一个遍历节点,然后判断即将遍历的节点是否等于 \(last\) 即可.
至于二叉树的存储,通常使用 dfs ,详见 三色二叉树 hzoi-tg#282 存图方法
Ⅱ 树上背包问题
详见 树上背包问题
状压DP
Ⅰ 概述
状态压缩 DP 其实大部分都属于其他 DP 的范畴,即状态转移方程很好推. 状压的主要难点是在它的状态可能有很多. 比如下面的例题.
在一个国际象棋棋盘中放 \(k\) 个国王,使它们都不能互相吃. 问方案数.
这个题我们想要 DP,那么需要记录当前这一行的每个位置是否放了棋子,但是当 \(n\) 很大的时候(当然不能非常大),我们开三十维状态数组就会很臃肿,内存也不允许.
因此,我们想要开下这样的数组,就需要将这样的状态压缩进同一个变量里.
假如我们用 “状态的二进制表示中第 \(i\) 位为 \(1\)” 来表示第 \(i\) 位放了棋子,那么我们可以很方便地把一整行的状态压缩进一个整形变量里,鉴于一个整型变量最大只能有 \(31\) 位,并且状压 DP 的复杂度也就比深搜快了一点,因此判断一道题能不能状压,一般看 \(n\le 30\) 就可以了.
我们这样做还有一个优势,就是在进行不同行比较的时候可以使用位运算快速比较. 下面给出一些比较的例子:
判断该行第 \(i\) 位是否为 \(1\)
(a>>i)&1==1
判断该行与下一行是否相同
!(a^b)
判断状态 \(a\) 中是否包含状态 \(b\) 中的全部元素
(a&b)==b
或者 (a|b)==a
判断状态 \(a\) 中是否存在与状态 \(b\) 中坐标相邻的数字
(a&(b<<1)||(a&(b>>1)))
这样,我们通过枚举状态即可得到答案.
以例题为例给出代码:
点击查看代码
#include<stdio.h>
using namespace std;
typedef long long ll;
int n,k,top=0;
int c[1<<10],s[1<<10];
void dfs(int cond,int sum,int pos){
if(pos>n){
c[++top]=cond;
s[top]=sum;
return;
}
dfs(cond+(1<<pos-1),sum+1,pos+2);
dfs(cond,sum,pos+1);
}
ll f[11][1<<10][31];
int main(){
scanf("%d%d",&n,&k);
dfs(0,0,1);
for(int i=1;i<=top;++i) f[1][c[i]][s[i]]=1ll;
for(int i=2;i<=n;++i)
for(int j=1;j<=top;++j)
for(int h=1;h<=top;++h){
if(c[j]&c[h]) continue;
if((c[j]<<1)&c[h]) continue;
if((c[j]>>1)&c[h]) continue;
for(int sum=k;sum>=s[j];--sum)
f[i][c[j]][sum]+=f[i-1][c[h]][sum-s[j]];
}
ll ans=0ll;
for(int i=1;i<=top;++i) ans+=f[n][c[i]][k];
printf("%lld",ans);
return 0;
}
Ⅱ 例题:Tourist Attractions
题目描述
给出一张有 \(n\) 个点 \(m\) 条边的无向图,每条边有边权。
你需要找一条从 \(1\) 到 \(n\) 的最短路径,并且这条路径在满足给出的 \(g\) 个限制的情况下可以在所有编号从 \(2\) 到 \(k+1\) 的点上停留过。
每个限制条件形如 \(r_i, s_i\),表示停留在 \(s_i\) 之前必须先在 \(r_i\) 停留过。
注意,这里的停留不是指经过。
解法分析
对于这道题的状压. 我们考虑枚举 "现在已经在哪些点停留" 这样一种状态. 然后去寻找每一个当前未停留的点,考虑这个点的前置节点是否全部已经停留,如果是,那么枚举每一个已在集合内的节点,尝试把这个点通过某条边放入集合内,进行状态转移.
那么我们需要进行状态压缩的有两个东西: 现在已有的点的情况 (用于枚举) 和每个点的前置节点情况 (用于判断).
最后需要我们输出的就是在全部节点停留情况下的状态.
这道题的主要思路:
for(int i=1;i<=(1<<k)-1;++i){
//all possible chance
for(int j=0;j<=k-1;++j){ //node
if(i&(1<<j)){
//if this node in this chance
for(int hdk=0;hdk<=k-1;++hdk){
//then try to add a node
if(!(i&(1<<hdk))&&((i|r[hdk+2])==i)){
//if find a node not in chance and can be placed
update();
}
//then do the change. why it's +2 is because I store 3 in position 1.(1 and 2 is no need)
}
}
}
}
那么,为了更新已选中的点的距离,我们需要知道从任意点到另一点的距离,也就是跑一边全源最短路.
我们定义 \(dis[i][j]\) 为全源最短路下的 \(i,j\) 最短距离. \(r[i]\) 表示 \(i\) 的全部前置节点的状压表示. \(f[i][j]\) 表示在已经选择 \(i\) (状压表示) 这些节点的情况下,且最后一个选中的节点为 \(j\) 的最短路径长度. 那么我们有:
那么我们怎么表示 \(i\ add\ k\) 呢. 其实只需要将 \(i\) 中的 \(k\) 点的位置置为 \(1\). 也就是做一次或运算.
这题我也不知道它卡什么. 我存图的 vector 滚动数组会比前向星小很多,而 DIJ 又比 SPFA 快很多. 总之按对的来吧.
点击查看代码
#include<bits/stdc++.h>
using namespace std;
int n,m,k;
struct edge{
int to,w;
};
struct node{
int id,dis;
bool operator<(const node A)const{
return dis>A.dis;
}
};
priority_queue<node> q;
vector<edge> e[20001];
int dis[31][20001]; //root i's dis j
bool vis[20001];
void dij(int s){
memset(vis,false,sizeof vis);
for(int i=1;i<=n;++i){
dis[s][i]=1000000000;
}
while(!q.empty()) q.pop();
dis[s][s]=0;
q.push(node{s,0});
while(!q.empty()){
node u=q.top();
q.pop();
if(vis[u.id]) continue;
vis[u.id]=true;
for(edge i:e[u.id]){
if(dis[s][i.to]>dis[s][u.id]+i.w){
dis[s][i.to]=dis[s][u.id]+i.w;
q.push(node{i.to,dis[s][i.to]});
}
}
}
}
int f[1<<20][31],r[31],ans=1000000000; //1=zt(which nodes been chose) 2=node //r= mustfore
int main(){
cin>>n>>m>>k;
for(int i=1;i<=m;++i){
int a,b,c;
cin>>a>>b>>c;
e[a].push_back(edge{b,c});
e[b].push_back(edge{a,c});
}
if(k==0){
dij(1);
cout<<dis[1][n];
return 0;
}
int q;
cin>>q;
while(q--){
int a,b;
cin>>a>>b;
r[b]+=(1<<(a-2));//
}
for(int i=1;i<=k+1;++i){
dij(i);
}
for(int i=0;i<=(1<<k)-1;++i){
for(int j=1;j<=k+1;++j){
f[i][j]=1000000000;
}
}
f[0][1]=0;
for(int i=2;i<=k+1;++i){
if(!r[i]){ //if this point hasn't any requires.
f[1<<(i-2)][i]=dis[1][i];
}
}
for(int i=1;i<=(1<<k)-1;++i){ //all possible chance
for(int j=0;j<=k-1;++j){ //node
if(i&(1<<j)){ //if this node in this chance
for(int hdk=0;hdk<=k-1;++hdk){ //then try to add a node
if(!(i&(1<<hdk))&&((i|r[hdk+2])==i)){ //if find a node not in chance and can be placed
f[i|(1<<hdk)][hdk+2]=min(f[i|(1<<hdk)][hdk+2],f[i][j+2]+dis[j+2][hdk+2]);
} //then do the change. why it's +2 is because I store 3 in position 1.(1 and 2 is no need)
}
}
}
}
for(int i=2;i<=k+1;++i){
ans=min(ans,f[(1<<k)-1][i]+dis[i][n]);
}
cout<<ans;
}
概率与期望DP
Ⅰ 关于概率与期望
从数学角度来说,事件 \(A\) 的概率 \(P(A)\) 定义为 \(\frac{发生 A 的事件总数}{事件总数}\). 设有一个变量 \(x\) 可以拥有不同的值,它的值为 \(x_{i}\) 的概率为 \(p_{i}\),那么变量 \(x\) 的期望 \(E(x)\) 定义为 \(\sum x_{i}\times p_{i}\).
比如掷骰子,设结果为 \(x\),事件 \(A=\) "\(x=6\)",\(P(A)=\frac{1}{6}\). \(E(x)=\sum i\times \frac{1}{6}=3.5\).
对于概率与期望,一个很重要的性质是它们是可以分段计算的. 这里的分段计算,一部分是指可以分成几小部分分别计算然后加起来,还有另一部分是像计算复合函数 \(f(g(x))\) 一样,先计算 \(u=g(x)\),再计算 \(ans=f(u)\). 这启示我们概率期望的两种重要解法:搜索和递推
另外,还有非常重要的关于递推顺序的口诀:概率正推,期望逆推.
下面分别用搜索例题和递推例题来解释具体方法.
Ⅱ 搜索:扑克牌
题目描述
把一副扑克牌( \(54\) 张)随机洗开,倒扣着放成一摞.
然后 从上往下依次翻开每张牌,每翻开一张黑桃、红桃、梅花或者方块,就把它放到对应花色的堆里去.
得到 \(A\) 张黑桃、\(B\) 张红桃、\(C\) 张梅花、\(D\) 张方块需要翻开的牌的张数的期望值是多少?
特殊地,如果翻开的牌是大王或者小王,将会把它作为某种花色的牌放入对应堆中,使得放入之后期望值尽可能小.
题目分析
概率与期望DP的搜索一般都是记忆化搜索. 也属于动态规划的一种.
根据概率正推,期望逆推,我们首先设计递归最后一层的答案. 即已经翻出我们需要的牌的情况下期望是多少,很显然是 \(0\). 此后每一次倒推,我们都需要在上一次的基础上再翻一张牌 (\(+1\)),并累加各个子状态的期望.
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define inf 100.0
double f[14][14][14][14][5][5];
int a,b,c,d;
double dfs(int q,int w,int e,int r,int dw,int xw){
if(f[q][w][e][r][dw][xw]>0) return f[q][w][e][r][dw][xw];
// cout<<"dfs "<<q<<" "<<w<<" "<<e<<" "<<r<<" "<<dw<<" "<<xw<<endl;
int cnt[5]={0,q,w,e,r};
cnt[dw]++;cnt[xw]++;
if(cnt[1]>=a&&cnt[2]>=b&&cnt[3]>=c&&cnt[4]>=d){
f[q][w][e][r][dw][xw]=0;
return 0;
}
int sum=54-cnt[1]-cnt[2]-cnt[3]-cnt[4];
if(sum<=0){
f[q][w][e][r][dw][xw]=inf;
return inf;
}
double res=1;
if(q<13) res+=((13.0-q)/sum)*dfs(q+1,w,e,r,dw,xw);
if(w<13) res+=((13.0-w)/sum)*dfs(q,w+1,e,r,dw,xw);
if(e<13) res+=((13.0-e)/sum)*dfs(q,w,e+1,r,dw,xw);
if(r<13) res+=((13.0-r)/sum)*dfs(q,w,e,r+1,dw,xw);
if(!dw){
double greater=inf;
for(int i=1;i<=4;++i){
greater=min(greater,(1.0/sum)*dfs(q,w,e,r,i,xw));
}
res+=greater;
}
if(!xw){
double greater=inf;
for(int i=1;i<=4;++i){
greater=min(greater,(1.0/sum)*dfs(q,w,e,r,dw,i));
}
res+=greater;
}
f[q][w][e][r][dw][xw]=res;
return res;
}
int main(){
cin>>a>>b>>c>>d;
memset(f,-1,sizeof f);
if(a+b+c+d>54){
cout<<"-1.000";return 0;
}
double ans=dfs(0,0,0,0,0,0);
printf("%.3lf",(ans>inf-1e-8? -1:ans));
}
Ⅲ 递推:卡牌游戏
题目描述
\(N\) 个人坐成一圈玩游戏。一开始我们把所有玩家按顺时针从 \(1\) 到 \(N\) 编号。首先第一回合是玩家 \(1\) 作为庄家。每个回合庄家都会随机(即按相等的概率)从卡牌堆里选择一张卡片,假设卡片上的数字为 \(X\),则庄家首先把卡片上的数字向所有玩家展示,然后按顺时针从庄家位置数第 \(X\) 个人将被处决(即退出游戏)。然后卡片将会被放回卡牌堆里并重新洗牌。被处决的人按顺时针的下一个人将会作为下一轮的庄家。那么经过 \(N-1\) 轮后最后只会剩下一个人,即为本次游戏的胜者。现在你预先知道了总共有 \(M\) 张卡片,也知道每张卡片上的数字。现在你需要确定每个玩家胜出的概率。
这里有一个简单的例子:
例如一共有 \(4\) 个玩家,有四张卡片分别写着3,4,5,6
.
第一回合,庄家是玩家 \(1\) ,假设他选择了一张写着数字 \(5\) 的卡片。那么按顺时针数 1,2,3,4,1
,最后玩家 \(1\) 被踢出游戏。
第二回合,庄家就是玩家 \(1\) 的下一个人,即玩家 \(2\).假设玩家 \(2\) 这次选择了一张数字 \(6\),那么 2,3,4,2,3,4
,玩家 \(4\) 被踢出游戏。
第三回合,玩家 \(2\) 再一次成为庄家。如果这一次玩家 \(2\) 再次选了 \(6\),则玩家 \(3\) 被踢出游戏,最后的胜者就是玩家 \(2\).
题目分析
这是一道经典的期望递推DP. 其实二者的本质是一样的,但一般来说递推DP更难一些.
对于这道题,不妨以 剩下的人数+庄家 作为状态,枚举庄家,枚举牌,算出可能的情况,然后加上对应的期望,最后倒序加和即可.
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define div *1.0/
int n,m;
int k[51];
double f[51][51]; //host j the winp i, while remain k
inline int deadman(int tot,int tar){
return tar%(tot+1);
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;++i){
cin>>k[i];
}
f[1][1]=1 div 1;
for(int i=2;i<=n;++i){ //size
for(int j=1;j<=m;++j){ //choose
int p=(k[j]%i==0? i:k[j]%i);
for(int k=1;k<=i-1;++k){
if(++p>i) p=1;
f[i][p]+=f[i-1][k] div m;
}
}
}
for(int i=1;i<=n;++i){
printf("%.2lf%% ",100*f[n][i]);
}
}
实际上,概率与期望的灵活性十分高,需要注意其与其他类型DP,数据结构与博弈论的结合考察.
Ⅳ 二项式期望 DP
OSU
OSU 的题目是这样的:有一些相邻的块,给定每一个块的联通概率,每个连通块对答案有 \(size^{3}\) 的贡献,求总期望
关于此题我曾写过题解 此处
此类题的关键之处在于,当我们设计了一个线性状态 \(f_{i}\) 之后,假如我们基于拼接的思想,尝试维护出来了当前最近的一个连通块个数为 \(x\),其贡献应为 \(x^{3}\),那么现在我们再为其拼接一个块,贡献就会变为 \((x+1)^3\),即 \(x^{3}+3x^{2}+3x+1\),注意到这里还有我们尚未维护的 \(x^{2}\) 与 \(x\) 项,因此我们还需要维护这两种信息才能对 \(x^{3}\),进行转移. 对于 \(x^{2}\) 的维护,显然 \((x+1)^{2}=x^{2}+2x+1\),因此只需要维护 \(x\) 项即可,对于 \(x\) 的维护是显然的
引入两个变量 \(l_{i},s_{i}\),其中 \(f_{i}\) 表示考虑前 \(i\) 个,与 \(i\) 联通的连通块长度,\(s_{i}\) 表示前 \(i\) 个的总得分
容易想到 \(l_{i}\) 的转移:当第 \(i\) 个为断点时将 \(l_{i}\) 置零,否则 \(l_{i}=l_{i-1}+1\)
考虑 \(s_{i}\) 的转移:容易想到,当 \(i\) 为断点时有 \(s_{i}=s_{i-1}\),否则,我们可以得出 \(s_{i-1}=(l_{i-1})^{3}+S\)(其中 \(S\) 是一个之前累积的得分),而 \(s_{i}=(l_{i})^{3}+S\),根据上述 \(l_{i}\) 的转移式我们可以知道 \(l_{i}=l_{i-1}+1\),因而有:
因为 \(S\) 不好维护,考虑对两项做差分,消掉 \(S\)
因此,我们只需要维护出 \(l_{i}\),即可递推求解 \(s_{i}\)
下面我们来加上概率考虑
期望有一个性质(期望的线性性)\(E(a+b)=E(a)+E(b)\) ,因此有下述转化:
对于 \(i\) 确定为断点的情况,我们有 \(l_{i}=0\),因此 \(E((l_{i-1})^2)=E(l_{i-1})=0\),从而 \(E(s_{i})=E(s_{i-1})\)
否则,对于 \(i\) 确定联通的情况同理,有 \(E(s_{i})=E(s_{i-1})+3(l_{i-1})^{2}+3l_{i-1}\)
否则,对于随机选择的块,直接用上述两种情况乘对应的概率即可,即:
注意到我们还没有维护 \(E(l_{i})\)
对于 \(i\) 确定为断点的情况,\(E(l_{i})=0\)
对于 \(i\) 确定联通的情况,\(E(l_{i})=E_{l_{i-1}+1}=E(l_{i-1})+1\)
否则,按照上述思路,应为
接着考虑维护 \(E((l_{i})^{2})\)
对于 \(i\) 确定为断点的情况,\(E((l_{i})^{2})=0\)
对于 \(i\) 确定联通的情况,\(E((l_{i})^{2})=E_{(l_{i-1}+1)^{2}}=E((l_{i-1}^{2}+2l_{i-1}+1))=E((l_{i-1}^{2}))+2E(l_{i-1})+1\)
否则,按照上述思路,应为
因为已经维护过了 \(E(l_{i})\),因此至此我们完成了全部变量的维护
#include<bits/stdc++.h>
using namespace std;
int n;
double p[100001],l1[100001],l2[100001],s[100001];
int main(){
cin>>n;
for(int i=1;i<=n;++i){
cin>>p[i];
}
for(int i=1;i<=n;++i){
l1[i]=p[i]*(l1[i-1]+1);
l2[i]=p[i]*(l2[i-1]+2*l1[i-1]+1);
s[i]=(1-p[i])*s[i-1]+p[i]*(s[i-1]+3*l2[i-1]+3*l1[i-1]+1);
}
printf("%.1lf",s[n]);
}
Another OSU
OSU 的 \(k\) 次幂升级版,即贡献变为了 \(x^{k}\)
这一次我们不能再像上述一个一个推式子了,我们需要找一个普遍的规律:
对于刚才的问题我们发现:要想维护一个 \(x^{k}\) 的贡献,显然需要维护 \(k'\in[1,k]\) 的所有 \(x^{k'}\) 的贡献
有二项式定理,即 \((x + y)^n = \sum_{i = 0}^n C_{n}^i x^{n - i} y^{i}\),考虑设 \((f_{i})^{k}\) 为我们对 \(x_{k}\) 项进行的位置为 \(i\) 的转移,效仿刚才的解法,我们会有:
可以发现在这里实际上用到了全部次数比它低的 \(f_{i}\),因此对于每一个 \(i\),按 \(k\) 从小到大维护即可.
此外,除了用二项式定理求 \(C^{i}_{n}\),还可以用杨辉三角来求系数:
杨辉三角递推式:
计数DP
Ⅰ 计数DP简述
计数 DP 是一类求方案数的DP,但是不一样的是,计数DP需要处理的问题往往十分复杂,使用普通的线性 DP 会超时. 这种时候就可以用计数 DP 进行解决.
实际上,计数 DP 的核心思想就是“用远处的状态来更新当前状态,进而减少时间开销”.
一般的计数 DP 是使用容斥原理+排列组合实现转移的. 也有特例. 下面将分别选择两种基本 DP 思路的计数 DP 进行讲解.
Ⅱ 排列组合计数-Gerald and Giant Chess
给定一个 \(H*W\) 的棋盘,棋盘上只有 \(N\) 个格子是黑色的,其他格子都是白色的. 在棋盘左上角有一个卒,每一步可以向右或者向下移动一格,并且不能移动到黑色格子中. 求这个卒从左上角移动到右下角,一共有多少种可能的路线. \(H,W\) 很大,\(N\) 很小
因为这题 \(N\) 很小,为我们提供了一个根据每个 \(N\) 转移的思路.
为了递推的无后效性,我们把黑色格子按行再列优先的顺序从小到大排序,接着考虑构造不合法状态,于是设 \(f_{i}\) 表示以第 \(i\) 个黑色格子结尾的路径条数,因此转移为
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
int h,w,n;
int f[2001];
struct node{
int x,y;
bool operator<(const node &A)const{
if(x==A.x) return y<A.y;
return x<A.x;
}
}a[2001];
long long fact[1000001];
const int p=1e9+7;
long long power(long long a,long long n){
long long ans=1;
while(n){
if(n&1)ans=ans*a%p;
a=a*a%p;
n>>=1;
}
return ans;
}
void dofact(int n){
fact[0]=1;
for(int i=1;i<=n;++i){
fact[i]=fact[i-1]*i%p;
}
}
long long C(long long n,long long m){
if(!m){
return 1;
}
if(n<m){
return 0;
}
if(m>n-m){
m=n-m; //C{n}{m}=C{n}{n-m};
}
long long ans=fact[n]*power(fact[m],p-2)%p*power(fact[n-m],p-2)%p;
return ans;
}
long long lucas(long long n,long long m){
if(!m) return 1;
return lucas(n/p,m/p)*C(n%p,m%p)%p;
}
signed main(){
cin>>h>>w>>n;
for(int i=1;i<=n;++i){
cin>>a[i].x>>a[i].y;
}
dofact(h+w);
sort(a+1,a+n+1);
for(int i=1;i<=n;++i){
f[i]=lucas(a[i].x+a[i].y-2,a[i].x-1);
for(int j=1;j<i;++j){
if(a[j].x<=a[i].x&&a[j].y<=a[i].y){
f[i]-=f[j]*lucas(a[i].x+a[i].y-a[j].x-a[j].y,a[i].x-a[j].x);
f[i]=(f[i]+p)%p;
}
}
}
int ans=lucas(h+w-2,h-1);
for(int i=1;i<=n;++i){
ans-=(f[i]*lucas(h+w-a[i].x-a[i].y,h-a[i].x))%p;
ans=(ans+p)%p;
}
cout<<ans;
}
Ⅲ 位次问题转方案数-A decorative fence
这题爆搜肯定是不行,考虑一个事实:
假设 1xxxx 是 \(Rank=a\) 的一种方案,1yyyy 是 \(Rank=b\) 的另一种方案,而目标 \(Rank\ k\) 满足 \(a\le k\le b\),则有 \(Rank=k\) 的方案的首位一定是 \(1\).
跟我们猜数是一样的. 假设有个数给你猜,\(114\) 小了,\(191\) 大了,那你肯定知道这个数最高位是什么了.
所以我们就开个数组来转移并维护这个 \(Rank\) 值.
注意到 \(Rank\) 并不是非常好维护,我们可以考虑维护每种情况的方案数,然后按字典序从小到大依次加起来,这样就是 \(Rank\) 值了.
设 \(f[i][j][k]\) 为放入前 \(i\) 块木板构成的栅栏,当第 \(i\) 块木板的 \(Rank=j\) 时的方案数. 注意到这样还是不好维护,因为要考虑是高低高还是低高低,那么再开一维 \(k\) 来表示这个. \(k=1\) 时 \(1\) 为高,反之亦然.
那么这个转移非常好写,也不是本题的难点.
这里唯一需要注意的是求和的范围. 因为我们这个 \(k\) 指代的是 \(Rank=k\),而且会涉及到选高的还是选低的的问题,也就有了 \(k\) 的范围的差异.
那么还很容易注意到,这个转移和 \(n,m\) 完全没有关系,所以从多测里提出来作为初始化.
然后就是按上面的思想来逼近我们要求的答案.
先来确定第一位吧,我们需要做的就是遍历每个 \(1\le i\le n\),只要有 \(\sum^{i}_{j=1}(f[n][j][0]+f[n][j][1])> m\),就能判定 \(j-1\) 是我们要求的那个第一位.
很显然,当我们之前几位选过某个数字,那我们就不能再选了,因此在之后的几次逼近中,我们还需要判断当前 \(Rank\) 的板子是不是已经被使用过了,然后进行类似的判断即可.
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define speed ios::sync_with_stdio(false);
#define tests int cases;cin>>cases;while(cases--)
#define clear(i) memset((i),0,sizeof (i))
int f[21][21][2]; //now fences have n planks, and the leftest planks ranking j
//k=0 means leftest is shorter,else taller
int n,m;
bool vis[21];
/*
f[i][j][1]=sum k{from 1 to j-1} f[i-1][k][0]
f[i][j][0]=sum k{from j to i-1} f[i-1][k][1]
*/
void prework(){
f[1][1][1]=1;f[1][1][0]=1;
for(int i=2;i<=20;++i){
for(int j=1;j<=i;++j){
for(int k=1;k<=j-1;++k){
f[i][j][1]+=f[i-1][k][0];
}
for(int k=j;k<=i-1;++k){
f[i][j][0]+=f[i-1][k][1];
}
}
}
}
signed main(){
prework();
speed tests{
cin>>n>>m;
clear(vis);
int now,last;
for(int i=1;i<=n;++i){
if(f[n][i][1]>=m){
last=i;now=1;break;
}
else{
m-=f[n][i][1];
}
if(f[n][i][0]>=m){
last=i;now=0;break;
}
else{
m-=f[n][i][0];
}
}
cout<<last<<" ";
vis[last]=true;
for(int i=2;i<=n;++i){
now=1-now;int rank=0;
for(int len=1;len<=n;++len){
if(vis[len]) continue;
rank++;
if((now==0 and len<last)or(now==1 and len>last)){
if(f[n-i+1][rank][now]>=m){
last=len;break;
}
else{
m-=f[n-i+1][rank][now];
}
}
}
vis[last]=true;
cout<<last<<" ";
}
cout<<endl;
}
}
单调优化 DP
形如 \(f_{i,j}=\max_{1\le k\le j}(f_{i,k}+w_{k})\) 之类的式子,假如我们写成转移的话,代码会像下面这样:
for(int i=1;i<=n;++i){
for(int j=i;j<=n;++j){
for(int k=1;k<=j;++k){
f[i][j]=max(f[i][j],f[i][k]+w[k]);
}
}
}
显然它是 \(n^{3}\) 的,斜率优化即是为了优化此类 DP 而出现的
通过观察,可以发现这个式子有两个特殊性质:
- \(k\) 的值域:\([1,j]\)
- 状态转移:继承的状态中不会同时存在 \(j,k\) 项,比如假如我们的状态转移方程是 \(f_{i,j}=\max_{1\le k\le j}(f_{j,k}+w_{k})\),那这个题就没办法优化了,因为每次更新 \(j\) 的时候都会把所有值更新一遍
第一点,\(k\) 的值域有什么用
发现当 \(j=2\) 的时候,答案显然是在 \(k\in[1,2]\) 内的 \(f_{i,k}+w_{k}\) 的最大值,而你可以发现,\(j\) 是递增的,因此 \(k\) 的值域也是递增的,而递增的值域在求最大值的时候,仅仅需要把新加进来的值求一遍就行了,因此我们可以把这个求最大值的第三维优化到 \(O(1)\),同样,如果状态转移方程要求 \(k\in[j,n]\) 也很简单,你只需要把 \(j\) 倒着跑一遍就行了
下面我们来考虑如何去维护这个最值
开一个双端队列,用队首的元素来表示 “全部 \(k\in[1,j]\) 中的 \(f_{i,k}+w_{k}\) 的最大值”,那么我们每次在更新 \(j\) 的时候,只需要进行以下操作
- 如果队尾小于 \(f_{i,j}+w_{j}\),那么它一定不可能是最优的,因此从队尾出队
- 循环执行,直到队列为空或者队首更大
- 向队尾加入 \(f_{i,j}+w_{j}\)
这样可以保证队首的元素一定是最大的,因此我们对每次 \(j\) 的转移,只需要求、拿出队首元素即可
下面我们来讨论一些特殊的状态转移方程的处理方式
第一类 \(f_{i,j}=\max_{j-l\le k\le j}(f_{j,k}+w_{k})\)
对于这类式子,可以发现我们队列里的元素是具有时效性的,因此不能一直在队列里呆着
考虑到队尾元素并不影响更新,因此我们在队头进行操作:
- 每次检查队头元素是否满足 \(j-l\le k\le j\) 这个条件,如果不满足,则直接从队首出队
第二类 \(f_{i,j}=\max_{1-l\le k\le j}(f_{j,k}+w_{j})\)
这里的状态转移式的后半部分是与 \(k\) 无关的,因此我们不把它放进优先队列里,换句话说,哪些变量与 \(k\) 有关,我们就将它放入优先队列中
实际上,这个状态转移方程可以转化为这样:\(f_{i,j}=\max_{1-l\le k\le j}(f_{j,k})+w_{j}\)
做单调队列优化需要记住的两点
- 哪些变量与 \(k\) 有关,我们就将它放入优先队列中
- 假如状态式中存在一个同时由 \(j,k\) 影响的变量,则该状态转移方程无法用单调队列来优化