(笔记)动态树(LCT)

动态树与 LCT

什么是 LCT?

这是一种能用于处理有根森林中各棵树的动态操作的数据结构。它能以 \(O(n\log n)\) 的优秀均摊复杂度完成操作。

具体地,LCT 的核心思想是“原树实链从上到下,对应 Splay 树从左到右”。受到树剖的启发,先把树动态地分成多条链,其中边分为实链和虚链,可以理解为实链是双向的,而虚链是单向的,只有儿子连向父亲的边。LCT 实际上就是把原树通过一些技巧转化为能存储的一颗辅助树(Splay),把所有节点存在多个以深度为 \(key\) 的二叉搜索树(BST)中,BST 内节点通过实边两两相连,并且这些 BST 通过虚边同样相连。

这里我们采用深度分析法,建树过程将节点深度视为要维护的 BST 中的 \(key\)(如果你不了解这种平衡树的维护方式:(简记)FHQ Treap)。然后类似笛卡尔树(本质都是 BST),树的中序遍历就可以得到一个深度递增的序列。

具体地,每个节点 \(u\) 的右儿子代表的子树(如果有的话)就类似重链剖分中重儿子所在的子树(注意这里强调子树,因为真正的重儿子,或者 LCT 中实儿子,可能不与 \(u\) 直接相连),每次从 \(y\) 开始下往上找的过程中建立实链,每经过一条虚链就会丢掉原先实儿子,然后把 \(y\) 子树接在上面。注意我们不希望 LCT 的平均深度变得太大,所以经过虚链时要先 \(splay(x)\) 然后再将 \(y\) 子树接在 \(x\) 上。

知道这个 Splay 是怎么来的,接下来要怎么维护呢?

如何维护 LCT?

这里有几种操作,根据旋转 Treap 和 Splay 的原理,在改变节点位置的时候,这两种维护方式都不会改变 \(key\) 的中序遍历固有顺序(当然改变位置肯定会不符合大根堆性质,但是不需要管这个)。

\(rotate(x)\space splay(x)\)

Splay 经典操作,将辅助树上的 \(x\) 旋转到当前树的根上,这个操作不会影响原树,而且可以改善二叉树形态。具体来说,就是保证该树的中序遍历不变(针对 \(key\))的情况下改变父子关系,把 \(x\) 往上提一级,直到当前 Splay 树根为止(上面可能仍有虚边)。

void rotate(int x){
	int y=t[x].fa;
	int z=t[y].fa;
	bool k=(t[y].ch[1]==x);
	if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
	t[x].fa=z;
	t[y].ch[k]=t[x].ch[k^1];
	if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
	t[y].fa=x;
	t[x].ch[k^1]=y;
	pushup(y);
}
void splay(int x){
	int y,z;
	push(x);
	while(!isRoot(x)){
		y=t[x].fa,z=t[y].fa;
		if(!isRoot(y))
			(t[z].ch[0]==y)^(t[y].ch[0]==x)?rotate(x):rotate(y);
		rotate(x);
	}
	pushup(x);
}

具体地,\(rotate(x)\) 每次编码时画图会更好理解,就是 zig 和 zag 操作的简化版。

关于 \(splay(x)\) 内部的分讨,这是因为这一步能改善平衡性。设想节点 \(x,y,z\),满足 \(y\) 对于 \(z\)\(x\) 对于 \(y\) 是同一个方向的儿子(一条链)。这样如果每次只提 \(x\),完成左右操作时就还是一条链的状态,非常不美观。但是如果遇到这种情况先提 \(y\) 再提 \(x\),就可以一定程度上减少深度,维护平衡性,具体过程请读者自行模拟。

\(access(x)\)

在原树上建一条实链,使得链头是 \(root\),链尾是 \(x\)。如何实现?

只需要在下一个实链把链尾提到实链链头,把这个实链的链头接到下一个实链链头的右儿子即可(这样做是为了改善平衡性,避免深度过大)。如果这个实链为空,那么下一个实链链头的右儿子也为空,因为链尾得是 \(x\)

为什么要是右儿子?因为辅助树上虚链代表了原树中的深度偏序关系,如果一个节点通过虚链是另一个节点的父亲,那么这个节点深度肯定比另一个节点浅,显然就应该接在右儿子上。

void access(int x){
	for(int child=0;x;child=x,x=t[x].fa){
		splay(x);
		rc=child;
		pushup(x);
	}
}

\(makeroot(x)\)

这个操作能够改变原树形态。注意,这里的改变形态是指改变根节点,但和换根 DP 一样,如果将新树和原树都看做无根树,那么它们实际上应该是等价的(同构的)。

该操作有三步:\(access(x)\rightarrow splay(x)\rightarrow reverse(x)\)

第一步和第二步是为了创造辅助树上以 \(x\) 为根的 Splay,第三步则是主要操作。想象一下树上如果要将 \(x\) 提为根,那么 \(root\rightarrow x\) 这段路径上的节点深度的递增顺序都应该变成递减顺序。理解了这一步以后,我们只需要在 Splay 树上把实链上所有左右儿子都交换就可以完成这种关系的改变。

void makeroot(int x){
	access(x);
	splay(x);
	reverse(x);
}

具体实现

主要操作如上,剩下的就是根据性质变形得到的求解问题的方法。注意如果要查询路径 \(x\rightarrow y\) 上的信息,那么需要执行一次 \(split(x,y)\) 操作,在原树上以 \(x\) 为起点,\(y\) 为终点生成一条实链,然后路径上的信息就是这条实链上的所有信息了。

下面给出P3690 【模板】动态树(LCT)的完整代码,具体细节参照代码:

#include<bits/stdc++.h>
using namespace std;
const int N=3e5+5;
struct Node{int ch[2],sum,val,tg,fa;}t[N];
#define lc t[x].ch[0]
#define rc t[x].ch[1]
bool isRoot(int x){
	int g=t[x].fa;
	return t[g].ch[0]!=x&&t[g].ch[1]!=x;
}
void pushup(int x){
	t[x].sum=t[x].val^t[lc].sum^t[rc].sum;
}
void reverse(int x){
	if(!x)return ;
	swap(lc,rc);
	t[x].tg^=1;
}
void pushdown(int x){
	if(t[x].tg){
		reverse(lc);
		reverse(rc);
		t[x].tg=0;
	}
}
void push(int x){
	if(!isRoot(x))push(t[x].fa);
	pushdown(x);
}
void rotate(int x){
	int y=t[x].fa;
	int z=t[y].fa;
	bool k=(t[y].ch[1]==x);
	if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
	t[x].fa=z;
	t[y].ch[k]=t[x].ch[k^1];
	if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
	t[y].fa=x;
	t[x].ch[k^1]=y;
	pushup(y);
}
void splay(int x){
	int y,z;
	push(x);
	while(!isRoot(x)){
		y=t[x].fa,z=t[y].fa;
		if(!isRoot(y))
			(t[z].ch[0]==y)^(t[y].ch[0]==x)?rotate(x):rotate(y);
		rotate(x);
	}
	pushup(x);
}
void access(int x){
	for(int child=0;x;child=x,x=t[x].fa){
		splay(x);
		rc=child;
		pushup(x);
	}
}
void makeroot(int x){
	access(x);
	splay(x);
	reverse(x);
}
void split(int x,int y){
	makeroot(x);
	access(y);
	splay(y);
}
void link(int x,int y){
	makeroot(x);
	t[x].fa=y;
}
void cut(int x,int y){
	split(x,y);
	if(t[y].ch[0]!=x||rc)return ;
	t[y].ch[0]=t[x].fa=0;
	pushup(y);
}
int findroot(int x){
	access(x);splay(x);
	while(lc)pushdown(x),x=lc;
	return x;
}
#undef lc
#undef rc
int n,m;
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)
		cin>>t[i].val,t[i].sum=t[i].val;
	while(m--){
		int opt,a,b;
		cin>>opt>>a>>b;
		if(!opt)split(a,b),cout<<t[b].sum<<'\n';
		else if(opt==1){if(findroot(a)!=findroot(b))link(a,b);}
		else if(opt==2)cut(a,b);
		else splay(a),t[a].val=b;
	}
	return 0;
}

Q & A

  • Q:为什么 \(access(x)\) 的过程破坏了链的形态(可能有一个节点同时存在左右儿子)却不影响后续操作?
  • A:并没有破坏链的形态,该问题属于结构混淆,辅助树上的一棵 Splay 维护的就是一条实链中的所有节点。
  • Q:为什么 \(makeroot(x)\) 不可以只将 \(root\) 的左右儿子互换,而是要全体 \(reverse\)
  • A:参照前文讲解,手动模拟原树提根,只有深度都倒过来才不会影响原树结构。
  • Q:为什么 \(cut(x,y)\) 操作中 \(split(x,y)\) 操作中 \(splay(y)\) 可能在上升过程中先 \(rotate\) 其父节点再 \(rotate\) 它自己导致它的左儿子不是 \(x\) 而不用特殊判断?
  • A:这也是直接写 Splay 和 LCT 的一大区别,因为 \(cut\) 操作保证了 \(x,y\) 是原树上相邻节点,所以建实链,链上 \(x,y\) 之间必然不存在其他节点,也就不会有上面的问题了。
  • Q:为什么 \(findroot(x)\) 操作可以直接判断是否存在左儿子再 pushdown(x)
  • A:?pushdown(x) 更新的是 \(x\) 的儿子的信息,和 \(x\) 没关系。
  • Q:有哪些容易写错的地方?
  • A:\(rotate(x)\) 内判断 \(y\) 是否为根、查询路径信息 \(split(x,y)\) 后没有意识到 \(x\) 只是在 \(y\) 左子树内而不一定是 \(y\) 的左儿子,直接返回了 \(x\) 的信息等。

LCT 经典应用

LCT 维护子树信息(虚子树)

题解 P4219 【[BJOI2014]大融合】

本题有非常好的题解解释有关此 trick 的种种细节,故不在叙述。一个容易忽略的点,在模板问题的 \(link(x,y)\) 操作中,我们把 \(x\) 接到 \(y\) 的一个虚儿子上,这并不会影响 \(y\) 的任何维护值所以可以不执行makeroot(y)。但是本题则不然,如果不执行,那么 \(y\) 及其所有祖先的信息都没有得到及时更新,所以需要多写一步,这是值得注意的。

定根 LCT

无 makeroot 这一特点建立在原树的形态结构唯一的基础上。如果一个点在全局修改中可能有不止一个父亲,那么就不符合该条件。经典应用是SP16549 QTREE6 - Query on a tree VI的 LCT 解法。在该问题中,由于建模的独特性,致使不能改变原树形态,所以可以采取无 makeroot 的 LCT 并且不用担心父亲节点矛盾的问题。

边权 LCT 动态维护最小生成树

边权 LCT 解决方法:将每条边多加一个虚点,边权赋在点权上连接即可。(注意\(cut\) 操作中也需要删除虚点与实点的边)。

动态维护最小生成树:每次查询动态树链信息,找到最大边权,如果替换掉完全更优那么先断掉原来更劣的边,然后直接连上新边。

P4172 [WC2006] 水管局长

考虑到删除困难,加入容易,时光倒流维护最小生成树即可。

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+5,INF=1e9+1;
int n,m,q;
struct edge{int u,v,w;}op[N];
struct node{int ch[2],val,mx,id,fa;bool tg;}t[N*2];
inline bool isRoot(int x){
	int f=t[x].fa;
	return t[f].ch[0]!=x&&t[f].ch[1]!=x;
}
void pushup(int p){
	t[p].mx=max(t[p].val,max(t[t[p].ch[0]].mx,t[t[p].ch[1]].mx));
	if(t[p].mx==t[p].val)t[p].id=p;
	else if(t[p].mx==t[t[p].ch[0]].mx)t[p].id=t[t[p].ch[0]].id;
	else t[p].id=t[t[p].ch[1]].id;
}
void mktag(int p){
	if(!p)return ;
	t[p].tg^=1;
	swap(t[p].ch[0],t[p].ch[1]);
}
void pushdown(int p){
	if(t[p].tg){
		mktag(t[p].ch[0]);
		mktag(t[p].ch[1]);
		t[p].tg=0;
	}
}
void push(int p){
	if(!isRoot(p))push(t[p].fa);
	pushdown(p);
}
void rotate(int x){
	int y=t[x].fa,z=t[y].fa;
	if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
	t[y].fa=x;t[x].fa=z;
	bool k=(t[y].ch[1]==x);
	if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
	t[y].ch[k]=t[x].ch[k^1];
	t[x].ch[k^1]=y;
	pushup(y);
}
void splay(int x){
	int y,z;
	push(x);
	while(!isRoot(x)){
		y=t[x].fa,z=t[y].fa;
		if(!isRoot(y))
			((t[z].ch[1]==y)^(t[y].ch[1]==x))?rotate(x):rotate(y);
		rotate(x);
	}
	pushup(x);
}
void access(int x){
	for(int child=0;x;child=x,x=t[x].fa){
		splay(x);
		t[x].ch[1]=child;
		pushup(x);
	}
}
void makeroot(int x){
	access(x);
	splay(x);
	mktag(x);
}
int findroot(int x){
	access(x);splay(x);
	while(t[x].ch[0])pushdown(x),x=t[x].ch[0];
	return x;
}
void split(int x,int y){
	makeroot(x);
	access(y);
	splay(y);
}
void link(int x,int y){
	makeroot(x);
	t[x].fa=y;
}
void cut(int x,int y){
	split(x,y);
	assert(t[y].ch[0]==x);
	t[y].ch[0]=0,t[x].fa=0;
	pushup(y);
}
pair<int,int>query(int x,int y){
	if(findroot(x)!=findroot(y))return make_pair(INF,0);
	split(x,y);
	int pos=y;
	return make_pair((t[y].mx?t[y].mx:INF),t[y].id);
}
map<int,map<int,int> >vis;
map<int,map<int,bool> >mp;
int X[N],Y[N],W[N],ans[N];
void Link(int w){
	pair<int,int>res=query(X[w],Y[w]);
	if(res.first>W[w]&&res.second>n){
		int id=res.second-n;
		cut(X[id],id+n);
		cut(id+n,Y[id]);
	}
	if(res.first>W[w]){
		link(w+n,X[w]);
		link(Y[w],w+n);
	}
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m>>q;
	for(int i=1;i<=m;i++)
		cin>>X[i]>>Y[i]>>W[i],
		t[i+n].mx=t[i+n].val=W[i],
		t[i+n].id=i+n,
		vis[X[i]][Y[i]]=vis[Y[i]][X[i]]=i;
	for(int i=1;i<=q;i++){
		cin>>op[i].w>>op[i].u>>op[i].v;
		if(op[i].w==2)mp[op[i].u][op[i].v]=mp[op[i].v][op[i].u]=1;
	}
	for(int i=1;i<=m;i++)
		if(!mp[X[i]][Y[i]])
			Link(i);
	for(int i=q;i>=1;i--){
		if(op[i].w==2)Link(vis[op[i].u][op[i].v]);
		else ans[i]=query(op[i].u,op[i].v).first;
	}
	for(int i=1;i<=q;i++)
		if(op[i].w==1)cout<<ans[i]<<'\n';
	return 0;
}

P2387 [NOI2014] 魔法森林

最小生成树维护:比上一题多个扫描线,这个题对 \(a,b\) 中任意一维做一个扫描线,然后另一维直接动态最小生成树,记录路径信息是路径上边权最大的点及其编号,如果替换更优那么直接替换,然后找到 \(1\)\(m\) 可行路径上最大边权 \(+\) 扫描线所处位置就是本次答案,将所有答案取 \(\min\) 即可。

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=5e4+5,M=1e5+5,INF=1e9;
struct node{int ch[2],fa,mx,id,val;bool rev;}t[N+M];
inline bool isRoot(int x){
	int f=t[x].fa;
	return t[f].ch[0]!=x&&t[f].ch[1]!=x;
}
void pushup(int x){
	t[x].mx=max(t[t[x].ch[0]].mx,t[t[x].ch[1]].mx);
	t[x].mx=max(t[x].mx,t[x].val);
	if(t[x].mx==t[x].val)t[x].id=x;
	else if(t[x].mx==t[t[x].ch[0]].mx)t[x].id=t[t[x].ch[0]].id;
	else t[x].id=t[t[x].ch[1]].id;
}
void rev(int x){
	if(!x)return ;
	t[x].rev^=1;
	swap(t[x].ch[0],t[x].ch[1]);
}
void pushdown(int x){
	if(t[x].rev){
		rev(t[x].ch[0]);
		rev(t[x].ch[1]);
		t[x].rev=0;
	}
}
void push(int x){
	if(!isRoot(x))push(t[x].fa);
	pushdown(x);
}
void rotate(int x){
	int y=t[x].fa,z=t[y].fa;
	if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
	t[x].fa=z,t[y].fa=x;
	bool k=(t[y].ch[1]==x);
	if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
	t[y].ch[k]=t[x].ch[k^1];
	t[x].ch[k^1]=y;
	pushup(y);
}
void splay(int x){
	int y,z;
	push(x);
	while(!isRoot(x)){
		y=t[x].fa,z=t[y].fa;
		if(!isRoot(y))
			((t[z].ch[1]==y)^(t[y].ch[1]==x))?rotate(x):rotate(y);
		rotate(x);
	}
	pushup(x);
}
void access(int x){
	for(int child=0;x;child=x,x=t[x].fa){
		splay(x);
		t[x].ch[1]=child;
		pushup(x);
	}
}
void makeroot(int x){
	access(x);
	splay(x);
	rev(x);
}
int findroot(int x){
	access(x);splay(x);
	while(t[x].ch[0]||t[x].ch[1]){
		pushdown(x);
		if(t[x].ch[0])x=t[x].ch[0];
		else break;
	}
	return x;
}
void cut(int x,int y){
	makeroot(x);
	access(y);splay(y);
	if(t[y].ch[0]==x){
		t[y].ch[0]=0;
		t[x].fa=0;
		pushup(y);
	}
}
void link(int x,int y){
	makeroot(x);
	t[x].fa=y;
}
pair<int,int>query(int x,int y){
	if(findroot(x)!=findroot(y))
		return make_pair(INF,0);
	makeroot(x);
	access(y);splay(y);
	return make_pair(t[y].mx,t[y].id);
}
int n,m,X[N+M],Y[N+M],ans=INF,cnte,ncnt;
struct edge{int u,v,a,b;}e[M];
bool cmp(edge x,edge y){return x.b<y.b;}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;ncnt=n;
	for(int i=1;i<=n;i++)
		t[i].id=i;
	for(int i=1;i<=m;i++)
		cin>>e[i].u>>e[i].v>>e[i].a>>e[i].b;
	sort(e+1,e+1+m,cmp);
	for(int i=1;i<=m;i++){
		if(e[i].u==e[i].v)continue;
		pair<int,int>res=query(e[i].u,e[i].v);
		if(res.first<=e[i].a)continue;
		if(res.second){
			cut(X[res.second],res.second);
			cut(res.second,Y[res.second]);
			cnte--;
		}
		t[++ncnt].val=e[i].a;
		t[ncnt].mx=t[ncnt].val;
		t[ncnt].id=ncnt;
		X[ncnt]=e[i].u,Y[ncnt]=e[i].v;
		link(e[i].u,ncnt);link(ncnt,e[i].v);
		cnte++;
		if(findroot(1)==findroot(n))ans=min(ans,query(1,n).first+e[i].b);
	}
	cout<<(ans>=INF?-1:ans);
	return 0;
}

全局平衡二叉树(静态 LCT)

题解 P4115 【Qtree4】

科技。该结构本质上是重链剖分+实链剖分的应用。

对树进行重链剖分。对于每条重链,每次 build 求它的加权重心(每个点的点权是轻儿子 \(siz+1\)),然后递归建树。

每次从儿子跳到父亲的子树大小就至少翻倍,可以稍微分类讨论一下。如果经过一条轻边,那么子树大小至少翻倍。如果经过一条重链内部的边(BST 上的边),那么由于每次找重心进行 build,子树大小也是翻倍的。这样我们的树高就是 \(O(\log n)\) 的。

全局平衡二叉树可以帮我们解决许多问题,让我们可以摆脱树剖 \(O(\log ^2 n)\) 的单次操作,变成 \(O(\log n)\)

P4751 【模板】动态 DP(加强版)

但是我不将原树二叉化,只纯朴地建一颗全局平衡二叉树又如何呢?我们在 Qtree4 中,由于维护信息的特殊性,要额外在树上开一些线段树节点。但是一般情况下,如本题中不需要按一定秩序开出这些节点,直接朴素建树即可。需要注意的是,递归轻儿子建树时与上面不同,每次 dfs 前都要重置链首(因为这个调了 1h)。

关于调错

了解 LCT 代码错误的最好方式是知道 Splay 树的构造。因此,不妨写一个打印函数帮助你更好地调试。

void pr(int x){
	if(lc)pr(lc);
	cout<<x<<' ';
	if(rc)pr(rc);
}
void print(int x){
	pr(x);
	cout<<'\n';
}

当然啦实际上如果你的 LCT 没出什么离谱大锅,多数时候程序运行卡住了都是因为连边出现了环,可以尝试在每个 \(link(x,y)\)\(cut(x,y)\) 操作中输出连边删边的提示然后判断哪里出了问题。

关于 tag(血的教训)

P4332 [SHOI2014] 三叉神经树

本题按照标准的 \(O(n\log n)\) 的思路,需要在 Splay 每个节点维护两个信息。第一个是当前子树内深度最深且 \(val\) 不为 \(1\) 的点,第二个是当前子树内深度最深且 \(val\) 不为 \(2\) 的点。这两个信息在打 tag 修改的时候需要进行交换。 需要注意的是,本题不能直接使用覆盖 tag,这样可能会导致被两个不同的 tag 同时覆盖以后本来应该没有任何效果,但是 pushdown 判断自动交换了这两个信息。

解决方法是用一个反转 tag(用异或维护信息),在翻转两次时自动视为没有翻转。

本题调错耗时 3days。

posted @ 2025-04-22 12:27  TBSF_0207  阅读(88)  评论(0)    收藏  举报