LCT 学习笔记
前言
\(8.23\) 动工,开始填坑。
前置知识:\(\text{Splay}\)。
本篇为 \(\texttt{LCT}\) 的基础内容,更多的题目及解析请查看 \(\texttt{LCT——应用篇}\)。
\(\texttt{LCT}\) 介绍
\(\texttt{LCT}\),全称 \(\texttt{Link-Cut-Tree}\),是一个可以动态维护森林的数据结构,一般常见支持操作有「加边」「删边」「修改结点信息」「查询两点连通性」「查询路径信息和」等,是解决动态树上问题强而有力的工具。
辅助树
在学习「树链剖分」时,我们会采用将每条边划分成「轻边」和「重边」来保证复杂度,另外还有并不常见「长链剖分」。
在 \(\texttt{LCT}\),我们采用「实链剖分」的方式来操作,即,将每一条边划分为「实边」和「虚边」,每一个点当且仅当与一个儿子的的连边是实边,其余都是虚边。
并且注意,在 \(\texttt{LCT}\) 中,「虚边」与「实边」都是可以动态变换的。
如何做到动态变换呢?我们采用 \(\texttt{Splay}\) 维护一条从上到下,以实边串联的路径。这里的「从上到下」指的是深度严格递增,即不存在深度相同的节点。中序遍历一棵 \(\texttt{splay}\) 就相当于从上到下遍历每一个节点。
这其实也就可以类比「树链剖分」,对于每一条重链(也是从上到下),用一个线段树维护。
根据上述,我们可以得到,任何一点在且仅在一个 \(\texttt{splay}\) 中,并且在一个 \(\texttt{Splay}\) 中,深度最小的点肯定是从根节点出发一直往左子树走到的最后一个点。
辅助树有一个性质是,虚边父亲不认儿子,但儿子认父亲,即一个 \(\texttt{Splay}\) 的根认他的父亲,但他的父亲不认这个根。
通过这个性质,我们就可以判断一个节点是不是某一棵 \(\texttt{Splay}\) 的根。
inline bool isroot(int u){return u!=ch[fa[u]][0]&&u!=ch[fa[u]][1];}
意思是,他既不是他的父亲的左孩子,又不是他的右孩子,那显然他的父亲就不认他,他就是根。
当然我们的 \(\texttt{rotate}\) 也要稍微改一下:
inline void rotate(int x)
{
int y=fa[x],z=fa[y],chk=get(x);
if(!isroot(y)) ch[z][get(y)]=x;//加了这一句话
ch[y][chk]=ch[x][!chk],fa[ch[x][!chk]]=y;
ch[x][!chk]=y,fa[y]=x,fa[x]=z;
push_up(y);
push_up(x);
return;
}
显然,如果 \(y\) 是根,那么旋转之后 \(x\) 的父亲 \(z\) 就不能认 \(x\) 作儿子。
\(\texttt{splay}\) 操作也是同理:
inline void splay(int x)
{
update(x);
for(register int f;f=fa[x],!isroot(x);rotate(x))
if(!isroot(f)) rotate(get(f)==get(x)?f:x);
return;
}
道理也很简单,只要旋转到根就不再旋转了;如果父亲 \(f\) 是根,那么只单旋一次 \(x\) 即可,否则视情况双旋。这一部分是 \(\texttt{Splay}\) 的内容,如果你感到困惑,那么请移步至 \(\texttt{Splay}\)。
\(\texttt{update}\) 是从某一节点的根节点开始,不断下传信息,具体下传什么信息稍后会讲。
实际上,有了辅助树后,我们所有的操作都是在辅助树上进行的,只关心节点在原树的深度是否合法。
Access
光有一堆 \(\texttt{splay}\) 构成的辅助树是没有用处的,\(\texttt{LCT}\) 的威力还要靠 \(\texttt{access}\) 操作来体现。
注意,这是 \(\texttt{LCT}\) 里一个最重要的操作,其余所有操作都依赖于它。
如果我们调用 \(\operatorname{access}(x)\),那么我们会在原树中打通一条从 \(x\) 到根的实链,并使得这些点恰好存在于一棵 \(\texttt{splay}\) 中。
实现起来很简单,我们用图来解释(懒得画图了,就用 \(\texttt{OI-Wiki}\) 的吧,不过可能需要加载一小会)。
我们现在有这么一棵树,加粗的为实边,虚线为虚边。
它的辅助树可能会长这样:
如果我们调用 \(\operatorname{access}(N)\),那么就会打通一条在原树中,\(A-N\) 的一条路径。
第一步,我们将当前节点 \(N\) 旋转至它所在的 \(\texttt{Splay}\) 的根,即我们调用 \(\operatorname{splay}(N)\),这时,\(L\) 先会左旋一下,然后 \(N\) 再右旋。
\(N,L,O\) 的关系就变成,\(L\) 是 \(N\) 的左儿子,\(O\) 是 \(N\) 的右儿子,但这种形态就不合法了,因为 \(L\) 连了两条实链,所以我们将 \(L\) 的右儿子设成 \(\text{NULL}\),即,将 \(L-O\) 的边改成了虚边。
那么,现在辅助树就变成了这样:
现在,我们找 \(N\) 的父亲 \(I\),再将 \(I\) 旋转至它所在的 \(\texttt{Splay}\) 的根。
注意这里的旋转,由于 \(I\) 并不认 \(N\) 这个儿子,所以 \(K\) 右旋之后 \(N\) 还是 \(I\) 的儿子,但是我们想打通 \(A-N\) 的路径,而 \(I\) 连的儿子是 \(K\),所以我们需要将 \(I-K\) 的实边替换成虚边,\(I-N\) 的虚边替换成实边。其实也就是将 \(I\) 的右儿子更改成 \(N\)。
现在,辅助树就会变成这样:
之后的操作,其实也就顺藤摸瓜就行了。
我们再找到 \(I\) 的父亲 \(H\),然后将 \(H\) 旋转至 \(H\) 所在 \(\texttt{Splay}\) 根,再将 \(H\) 的右儿子设成 \(I\):
再找到 \(H\) 的父亲 \(A\),将 \(A\) 旋转至 \(A\) 所在的 \(\texttt{Splay}\) 的根,然后将 \(A\) 的右儿子设成 \(H\):
现在,我们发现 \(A\) 没有父亲了,那么停止操作。
验证一下,我们中序遍历以 \(A\) 为根的那棵 \(\texttt{Splay}\),会得到一条 \(A-C-G-H-I-L-N\) 的路径。
原图再放一遍:
恰好就是 \(A-C-G-H-I-L-N\)!
如果你仔细读完上面的论述,相信你很快就能找到规律:
-
设上一个 \(\texttt{Splay}\) 的根节点为 \(p\),设 \(x\) 为 \(p\) 的父亲。一开始,\(x\) 为给定的点,\(p\) 为 \(\text{NULL}\)。
-
若 \(x\) 不为空,则将 \(x\) 旋转至 \(x\) 所在 \(\texttt{splay}\) 的根节点,然后将 \(x\) 的右儿子设为 \(p\);否则退出。
-
令 \(p\leftarrow x\),\(x\leftarrow\text{father}(x)\),重复 \(2\) 操作。
代码也就跃然纸上了:
inline void access(int x)
{
for(register int p=0;x;p=x,x=fa[x])
{
splay(x);
ch[x][1]=p;
push_up(x);//更新了儿子之后注意 push_up 节点信息
}
return;
}
这里 \(\texttt{push\_up}\) 什么信息需要视题目而定,比如更新子树和,子树大小,子树异或和之类的。
那么 \(\texttt{access}\) 操作有什么用呢?来看看它可以实现的函数!
makeroot
这也是 \(\texttt{LCT}\) 中一个非常重要的操作,目的是,将指定的点 \(x\) 旋转至原树的根。
我们先打通 \(x\) 到树根的路径,即调用 \(\operatorname{access}(x)\),现在 \(root\to x\) 路径上所有的点都在一棵 \(\texttt{splay}\) 里了,我们把 \(x\) 旋转到根上,即调用 \(\operatorname{splay}(x)\)。
可能有些读者会觉得,\(x\) 已经旋转到根了,\(\texttt{makeroot}\) 操作就做完了。
但是,树的形态是否发生改变,取决于 \(\texttt{Splay}\) 的中序遍历是否发生改变。
冷静思考一下。我们发现,\(x\) 是原树中,\(root\to x\) 这条路径上深度最深的点,也就是当 \(x\) 旋转至根时,\(x\) 没有右子树,现在我们想让 \(x\) 变成根,就相当于中序遍历发生反转!也就是将 \(x\) 为根的这棵 \(\texttt{Splay}\) 进行反转操作,即不断交换左儿子和右儿子,这样中序遍历就能反转。
那么我们直接对 \(x\) 节点打一个标记并反转它的左右儿子不就好了?并且这也解释了 \(\operatorname{update}\) 函数里的 \(\operatorname{push\_down}\) 到底下传的是什么信息。
代码同样非常简单。先给出 \(\operatorname{push\_down}\) 的代码:
inline void push_down(int u)
{
if(tag[u])
{
if(ch[u][0]) lazy_tag(ch[u][0]);
if(ch[u][1]) lazy_tag(ch[u][1]);
tag[u]=false;
}
return;
}
\(\operatorname{lazy\_tag}\) 函数也就是一个打标记:
inline void lazy_tag(int u)
{
Swap(ch[u][0],ch[u][1]);
tag[u]^=1;
return;
}
再稍回顾一下刚刚在 \(\operatorname{splay}\) 函数中的 \(\operatorname{update}\) 函数,它的目的是,从 \(x\) 所在 \(\texttt{Splay}\) 的根开始下传信息。
那么我们就一直找父亲,直到找到根,然后 \(\operatorname{push\_down}\):
inline void update(int u)
{
if(!isroot(u)) update(fa[u]);
push_down(u);
return;
}
\(\operatorname{makeroot}\) 函数至此也就很容易实现了:
- 打通路径。
- 旋转至根。
- 翻转。
注意,这里的翻转并不是真正意义上的翻转,而是指打一个翻转的标记。
inline void makeroot(int x)
{
access(x);
splay(x);
lazy_tag(x);
return;
}
有了 \(\operatorname{access}\) 和 \(\operatorname{makeroot}\),我们就可以实现一些函数啦!
findroot
字面意思,找到某一点所在原树的根。
这个根并查集很像,比如判断两点的连通性,我们就可以判断它们所在树的树根是否一样。
我们先打通到根的路径,然后把询问点 \(x\) 旋转至根。
这里又用到了辅助树的性质,\(x\) 是在原树中 \(root\to x\) 深度最大的点,所以旋转之后的 \(\text{Splay}\) 中 \(x\) 一定没有右子树。换句话说,树根一定是 \(x\) 所在 \(\text{Splay}\) 中中序遍历最小的节点,也就是不断的找左子树。
注意我们在暴力找左子树的时候要下传信息,代码同样非常简单:
inline int findroot(int x)
{
access(x);
splay(x);
push_down(x);
while(ch[x][0]) x=ch[x][0],push_down(x);
return x;
}
Link & Cut
这也是 \(\texttt{LCT}\) 中最常见的两个操作,假设操作的两点分别为 \(x,y\)。
先来说 \(\texttt{Link}\),先判断一手是否合法,即 \(x,y\) 是否已经在同一个连通块里了,这个可以通过 \(\operatorname{findroot}\) 轻松实现。
否则我们直接指定 \(x\) 变成树根,即 \(\operatorname{makeroot}(x)\),然后将 \(x\) 的父亲设成 \(y\),就类似并查集。注意,此时我们钦定 \(x\to y\) 的边为虚边。
代码:
inline void link(int x,int y)
{
if(!same(x,y)) makeroot(x),fa[x]=y;
return;
}
(\(\operatorname{same}(x,y)\) 判断的是 \(x,y\) 的 \(\operatorname{findroot}\) 返回值是否相等)
再来看 \(\texttt{cut}\),这个同样需要用到辅助树的性质。
先 \(\operatorname{makeroot}(x)\),然后打通根到 \(y\) 也就是 \(x\to y\) 的路径,然后将 \(y\) 旋转到根上。
对于判断合法性,如果 \(x,y\) 之间有边,那么:
- \(x\) 一定是 \(y\) 的左儿子。
- \(x\) 没有右儿子。
先来解释第一点。还是根据辅助树的性质,\(y\) 是 \(x\to y\) 路径上最后一个点,所以当 \(y\) 旋转到根上的时候,\(y\) 没有右子树。
再来解释第二点,如果 \(x\) 有右儿子 \(z\),根据辅助树的性质,原树的形态是通过 \(\text{Splay}\) 中序遍历得来的,那么 \(x\) 肯定在 \(y\) 之前,\(z\) 在 \(x\) 之后且在 \(y\) 之前,所以一定会出现一条 \(x\to z\to y\) 的路径,而树是一张无向无环图,所以,不可能存在一条 \(x\to y\) 的边。
判断完这两点之后,我们直接将 \(x\) 的父亲设为 \(\text{NULL}\),\(y\) 的左二子也设为 \(\text{NULL}\)。
\(y\) 的左孩子更改,需要 \(\operatorname{push\_up}\) 信息。
代码也很容易:
inline void cut(int x,int y)
{
split(x,y);
if(ch[y][0]==x&&!ch[x][1]) ch[y][0]=fa[x]=0;
push_up(y);
return;
}
split
通常,根据题目需要,我们会提取出树上的一条链来打标记或做相应的操作。
假设我们要 \(\operatorname{split}(x,y)\),做法跟前面的其实很相像。
先 \(\operatorname{makeroot}(x)\),然后 \(\operatorname{access}(y)\),最后 \(\operatorname{splay}(y)\)。那么以 \(y\) 为根的 \(\text{Splay}\) 就恰好包含了 \(x\to y\) 路径上的所有点。
原理很简单,这里作为一个小问题留给读者思考。
代码
至此,\(\texttt{Link-Cut-Tree}\) 的基础内容都已经解决了,看一下模板题的代码吧:
#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#include<set>
#include<vector>
#include<queue>
#include<stack>
#include<cstring>
#include<cstdlib>
#include<ctime>
#define rep(i,a,b) for(register int i=a;i<=b;++i)
#define rev(i,a,b) for(register int i=a;i>=b;--i)
#define gra(i,u) for(register int i=head[u];i;i=edge[i].nxt)
#define Clear(a) memset(a,0,sizeof(a))
#define yes puts("YES")
#define no puts("NO")
using namespace std;
typedef long long ll;
const int INF(1e9+10);
const ll LLINF(1e18+10);
inline int read()
{
int s=0,w=1;
char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')w=-1;ch=getchar();}
while(ch>='0'&&ch<='9')s=s*10+(ch-'0'),ch=getchar();
return s*w;
}
template<typename T>
inline T Min(T x,T y){return x<y?x:y;}
template<typename T>
inline T Max(T x,T y){return x>y?x:y;}
template<typename T>
inline void Swap(T&x,T&y){T t=x;x=y;y=t;return;}
template<typename T>
inline T Abs(T x){return x<0?-x:x;}
const int MAXN(3e5+10);
int n,m;
struct Link_Cut_Tree
{
int fa[MAXN],ch[MAXN][2],val[MAXN],sum[MAXN],siz[MAXN];
bool tag[MAXN];
inline void push_up(int u)
{
int ls=ch[u][0],rs=ch[u][1];
sum[u]=sum[ls]^sum[rs]^val[u];
siz[u]=siz[ls]+siz[rs]+1;
return;
}
inline void lazy_tag(int u)
{
Swap(ch[u][0],ch[u][1]);
tag[u]^=1;
return;
}
inline void push_down(int u)
{
if(tag[u])
{
if(ch[u][0]) lazy_tag(ch[u][0]);
if(ch[u][1]) lazy_tag(ch[u][1]);
tag[u]=false;
}
return;
}
inline bool get(int u){return u==ch[fa[u]][1];}
inline bool isroot(int u){return u!=ch[fa[u]][0]&&u!=ch[fa[u]][1];}
inline void update(int x)
{
if(!isroot(x)) update(fa[x]);
push_down(x);
return;
}
inline void rotate(int x)
{
int y=fa[x],z=fa[y],chk=get(x);
if(!isroot(y)) ch[z][get(y)]=x;
ch[y][chk]=ch[x][!chk],fa[ch[x][!chk]]=y;
ch[x][!chk]=y,fa[y]=x,fa[x]=z;
push_up(y);
push_up(x);
return;
}
inline void splay(int x)
{
update(x);
for(register int f;f=fa[x],!isroot(x);rotate(x))
if(!isroot(f)) rotate(get(f)==get(x)?f:x);
return;
}
inline void access(int x)
{
for(register int p=0;x;p=x,x=fa[x])
{
splay(x);
ch[x][1]=p;
push_up(x);
}
return;
}
inline void makeroot(int x)
{
access(x);
splay(x);
lazy_tag(x);
return;
}
inline int findroot(int p)
{
access(p);
splay(p);
push_down(p);
while(ch[p][0]) p=ch[p][0],push_down(p);
splay(p);
return p;
}
inline void link(int x,int y)
{
if(findroot(x)!=findroot(y)) makeroot(x),fa[x]=y;
return;
}
inline void cut(int x,int y)
{
makeroot(x);
access(y);
splay(y);
if(ch[y][0]==x&&!ch[x][1]) ch[y][0]=fa[x]=0;
return;
}
inline void split(int x,int y)
{
makeroot(x);
access(y);
splay(y);
return;
}
};
Link_Cut_Tree lct;
int main()
{
// freopen("read2.txt","r",stdin);
n=read(),m=read();
rep(i,1,n) lct.val[i]=read();
rep(i,1,m)
{
int opt=read(),x=read(),y=read();
if(opt==0)
{
lct.split(x,y);
printf("%d\n",lct.sum[y]);
}
else if(opt==1) lct.link(x,y);
else if(opt==2) lct.cut(x,y);
else if(opt==3)
{
lct.splay(x);
lct.val[x]=y;
}
}
return 0;
}
\(\texttt{PS}\):\(\text{590ms}\),\(\text{5.58MB}\),\(\text{3.33KB}\)。
完结撒花
感谢您的阅读!
或者继续前往 \(\texttt{LCT——应用篇}\)。