为了优化体验(实际上是强迫症),蒟蒻把总结拆成了两篇,方便不一样学习阶段的Dalao们切换。html
LCT总结——应用篇戳这里c++
首先介绍一下链剖分的概念(感谢laofu的讲课)
链剖分,是指一类对树的边进行轻重划分的操做,这样作的目的是为了减小某些链上的修改、查询等操做的复杂度。
目前总共有三类:重链剖分,实链剖分和并不常见的长链剖分数组
实际上咱们常常讲的树剖,就是重链剖分的经常使用称呼。
对于每一个点,选择最大的子树,将这条连边划分为重边,而连向其余子树的边划分为轻边。
若干重边链接在一块儿构成重链,用树状数组或线段树等静态数据结构维护。
至于有怎样优秀的性质等等,不在本总结的讨论范畴了(实际上是由于本蒟蒻连树剖都不会)数据结构
一样将某一个儿子的连边划分为实边,而连向其余子树的边划分为虚边。
区别在于虚实是能够动态变化的,所以要使用更高级、更灵活的Splay来维护每一条由若干实边链接而成的实链。
基于性质更加优秀的实链剖分,LCT(Link-Cut Tree)应运而生。
LCT维护的对象实际上是一个森林。
在实链剖分的基础下,LCT资磁更多的操做函数
想学Splay的话,推荐巨佬yyb的博客学习
LCT的主要性质以下:优化
每个Splay维护的是一条从上到下按在原树中深度严格递增的路径,且中序遍历Splay获得的每一个点的深度序列严格递增。
是否是有点抽象哈
好比有一棵树,根节点为\(1\)(深度1),有两个儿子\(2,3\)(深度2),那么Splay有\(3\)种构成方式:
\(\{1-2\},\{3\}\)
\(\{1-3\},\{2\}\)
\(\{1\},\{2\},\{3\}\)(每一个集合表示一个Splay)
而不能把\(1,2,3\)同放在一个Splay中(存在深度相同的点)spa
每一个节点包含且仅包含于一个Splay中3d
边分为实边和虚边,实边包含在Splay中,而虚边老是由一棵Splay指向另外一个节点(指向该Splay中中序遍历最靠前的点在原树中的父亲)。
由于性质2,当某点在原树中有多个儿子时,只能向其中一个儿子拉一条实链(只认一个儿子),而其它儿子是不能在这个Splay中的。
那么为了保持树的形状,咱们要让到其它儿子的边变为虚边,由对应儿子所属的Splay的根节点的父亲指向该点,而从该点并不能直接访问该儿子(认父不认子)。code
LCT核心操做,也是最难理解的操做。其它全部的操做都是在此基础上完成的。
由于性质3,咱们不能老是保证两个点之间的路径是直接连通的(在一个Splay上)。
access即定义为打通根节点到指定节点的实链,使得一条中序遍历以根开始、以指定点结束的Splay出现。
蒟蒻深知没图的痛苦qwq
因此仍是来几张图吧。
下面的图片参考YangZhe的论文
有一棵树,假设一开始实边和虚边是这样划分的(虚线为虚边)
那么所构成的LCT可能会长这样(绿框中为一个Splay,可能不会长这样,但只要知足中序遍历按深度递增(性质1)就对结果无影响)
如今咱们要\(access(N)\),把\(A-N\)的路径拉起来变成一条Splay。
由于性质2,该路径上其它链都要给这条链让路,也就是把每一个点到该路径之外的实边变虚。
因此咱们但愿虚实边从新划分红这样。
而后怎么实现呢?
咱们要一步步往上拉。
首先把\(splay(N)\),使之成为当前Splay中的根。
为了知足性质2,原来\(N-O\)的重边要变轻。
由于按深度\(O\)在\(N\)的下面,在Splay中\(O\)在\(N\)的右子树中,因此直接单方面将\(N\)的右儿子置为\(0\)(认父不认子)
而后就变成了这样——
咱们接着把\(N\)所属Splay的虚边指向的\(I\)(在原树上是\(L\)的父亲)也转到它所属Splay的根,\(splay(I)\)。
原来在\(I\)下方的重边\(I-K\)要变轻(一样是将右儿子去掉)。
这时候\(I-L\)就能够变重了。由于\(L\)确定是在\(I\)下方的(刚才\(L\)所属Splay指向了\(I\)),因此I的右儿子置为\(N\),知足性质1。
而后就变成了这样——
\(I\)指向\(H\),接着\(splay(H)\),\(H\)的右儿子置为\(I\)。
\(H\)指向\(A\),接着\(splay(A)\),\(A\)的右儿子置为\(H\)。
\(A-N\)的路径已经在一个Splay中了,大功告成!
代码其实很简单。。。。。。循环处理,只有四步——
inline void access(int x){ for(int y=0;x;y=x,x=f[x]) splay(x),c[x][1]=y,pushup(x);//儿子变了,须要及时上传信息 }
只是把根到某个节点的路径拉起来并不能知足咱们的须要。更多时候,咱们要获取指定两个节点之间的路径信息。
然而必定会出现路径不能知足按深度严格递增的要求的状况。根据性质1,这样的路径不能在一个Splay中。
Then what can we do?
\(makeroot\)定义为换根,让指定点成为原树的根。
这时候就利用到\(access(x)\)和Splay的翻转操做。
\(access(x)\)后\(x\)在Splay中必定是深度最大的点对吧。
\(splay(x)\)后,\(x\)在Splay中将没有右子树(性质1)。因而翻转整个Splay,使得全部点的深度都倒过来了,\(x\)没了左子树,反倒成了深度最小的点(根节点),达到了咱们的目的。
代码
inline void pushr(int x){//Splay区间翻转操做 swap(c[x][0],c[x][1]); r[x]^=1;//r为区间翻转懒标记数组 } inline void makeroot(int x){ access(x);splay(x); pushr(x); }
关于pushdown和makeroot的一个相关的小问题详见下方update(关于pushdown的说明)
找\(x\)所在原树的树根,主要用来判断两点之间的连通性(findroot(x)==findroot(y)代表\(x,y\)在同一棵树中)
代码:
inline int findroot(R x){ access(x); splay(x); while(c[x][0])pushdown(x),x=c[x][0]; //如要得到正确的原树树根,必定pushdown!详见下方update(关于findroot中pushdown的说明) splay(x);//保证复杂度 return x; }
一样利用性质1,不停找左儿子,由于其深度必定比当前点深度小。
神奇的\(makeroot\)已经出现,咱们终于能够访问指定的一条在原树中的链啦!
split(x,y)定义为拉出\(x-y\)的路径成为一个Splay(本蒟蒻以\(y\)做为该Splay的根)
代码
inline void split(int x,int y){ makeroot(x); access(y);splay(y); }
x成为了根,那么x到y的路径就能够用\(access(y)\)直接拉出来了,将y转到Splay根后,咱们就能够直接经过访问\(y\)来获取该路径的有关信息
连一条\(x-y\)的边(本蒟蒻使\(x\)的父亲指向\(y\),连一条轻边)
代码
inline bool link(int x,int y){ makeroot(x); if(findroot(y)==x)return 0;//两点已经在同一子树中,再连边不合法 f[x]=y; return 1; }
若是题目保证连边合法,代码就能够更简单
inline void link(int x,int y){ makeroot(x); f[x]=y; }
将\(x-y\)的边断开。
若是题目保证断边合法,却是很方便。
使\(x\)为根后,\(y\)的父亲必定指向\(x\),深度相差必定是\(1\)。当\(access(y),splay(y)\)之后,\(x\)必定是\(y\)的左儿子,直接双向断开链接
inline void cut(int x,int y){ split(x,y); f[x]=c[y][0]=0; pushup(y);//少了个儿子,也要上传一下 }
那若是不必定存在该边呢?
充分利用好Splay和LCT的各类基本性质吧!
正确姿式——先判一下连通性(注意\(findroot(y)\)之后\(x\)成了根),再看看\(x,y\)是否有父子关系,还要看\(y\)是否有左儿子。
由于\(access(y)\)之后,假如y与x在同一Splay中而没有直接连边,那么这条路径上就必定会有其它点,在中序遍历序列中的位置会介于\(x\)与\(y\)之间。
那么可能\(y\)的父亲就不是\(x\)了。
也可能\(y\)的父亲仍是\(x\),那么其它的点就在\(y\)的左子树中
只有三个条件都知足,才能够断掉。
inline bool cut(int x,int y){ makeroot(x); if(findroot(y)!=x||f[y]!=x||c[y][0])return 0; f[y]=c[x][1]=0;//x在findroot(y)后被转到了根 pushup(x); return 1; }
若是维护了\(size\),还能够换一种判断
inline bool cut(int x,int y){ makeroot(x); if(findroot(y)!=x||sz[x]>2)return 0; f[y]=c[x][1]=0; pushup(x); return 1; }
解释一下,若是他们有直接连边的话,\(access(y)\)之后,为了知足性质1,该Splay只会剩下\(x,y\)两个点了。
反过来讲,若是有其它的点,\(size\)不就大于\(2\)了么?
其实,还有一些LCT中的Splay的操做,跟咱们以往学习的纯Splay的某些操做细节不甚相同。
包括\(splay(x),rotate(x),nroot(x)\)(看到许多版本LCT写的是\(isroot(x)\),但我以为反过来会方便些)
这些区别之处详见下面的模板题注释。
蒟蒻真的一时没注意这个问题。。。。。。Splay根本没学好
找根的时候,固然不能保证Splay中到根的路径上的翻转标记全放掉。
因此最好把pushdown写上。
Candy巨佬的总结对pushdown问题有详细的分析
只不过蒟蒻后来常常习惯这样判连通性(我也不知道怎么养成的)
makeroot(x); if(findroot(y)==x)//后续省略
这样好像没出过问题,那应该能够证实是没问题的(makeroot保证了x在LCT的顶端,access(y)+splay(y)之后,假如x,y在一个Splay里,那x到y的路径必定所有放完了标记)
致使好久没有发现错误。。。。。。
另外提一下,假如LCT题目在维护连通性的状况中只可能出现合并而不会出现分离的话,其实能够用并查集哦!(实践证实findroot很慢)
这样的例子有很多,好比下面“维护链上的边权信息”部分的两道题都是的。
甚至听到Julao们说有少许题目还专门卡这个常数。。。。。。XZY巨佬的博客就提到了
我pushdown和makeroot有时候会这样写,常数小一点
void pushdown(int x){ if(r[x]){ r[x]=0; int t=c[x][0]; r[c[x][0]=c[x][1]]^=1; r[c[x][1]=t]^=1; } } void makeroot(int x){ access(x);splay(x); r[x]^=1; }
这种写法等于说当x有懒标记时,x的左右儿子仍是反的
那么若是findroot里实在要写pushdown,那么这种pushdown就会出现问题(参考评论区@ zjp_shadow巨佬的指正)
再次update,蒟蒻发现这种问题仍是能够避免的,若用这种pushdown,findroot这样写就好啦
inline int findroot(int x){ access(x);splay(x); pushdown(x); while(lc)pushdown(x=lc); splay(x); return x; }
当题目中维护的信息与左右儿子顺序有关的时候,pushdown若是用这种不严谨写法会是错的(好比[NOI2005]维护数列(这是Splay题)和洛谷P3613 睡觉困难综合征)
再次update,夏丶沐瑾巨佬指出这种问题也是能够避免的,把pushup这样写就好啦
inline void pushup(int x){ pushdown(lc);pushdown(rc);//加上两个 //...... }
因此此总结以及下面模板里的pushdown,常数大了一点点,倒是更稳妥、严谨的写法
//pushr同上方makeroot部分 void pushdown(int x){ if(r[x]){ if(c[x][0])pushr(c[x][0]);//copy自模板,而后发现if能够不写 if(c[x][1])pushr(c[x][1]); r[x]=0; } } void makeroot(int x){ access(x);splay(x); pushr(x);//能够看到两种写法形成makeroot都是不同的 }
这种写法等于说当x有懒标记时,x的左右儿子已经放到正确的位置了,只是儿子的儿子仍是反的
那么这样就不会出问题啦
两种写法差异还确实有点大呢
洛谷P3690 【模板】Link Cut Tree (动态树)(点击进入题目)
最基本的LCT操做都在这里,也没有更多额外的复杂操做了,确实很模板。
#include<bits/stdc++.h> #define R register int #define I inline void #define G if(++ip==ie)if(fread(ip=buf,1,SZ,stdin)) #define lc c[x][0] #define rc c[x][1] using namespace std; const int SZ=1<<19,N=3e5+9; char buf[SZ],*ie=buf+SZ,*ip=ie-1; inline int in(){ G;while(*ip<'-')G; R x=*ip&15;G; while(*ip>'-'){x*=10;x+=*ip&15;G;} return x; } int f[N],c[N][2],v[N],s[N],st[N]; bool r[N]; inline bool nroot(R x){//判断节点是否为一个Splay的根(与普通Splay的区别1) return c[f[x]][0]==x||c[f[x]][1]==x; }//原理很简单,若是连的是轻边,他的父亲的儿子里没有它 I pushup(R x){//上传信息 s[x]=s[lc]^s[rc]^v[x]; } I pushr(R x){R t=lc;lc=rc;rc=t;r[x]^=1;}//翻转操做 I pushdown(R x){//判断并释放懒标记 if(r[x]){ if(lc)pushr(lc); if(rc)pushr(rc); r[x]=0; } } I rotate(R x){//一次旋转 R y=f[x],z=f[y],k=c[y][1]==x,w=c[x][!k]; if(nroot(y))c[z][c[z][1]==y]=x;c[x][!k]=y;c[y][k]=w;//额外注意if(nroot(y))语句,此处不判断会引发致命错误(与普通Splay的区别2) if(w)f[w]=y;f[y]=x;f[x]=z; pushup(y); } I splay(R x){//只传了一个参数,由于全部操做的目标都是该Splay的根(与普通Splay的区别3) R y=x,z=0; st[++z]=y;//st为栈,暂存当前点到根的整条路径,pushdown时必定要从上往下放标记(与普通Splay的区别4) while(nroot(y))st[++z]=y=f[y]; while(z)pushdown(st[z--]); while(nroot(x)){ y=f[x];z=f[y]; if(nroot(y)) rotate((c[y][0]==x)^(c[z][0]==y)?x:y); rotate(x); } pushup(x); } /*固然了,其实利用函数堆栈也很方便,代替上面的手工栈,就像这样 I pushall(R x){ if(nroot(x))pushall(f[x]); pushdown(x); }*/ I access(R x){//访问 for(R y=0;x;x=f[y=x]) splay(x),rc=y,pushup(x); } I makeroot(R x){//换根 access(x);splay(x); pushr(x); } int findroot(R x){//找根(在真实的树中的) access(x);splay(x); while(lc)pushdown(x),x=lc; splay(x); return x; } I split(R x,R y){//提取路径 makeroot(x); access(y);splay(y); } I link(R x,R y){//连边 makeroot(x); if(findroot(y)!=x)f[x]=y; } I cut(R x,R y){//断边 makeroot(x); if(findroot(y)==x&&f[y]==x&&!c[y][0]){ f[y]=c[x][1]=0; pushup(x); } } int main() { R n=in(),m=in(); for(R i=1;i<=n;++i)v[i]=in(); while(m--){ R type=in(),x=in(),y=in(); switch(type){ case 0:split(x,y);printf("%d\n",s[y]);break; case 1:link(x,y);break; case 2:cut(x,y);break; case 3:splay(x);v[x]=y;//先把x转上去再改,否则会影响Splay信息的正确性 } } return 0; }