LCT
1 概述
首先我们需要知道一类问题,在这类问题中我们需要维护一个森林,支持加边和删边操作,然后要求维护树上的一些信息。这类问题称为动态树问题。
而 LCT,即 Link-Cut Tree,就是用于解决动态树问题的一种数据结构。它可以以
学习 LCT 之前需要对 Splay 这种平衡树有一定了解,当然两者在细节上还有一些差别。
2 实链剖分
我们先来看看如果不要求动态加边删边怎样做。假如给出一道这样的问题:
- 修改一个点的点权。
- 查询路径
上点的点权异或和。
显然这个问题可以轻松用树链剖分解决掉。接下来我们加入加边删边操作之后普通的树链剖分就难以维护了,原因就在于树结构改变的同时树链剖分出来的重链也会改变。
考虑树剖维护链上操作的本质,我们其实就是给同一条重链上的点赋上连续的
在动态树问题中,我们困难的地方就是对树时刻维护这样的链,上面已经说过树剖难以维护,所以我们更希望这个重链是由我们自行决定的。换句话讲,我们希望对每个节点自行指定重儿子和轻儿子然后进行维护。
这种划分方式就被称为实链剖分,而我们自行指定的儿子叫做实儿子和虚儿子(需要注意的是一个点不一定必须有实儿子)。然后整棵树就可以被划分成若干实链,接下来我们就需要利用 Splay 去维护每一条链。
然后我们就需要考虑怎样去维护这些链,我们还需要引入另一个东西。
3 辅助树
辅助树实际上就是维护每条链的 Splay 之间通过某种方式相连形成的树结构。可以理解为 Splay 维护的是每一条实链,而辅助树维护的就是一棵树;将一些辅助树放在一起就构成了 LCT,用于维护整个森林。
现在我们来看辅助树的性质:
- 辅助树有多个 Splay 组成,每个 Splay 维护原树的一条实链,且 Splay 的中序遍历对应实链从上到下的点。
- 辅助树上的 Splay 通过如下方式连成一棵树:对于一棵 Splay,其根节点的父亲指向其对应维护的实链的链顶的父亲。同时对于我们指向的这个点,我们仍然让它在辅助树上的儿子为空,以此表示这条边是虚边。也就是说,所有的虚边都是认父不认子的。
- 原树上的操作均可以转化为在辅助树上操作,所以接下来我们只需要考虑在辅助树上的操作即可。
举个例子,对于如下所示的原树:
其辅助树结构可能如下(显然这个结构会随 Splay 形态变化):
然后我们来看一下原树和辅助树的关系:
- 原树的实链都在辅助树的同一个 Splay 中。
- 原树的虚边由儿子所在 Splay 的根节点指向父亲,但是这个点不指向根节点。
- Splay 上最多有两个实儿子,但是可能会有很多虚儿子。
- 原树的根不等于辅助树的根;原树上的父亲指向不等于辅助树上的父亲指向。
接下来就可开始实现 LCT 的基本操作了。
4 具体实现
4.1 Splay 基本操作
4.1.1 get
get
函数实现的就是找当前节点是父亲的哪个儿子。
il void get(int p) {return rs(fa(p)) == p;}
4.1.2 isroot
顾名思义,用来判断当前节点是不是一个 Splay 的根。根据辅助树上虚边任父不认子的性质可以判断。
il bool isroot(int p) {return ls(fa(p)) != p && rs(fa(p)) != p;}
4.1.3 pushup / pushdown
在 LCT 的 Splay 中,基本上都要实现区间翻转操作,所以需要基本的下放懒标记操作。
这里就是正常的 Splay 上传下放操作,代码没有区别。
il void pushup(int p) {sum(p) = sum(ls(p)) ^ sum(rs(p)) ^ val(p);}
il void pushdown(int p) {
if(tag(p)) {
swap(ls(p), rs(p));
tag(ls(p)) ^= 1;
tag(rs(p)) ^= 1;
tag(p) = 0;
}
}
值得注意的是有时候这种 pushdown
会导致某一个点上有懒标记时它的两个儿子还是反的,某些题目中会导致错误。所以更稳妥的写法是标记该节点的两个儿子需不需要翻转。代码如下:
il void pushrev(int p) {
swap(ls(p), rs(p));
tag(p) ^= 1;
}
il void pushdown(int p) {
if(tag(p)) {
pushrev(ls(p)), pushrev(rs(p));
tag(p) = 0;
}
}
如果还需要其他的标记正常上传下放即可。
4.1.4 update
update 函数用于在 splay 操作前将根到
il void update(int p) {
if(!isroot(p)) update(fa(p));
pushdown(p);
}
4.1.5 rotate
rotate 操作和 Splay 中的操作是一致的。值得注意的是在 LCT 中,不能简单的通过判断 isroot
函数即可。
il void rotate(int p) {
int y = fa(p), z = fa(y), c = get(p);
if(!isroot(y)) t[z].son[get(y)] = p;//注意这一句要提前
fa(t[p].son[c ^ 1]) = y;
t[y].son[c] = t[p].son[c ^ 1];
t[p].son[c ^ 1] = y;
fa(y) = p;
fa(p) = z;
pushup(y), pushup(p);
}
4.1.6 splay
依然是正常的 splay 操作,判断根的部分依然采用 isroot
来判断即可。注意 splay 前要先 update
一下。
il void splay(int p) {
update(p);
int f = fa(p);
while(!isroot(p)) {
if(!isroot(f)) {
rotate(get(f) == get(p) ? f : p);
}
rotate(p);
f = fa(p);
}
}
以上就是原先 Splay 的函数的变化,实际上并不多。下面我们来看真正属于 LCT 的新函数。
4.2 LCT 基本操作
4.2.1 access
LCT 中最重要的操作,也是最难理解的操作。
我们令 access(x)
表示原树中
我们举例来说明如何进行 access
操作。假如我们有一颗原树为:
那么它的一种辅助树可能是:
现在假如我们执行 access(N)
操作,我们希望原树变成:
我们现在要将
先从第一步开始,既然要 access(N)
,那就先将
当然我们不必去找后继,只需要将
于是辅助树会变成下图:
此时我们看
然后按照上面的方法一直操作直到根节点即可。不难发现我们的操作实际上就是先 splay,再接右儿子,然后跳到父亲重复即可。所以代码很简单,如下:
il void access(int p) {
int x = 0;
while(p) {
splay(p);
rs(p) = x;
pushup(p);
x = p, p = fa(p);
}
}
4.2.2 makeroot
在维护路径信息的时候,绝大部分路径不能保证深度递增,一般都是先向上再向下,此时这两个点一定不在同一个 Splay 中,难以进行维护。
那么怎么办呢?很简单,把一个点旋转到根不就变成一条根链了吗?然后就可以把两个点搞到一个 Splay 里然后维护了。所以 makeroot(x)
实现的就是将
考虑这个操作怎样实现,假如我们先 access(x)
,将
这就是前面说的区间翻转操作,我们先 access(x)
搞出 Splay,然后整体打翻转标记即可。但是你发现现在你不知道这个 Splay 的根,那好办,把
il void makeroot(int p) {
access(p), splay(p);
pushrev(p);
}
4.2.3 find
find
操作的作用是找出 access(x)
,再 splay(x)
,此时以
根据辅助树上 Splay 的特性,由于原树的根是这条实链上从上到下第一个点,所以原树的根就是 Splay 中中序遍历的第一个点。因此不断走左儿子即可。
找到根节点之后还要 splay(x)
以保证复杂度。
il int find(int p) {
access(p), splay(p);
while(ls(p)) {
pushdown(p);
p = ls(p);
}
splay(p);
return p;
}
4.2.4 split
split(x, y)
的作用是将
il void split(int x, int y) {
makeroot(x), access(y), splay(y);
}
4.2.5 link
link(x, y)
的作用是将原树中的
il void link(int x, int y) {
makeroot(x);
if(find(y) != x) fa(x) = y;
}
4.2.6 cut
cut(x, y)
的作用是将原树中 makeroot(x)
,然后还要判断 access(y)
和 splay(x)
操作,这样可以保证如果两点之间有连边,必然是一条实边。
由于
il void cut(int x, int y) {
makeroot(x), access(y), splay(x);
if(fa(y) == x && !ls(y)) fa(y) = rs(x) = 0;
pushup(x);
}
至此我们就介绍完了 LCT 的所有基本函数。
4.3 代码
模板题:【模板】动态树(LCT),把上面的代码拼起来即可。
#include <bits/stdc++.h>
#define il inline
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
int n, m, a[Maxn];
struct node {
int son[2], fa, sum, tag, val;
}t[Maxn];
#define ls(p) t[p].son[0]
#define rs(p) t[p].son[1]
#define fa(p) t[p].fa
#define sum(p) t[p].sum
#define tag(p) t[p].tag
#define val(p) t[p].val
int tot = 0;
il void newnode(int v) {tot++, t[tot].val = t[tot].sum = v;}
il int get(int p) {return rs(fa(p)) == p;}
il int isroot(int p) {return ls(fa(p)) != p && rs(fa(p)) != p;}
il void pushup(int p) {sum(p) = sum(ls(p)) ^ sum(rs(p)) ^ val(p);}
il void pushrev(int p) {
swap(ls(p), rs(p));
tag(p) ^= 1;
}
il void pushdown(int p) {
if(tag(p)) {
pushrev(ls(p)), pushrev(rs(p));
tag(p) = 0;
}
}
il void update(int p) {
if(!isroot(p)) update(fa(p));
pushdown(p);
}
il void rotate(int p) {
int y = fa(p), z = fa(y), c = get(p);
if(!isroot(y)) t[z].son[get(y)] = p;
fa(t[p].son[c ^ 1]) = y;
t[y].son[c] = t[p].son[c ^ 1];
t[p].son[c ^ 1] = y;
fa(y) = p;
fa(p) = z;
pushup(y), pushup(p);
}
il void splay(int p) {
update(p);
int f = fa(p);
while(!isroot(p)) {
if(!isroot(f)) {
rotate(get(f) == get(p) ? f : p);
}
rotate(p);
f = fa(p);
}
}
il void access(int p) {
int x = 0;
while(p) {
splay(p);
rs(p) = x;
pushup(p);
x = p, p = fa(p);
}
}
il void makeroot(int p) {
access(p), splay(p);
pushrev(p);
}
il int find(int p) {
access(p), splay(p);
while(ls(p)) {
pushdown(p);
p = ls(p);
}
splay(p);
return p;
}
il void split(int x, int y) {
makeroot(x), access(y), splay(y);
}
il void link(int x, int y) {
makeroot(x);
if(find(y) != x) fa(x) = y;
}
il void cut(int x, int y) {
makeroot(x), access(y), splay(x);
if(fa(y) == x && !ls(y)) fa(y) = rs(x) = 0;
pushup(x);
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= n; i++) {
cin >> a[i];
newnode(a[i]);
}
while(m--) {
int opt, x, y;
cin >> opt >> x >> y;
switch(opt) {
case 0: {
split(x, y);
cout << t[y].sum << '\n';
break;
}
case 1: {
link(x, y);
break;
}
case 2: {
cut(x, y);
break;
}
case 3: {
splay(x);//先转到根再改
t[x].val = y;
pushup(x);
break;
}
}
}
return 0;
}
5 常用技巧
LCT 除了上述基本应用外,还有一些常见的维护技巧。
5.1 维护边权信息
上面讲的 LCT 是基于点权的,那么实际运用中显然会有树给出的是边权。我们可能会想将边权下放到每一个点上,但是这样做需要时刻维护一条边上的父子关系,保证下放到的是深度较大的。这个操作显然很难在 makeroot
的时候去简单维护出来。
考虑另一种方式,我们将每一条边
例 1 [WC2006] 水管局长
首先前半部分是经典套路,只需要维护出当前树的最小生成树,答案就是两点间边权最大值。然后删边改加边,现在的问题就是如何动态维护最小生成树。
我们建一颗 LCT 来维护,假如此时要插入一条新边
核心代码如下:
il void add(int id) {
int x = e[id].u, y = e[id].v, w = e[id].w;
val(id + n) = w;
mx(id + n) = mk(w, id + n);
if(find(x) != find(y)) {//直接相连
link(x, id + n);
link(y, id + n);
}
else {
split(x, y);
int v = mx(y).first, p = mx(y).second - n;//找出两点路径上最大值和下标
if(v > w) {//需要换掉最大值
cut(e[p].u, p + n);//删边
cut(e[p].v, p + n);
link(x, id + n);//加边
link(y, id + n);
}
}
}
例 2 [BJWC2010] 严格次小生成树
首先发现严格次小生成树和最小生成树之间一定只差了一条边,所以我们先跑出最小生成树。接下来枚举剩下的每一条边,我们计算出将这条边加入最小生成树后构造出来的次小生成树的值。
仍然使用 LCT 来维护,考虑加入一条边
例 3 最小差值生成树
我们考虑先枚举最大值,也就是将边按边权从小到大依次插入。此时我们希望全局的最小值尽可能大,考虑插入边
然后当我们合并次数达到 set
简单维护。
通过这三道题不难发现,LCT 维护生成树的关键就是插入的时候看删去环上的哪一条边,然后用 LCT 的动态加删边维护即可。
5.2 维护连通性
5.2.1 维护图上连通性
这一部分其实较为简单,查询两个点是否联通,实际上就是看它们两个 find
的结果是否一致,然后直接判断即可。注意此时必须满足图是森林或者没有删边操作才可以直接用 LCT。
例 1 [BZOJ3514] GERALD 加强版
题意: 给出一张无向图,询问保留图中编号在
由于强制在线,并且还要求一段区间的信息,所以考虑使用线段树。但是联通块个数这个信息难以在线段树上表示出来,所以我们需要换一种表示方式。
考虑对图中每一个联通块建出一个生成树,则有一个经典结论:森林的联通块个数是点数减边数。现在的问题又转化为
考虑固定 find
判断两点是否联通即可,但是这样的复杂度过高。如果足够敏锐的话会发现这个东西和前缀线性基是一个形式,所以我们也用前缀线性基的处理方式来处理。
具体的,我们不再每次挪动
由于只保留编号为
5.2.2 维护边双连通分量
动态维护边双连通分量同样可以考虑使用 LCT,不过注意我们只能实现加边操作。
我们加入一条边的时候,如果出现了环,那么我们就要将环上的所有点缩成一个点。具体的,我们执行 split(x,y)
取出两点间路径,然后遍历子树将这些点合并(指向根结点即可),这个合并过程需要使用并查集来维护。然后直接删去根节点的儿子即可。此时由于我们合并了某些节点,所以在 LCT 上操作的时候都要先找到其在并查集上真正对应的节点后才能操作。
例 1 [AHOI2005] 航线规划
不难发现如果我们对所有边双连通分量缩点后,答案就是
由于只支持加边,所以先删边改加边,然后按照上面的做法直接做。在 LCT 上额外维护一个 siz
即可求出路径上的点数信息了。
5.3 维护链上信息
这一个部分相对简单。考虑到 LCT 的基本结构是 Splay,所以我们自然可以将平衡树上的操作放到 LCT 上,实际中只需要实现懒标记即可。如此就可以维护一些信息较复杂的链上问题了。
例 1 [国家集训队] Tree II
这道题要求我们维护动态加删边和路径加、路径乘、路径求和操作。
不难看出前面的动态加删边就是朴素的 LCT,而后面就是线段树 2,所以只需要维护乘法标记、加法标记以及子树和即可。注意下放的时候先乘后加,乘法标记要更新加法标记等。此处不再赘述。
5.4 维护子树信息
上文我们讲到了很多关于 LCT 维护链信息的题目,而没有提到有关维护子树的内容。事实上,LCT 是长于维护链而弱于维护子树的。不过实际上,对于维护子树 LCT 也尚有一战之力。
考虑在辅助树上我们每个节点的 siz
维护的只是其 Splay 上节点总和,而丢失了的信息实际上只有虚儿子的总和。于是我们令新的 siz
表示该节点子树信息总和,vsz
表示该节点虚儿子信息总和。那么 pushup
中就应该有:
il void pushup(int p) {siz(p) = siz(ls(p)) + siz(rs(p)) + vsz(p) + 1;}
现在的问题是我们怎样维护 vsz
。考虑 LCT 中的每一个操作,关注实虚边的变化情况。
不难发现,所有操作中只有 access
、 link
操作改变了实虚边关系,所以我们在这些操作中维护 vsz
即可。
先看 access
,我们相当于将原来的右儿子变成一个虚儿子,将原先的某个虚儿子变成实儿子。所以一加一减即可,代码如下:
il void access(int p) {
int x = 0;
while(p) {
splay(p);
vsz(p) += siz(rs(p)) - siz(x), rs(p) = x;
pushup(p);
x = p, p = fa(p);
}
}
然后是 link
,显然操作只有将 vsz
加上 siz(x)
即可。但是 LCT 中的单点修改不能直接改,所以要先将
il void link(int x, int y) {split(x, y), fa(x) = y, vsz(y) += siz(x);}
//这里的 split(x,y) 恰好对应了原先的 makeroot(x) 以及转 y 的 access(y), splay(y) 操作
不过注意到这样维护的信息必须要有可减性,例如子树大小、子树权值和等;而对于子树最值的话可以利用树套树,即对每个节点开一棵平衡树来维护虚子树内的所有权值即可。
例 1 [BJOI2014] 大融合
没错又是这道题。加边操作不必多说,对于求负载我们可以先进行 cut(x,y)
,然后将两棵树的 siz
相乘得出答案,最后再 link(x,y)
即可。
所以直接套上面讲解的子树大小的模板即可。
5.5 其他题目
例 1 [BZOJ3159] 决战
发现此题中难操作的只有区间翻转这个操作。如果我们直接反转这个区间的话其实做的是 makeroot
操作,节点的深度就改变了。而如果只去翻转值,由于两个子树大小不一定一样,所以无法一一对应,难以保证正确性。
发现一棵树上只能维护权值和形态中的一种信息,那不妨将两者拆开维护。建立两个 LCT,一个用于维护形态,一个用于维护权值(实际上维护权值的 LCT 由于丢失了父亲信息,所以它其实只能算若干个 Splay)。注意二者的排序方式都是按照实链从上到下排序的。
我们在形态 LCT 上额外维护一个指针 pos
,指向该实链对应的权值树上的某一个节点,方便我们去找根。然后在 access
操作的时候我们需要将两棵树同时操作,所以每一次我们要在权值树中找出根节点对应点,对两个点同时断边并连上新边,保证二者维护的信息一致。同时断开边之后子树的 pos
值会改变,给子树打一个覆盖标记即可。
对于剩下的操作,只要会修改形态树结构的操作,都要一并修改权值树。核心代码如下:
struct LCT {
//...
}xb, qz;//下标 权值
il void access(int p) {//两颗树同步操作
int x = 0, y = 0;
while(p) {
xb.splay(p);
int pos = xb.col(p);//找到对应权值树
qz.splay(pos);
pos = qz.find(pos, xb.siz(xb.ls(p)) + 1);//找到根节点对应点并旋转至根
qz.splay(pos);
xb.pushcol(xb.rs(p), qz.rs(pos));//断开右子树后右子树 pos 值改变,打覆盖标记
xb.rs(p) = x;
xb.pushcol(p, pos);//对当前子树打覆盖标记
qz.fa(qz.rs(pos)) = 0;//断开权值树的边并连新边
qz.rs(pos) = y;
qz.fa(y) = pos;
xb.pushup(p), qz.pushup(pos);
x = p, y = pos;
p = xb.fa(p);
}
}
il void makeroot(int p) {
access(p);
xb.splay(p), qz.splay(xb.col(p));//形态树操作之后权值树也要操作
xb.pushrev(p), qz.pushrev(xb.col(p));
}
il void split(int x, int y) {makeroot(x), access(y), xb.splay(y), qz.splay(xb.col(y));}
il void link(int x, int y) {makeroot(x), xb.fa(x) = y;}
例 2 [THUWC2017] 在美妙的数学王国中畅游
首先我们发现此题除了函数操作以外就是 LCT 板子,但是这三个函数难以直接相加。题目中给了我们泰勒展开的式子,所以我们应该将这三个函数化成多项式形式然后就可以直接累加系数了。
考虑题目中给出的泰勒展开式,发现当
前面就是
注意到对于
不难发现其以四个值为一循环,
对于
所以
转化成多项式形式后就是 LCT 板子了,套上即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律