LCT
有一类问题,要求我们维护一个森林,支持加边和删边操作,并维护树上的一些信息。这类问题称为动态树问题。
Link-Cut Tree (LCT),就是用于解决动态树问题的数据结构。均摊复杂度
LCT 支持的操作:查询/修改链上的信息,换根,动态连边/删边,合并两棵树/分离一棵树,动态维护连通性,等等
前置知识:Splay dalao blog
请奆佬们自行跳过这一部分,(蒟蒻因为太菜,不会Splay,写给自己看
Splay树,或 伸展树,是一种平衡二叉查找树。它通过 Splay/伸展操作 不断将某个节点旋转到根节点,使得整棵树仍然满足二叉查找树的性质,能够在均摊
二叉查找树的性质:左子树任意节点的值 < 根节点的值 < 右子树任意节点的值
基本操作
-
pushup(x): 改变节点位置后,更新节点信息
-
get(x): 判断节点 x 是父亲节点的左儿子还是右儿子
-
clear(x): 销毁节点 x
rotate 旋转操作
为了使 Splay 保持平衡。旋转的本质是将某个节点上移一个位置。
需要保证:
-
整棵 Splay 的中序遍历不变(二叉查找树的性质
-
受影响的节点维护的信息依然正确有效
-
root必须指向旋转后的根节点
具体分为左旋和右旋(图源oi-wiki
以右旋为例,设要旋转的节点为x,其父亲节点为y:
-
将y的左儿子指向x的右儿子,且x的右儿子的父亲指向y
-
将x的右儿子指向y,且y的父亲指向x
-
如果原来的y还有父亲z,那么把z的某个儿子指向x,且x的父亲指向z
void rotate(int x){//本质是交换x,y的位置
int y=t[x].fa;//x父亲
int z=t[y].fa;//x祖父
int k=(t[y].c[1]==x);//x为0左1右儿子
t[y].c[k]=t[x].c[k^1];
if(t[x].c[k^1]) t[t[x].c[k^1]].fa=y;
t[x].c[k^1]=y;
t[y].fa=x,t[x].fa=z;
if(z) t[z].c[t[z].c[1]==y]=x;
pushup(y),pushup(x);
}
Splay 伸展操作
Splay操作规定:每访问一个节点x后,都要强制将其旋转到根节点
Splay操作,就是把x旋转到根的操作。定义y为x的父节点,z为y的父节点。Splay步骤有三种,具体分为六种情况。
-
y是根节点:x是y左儿子,右旋;x是y右儿子,左旋。
-
y不是根,且x、y同为左儿子或右儿子:同为左儿子,两次右旋;同为左儿子,两次左旋。这里都是先做y-z,再做x-y。
-
y不是根,且x、y一个为左儿子,一个为右儿子:x是y左儿子,y是z右儿子,先对x-y右旋,再对x-z左旋;x是y右儿子,y是z左儿子,先对x-y左旋,再对x-z右旋。
void splay(int x){
while(t[x].fa){
int y=t[x].fa,z=t[y].fa;
if(t[z].fa){
if((t[z].c[0]==y)^(t[y].c[0]==x)) rotate(x);
else rotate(y);
}
rotate(x);
}
root=x;
}
好了,会了这俩操作LCT就够用了,所以Splay剩下操作不学了
实链剖分
众所周知,重链剖分就是根据子树大小把儿子节点分为重儿子和轻儿子,连出重链和轻链。它可以将树上的任意一条路径划分成不超过
而对于这样动态树,我们想相应地通过一些手段来维护树上路径,就诞生了实链剖分。
我们对每个节点自行指定实儿子和虚儿子,(需要注意的是一个点不一定必须有实儿子),然后我们就可以利用 Splay 去维护每一条链。
辅助树(AuxTree
维护每条链的Splay之间通过某种方式相连形成的树结构。因为Splay维护的是每一条实链,而辅助树维护的就是一棵树。一些辅助树放在一起就是LCT,用于维护整个森林。
1.辅助树由多个Splay组成,每个 Splay 维护原树的一条实链,且中序遍历 Splay 对应实链从上到下的点,注意 Splay 中不能出现深度相同的点。
2.每个节点包含且仅包含于一个 Splay 中。
3.辅助树上边分为实边和虚边,实边包含在 Splay 中;虚边由该 Splay 中中序遍历最靠前的点x,也就是实链的链顶,指向它在原树中的父亲y=fa[x]。同时对于y,我们仍然让它在辅助树上的儿子为空,以表示这条边是虚边。所有虚边都认父不认子。
- 原树上的操作均可转化为辅助树上的操作。
整理一下原树和辅助树的关系:
-
原树的实链都在辅助树的同一个Splay中
-
原树的虚边由儿子所在的Splay的根节点指向父亲,但这个父亲不指向该根节点(不回指,认父不认子)
-
Splay上最多有两个实儿子,但可能有很多虚儿子
-
原树的根不等于辅助树的根,原树的父亲指向不等于辅助树上的父亲指向
举例:一棵原树
它的辅助树就可以长成
这里我们可以更深刻地认识一下LCT虚边认父不认子的特性。即,从某个节点出来的虚边,在原树中是多少,无论辅助树长什么样,这个节点出发的虚边都还是多少。而虚边的子节点比较自由,指向的可以是子节点属于的splay中的任意一个点。
这让我们不由得考虑,辅助树是如何维护原树的节点关系的,尤其是对于虚边的子节点。其实是依靠我们规定的Splay中序遍历深度严格递增的性质。在后面的一系列操作中,这个性质都不会被破坏。
相关Splay的操作
get
寻找当前节点是父亲的哪个儿子
int get(int x){
return t[t[x].fa].c[1]==x;
}
isroot
用于判断该点是不是Splay的根。由于辅助树上虚边认父不认子的性质,不能直接看有没有father。
bool isroot(int x){
return t[t[x].fa].c[0]!=x&&t[t[x].fa].c[1]!=x;
}
pushup/pushdown
在LCT的Splay中,基本上都要实现区间翻转操作,故需要下放懒标记。
正常的Splay上传下放操作
void pushup(int x){
t[x].sum=t[t[x].c[0]].sum^t[t[x].c[1]].sum^t[x].val;
}
void pushdown(int x){
if(t[x].tag){
swap(t[x].c[0],t[x].c[1]);
t[t[x].c[0]].tag^=1;
t[t[x].c[1]].tag^=1;
t[x].tag=0;
}
}
但这种pushdown的写法会导致某个点上面有懒标记时,它的两个儿子还是反的,在某些题目中会导致错误。
可以理解为,每个节点储存的信息实际上就是它两个儿子的左右位置,所以一定要保证不论当前节点含不含懒标记,只要当前节点之上的节点的懒标记全部被下放,它储存的信息一定要是对的。(这和普通线段树对懒标记的要求就一样了)
所以更稳妥的写法是标记该节点的两个儿子需不需要翻转。
void pushrev(int x){
swap(t[x].c[0],t[x].c[1]);
t[x].tag^=1;
}
void pushdown(int x){
if(t[x].tag){
pushrev(t[x].c[0]),pushrev(t[x].c[1]);
t[x].tag=0;
}
}
update
用于在Splay操作前将根到x路径上的点的标记全部下放,来保证每个节点的儿子是正确的。
void update(int x){
if(!isroot(x)) update(t[x].fa);
pushdown(x);
}
rotate
和Splay中的操作一致。注意要用isroot判断根。
void rotate(int x){
int y=t[x].fa,z=t[y].fa,k=get(x);
if(!isroot(y)) t[z].c[get(y)]=x;//这一句提前,由于isroot判断的特性
t[y].c[k]=t[x].c[k^1];
if(t[x].c[k^1]) t[t[x].c[k^1]].fa=y;
t[x].c[k^1]=y;
t[y].fa=x,t[x].fa=z;
pushup(y),pushup(x);
}
splay
正常操作
void splay(int x){
update(x);
int y=t[x].fa;
while(!isroot(x)){
if(!isroot(y)){
rotate(get(y)==get(x)?y:x);
}
rotate(x);
y=t[x].fa;
}
}
LCT基本操作
access
LCT中最重要/难理解的操作。
令access(x)表示原树中x到根路径上的所有边改成实边,且与这些边相邻的边全改成虚边。我们先看代码。
void access(int x){
int y=0;
while(x){
splay(x);
t[x].c[1]=y;
pushup(x);
y=x,x=t[x].fa;
}
}
实际只有四步,(x为y虚边上的父亲),把x旋转到当前Splay的根,令x的右子节点为y,更新x的信息,继续上跳寻找下一个当前splay由虚边指向的点。
沿用上面辅助树的图。
原树
辅助树,一种可能的情况
执行一次 access(N) 我们希望它能变成:
即,把原树A到N路径上的边都变成实边,拉成一棵Splay。实现上考虑从下向上更新 Splay。
makeroot
重要性丝毫不亚于access。我们在需要维护路径信息时,一定会出现路径深度无法严格递增的情况,但根据定义,这种路径是不能出现在一棵Splay中的。
这时候我们需要用到makeroot。
makeroot的作用是使指定的点成为原树的根。
考虑如何实现:对makeroot(x),先做access(x),先把x到根打通。
将树用有向图表示出来,给每条边定一个方向,表示从儿子到父亲的方向。不难发现,换根相当于将x到根的路径的所有边反向(画图理解即可)。
又有,Splay维护的是中序遍历得到深度递增的实链,那么将x到原树根的路径翻转。注意一定要splay(x),让x为根,然后再把以x为根的Splay树区间翻转。不这样写会wa的很惨
void pushrev(int x){
swap(t[x].c[0],t[x].c[1]);
t[x].tag^=1;
}
void makeroot(int x){
access(x),splay(x);
pushrev(x);
}
findroot
注意!findroot找的是原树的根,并非辅助树的根!
先access(x),再splay(x),此时以x为根的Splay就代表根到x的实链。又根据辅助树上Splay的特性,不断走左儿子即可。
找到根节点之后还要splay(x)保证复杂度。(这东西涉及复杂度证明,记住就行)
int findroot(int x){
access(x),splay(x);
while(t[x].c[0]){
pushdown(x);
x=t[x].c[0];
}
splay(x);
return x;
}
split
split(x,y)就是将x到y的路径拿出来变成一棵Splay。
先makeroot(x),再access(y),如果要求y是根,就splay(y)。
这三个操作能直接把需要的路径拿到y的子树上,从而进行其他操作。
void split(int x,int y){
makeroot(x),access(y),splay(y);
}
link
就是将x和y之间连边。先让x成为子树的根,再让y成为x的父亲,x成为y的虚儿子即可。
注意题目不保证合法时要先判x-y直接有没有边。
void link(int x,int y){
makeroot(x);
if(findroot(y)!=x) t[x].fa=y;
}
cut
先做一次makeroot(x),再做access(y)和splay(x)。就能保证若两点之间有连边,则一定是实边,且y一定是x的右儿子且y没有左儿子。把它们双向断开即可,注意更新x信息。
void cut(int x,int y){
makeroot(x),access(y),splay(x);
if(t[y].fa==x&&!t[y].c[0]) t[y].fa=t[x].c[1]=0;
pushup(x);
}
终于结束了,累死我了
拼起来就是
[模板] 动态树(LCT)
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e5+5;
int n,m,a[maxn],tot;
struct node{
int c[2],fa,sum,val,tag;
}t[maxn];
void newnode(int val){
tot++;
t[tot].val=t[tot].sum=val;
}
int get(int x){
return t[t[x].fa].c[1]==x;
}
bool isroot(int x){
return t[t[x].fa].c[0]!=x&&t[t[x].fa].c[1]!=x;
}
void pushup(int x){
t[x].sum=t[t[x].c[0]].sum^t[t[x].c[1]].sum^t[x].val;
}
void pushrev(int x){
swap(t[x].c[0],t[x].c[1]);
t[x].tag^=1;
}
void pushdown(int x){
if(t[x].tag){
pushrev(t[x].c[0]),pushrev(t[x].c[1]);
t[x].tag=0;
}
}
void update(int x){
if(!isroot(x)) update(t[x].fa);
pushdown(x);
}
void rotate(int x){
int y=t[x].fa,z=t[y].fa,k=get(x);
if(!isroot(y)) t[z].c[get(y)]=x;
t[y].c[k]=t[x].c[k^1];
if(t[x].c[k^1]) t[t[x].c[k^1]].fa=y;
t[x].c[k^1]=y;
t[y].fa=x,t[x].fa=z;
pushup(y),pushup(x);
}
void splay(int x){
update(x);
int y=t[x].fa;
while(!isroot(x)){
if(!isroot(y)){
rotate(get(y)==get(x)?y:x);
}
rotate(x);
y=t[x].fa;
}
}
void access(int x){
int y=0;
while(x){
splay(x);
t[x].c[1]=y;
pushup(x);
y=x,x=t[x].fa;
}
}
void makeroot(int x){
access(x),splay(x);
pushrev(x);
}
int findroot(int x){
access(x),splay(x);
while(t[x].c[0]){
pushdown(x);
x=t[x].c[0];
}
splay(x);
return x;
}
void split(int x,int y){
makeroot(x),access(y),splay(y);
}
void link(int x,int y){
makeroot(x);
if(findroot(y)!=x) t[x].fa=y;
}
void cut(int x,int y){
makeroot(x),access(y),splay(x);
if(t[y].fa==x&&!t[y].c[0]) t[y].fa=t[x].c[1]=0;
pushup(x);
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
newnode(a[i]);
}
while(m--){
int opt,x,y;
scanf("%d%d%d",&opt,&x,&y);
if(opt==0){
split(x,y);
printf("%d\n",t[y].sum);
}
else if(opt==1) link(x,y);
else if(opt==2) cut(x,y);
else{
splay(x);//转到根才能不影响前面的节点
t[x].val=y;
pushup(x);
}
}
return 0;
}
例题+手法
LCT维护边权:[POJ3237]树的维护
这题本可以直接用树剖维护,但我们想要练习LCT的维护边权以应对更复杂的情况
注意到我们不能直接边权下放点权,因为LCT的根是会变化的。我们考虑给x-y之间的边设一个字母z,变成x-z-y。把所有的边权的信息都放到z上去做LCT,然后正常维护就可以了。
LCT维护最小生成树:[Wc2006] 水管局长
开始思路在维护无向图的环中绕了很久。结果突然发现人求的是最长时间的最小值。
直接用LCT维护最小生成树就行了。开始直接建最小生成树,然后后面的删边改加边。
每次加边时取x-y的路径,比较当前边和路径上最值边的大小,若当前边更优,则替换掉原树中那条边。
LCT维护连通块:[Codechef MARCH14] GERALD07 加强版
真没想出来,感觉线段树套LCT时间复杂度不对。
还是线段树。
经典结论:森林的连通块个数是点数-边数。问题就转化为[l,r]内可以成功插入的边,再转化为不可成功插入的边。
考虑逐个往后枚举插入,直到插入某个边成环。这时我们就知道,这个环上的边不能共存。此时我们应该删去编号最小的边。思考一下为什么要删最小的边,其实想法类似于前缀线性基,就是这样对于当前r能包含到的l最多(因为这环上的边随便删一个就行,就是至少有一个环上的边小于查询区间l)。
我们记录一下当r到某个数时它的l小于等于某个值时,会贡献+1。二维数点,用主席树解决。
闲话:这用LCT维护图基本上都是转化为生成树去做的。
[THUWC2017] 在美妙的数学王国中畅游
好家伙我就说看不出来怎么维护。
居然是用泰勒展开把这三个式子都转化成多项式去维护,还要求导,我是一个都不会。
展开之后的维护操作都是LCT基本操作了。且考虑到精度限制,我们只需要算到20位就可以了。
权值LCT维护LCT:[bzoj3159] 决战
这题最难绷的是要翻转权值,而不是连着区间的点一起翻转。在LCT中本来写的区间翻转是连着点一起翻,导致每个点对的权值反而没变。实际上是不是分开维护这两个翻转就行?
nonono,两个子树大小不一定一样,不能一一对应。很简单的原因啊,我真是唐了。
用LCT维护LCT。就是开两棵LCT,一个维护权值,一个维护位置。对位置树的每个节点维护一个pos值,表示这点的形态splay对应的的权值splay中的任意一点,就是为了找到一个对应的splay。更新的时候注意任意操作都要两个树一起更新了!!!因为权值树没有位置信息,所以必须和位置树一起更新。
另外,这题给了一定是一条链的性质,因此可以直接用树剖 + FHQ-treap维护,双log。
#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int maxn=1e5+5;
int n,m,rt,pos[maxn];
struct node{
int fa,c[2],tag,add,mn,mx,v,siz;
ll sum;
}t[maxn];
void init(){
t[0].mn=2e9;
for(int i=1;i<=n;i++) pos[i]=i+n,pos[i+n]=i;
for(int i=1;i<=2*n;i++) t[i].siz=1;
}
void pushup(int x){
t[x].sum=t[t[x].c[0]].sum+t[t[x].c[1]].sum+t[x].v;
t[x].siz=t[t[x].c[0]].siz+t[t[x].c[1]].siz+1;
t[x].mn=min({t[t[x].c[0]].mn,t[t[x].c[1]].mn,t[x].v});
t[x].mx=max({t[t[x].c[0]].mx,t[t[x].c[1]].mx,t[x].v});
}
void pushrev(int x){
swap(t[x].c[0],t[x].c[1]);
t[x].tag^=1;
}
void pushadd(int x,int add){
t[x].sum+=1ll*t[x].siz*add;
t[x].mx+=add,t[x].mn+=add;
t[x].add+=add,t[x].v+=add;
}
void pushdown(int x){
if(t[x].tag){
pushrev(t[x].c[0]);
pushrev(t[x].c[1]);
t[x].tag=0;
}
if(t[x].add){
if(t[x].c[0]) pushadd(t[x].c[0],t[x].add);
if(t[x].c[1]) pushadd(t[x].c[1],t[x].add);
t[x].add=0;
}
}
bool isroot(int x){
return t[t[x].fa].c[0]!=x&&t[t[x].fa].c[1]!=x;
}
int get(int x){
return t[t[x].fa].c[1]==x;
}
void update(int x){
if(!isroot(x)) update(t[x].fa);
pushdown(x);
}
void rotate(int x){
int y=t[x].fa,z=t[y].fa,k=get(x);
if(!isroot(y)) t[z].c[get(y)]=x;
else pos[x]=pos[y];
t[t[x].c[!k]].fa=y;
t[y].c[k]=t[x].c[!k],t[x].c[!k]=y;
t[x].fa=z,t[y].fa=x;
pushup(y),pushup(x);
}
void splay(int x){
update(x);
int y=t[x].fa;
while(!isroot(x)){
if(!isroot(y)) rotate(get(x)==get(y)?y:x);
rotate(x);
y=t[x].fa;
}
}
int find(int x,int k){
pushdown(x);
if(t[t[x].c[0]].siz+1==k) return x;
else if(k<=t[t[x].c[0]].siz) return find(t[x].c[0],k);
else return find(t[x].c[1],k-t[t[x].c[0]].siz-1);
}
void setpos(int x){//就是寻找对应点并把它放到根,相当于带上权值树的splay
splay(x);
splay(pos[x]);
pos[x]=find(pos[x],t[t[x].c[0]].siz+1);
splay(pos[x]);
}
void access(int x){//这里一定要更新权值树,因为它已经失去了形态,所以在更新形态时要把权值跟上
int y=0;
while(x){
setpos(x);
pos[t[x].c[1]]=t[pos[x]].c[1],t[t[pos[x]].c[1]].fa=0;
t[x].c[1]=y,t[pos[x]].c[1]=pos[y],t[pos[y]].fa=pos[x];
pushup(x),pushup(pos[x]);
y=x,x=t[x].fa;
}
}
void makeroot(int x){
access(x),setpos(x);
pushrev(x),pushrev(pos[x]);
}
int split(int x,int y){
makeroot(x),access(y),setpos(y);
return pos[y];
}
void link(int x,int y){
makeroot(x),setpos(y);
t[x].fa=y;
}
int main(){
scanf("%d%d%d",&n,&m,&rt);
init();
for(int i=1,u,v;i<n;i++){
scanf("%d%d",&u,&v);
link(u,v);
}
while(m--){
string s; int x,y,val;
cin>>s; scanf("%d%d",&x,&y);
if(s[2]=='c'){
scanf("%d",&val);
pushadd(split(x,y),val);
} else if(s[2]=='m') printf("%lld\n",t[split(x,y)].sum);
else if(s[2]=='j') printf("%d\n",t[split(x,y)].mx);
else if(s[2]=='n') printf("%d\n",t[split(x,y)].mn);
else pushrev(split(x,y));
}
return 0;
}
本文作者:YYYmoon
本文链接:https://www.cnblogs.com/YYYmoon/p/18685475
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步