DP 优化方法大杂烩 II.

由于原 DP 优化方法大杂烩 实在是太卡了,故新开一篇博客分摊压力。

6. 决策单调性:分治:2D / 1D

决策单调性分治常见于 2D / 1D 动态规划,其中一维是 转移层数,且从一层 仅转移至下一层

注意:下列讨论全部基于具有 决策单调性 的动态规划。

*6.0. 决策单调性:四边形不等式

决策单调性是非常优秀的性质。我们有各种手段优化一个具有决策单调性的动态规划。

通常情况下,决策单调性 体现在 1D 维度 上。设 f 表示当前层,g 表示下一层,设 gifpi 处转移且 pi 最小 / 最大,则 i<jpipj

对于 1D / 1D 动态规划,设 fjfi+w(i,j) 转移得到,如果贡献函数 w(i,j) 具有 四边形不等式,那么 f 具有决策单调性。四边形不等式,即 包含劣于相交:若 a<b<c<d,不妨设我们需要最小化代价,则 w(a,d)+w(b,c)w(a,c)+w(b,d)

证明:不妨设 p 为最小的决策点。考虑反证法,假设存在 pj<pi<i<j。根据决策点的最优性,我们有

 fpj+w(pj,i)fpi+w(pi,i) fpi+w(pi,j)fpj+w(pj,j) w(pj,i)+w(pi,j)w(pi,i)+w(pj,j)

由四边形不等式,w(pj,i)+w(pi,j)w(pi,i)+w(pj,j)。所以当 w(pj,i)+w(pi,j)<w(pi,i)+w(pj,j) 时,矛盾。当 w(pj,i)+w(pi,j)=w(pi,i)+w(pj,j) 时,fpj+w(pj,i)=fpi+w(pi,i),说明 pj 也可以转移到 i,与 pi 的最小性矛盾。证毕。

四边形不等式是证明决策单调性的常用方法。大部分时候,我们通过 猜测 某动态规划具有四边形不等式的策略以解决问题。

6.1. 算法简介

由于相邻两层之间的转移具有决策单调性,考虑每次取区间中点 m 并暴力求出 m 的最优决策点 pm,注意 pmm,然后递归处理 [l,m)(m,r] 并利用 pm 缩小暴力求最优决策点的范围,算法时间复杂度的正确性基于这一操作。

不妨设原来可能的决策范围为 [pl,pr],那么 [l,m) 决策范围 [pl,pm](m,r] 决策范围 [pm,pr]。对于分治的每一层,暴力枚举决策点的时间复杂度为 O(n)。分治树共有 logn 层,时间复杂度为 O(nlogn)

若限制选取的物品个数 kk 很小,考虑每次只选一个物品进行扩展。如果有决策单调性,即可决策单调性分治优化,时间复杂度 O(knlogn)。被 wqs 二分吊着打(大雾)。

6.2. 技巧:贡献难算

有时我们无法直接计算两个位置之间的贡献,但若 lr 的权值能够快速地,在 O(v) 的时间内扩展到 (l±1)(r±1) ,决策单调性仍然可以做到 O(nvlogn),即 端点移动距离总和 级别是 线性对数。类比莫队,维护当前贡献区间的左右端点 l,r,如果要查询某个区间的贡献,直接左右端点跳到该区间。

复杂度看起来很有问题,但实际上它仍然是优秀的线性对数!考虑每一层分治树:

  • 对于右端点,它是每个区间的 中点。因此,对于一个区间 [l,r],设其中点为 m,右端点要么从 l 跳过来到 m,要么从 r 跳过来到 m,次数是区间长度级别的。因此一层跳动次数不超过 O(n)
  • 对于左端点,它是每个区间的 决策点。同理,它在 [l,r] 进入 [l,m) 时,以及 [l,r] 进入 (m,r] 时,跳动的次数也是决策点区间长度 O(prpl) 级别的。因此一层跳动次数不超过 O(n)

故时间复杂度为 O(nvlogn)v 是指针跳动一次的复杂度。

6.3. 例题

我们 不证明 决策单调性,仅进行 猜测

I. P4360 [CEOI2004]锯木厂选址

预处理 w,d 的前缀和以及将 1i 的木材全部运到 i 的代价 vi 可以快速计算从 j 转移到 i 的代价。设 fi=min1j<ivj+cost(j+1,i),答案显然为 mini=1nfi+cost(i+1,n+1)。由于转移层数为 1,考虑套用决策单调性分治,时间复杂度 nlogn

#include <bits/stdc++.h>
using namespace std;

#define ll long long
const int N = 2e4 + 5;

ll n, ans = 2e9, d[N], w[N], dw[N], f[N];
ll cost(int i, int j) {return dw[j] - dw[i] - w[i] * (d[j] - d[i]);}
void solve(int l, int r, int pl, int pr) {
	int m = l + r >> 1, R = min(m - 1, pr), p = -1; f[m] = 2e9;
	for(ll i = pl, v; i <= R; i++) if((v = dw[i] + cost(i, m)) < f[m]) f[m] = v, p = i;
	if(l < m) solve(l, m - 1, pl, p); if(m < r) solve(m + 1, r, p, pr);
}
int main() {
	cin >> n;
	for(int i = 2; i <= n + 1; i++) cin >> w[i - 1] >> d[i], w[i - 1] += w[i - 2];
	for(int i = 2; i <= n + 1; i++) dw[i] = dw[i - 1] + w[i - 1] * d[i], d[i] += d[i - 1];
	for(int i = (solve(2, n, 1, n - 1), 2); i <= n; i++) ans = min(ans, f[i] + cost(i, n + 1));
	cout << ans << endl;
	return 0;
}

II. CF868F Yet Another Minimization Problem

使用 6.2 的技巧,时间复杂度 O(nklogn)

III. CF833B The Bakery

不难发现 w(i,j) 表示 ij 之间不同数字个数满足四边形不等式,故有决策单调性。使用 6.2 的技巧,时间复杂度同上。

*IV. P5574 [CmdOI2019]任务分配问题

猜测贡献函数 w(i,j) 有决策单调性(满足四边形不等式),那么使用 6.2 的技巧 + 树状数组维护可以做到 O(nklog2n)

#include <bits/stdc++.h>
using namespace std;

const int N = 2.5e4 + 5;
int n, k, x = 1, y, cur, a[N], f[N], g[N], c[N];
void add(int x, int v) {while(x <= n) c[x] += v, x += x & -x;}
int query(int x) {int s = 0; while(x) s += c[x], x -= x & -x; return s;}
void solve(int l, int r, int pl, int pr) {
	int m = l + r >> 1, R = min(pr, m - 1), p = pl;
	while(y < m) cur += query(a[++y] - 1), add(a[y], 1);
	while(x > pl + 1) x--, cur += y - x - query(a[x]), add(a[x], 1);
	while(x < pl + 1) add(a[x], -1), cur -= y - x - query(a[x]), x++;
	while(y > m) add(a[y], -1), cur -= query(a[y--] - 1);
	g[m] = f[pl] + cur;
	for(int i = pl + 1, v; i <= R; i++) {
		add(a[x], -1), cur -= y - x - query(a[x]), x++, v = f[i] + cur;
		if(v < g[m]) g[m] = v, p = i;
	} if(l < m) solve(l, m - 1, pl, p);
	if(m < r) solve(m + 1, r, p, pr);
}
int main() {
	cin >> n >> k, memset(f, 0x3f, sizeof(f)), f[0] = 0;
	for(int i = 1; i <= n; i++) cin >> a[i];
	for(int i = 1; i <= k; i++) solve(1, n, 0, n - 1), swap(f, g);
	cout << f[n] << endl;
	return 0;
}

V. HDU2829 Lawrence

题意简述:定义一个序列的价值是序列中任意两个元素的积,将长度为 n 的序列分成 m+1 段使序列价值和最大。1m<n103

不难发现贡献函数满足四边形不等式,直接决策单调性分治。时间复杂度 O(nmlogn)

VI. *CF1603D. Artistic Partition *3000

一个结论是若 n<2k 则答案为 n,因为令 xi2i11,则对于任意 [xi+1,xi+1] 都有 xi+1(xi+1)<xi+1c(xi+1,xi+1)=xi+1xi,显然达到了理论最优。否则我们设 fi,j 表示当 n=i,k=j 时的答案,较为显然的转移方程为

fi,j=minp=0i1fp,j1+c(p+1,j)

现在的主要问题转化为如何快速计算 c(l,r),所有分式都是下取整。

 c(l,r)= lijr[gcd(i,j)l]= k=lri=lrj=ir[gcd(i,j)=k]= k=lri=1rj=ir[gcd(i,j)=k]= k=lr1ijrk[gcd(i,j)=1]= k=lri=1rkφ(i)= k=lrS(rk)(S(i)=j=1iφ(j))

此时可以直接整除分块算 c(l,r)。更进一步地,猜测 c(l,r) 具有四边形不等式,证明见官方题解,可以决策单调性分治优化。但是 O(nlog2nn) 的复杂度似乎无法接受。实际上可以通过,因为常数太小了,当 l 较大的时候计算 c(l,r) 非常快。

通过对于每个 r 预处理前缀和可以做到 O(nlog2n+nn)

注意到决策单调性分治在贡献难算的时候仍然可以做到很优秀的复杂度:维护贡献函数的两个端点 l,r 并不断移动,左端点 l 的移动可以做到 O(1) 因为只要加减 S(rl),而右端点 r 的移动需要 d(r) 重新计算上式:对于 r1d<rd(下取整后)的所有 d,需要将贡献增加 φ(rd),而这样的 d 只有在 dr 时取到。时间复杂度为 O(nlog3n) (i=1nd(i)nlnn)代码

6.4. 参考博客

7. 决策单调性:1D/1D 斜率优化

斜率优化是非常重要的一类 DP 优化方法。然而我鸽到了现在才学。

7.1. 算法介绍

如果一类最优化 DP 可以被写成 fi=min/maxfj+costj+costi+FiFj,即一些只和 i,j 有关的项和一个i,j 都有关的项的和,那么一般就可以使用斜率优化。

具体地,把转移方程改写为 fj+costj=FiFj+(ficosti),设 y=fj+costjk=Fix=Fjb=ficosti,那么它就是一个一次函数的表达式 y=kx+b。注意到 k 是固定的,而所有的 (x,y) 我们也已知,由于 fi=b+costi,所以我们就是要找到这样的点 (x,y) 使得经过这个点的斜率为 k 的直线在 y 轴的截距最大 / 小(取决于是 max 还是 min)。

不妨设转移方程里面是 min,这就相当于我们要动态维护一个下凸包,并每次在这个凸包上二分出斜率为 k 的直线切到的点,则这个点就是最优决策点。但一般情况下我们并不需要动态凸包,也不需要二分:

  • 如果满足插入的点的纵坐标 x 有序,且每次查询的斜率 k 有序,那么可以单调队列维护凸包。具体方式可见 wqs 二分部分的例题 IX. Aliens。
  • 如果只满足 x 有序,那么就需要单调栈维护凸包并在上面二分(例题 VII.)。
  • 否则需要动态凸包 / 李超线段树。

那么斜率优化就讲完了。

注意特判斜率不存在的情况

7.2. 优质文章

7.3. 例题

wqs 二分部分的例题 IX. & X. & XI. & XII. 就需要使用斜率优化作为每次二分时内层的 DP。

I. P3195 [HNOI2008]玩具装箱

太经典了。设 si=j=1iCj+1 并将 L 增加 1,那么转移方程即为 fi=minj=0i1fj+(sisjL)2

化简一下就是 fj+sj2 y=2(siL) k×sj x+fi(siL)2 b

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double

const int N=5e4+5;
const ld eps=1e-10;

ll n,L,hd,tl,d[N],f[N],s[N];
ll sq(ll x){return x*x;}
ll Y(int i){return f[i]+sq(s[i]);}
ll X(int i){return s[i];}
ld slope(int i,int j){return (ld)(Y(j)-Y(i))/(X(j)-X(i));}

int main(){
	cin>>n>>L,L++;
	for(int i=1;i<=n;i++)cin>>s[i],s[i]+=s[i-1]+1;
	d[hd=tl=1]=0;
	for(int i=1;i<=n;i++){
		while(hd<tl&&slope(d[hd],d[hd+1])+eps<=2*(s[i]-L))hd++;
		int j=d[hd]; f[i]=f[j]+sq(s[i]-s[j]-L);
		while(hd<tl&&slope(d[tl-1],d[tl])-eps>=slope(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<f[n]<<endl;
	return 0;
}

II. P2120 [ZJOI2007]仓库建设

预处理 pi 的前缀和 spi1i 的产品全部运送到位置 i 的代价 svi,则转移方程为 fi=minj=0i1fj+svisvj+spj×(xixj)

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double
#define gc getchar()

inline int read(){
	int x=0,sign=0; char s=gc;
	while(!isdigit(s))sign|=s=='-',s=gc;
	while(isdigit(s))x=(x<<1)+(x<<3)+(s-'0'),s=gc;
	return sign?-x:x;
}

const int N=1e6+5;
const ld eps=1e-10;

ll n,x[N],p[N],c[N],f[N],sp[N],sv[N];
ll X(int i){return sp[i];}
ll Y(int i){return f[i]-sv[i]+sp[i]*x[i];}
ll cal(int i,int j){return sv[j]-sv[i]-sp[i]*(x[j]-x[i])+c[j];}
ld slope(int i,int j){return (ld)(Y(j)-Y(i))/(X(j)-X(i));}

ll hd,tl,d[N];
int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		x[i]=read(),p[i]=read(),c[i]=read();
		sp[i]=sp[i-1]+p[i],sv[i]=sv[i-1]+sp[i-1]*(x[i]-x[i-1]);
	} hd=tl=1;
	for(int i=1;i<=n;i++){
		while(hd<tl&&slope(d[hd],d[hd+1])+eps<=x[i])hd++;
		f[i]=f[d[hd]]+cal(d[hd],i);
		while(hd<tl&&slope(d[tl-1],d[tl])-eps>=slope(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<f[n]<<endl;
	return 0;
}

*III. P3648 [APIO2014]序列分割

一个非常关键的 Observation 是切的顺序无关,证明的话很简单,假设最终切出来 k+1 个块的元素和为 c1,c2,,ck+1,那么答案即为 i=1k+1j=i+1k+1cicj。因此可以愉快 DP 了:设 siai 的前缀和,fi,p 为前 i 个数分 p 个块的最大值,那么有 fi,p=maxj=0i1fj,p1+sj×(sisj)。注意记录每个 fi,p 的最优决策点,方便输出方案,并特判 si=sj 的情况(因为 ai 可能等于 0)。时间复杂度 O(nk),使用 long double 会被卡常。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld double

const int N=1e5+5;
const int K=200+5;
const ld eps=1e-9;
const ld inf=1e9;

ll n,k,hd,tl,d[N],a[N],s[N],f[N],g[N];
int tr[N][K];
ld sl(int i,int j,int k){
	ld dx=s[j]-s[i],dy=f[j]-s[j]*s[j]-f[i]+s[i]*s[i];
	return fabs(dx)<eps?-inf:dy/dx;
} void print(int p,int t){
	if(!t)return;
	print(tr[p][t-1],t-1),cout<<p<<" ";
}
int main(){
	cin>>n>>k;
	for(int i=1;i<=n;i++)cin>>a[i],s[i]=s[i-1]+a[i];
	for(int i=1;i<=k;i++){
		d[hd=tl=1]=i-1;
		for(int j=i;j<=n;j++){
			while(hd<tl&&sl(d[hd],d[hd+1],i)+eps>=-s[j])hd++;
			int fr=d[hd];
			tr[j][i]=fr,g[j]=f[fr]+s[fr]*(s[j]-s[fr]);
			while(hd<tl&&sl(d[tl-1],d[tl],i)+eps<=sl(d[tl],j,i))tl--;
			d[++tl]=j;
		}
		memcpy(f,g,sizeof(g));
	} cout<<f[n]<<endl,print(tr[n][k],k);
	return 0;
}

IV. P6047 丝之割

具体地,如果两条弦 (ui,vi)(uj,vj) 满足 uiujvivj,那么 j 显然是无用的,因为割掉 i 的同时也一定能割掉 j。因此有用的弦一定满足 ui,vi 同时单调递增。像极了 “IOI2016 aliens”。

piai 的前缀最小值,qibi 的后缀最小值,fi 为割掉前 i 条弦的最小代价,那么有 fi=minj=0i1fj+auj+11×bvi1。接下来斜率优化即可。时间复杂度 n+mlogm,瓶颈在排序,可以使用桶排优化到线性。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld double

const int N=3e5+5;
const ld eps=1e-9;

ll n,m,cnt,p[N],q[N],f[N];
struct str{
	int u,v;
	bool operator < (const str &x) const{
		return u==x.u?v>x.v:u<x.u;
	}
}c[N];

int hd,tl,d[N];
ld sl(int i,int j){
	ld dx=-p[c[j+1].u-1]+p[c[i+1].u-1];
	if(fabs(dx)<eps)return f[j]-f[i]<0?-1e9:1e9;
	else return (ld)(f[j]-f[i])/dx;
}

int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>p[i];
	for(int i=1;i<=n;i++)cin>>q[i];
	for(int i=1;i<=m;i++)cin>>c[i].u>>c[i].v;
	for(int i=2;i<=n;i++)p[i]=min(p[i],p[i-1]);
	for(int i=n-1;i;i--)q[i]=min(q[i],q[i+1]);
	sort(c+1,c+m+1);
	for(int i=1,r=0;i<=m;i++)if(c[i].v>r)c[++cnt]=c[i],r=c[i].v;
	m=cnt,hd=tl=1;
	for(int i=1;i<=m;i++){
		while(hd<tl&&sl(d[hd],d[hd+1])+eps<=q[c[i].v+1])hd++;
		f[i]=f[d[hd]]+p[c[d[hd]+1].u-1]*q[c[i].v+1];
		while(hd<tl&&sl(d[tl-1],d[tl])-eps>=sl(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<f[m]<<endl;
	return 0;
}

V. CF311B Cats Transport

经典题。首先将所有 ti 减去 j=2HiDj 求出如果要恰好接到第 i 只猫要从哪一时刻出发,然后设 siti 的前缀和,fi,j 表示用 j 个人接到前 i 只猫的最小等待时间之和,那么有 fi,j=mink=0i1fk,j1+(ik)ti(sisk),斜率优化即可。

注意出发时间可以为负数,即不需要特判 ti<0 的情况。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double
#define pb push_back
#define pii pair <int,int>
#define fi first
#define se second
#define gc getchar()
#define mcpy(x,y) memcpy(x,y,sizeof(y))
#define mem(x,v) memset(x,v,sizeof(x))

inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=x*10+s-'0',s=gc;
	return x;
}

const int N=1e5+5;
const ld eps=1e-9;

int n,m,p,hd,tl,d[N];
ll t[N],s[N],f[N],g[N];

ld sl(int i,int j){return (ld)(g[j]+s[j]-g[i]-s[i])/(j-i);}
int main(){
	cin>>n>>m>>p;
	for(int i=2;i<=n;i++)cin>>d[i],d[i]+=d[i-1];
	for(int i=1,h;i<=m;i++)cin>>h>>t[i],t[i]-=d[h];
	sort(t+1,t+m+1);
	for(int i=1;i<=m;i++)s[i]=s[i-1]+t[i];
	mem(g,0x3f),g[0]=0;
	for(int z=1;z<=p;z++){
		d[hd=tl=1]=0;
		for(int i=1;i<=m;i++){
			while(hd<tl&&sl(d[hd],d[hd+1])+eps<=t[i])hd++;
			int j=d[hd]; f[i]=g[j]+(i-j)*t[i]-s[i]+s[j];
			while(hd<tl&&sl(d[tl-1],d[tl])-eps>=sl(d[tl],i))tl--;
			d[++tl]=i;
		} mcpy(g,f);
	} cout<<f[m]<<endl;
	return 0;
}

VI. P2365 任务安排

经典题。发现一组任务的完成时间依赖于分成的组数,很难受,那么使用 代价提前计算 的技巧,即将启动时间 sj+1n 的所有任务的代价计算在内,而不仅仅是对于 j+1i 的任务。

因此有转移方程 fi=minj=0i1fj+s(sfnsfj)+sti(sfisfj)。带个 s 是前缀和。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double
#define pb push_back
#define pii pair <int,int>
#define fi first
#define se second
#define gc getchar()
#define mcpy(x,y) memcpy(x,y,sizeof(y))
#define mem(x,v) memset(x,v,sizeof(x))

inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=x*10+s-'0',s=gc;
	return x;
}

const int N=5e3+5;
const ld eps=1e-10;

ll n,s,f[N],t[N],g[N],hd,tl,d[N];
ld sl(int i,int j){return (ld)(g[j]-s*f[j]-g[i]+s*f[i])/(f[j]-f[i]);}
int main(){
	cin>>n>>s;
	for(int i=1;i<=n;i++)cin>>t[i]>>f[i],f[i]+=f[i-1],t[i]+=t[i-1];
	hd=tl=1;
	for(int i=1;i<=n;i++){
		while(hd<tl&&sl(d[hd],d[hd+1])+eps<=t[i])hd++;
		int j=d[hd]; g[i]=g[j]+s*(f[n]-f[j])+t[i]*(f[i]-f[j]);
		while(hd<tl&&sl(d[tl-1],d[tl])-eps>=sl(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<g[n]<<endl;
	return 0;
}

*VII. P5785 [SDOI2012]任务安排

和上题差不多。注意到 Ti 可能是负数,所以查询的斜率不一定单调增,但是插入的点的纵坐标一定单调不降(注意特判纵坐标相同的情况,因为 Ci 可能等于 0),所以要单调栈维护凸包,查询时二分斜率。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double
#define pb push_back
#define pii pair <int,int>
#define fi first
#define se second
#define gc getchar()
#define mcpy(x,y) memcpy(x,y,sizeof(y))
#define mem(x,v) memset(x,v,sizeof(x))

inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=x*10+s-'0',s=gc;
	return x;
}

const int N=3e5+5;
const ld eps=1e-10;
const ld inf=1e18;

ll n,s,f[N],t[N],g[N],hd,tl,d[N];
ld sl(int i,int j){
	ll dy=g[j]-s*f[j]-g[i]+s*f[i];
	if(f[i]==f[j])return dy<0?-inf:inf;
	return (ld)dy/(f[j]-f[i]);
}
int main(){
	cin>>n>>s;
	for(int i=1;i<=n;i++)cin>>t[i]>>f[i],f[i]+=f[i-1],t[i]+=t[i-1];
	hd=tl=1;
	for(int i=1;i<=n;i++){
		int l=hd,r=tl;
		while(l<r){
			int mid=l+r>>1;
			if(sl(d[mid],d[mid+1])+eps<=t[i])l=mid+1;
			else r=mid;
		} int j=d[l];
		g[i]=g[j]+s*(f[n]-f[j])+t[i]*(f[i]-f[j]);
		while(hd<tl&&sl(d[tl-1],d[tl])-eps>=sl(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<g[n]<<endl;
	return 0;
}

*VIII. P2305 [NOI2014] 购票

首先将 si 前缀和处理,那么 gi=minjanc(i)sisjlifj+(sisj)pi+qi。斜率优化显然。

注意到每次查询一段后缀的凸包,而且是树上询问需要撤销,那么用线段树 / BIT 套可撤销单调栈维护凸包即可。由于 BIT 查询的是一段前缀,所以需要翻转一下。可以预处理 BIT 上每个节点维护的单调栈所需要的数组大小,这样就不需使用 vector 动态开空间(会 MLE)。时间复杂度 O(nlog2n)

类似题目:CEOI2009 Harbingers。本题见博客 “线段树的高级应用” 李超树部分例题 IV.

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double

const int N=2e5+5;
ll n,t,fa[N],s[N],c[N],f[N];
ll p[N],q[N],l[N];
vector <int> e[N];
int get(ll x){return lower_bound(c+1,c+n+1,x)-c;}
ld sl(int i,int j){return (ld)(f[j]-f[i])/(s[j]-s[i]);}

int sz[N],ts[N],td[N],st[N<<4];
pair <int,int> d[N<<4];
void push(int id,int p){
	int b=sz[id]+ts[id];
	while(ts[id]>1&&sl(st[b-2],st[b-1])>sl(st[b-1],p))
		d[sz[id]+td[id]++]={p,st[b-1]},ts[id]--,b--;
	st[b++]=p,ts[id]++;
}
void del(int id,int p){
	ts[id]--; int b=sz[id]+td[id];
	while(td[id]&&d[b-1].first==p)st[sz[id]+ts[id]++]=d[b-1].second,td[id]--,b--;
}
int query(int id,ld x){
	if(!ts[id])return -1;
	int l=sz[id],r=sz[id]+ts[id]-1;
	while(l<r){
		int m=l+r>>1;
		if(sl(st[m],st[m+1])<=x)l=m+1;
		else r=m;
	} return st[l];
}

void dfs(int id){
	int x=n-get(s[id]-l[id])+1,y=n-get(s[id])+1,z=y;
	if(id>1)f[id]=1e18;
	while(x){
		int pos=query(x,p[id]);
		if(pos!=-1)f[id]=min(f[id],f[pos]+(s[id]-s[pos])*p[id]+q[id]);
		x-=x&-x;
	} while(y<=n)push(y,id),y+=y&-y;
	for(int it:e[id])dfs(it);
	while(z<=n)del(z,id),z+=z&-z;
}
int main(){
	cin>>n>>t;
	for(int i=1;i<=n;i++)for(int j=i;j<=n;j+=j&-j)sz[j+1]++;
	for(int i=3;i<=n;i++)sz[i]+=sz[i-1];
	for(int i=2;i<=n;i++){
		scanf("%lld%lld%lld%lld%lld",&fa[i],&s[i],&p[i],&q[i],&l[i]);
		c[i]=s[i]+=s[fa[i]],e[fa[i]].push_back(i);
	} sort(c+1,c+n+1),dfs(1);
	for(int i=2;i<=n;i++)printf("%lld\n",f[i]);
	return 0;
}

IX. P2900 [USACO08MAR]Land Acquisition G

显然如果两块土地 i,j 满足 wiwjlilj 那么 i 完全无用。因此将所有土地按照 wi 从大到小排序,筛选出所有有用的土地,那么答案即为 fi=min0j<ifj+wj+1×li。斜率优化即可。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double

const int N=5e4+5;

ll n,q,hd,tl,d[N],f[N];
struct seq{
	ll w,l;
	bool operator < (const seq &v) const{
		return w>v.w;
	}
}a[N],b[N];
ld sl(int i,int j){return (ld)(f[j]-f[i])/(b[i+1].w-b[j+1].w);}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)cin>>a[i].w>>a[i].l;
	sort(a+1,a+n+1);
	for(int i=1,r=0;i<=n;i++)if(a[i].l>r)r=a[i].l,b[++q]=a[i];
	for(int i=1;i<=q;i++){
		while(hd<tl&&sl(d[hd],d[hd+1])<=b[i].l)hd++;
		f[i]=f[d[hd]]+b[d[hd]+1].w*b[i].l;
		while(hd<tl&&sl(d[tl-1],d[tl])>=sl(d[tl],i))tl--;
		d[++tl]=i;
	} cout<<f[q]<<endl;
	return 0;
}

*X. P3571 [POI2014]SUP-Supercomputer

题意简述:一棵以 1 为根的树。q 次询问,每次给出 k,求至少要多少次同时访问不超过 k 个父节点已经被访问过的节点,才能访问完整棵树。根节点无限制。

n,q106

sweet tea.

对于每一个 k,一定存在 i 使得深度不大于 i 的节点用 i 次访问,且深度大于 i 的节点每次都能访问 k 个(除了最后一次)。记 si 表示深度不小于 i 的节点个数,答案即为 maxi=1d(i+si+1k),其中 d 是最大深度。

i 是最优决策,那么对于任意一个 ji,有 i+si+1kj+sj+1k。略作变形得到 ijsj+1si+1k。令横坐标为深度,纵坐标为 sx+1,再写成斜率的形式,即当 j<i 时,si+1sj+1ijk,当 j>i 时,si+1sj+1ijk。不难看出这是一个上凸包的形式,即斜率递减

具体地,我们对 (i,si) 建出上凸包,然后当 k 递增时,k 递减,顶点会向横坐标大的方向移动,用指针维护即可。时间复杂度 O(n)

经过卡常拿到了最优解。

const int N=1e6+5;

int n,q,mxd,mxq,dep[N],f[N],qu[N];
int d[N],hd=1,tl;
ll s[N];

int main(){
	cin>>n>>q,dep[1]=s[1]=1;
	for(int i=1;i<=q;i++)mxq=max(mxq,qu[i]=read());
	for(int i=2,a;i<=n;i++)mxd=max(mxd,dep[i]=dep[read()]+1),s[dep[i]]++;
	for(int i=mxd-1;i;i--)s[i]+=s[i+1];
	for(int i=0;i<=mxd;i++){
		while(hd<tl&&(s[d[tl]+1]-s[d[tl-1]+1])*(i-d[tl])<=(s[i+1]-s[d[tl]+1])*(d[tl]-d[tl-1]))tl--;
		d[++tl]=i;
	}
	for(int i=1;i<=mxq;i++){
		while(hd<tl&&s[d[hd+1]+1]-s[d[hd]+1]>-i*(d[hd+1]-d[hd]))hd++;
		f[i]=d[hd]+(d[hd]==mxd?0:((s[d[hd]+1]-1)/i+1));
	}
	for(int i=1;i<=q;i++)print(f[qu[i]]),pc(' ');
	return flush(),0;
}

XI. P2497 [SDOI2012]基站建设

首先求出 j 对于 i 的贡献:根据勾股定理有 (xixj)2+(r2,ir1,j)2=(r2,i+r1,j)2。略作变形得到 4r2,ir1,j=(xixj)2,故 r2,i=xixj2r1,j

显然的斜率优化,fjxj2r1,j (yj)=xi×(12r1,j)+(fivi)。注意到插入的 12r1,j 并不单调,所以将其相反数看做斜率 kyj 看做截距插入动态开点李超线段树即可。时间复杂度 O(nlogx)

const int N = 5e5 + 5;
const ld inf = 1e18;

ll  m, x[N];
int n, R, node, mx[N << 5], ls[N << 5], rs[N << 5];
ld r[N], v[N], f[N], k[N], b[N];
ld get(int id, ld p) {return k[id] * p + b[id];}
void modify(ll l, ll r, int &x, int v) {
	if(!x) x = ++node;
	if(l == r) {
		if(get(mx[x], l) > get(v, l)) mx[x] = v;
		return;
	}
	ll m = l + r >> 1;
	if(get(mx[x], m) > get(v, m)) swap(v, mx[x]);
	if(get(mx[x], l) > get(v, l)) modify(l, m, ls[x], v);
	else modify(m + 1, r, rs[x], v);
}
ld query(ll l, ll r, int x, ll p){
	int m = l + r >> 1; ld ans = get(mx[x], p);
	if(l == r || !x) return ans;
	return min(ans, p <= m ? query(l, m, ls[x], p) : query(m + 1, r, rs[x], p));
}


int main() {
	cin >> n >> m, b[0] = inf;
	for(int i = 1; i <= n; i++) cin >> x[i] >> r[i] >> v[i];
	for(int i = 1; i <= n; i++) {
		if(r[i] == 0) {
			f[i] = inf;
			continue;
		}
		if(i == 1) f[i] = v[i];
		else f[i] = query(x[1], x[n], R, x[i]) + v[i];
		k[i] = 0.5 / sqrt(r[i]), b[i] = f[i] - x[i] / (2 * sqrt(r[i]));
		modify(x[1], x[n], R, i);
	}
	ld ans = inf;
	for(int i = 1; i <= n; i++)
		if(x[i] + r[i] >= m)
			ans = min(ans, f[i]);
	printf("%.3LF\n", ans);
	return 0;
}

XII.P4655 [CEOI2017]Building Bridges

有一个显然的 n2 DP:fi=minj=1i1fj+(hihj)2+k=j+1i1wk,后面那一坨前缀和优化一下,再把平方拆开,移个项,得

fj+hj2swj=2hihj+(fiswi1hi2)

显然的斜率优化形式,注意斜率和下标都不单调,故使用李超线段树维护即可。

const int N = 1e5 + 5;
const int H = 1e6;
const ll inf = 1e18;

ll n, f[N], h[N], w[N];

// Li Chao Segment Tree
int R, node, ls[N << 5], rs[N << 5], mi[N << 5];
ll k[N], b[N];
ll get(int x, int id) {return k[id] * x + b[id];}
void modify(int l, int r, int &x, int v) {
	if(!x) x = ++node;
	if(l == r) {
		if(get(l, v) < get(l, mi[x])) mi[x] = v;
		return;
	} int m = l + r >> 1;
	if(get(m, v) < get(m, mi[x])) swap(mi[x], v);
	if(get(l, v) < get(l, mi[x])) modify(l, m, ls[x], v);
	else if(get(r, v) < get(r, mi[x])) modify(m + 1, r, rs[x], v);
}
ll query(int l, int r, int p, int x) {
	if(l == r || !x) return get(p, mi[x]);
	ll m = l + r >> 1, ans = get(p, mi[x]);
	if(p <= m) return min(query(l, m, p, ls[x]), ans);
	return min(query(m + 1, r, p, rs[x]), ans);
}

int main() {
	cin >> n, mem(f, 63, N), f[1] = 0, b[0] = inf;
	for(int i = 1; i <= n; i++) cin >> h[i];
	for(int i = 1; i <= n; i++) cin >> w[i], w[i] += w[i - 1];
	for(int i = 2; i <= n; i++) {
		int j = i - 1;
		k[j] = -2 * h[j], b[j] = f[j] + h[j] * h[j] - w[j];
		modify(-H, H, R, j);
		f[i] = query(-H, H, h[i], R) + h[i] * h[i] + w[j];
	}
	cout << f[n] << endl;
	return 0;
}

XIII. P6302 [NOI2019] 回家路线 加强版

经典斜率优化。注意到如果按照站点 DP 则还需记录时间这一维,无法接受,不如按照列车 DP:设 fi 表示搭乘第 i 号列车的最小代价。把柿子写出来:

fv=minyu=xvqupvfu+A(pvqu)2+B(pvqu)+C

时间造成的烦躁值可以在统计答案时算上,也可以算在 DP 方程里面。

注意到时间所产生的偏序关系为 DP 定下了一个顺序:按照时间 DP,将每号列车拆成出发和到达两个事件并按时间排序。注意因为可以刚下车就上车,即同一时刻从到达转移到出发,所以对于时刻相同的两个事件,到达应该排在出发之前。

由于时刻递增即斜率和插入点的横坐标有序,我们只需要对每个站点维护一个用单调队列实时维护的下凸壳即可。时间复杂度 O(mlogm),复杂度瓶颈在于排序。若使用桶排可做到 O(m)

时刻注意对于横坐标相同,斜率不存在的情况的处理。

const int N = 1e5 + 5;
const int M = 1e6 + 5;
const ld eps = 1e-9;
const ll inf = 1e15;

// type = 1 : depart
// type = 2 : arrive

struct Event {
	ll id, type, sta, time;
	bool operator < (const Event &v) const {
		return time != v.time ? time < v.time : type > v.type;
	}
} tr[M << 1];
deque <pll> conv[N];
ll n, m, A, B, C, ans = inf;
ll x[M], y[M], p[M], q[M], res[M];

ld Slope(pll a, pll b) {
	if(a.fi == b.fi) return b.se > a.se ? inf : -inf;
	return (ld)(b.se - a.se) / (b.fi - a.fi);
}
void Ins(int id, pll pt) {
	int sz = conv[id].size();
	while(sz > 1) {
		pll t1 = conv[id][sz - 2], t2 = conv[id][sz - 1];
		if(Slope(t1, t2) >= Slope(t2, pt)) conv[id].pop_back(), sz--;
		else break;
	} conv[id].pb(pt);
}
ll Getb(int id, ll k) {
	int sz = conv[id].size();
	if(!sz) return inf;
	while(sz > 1) {
		pll t1 = conv[id][0], t2 = conv[id][1];
		if(Slope(t1, t2) <= k) conv[id].pop_front(), sz--;
		else break;
	}
	pll hd = conv[id][0];
	return hd.se - hd.fi * k;
}

int main() {
	n = read(), m = read(), A = read(), B = read(), C = read();
	for(int i = 1; i <= m; i++) {
		x[i] = read(), y[i] = read(), p[i] = read(), q[i] = read();
		tr[(i << 1) - 1] = {i, 1, x[i], p[i]};
		tr[i << 1] = {i, 2, y[i], q[i]};
	} sort(tr + 1, tr + m * 2 + 1);
	Ins(1, {0, 0});
	for(int i = 1; i <= m << 1; i++) {
		Event e = tr[i];
		int id = e.id, st = e.sta, t = e.time;
		if(e.type == 1) {
			ll cost = Getb(st, t);
			if(cost >= inf) {
				res[id] = inf;
				continue;
			}
			cost += C + A * t * t + B * t + t;
			res[id] = cost;
		}
		else {
			ll cost = res[id] + t - p[id];
			if(cost >= inf) continue;
			if(st == n) ans = min(ans, cost);
			Ins(st, {2 * A * t, cost + A * t * t - B * t - t});
		}
	} cout << ans << endl;
	return 0;
}

XIV. P4027 [NOI2007] 货币兑换

设在第 i 天用 d 元能买到 cRi 数量的 A 劵和 c 数量的 B 劵,有 cRiAi+cBi=d,解得 c=dRiAi+Bi。于是设第 i 天最多能获得多少钱,有:

fi=max1j<ifjRjRjAj+BjAi+fjRjAj+BjBi

稍微化简可得 fi=max1j<icRjAi+cBi,其中 c 只与 j 有关。注意到有两个和 i,j 同时有关的项,所以用不起来斜率优化了吗?Nope!由于没有只与 j 有关的项,所以我们可以进行一些移项:将其看作直线 ax+by=c 化简得到 y=caxb,本题中即 c=fiBicRj×AiBi。然后使用斜率优化即可。

注意到斜率和插入点横坐标都不单调,所以需要使用李超线段树。虽然查询位置不是整数,但是注意到我们已经知道了所有查询位置 AiBi,故将 AiBi 离散化即可。

时间复杂度 O(nlogn)

const int N = 1e5 + 5;

double d[N], k[N], b[N];
double Get(double x, int id) {return k[id] * x + b[id];}

int mx[N << 2];
void modify(int l, int r, int x, int v) {
	if(l == r) {
		if(Get(d[l], v) > Get(d[l], mx[x])) mx[x] = v;
		return;
	} int m = l + r >> 1;
	if(Get(d[m], v) > Get(d[m], mx[x])) swap(mx[x], v);
	if(Get(d[l], v) > Get(d[l], mx[x])) modify(l, m, x << 1, v);
	else if(Get(d[r], v) > Get(d[r], mx[x])) modify(m + 1, r, x << 1 | 1, v);
}
double query(int l, int r, int p, int x) {
	if(l == r) return Get(d[p], mx[x]);
	int m = l + r >> 1; double ans = Get(d[p], mx[x]);
	if(p <= m) return max(query(l, m, p, x << 1), ans);
	return max(query(m + 1, r, p, x << 1 | 1), ans);
}

int n;
double f[N], A[N], B[N], c[N], R[N];
int main() {
	cin >> n >> f[1];
	for(int i = 1; i <= n; i++)
		cin >> A[i] >> B[i] >> R[i], d[i] = c[i] = A[i] / B[i];
	sort(d + 1, d + n + 1);
	for(int i = 2; i <= n; i++) {
		int j = i - 1; f[i] = f[j];
		double coef = f[j] / (R[j] * A[j] + B[j]);
		k[j] = coef * R[j], b[j] = coef, modify(1, n, 1, j);
		c[i] = lower_bound(d + 1, d + n + 1, c[i]) - d;
		f[i] = max(f[i], query(1, n, c[i], 1) * B[i]);
	} printf("%.3lf\n", f[n]);
	return 0;
}

XV. CF643C Levels and Regions

根据 (1p)+(1p)2+(1p)3+=11(1p)=1p 可知如果 AB 发生时有 p 的概率发生,那么发生 A 所需的 B 的发生次数的期望为 1p。因此可以设计 DP:

fi=minj=0i1fj+k=j+1ix=j+1ktxtk

st 的前缀和,方程式改写为:

fi=minj=0i1fj+k=j+1isksjtk

sksj 分离,再记 1t 的前缀和为 srst 的前缀和为 sd,有

fi=minj=0i1fj+sdisdjsj×(srisrj)

显然的斜率优化,时间复杂度 O(nk)。可不可以 wqs 二分?

const int N = 2e5 + 5;
const ll inf = 1e18;

int n, k, hd, tl, d[N];
double t[N], s[N], p[N], sd[N], sr[N], x[N], y[N], f[N], g[N];
double slope(int i, int j) {return (y[j] - y[i]) / (x[j] - x[i]);}
int main() {
	cin >> n >> k;
	for(int i = 1; i <= n; i++)
		cin >> t[i], s[i] = s[i - 1] + t[i],
		sd[i] = sd[i - 1] + s[i] / t[i], sr[i] = sr[i - 1] + 1 / t[i];
	for(int i = 1; i <= n; i++) g[i] = inf;
	while(k--) {
		d[hd = tl = 0] = 0;
		for(int i = 1; i <= n; i++) {
			while(hd < tl && slope(d[hd], d[hd + 1]) <= sr[i]) hd++;
			int j = d[hd]; f[i] = g[j] + sd[i] - sd[j] - s[j] * (sr[i] - sr[j]);
			x[i] = s[i], y[i] = g[i] - sd[i] + s[i] * sr[i];
			while(hd < tl && slope(d[tl - 1], d[tl]) >= slope(d[tl], i)) tl--; d[++tl] = i;
		} cpy(g, f, N);
	}
	printf("%.10lf\n", f[n]);
	return 0;
}

XVI. CF1083E The Fair Nut and Rectangles

由于所有矩形不会互相包含,所以我们按照 x 递增排序后 y 递减。考虑设计 DP:设 fi 表示考虑前 i 个矩形的答案且第 i 个矩形必须选,枚举上一个选择的矩形,有:

fi=maxj=0i1fj+yi(xixj)ai

显然的斜率优化。

不用 fread 擦着时限过,用了只有时限的 1/5,只能说离谱。

const int N = 1e6 + 5;

ll n, f[N], hd, tl, d[N], ans;
struct Rect {
	ll x, y, w;
	bool operator < (const Rect &v) const {return x < v.x;}
} c[N];

ld slope(int i, int j) {return (ld)(f[j] - f[i]) / (c[j].x - c[i].x);}

int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) c[i].x = read(), c[i].y = read(), c[i].w = read();
	sort(c + 1, c + n + 1);
	for(int i = 1; i <= n; i++) {
		while(hd < tl && slope(d[hd], d[hd + 1]) >= c[i].y) hd++;
		int j = d[hd]; f[i] = f[j] + c[i].y * (c[i].x - c[j].x) - c[i].w;
		while(hd < tl && slope(d[tl - 1], d[tl]) <= slope(d[tl], i)) tl--; d[++tl] = i;
		ans = max(ans, f[i]);
	} cout << ans << endl;
	return 0;
}

XVII. LOJ 2769. 「ROI 2017 Day 1」前往大都会

首先求出最短路图 T,是个 DAG。注意到 T 上属于相同的铁路的一段边,我们有转移 fi=maxjfj+(disidisj)2,其中 j 能到达 i。显然的斜率优化。

但是不同于普通斜率优化的是我们要维护一个斜率单调递增的上凸包,这合理吗?没有关系,单调队列不管用一般可以用单调栈,在队尾及时弹出不符合题意的决策即可。

时间复杂度 O(mlogm)

const int N = 1e6 + 5;
const ld eps = 1e-9;

int cnt, hd[N], nxt[N], to[N], val[N];
void add(int u, int v, int w) {
	nxt[++cnt] = hd[u], hd[u] = cnt;
	to[cnt] = v, val[cnt] = w;
}

int n, m, v[N], t[N];
struct Edge {
	int u, v, w, id;
};
vector <Edge> e[N];

int dis[N], vis[N], deg[N];
void Dijkstra() {
	priority_queue <pii, vector <pii>, greater <pii>> q;
	mem(dis, 63, N), dis[1] = 0, q.push({0, 1});
	while(!q.empty()) {
		pii t = q.top(); q.pop();
		int id = t.se, ds = t.fi;
		if(vis[id]) continue;
		if(id == n) break;
		vis[id] = 1;
		for(int i = hd[id]; i; i = nxt[i]) {
			int it = to[i], nd = ds + val[i];
			if(dis[it] > nd) q.push({dis[it] = nd, it});
		}
	}
}

vector <int> st[N << 1], v2[N << 1], bel[N];
ll ans[N], r;
ll Y(int x) {return ans[x] + 1ll * dis[x] * dis[x];}
ld slope(int i, int j) {return (ld)(Y(j) - Y(i)) / (dis[j] - dis[i]);}
void checksl(int id, ll sl) {
	int sz = st[id].size();
	while(sz > 1 && slope(st[id][sz - 2], st[id][sz - 1]) <= sl)
		sz--, st[id].pop_back();
}
void checkpt(int id, int p) {
	int sz = st[id].size();
	while(sz > 1 && slope(st[id][sz - 2], st[id][sz - 1]) + eps <= 
		slope(st[id][sz - 1], p)) sz--, st[id].pop_back();
	st[id].pb(p);
}
void dfs(int id) {
	deg[id] = -1;
	map <int, bool> mp; 
	for(int it : bel[id]) {
		while(st[it].size() > 1 && slope(st[it][st[it].size() - 2], st[it].back())
			<= dis[id] * 2) st[it].pop_back();
		if(!st[it].empty()) {
			int tp = st[it].back();
			ans[id] = max(ans[id], ans[tp] + 1ll * (dis[id] - dis[tp]) * (dis[id] - dis[tp]));
        }
	}
	for(int it : bel[id]) checkpt(it, id);
	for(int i = hd[id]; i; i = nxt[i])
		if(dis[id] + val[i] == dis[to[i]] && !--deg[to[i]]) dfs(to[i]);
}
int main() {
	cin >> n >> m;
	for(int i = 1; i <= m; i++) {
		int s = read(); v[1] = read();
		e[i].resize(s);
		for(int j = 1; j <= s; j++)
			t[j] = read(), v[j + 1] = read();
		for(int j = 1; j <= s; j++) {
			add(v[j], v[j + 1], t[j]);
			e[i][j - 1] = {v[j], v[j + 1], t[j], cnt};
		}
	} Dijkstra();
	for(int i = 1; i <= m; i++) {
		v2[++r].pb(e[i][0].u);
		for(Edge it : e[i]) {
			if(dis[it.u] + it.w == dis[it.v]) deg[it.v]++;
			else r++;
			v2[r].pb(it.v);
		}
	}
	for(int i = 1; i <= r; i++)
		for(int it : v2[i]) bel[it].pb(i);
	for(int i = 1; i <= n; i++) if(!deg[i]) dfs(i);
	cout << dis[n] << " " << ans[n] << endl;
	return 0;
}

XVIII. P6173 [USACO16FEB]Circular Barn P

首先看到环不难想到破环成链,枚举断点后变成序列上的问题,DP 状态非常明显:设 fi,j 表示在位置 i 及以前解锁 j 个谷仓且位置 i 下一个位置打开谷仓(即走到 i 的牛不会再往下一个位置走,保证了无后效性)的最小代价,转移为:

fi,j=mink=0i1fk,j1+l=k+1irl×(lk1)

把括号拆开,加一个经典前缀和优化,即设 si=j=1irisdi=j=1iri×i,则方程可写为:

fi,j=mink=0i1fk,j1+(sdisdk)(sisk)×(k+1)

斜率优化即可,时间复杂度 O(n2k)。感觉可以 wqs 二分(大雾。

const int N=2e3+5;
const ld eps=1e-8;

ll n,k,ans=1e12,r[N],a[N],s[N],sd[N];
ll hd,tl,d[N],x[N],y[N],f[N],g[N];
void solve(){
	x[0]=1,mem(f,0x3f,N),f[0]=0;
	for(int i=1;i<=n;i++)s[i]=s[i-1]+a[i],sd[i]=sd[i-1]+a[i]*i;
	for(int c=1;c<=k;c++){
		hd=tl=1;
		for(int i=1;i<=n;i++){
			while(hd<tl&&y[d[hd+1]]-y[d[hd]]<=(double)s[i]*(x[d[hd+1]]-x[d[hd]]))hd++;
			int j=d[hd]; g[i]=f[j]+sd[i]-sd[j]-(s[i]-s[j])*(j+1);
			x[i]=i+1,y[i]=f[i]-sd[i]+s[i]*(i+1);
			while(hd<tl&&(double)(y[d[tl]]-y[d[tl-1]])*(x[i]-x[d[tl]])>=(double)(y[i]-y[d[tl]])*(x[d[tl]]-x[d[tl-1]]))tl--;
			d[++tl]=i;
		} cpy(f,g,N);
	} ans=min(ans,f[n]);
}
int main(){
	cin>>n>>k;
	for(int i=1;i<=n;i++)cin>>r[i],r[i+n]=r[i];
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++)a[j]=r[i+j-1];
		solve();
	} cout<<ans<<endl;
    return 0;
}

XIX. *P3438 [POI2006]ZAB-Frogs

世 界 线 收 束。以前在 hb 那做过的题目,现在换种方法解决:设 mi,j 表示第 i 行距离 (i,j) 的最近坏点,设 fi,j 表示距离 (i,j) 的最近坏点,那么有:

fi,j=mink[1,n](ik)2+mk,j2

写成 mk,j2+k2=2ki+(fi,ji2) 的形式,斜率优化即可。求出距离后并查集维护连通性即可做到 O(nmα)

8. 决策单调性:二分队列

8.1 算法介绍

二分队列常用来优化具有决策单调性的 DP 问题,且这里的决策单调性指的是对于 ij 的最优决策点 pi,pj,若 i<j 则一定满足 pipj。要求转移时的贡献能快速计算,一般是 O(1)

通常情况下的限制为:贡献函数二阶导恒为非负,求最小值二阶导恒为非正,求最大值

具体地,建立一个存储三元组 (j,l,r) 的队列,表示 lr 的最优决策点是 j。每次判断队首三元组是否过时,若 r<i 则弹出队首。否则将 l 赋值为 i

加入决策时,如果 i 相比队尾的 j 转移到 l 更优,那么根据决策单调性,i 转移到 lnj 更优,因此 (j,l,r) 就完全无用了,弹出。重复操作直到不满足条件或队列仅剩下一个三元组。

接下来取出队尾的三元组 (j,l,r),我们要找到一个位置 p 使得 p 以前的位置,从 j 转移更优;而 pp 以后的位置,从 i 转移更优。而这个因为能快速计算贡献,所以显然可以二分出来。注意二分位置从 lr+1如果 pn 那么将 (i,p,n) 压入队列即可,同时不要忘记将队尾三元组的 r 改为 p1

综上,整个算法时间复杂度为 O(nlogn)

8.2. 例题

I. P1912 [NOI2009] 诗人小G

经典题。

转移方程为 fi=min0j<i|sisj+(ij1)L|Psi 是长度前缀和。可以使用二分队列。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define ld long double
#define pb push_back
#define fi first
#define se second
#define mcpy(x,y) memcpy(x,y,sizeof(y))
#define mem(x,v) memset(x,v,sizeof(x))

#define gc getchar()
inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=x*10+s-'0',s=gc;
	return x;
}

ld ksm(ld a,int b){
	ld s=1; while(b){
		if(b&1)s=s*a;
		b>>=1,a=a*a;
	} return s;
}

const int N=1e5+5;
const int S=30+5;
const ld eps=1e-10;

int n,p,hd,tl,tr[N],s[N];
char str[N][S];
ld f[N],l;

ld cal(int i,int j){return ksm(abs(s[j]-s[i]-l),p);}
ld val(int i,int j){return f[i]+cal(i,j);}

struct tuple{
	int j,l,r;
}d[N];
void print(int x){
	if(!x)return;
	print(tr[x]);
	for(int i=tr[x]+1;i<=x;i++)cout<<(str[i]+1)<<(i==x?'\n':' ');
}

void solve(){
	cin>>n>>l>>p,l++;
	for(int i=1;i<=n;i++){
		scanf("%s",str[i]+1);
		s[i]=s[i-1]+strlen(str[i]+1)+1;
	} d[hd=tl=1]={0,1,n};
	for(int i=1;i<=n;i++){
		while(hd<tl&&d[hd].r<i)hd++; d[hd].l=i;
		int j=d[hd].j; tr[i]=j,f[i]=f[j]+cal(j,i);
		while(hd<tl&&val(i,d[tl].l)<=val(d[tl].j,d[tl].l))tl--;
		int l=d[tl].l,r=d[tl].r+1;
		while(l<r){
			int mid=l+r>>1;
			if(val(i,mid)<=val(d[tl].j,mid))r=mid;
			else l=mid+1;
		} if(l<=n)d[tl].r=l-1,d[++tl]={i,l,n};
	} if(f[n]-eps>1e18)puts("Too hard to arrange");
	else cout<<(ll)(f[n])<<endl,print(n);
	puts("--------------------");
}
int main(){
	int t; cin>>t;
	while(t--)solve();
	return 0;
}

II. P3515 [POI2011]Lightning Conductor

题目中的柿子变一下就是 p=(maxijaj+|ij|)ai。正反做一遍二分队列即可。

#include <bits/stdc++.h>
using namespace std;

typedef double db;
typedef long long ll;
typedef long double ld;
typedef unsigned long long ull;

#define gc getchar()
#define pb push_back
#define mem(x,v,n) memset(x,v,sizeof(int)*n)
#define cpy(x,y,n) memcpy(x,y,sizeof(int)*n)

const ld Pi=acos(-1);
const ld eps=1e-10;
const ll mod=998244353;

inline int read(){
	int x=0; char s=gc;
	while(!isdigit(s))s=gc;
	while(isdigit(s))x=x*10+s-'0',s=gc;
	return x;
}

const int N=5e5+5;

struct tuple{
	int l,r,k;
}d[N];

int n,hd,tl,a[N];
double f[N],g[N],sqr[N];
double val(int l,int r){return a[l]+sqr[r-l];}
void solve(){
	d[hd=tl=1]={1,n,1},mem(f,0,N*2),f[1]=a[1];
	for(int i=2;i<=n;i++){
		while(hd<tl&&d[hd].r<i)hd++; d[hd].l=i;
		int j=d[hd].k; f[i]=a[j]+sqr[i-j];
		while(hd<tl&&val(i,d[tl].l)-eps>=val(d[tl].k,d[tl].l))tl--;
		int l=d[tl].l,r=d[tl].r+1;
		while(l<r){
			int m=l+r>>1;
			if(val(i,m)-eps>=val(d[tl].k,m))r=m;
			else l=m+1;
		} if(l<=n)d[tl].r=l-1,d[++tl]={l,n,i};
	}
}
int main(){
	n=read();
	for(int i=1;i<=n;i++)a[i]=read(),sqr[i]=sqrt(i);
	solve(),reverse(a+1,a+n+1),cpy(g,f,N*2),solve();
	for(int i=1;i<=n;i++){
		g[i]=max(g[i],f[n-i+1]);
		printf("%d\n",max(0,(int)g[i]+(fabs(g[i]-(int)g[i])>eps)-a[n-i+1]));
	}
	return 0;
}

9. 决策单调性:二分栈

9.1. 算法介绍

二分栈算法常用于有如下决策单调性的 DP 问题中:每个决策点 j 只会被比它更前的决策点 i (i<j) 反超。vi 为从决策点 i 转移到当前位置的贡献

通常情况下的限制为:贡献函数二阶导恒为非负,求最大值二阶导恒为非正,求最小值。这一点可以和前面的二分队列进行对比。

我们可以用一个栈维护可能的决策点,栈顶为当前位置的决策点。考虑如何更新决策点:一个自然的想法是如果栈顶劣于次栈顶,就弹出。但这样是错误的:如果存在 i<j<k 满足 k 优于 j劣于 i,那么这个算法会从 k 而不是 i 转移过来。但是我们有补救的机会:如果 i 反超 j 的时间在 j 反超 k 的时间之前,那么在 i 反超 jj 也不会反超 k 成为最优决策,也就是说决策点 j 已经完全无用了。因此,我们可以在加入决策 k 之前二分出 j 反超 k 的时间 t1i 反超 j 的时间 t2,如果 t1t2,那么弹出 j。重复上述操作直到栈内只剩 1 个元素或 t1>t2,然后压入 k

不要忘了在每次转移前不断二分出次栈顶反超栈顶的时间 t,如果不大于当前时间 i,那么弹出栈顶。

9.2. 例题

I. P5504 [JSOI2011] 柠檬

经典好题。

fi 为取前 i 只贝壳所能获得的最多柠檬数。一个关键的结论是 DP 仅会在相同颜色的贝壳之间转移,证明不难但不易想到。由于 (st2)=2s>0 且求最大值,所以使用二分栈即可。

const int N=1e5+5;

int n,h[N],s[N],p[N];
ll f[N],buc[N];
vector <int> st[N];
ll cal(int p,ll l){return f[p-1]+l*l*s[p];}

#define tp st[c][st[c].size()-1]
#define se st[c][st[c].size()-2]

int chk(int i,int j){
	int l=p[j],r=buc[s[j]]+1;
	while(l<r){
		int m=l+r>>1;
		if(cal(i,m-p[i]+1)<cal(j,m-p[j]+1))l=m+1;
		else r=m;
	} return l;
}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)s[i]=read(),p[i]=++buc[s[i]];
	for(int i=1;i<=n;i++){
		int c=s[i];
		while(st[c].size()>1&&chk(se,tp)<=chk(tp,i))st[c].pop_back();
		st[c].push_back(i);
		while(st[c].size()>1&&chk(se,tp)<=p[i])st[c].pop_back();
		f[i]=cal(tp,p[i]-p[tp]+1);
	} cout<<f[n]<<endl;
	return 0;
}

10. 整体 DP

10.1. 算法简介

整体 DP 就是用线段树合并维护 DP。

如果看到请催我补一下这个部分的 blog。

10.2. 例题

*I. P4577 [FJOI2018]领导集团问题

fi,j 表示以 i 为根的子树 minwj 的答案,分两种情况讨论:

  • fi,j (jwi)max(fi,j,fi,wi+1),在合并完儿子后进行该操作。
  • fi,jfi,j+fu,j,其中 ui 的儿子,即合并树上的一条边 (i,j)

对于上述转移方程使用整体 DP 即可。

注意到区间 checkmax 的标记永久化无法合并:对于两棵线段树的同一个位置,它的新值应该是两棵树从根到路径的所有节点的标记的 max 的和而不是所有节点的标记的和的 maxmax(ai)+max(bi)max(ai+bi)

由于我们只进行 fi,wi+1fi,j 是单调递减的,所以注意到每次 checkmax 就相当于区间 +1,标记永久化即可,时间复杂度 O(nlogn)

const int N=2e5+5;

int n,node,R[N],ls[N<<5],rs[N<<5],laz[N<<5],mn[N<<5];
void push(int x){mn[x]=min(mn[ls[x]],mn[rs[x]])+laz[x];}
void modify(int l,int r,int ql,int qr,int &x){
	if(!x)x=++node;
	if(ql<=l&&r<=qr)return laz[x]++,mn[x]++,void();
	int m=l+r>>1;
	if(ql<=m)modify(l,m,ql,qr,ls[x]);
	if(m<qr)modify(m+1,r,ql,qr,rs[x]);
	push(x); 
}
int qval(int l,int r,int p,int x){
	if(l==r||!x)return laz[x];
	int m=l+r>>1;
	if(p<=m)return laz[x]+qval(l,m,p,ls[x]);
	return laz[x]+qval(m+1,r,p,rs[x]); 
}
int qpos(int l,int r,int x,int val){
	if(l==r)return l;
	int m=l+r>>1; val-=laz[x];
	if(mn[ls[x]]<=val)return qpos(l,m,ls[x],val);
	return qpos(m+1,r,rs[x],val);
}
int merge(int x,int y){
	if(!x||!y)return x|y;
	ls[x]=merge(ls[x],ls[y]);
	rs[x]=merge(rs[x],rs[y]);
	laz[x]+=laz[y],push(x);
	return x;
}

int w[N],W[N],ans;
vector <int> e[N];
void dfs(int id){
	for(int it:e[id])dfs(it),R[id]=merge(R[id],R[it]);
	int tmp=qval(1,n,w[id],R[id]);
	modify(1,n,qpos(1,n,R[id],tmp),w[id],R[id]);
}
int main(){
	cin>>n;
	for(int i=1;i<=n;i++)cin>>w[i],W[i]=w[i]; sort(W+1,W+n+1);
	for(int i=1;i<=n;i++)w[i]=lower_bound(W+1,W+n+1,w[i])-W;
	for(int i=2,f;i<=n;i++)cin>>f,e[f].pb(i);
	dfs(1),cout<<qval(1,n,1,R[1])<<endl;
	return 0;
}

*II. P6773 [NOI2020] 命运

神仙题。

注意到对于一端在 x 的子树内,另一端在 x 的祖先的限制 (u,v),若 u 最深的限制被满足,那么所有限制都被满足。因此,设 fi,j 表示一端在以 i 的子树内,另一端在 i 的子树外且 没有被满足的限制 (u,v)v 的最大深度为 j 的方案数。

  • (x,y) 不选,则有 fx,jmax(k,l)=jfx,kfy,l,即 k=0jfx,jfy,k+k=0j1fx,kfy,j
  • (x,y) 选,则有 fx,jk=0depxfx,jfy,k

使用前缀和优化后发现子树合并相当于合并两个带乘法标记的线段树。时间复杂度 O(nlogn)

下推标记时不需要新建节点,因为没有子节点意味着子节点所表示区间处 DP 值为 0,乘以任何数后仍为 0

const ll mod = 998244353;
const int N = 5e5 + 5;

void add(ll &x, ll y) {x += y; if(x >= mod) x -= mod;}
void mul(ll &x, ll y) {x = x * y % mod;}

struct Edge {
	int cnt, hd[N], nxt[N << 1], to[N << 1];
	void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
} edg, lim;

int node, R[N], ls[N << 5], rs[N << 5];
ll sum[N << 5], laz[N << 5];
void pushdown(int x) {
	if(laz[x] != 1) {
		mul(sum[ls[x]], laz[x]), mul(sum[rs[x]], laz[x]);
		mul(laz[ls[x]], laz[x]), mul(laz[rs[x]], laz[x]);
	} laz[x] = 1;
}
void pushup(int x) {sum[x] = (sum[ls[x]] + sum[rs[x]]) % mod;}
void init(int l, int r, int p, int &x) {
	x = ++node, laz[x] = sum[x] = 1;
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) init(l, m, p, ls[x]);
	else init(m + 1, r, p, rs[x]);
}
int merge(int l, int r, int x, int y, ll &s1, ll &s2) {
	if(!x && !y) return 0;
	if(!y) return add(s2, sum[x]), mul(laz[x], s1), mul(sum[x], s1), x;
	if(!x) return add(s1, sum[y]), mul(laz[y], s2), mul(sum[y], s2), y;
	if(l == r) {
		ll tmp = sum[x];
		add(s1, sum[y]), mul(sum[x], s1);
		add(sum[x], sum[y] * s2 % mod), add(s2, tmp);
		return x;
	} pushdown(x), pushdown(y);
	int m = l + r >> 1;
	ls[x] = merge(l, m, ls[x], ls[y], s1, s2);
	rs[x] = merge(m + 1, r, rs[x], rs[y], s1, s2);
	return pushup(x), x;
}
int query(int l, int r, int p, int x) {
	if(!x || r <= p) return sum[x];
	ll m = l + r >> 1, ans = 0; pushdown(x);
	if(m < p) ans = query(m + 1, r, p, rs[x]);
	return add(ans, query(l, m, p, ls[x])), ans;
}

int n, m, dep[N];
void dfs(int id, int f) {
	int mxd = 0; dep[id] = dep[f] + 1;
	for(int i = lim.hd[id]; i; i = lim.nxt[i]) mxd = max(mxd, dep[lim.to[i]]);
	init(0, n, mxd, R[id]);
	for(int i = edg.hd[id], it; i; i = edg.nxt[i])
		if((it = edg.to[i]) != f) {
		dfs(it, id);
		ll G = query(0, n, dep[id], R[it]), GG = 0;
		R[id] = merge(0, n, R[id], R[it], G, GG);
	}
}

int main() {
	cin >> n;
	for(int i = 1; i < n; i++) {
		int x = read(), y = read();
		edg.add(x, y), edg.add(y, x);
	} cin >> m;
	for(int i = 1; i <= m; i++) {
		int u = read(), v = read();
		lim.add(v, u);
	} dfs(1, 0), printf("%d\n", query(0, n, 0, R[1]));
	return 0;
}

III. CF490F Treeland Tour

整体 DP 的经典题。对于每个区间维护 LIS 和 LDS,线段树合并直接取 max 即可。

具体地,设 f/gi,j 分别表示最后一个元素为 j 的 LIS/LDS 最长长度。合并的时候也要更新答案。

时间复杂度 O(nlogn)

const int N = 6e3 + 5;
const int L = 1e6;
const int mod = 998244353;

int node, R[N], ls[N << 5], rs[N << 5];
int pre[N << 5], suf[N << 5], ans;
void modify(int l, int r, int p, int v, int &x, int *val) {
	if(!x) x = ++node; val[x] = max(val[x], v);
	if(l == r) return;
	int m = l + r >> 1;
	if(p <= m) modify(l, m, p, v, ls[x], val);
	else modify(m + 1, r, p, v, rs[x], val);
}
int merge(int x, int y) {
	if(!x || !y) return x | y;
	pre[x] = max(pre[x], pre[y]), suf[x] = max(suf[x], suf[y]);
	ans = max(ans, max(pre[ls[x]] + suf[rs[y]], suf[rs[x]] + pre[ls[y]]));
	ls[x] = merge(ls[x], ls[y]), rs[x] = merge(rs[x], rs[y]);
	return x;
}
int query(int l, int r, int ql, int qr, int x, int *val) {
	if(ql > qr) return 0;
	if(!x || ql <= l && r <= qr) return val[x];
	int m = l + r >> 1, ans = 0;
	if(ql <= m) ans = query(l, m, ql, qr, ls[x], val);
	if(m < qr) ans = max(ans, query(m + 1, r, ql, qr, rs[x], val));
	return ans;
}

int n, a[N];
vector <int> e[N];
void dfs(int id, int f) {
	int np = 0, ns = 0;
	for(int it : e[id]) if(it != f) {
		int tpre = 0, tsuf = 0; dfs(it, id);
		tpre = query(1, L, 1, a[id] - 1, R[it], pre);
		tsuf = query(1, L, a[id] + 1, L, R[it], suf);
		ans = max(ans, max(np + tsuf, ns + tpre) + 1);
		np = max(np, tpre), ns = max(ns, tsuf);
		R[id] = merge(R[id], R[it]);
	}
	modify(1, L, a[id], np + 1, R[id], pre);
	modify(1, L, a[id], ns + 1, R[id], suf);
}
int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	for(int i = 1; i < n; i++) {
		int u, v; cin >> u >> v;
		e[u].pb(v), e[v].pb(u);
	} dfs(1, 0), cout << ans << endl;
	return 0;
}

11. 长链剖分优化 DP

简单树论 长链剖分部分。

简要地,长链剖分可以优化树上与深度有关的动态规划。“十二省联考2019 希望” 是一道非常好的入门题,快去试试吧!

12. 轮廓线 DP

轮廓线 DP,又称插头 DP,是一类比较困难的动态规划类型,

12.1. 算法介绍

12.1.1. 概括

要想理解插头 DP,离开例题是不行的。这里简要阐述一下插头 DP 的核心思想:仅记录轮廓线上有用的信息

实际上,插头 DP 本质上是一种优化过的状压 DP。这类 DP 一般在网格图上进行。不同于状压直接转移一整行,插头是一个一个格子的转移,大大减小了复杂度:n()×4n()n2()×2n()

12.1.2. 例题

一个经典例题是 P1879 [USACO06NOV]Corn Fields G,虽然它可以用忽略无用状态的状压 DP 通过,但是用来理解插头 DP 再好不过了。

考虑哪些格子的状态对转移有影响:仅有当前格左边和上方的格子。但是我们显然不能仅记录这两个各自的状态因为虽然知道了当前格能否种玉米,但是没办法推出下一个格子上方的状态,因为我们没有记录当前格右上方格子的状态。因此,我们不仅要记录 (r1,c)(r,c1),还要记录 (r,i) (i<c) 以及 (r1,i) (ic) 一共 m 个格子的 0/1 状态。而转移的复杂度仅仅需要枚举所有 2m 种情况,然后 O(1) 推出下一个状态。

综上,总时间复杂度 O(n22n)

12.1.3. 总结

所以为什么轮廓线 DP 叫做插头 DP 呢?因为我们将当前格 c 左边和上方与 c 连通的这两个格子内部对 c 有影响的状态称为插头。更一般的,每个格子的状态对于与其相邻(一般是右方和下方)的两个格子的连通性的影响称为插头。这里的 “连通性” 是广义的,可以简要理解为对转移产生的影响。

而由于大部分插头 DP 是逐格转移,因此通常情况下我们有 “右插头” 以及 “下插头”。在例题中,(r1,c) 的状态是下插头,(r,c1) 的状态是右插头。

12.2. 扩展与应用

众所周知,插头 DP 常见于与连通性有关的动态规划题目中。很多状压 DP 也可以使用插头 DP 进行优化。这里给出一些常用技巧与注意点:

  • 有的时候一个插头的状态可能不止有 / 无两种(例如在哈密顿回路问题中,一个插头可以被表示成左括号或右括号)。当插头种数不是 2 的幂时,提取插头的状态较麻烦而且常数大。为了方便,一般用最接近且大于插头种数的 2 的幂作为状压的进制,例如当种数为 3 时使用 4 进制,种数为 5 时使用 8 进制。

    但是这样会枚举到很多无用状态,例如种数为 58 进制只要任意一位 >5 就是不合法状态。为了忽略掉无用状态节省空间,可以使用哈希表(如果是连通块相关题目有时还需再加上最小表示法)压缩状态,写起来稍微有一点麻烦(链式前向星)。此外注意清空哈希表时 memset 的复杂度。

  • 插头的讨论方法:有 / 无 × 下 / 右插头,四种情况分别讨论即可。

  • 轮廓线上记录 m+1 个状态时,从一行转移至另一行需要将状态整体左移一位(想一想,为什么)。

12.3. 例题

I. P5056 【模板】插头dp

为了区分插头的连通性(防止出现过早出现回路的情况),需要用括号表示法。三进制直接 4 进制哈希即可。轮廓线上记录 m+1 个插头:(r,1)(r,c1) 的下插头状态,(r,c1) 的右插头状态以及 (r1,c)(r1,m) 的下插头状态,这样才能转移。

  • 无下插头,无右插头:由于必须铺,所以新建联通分量:左括号下插头和右括号右插头。
  • 无下插头,有右插头:拐弯至当前格下插头或直走至当前格右插头,插头种类显然不变。
  • 有下插头,无右插头:拐弯至当前格右插头或直走至当前格下插头,插头种类显然不变。
  • 有下插头,有右插头:这种情况就比较麻烦了,需要根据插头种类继续分类讨论:首先显然合并两个插头会使它们湮灭,所以先删掉下插头和右插头。
    • 左括号右插头,右括号下插头:相当于合并一个连通分量分量,合法当且仅当没有别的插头且在最后一个合法格子。
    • 左括号右插头,左括号下插头:将下插头匹配的右括号变成左括号。
    • 右括号右插头,右括号下插头:将右插头匹配的左括号变成右括号。
    • 右括号右插头,左括号下插头:啥都不影响。

此外,插头的延伸需要考虑延伸至的格子是否可以放回路,若不可以,显然转移不合法。另外特殊考虑当前格不合法的情况,只需要原封不动地将状态塞回去即可。

时间复杂度 O(nm×3m)。常数较小因为哈希表自动帮我们忽略了不合法的情况:有值的状态才会被插入哈希表

const int N = 15;
const int H = 299987;

struct HashTable {
	int cnt, id[H << 1], nxt[H << 1], hd[H];
	ll val[H << 1];
	void clear() {mem(hd, 0, H), cnt = 0;}
	void insert(int st, ll v) {
		int r = st % H;
		for(int i = hd[r]; i; i = nxt[i])
			if(id[i] == st) return val[i] += v, void();
		val[++cnt] = v, nxt[cnt] = hd[r];
		id[cnt] = st, hd[r] = cnt;
	}
} h[2];
ll ans;
int n, m, edi, edj, now, pre = 1;
int bit[N], bas[N], mp[N][N];
char s;

int main() {
	cin >> n >> m;
	for(int i = 1; i <= n; i++)
		for(int j = 1; j <= m; j++)
			cin >> s, mp[i][j] = s == '.';
	for(int i = n; i && !edi; i--)
		for(int j = m; j && !edj; j--)
			if(mp[i][j]) edi = i, edj = j;
	for(int i = 0; i <= m; i++) bit[i] = i << 1, bas[i] = 1 << bit[i];
	h[0].insert(0, 1);
	for(int i = 1; i <= n; i++) {
		for(int k = 1; k <= h[now].cnt; k++) h[now].id[k] <<= 2;
		for(int j = 1; j <= m; j++) {
			swap(now, pre), h[now].clear();
			for(int k = 1; k <= h[pre].cnt; k++) {
				ll st = h[pre].id[k], dp = h[pre].val[k];
				int lft = st >> bit[j - 1] & 3, up = st >> bit[j] & 3;
				if(!mp[i][j]) {
					if(!lft && !up) h[now].insert(st, dp);
				} else if(!lft && !up) {
					if(mp[i + 1][j] && mp[i][j + 1])
						h[now].insert(st | bas[j - 1] | (bas[j] << 1), dp);
				} else if(lft && !up) {
					if(mp[i + 1][j]) h[now].insert(st, dp);
					if(mp[i][j + 1]) h[now].insert(st + lft * (bas[j] - bas[j - 1]), dp);
				} else if(!lft && up) {
					if(mp[i + 1][j]) h[now].insert(st + up * (bas[j - 1] - bas[j]), dp);
					if(mp[i][j + 1]) h[now].insert(st, dp);
				}
				else if(lft == 1 && up == 1){
					int cur = 1;
					for(int p = j + 1; p <= m; p++) {
						int t = st >> (p << 1) & 3;
						cur += t == 1 ? 1 : t == 2 ? -1 : 0;
						if(cur == 0) {
							h[now].insert(st - bas[j - 1] - bas[j] - bas[p], dp);
							break;
						}
					}
				} else if(lft == 2 && up == 2) {
					int cur = -1;
					for(int p = j - 2; ~p; p--) {
						int t = st >> (p << 1) & 3;
						cur += t == 1 ? 1 : t == 2 ? -1 : 0;
						if(cur == 0) {
							h[now].insert(st - (bas[j - 1] + bas[j] << 1) + bas[p], dp);
							break;
						}
					}
				} else if(lft == 1 && up == 2) {
					if(i == edi && j == edj) ans += dp;
				} else if(lft == 2 && up == 1)
					h[now].insert(st - (bas[j - 1] << 1) - bas[j], dp);
			}
		}
	}
	cout << ans << endl;
	return 0;
}

*II. P4262 [Code+#3]白金元首与莫斯科

一道神仙插头 DP:轮廓线上有 m 个格子,m+1 个插头(定义为向右或向下延伸一个格子),需要记录所有格子的下插头和当前格子左侧的右插头。考虑把每一个位置看做障碍后重新做一遍插头 DP,时间复杂度为 n2m22m,无法接受。GG。

Trick)接下来就是一个非常神仙的操作了。通常我们在求出一些不可减信息去掉单点后的值时,可以维护一个前缀信息和与后缀信息和,然后快速合并(例如拉格朗日插值在取值连续时,维护前缀积和后缀积避免求逆元从而线性插值)。

对于本题,就是正反各做一遍插头 DP,用空间换时间,即存储每个位置所有插头状态的权值。合并也很有讲究:首先,被挖掉的格子四周不能有插头,而且轮廓线其它地方的插头状态对应,即若 (r,c) 有下插头,则 (r+1,c) 有上插头,因此若确定了一个轮廓线的状态,那么另一个轮廓线与其对应的合法状态是唯一的。因此可以 2m 合并。

综上,时空复杂度 O(nm2m)

const int N=18;
const int mod=1e9+7;
void add(int &x,int y){x=(x+y)%mod;}

int n,m,mp[N][N],rev[1<<N];
int f[N][N][1<<N],g[N][N][1<<N];
void solve(int f[][N][1<<N]){
	f[1][0][0]=1;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++)
			for(int k=0;k<1<<m+1;k++)
				if(f[i][j-1][k]){
					int lft=k>>j-1&1,up=k>>j&1,v=f[i][j-1][k];
					if(!mp[i][j]){
						if(!lft&&!up)add(f[i][j][k],v);
						continue;
					}
					if(!lft&&!up){
						if(mp[i+1][j])add(f[i][j][k^(1<<j-1)],v);
						if(mp[i][j+1])add(f[i][j][k^(1<<j)],v);
						add(f[i][j][k],v);
					}
					if(!lft&&up)add(f[i][j][k^(1<<j)],v);
					if(lft&&!up)add(f[i][j][k^(1<<j-1)],v);
				}
		if(i<n)for(int k=0;k<1<<m;k++)
			f[i+1][0][k<<1]=f[i][m][k];
	}
}
int main(){
	cin>>n>>m;
	for(int i=0;i<1<<m+1;i++)for(int j=0;j<m+1;j++)
		rev[i]+=(i>>m-j&1)<<j;
	for(int i=1;i<=n;i++)for(int j=1;j<=m;j++)
		cin>>mp[i][j],mp[i][j]=!mp[i][j];
	solve(f);
	for(int i=1;i<=n;i++)reverse(mp[i]+1,mp[i]+m+1);
	reverse(mp+1,mp+n+1),solve(g);
	for(int i=1;i<=n;i++,cout<<endl)
		for(int j=1;j<=m;j++,cout<<' ')
			if(!mp[n-i+1][m-j+1])cout<<"0";
			else{
				int ans=0;
				for(int k=0;k<1<<m+1;k++){
					int lft=k>>j-1&1,up=k>>j&1;
					if(lft||up)continue;
					add(ans,1ll*f[i][j-1][k]*g[n-i+1][m-j][rev[k]]%mod);
				} cout<<ans;
			}
    return 0;
}

13. DP 套 DP

13.1. 算法简介

DP 套 DP 就是以内层 DP 的结果作为状态进行外层 DP。由于一般动态规划的转移可以看成一个自动机,所以也被称为 DP 自动机。可以类比 AC 自动机上 DP,只是将通过 trie 建出的自动机变成了由内层 DP 建出的自动机。

通常情况下内层 DP 的状态不会很多,否则时间复杂度无法承受。而外层 DP 以内层的状态作为一个维度,因此可以将每个状态重新编号并建出自动机,方便外层 DP 通过自动机转移。

这种类型的动态规划目前在 OI 并不常见。

13.2. 例题

*I. P4590 [TJOI2018]游园会

经典例题,感觉挺神仙的。

这种有奇奇怪怪限制的计数题,从动态规划的角度切入通常比较顺手。显然,DP 的状态设计应与兑奖串的长度(这一维大小为 103),以及和 NOI 的匹配长度(这一维大小为 3)有关。

注意到限制 LCS 长度比较棘手,我们考虑求 LCS 的经典方法:fi,j=max(fi1,j1+[ai=bj],fi1,j,fi,j1)。 不妨设第一维对应长度为 K 的奖章串 t 的匹配位置,第二维对应兑奖串 s 的匹配位置。一个显然但不易想到的结论是:如果我们知道了 fi,j1 (1iK),根据 sj 是什么,可以推出所有 fi,j。这启发我们将 LCS 的 DP 数组设计到动态规划里面,转移时根据内层 LCS 的 DP 推出外层 DP 的新状态。此外,不难发现 fi,jfi+1,jfi,j+1,因此其差分数组一定由 0 / 1 组成,这保证我们能够将 f 数组作为 DP 的一维,且大小为 2K

时间复杂度 O(n2KK(DP)Σ2)

template <class T> void cmax(T &a, T b) {a = a > b ? a : b;}
template <class T> void cmin(T &a, T b) {a = a < b ? a : b;}

const int N = 1e3 + 5;
const int K = 15;
const int mod = 1e9 + 7;
void add(int &x, int y) {x = (x + y) % mod;}

int n, k, f[2][1 << K][3], ans[K + 5], s[K + 5];
int main() {
	cin >> n >> k;
	for(int i = 1; i <= k; i++) {
		char tmp; cin >> tmp;
		s[i] = (tmp == 'N' ? 0 : tmp == 'O' ? 1 : 2);
	}
	f[0][0][0] = 1;
	for(int i = 0, p = 0, nw = 1; i < n; i++, swap(p, nw), mem(f[nw], 0, 1 << K))
		for(int j = 0; j < 1 << k; j++)
			for(int m = 0; m < 3; m++) if(f[p][j][m])
				for(int c = 0; c < 3; c++) {
					int nxt = m == c ? m + 1 : (c == 0 ? 1 : 0), nwS = 0;
					if(nxt == 3) continue;
					static int g[K + 2]; g[0] = 0;
					for(int p = 1; p <= k; p++) g[p] = g[p - 1] + ((j >> p - 1) & 1);
					for(int p = 1, pre = 0; p <= k; p++) {
						int cur = max(max(pre, g[p]), g[p - 1] + (c == s[p]));
						nwS += (cur - pre) << p - 1, pre = cur;
					} add(f[nw][nwS][nxt], f[p][j][m]);
				}
	for(int i = 0; i < 1 << k; i++) {
		int len = __builtin_popcount(i);
		for(int j = 0; j < 3; j++) add(ans[len], f[n & 1][i][j]);
	}
	for(int i = 0; i <= k; i++) cout << ans[i] << endl;
	return 0;
}

14. 其它技巧

下述优化方法本质上只能算小技巧而非算法,所以合并在一起记录了。

14.1. 双栈优化 DP

如果遇到不可减的信息,但线性次信息加法的时间复杂度可以接受时:维护两个 “对底栈” 并记录栈内信息从栈底到栈顶的前缀和。右端点右移则将新信息压入右边的栈,左端点右移则弹出左边栈顶的信息。若左边的栈为空则将右边的栈的栈顶不断压入左边的栈直到右边的栈为空即可。

不难发现一个信息最多分别进入双栈一次,因此时间复杂度为 O(nw),其中 w 是合并信息的复杂度,应用见例题 I。

14.2. 例题

I. 2019 五校联考镇海 B. 小 ω 的仙人掌

题意简述:求最短区间 [l,r] 使得 (wi,vi) (lir) 做完背包后权值为 w 的代价 v

n104w,wi5×103vi2×105v109。TL 1s,ML 512MB

使用对底栈即可,时间复杂度 O(nw)。写分治 T 掉了,咍咍。

const int N = 1e4 + 5;
const int B = N << 1;
const int W = N >> 1;

int n, ans, wlim, vlim, ltop, rtop, a[N], b[N], e[N];
struct Knapsack {
	int b[W];
	void init() {mem(b, 63, W), b[0] = 0;}
	void ins(int w, int v) {
		for(int i = wlim; i >= w; i--)
			if(b[i - w] + v < b[i])
				b[i] = b[i - w] + v;
	}
} c[N], d[N];
bool check() {
	for(int i = 0; i <= wlim; i++)
		if(c[ltop].b[i] + d[rtop].b[wlim - i] <= vlim)
			return 1;
	return 0;
}
void reverse() {
	while(rtop) {
		ltop++, c[ltop] = c[ltop - 1];
		int id = e[rtop--];
		c[ltop].ins(a[id], b[id]);
	}
}
void push(int id) {
	rtop++, d[rtop] = d[rtop - 1];
	d[rtop].ins(a[id], b[id]), e[rtop] = id;
} 
int main() {
	cin >> n >> wlim >> vlim;
	ans = n + 5, c[0].init(), d[0].init();
	for(int i = 1; i <= n; i++) cin >> a[i] >> b[i];
	for(int i = 1, r = 1; i <= n; i++) {
		while(r <= n && !check()) push(r++);
		if(check()) ans = min(ans, r - i);
		if(ltop == 0) reverse(), ltop--;
		else ltop--;
	} cout << ans << endl;
	return 0;
}
posted @   qAlex_Weiq  阅读(8303)  评论(2编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示