CF280E - Sequence Transformation

给定一个不降整数序列 1x1x2xnq,请构造一个实数序列 y 满足 yi[1,q]yiyi1[a,b],且最小化 (yixi)2,保证有解。

利用凸函数性质维护导数

我们设 dpi(u) 表示对于所有的合法的 uyi=uji(yjxj)2 的最小值。

然后有转移式,设 dpi(u) 的最小值在 v 处取到,那么 dpi+1(u) 就应该是 dpi+1(u)=mind[a,b]dpi(ud)+(uxj)2

我们发现 dpi(u) 是有点棘手的,但是我们转而研究 dpi(u)。首先,我们发现 dpi(u) 是单调的,也就是 dpi(u) 是凸的。考虑数学归纳法,在 dp1(u),因为只是一个开口向上的二次函数,所以其导数必然是单调增的。

然后如果 dpi1(u) 满足条件,就很好了,因为凸函数的转移是显然的。设 dpi1(u) 的最小值取在 ki(注意因为有定义域限制,所以此处导数不一定为 0

所能转移到的函数段单调下降,

dpi(u)=dpi1(ua)+(uxi)2(u<ki+a)

所能转移到的函数段包含顶点,

dpi(u)=dpi1(ki)+(uxi)2(ki+auki+b)

所能转移到的函数段单调上升,

dpi(u)=dpi1(ub)+(uxi)2(u>ki+b)

这是很好证明的,在凸函数上这些都是显然的。然后我们就发现,如果是导数的话:

dpi(u)=dpi1(ua)+2u2xi(u<ki+a)

dpi(u)=2u2xi(ki+auki+b)

dpi(u)=dpi1(ub)+2u2xi(u>ki+b)

这就很好了!因为我们发现,新的函数就是把原来的函数从中间断开,然后左边往右平移 a,右边往右平移 b,中间用 0 填满。这是单调不降的。然后整体加上单调上升的 2u2xi,我们发现 dpi(u) 也是单调上升的。我们就证明了任意的 dpi(u) 都是单调上升的。

然后我们发现,我们每次只是平移,然后加上一次函数,所以导函数也一直是一次函数,这就很好分段维护了。考虑分段维护当前所有的 dpi(x),每次只需要找到零点断开,左右分别平移,中间安排 0,然后整体加上 2u2xi。每次最多增加两个段,所以最终的段个数不会超过 2n,复杂度 O(n2),是可以通过的。

讲一下卡了我很久的一个问题,就是我们的函数可能不是连续的。我一开始认为它一定是连续的,所以在发现函数不连续且没有 0 点的时候我就一直在找转移的问题,但实际上,我们的原函数本来就不是光滑的,插入一次函数 0u+0 之后导函数也不是连续的。但是我们的 0 点可以正常找,因为 0 点的定义并不是导数为 0 的点,而是原函数取到最小值的点。这时候如果相邻两段导函数一个右端点取值是负的,一个左端点取值是正的,就可以直接用它们交接的地方做 0 点,端点同理。

最后我们以 yn=kn 为基础倒推 y,因为 ki 是使得当前取值最小的位置,而 dpi(u) 还是个凸函数,所以很显然,尽可能的让 yi1 在不违反 yi 条件的情况下最接近 ki1 即可。然后直接用得到的 y 计算答案。

const int N=6000;
const ld limit=0.000000001;
ll n,x[N+5],q,a,b;
ld y[N+5];
struct slope{
	ld k,b,l,r;
	slope(ld K,ld B,ld L,ld R){
		k=K,b=B,l=L,r=R;
	}
	slope(){
		k=0,b=0,l=0,r=0;
	}
	ld zerop(){
		return -b/k;
	}
};
slope dp[3*N+5],f[3*N+5];
ld k[N+5];
int m,cnt;
int main(){
	scanf("%lld%lld%lld%lld",&n,&q,&a,&b);
	rep(i,1,n)scanf("%lld",&x[i]);
	dp[m=1]=slope(2,-2*x[1],1,q);
	rep(i,2,n){
		cnt=0;
		k[i]=dp[1].l;
		rep(j,1,m){
			ld c=dp[j].zerop();
			if(dp[j].l<c+limit && c<dp[j].r+limit)k[i]=c;
			if(j!=1&&dp[j-1].k*dp[j].l+dp[j-1].b+limit<0){
				if(dp[j].k*dp[j].l+dp[j].b>limit)k[i]=dp[j].l;
			}
		}
		if(k[i]>q+limit)k[i]=q;
		rep(j,1,m){
			if(dp[j].r<k[i]+limit){//r<=k
				f[++cnt]=slope(dp[j].k,dp[j].b-a*dp[j].k,dp[j].l+a,dp[j].r+a);
			}else if(dp[j].l>k[i]+limit){//l>k
				f[++cnt]=slope(dp[j].k,dp[j].b-b*dp[j].k,dp[j].l+b,dp[j].r+b);
			}else{//l<=k<r
				if(dp[j].l+limit<k[i]){//l<k
					f[++cnt]=slope(dp[j].k,dp[j].b-a*dp[j].k,dp[j].l+a,k[i]+a);
				}
				f[++cnt]=slope(0,0,k[i]+a,k[i]+b);
				f[++cnt]=slope(dp[j].k,dp[j].b-b*dp[j].k,k[i]+b,dp[j].r+b);
			}
		}
		m=cnt;
		rep(j,1,m){
			dp[j]=f[j];
			dp[j].k=dp[j].k+2;
			dp[j].b=dp[j].b-2*x[i];
		}
	}
	ld ans=dp[1].l;
	rep(j,1,m){
		ld c=dp[j].zerop();
		if(dp[j].l<c+limit && c<dp[j].r+limit)ans=c;
		if(j!=1&&dp[j-1].k*dp[j].l+dp[j-1].b+limit<0)
			if(dp[j].k*dp[j].l+dp[j].b>limit)ans=dp[j].l;
	}
	if(ans>q+limit)ans=q;
	per(i,1,n){
		y[i]=ans;
		if(ans-b+limit>k[i])ans=ans-b;
		else if(ans-a<k[i]+limit)ans=ans-a;
		else ans=k[i];
	}
	ld sum=0;
	rep(i,1,n){
		sum=sum+(x[i]-y[i])*(x[i]-y[i]);
		printf("%.8Lf ",y[i]);
	}
	printf("\n%.8Lf\n",sum);
	return 0;
}
//Nyn-siyn-hert

二次函数规划

金老师讲的做法,妙妙。

这个算法的核心是这样的。假设当 yi=dpi 时存在最优化 ji(xjyj)2 的一个方案。我们通过调整原先的答案依次求出 dpi,然后同样选取结果倒推。

我们需要实现一个功能,一个函数 ld solve(int i,ld l,ld r,ld A,ld B),表示返回一个 v,使得对于前 i 个位置,存在一个解 {y} 满足 yi=v 并且 j<i(yjxj)2+Av2+Bv v[l,r] 的限制条件下 被该方案最优化。

我们发现,想要求出 dpi,其实就是做一个 l=1,r=q 的规划,这个规划要最优化的是 ji(yjxj)2,忽略常数项(最优解的取值和常数项无关,即二次函数顶点横坐标和常数无关)也就是 j<i(yjxj)2+yi22xiyi。即 A=1,B=2xi

然后考虑如何求取规划。

首先证明一个引理:

  • 假设存在一个前 i 位的子问题的最优解 {y},并且 yi+b<curcurAyi+12+Byi 的最优解),那么一定存在一个前 i+1 位的子问题的最优解 {z} 满足 zi+b=zi+1

这个结论看起来是显然的,不过我们还是证一下。(金老师上课没讲,所以这个做法是我自己搞的,需要上一个做法的结论。不知道金老师的证法需不需要)

首先考虑最优解的 yi+1,如果有多个解,我们选取 yi+1 最大的一个,都相同就选取 yi 最小的一个。

然后假设 yi>yi+1b,那么因为第一个做法中转移函数的凸性,所以 yi=yi+1b 一定不会更劣(其实是严格更优)。但是这就和当前解是 yi 最小的最优解矛盾,所以得证。

同理,又有

  • 假设存在一个前 i 位的子问题的最优解 {y}yi+a>curcurAyi+12+Byi 的最优解),那么一定存在一个前 i+1 位的子问题的最优解 {z} 满足 zi+a=zi+1

这个证明过程和上面是一样的,就不证了。

那么,现在我们尝试规划 yi,先把前面的东西当作常数,单独找到 Ayi2+Byi 的最优解 yi=cur。这是二次函数顶点,注意有边界。

然后,如果 cur[dpi1+a,dpi1+b],那么我们就找到当前规划的最优解(前半段和后半段同时最优化),直接返回 cur

如果 cur>dpi1+b,由引理可知一定存在一个解使得 yi1+b=yi,那么就直接令 yi=yi1+b,这样 j<i(yjxj)2+Ayi2+Byi 也就变成了 j<i(yjxj)2+A(yi1+b)2+B(yi1+b),忽略常数项即为 j<i1(yjxj)2+(A+1)yi12+(B2xi1+2bA)yi1。同时为了满足当前规划条件,还要 yi1[lb,rb]。这就是一个 i=i1 的子规划问题。递归求解直至和上一位不冲突或没有上一位即可。

每次最多倒退 O(n) 次,所以复杂度是 O(n2)

const ld eps=1e-8;
int n,x[6005],q,a,b;
ld dp[6005],y[6005],ans;
inline bool lt(ld x,ld y){
	return x+eps<y;
}
inline bool gt(ld x,ld y){
	return x>y+eps;
}
inline bool le(ld x,ld y){
	return x<y+eps;
}
inline ld solve(int i,ld l,ld r,ld A,ld B){
	if(lt(l,1))l=1;
	if(lt(q,r))r=q;
	ld cyr=-B/(2*A);
	if(lt(r,cyr))cyr=r;
	if(lt(cyr,l))cyr=l;
	if(i==1||(le(dp[i-1]+a,cyr)&&le(cyr,dp[i-1]+b))){
		return cyr;
	}else if(gt(dp[i-1]+a,cyr)){
		return solve(i-1,l-a,r-a,A+1,B-2*x[i-1]+2*a*A)+a;
	}else{
		return solve(i-1,l-b,r-b,A+1,B-2*x[i-1]+2*b*A)+b;
	}
}
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(0);
	cin>>n>>q>>a>>b;
	rp(i,n)cin>>x[i];
	dp[1]=x[1];
	rep(i,2,n){
		if(le(dp[i-1]+a,x[i])&&le(x[i],dp[i-1]+b)){
			dp[i]=x[i];
		}else if(gt(dp[i-1]+a,x[i])){
			dp[i]=solve(i,1,q,1,-2*x[i]);
		}else{
			dp[i]=solve(i,1,q,1,-2*x[i]);
		}
	}ld res=dp[n];
	cout<<fixed<<setprecision(8);
	per(i,1,n){
		y[i]=res;
		ans+=(res-x[i])*(res-x[i]);
		if(gt(dp[i-1]+a,res))res=res-a;
		else if(gt(res,dp[i-1]+b))res=res-b;
		else res=dp[i-1];
	}
	rep(i,1,n)cout<<y[i]<<" ";
	cout<<endl;
	cout<<ans<<endl;
	return 0;
}
//Nyn-siyn-hert

FHQTreap 优化导数维护

我们考虑使用类似 FHQTreap 的数据结构维护导数。数据结构是为算法服务的,我们可以调整数据结构从而更加契合我们的算法。

首先看到 split,这个在无旋平衡树很多时候是核心操作,但是这里我们只需要插入,还不是按照下标插入。而我们发现我们还有一个功能是找零点,那么为什么不把这两个功能合并起来呢?

具体而言就是,我们用两个函数 splitlsplitr,一个把右端点取值大于等于 0split 到右边,这样先 splitr 一次得到 lr,右边的子树右端点取值就都大于等于 0。然后 splitl 把左端点取值大于等于 0 的划分到 右边,这样我们再把 r splittmpr,我们会惊奇的发现:

如果 tmp 不为零,它一定是跨越正负的。也就是零点一定在 tmp 里面。

如果 tmp0,那么意味着没有 0 点,那么 0 点就在左树和右树的交界点,这正好是右子树的最左点的左边界。我们就很好的把“找到目标位置”和“插入”直接合并起来了。

然后考虑我们需要进行的操作,一个是子树整体平移,一个是子树整体加一次函数。我们可以设计三个 tagaddk,addb,movemove 是把定义域向右平移 move,然后给 b 减去 kmoveadd 就是简单的加法。这样就不可避免的涉及到下传多 tag 的顺序问题。这里,我们钦定先执行 move,再执行加法。

然后考虑合并 tag,原先有的 tagaddmov,新增的是 addmov。本来的顺序应该是 movaddmovadd

但是合并 tag 使得顺序变成了 movmovaddadd。这样就相当于交换了 movadd 的位置。movadd 是没有贡献的,但是 addmov 是有贡献的。

具体而言,本来的 b 应该是 b+addbmov(k+addk)+addb,但是现在却是 b+addbmovk+addb,这就少减了一个 movaddk,不过很好的是 addmov 都是没有贡献的。所以直接把这个减在 addb 上就能消除交换顺序造成的影响了。

而我们只需要在 split 之后给 lr 分别打上右移 abtag

然后考虑 merge 的问题。我们发现,我们的树还有一个好处,就是我们不需要上传什么东西。我们不需要维护区间信息。那么在没有 pushup 的情况下,我们的 merge 就会好些很多。

最后是插入新段。对于 tmp0 的情况,可以直接把 tmp 作为新点插入。否则,我们可以开两个新点作为 tmp 的左右儿子,一个记录 tmp 零点左边的部分右移 a 位的结果,另一个记录右边部分右移 b 位的结果。然后就可以直接 merge,避免了开一堆新的单点子树然后套好多层 merge 的情况。

我们直接 split 出最终的零点,倒推答案。

const ld eps=1e-8;
int n,X[6005],q,a,b;
ld dp[6005],y[6005],ans;
inline bool lt(ld x,ld y){
	return x+eps<y;
}
mt19937 rng(time(0));
struct node{
	ld k,b,l,r,addk,addb;
	int ls,rs,mov;
	node(){}
	node(ld _k,ld _b,ld _l,ld _r){
		mov=0,addk=0,addb=0,ls=0,rs=0;
		k=_k,b=_b,l=_l,r=_r;
	}
}tr[114514];
int cnt=0,root;
#define ls(x) tr[x].ls
#define rs(x) tr[x].rs
inline void addtg(int x,ld k,ld b,int m){
	if(!x)return;
	tr[x].addb+=-m*tr[x].addk+b;
	tr[x].addk+=k;
	tr[x].mov+=m;
}
inline void pushdown(int x){
	if(!x)return;
	tr[x].l+=tr[x].mov;
	tr[x].r+=tr[x].mov;
	tr[x].b-=tr[x].mov*tr[x].k;
	tr[x].k+=tr[x].addk;
	tr[x].b+=tr[x].addb;
	addtg(ls(x),tr[x].addk,tr[x].addb,tr[x].mov);
	addtg(rs(x),tr[x].addk,tr[x].addb,tr[x].mov);
	tr[x].addk=0,tr[x].addb=0,tr[x].mov=0;
}
inline void splitl(int x,int &l,int &r){
	if(!x)return (void)(l=r=0);
	pushdown(x);
	ld val=tr[x].k*tr[x].l+tr[x].b;
	if(le(0,val)){
		r=x,splitl(ls(x),l,ls(x));
	}else l=x,splitl(rs(x),rs(x),r);
}
inline void splitr(int x,int &l,int &r){
	if(!x)return (void)(l=r=0);
	pushdown(x);
	ld val=tr[x].k*tr[x].r+tr[x].b;
	if(lt(0,val)){
		r=x,splitr(ls(x),l,ls(x));
	}else l=x,splitr(rs(x),rs(x),r);
}
inline int merge(int l,int r){
	pushdown(l);
	pushdown(r);
	if(!l)return r;
	if(!r)return l;
	int key=rng()%2;
	if(key){
		tr[r].ls=merge(l,tr[r].ls);
		return r;
	}else {
		tr[l].rs=merge(tr[l].rs,r);
		return l;
	}
}
inline int find_first(int x){
	return tr[x].ls?find_first(tr[x].ls):x;
}
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(0);
	cin>>n>>q>>a>>b;
	rp(i,n)cin>>X[i];
	cnt++,root=1,tr[0].l=q;
	tr[1]=node(2,-2*X[1],1,q);
	rep(i,2,n){
		int l,x,r,R;
		splitr(root,l,r),splitl(r,x,r);
		addtg(l,0,0,a),addtg(r,0,0,b);
		R=find_first(r);
		if(!x){
			x=++cnt,dp[i-1]=tr[R].l;
			tr[x]=node(0,0,tr[R].l+a,tr[R].l+b);
			root=merge(merge(l,x),r);
		}else{
			ld cyr=-tr[x].b/tr[x].k;
			dp[i-1]=cyr;
			if(lt(tr[x].l,cyr)){
				tr[++cnt]=node(tr[x].k,tr[x].b-a*tr[x].k,tr[x].l+a,cyr+a);
				tr[x].ls=cnt;
			}
			if(lt(cyr,tr[x].r)){
				tr[++cnt]=node(tr[x].k,tr[x].b-b*tr[x].k,cyr+b,tr[x].r+b);
				tr[x].rs=cnt;
			}tr[x].k=0,tr[x].b=0,tr[x].l=cyr+a,tr[x].r=cyr+b;
			root=merge(merge(l,x),r);
		}
		addtg(root,2,-2*X[i],0);
	}
	cout<<fixed<<setprecision(8);
	int l,x,r,R;
	splitr(root,l,r);
	splitl(r,x,r);
	R=find_first(r);
	ld res=(x?(-tr[x].b/tr[x].k):tr[R].l);
	if(lt(q,res))res=q;
	per(i,1,n){
		y[i]=res;
		ans+=(res-X[i])*(res-X[i]);
		if(gt(dp[i-1]+a,res))res=res-a;
		else if(gt(res,dp[i-1]+b))res=res-b;
		else res=dp[i-1];
	}
	rep(i,1,n)cout<<y[i]<<" ";
	cout<<endl;
	cout<<ans<<endl;
	return 0;
}
//Nyn-siyn-hert

两个做法之间的共同点

实际上,我们发现,这道题最关键的地方就是“对前 i 个子问题分别求解”。我们每次先把前缀 i 当成独立的子问题求解,然后推出 i+1

三个做法中,这个东西都至关重要。

在第一个和第三个做法中,这个点是原函数的顶点,导函数的零点,有了它才能决定新函数在哪里断开。

在第二个做法中,这个点是预处理的前缀问题的最优解。当一个前缀问题中,前缀的前缀前缀的最优解和前缀的后缀的最优规划互不冲突,就找到了当前子问题的解。然后就可以往后推了。

这是数学归纳思想,或者说递推思想在 OI 中重要的体现。

这道题我学到了什么

其实,最主要的是第三个做法,搞清楚了多个互相影响的 tag 同时下传的时候如何分析它们之间的影响并将其消除。在其他地方可谓用途很大。

然后,就是用分段函数 dp 的方法。不知道是不是我做题太少,没有遇到其他这样的题。

最后,是子问题规划的思想。但是在现在的我看来这个做法就像一个纸飞机,只有别人当面给我折叠一遍,我才能理解这个方法。否则这种巧妙的做法是我完全无法想到,下次遇到类似题目也难以再一次做出的。所以它对现在的我来说反而价值不大,不知道未来的自己能不能真正掌握这种思想。

posted @   jucason_xu  阅读(87)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示