FHQ版LCT教程

LCT but FHQ:

邪教 ——FHQ & LCT

小言:

  • 每条重链单独使用一个平衡树进行维护。
  • LCT:实链剖分与平衡树维护动态树的信息,并且同时维护多个动态树,所以全局是森林。
  • 每个节点认定一个实儿子,然后把这条边看成实边,其他为虚边,然后忽视虚边,维护一堆链,如果需要别的操作,虚实边可以随时转换。
  • 通过一个个连边建树来建图,开始连的都是虚边,在以后通过 \(\operatorname{access}\) 函数转实边,时间复杂度看似很大,实际不错。

概念:

  • 原树:原来的多叉树,方便观察原树形态。
  • 辅助树:把一整个平衡树看成一个整块,用虚边连起来这些整块,方便观察各个平衡树形态。一坨实链里是平衡树(二叉),然后虚边连了好几坨(多叉)

为了方便,我把下面的单个平衡树(一坨实链)全说成辅助树了。

结合代码讲解:

其实 LCT 就像个暴力算法,大胆去写,下面未解释区域是因为自己推导并不难。

注意:
\(\operatorname{link(x,y)}\)\(\operatorname{cut}(x,y)\) 如果不保证合法,就都要判合法。

……—— FHQ 定义

bool fx[300030];
struct treap {int l,r,fa,sui,xu,v,x;bool lan;} t[300030];
struct node {int x,y;};

\(\operatorname{pushdown}\) —— FHQ 下传懒标记

懒标记之翻转:用于 \(\operatorname{access}\),是必写的,同时也是为什么只能用 FHQ 和 splay 的原因。

void pushdown(int o)
{
	if(t[o].lan) swap(t[o].l,t[o].r),t[t[o].l].lan^=1,t[t[o].r].lan^=1,t[o].lan=0;
}

\(\operatorname{updata}\) —— FHQ 更新

这里的异或是题目要求异或和。

inline void updata(int o)
{
	t[o].x=t[o].v^t[t[o].l].x^t[t[o].r].x;
	if(t[o].l) t[t[o].l].fa=o;
	if(t[o].r) t[t[o].r].fa=o;
}

\(\operatorname{hebing}\) —— FHQ 合并

int hebing(int x,int y)
{
	if(!x||!y) return x+y;
	if(t[x].sui<t[y].sui) return pushdown(x),t[x].r=hebing(t[x].r,y),updata(x),x;
	else return pushdown(y),t[y].l=hebing(x,t[y].l),updata(y),y;
}

\(\operatorname{isroot}\) —— 是否是辅助树树根

bool isroot(int o) {return (t[t[o].fa].l!=o&&t[t[o].fa].r!=o)||!t[o].fa;}}

\(\operatorname{findroot}\) —— 找辅助树的根

这里的 fx 数组同时记录分裂时的方向,因为要按 o 的位置分裂,所以用分裂要先用这个函数。

int findroot(int o)
{
	top=0;
	while(!isroot(o)) fx[++top]=(t[t[o].fa].l==o),o=t[o].fa;
	return o;
}

\(\operatorname{findleft}\) —— 找当前辅助树中中序最小的

本质上是找辅助树在原树上深度最小的节点。

int findleft(int o)
{
	o=findroot(o),pushdown(o);
	while(t[o].l) o=t[o].l,pushdown(o);
	return o;
}

另一种写法直接省略此函数,直接用辅助树的根的 fa 数组为原树深度最小的节点的父亲(fa 数组是 FHQ 的),如果按此方法写,可以用 \(\operatorname{findroot}\) + fa 数组解决。

复杂度是一样的,因为 \(\operatorname{findroot}\)\(\operatorname{findleft}\) 复杂度一样。

但是 FHQ 貌似只能用 \(\operatorname{findleft()}\),splay 一般维护 fa 数组。

\(\operatorname{split}\) —— 改的 FHQ 分裂

在当前辅助树中把位置 \(\le o\) 的分裂为左,余为右。

关于如何找 \(o\) 的位置,我们在分裂前必须用 \(\operatorname{findroot}\) 来记录数组。

void split(int o,int &l,int &r)
{
	if(!top) return pushdown(o),l=o,r=t[o].r,t[o].r=0,updata(o),void();
	bool d=fx[top--];
	d^=t[o].lan,pushdown(o);
	if(d) r=o,split(t[o].l,l,t[o].l);
	else l=o,split(t[o].r,t[o].r,r);
	updata(o);
}

\(\operatorname{access}\) —— 把当前节点到根全部改为实链

int access(int o)
{
	int last=0;
	while(o)
	{
		int shang,xia;
		split(findroot(o),shang,xia);
		t[findleft(last)].xu=0;
		last=hebing(shang,last);
		t[findleft(xia)].xu=o;
		o=t[findleft(last)].xu;
	}
	return last;
}

\(\operatorname{root}\) —— 找当前节点所在原树的根

int root(int o) {return findleft(access(o));}

\(\operatorname{changroot}\) —— 把当前节点改为原树的根

void changeroot(int o) {t[access(o)].lan^=1;}

\(\operatorname{link}\) —— 连 x 到 y 的边

void link(int x,int y) {changeroot(x),t[x].xu=y;}

\(\operatorname{cut}\) —— 切断 x 到 y 的边

void cut(int x,int y) {changeroot(x),access(y),access(x),t[y].xu=0;}

\(\operatorname{query}\) —— 查询

这个代码是查询 x 到 y 的路径上的 xor 和:

int query(int x,int y) {return changeroot(x),access(y),t[findroot(y)].x;}

\(\operatorname{change}\) —— 修改

这个代码是将点 x 上的权值变成 y。

void change(int o,int v)
{
	int o1,o2;
	changeroot(o);
	split(findroot(o),o1,o2);
	t[o].v=v,hebing(o1,o2);
}

\(\operatorname{solve}\)

cin>>n>>m;
for(int i=1;i<=n;++i) cin>>t[i].v,t[i].x=t[i].v,t[i].sui=rand();
for(int i=1,op,x,y;i<=m;i++)
{
  cin>>op>>x>>y;
  if(op==0) cout<<query(x,y)<<'\n';
  else if(op==1&&root(x)!=root(y)) link(x,y);
  else if(op==2) cut(x,y);
  else if(op==3) change(x,y);
}

优秀的时间复杂度

splay 时间复杂度 \(O(n \log n)\),常数 \(≈11.4514\)
由于实现只要能维护翻转操作就行,所以可以选择 FHQ,FHQ 常数更小了,但是时间复杂度是 \(O(n \log ^2n)\),写个快读表现就和 splay 差不多了。

关于 splay LCT 时间复杂度的严谨证明
参照这个,我们发现访问虚边的势能分析没影响,但是 FHQ 无法均摊平衡树复杂度,所以时间复杂度要把 FHQ 的 \(O( \log n)\) 和跳链的 \(O( \log n)\),最后证明是 \(O(n \log ^2n)\)

LCT 应用:

1. 动态树问题。

无需多言。

2. 大部分树链剖分操作。

好处:

  • 在维护链用 splay 理论复杂度甚至更快,但是常数很大!树剖非理论复杂度很快。
  • 比树剖套数据结构的代码短。

坏处:

  • 维护子树难
    最好学的 LCT 动态树无法修改子树!令人伤心。
    至于怎么路径改子树查……
    蒟蒻我写 FHQ 维护写挂了,没调出来,思路大概是每个点用 set 维护虚子树信息,然后查询把所有儿子都变成虚儿子后查自己和 set 的信息并。
    我知道我的说法并不好,建议大家另找别的博客学习维护子树,抱歉。

3. 支持删除边的并查集(需保证无环)。

找根操作。

4. 可在线维护边权。(最小生成树之类)。

看题理解吧。

5. 在线加边维护边双联通分量。

\(\operatorname{link}\) 的时候,如果发现 \(x\)\(y\) 在一个树里,有环,那肯定要开始缩了,锁点的时候,把这个环上的所有点全部锁到这个辅助树的根节点,因为可以把根节点的 \(fa\) 设为原树父亲,比较特殊,所以缩这里。

根节点为标志节点,用并查集代表当前节点被锁到哪去了,然后把当前环上的点提出来一个辅助树,然后递归这个辅助树更新并查集为标志节点,最后断开标志节点与子树的连接,同时在需要访问原树的父亲节点的时候,需要套用并查集,因为这个点的父亲可能已经被缩了。

函数递归缩点:

void del(int x,int y) {if(x) bcj[x]=y,del(lc,y),del(rc,y);}

找爸爸:

\(fa(x)←find(fa_x)\)

6. 维护树上染色联通块。

看题理解吧

7. 求 LCA

inline int access(int x)
{
    int y = 0;
    while (x) { splay(x); Rs(x) = y; pushup(x); x = Fa(y = x); }
    return y;
}
int lca = (access(u), access(v));

我们拉完 \(u\) 到根的实链后再拉 \(v\) 的。则最后一个需要调整的实链顶对应的结点自然是 \(lca(u,v)\)

在动态树上,你想写 LCA:

忍住别写:

  • 倍增 LCA:动态树上不可离线。
  • 在动态树上跳链 LCA:在动态树上复杂度是均摊的,普通跳链复杂度不对。(\(\operatorname{access}\) 其实也是跳链,但是它一路上变了好多实链,这是与其不一样的地方)。

LCT 的痛点:

1. 无法子树修改

只有 TopTree 可以子树修改。

2. 原树根不固定

你不要自以为是地 \(\operatorname{access}\)

std

#include<bits/stdc++.h>
using namespace std;
int n,m,top;
bool fx[300030];
struct treap {int l,r,fa,sui,xu,v,x;bool lan;} t[300030];
void pushdown(int o)
{
	if(t[o].lan) swap(t[o].l,t[o].r),t[t[o].l].lan^=1,t[t[o].r].lan^=1,t[o].lan=0;
}
void updata(int o)
{
	t[o].x=t[o].v^t[t[o].l].x^t[t[o].r].x;
	if(t[o].l) t[t[o].l].fa=o;
	if(t[o].r) t[t[o].r].fa=o;
}
int hebing(int x,int y)
{
	if(!x||!y) return x+y;
	if(t[x].sui<t[y].sui) return pushdown(x),t[x].r=hebing(t[x].r,y),updata(x),x;
	else return pushdown(y),t[y].l=hebing(x,t[y].l),updata(y),y;
}
bool isroot(int o) {return (t[t[o].fa].l!=o&&t[t[o].fa].r!=o)||!t[o].fa;}
int findroot(int o)
{
	top=0;
	while(!isroot(o)) fx[++top]=(t[t[o].fa].l==o),o=t[o].fa;
	return o;
}
void split(int o,int &l,int &r)
{
	if(!top) return pushdown(o),l=o,r=t[o].r,t[o].r=0,updata(o),void();
	bool d=fx[top--];
	d^=t[o].lan,pushdown(o);
	if(d) r=o,split(t[o].l,l,t[o].l);
	else l=o,split(t[o].r,t[o].r,r);
	updata(o);
}
int findleft(int o)
{
	o=findroot(o),pushdown(o);
	while(t[o].l) o=t[o].l,pushdown(o);
	return o;
}
int access(int o)
{
	int last=0;
	while(o)
	{
		int shang,xia;
		split(findroot(o),shang,xia);
		t[findleft(last)].xu=0;
		last=hebing(shang,last);
		t[findleft(xia)].xu=o;
		o=t[findleft(last)].xu;
	}
	return last;
}
int root(int o) {return findleft(access(o));}
void changeroot(int o) {t[access(o)].lan^=1;}
void link(int x,int y) {changeroot(x),t[x].xu=y;}
void cut(int x,int y) {changeroot(x),access(y),access(x),t[y].xu=0;}
int query(int x,int y) {return changeroot(x),access(y),t[findroot(y)].x;}
void change(int o,int v)
{
	int o1,o2;
	changeroot(o);
	split(findroot(o),o1,o2);
	t[o].v=v,hebing(o1,o2);
}
void read(int &x)
{
	x=0;
	char c=getchar();
	while(c<'0'||c>'9') c=getchar();
	while(c>='0'&&c<='9') x=(x<<3)+(x<<1)+(c^48),c=getchar();
}
int main()
{
	cin>>n>>m;
	for(int i=1;i<=n;++i) read(t[i].v),t[i].x=t[i].v,t[i].sui=rand();
	for(int i=1,op,x,y;i<=m;i++)
	{
		read(op),read(x),read(y);
		if(op==0) printf("%d\n",query(x,y));
		else if(op==1&&root(x)!=root(y)) link(x,y);
		else if(op==2) cut(x,y);
		else if(op==3) change(x,y);
	}
	return 0;
}
posted @ 2025-09-17 22:21  _a1a2a3a4a5  阅读(20)  评论(0)    收藏  举报