[OI] DP

温馨提示:Contest 大字的右侧有目录可以快速跳转

Contest

  1. 背包DP
  2. 线性DP
  3. 区间DP
  4. 坐标DP
  5. 树形DP
  6. 状压DP
  7. 概率与期望DP
  8. 计数DP
  9. 单调优化DP

背包DP

背包DP可以理解为“从若干物品中选择特定物品”的问题的统称. 对背包问题开dp数组,通常需要考虑以下几个维度

  1. 已选物品数量
  2. 背包容量

数组中通常存放该条件下的最大价值.
下面是一些常见的背包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) ,写 会小概率导致有空物品
③第一层循环是 1now 不是 1n ,这个不能按印象写,这个问题我已经调过两遍了

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];

Ⅴ-Ⅱ 分组背包二:每组中的物品不冲突,每组至少选一件

I love sneakers!

Ⅵ 有依赖的背包

有依赖的背包也叫树上背包,详见 树上背包问题.

Ⅶ 背包方案数

例题-砝码称重 Coins

对方案数问题,我们可以考虑开一个 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)
关于符号:
①最长上升 <
②最长下降 >
③最长不上升
④最长不下降
另外,为了方便说明,我们把在这里的①③,②④分别叫做相反的最长序列
原理:假如在 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;

Ⅰ-Ⅱ 更优的最长上升子序列问题

上述做法为 n2 做法,效率显然不是太高,事实上,根据贪心思路,我们可以将最长上升子序列问题的求解优化至 nlogn

考虑到对于每次转移,我们都可以记录下当前序列最末尾的数字,可以想到的是,当前序列末尾数字越小,则可能被填入子序列的数字就越多,因此末尾更小的序列可能就是决策更优的,因此我们每次都保留这样的序列向后拓展.

也就是,我们使用数组 fi 表示当枚举到第 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[i1][j1]+1, 1 代表 x 的第 i 个字符与 y 的第 j 个字符相等,根据 dp[i][j] 的定义,那么它等于 x 的前 i1 个字符与 y 的 前 j1 个字符的最长公共子串加一.
如果 x 的第 i 个字符与 y 的第 j 个字符不相等,那么显然 dp[i][j]=0 ,因为 dp[i][j] 定义为最长公共子串,所以只要有一个字符不相等,就说明不满足最长公共子串这个定义.

综上,我们的状态转移方程如下:

dp[i][j]={dp[i1][j1]+1(x[i]==y[j])0(x[i]y[j])

Ⅳ-Ⅱ 最长公共子序列问题

定义:字序列是一个字符串中有序的一段,即序列中的每个数在原序列内都从左到右排列,公共子序列即为几个字符串都含有的子序列.

dp[i][j]={dp[i1][j1]+1(x[i]==y[j])max{dp[i][j1]dp[i1][j](x[i]y[j])

类似最长公共子串,但不一样的是,假如 x[i]y[j],不要急着清零,因为最长公共子序列并不需要严格相邻. 此时应该跳过不相等的内容,那么如何跳过呢? 我们采用了继承相邻状态的方法.

Ⅳ-Ⅲ 排列的最长公共子序列问题

假如序列中的每个数都仅仅出现过一次,那么我们可以考虑对上述 n2 算法进行优化.

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 的最长上升子序列最长
因此,我们只需要在 nlogn 的时间复杂度内求出 B 的最长上升子序列即可


区间DP

区间DP主要用于能将大问题划分成小段区间的问题当中,一般来说,区间DP有这样几种维度可以枚举.

  1. 区间长度
  2. 起点位置
  3. 断开位置
    给出一个大致模板:
//首先预处理(区间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 堆石子排成一排( n100 ),现要将石子有次序地合并成一堆,规定每次只能选相邻的两堆合并成一堆,并将新的一堆的石子数,记为改次合并的得分,编一程序,由文件读入堆数 n 及每堆石子数( 200 ).

(1)选择一种合并石子的方案,使得做 n1 次合并,得分的总和最少

(2)选择一种合并石子的方案,使得做 n1 次合并,得分的总和最多

这是一道需要我们合并的区间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 次之后的最大乘积. 那么我们有:

f[i][j]=f[k][j1]a[k+1][i]

其中 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 个顶点(从 1N 编号)的凸多边形,每个顶点的权均已知。问如何把这个凸多边形划分成 N2 个互不相交的三角形,使得这些三角形顶点的权的乘积之和最小?

根据区间DP的思想,我们规定区间 [i,j] 在凸多边形里表示从顶点 i 到顶点 j (字典序) 的全部边与边 IJ 围成的封闭图形,那么,我们就可将一个这样的多边形划分成三部分:若中间有一个以 i,j 为顶点的三角形,则还存在左边,右边各一个多边形,而这三个部分中,三角形的顶点权乘积可以直接求出,而其余两个多边形又可以继续DP. 这样我们就能用区间DP来解决这道题. 注意边界条件,两条边的多边形无意义.

另外,本题的初始化应该从三角形的顶点权乘积可求来下手,即:

f[i][i+1]=max1knka[i]a[j]a[k]

再说一句,矩阵连乘这道题与本题目代码一致,它的题目如下:

一个 nm 矩阵由 nm 列共 nm 个数排列而成。两个矩阵 AB 可以相乘当且仅当 A 的列数等于 B 的行数。一个 NM 的矩阵乘以一个 MP 的矩阵等于一个 NP 的矩阵,运算量为 nmp 。 矩阵乘法满足结合律,ABC 可以表示成 (AB)C 或者是 A(BC) ,两者的运算量却不同。例如当 A=23,B=34,C=45 时,(AB)C=64A(BC)=90 。显然第一种顺序节省运算量。 现在给出 N 个矩阵,并输入 N+1 个数,第 i 个矩阵是 a[i1]×a[i] ,求最优的运算量.


坐标DP

坐标DP本身和区间DP有许多相似之处,比如都用一些类似坐标的信息来定义DP数组. 但是两者不同的是,区间DP是通过小区间判断大区间,而坐标DP是通过其他坐标判断当前坐标. 总的来说,坐标DP有下面几种经典的问题.

Ⅰ 传纸条-路线往返求最大价值
Ⅱ 晴天小猪历险记之Hill-三角形的最大价值路线
Ⅲ 免费馅饼-时空坐标轴
Ⅳ 盖房子-阻碍联通的分型图最大面积
Ⅴ 矩阵取数游戏

Ⅰ 传纸条-路线往返求最大价值

对于此类问题,我们不妨将两次行动一起DP,只需要判断两个轨迹不重复即可,因为是二维地图,很容易想到二维dp数组,但实际上还要再加两维(因为是两次行动一起DP),状态转移方程为

f[i1][j1][i2][j2]=max{f[i11][j1][i21][j2]f[i11][j1][i2][j21]f[i1][j11][i21][j2]f[i1][j11][i2][j21]

但是这样做很麻烦,注意到若设步数为 k ,总有 i+j=k ,因此压缩为三维 f[k][ii][i2] ,这样根据 j=ki 即可算出两点坐标,状态转移方程变为

f[k][ii][i2]=max{f[k1][ii][i2]f[k1][ii1][i2]f[k1][ii][i21]f[k1][ii1][i21]

实际上,因为第一维全都是 k1 ,我们还可以用滚动数组将其直接优化掉. 这里不再展开,给出三维代码:

点击查看代码
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-三角形的最大价值路线

image
对于上图这样的经典三角形,求从下到上的最大价值路线.

和其他三角图形题的做法类似,我们先将三角形进行左对齐.

我们发现,每一个上层节点都可以由其下层节点而来,即:

f[i][j]=max{f[i+1][j]f[i+1][j+1]

这样我们从下向上遍历(以坐标来看是从大到小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 的复杂度也就比深搜快了一点,因此判断一道题能不能状压,一般看 n30 就可以了.

我们这样做还有一个优势,就是在进行不同行比较的时候可以使用位运算快速比较. 下面给出一些比较的例子:

判断该行第 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 条边的无向图,每条边有边权。

你需要找一条从 1n 的最短路径,并且这条路径在满足给出的 g 个限制的情况下可以在所有编号从 2k+1 的点上停留过。

每个限制条件形如 ri,si,表示停留在 si 之前必须先在 ri 停留过。

注意,这里的停留不是指经过

解法分析

对于这道题的状压. 我们考虑枚举 "现在已经在哪些点停留" 这样一种状态. 然后去寻找每一个当前未停留的点,考虑这个点的前置节点是否全部已经停留,如果是,那么枚举每一个已在集合内的节点,尝试把这个点通过某条边放入集合内,进行状态转移.

那么我们需要进行状态压缩的有两个东西: 现在已有的点的情况 (用于枚举) 和每个点的前置节点情况 (用于判断).

最后需要我们输出的就是在全部节点停留情况下的状态.

这道题的主要思路:

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 的最短路径长度. 那么我们有:

f[i add k][k]=min(f[i add k][k],minjij(f[i][j]+dis[j][k]))

那么我们怎么表示 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) 定义为 A. 设有一个变量 x 可以拥有不同的值,它的值为 xi 的概率为 pi,那么变量 x 的期望 E(x) 定义为 xi×pi.

比如掷骰子,设结果为 x,事件 A= "x=6",P(A)=16. E(x)=i×16=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 个人坐成一圈玩游戏。一开始我们把所有玩家按顺时针从 1N 编号。首先第一回合是玩家 1 作为庄家。每个回合庄家都会随机(即按相等的概率)从卡牌堆里选择一张卡片,假设卡片上的数字为 X,则庄家首先把卡片上的数字向所有玩家展示,然后按顺时针从庄家位置数第 X 个人将被处决(即退出游戏)。然后卡片将会被放回卡牌堆里并重新洗牌。被处决的人按顺时针的下一个人将会作为下一轮的庄家。那么经过 N1 轮后最后只会剩下一个人,即为本次游戏的胜者。现在你预先知道了总共有 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

yet Another OSU

yet yet Another OSU

OSU 的题目是这样的:有一些相邻的块,给定每一个块的联通概率,每个连通块对答案有 size3 的贡献,求总期望

关于此题我曾写过题解 此处

此类题的关键之处在于,当我们设计了一个线性状态 fi 之后,假如我们基于拼接的思想,尝试维护出来了当前最近的一个连通块个数为 x,其贡献应为 x3,那么现在我们再为其拼接一个块,贡献就会变为 (x+1)3,即 x3+3x2+3x+1,注意到这里还有我们尚未维护的 x2x 项,因此我们还需要维护这两种信息才能对 x3,进行转移. 对于 x2 的维护,显然 (x+1)2=x2+2x+1,因此只需要维护 x 项即可,对于 x 的维护是显然的

引入两个变量 li,si,其中 fi 表示考虑前 i 个,与 i 联通的连通块长度,si 表示前 i 个的总得分

容易想到 li 的转移:当第 i 个为断点时将 li 置零,否则 li=li1+1

考虑 si 的转移:容易想到,当 i 为断点时有 si=si1,否则,我们可以得出 si1=(li1)3+S(其中 S 是一个之前累积的得分),而 si=(li)3+S,根据上述 li 的转移式我们可以知道 li=li1+1,因而有:

si=(li1+1)3+S=(li1)3+3(li1)2+3li1+1

因为 S 不好维护,考虑对两项做差分,消掉 S

si=si1+3(li1)2+3li1

因此,我们只需要维护出 li,即可递推求解 si

下面我们来加上概率考虑

期望有一个性质(期望的线性性)E(a+b)=E(a)+E(b) ,因此有下述转化:

E(si)=E(si1+3(li1)2+3li1)=E(si1)+3E((li1)2)+3E(li1)

对于 i 确定为断点的情况,我们有 li=0,因此 E((li1)2)=E(li1)=0,从而 E(si)=E(si1)

否则,对于 i 确定联通的情况同理,有 E(si)=E(si1)+3(li1)2+3li1

否则,对于随机选择的块,直接用上述两种情况乘对应的概率即可,即:

E(si)=p1×E(si1)+p2×(E(si1)+3E(li1)2+3Eli1)

注意到我们还没有维护 E(li)

对于 i 确定为断点的情况,E(li)=0

对于 i 确定联通的情况,E(li)=Eli1+1=E(li1)+1

否则,按照上述思路,应为

E(li)=p1×0+p2×(E(li1)+1)

接着考虑维护 E((li)2)

对于 i 确定为断点的情况,E((li)2)=0

对于 i 确定联通的情况,E((li)2)=E(li1+1)2=E((li12+2li1+1))=E((li12))+2E(li1)+1

否则,按照上述思路,应为

((li)2)=p1×0+p2×(E(li1+1)2=E((li12+2li1+1))=E((li12))+2E(li1)+1)

因为已经维护过了 E(li),因此至此我们完成了全部变量的维护

#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

Another OSU

Another Another OSU

yet Another Another OSU

OSU 的 k 次幂升级版,即贡献变为了 xk

这一次我们不能再像上述一个一个推式子了,我们需要找一个普遍的规律:

对于刚才的问题我们发现:要想维护一个 xk 的贡献,显然需要维护 k[1,k] 的所有 xk 的贡献

有二项式定理,即 (x+y)n=i=0nCnixniyi,考虑设 (fi)k 为我们对 xk 项进行的位置为 i 的转移,效仿刚才的解法,我们会有:

(fi)k=(fi1+a)k=j=0kCnj(fi1)njaj

可以发现在这里实际上用到了全部次数比它低的 fi,因此对于每一个 i,按 k 从小到大维护即可.

此外,除了用二项式定理求 Cni,还可以用杨辉三角来求系数:

杨辉三角递推式:

fi,j={1 j=1orj=ifi1,j1+fi1,jotherwise

计数DP

Ⅰ 计数DP简述

计数 DP 是一类求方案数的DP,但是不一样的是,计数DP需要处理的问题往往十分复杂,使用普通的线性 DP 会超时. 这种时候就可以用计数 DP 进行解决.

实际上,计数 DP 的核心思想就是“用远处的状态来更新当前状态,进而减少时间开销”.

一般的计数 DP 是使用容斥原理+排列组合实现转移的. 也有特例. 下面将分别选择两种基本 DP 思路的计数 DP 进行讲解.

Ⅱ 排列组合计数-Gerald and Giant Chess

给定一个 HW 的棋盘,棋盘上只有 N 个格子是黑色的,其他格子都是白色的. 在棋盘左上角有一个卒,每一步可以向右或者向下移动一格,并且不能移动到黑色格子中. 求这个卒从左上角移动到右下角,一共有多少种可能的路线. H,W 很大,N 很小

因为这题 N 很小,为我们提供了一个根据每个 N 转移的思路.

为了递推的无后效性,我们把黑色格子按行再列优先的顺序从小到大排序,接着考虑构造不合法状态,于是设 fi 表示以第 i 个黑色格子结尾的路径条数,因此转移为

fi=Cxi+yi2xi1j=1i1fj×Cxi+yixjyjxiyi

点击查看代码
#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

这题爆搜肯定是不行,考虑一个事实:

假设 1xxxxRank=a 的一种方案,1yyyyRank=b 的另一种方案,而目标 Rank k 满足 akb,则有 Rank=k 的方案的首位一定是 1.

跟我们猜数是一样的. 假设有个数给你猜,114 小了,191 大了,那你肯定知道这个数最高位是什么了.

所以我们就开个数组来转移并维护这个 Rank 值.

注意到 Rank 并不是非常好维护,我们可以考虑维护每种情况的方案数,然后按字典序从小到大依次加起来,这样就是 Rank 值了.

f[i][j][k] 为放入前 i 块木板构成的栅栏,当第 i 块木板的 Rank=j 时的方案数. 注意到这样还是不好维护,因为要考虑是高低高还是低高低,那么再开一维 k 来表示这个. k=11 为高,反之亦然.

那么这个转移非常好写,也不是本题的难点.

{f[i][j][1]=1kj1f[i1][k][0]f[i][j][0]=jki1f[i1][k][1]

这里唯一需要注意的是求和的范围. 因为我们这个 k 指代的是 Rank=k,而且会涉及到选高的还是选低的的问题,也就有了 k 的范围的差异.

那么还很容易注意到,这个转移和 n,m 完全没有关系,所以从多测里提出来作为初始化.

然后就是按上面的思想来逼近我们要求的答案.

先来确定第一位吧,我们需要做的就是遍历每个 1in,只要有 j=1i(f[n][j][0]+f[n][j][1])>m,就能判定 j1 是我们要求的那个第一位.

很显然,当我们之前几位选过某个数字,那我们就不能再选了,因此在之后的几次逼近中,我们还需要判断当前 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

形如 fi,j=max1kj(fi,k+wk) 之类的式子,假如我们写成转移的话,代码会像下面这样:

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

显然它是 n3 的,斜率优化即是为了优化此类 DP 而出现的

通过观察,可以发现这个式子有两个特殊性质:

  • k 的值域:[1,j]
  • 状态转移:继承的状态中不会同时存在 j,k 项,比如假如我们的状态转移方程是 fi,j=max1kj(fj,k+wk),那这个题就没办法优化了,因为每次更新 j 的时候都会把所有值更新一遍

第一点,k 的值域有什么用

发现当 j=2 的时候,答案显然是在 k[1,2] 内的 fi,k+wk 的最大值,而你可以发现,j 是递增的,因此 k 的值域也是递增的,而递增的值域在求最大值的时候,仅仅需要把新加进来的值求一遍就行了,因此我们可以把这个求最大值的第三维优化到 O(1),同样,如果状态转移方程要求 k[j,n] 也很简单,你只需要把 j 倒着跑一遍就行了

下面我们来考虑如何去维护这个最值

开一个双端队列,用队首的元素来表示 “全部 k[1,j] 中的 fi,k+wk 的最大值”,那么我们每次在更新 j 的时候,只需要进行以下操作

  • 如果队尾小于 fi,j+wj,那么它一定不可能是最优的,因此从队尾出队
  • 循环执行,直到队列为空或者队首更大
  • 向队尾加入 fi,j+wj

这样可以保证队首的元素一定是最大的,因此我们对每次 j 的转移,只需要求、拿出队首元素即可

下面我们来讨论一些特殊的状态转移方程的处理方式

第一类 fi,j=maxjlkj(fj,k+wk)

对于这类式子,可以发现我们队列里的元素是具有时效性的,因此不能一直在队列里呆着

考虑到队尾元素并不影响更新,因此我们在队头进行操作:

  • 每次检查队头元素是否满足 jlkj 这个条件,如果不满足,则直接从队首出队

第二类 fi,j=max1lkj(fj,k+wj)

这里的状态转移式的后半部分是与 k 无关的,因此我们不把它放进优先队列里,换句话说,哪些变量与 k 有关,我们就将它放入优先队列中

实际上,这个状态转移方程可以转化为这样:fi,j=max1lkj(fj,k)+wj

做单调队列优化需要记住的两点

  • 哪些变量与 k 有关,我们就将它放入优先队列中
  • 假如状态式中存在一个同时由 j,k 影响的变量,则该状态转移方程无法用单调队列来优化
posted @   HaneDaniko  阅读(79)  评论(0编辑  收藏  举报
编辑推荐:
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示