动态树 LCT (Link-Cut-Tree)
什么是动态树?
动态维护⼀个森林,动态的⽀持 \(2\) 个操作——添加边、删除边的数据结构。
它还可以维护树上路径的⼀些信息
时间复杂度单独操作:\(logn\)
虽然树连剖分的复杂度比动态树大,但是常数比动态树⼩。
回忆⼀下,树连剖分是维护一些重链来维护⼀些树,动态树也是类似的。
树链剖分可以将边分为重边和虚边,动态树可以维护实边和虚边。
具体这样表述:任意⼀个点最多只有一条实边
我们可以定义一条与实边对应的路径
如下图所示:
孤⽴点我们也单独当作实边处理

\(Spaly\) 维护的所有实边路径
红圈圈出来的就是一个 \(splay\)
Q: 具体怎么维护?
A: \(splay\) 中序遍历就是这个路径从上到下的遍历
\(splay\) 维护的是当前联通的所有实边的极⼤路径。
\(splay\) 通过其中的后继与前驱关系,来维护原树中的⽗⼦关系。
Q: 树跟树之间的关系如何维护?
A:\(splay\)中,\(rt\) 的\(fa\)信息还没利⽤上,所以⽤ \(splay\) 中 \(rt\) 的结点来维护
提示,\(x\) 的⽗节点不⼀定是 \(y\),因为 \(x\) 不⼀定是 \(spaly\) 中的 \(rt\)
举个例⼦,假设 \(x\) 的 \(fa\) 是整个 \(splay\) 的 \(rt\),那么\(t[r].p = y\)
Q:如何判断实边虚边?
A:因为每个结点最多只有⼀个实边
如果是虚边
在链接 \(splay\) 的时候只有只有这个 \(splay\) 的 \(rt\) 的 \(fa\)(点A)指向了某个点(点B)
但是这个点B的儿子却不是点A
虚边:子结点知道父结点是谁,但是父结点不知道⼦结点是谁
实边:⽗⼦之间相互知道
基于这种情况,我们只需要修改一次父子关系
我们可以很轻松的完成实边和虚边的转换
即:只需要确定父亲的儿子是谁
LCT基本操作
换边操作
将结点 \(fa\) 的后继改为想要变成实边的那个点 \(x\)

注意,得把 \(fa\) 转到根节点
此时fa的右⼦树是空的(因为 \(fa\) 的序号最⼤)
然后把 \(x\) 点接到 \(fa\) 的右⼦树
access(x)
核⼼操作:将 \(rt\) 到 \(x\) 的路径全部变成实边
(建⽴⼀条 \(rt\) 到 \(x\) 的实边路径)
注意这个实边路径只能包含 \(rt\) 到 \(x\) 的实边路径

这个过程是从下往上做的
⾸先需要将 \(x\) 旋到当前实边的 \(rt\) 上
接下来希望将 \(x\) 与 \(y\) 的连边边成实边
因为 \(y\) 是最⼤的点,那么直接将 \(y\) 旋到 \(rt\) 上
此时 \(y\) 没有右⼦树,直接将 \(x\) 接到 \(y\) 的后继节点上
那么直接将 \(x\) 插到 \(y\) 的右节点即可
此时,我们以 \(y\) 为 \(rt\) 的 \(splay\) 维护了整个路径
同样的道理对于 \(z\) 来讲,先把 \(z\) 旋转到 \(rt\)
此时 \(z\) 有左右子树(因为原树的有⼦树)
解决⽅案很简单
基于之前的换边理论,我们直接将y挂到z的右⼦树上
此时z就是这个 \(splay\) 的 \(rt\)
总结⼀下:节点直接旋上去。挂到右⼦树。递归做即可。
make_root(x)
将x变为根节点
利⽤第一个操作 \(access(x)\) 建立一条rt到x的实边路径。
对于一个无根树而言,我们将路径翻转是不会影响这个树的拓扑结构的。
所以直接将 \(x\) 转到 \(rt\),然后将整个路径翻转即可。
拓扑结构不变:翻转前后 任意两点 \(x\) 到 \(y\) 经过的路径点不会发⽣变化。
路径翻转是 \(splay\) 的⼀个经典操作,直接将某个区间翻转即可。
利⽤ \(lazy-tag\) 实现。
提示:此处2个关系别搞混了,⼀个是从原树的,⼀个是 \(splay\) 的
⼀个疑惑:区间翻转后为什么不会破坏其他边的偏序关系
因为父子关系不会发⽣改变
find_root(x)
找到 \(x\) 所在的树的根节点
-
建⽴ \(x\) 到 \(rt\) 的实边路径(调⽤ \(access\))
-
将 \(x\) 旋转到 \(rt\)
-
整个路径深度最小的点就是根节点
只需要找到整个树的最左的节点
做两遍 \(findrt\) 即可
进阶操作:判断 x 和 y 是否在同⼀个 splay 之中
split(x,y)
将从 \(x\) 到 \(y\) 的路径变为⼀条实边路径
⾸先通过 \(makert\) 将 \(x\) 变为 \(rt\)
再 \(access(y)\) 这样就实现了\(split\)
link(x,y)
如果 \(x\) \(y\) 不连通,则加\((x,y)\)这⼀条边
具体操作:
- 判断连通,\(makert(x)\) 将 \(x\) 变为 \(rt\)
看 \(findrt(y)\) 是否为 \(x\)
如果\(findrt(y)!=x\)
则说明他们不连通
- 由于在判断连通时\(x\) 已经是 \(rt\) 了
所以若需要加边,只需要将 \(x\) 的 \(fa\) 记为 \(y\)
cut(x,y)
若 \(x\) \(y\) 有边,则删掉该边
操作:
-
将 \(x\) 变为 \(rt\)
-
\(findrt(y)\) 找到y所在的根节点
-
考虑第⼆个操作的副作⽤:当 \(findrt(y)\) 后,除了找到y的根节点外的同时,会将y所在
树的 \(rt\) 旋转到 \(y\) 所在实边 \(splay\) 的 \(rt\) 上(在 \(findrt\) 后,会从左⼀直⾛找到 \(rt\),然后 \(splay\) 操作为了保证时间复杂度,最后还会 \(splay\) ⼀次把 \(rt\) 旋上去。)\(y\) 所在实边\(splay\) 的根节点就应该是整个树的根节点,也就是 \(x\)。 -
如果\(x\space y\)有边,则意味着 \(y\) 应是 \(x\) 的后继。所以只需要判断 \(y\) 是否为 \(x\) 的后继即可( \(y\) 是否为 \(x\) 的后继的判断标准:\(y\) 是不是 \(x\) 的右⼦树,同时 \(y\) 的左⼦树是否为空。
这样就🆗了
isrt(x)
判断 \(x\) 是否为所在 \(splay\) 的 \(rt\)(注意不是原树的 \(rt\) )
如果 \(x\) 不是 \(rt\),则 \(x\) 必然存在父节点。则x必然是其fa的左儿子或右儿子。
因此,若 \(x\) 既不是其 \(fa\) 的左儿子,也不是其 \(fa\) 的右儿子。那么 \(x\) ⼀定是 \(splay\) 的 \(rt\)。
code
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+2;
int n,m,s[N],op,x,y;
struct node{
int ch[2],p,a,sum,tag;
}t[N];
void pushtag(int x){
swap(t[x].ch[0],t[x].ch[1]);
t[x].tag^=1;
}
void pushup(int x){
t[x].sum=t[t[x].ch[0]].sum^t[x].a^t[t[x].ch[1]].sum;
}
void pushdown(int x){
if(t[x].tag){
pushtag(t[x].ch[0]);
pushtag(t[x].ch[1]);
t[x].tag=0;
}
}
bool isrt(int x){
return t[t[x].p].ch[0]!=x&&t[t[x].p].ch[1]!=x;
}
void rotate(int x){//x上旋
int y=t[x].p,z=t[y].p;
int k=(t[y].ch[1]==x);//x原来的位置
if(!isrt(y)) t[z].ch[(t[z].ch[1]==y)]=x;//x代替y位置
t[x].p=z;
t[y].ch[k]=t[x].ch[k^1];
t[t[x].ch[k^1]].p=y;
t[x].ch[k^1]=y;
t[y].p=x;
pushup(y);
pushup(x);
}
void splay(int x){
int top=0,r=x;
s[++top]=r;
while(!isrt(r)){
s[++top]=r=t[r].p;
}
while(top) pushdown(s[top--]);
while(!isrt(x)){
int y=t[x].p,z=t[y].p;
if(!isrt(y)){
if((t[y].ch[1]==x)^(t[z].ch[1]==y)) rotate(x);
else rotate(y);
}
rotate(x);
}
pushup(x);
}
void access(int x){// 建1条从根到x的路径,同时将x变成splay的根节点
int z=x;
for(int y=0;x;y=x,x=t[x].p){
splay(x);
t[x].ch[1]=y;//右子树
pushup(x);
}
splay(z);
}
void makert(int x){// 将x变成原树的根节点
access(x);
pushtag(x);
}
int findrt(int x) {// 找到x所在原树的根节点, 再将原树的根节点旋转到splay的根节点
access(x);
while(t[x].ch[0]){
pushdown(x);
x=t[x].ch[0];
}
//路径深度最小
splay(x);
return x;
}
void split(int x,int y){ // 给x和y之间的路径建1个splay,其根节点是y
makert(x);
access(y);
}
void link(int x,int y){//如果x和y不连通,加1条x和y之间的边
makert(x);
if(findrt(y)!=x){//不连通
t[x].p=y;
}
}
bool back(int x,int y){//y是否是x的后继
return t[x].ch[1]==y&&!t[y].ch[0];
}
void cut(int x,int y){// 如果x和y之间存在边,则删除该边
makert(x);//将x变为root
if(findrt(y)==x&&back(x,y)){
t[x].ch[1]=t[y].p=0;
pushup(x);
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%d",&t[i].a);
}
while(m--){
scanf("%d%d%d",&op,&x,&y);
if(op==0){
split(x,y);
printf("%d\n",t[y].sum);
}else if(op==1){
link(x,y);
}else if(op==2){
cut(x,y);
}else{
splay(x);
t[x].a=y;
pushup(x);
}
}
return 0;
}
一些题
「HNOI2010」弹飞绵羊
就是将自己和可以跳到的点连边,询问就size咯
[SHOI2014]三叉神经树
每次更改一个子节点的信息,它的父亲能改变权值
当且仅当它的父亲还有 1 或 2 个点权值为 1
(这里的 1 或 2 个权值取决于叶子结点改变的权值)
那么再开一个 \(val\) 数组记录其儿子权值为 1 的个数
那么我们维护这棵树中叶子结点到根节点深度最大的 \(val≠1\) 或者 \(val≠2\) 的。
这个可以用 LCT ,每次维护的 splay 中右子树的深度大于他,左子树的深度小于他
那么直接在右子树中修改,这个点单调修改就行了(连续修改的区间)
用一个 \(tag\) 来维护是否需要修改
每次 \(access(x)\),这个时候 x 到根节点是一个 splay
直接在 \(x\) 上做文章就行了
要注意的细节:
-
每次都是从 \(fa_x\) 开始 splay 的,如果从叶子节点开始的话,那么维护的最大深度的 val 就没有任何意义了(叶子结点不应该维护 val )
-
这个 \(pushup\) 操作是跟 \(lson\) 和 \(rson\) 的顺序有关的,在 \(pushup\) 之前一定要 \(pushdown\)
「NOI2014」魔法森林
根据库鲁斯卡尔生成树算法,我们先按a关键字将边排序,动态加边
LCT里就存b最大值,动态更新答案
「WC2006」水管局长
我们发现删边特别难处理,那么怎样转化一下呢?
倒着处理所有询问,于是删边变成了加边。
然后查询所有路径上最大值的最小值,不难发现就是要维护这个图的最小生成树
然后就可以直接查询树路径上的最大值(用 \(lct\))
那么问题转化为了动态维护最小生成树
我们此时已经将询问倒过来处理了
那么假设我们已经维护好了一棵\(mst\)
每加一条边 \(u-v\),肯定会形成一个环。
因为是最小生成树,所以我们肯定要在环上去掉一条最大的边
于是处理加边操作流程如下:
查询\(u-v\)链上最大边权\(mx\)
比较新加的边权 \(w\) 和 \(mx\) 的大小关系,如果 \(w>mx\) ,则不做任何操作;
否则删去边权为 \(mx\) 的边(\(cut\)),加上\(u-v\)这条边(\(link\))。
那么查询就直接查链上最大值即可

浙公网安备 33010602011771号