【10】LCT学习笔记

前言

老早就想写了,但是一直抽不出时间。借助集训的契机把这篇学习笔记写出来。

时间跨度比较长,可能有一些代码不是现在的码风,我会标注出来的。

长文警告:本文一共 \(1174\) 行,请合理安排阅读时间。

前置知识:【8】平衡树学习笔记 中的 Splay 部分。

UPD on \(2025.8.8\):增加了一道 LCT 好题。

LCT

LCT 是一种动态树,能在 \(O(\log n)\) 的复杂度内支持加边删边,换根以及大部分路径信息。LCT 是一种强路径弱子树的数据结构,遇到子树类问题还是需要考虑 DFS 序拍成序列或树剖。时间复杂度我不会证,所以就不证了,反正用起来实际效果和 \(O(n\log^2 n)\) 没啥区别。

LCT 可以维护森林,它将每一棵树分成若干条实链,同一实链节点之间通过实边相连,不同实链之间通过虚边相连。以下根均指节点对应的树的根。

每个点连向儿子的边中有且仅有一条实边,某条虚边变成实边时这条实边会变成虚边。

同一实链的节点通过一棵 Splay 维护,Splay 满足二叉搜索树的性质的关键字为节点的相对深度。即同一实链中深度最浅的点在最左子树,深度最深的点在最右子树。

Splay 的根节点的父节点为空,我们考虑利用这个空位。在 LCT 中,一条实链的 Splay 的根节点的父节点记录的是这条实链中最浅的节点在原树中的父节点。这固定了实链的位置。

上面这种特殊的记录方式对应了一条虚边。因此,实边的特点说节点父子相认,而虚边的特点是儿子认父亲,但父亲不认儿子。

判断左右儿子

由于旋转时需要确定方向,所以我们需要知道一个节点是其父亲的左儿子还是右儿子。这个过程可以单独写一个函数。

int wh(int x)
{
	return ch[fa[x]][1]==x;
}

判断是否为根

利用 LCT 中 Splay 的根节点儿子认父亲,但父亲不认儿子的特性,如果一个节点不是它父亲的任何一个儿子,那么这个节点就是某个 Splay 的根。

bool isroot(int x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

旋转

LCT 中的旋转与 Splay 的旋转本质相同,但需要特判如果 \(x\) 的爷爷 \(z\) 不是这条链上的点,即 \(y\) 是这个 Splay 的根节点,此时不能更新 \(z\) 的儿子,但是 \(x\) 的父亲照常更新,因为儿子认父亲,但父亲不认儿子。

void rotate(int x)
{
	int y=fa[x],z=fa[y],k=wh(x);
	if(!isroot(y))ch[z][wh(y)]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

Splay

和正常 Splay 的操作一样,把某个节点旋转到 Splay 的根。注意特判 \(y\) 为根的情况以及 pushdown 下放标记。

void splay(int x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	while(!isroot(x))
	   {
	   	int y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

打通到根

把一个点 \(x\) 到根的路径打通为一条实链。我们一条实链一条实链地跳,设 \(x\) 是从上一条实链的根节点 \(t\) (初始状态为 \(0\) 表示空)跳一次 \(fa\) 过来的,先把 \(x\) 转到所在 Splay 的根,然后令 \(ch[x][1]=t\)。这个赋值操作有两个意义,一个是断开原来的实边,连上虚边。因为原本的 \(ch[x][1]\) 的父亲依旧是 \(x\),但是 \(x\) 已经不认它了,满足虚边的特点。另一个是连接新的实边,\(x\)\(t\)\(fa\) 得到的,父亲是 \(x\),现在 \(x\) 也认它了,满足实边的特点。

并且这个 \(ch[x][1]\) 也满足了深度的二叉搜索树性质。\(x\)\(t\)\(fa\) 得到的,合并到同一个链中 \(t\) 的相对深度更深,所以是 \(x\) 的右儿子。并且原本的 \(ch[x][1]\) 以及其子树是原本实链中比 \(x\) 深度更深的点,因为 \(t\) 连了实边,那么比 \(x\) 深度更深的点都会因为 \(x\) 连向 \(ch[x][1]\) 子树中最浅的边,即 \(x\) 的原实链中的直接儿子的连边变为虚边断开,在代码中表现为 \(ch[x][1]\)\(t\) 覆盖。

记得 pushup

void access(int x)
{
	for(int t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

换根

\(x\) 换成树根。考虑直接把 \(x\) 打通到根,然后把 \(x\) 转到对应 Splay 的根节点,把整个 Splay 打上翻转标记。和【8】平衡树学习笔记 中的例题 \(2\) 一样。

因为把 \(x\) 换成树根之后,对其他实链节点的相对深度以及实链之间连接的节点没有影响,唯一有影响是 \(x\) 所处的实链。由于 access 操作打通到根是 \(x\) 是最深的节点,原本的根是最浅的节点,所以把整条链翻转一下就把根换成了 \(x\)

void makeroot(int x)
{
	access(x),splay(x),re[x]^=1;
}

由于时间久远,这个翻转标记并不是一般翻转标记的打法,正常的翻转标记应该是例题 \(2\) 中的写法。

提取路径

先把 \(x\) 换成根,再把 \(y\) 打通到根,那 \(y\) 这条实链不就是 \(x\)\(y\) 之间的路径吗?操作或查询时记得把 \(y\) 转到根节点,不然不是对整个 Splay 进行操作。

int query(int x)
{
    makeroot(x),access(y),splay(y);
    return v[y];
}

找根

找节点 \(x\) 所在的子树的最浅的节点,如果 \(x\) 是整个 Splay 的根就是找这条实链的根节点。把 \(x\) 转到 Splay 的根,一直走左子树就是深度最浅的点。记得最后 Splay 保证复杂度。

int findroot(int x)
{
    splay(x);
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

连边

连接节点 \(x,y\)。先把根换为 \(x\),再把 \(y\) 打通到根,然后把 \(y\) 转到 Splay 的根。然后把 \(x\) 的父节点连向 \(y\),相当于向 \(y\) 连了一条虚边,整个树的根变为 \(y\)

此时如果 \(x,y\) 已经连通,那 \(x,y\) 肯定在同一条实链上,查 \(y\) 所在的实链的根一定为 \(x\),就不能修改 \(x\) 的父节点。而 \(y\) 在 Splay 的根节点,所以直接 findroot 就能判断。

void link(int x,int y)
{
	makeroot(x),access(y),splay(y);
	if(findroot(y)!=x)fa[x]=y;
}

删边

删除节点 \(x,y\) 之间的连边。先把根换为 \(x\),再把 \(y\) 打通到根,然后把 \(y\) 转到 Splay 的根。\(x\) 为根,在链中深度最浅,\(y\)\(x\) 如果有直连边,那么 \(y\) 是深度次浅的节点。现在 \(y\) 是 Splay 的根,所以 \(ch[y][0]\) 只能是 \(x\)。把 \(x\) 的父节点和 \(ch[y][0]\) 指向空,就删掉了这条边。记得 pushup

特别的,如果 \(ch[ch[y][0]][1]\) 不为空,证明 \(x\)\(y\) 只是连通,并没有直连边,所以还需要再判一下。

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

例题

例题 \(1\)

P3690 【模板】动态树(LCT)

LCT 模板题,Splay 每个节点维护子树内所有点的权值的异或和,查询时提取路径后取 Splay 的根的信息即可,不多赘述。

再强调一遍,这里的懒标记定义有问题,最好按照例题 \(2\) 的写法,打上标记时立即更新当前节点的记录的状态,不然很容易出锅。

#include <bits/stdc++.h>
using namespace std;
long long n,m,x,y,c,a[400000],siz[400000],v[400000],ad[400000],mu[400000],ch[400000][2],fa[400000],re[400000],st[400000],top=0,cnt=0;
const long long mod=51061;
char op;
void pushup(long long x)
{
	siz[x]=(siz[ch[x][0]]+siz[ch[x][1]]+1)%mod;
	v[x]=(v[ch[x][0]]+v[ch[x][1]]+a[x])%mod;
}

void pushdown(long long x)
{
	if(re[x])re[ch[x][0]]^=1,re[ch[x][1]]^=1,swap(ch[x][0],ch[x][1]),re[x]=0;
	for(int i=0;i<=1;i++)
	    {
	    	v[ch[x][i]]=v[ch[x][i]]*mu[x]%mod,mu[ch[x][i]]=mu[ch[x][i]]*mu[x]%mod,ad[ch[x][i]]=ad[ch[x][i]]*mu[x]%mod;
		    a[ch[x][i]]=a[ch[x][i]]*mu[x]%mod;
			v[ch[x][i]]=(v[ch[x][i]]+ad[x]*siz[ch[x][i]])%mod,ad[ch[x][i]]=(ad[ch[x][i]]+ad[x])%mod;
			a[ch[x][i]]=(a[ch[x][i]]+ad[x])%mod;
		}
	mu[x]=1,ad[x]=0;
}

long long wh(long long x)
{
	return x==ch[fa[x]][1];
}

bool isroot(long long x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x),l=wh(y);
	if(!isroot(y))ch[z][l]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

void makeroot(long long x)
{
	access(x),splay(x),re[x]^=1;
}

long long findroot(long long x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void link(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(findroot(y)!=x)fa[x]=y;
}

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

void add(long long x,long long y,long long c)
{
	makeroot(x),access(y),splay(y);
	v[y]=(v[y]+c*siz[y])%mod,ad[y]=(ad[y]+c)%mod,a[y]=(a[y]+c)%mod;
}

void mul(long long x,long long y,long long c)
{
	makeroot(x),access(y),splay(y);
	v[y]=v[y]*c%mod,mu[y]=mu[y]*c%mod,ad[y]=ad[y]*c%mod,a[y]=a[y]*c%mod;
}

int main()
{
	cin>>n>>m; 
	for(int i=1;i<=n;i++)a[i]=1,mu[i]=1,ad[i]=0;
	for(int i=1;i<=n-1;i++)
	    {
	    cin>>x>>y;
	    link(x,y);
	    }
	for(int i=1;i<=m;i++)
	    {
	    	cin>>op;
	    	if(op=='+')cin>>x>>y>>c,add(x,y,c);
	    	else if(op=='-')cin>>x>>y,cut(x,y),cin>>x>>y,link(x,y);
	    	else if(op=='*')cin>>x>>y>>c,mul(x,y,c);
	    	else if(op=='/')cin>>x>>y,makeroot(x),access(y),splay(y),printf("%lld\n",v[y]%mod);
		}
	return 0;
}

例题 \(2\)

P1501 [国家集训队] Tree II

老生常谈的标记下传顺序问题。

Splay 每个节点维护子树内点权和,查询依旧是提取路径后查询 Splay 根的信息。

然后是加法懒标记和乘法懒标记,我们钦定先下传乘法标记,下传乘法标记是同时更新加法标记,即让加法标记也乘以乘法标记的数值。先下传加法标记无法处理标记之间的贡献。

#include <bits/stdc++.h>
using namespace std;
long long n,m,x,y,c,a[400000],siz[400000],v[400000],ad[400000],mu[400000],ch[400000][2],fa[400000],re[400000],st[400000],top=0,cnt=0;
const long long mod=51061;
char op;
void pushup(long long x)
{
	siz[x]=(siz[ch[x][0]]+siz[ch[x][1]]+1)%mod;
	v[x]=(v[ch[x][0]]+v[ch[x][1]]+a[x])%mod;
}

void pushdown(long long x)
{
	if(re[x])re[ch[x][0]]^=1,re[ch[x][1]]^=1,swap(ch[x][0],ch[x][1]),re[x]=0;
	for(int i=0;i<=1;i++)
	    {
	    	v[ch[x][i]]=v[ch[x][i]]*mu[x]%mod,mu[ch[x][i]]=mu[ch[x][i]]*mu[x]%mod,ad[ch[x][i]]=ad[ch[x][i]]*mu[x]%mod;
		    a[ch[x][i]]=a[ch[x][i]]*mu[x]%mod;
			v[ch[x][i]]=(v[ch[x][i]]+ad[x]*siz[ch[x][i]])%mod,ad[ch[x][i]]=(ad[ch[x][i]]+ad[x])%mod;
			a[ch[x][i]]=(a[ch[x][i]]+ad[x])%mod;
		}
	mu[x]=1,ad[x]=0;
}

long long wh(long long x)
{
	return x==ch[fa[x]][1];
}

bool isroot(long long x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x),l=wh(y);
	if(!isroot(y))ch[z][l]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

void makeroot(long long x)
{
	access(x),splay(x),re[x]^=1;
}

int findroot(int x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void link(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(findroot(y)!=x)fa[x]=y;
}

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

void add(long long x,long long y,long long c)
{
	makeroot(x),access(y),splay(y);
	v[y]=(v[y]+c*siz[y])%mod,ad[y]=(ad[y]+c)%mod,a[y]=(a[y]+c)%mod;
}

void mul(long long x,long long y,long long c)
{
	makeroot(x),access(y),splay(y);
	v[y]=v[y]*c%mod,mu[y]=mu[y]*c%mod,ad[y]=ad[y]*c%mod,a[y]=a[y]*c%mod;
}

int main()
{
	cin>>n>>m; 
	for(int i=1;i<=n;i++)a[i]=1,mu[i]=1,ad[i]=0;
	for(int i=1;i<=n-1;i++)
	    {
	    cin>>x>>y;
	    link(x,y);
	    }
	for(int i=1;i<=m;i++)
	    {
	    	cin>>op;
	    	if(op=='+')cin>>x>>y>>c,add(x,y,c);
	    	else if(op=='-')cin>>x>>y,cut(x,y),cin>>x>>y,link(x,y);
	    	else if(op=='*')cin>>x>>y>>c,mul(x,y,c);
	    	else if(op=='/')cin>>x>>y,makeroot(x),access(y),splay(y),printf("%lld\n",v[y]%mod);
		}
	return 0;
}

例题 \(3\)

P3203 [HNOI2010] 弹飞绵羊

LCT 做法非常无脑啊。

首先弹力系数是一个正整数,所以如果我们把每一个装置看作一个节点,每一次弹射看作一条边,从 \(i\) 连向 \(k_i\),并将被弹飞时的边连向一个虚拟节点,以这个虚拟节点为根,操作 \(1\) 就是把 \(x\) 打通到根,求整个链的大小就行了。对每个结点维护子树大小,直接查提取区间后 Splay 树根子树大小的信息就行了。

操作 \(2\) 就是删边和连边。先把本来的边删掉,更新弹力系数,再把边加回来。

这里的懒标记定义也有问题,最好按照例题 \(2\) 的写法。

#include <bits/stdc++.h>
using namespace std;
long long n,m,op,x,y,a[300000],siz[300000],ch[300000][2],fa[300000],re[300000],st[300000],top=0;
void pushup(long long x)
{
	siz[x]=siz[ch[x][0]]+siz[ch[x][1]]+1;
}

void pushdown(long long x)
{
	if(re[x])re[ch[x][0]]^=1,re[ch[x][1]]^=1,swap(ch[x][0],ch[x][1]),re[x]=0;
}

long long wh(long long x)
{
	return x==ch[fa[x]][1];
}

bool isroot(long long x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x),l=wh(y);
	if(!isroot(y))ch[z][l]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

void makeroot(long long x)
{
	access(x),splay(x),re[x]^=1;
}

long long findroot(long long x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void link(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(findroot(y)!=x)fa[x]=y;
}

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

int main()
{
	scanf("%lld",&n);
	for(int i=1;i<=n+1;i++)siz[i]=1;
	for(int i=1;i<=n;i++)
	    {
	    scanf("%lld",&a[i]);
	    if(i+a[i]<=n)link(i,i+a[i]);
	    else link(i,n+1);
	    }
	scanf("%lld",&m);
	for(int i=1;i<=m;i++)
	    {
	    	scanf("%lld",&op);
	    	if(op==1)scanf("%lld",&x),x++,makeroot(n+1),access(x),splay(x),printf("%lld\n",siz[ch[x][0]]);
	    	else if(op==2)
	    	    {
			    scanf("%lld%lld",&x,&y);
			    x++;
				if(x+a[x]<=n)cut(x,x+a[x]);
				else cut(x,n+1);
				a[x]=y;
				if(x+a[x]<=n)link(x,x+a[x]);
				else link(x,n+1);
			    }
		}
	return 0;
}

例题 \(4\)

P2486 [SDOI2011] 染色

比较复杂的 pushup

对于每个节点,我们维护其 Splay 子树内对应链的区间的颜色数,最浅的点颜色数,最深的点颜色数。pushup 的时候,由于左儿子是深度较浅的点,所以如果左儿子深度最深的点的颜色与当前节点颜色相同,那么说明合并后这种颜色被重复计算了一次,应该减掉。右儿子深度最浅的点的颜色与当前节点颜色相同也是同理。对于和最浅的点颜色数,最深的点颜色数,直接继承左、右儿子的。

覆盖操作也比较简单。使用覆盖懒标记,打标记时更新子树内的颜色数为 \(1\),最浅的点颜色数和最深的点颜色数更新为这个颜色。

这里的懒标记定义也有问题,最好按照例题 \(2\) 的写法。

#include <bits/stdc++.h>
using namespace std;
long long n,m,x,y,z,a[200000],ls[200000],rs[200000],cnt[200000],ch[200000][2],fa[200000],re[200000],tg[200000],st[200000],top=0;
char op;
void pushdown(long long x)
{
	if(re[x])
	   {
	   re[ch[x][0]]^=1,re[ch[x][1]]^=1;
	   swap(ls[x],rs[x]),swap(ch[x][0],ch[x][1]),re[x]=0;
       }
    if(tg[x])
       {
       tg[ch[x][0]]=tg[x],tg[ch[x][1]]=tg[x];
	   a[x]=tg[x],ls[x]=tg[x],rs[x]=tg[x],cnt[x]=1,tg[x]=0;	
	   }
}

void pushup(long long x)
{
	if(ch[x][0])pushdown(ch[x][0]);
	if(ch[x][1])pushdown(ch[x][1]);
	ls[x]=a[x],rs[x]=a[x],cnt[x]=1;
	if(ch[x][0])
	   {
	   ls[x]=ls[ch[x][0]],cnt[x]+=cnt[ch[x][0]];
	   if(rs[ch[x][0]]==a[x])cnt[x]--;
       }
	if(ch[x][1])
	   {
	   rs[x]=rs[ch[x][1]],cnt[x]+=cnt[ch[x][1]];
	   if(ls[ch[x][1]]==a[x])cnt[x]--;
       }
}

long long wh(long long x)
{
	return x==ch[fa[x]][1];
}

bool isroot(long long x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x),l=wh(y);
	if(!isroot(y))ch[z][l]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	if(ch[x][0])pushdown(ch[x][0]);
	if(ch[x][1])pushdown(ch[x][1]);
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

void makeroot(long long x)
{
	access(x),splay(x),re[x]^=1;
}

long long findroot(long long x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void link(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(findroot(y)!=x)fa[x]=y;
}

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

int main()
{
	ios::sync_with_stdio(false);
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=n-1;i++)cin>>x>>y,link(x,y);
	for(int i=1;i<=m;i++)
	    {
	    	cin>>op;
	    	if(op=='C')cin>>x>>y>>z,makeroot(x),access(y),splay(y),tg[y]=z;
	    	else if(op=='Q')cin>>x>>y,makeroot(x),access(y),splay(y),printf("%lld\n",cnt[y]);
		}
	return 0;
}

例题 \(5\)

P2387 [NOI2014] 魔法森林

本题是 LCT 维护动态最小生成树和边权 LCT 的经典应用。

首先,我们把边按照需要 A 型守护精灵的数量从小到大排序,然后从小到大遍历这些边依次加入图中。这样,我们就不需要考虑 A 型守护精灵的限制。考虑反证,假设存在最优路径在当前 A 型守护精灵的限制下,如果我们为了让 B 型守护精灵最大值最小导致 A 型守护精灵的最大值并非新加入的边,那这条路径会在之前的 A 型守护精灵的限制下被计算,所以不会有问题。而如果 A 型守护精灵的最大值是新加入的边,那相当于我们枚举 A 型守护精灵的最大值计算,覆盖了所有方案,也不会有问题。

根据最小生成树 Kruskal 的过程,最小生成树上的边一定是使某两个点连通的最小边。因此,我们在加边的时候维护最小生成树即可。假设加入的边连接了 \(u,v\),如果 \(u,v\) 不连通,直接连边。否则,我们用 LCT 查询出 \(u,v\) 之间路径上 B 型守护精灵的最大值,如果新加入的路径权值更小,那就删掉这条边,换成新的边。

然后就是如何维护边权 LCT 了。我们把每条边拆成一个点,分别连接它连接的两个端点。在 pushup 时如果是边拆成的点就加入边的信息并合并信息,否则值合并左右儿子的信息。

细节有点多。为了方便删边,我们需要维护 B 型守护精灵的最大值对应的边是哪条。同时,\(1\to n\) 的路径上不一定会经过 A 型守护精灵数量最大的那条边,所以我们还需要维护一下路径 A 型守护精灵的最大值,最后加起来。有点难写。

这里的懒标记定义还是有问题,最好按照例题 \(2\) 的写法。

#include <bits/stdc++.h>
using namespace std;
struct edge
{
	long long u,v,d1,d2;
}e[200000]; 
long long n,m,op,x,y,m1[200000],m2[200000],p[200000],ch[200000][2],fa[200000],re[200000],st[200000],top=0,ans=1e12;
bool cmp(struct edge a,struct edge b)
{
	return a.d1<b.d1;
}

void pushup(long long x)
{
	if(x>n)m1[x]=e[x-n].d1,p[x]=x,m2[x]=e[x-n].d2;
	else m1[x]=m2[x]=0,p[x]=0;
	m1[x]=max(m1[x],max(m1[ch[x][0]],m1[ch[x][1]]));
	if(m2[ch[x][0]]>m2[x])m2[x]=m2[ch[x][0]],p[x]=p[ch[x][0]];
	if(m2[ch[x][1]]>m2[x])m2[x]=m2[ch[x][1]],p[x]=p[ch[x][1]];
}

void pushdown(long long x)
{
	if(re[x])re[ch[x][0]]^=1,re[ch[x][1]]^=1,swap(ch[x][0],ch[x][1]),re[x]=0;
}

long long wh(long long x)
{
	return x==ch[fa[x]][1];
}

bool isroot(long long x)
{
	return (x!=ch[fa[x]][0])&&(x!=ch[fa[x]][1]);
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x),l=wh(y);
	if(!isroot(y))ch[z][l]=x;
	fa[x]=z,fa[y]=x,fa[ch[x][k^1]]=y;
	ch[y][k]=ch[x][k^1],ch[x][k^1]=y;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	top=0,st[++top]=x;
	for(int i=x;!isroot(i);i=fa[i])st[++top]=fa[i];
	while(top)pushdown(st[top]),top--;
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])splay(x),ch[x][1]=t,pushup(x);
}

void makeroot(long long x)
{
	access(x),splay(x),re[x]^=1;
}

long long findroot(long long x)
{
	access(x),splay(x);
	while(ch[x][0])x=ch[x][0];
    splay(x);
	return x;
}

long long getroot(long long x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void link(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(getroot(y)!=x)fa[x]=y;
}

void cut(long long x,long long y)
{
	makeroot(x),access(y),splay(y);
	if(ch[y][0]==x&&ch[ch[x][0]][1]==0)fa[x]=0,ch[y][0]=0,pushup(y);
}

int main()
{
	scanf("%lld%lld",&n,&m);
	for(int i=1;i<=m;i++)scanf("%lld%lld%lld%lld",&e[i].u,&e[i].v,&e[i].d1,&e[i].d2);
	sort(e+1,e+m+1,cmp);
	for(int i=1;i<=m;i++)m1[n+i]=e[i].d1,m2[n+i]=e[i].d2,p[n+i]=n+i;
	for(int i=1;i<=m;i++)
	    {
	    if(findroot(e[i].u)!=findroot(e[i].v))link(e[i].u,n+i),link(n+i,e[i].v);
	    else
		   {
		   makeroot(e[i].u),access(e[i].v),splay(e[i].v);
		   if(e[i].d2<m2[e[i].v])
		      {
		      	long long k=p[e[i].v]-n;
		      	cut(e[k].u,n+k),cut(n+k,e[k].v);
		      	link(e[i].u,n+i),link(n+i,e[i].v);
			  }
	       }
	    makeroot(1),access(n),splay(n);
	    if(findroot(1)==findroot(n))ans=min(ans,m1[n]+m2[n]);
	    }
	if(ans!=1e12)printf("%lld\n",ans);
	else printf("-1\n");
	return 0;
}

例题 \(6\)

P3703 [SDOI2017] 树点涂色

做题时我们要注意把题目内容和熟知的算法进行类比迁移。

我们观察这个 \(1\) 操作,把 \(x\) 到根染上一种没有用过的颜色。打通到根,彼此覆盖,这就很像 LCT access 操作。

因此,我们考虑用 LCT 维护原树。然后,我们就会发现每个点到根的颜色数就是从这个点到根经过的虚边数量加 \(1\)access 操作时,虚实边修改的时候同时改一下子树内的点到根经过的虚边数量。初始值为在树上的深度。

由于每次虚边变成实边或实边变成虚边都对这条边上深度较深的节点的子树内的每个节点的到根的颜色数有影响,且原树固定,所以考虑把原树的 DFS 序求出来,通过 DFS 序转化为序列问题。然后考虑影响,虚边变成实边根据转化相当于区间减 \(1\),实边变成虚边相当于区间加 \(1\)

注意这里是这条边上深度较深的节点的子树内,也就是下面的链中最浅的节点的子树内,所以 access 的时候需要对 \(t\)\(ch[x][1]\) 进行 findroot 操作。

接下来考虑 \(2\) 操作。注意到路径上的颜色数可以差分,即设 \(d[x]\)\(x\) 到根的颜色数,则 \(x\to y\) 的颜色数为 \(d[x]+d[y]-2\times d[\text{lca}(x,y)]+1\)

因为 \(x\to\text{lca}(x,y)\)\(\text{lca}(x,y)\to\text{y}\) 的颜色不可能相同,且 \(\text{lca}(x,y)\) 到根的路径上的颜色被计算了两遍,需要减去。但是 \(\text{lca}(x,y)\) 的颜色又是需要计算的,所以再加回来。

上面论证显然漏了一种情况,可能 \(\text{lca}(x,y)\) 到根和 \(\text{lca}(x,y)\)\(x\)\(y\) 的路径上有重复颜色,此时这两个色块一定相连。而这个色块先被算两次,再被减两次,最后再被 \(\text{lca}(x,y)\) 的颜色加回来,刚好算了一次,所以是对的。

\(3\) 操作就简单了,相当于区间 \(\text{max}\)。因此我们需要维护一个区间加区间 \(\text{max}\) 的数据结构,线段树即可。

#include <bits/stdc++.h>
#define lc(x) ((x)<<1)
#define rc(x) ((x)<<1)|1
using namespace std;
struct edge
{
	int v,nxt;
}e[200000];
struct node
{
	int mx,tg;
}tr[400000];
int n,m,a,b,op,x,y,h[200000],dep[200000],dfn[200000],d[200000],siz[200000],ch[200000][2],fa[200000],f[200000][20],rt=1,dfc=0,cnt=0;
void pushup(int x)
{
	tr[x].mx=max(tr[lc(x)].mx,tr[rc(x)].mx);
}

void pushdown(int x)
{
	tr[lc(x)].tg+=tr[x].tg,tr[lc(x)].mx+=tr[x].tg;
	tr[rc(x)].tg+=tr[x].tg,tr[rc(x)].mx+=tr[x].tg;
	tr[x].tg=0;
}

void build(int x,int l,int r)
{
	tr[x].tg=0;
	if(l==r)
	   {
	   	tr[x].mx=dep[d[l]];
	   	return;
	   }
	int mid=(l+r)>>1;
	build(lc(x),l,mid),build(rc(x),mid+1,r);
	pushup(x);
}

void update(int x,int l,int r,int lx,int rx,int k)
{
	if(l>=lx&&r<=rx)
	   {
	   	tr[x].tg+=k,tr[x].mx+=k;
	   	return;
	   }
	pushdown(x);
	int mid=(l+r)>>1;
	if(lx<=mid)update(lc(x),l,mid,lx,rx,k);
	if(rx>=mid+1)update(rc(x),mid+1,r,lx,rx,k);
	pushup(x);
}

int query(int x,int l,int r,int lx,int rx)
{
	if(l>=lx&&r<=rx)return tr[x].mx;
	pushdown(x);
	int mid=(l+r)>>1,ans=-1e9;
	if(lx<=mid)ans=max(ans,query(lc(x),l,mid,lx,rx));
	if(rx>=mid+1)ans=max(ans,query(rc(x),mid+1,r,lx,rx));
	return ans;
}

void add_edge(int u,int v)
{
	e[++cnt].nxt=h[u];
	e[cnt].v=v;
	h[u]=cnt;
}

void dfs(int x,int pr)
{
	dfn[x]=++dfc,d[dfn[x]]=x,siz[x]=1,dep[x]=dep[pr]+1,fa[x]=pr,f[x][0]=pr;
	for(int i=1;i<=19;i++)
	    if(f[x][i-1])f[x][i]=f[f[x][i-1]][i-1];
	    else break;
	for(int i=h[x];i;i=e[i].nxt)
	    if(e[i].v!=pr)dfs(e[i].v,x),siz[x]+=siz[e[i].v];
}

int lca(int x,int y)
{
	if(dep[x]>dep[y])swap(x,y);
	int c=dep[y]-dep[x];
	for(int i=19;i>=0;i--)
	    if(c&(1<<i))y=f[y][i];
	if(x==y)return x;
	for(int i=19;i>=0;i--)
	    if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
	return f[x][0];
}

int wh(int x)
{
	return ch[fa[x]][1]==x;
}

bool isroot(int x)
{
	return (ch[fa[x]][0]!=x)&&(ch[fa[x]][1]!=x);
}

void rotate(int x)
{
	int y=fa[x],z=fa[y],k=wh(x);
	if(!isroot(y))ch[z][wh(y)]=x;
	fa[x]=z;
	fa[ch[x][k^1]]=y,ch[y][k]=ch[x][k^1];
	fa[y]=x,ch[x][k^1]=y;
}

void splay(int x)
{
	while(!isroot(x))
	   {
	   	int y=fa[x];
	   	if(!isroot(y))
	   	   {
	   	   	if(wh(x)==wh(y))rotate(y);
	   	   	else rotate(x);
		   }
		rotate(x);
	   }
}

int findroot(int x)
{
	while(ch[x][0])x=ch[x][0];
	splay(x);
	return x;
}

void access(int x)
{
	for(int t=0;x;t=x,x=fa[x])
	    {
	    splay(x);
	    if(t)
	       {
	       int k=findroot(t); 
		   update(rt,1,n,dfn[k],dfn[k]+siz[k]-1,-1);
		   splay(t);
	       }
		if(ch[x][1])
		   {
		   int k=findroot(ch[x][1]);
		   update(rt,1,n,dfn[k],dfn[k]+siz[k]-1,1);
		   splay(x);
	       }
		ch[x][1]=t;
	    }
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n-1;i++)scanf("%d%d",&a,&b),add_edge(a,b),add_edge(b,a);
	dfs(1,0),build(rt,1,n);
	for(int i=1;i<=m;i++)
	    {
	    	scanf("%d",&op);
	    	if(op==1)scanf("%d",&x),access(x);
	    	else if(op==2)scanf("%d%d",&x,&y),printf("%d\n",query(rt,1,n,dfn[x],dfn[x])+query(rt,1,n,dfn[y],dfn[y])-2*query(rt,1,n,dfn[lca(x,y)],dfn[lca(x,y)])+1);
		    else if(op==3)scanf("%d",&x),printf("%d\n",query(rt,1,n,dfn[x],dfn[x]+siz[x]-1));
		}
	return 0;
}

例题 \(7\)

P6845 [CEOI 2019] Dynamic Diameter

LCT 维护虚子树信息的经典应用以及设计 LCT 上复杂信息的基本技巧。

可以转化为维护动态直径问题。考虑直径的求法,一种方式是对于每棵子树维护最长链和次长链,合并取最大值。我们沿用这个思路。

还是使用边权 LCT 拆边为点。但如果我们对每个节点直接维护最长链和次长链,由于 LCT 中的 Splay 子树不一定按原来的顺序排,所以基本没法上传。因此,LCT 维护信息的常用套路是维护 Splay 子树内最深或最浅的点的信息。

先只考虑实链间的贡献。我们设 \(lm[x]\) 表示节点 \(x\) Splay 子树内最浅的节点沿这条实链往下的节点的原树的子树内的最长链。转移考虑左子树更浅,可以直接继承;右子树更深,可以接到左子树最浅点底下。考虑设 \(len[x]\) 表示 \(x\) 对应边的长度,\(sum[x]\) 为子树内的和,由于子树内最浅的点在左子树,\(x\) 到子树内最浅的点的距离其实是 \(sum[ch[x][0]]+len[x]\)。因此,我们有 \(lm[x]=\max\{lm[ch[x][0]],lm[ch[x]][1]+sum[ch[x][0]]+len[x]\}\)。再设 \(rm[x]\) 表示节点 \(x\) Splay 子树内最深的节点沿这条实链往上的节点的原树的子树内(不含这条实链中比 \(x\) 更深的点以及其子树)的最长链,可以类似的转移。如果有翻转操作(来自 makeroot),就把 \(lm[x]\)\(rm[x]\) 交换一下。不过本题可以没有。

接下来记 \(dm[x]\)\(x\) 所在的 Splay 子树内最浅的节点对应的原树中的子树内的直径,考虑在 \(x\) 处合并。显然,左子树较浅,右子树较深,为了保证不重复旋转一条链,我们让左子树最深的点往上走,右子树最浅的点往下走,再加上自己,就完成了合并。再算上继承左右子树信息,即 \(dm[x]=\max\{dm[ch[x][0]],dm[ch[x]][1],rm[ch[x][0]]+lm[ch[x][0]]+len[x]\}\)

然后我们考虑虚子树的信息。记 \(ml[x]\) 为点 \(x\) 虚子树内最浅的节点沿这条实链往下的节点的原树的子树内的最长链的集合,\(mp[x]\) 为点 \(x\) 虚子树内最浅的节点对应的原树中的子树内的直径。这两个集合实质上就是虚子树 \(lm,dm\) 信息的集合,access 的时候用可删堆维护。

我们再来考虑虚子树信息对当前实链的贡献。记 \(\max\{S\}\) 为一个集合中的最大值,\(\sec\{S\}\) 为一个集合中的次大值。对于 \(lm\)\(rm\),虚子树提供了另一种接在之前路径后的选择。有 \(lm[x]=\max\{lm[x],\max\{ml[x]\}+sum[ch[x][0]]+len[x]\}\)\(rm\) 同理。对于 \(dm\),虚子树信息额外提供了若干条路径,考虑实链与虚子树合并。有 \(dm[x]=\max\{dm[x],\max\{ml[x]\}+rm[ch[x][0]]+len[x],\max\{ml[x]\}+lm[ch[x][1]]+len[x]\}\)。另外还可以虚子树和虚子树合并,为了避免重复合并一条路径我们第二次用次大值。有 \(dm[x]=\max\{dm[x],\max\{ml[x]\}+\sec\{ml[x]\}+len[x]\}\)。记得继承虚子树的 \(lm,rm,dm\) 信息。代码中 \(lm,rm\) 的继承一定不优就没写。

实现的时候如果最大值或次大值不存在会返回负无穷,由于可以取空所以最大值或次大值对 \(0\) 取了 \(\max\)。代码中的存图方式是一种卡常的存图方式,可以借鉴。

#include <bits/stdc++.h>
using namespace std;
struct erasable_heap
{
	priority_queue<long long>mx,del;
	void insert(long long x){mx.push(x);}
	void erase(long long x){del.push(x);}
	long long top()
	    {
		while(!mx.empty()&&!del.empty()&&mx.top()==del.top())mx.pop(),del.pop();
		if(mx.empty())return -1e18;
		return mx.top();
	    }
	long long second()
	    {
	   	long long id=top(),res=-1e18;
	   	if(id!=-1e18)erase(id),res=top(),insert(id);
	   	return res;
	    }
}ml[200001],mp[200001];
long long n,q,w,x,y,fa[200001],ch[200001][2],lm[200001],rm[200001],dm[200001],sum[200001],len[200001],a[100001],b[100001],c[100001],d[200001],lx[200001],rx[200001],e[400001],la=0;
bool wh(long long x)
{
	return ch[fa[x]][1]==x;
}

bool isroot(long long x)
{
	return (ch[fa[x]][0]!=x)&&(ch[fa[x]][1]!=x);
}

void pushup(long long x)
{
	sum[x]=sum[ch[x][0]]+sum[ch[x][1]]+len[x];
	lm[x]=max(lm[ch[x][0]],lm[ch[x][1]]+sum[ch[x][0]]+len[x]);
	lm[x]=max(lm[x],max(ml[x].top(),0ll)+sum[ch[x][0]]+len[x]);
	rm[x]=max(rm[ch[x][1]],rm[ch[x][0]]+sum[ch[x][1]]+len[x]);
	rm[x]=max(rm[x],max(ml[x].top(),0ll)+sum[ch[x][1]]+len[x]);
	dm[x]=max(dm[ch[x][0]],max(dm[ch[x][1]],rm[ch[x][0]]+lm[ch[x][1]]+len[x]));
	dm[x]=max(dm[x],max(rm[ch[x][0]]+max(ml[x].top(),0ll),lm[ch[x][1]]+max(ml[x].top(),0ll))+len[x]);
	dm[x]=max(dm[x],max(mp[x].top(),max(ml[x].top(),0ll)+max(ml[x].second(),0ll)+len[x]));
}

void rotate(long long x)
{
	long long y=fa[x],z=fa[y],k=wh(x);
	if(!isroot(y))ch[z][wh(y)]=x;
	fa[x]=z;
	ch[y][k]=ch[x][k^1],fa[ch[x][k^1]]=y;
	ch[x][k^1]=y,fa[y]=x;
	pushup(y),pushup(x);
}

void splay(long long x)
{
	while(!isroot(x))
	   {
	   	long long y=fa[x];
	   	if(!isroot(y))
		   	{
		   	if(wh(x)==wh(y))rotate(y);
		   	else rotate(x);
		    }
	   	rotate(x);
	   }
}

void access(long long x)
{
	for(long long t=0;x;t=x,x=fa[x])
	    {
	    	splay(x);
	    	if(ch[x][1])ml[x].insert(lm[ch[x][1]]),mp[x].insert(dm[ch[x][1]]);
	    	if(t)ml[x].erase(lm[t]),mp[x].erase(dm[t]);
	    	ch[x][1]=t,pushup(x);
		}
}

long long modify(long long x,long long k)
{
	access(x),splay(x),len[x]=k,pushup(x);
	return dm[x];
}

void dfs(long long x,long long pr)
{
	for(long long i=lx[x];i<=rx[x];i++)
	    if(e[i]!=pr)dfs(e[i],x);
	pushup(x);
	fa[x]=pr,ml[pr].insert(lm[x]),mp[pr].insert(dm[x]);
}

int main()
{
	scanf("%lld%lld%lld",&n,&q,&w);
	for(int i=1;i<=n-1;i++)scanf("%lld%lld%lld",&a[i],&b[i],&c[i]),len[i+n]=c[i],d[a[i]]++,d[b[i]]++,d[n+i]+=2;
	for(int i=1;i<=2*n-1;i++)rx[i]=d[i-1],lx[i]=d[i-1]+1,d[i]+=d[i-1];
	for(int i=1;i<=n-1;i++)
	    {
	    rx[a[i]]++,e[rx[a[i]]]=n+i,rx[b[i]]++,e[rx[b[i]]]=n+i;
	    rx[n+i]++,e[rx[n+i]]=a[i],rx[n+i]++,e[rx[n+i]]=b[i];
	    }
	dfs(1,0);
	for(int i=1;i<=q;i++)
	    {
	    	scanf("%lld%lld",&x,&y);
	    	x=(x+la)%(n-1)+1,y=(y+la)%w;
	    	printf("%lld\n",la=modify(x+n,y));
		}
	return 0;
}

后记

真的很喜欢 LCT,学过 LCT 之后基本上就没写过树剖了。但 LCT 的常数确实巨大,\(O(\log n)\) 能跑得比 \(O(\log^2 n)\) 的树剖慢一到两倍。

还有一些很帅的 LCT 用法没有记录,因为我不会。

清浊分 花有灵 双生红尘

星辰变 弦音绝 当舍痴嗔

既不追朝与露 也不悲他年吻

九星书尽 这过往爱恨

posted @ 2025-07-02 15:14  w9095  阅读(21)  评论(0)    收藏  举报