这是一份 \(\rm LCT\) 入门总结。
关于 \(\rm LCT\) 的复杂度这里不会提及,只会记录 \(\rm LCT\) 的基本操作和经典例题,但神奇的 \(\rm LCT\) 虽然常数巨大但还是 \(O(n \log n)\) 的优秀复杂度。
UPD on 2021.7.1 : 复杂度证明可以参考 这里
\(\rm Link-Cut-Tree\) 又名动态树,顾名思义他能支持动态维护树的形态即支持加边删边,那么这样一个神仙数据结构是怎样工作呢?
首先类似于树剖的思想,我们将原树剖分成链在链上做序列操作,但不同于树剖的是我们 \(\rm LCT\) 是随意认一个儿子连成链,下面称这个儿子为实儿子,并且这个儿子可能随意更改。具体的说我们会将一棵树剖分成一些链,下面我们成这条链为实链,如下图所示(感谢 \(\rm FlashHu\) 的图片)
可以看到在原树上我们向他的实儿子连一条实边即上图加粗的边,向他的虚儿子连一条虚边即上图中画成虚线的边。对于实边连成的一条链我们以深度为权值将其放入一颗 \(\rm Splay\) 中维护,并且对于每个点我们将他的儿子记作在 \(\rm Splay\) 中的左右儿子。那么对于每个点的父亲,如果该点不是其所在 \(\rm Splay\) 的根,那么他的父亲将是在 \(\rm Splay\) 中的父亲,否则他的父亲将是他所在实链顶端的父亲。根据这些定义我们可以将上图画成用 \(\rm Splay\) 维护之后的形态,如下图所示。
搞懂了这些定义之后我们就可以来看一些操作了,可以拿出草稿纸画画图,建议直接画出原树和虚实边。
我才不会告诉你下面没图了
-
\(\rm Access(x)\)
将 \(x\) 到根节点放入到同一颗 \(\rm Splay\) 中,即打通 \(x\) 到根节点的路径,这也是 \(\rm LCT\) 最为核心的操作。首先我们 \(\rm Splay(x)\) 将 \(x\) 旋到当前 \(\rm Splay\) 的根,那么这时候 \(x\) 的父亲将会是 \(x\) 所在实链顶端的父亲,那么我们就应该将其父亲的实儿子换成 \(x\) 即可,为了保持 \(\rm Splay\) 的形态,我们 \(\rm Splay(fa_x)\),那么此时 \(fa_x\) 的右儿子将会是所有深度比 \(fa_x\) 大的所在实链中的节点,即 \(fa_x\) 所在实链在 \(fa_x\) 下面的一部分,这时候我们将 \(fa_x\) 的右儿子改成 \(x\),我们重复这个操作知道 \(x\) 没有父亲为止。
-
\(\rm Makeroot(x)\)
很骚的一个操作,我们可以使用他将原树的根换为 \(x\)。首先根据前面定义的东西可以发现 \(LCT\) 并没有直接维护原树中的深度,而是通过 \(\rm Splay\) 维护其相对深度关系。那么不难发现我们将原树根换为 \(x\) 后,只有 \(x\) 到根的这条路径上点的相对深度关系会改变,并且恰好是将深度关系翻转过来了,我们知道 \(\rm Splay\) 是可以做区间翻转操作的,于是这个操作就可以解决了。具体地我们首先 \(\rm Access(x)\) 将 \(x\) 到根的路径放到一颗 \(\rm Splay\) 中,为了方便起见我们 \(\rm Splay(x)\) 将 \(x\) 旋到当前 \(\rm Splay\) 的根,接着只需要在 \(x\) 这个位置上打上区间翻转标记即可。
-
\(\rm Split(x, y)\)
将 \(x, y\) 这条路径放到同一个 \(\rm Splay\) 中,有了前面的两个操作,这个操作就不难了,我们首先 \(\rm Makeroot(x)\),然后 \(\rm Access(y)\) 即可。为了方便后面的操作我们再 \(\rm Splay(y)\).
-
\(\rm Findroot(x)\)
找到 \(x\) 所在原树中的树根。首先我们 \(\rm Access(x)\),接着 \(\rm Splay(x)\) 那么 \(x\) 所在实链最浅的节点即为原树的根,那么我们只需要不断查找 \(x\) 的左子树即可。
-
\(\rm Link(x, y)\)
在 \(x, y\) 中连一条边。终于到了 \(\rm LCT\) 支持的操作了。有一些毒瘤题可能 \(x, y\) 已经联通如果,我们在 \(x, y\) 之间直接连边那么这张图就不再会是一颗树,就会破坏 \(\rm LCT\) 的形态,为了避免这种情况我们必须要判断 \(x, y\) 的联通性。类似于并查集的思想我们首先 \(\rm Makeroot(x)\),接下来只需要判断 \(\rm Findroot(y) \ne x\) 即可。如果 \(x, y\) 未联通,在我们执行完 \(\rm Makeroot(x), Findroot(y)\) 之后此时 \(x\) 已经是所在 \(\rm Splay\) 的根,且 \(x\) 是 \(\rm Splay\) 中深度最小的点,那么我们直接令 \(fa_x = y\) 即可。
-
\(\rm Cut(x, y)\)
删除 \(x, y\) 之间的边。同样这个操作可能在某些题中也会不合法,因此我们要判断删边的合法性。首先我们需要判断 \(x, y\) 的联通性,像 \(\rm Link(x, y)\) 中的判断那样,首先 \(\rm Makeroot(x)\) 再判断 \(\rm Findroot(y) = x\)。同时要注意这样并不能判断 \(x, y\) 是否直接相连,如果 \(x, y\) 直接相连bugu的话那么 \(x, y\) 中将不会存在节点,即实链中不会存在深度大于 \(x\) 且小于 \(y\) 的节点,同样此时我们 \(\rm Makeroot(x), Findroot(y)\) 以后 \(x, y\) 在同一颗 \(\rm Splay\) 中且 \(y\) 为当前 \(\rm Splay\) 的根,那么我们只需判断 \(y\) 的左儿子是否为 \(x\),且 \(x\) 的右子树是否非空即可。如果删边操作合法,我们只需要把 \(fa_x, y\) 的左儿子改成 \(0\) 即可。
这就是 \(\rm LCT\) 的一些基本操作了,具体的题目 \(\rm Splay\) 中的节点维护的东西不同,但是关于 \(\rm LCT\) 我们一定要注意的一点就是因为我们有区间翻转操作,同时我们在 \(\rm rotate(x), Findroot(x)\) 的时候需要调用左右儿子信息,如果我们没有下传区间翻转标记那么我们当前调用的左右儿子的信息将会是不正确的,所以我们在 \(\rm Splay(x)\) 之前先将 \(x\) 到根的翻转标记下传,在跟普通平衡树一样 \(\rm Splay\) 即可。
下面放出【模板】Link Cut Tree 的代码及注释。
#include<bits/stdc++.h>
using namespace std;
#define N 500000 + 5
#define rep(i, l, r) for(int i = l; i <= r; ++i)
int n, m, x, y, opt, top, s[N], st[N], fa[N], val[N], tag[N], ch[N][2];
int read(){
char c; int x = 0, f = 1;
c = getchar();
while(c > '9' || c < '0'){ if(c == '-') f = -1; c = getchar();}
while(c >= '0' && c <= '9') x = x * 10 + c - '0', c = getchar();
return x * f;
}
int which(int x){
return (ch[fa[x]][1] == x);
} //查询 x 是其父亲的左儿子还是右儿子
void up(int x){
s[x] = s[ch[x][0]] ^ s[ch[x][1]] ^ val[x];
} //维护 Splay 中子树异或和
int isroot(int x){
return ((ch[fa[x]][0] != x) && (ch[fa[x]][1] != x));
} //判断 x 是否为当前 Splay 的根
void down(int x){
if(!tag[x]) return;
tag[x] = 0, tag[ch[x][0]] ^= 1, tag[ch[x][1]] ^= 1;
swap(ch[x][0], ch[x][1]);
} //下传区间翻转标记
void rotate(int x){
int y = fa[x], z = fa[y], k = which(x), w = ch[x][k ^ 1];
fa[w] = y, ch[y][k] = w;
fa[x] = z; if(!isroot(y)) ch[z][which(y)] = x; // 注意这里如果 y 为 Splay 的根那么 z 和 y 就不会在一条实链(Splay)中
fa[y] = x, ch[x][k ^ 1] = y;
up(y), up(x); //修改了父子关系,更新节点答案。
}
void Splay(int x){
int cur = x; st[++top] = x;
while(!isroot(cur)) cur = fa[cur], st[++top] = cur; //将 x 到根路径上的点压入栈
while(top) down(st[top--]); // 下传 x 到根路径上的翻转标记以便获取正确的儿子信息
while(!isroot(x)){
int y = fa[x], z = fa[y];
if(!isroot(y)){
if(which(x) == which(y)) rotate(y);
else rotate(x); //双旋
}
rotate(x);
}
}
void Access(int x){
for(int y = 0; x; y = x, x = fa[x]) Splay(x), ch[x][1] = y, up(x);
}
void Makeroot(int x){
Access(x), Splay(x), tag[x] ^= 1, down(x);
}
void Split(int x, int y){
Makeroot(x), Access(y), Splay(y);
}
int Findroot(int x){
Access(x), Splay(x);
while(ch[x][0]) x = ch[x][0], down(x);
return x;
}
void Link(int x, int y){
Makeroot(x);
if(Findroot(y) != x) fa[x] = y;
}
void Cut(int x, int y){
Makeroot(x);
if(Findroot(y) == x && ch[y][0] == x && !ch[x][1]) fa[x] = ch[y][0] = 0, up(y);
}
int main(){
n = read(), m = read();
rep(i, 1, n) val[i] = read();
rep(i, 1, m){
opt = read(), x = read(), y = read();
if(opt == 0) Split(x, y), printf("%d\n", s[y]);
if(opt == 1) Link(x, y);
if(opt == 2) Cut(x, y);
if(opt == 3) Splay(x), val[x] = y, up(x);
}
return 0;
}
看完了 \(\rm LCT\) 的一些基本操作之后让我们来看一些 \(\rm LCT\) 的经典例题。
维护链信息
这类问题一般使用和线段树一样的懒标记。
经典例题:[国家集训队]Tree II
题意:给定一颗大小为 \(n(n \le 10 ^ 5)\) 的树,支持加边删边,将一条链上的权值加上一个数,乘上一个数,求一条链上的权值和。
放到序列上来做就是 线段树2 了,首先如果没有加删边操作我们可以直接使用树剖,但加上加删边操作后我们只能考虑 \(\rm LCT\)。类似于线段树打懒标记的想法,我们也可以直接在每个节点打下加法乘法懒标记,具体来说对于一个修改 \(x, y\),我们首先 \(\rm Split(x, y)\) 直接在 \(y\) 上面打上懒标记即可,每次下传懒标记的时候先下传区间翻转标记,再像 线段树2 那样下传懒标记即可,但是这里我们并不知道区间的长度,所以我们还需要维护一个子树大小 \(s_x\).
其他的同类题目:
维护联通性 / 双连通分量
维护联通性的题目比较板或者与 \(\rm LCT\) 本身没有太大关系就不放在这里了。
这类问题的一般套路是将环缩成点处理。
题意:给出一张 \(n(n \le 3 \times 10 ^ 4)\) 个点 \(m(m \le 10 ^ 5)\) 条边的无向图,\(q(q \le 4 \times 10 ^ 4)\) 次操作,支持删边和查询两点之间割边的数量。
不难发现本题中修改操作只有删边,那么我们可以时间倒流从后往前依次加边。不难发现如果一条链在两端之间加一条边将形成一个环,那么这个环中的任何一条边都不会是割边,那么由此我们可以将连成的环缩成一个点,那么剩下的图将会是一棵树,查询两点之间的割边数量就是两点之间的点数减 \(1\).具体来讲我们加入边 \(x, y\) 的过程中如果 \(x, y\) 不连通,那么我们直接连边 \(x, y\),如果 \(x, y\) 已经联通那么 \(x, y\) 这条路径会形成一个环,我们将 \(x, y\) 这条路径上暴力缩成一个点,因为总共修改的点是 \(n\) 的,所以暴力修改的复杂度是对的。每次查询就只需要查询 \(x, y\) 这条路径上点的个数减 \(1\) 即可。但本题并不需要这么麻烦,对于已经联通的 \(x, y\),我们将 \(x, y\) 这条路径上的权值置成 \(0\) ,对于查询只需要查询 \(x, y\) 上的权值和即可。
其他同类题目:
维护生成树
这类问题的一般套路是断掉原树上的一条边再连一条边让答案更优。
经典例题:[WC2006]水管局长
题意:给定一张有 \(n(n \le 10 ^ 5)\) 个节点和 \(m(m \le 10 ^ 5)\) 条边的无向图,\(q(q \le 10 ^ 5)\) 次操作,支持删边和找到 \(x, y\) 之间的路径是得最大的边权最小。
修改只有删边操作的话按照套路考虑时间倒流。不难发现为了让最小化 \(x, y\) 路径上的最大边权,我们只需要维护一颗最小生成树即可,由 \(\rm Kruskal\) 的流程就可说明正确性。现在我们加入一条边 \(x, y\) 如果 \(x, y\) 之间没有联通,为了保证联通性我们直接将 \(x, y\) 连一条边。如果 \(x, y\) 之间已经联通,如果保留这条边那么我们就必须删掉 \(x, y\) 路径上的一条边才能继续维护树的形态。那么为了让答案更优,我们直接查询 \(x, y\) 路径上最大的边权 \(w\),如果 \(w\) 大于当前加入边的边权,那么我们将最大边权的边删掉,将当前边连上,否则加入当前边答案不会更优,那么直接跳过即可。查询我们就只需要查询 \(x, y\) 路径上的最大边权即可。注意我们这里都是维护的边信息,跟我们原 \(\rm LCT\) 维护点信息有所不同,所以我们要想办法将边信息放到点上。但是因为我们的 \(\rm LCT\) 需要支持换根,那么我们不能跟以前树剖的套路一样将与儿子连边的边权挂到儿子上,因为根会改变,原树的父子关系也会改变。但是如果我们建立一个虚点 \(x\),将边权转化为 \(x\) 的点权,接着将当前需要连接的两个点 \(u, v, \rm Link(u, x), Link(x, v)\) 即可,然后我们删边需要删掉这两条边。
其他同类题目:
维护子树信息
这类问题的一般套路是实儿子和虚儿子分开维护。
经典例题1:[BJOI2014]大融合
题意:完成 \(q(q \le 10 ^ 5)\) 次操作,支持无向边,查询经过 \(u, v\) 的简单路径数量。
不难发现这个查询实际上是 \(u\) 为根的子树大小和除此之外 \(v\) 为根的子树大小的乘积。那么现在的问题转化为需要维护子树大小和动态加边。回顾我们 \(\rm LCT\) 的操作,我们发现我们维护的所有的信息都是所在 \(\rm Splay\) 也就是实儿子的信息,这样看来 \(\rm LCT\) 貌似不能做这个问题?不难发现我们的问题在于没有维护虚儿子的信息,那么我们不直接维护虚儿子的信息就好了?于是我们令 \(s_x\) 为以 \(x\) 为根的 \(\rm Splay\) 子树整棵树包括虚儿子的大小和,和 \(es_x\) 表示 \(x\) 的虚子树大小之和。这样一来最开始所有儿子均为虚儿子,于是可以将 \(es\) 直接初始化为原树的 \(size\),那么我们在 \(\rm Access(x)\) 切换实虚儿子的时候将 \(es\) 更改即可。最后我们查询答案首先 \(\rm Makeroot(u)\),接着 \(\rm Access(v), Splay(v)\) 输出 \(s_u \times (s_v - s_u)\) 即可。
经典例题2:QTREE5 - Query on a tree V
题意:给定一颗大小为 \(n(n \le 10 ^ 5)\) 的树,每个点开始为黑色,完成 \(q\) 次操作,支持修改某个点的颜色(黑色变白色或白色变黑色),给定 \(u\) 查询距离 \(u\) 最近的白点距离。
对于树上任意两点 \(u, v\),\(dis_{u, v} = dep_u + dep_v - 2 \times dep_{lca}\),那么当我们 \(dep_u\) 固定的时候,我们只需要找到白点中最小的 \(dep_v - 2 \times dep_{lca}\) 即可。不难发现这里的 \(lca\) 是 \(u\) 到根路径上的一个点,那么我们对每个点开一个 \(set\) 来维护该节点虚子树和自身的 \(dep_v - 2 \times dep_{lca}\),对于实链上的信息我们直接维护整颗 \(\rm Splay\) 上所有节点包括虚子树的 \(dep_v - 2 \times dep_{lca}\) 最小值,显然这样是可以向上传递信息的,每次修改我们只需要 \(\rm Access(x), Splay(x)\) 那么 \(x\) 既不会是任意一个节点的虚儿子也不会是任意一个节点的实儿子,我们直接对 \(x\) 节点进行修改是不会对答案造成影响的。每次查询我们直接 \(\rm Access(x), Splay(x)\) 直接查询 \(x\) 的答案再加上 \(dep_x\) 即可。
经典例题3:QTREE4 - Query on a tree IV
题意:给定一颗大小为 \(n(n \le 10 ^ 5)\) 的树,每个点开始为黑色,完成 \(q\) 次操作,支持修改某个点的颜色(黑色变白色或白色变黑色),查询全局最远的白色点对。
实际上上面那个题的做法加上树的直径的性质也能做掉这个题,但下面我们将提出另外一种做法,当然这种做法也能做掉上面那个题。
这种做法类似于线段树求最大子段和的分治做法,对于 \(\rm Splay\) 上的节点 \(x\) 我们递归处理出最远的包含在左子树和右子树(包括虚儿子)的点对,再处理跨过 \(x\) 的点对,显然这样是能包含所有点对的,下面我们考虑如何用子节点的答案来更新父节点。先上一张图:
假设我们当前需要更新出以 \(4\) 为根的 \(\rm Splay\) 所在原树中包含虚儿子的答案,我们令其为 \(val_x\),和其他维护子树信息的方法一样,我们将实儿子和虚儿子分开维护,对于虚儿子我们可以直接继承虚儿子的答案,或者考虑跨过 \(4\) 号点的答案,那么我们只需要找到 \(4\) 往下虚儿子的最长链和次长链即可。于是对于每个虚儿子,我们维护两个 \(set\) 一个直接维护虚儿子的答案,一个维护虚儿子中的最长链,于是虚儿子对答案的贡献我们就处理完了,下面考虑实儿子。看一看上面的图,红框内的点表示 \(4\) 所在 \(\rm Splay\) 中的左右子树,黑框内的点表示整个左右子树包括虚儿子维护的子树范围。首先我们一样可以直接继承左右儿子的答案即 \(val_x = \max(val_{ls}, val_{rs})\),类似地再考虑跨过 \(x\) 的最长链,也就是 \(x\) 能往上走到的最远白点和往下或往虚子树内走到的最远白点的距离之和。但我们只维护出了左右儿子在其维护范围内的答案,不能直接维护出 \(x\) 往上走到的最远白点和往下或往虚子树内走到的最远白点的距离。为了能够表示出 \(x\) 往上走到的最远白点和往下或往虚子树内走到的最远白点的距离,我们可以发现 \(x\) 往上走到的最远白点距离就是 \(x\) 在原树中父亲即 \(2\) 能走到的最远白点距离加上 \(x\) 到父亲的距离,而 \(x\) 在原树的父亲就是 \(x\) 在 \(\rm Splay\) 中左子树中深度最小的点,这个是可以递归维护出来的,那么我们只需要令 \(lx_x\) 表示在 \(x\) 为根的左子树内深度最小的点能走到的最远白点距离,类似地令 \(rx_x\) 表示在 \(x\) 为根的左子树内深度最大的点能走到的最远白点距离,那么我们 \(x\) 往上走到的最远白点和往下能走到的最远白点距离就可以用 \(lx, rx\) 来表示了,这样向上更新答案就只需要分类讨论即可,\(lx, rx\) 的更新也类似。注意最后一个细节,我们这里都是维护的链长,但 \(\rm LCT\) 只能维护点权,注意到我们这个做法并不需要 \(\rm Makeroot\) 那么我们直接将边权下方到儿子节点即可,这样一来 \(x\) 能到达虚儿子中的最长链就是虚儿子的 \(lx_x\) 于是第二个 \(set\) 我们直接用来维护虚儿子 \(lx_x\) 即可,细节有点多,具体转移看代码。
其他同类型题目:
「Antileaf's Round」我们的 CPU 遭到攻击
维护树上染色联通块
这类问题的一般套路是将不同颜色分别开 \(\rm LCT\) 分开维护。
当然也有特例比如下面这题。
经典例题1:[SDOI2017]树点涂色
题意:给定一颗大小为 \(n\) 的有根树,最开始每个点的颜色不同,需要支持几种操作:将 \(x\) 到根路径上的点涂上一种没有出现过的颜色,查询 \(x, y\) 之间不同颜色的数量,查询以 \(x\) 为根的子树内的点到达根的路径上不同颜色的数量的最大值。
首先要注意到树上每一段颜色互不相同,如果没有操作 \(3\) 那么我们只需要写一个 \(\rm LCT\) 支持区间覆盖和查询颜色段即可,但加入操作 \(3\) 后我们不能继续这么做所以需要转变思路。想到这类问题的一般套路将不同颜色分别维护一颗 \(\rm LCT\) 但这样显然不现实,因为颜色太多了,因此这种办法也行不通。但是我们需要注意到问题当中的修改操作只需要修改 \(x\) 到根上的一条路径并涂上一种新的颜色,这是不是有点像 \(\rm LCT\) 中的 \(\rm Access\) 操作?进一步想 \(\rm Access\) 后 \(x\) 到根的路径放到了同一颗 \(\rm Splay\) 当中,这是不是恰好对应着染成了同一种颜色,那么我们就有了一个想法,在同一 \(\rm Splay\) 中维护同一种颜色,那么 \(\rm Access\) 就会将 \(\rm Splay\) 和在一起也就相当于染成同一种颜色,那么操作 \(2\) 就只需要查询 \(x\) 到根有多少棵 \(\rm Splay\) 即可。但这样还是不能直接做操作 \(3\),我们想想能不能直接维护每个点到根的颜色数量呢?事实上是可以的,每次我们在 \(\rm Access\) 断连虚实儿子合并 \(\rm Splay\) 的时候对于虚儿子中的所有节点相当于减少了到根的一种颜色,而对于其实儿子相当于增加了一种到根的颜色,那么我们就只需要写一颗线段树支持子树修改即可。最后查询我们只需要查询子树内的最大值,用线段树维护最大值即可。
经典例题2:QTREE6 - Query on a tree VI
题意:给定一颗大小为 \(n\) 的树,初始所有点为黑色,需要支持两种操作:给定 \(u\) 查询有多少个 \(v\) 满足 \(u, v\) 的路径上均为同种颜色,修改某个点的颜色(白变黑,黑变白)
首先按照该类型的套路我们分开维护两个 \(\rm LCT\),在白色 \(\rm LCT\) 上将树上边两端均为白点的边连上,黑色 \(\rm LCT\) 类似。于是每次查询我们只需要输出 \(x\) 所在连通块的大小即可,但是每次修改操作需要暴力枚举 \(x\) 周围的边暴力删边,这样菊花图就可以卡掉这个做法,于是我们需要考虑将这个做法优化。不难发现这个做法的问题在于 \(\rm LCT\) 只能支持删边操作而我们需要支持删点操作,那么我们如果能将删点转化为删边问题将得以解决。根据我们边权下方到儿子节点的套路,我们能否反其道而行之将节点颜色放到边上呢,事实上是可以的。具体来讲我们对于一个白点 \(x\) 我们将它和父亲的边在白色 \(\rm LCT\) 上连上,在黑色 \(\rm LCT\) 上断开,黑点相反。那么不难发现去掉当前联通块的根即最上面那个点后的连通块就和原来我们删点后的联通块一致了,因为最上面那个点与父亲没有边,也就意味这他不会属于这个联通块。那么对于每次查询,我们 \(\rm Access(x), Splay(x)\) 后查询右子树的大小加 \(1\) 即可。
其他同类型题目:
至此 \(\rm LCT\) 的基本操作和经典例题已经记录完毕,剩下的就是 \(\rm LCT\) 的一些终极难题。