LCT感性瞎扯

LCT。

I.LCT可以干什么?

动态树问题,
是近几年在OI中兴起的一种新型问题,
是一类要求维护一个有根树森林,
支持对树的分割,合并等操作的问题。

由Robert E Tarjan为首的科学家们
提出解决算法Link-Cut Tree,简称LCT。
                                    ——《百度百科》

算了,看看就行。

我们唯一知道的就是,你们的大毒瘤,那个发明强连通分量算法、HLPP、splay、离线LCA算法的Tarjan,他又跑来祸害咱们了!

附上高清大图

通俗点说,它支持你将一棵树一刀两断,也支持你把两棵树嫁接在一起(无性生殖?),还支持你从树上扯下来一条路径在上面搞事情。

或者换句话说,它就是(动态树剖+并查集+平衡树),再加上一堆奇妙的特性。

这么酷炫的吗!!!

好的那我们就开始吧。

II.前置芝士

splay:必修,特别是区间操作(fhq treap亦可)

树剖:选修

并查集:必修

线段树:必修

III.思想

我们常说的树链剖分,实际上是重链剖分。它还有两个兄弟,长链剖分实链剖分

这仨家伙有一个共同点:毒瘤可以把一棵树按照一些规则剁成几条不相交的路径。并且,这些路径上的点按照深度递增,不存在两个相同深度的点处在同一条路径上

例如重链剖分,就是每个点和它所有子节点中size最大的点在同一条链上。

而长链剖分,我没学过,咱也不敢瞎说

LCT运用的就是里面最灵活的实链剖分。我们常见的重链剖分,一旦剖分完成,是不可以再改变链的构成的,除非你暴力重构树。

但是,实链剖分,你就可以任意指定一个点的实边实儿子。当然咯,这么方便也不是没有坏处,在极端情况下,实链剖分是O(n)的,不像重链剖分是严格的O(logn)

因此,我们就将实链剖分和灵活的splay结合在一起,得到了均摊O(log)的LCT。

我们将每一条实链,扔进一颗splay中维护。这棵splay以深度为键值,深度越大排名越靠后。

当然咯,因为splay的结构,我们没有必要建出一棵一棵的splay出来。它还是可以保有一个完整的结构的。只不过,对于某棵splay的树根,它有一个父亲,那是原树中它的父亲;但是它的父亲尽管有这个儿子,但它却不认!

例如这个剖分:

在以E为根的那棵splay中,E认了一个父亲,D。但是,D却不认E这个儿子。它唯一承认的儿子,是F,它splay中的右儿子。当然,F也承认D这个父亲。

但是,splay不一定只有一种构造。也有可能,F是splay的根,而D是它的左儿子。这个时候,DF仍然互相承认,D还是不承认E,但是E承认D

因此,我们可以看出,不管哪个儿子,都是承认与父亲的关系的。但是,父亲只承认与实儿子的关系(尽管这个实儿子有可能在splay中成为了它的父亲甚至有可能离他很远很远)。

因此我们便解锁了LCT中一个最重要的性质:实边认父也认子,虚边认父不认子

IV.约定

ch:儿子,默认为x的。ch0为左儿子,ch1为右儿子。

fa:父亲,默认为x的。

rootx所在的splay的根。

ROOTx所在的原树的根(注意区分!!!)。

V.实现

access(x)

效果:将节点xROOT这条路径上的所有边设成实边(并自动把其他边设成虚边),并且扔到一个splay里面。

例:

怎么实现呢?

这个时候,我们就要回想起splay的经典操作splay:将一个x旋转到它所在的splayroot

这个时候,我们就可以向fa转移了(因为xroot,它的父亲一定处在不同的splay中)。

因为x的深度一定大于fa的深度,所以如果我们将fa也splay到它所属的splay的根,则x可以直接设成fa的右儿子(在设的同时,fa原本的右儿子,原来是实儿子,被直接断成了虚儿子,认父不认子,成了家庭餐具了;反而,原来认子不认父的x,现在fa重新承认了x这个儿子,当然x也一直承认着fa,因此这便成为了新的实边)。

看一下代码:

inline void access(int x){
	for(register int y=0;x;x=t[y=x].fa)
		splay(x),rson=y,pushup(x);
}

就是将x转到根;将x的右儿子设成y;将x变成新的y(在x的父亲splay时继续更新)。

至于这个pushup,是更新的函数,类似于线段树的pushup


makeroot(x)

效果:将x设为这棵树的ROOT(注意不是splay的root)!

先上代码:

inline void makeroot(int x){
	access(x),splay(x),REV(x);
}

access(x),将xROOT这条路径上的点打包成一个splay;

splay(x),将x设成这个splay的根。

但是,注意,因为x在整棵splay中深度最大,它此时并没有右儿子!

因此,在调用REV函数后,整棵splay翻转过来,x成为深度最浅的那一个!

因为这棵splay中深度最浅的是根节点,所以这个时候,x就成为了新根。


findroot(x)

效果:将xROOT的路径打包成一个splay,找到xROOT,并将ROOT转到root

先上代码:

inline int findroot(int x){
	access(x),splay(x);
	while(lson)pushdown(x),x=lson;
	splay(x);
	return x;
}

可以类比冰茶姬的找父亲操作。

access函数打包,splay函数转到root,那个while循环找到深度最浅的点,即为ROOT,再splay就把ROOT转到root(保证深度为log级别),并返回root

如果你把这些函数的目的都能想清楚,就没有问题了。


split(x,y)

效果:将xy路径上的所有点打包成一个splay,并令yroot

代码:

inline bool split(int x,int y){
	if(findroot(x)!=findroot(y))return false;
	makeroot(x),access(y),splay(y);
	return true;
}

如果xy不在同一颗树中,显然操作不合法,直接退出;

否则,把x设成新ROOT,这样子再access一下就抽出了ROOTy的路径。由于x就是ROOT,这就是xy的路径。然后再将y splayroot,保证深度。


link(x,y)

效果:在节点x,y间连一条边。

代码:

inline bool link(int x,int y){
	makeroot(x);
	if(findroot(y)==x)return false;
	t[x].fa=y;
	return true;
}

x转到ROOT;如果发现x,y已经连通(在同一棵子树中),忽略之;否则,将x的父亲设成y,相当于连一条虚边。


cut(x,y)

效果:断掉节点x,y间的边。

代码:

inline bool cut(int x,int y){
	makeroot(x);
	if(findroot(y)!=x||t[y].fa!=x||t[y].ch[0])return false;
	t[x].ch[1]=t[y].fa=0;
	pushup(x);
	return true;
}

这是仅次于access最重点的一个,也是仅次于access最难的那个。

先让xROOT;如果findroot(y)x,即它们不在同一棵树中,忽略之;如果t[y].fax,则显然它们之间不可能有边,因为findroot后,xy处于同一棵splay中,且x是根(不明白的马上转回去看findroot),则深度差为1的点对(x,y)必然紧贴;就算这两点都满足,如果y有左儿子,则xy仍然没有真正地紧贴,它们之间也不可能有边;这些都是需要忽略的。

之后,就是断边了。因为这时x为根,且x的深度小于y,因此x的右儿子一定就是y

VI.汇总

struct LCT{
	int fa,ch[2],val,sum;
	bool rev;
}t[100100];
inline int identify(int x){
	if(x==t[t[x].fa].ch[0])return 0;
	if(x==t[t[x].fa].ch[1])return 1;
	return -1;
}
inline void pushup(int x){
	t[x].sum=t[lson].sum^t[rson].sum^t[x].val;
}
inline void REV(int x){
	t[x].rev^=1,swap(t[x].ch[0],t[x].ch[1]);
}
inline void pushdown(int x){
	if(!t[x].rev)return;
	if(lson)REV(lson);
	if(rson)REV(rson); 
	t[x].rev=0;
}
inline void rotate(int x){
	register int y=t[x].fa;
	register int z=t[y].fa;
	register int dirx=identify(x);
	register int diry=identify(y);
	register int b=t[x].ch[!dirx];
	if(diry!=-1)t[z].ch[diry]=x;t[x].fa=z;
	if(b)t[b].fa=y;t[y].ch[dirx]=b;
	t[y].fa=x,t[x].ch[!dirx]=y;
	pushup(y),pushup(x);
}
inline void pushall(int x){//pushdown the nodes in the route from x to root
	if(identify(x)!=-1)pushall(t[x].fa);
	pushdown(x);
}
inline void splay(int x){//splay x to the root
	pushall(x);
	while(identify(x)!=-1){
		register int fa=t[x].fa;
		if(identify(fa)==-1)rotate(x);
		else if(identify(x)==identify(fa))rotate(fa),rotate(x);
		else rotate(x),rotate(x);
	}
}
inline void access(int x){//pull out all the nodes in the route from x to the ROOT, and form a splay
	for(register int y=0;x;x=t[y=x].fa)splay(x),rson=y,pushup(x);
}
inline void makeroot(int x){//make x the new ROOT
	access(x),splay(x),REV(x);
}
inline int findroot(int x){//find the ROOT of x, and make ROOT the root
	access(x),splay(x);
	while(lson)pushdown(x),x=lson;
	splay(x);
	return x;
}
inline bool split(int x,int y){//pull out the route from x to y and form a splay rooted y; if there isn't such route,return false
	if(findroot(x)!=findroot(y))return false;
	makeroot(x),access(y),splay(y);
	return true;
}
inline bool link(int x,int y){//link an edge between x and y; if they have already connected before, return false.
	makeroot(x);
	if(findroot(y)==x)return false;
	t[x].fa=y;
	return true;
}
inline bool cut(int x,int y){//cut the edge between x and y; if there isn't such edge, return false
	makeroot(x);
	if(findroot(y)!=x||t[y].fa!=x||t[y].ch[0])return false;
	t[x].ch[1]=t[y].fa=0;
	pushup(x);
	return true;
}

注意到几处与普通splay的不同:

1.在identify函数判断左儿子还是右儿子时,如果return1,则说明xroot

2.在splay时调用pushall函数,从上到下递归地pushdown

3.在rotate时,在多个可能为root或者该节点不存在的地方进行特判。

直接把那份模板加个头尾就能过。

posted @   Troverld  阅读(71)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示