顶级树上数据结构 LCT
Link/Cut Tree (动态树)
动态树问题, 维护一个森林, 支持但不仅限于如下操作:
-
Query
查询路径信息.
-
Link
连接两个不同树上的点.
-
Cut
断开两点之间的连边.
-
Change
单点修改
给出的解决方案是 Link/Cut Tree, 基于 Splay 的数据结构, 简称 LCT.
关于 Splay 的内容.
LCT 是对原树进行树链剖分后用 Splay 树 (以下简称 Splay) 维护树链的数据结构, 这里的树链剖分指实链剖分, 如果你还不会轻重链剖分, 请先学习轻重链剖分, 因为轻重链剖分是最简单的树链剖分.
LCT
对于一棵树, 对它建立 LCT. LCT 也是一棵树, 是由若干 Splay 组成的.
原树上的边划分为两种, 实边和虚边. 在原树中, 每个节点最多只能有一条连接自己和儿子的实边 (注意, 也可以没有, 这里是区别于轻重链剖分的地方), 其余都是虚边. 原树可以被分成若干实链 (实边连接的极大链, 也就是将虚边去掉后剩下的若干连通块).
LCT 就是对每个实链建立 Splay, 然后用虚边将 Splay 连接起来. 根据树链剖分的原理得, 原树每个实链是竖直的, 即两端点的 LCA 必然是其中一个. 所以对于一条实链, 它的节点的深度是连续的, 从链顶到链底递增.
规定 Splay 的中序遍历递增的权值为 Splay 的性质权值, 也就是 Splay 的 BST 的 Key. LCT 中每个 Splay 的性质权值就是每个节点在原树中的深度.
对于 LCT 上的虚边, 可以理解成单向边, 从一棵 Splay 的根引出, 连向另一棵 Splay 的节点. 连边的原则是: LCT 中的一棵 Splay 的根连向它在原树中所在实链链顶的连接父亲的虚边连接的那个节点在 LCT 中的节点. 可能有些绕, 换个角度理解. 先规定父端点指虚边深度大的端点, 另一个端点即为子端点. 原树的虚边和 LCT 中的虚边一一对应, 原树虚边的父端点在 LCT 中不变, LCT 中父端点还是原树父端点在 LCT 中对应的点. 子端点发生改变, LCT 中, 一条虚边的子端点是原树的子端点对应节点所在的 Splay 的根.
根据一棵 LCT 可以构造出唯一的一棵树. 这是因为每个 Splay 构造出的实链是唯一的, 而实链的连接方式, 即虚边也是确定的. 因为它的父端点是确定的, 就是 LCT 中虚边父端点对应的节点, 子节点也是确定的, 因为实链的链顶是确定的.
因此我们完全可以用 LCT 来存原树, 无需再保存原树. 对于原树链上的信息, 直接通过 LCT 查询, 修改. 后面说的关于原树的操作只是一个模型便于理解, 至于实现, 则完全是在 LCT 上的操作.
使 LCT 强于轻重链剖分和并查集的是它可以使原树的边在虚实间变换, 来实现两棵树的连接, 树的换根等操作.
本文默认读者已经掌握了 Splay 的所有操作.
虚实
对于存边的方式, 由于不存原树, 所以只要考虑 LCT 上的实虚边怎么存即可.
对于实边, 就像 Splay 中那样, 每个点保存
但是懒出名的我曾经在写 AC 自动机的时候都坚决不写邻接表 (结果最后还是不得不写), 这次必然会想办法偷懒. 先说结论, 因为 LCT 上的每个操作需要通过虚边走到别的点的时候, 都是从下到上访问的, 所以可以之存指向父亲的单向边. 而每个节点只有一个父亲, 所以每个点存一个指针就能避免边表.
你的虚边指针, 何必额外定义, 思考实边中指向父亲的指针是否能够胜任. 首先, 指向父亲的边非实即虚, 也就是虚实不会冲突. 其次, 可以判断一个指向父亲的边是虚还是实, 只要一个节点不是父亲的左儿子也不是右儿子, 那么就可以鉴定这个指针是实还是虚.
所以一个边是虚边, 只要存儿子的
接下来介绍操作原理, 先从结构操作讲起, 这一部分是 LCT 的形态结构的各种变换.
Access
LCT 的核心, 可以将原树中根到某点
考虑原树上的改变. 先把所有
考虑从树上从根出发寻找到达某节点的路径的困难, 所以选择从节点
无需操作.
-
对于
Tag = 1 的x 交换
x 左右儿子的指针, 然后对两个儿子Make_Tag
Find_Root
寻根, 用来判断两点的连通性, 只要所在原树树根相同, 则两点在一棵树上. 这颇似用并查集维护的集合, 只要对比并查集的编号就可查询两个元素是否在同一集合内.
操作也很简单, 寻找节点 Access(x), Splay(x), 这时
Link
对两个节点 Link(x, y) 操作, 分两种情况讨论.
对两点是否连通的判断可以通过 Make_Root(x) 后判断 Find_Root(y) 是否等于
-
两点在同一棵树上
这时两点连通, 无需操作.
-
两点在不在同一棵树上
在两点间连边, 考虑如何在连边时维护 LCT 性质. 这时, 因为判断重复时用到了
Make_Root(x),Find_Root(y)所以这时x 是原树的树根, 也是对应 LCT 的树根. 这时将x 连一条虚边向y , 因为这样不用考虑y 的儿子分布. 并且也满足原树中边x-y 的出现.所以操作就是判断
x ,y 不连通后, 直接将y 置为x 的父亲.
Cut
这个操作只针对有直接连边的两点, 所以仍然首先讨论连通性.
-
两点在不在同一棵树上
这时根本就不连通, 无需操作.
-
两点在同一棵树上
这时
x 是原树根,y 是x 所在 Splay 的根, 两点在原树中是某实链的两端. 如果有直接连边, 则两点在 Splay 中,x 是y 的左儿子, 且没有其它的点在这个 Splay 中(可能存在x 有右子树的情况, 这时,x 的右子树在实链上就夹在x ,y 之间), 否则没有连边, 直接跳出.对于有连边的情况, 直接断开
x ,y 的实边连接, 将点数为2 的 Splay 分成两棵点数为1 的 Splay,x ,y 分别为两棵新的原树的根节点, 操作完成.
接下来是信息操作, 这一部分是运用 LCT 维护数据的操作.
在每个节点上维护
至于变换时维护
Query
查询路径
首先要 Make_Root(x), Access(y), 这时
Change
为了方便维护 Sum, 直接 Splay(x), 修改
代码实现 模板 Luogu P3690
维护一个森林, 支持四个操作:
-
Query
为了考虑精度, 查询路径异或和.
-
Change
直接修改单点的权值.
-
Link
判断连通, 如果不连通就连边.
-
Cut
判断是否有连边, 有连边就断开.
一开始给
分函数分别解析, 虽然有些函数和 Splay 同名, 但是有部分不同点.
Update()
用一个点的儿子更新这个点的
inline void Update(Node *x) {
x->Sum = x->Value;
if(x->Son[0]) {
x->Sum ^= x->Son[0]->Sum;
}
if(x->Son[1]) {
x->Sum ^= x->Son[1]->Sum;
}
return;
}
Push_Down()
下传标记, 前面提到过, 选取常数较小的写法. 每次打
inline void Push_Down(Node *x) { // Push_Down the spliting tag
if(x->Tag) {
register Node *TmpSon(x->Son[0]);
x->Tag = 0, x->Son[0] = x->Son[1], x->Son[1] = TmpSon;
if(x->Son[0]) {
x->Son[0]->Tag ^= 1;
}
if(x->Son[1]) {
x->Son[1]->Tag ^= 1;
}
}
}
Rotate()
和单独的 Splay 中的写法差别不大, 将 Splay 维护的 Size 转换为 LCT 中维护的 Sum 即可.
inline void Rotate(Node *x) {
register Node *Father(x->Fa);
x->Fa = Father->Fa; // x link to grandfather
if(Father->Fa) {
if(Father->Fa->Son[0] == Father) {
Father->Fa->Son[0] = x; // grandfather link to x
}
if(Father->Fa->Son[1] == Father) {
Father->Fa->Son[1] = x; // grandfather link to x
}
}
x->Sum = 0, Father->Fa = x;
if(Father->Son[0] == x) {
Father->Son[0] = x->Son[1];
if(Father->Son[0]) {
Father->Son[0]->Fa = Father;
}
x->Son[1] = Father;
if(x->Son[0]) {
x->Sum = x->Son[0]->Sum;
}
}
else {
Father->Son[1] = x->Son[0];
if(Father->Son[1]) {
Father->Son[1]->Fa = Father;
}
x->Son[0] = Father;
if(x->Son[1]) {
x->Sum = x->Son[1]->Sum;
}
}
Update(Father);
x->Sum ^= x->Value ^ Father->Sum;
return;
}
Splay()
和纯 Splay 中的写法有很大不同, 因为 LCT 中的 Splay 是带有 Push_Down() 所经历的链. 为了从上到下遍历, 用栈存储回溯路径.
void Splay (Node *x) {
register unsigned Head(0);
while (x->Fa) { // 父亲没到头
if(x->Fa->Son[0] == x || x->Fa->Son[1] == x) { // x is the preferred-edge linked son (实边连接的儿子)
Stack[++Head] = x;
x = x->Fa;
continue;
}
break;
}
Push_Down(x);
if(Head) {
for (register unsigned i(Head); i > 0; --i) {//Must be sure there's no tags alone Root-x, and delete Root->Fa for a while
Push_Down(Stack[i]);
}
x = Stack[1];
while (x->Fa) { // 父亲没到头
if(x->Fa->Son[0] == x || x->Fa->Son[1] == x) { // x is the preferred-edge linked son (实边连接的儿子)
if (x->Fa->Fa) {
if (x->Fa->Fa->Son[0] == x->Fa || x->Fa->Fa->Son[1] == x->Fa) { // Father
Rotate((x->Fa->Son[0] == x)^(x->Fa->Fa->Son[0] == x->Fa) ? x : x->Fa);
} // End
}
Rotate(x); //最后一次旋转
}
else {
break;
}
}
}
return;
}
Access()
这个操作在变量写得合理的情况下, 可以把一开始的边界情况处理到循环里去, 一个循环解决问题. 但是为了逻辑清晰, 这次还是写得麻烦了一些.
void Access (Node *x) { // Let x be the bottom of the chain where the root at
Splay(x), x->Son[1] = NULL, Update(x); // Delete x's right son
Node *Father(x->Fa);
while (Father) {
Splay(Father), Father->Son[1] = x; // Change the right son
x = Father, Father = x->Fa, Update(x); // Go up
}
return;
}
Find_Root()
结构很简单的函数, 有人说找到根节点 (原树) 后, Splay(x), 维护复杂度, 实验证明, 最后的 Splay(x) 存在与否不影响效率.
Node *Find_Root(Node *x) { // Find the root
Access(x), Splay(x), Push_Down(x);
while (x->Son[0]) {
x = x->Son[0], Push_Down(x);
}
return x;
}
实现操作的接口
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
#define Wild_Donkey 0
using namespace std;
inline unsigned RD() {
unsigned intmp = 0;
char rdch(getchar());
while (rdch < '0' || rdch > '9') {
rdch = getchar();
}
while (rdch >= '0' && rdch <= '9') {
intmp = intmp * 10 + rdch - '0';
rdch = getchar();
}
return intmp;
}
unsigned a[10005], n, m, Cnt(0), Tmp(0), Mx;
bool flg(0);
char inch, List[155][75];
struct Node {
Node *Son[2], *Fa;
char Tag;
unsigned Value, Sum;
}N[100005], *Stack[100005];
int main() {
n = RD();
m = RD();
for (register unsigned i(1); i <= n; ++i) {
N[i].Value = RD();
}
register unsigned A, B, C;
for (register unsigned i(1); i <= m; ++i) {
A = RD();
B = RD();
C = RD();
switch (A) {
case 0: { // Query
Access(N + B), Splay(N + B), N[B].Tag ^= 1; // x 为根
Access(N + C); // y 和 x 为同一实链两端
Splay(N + C); // y 为所在实链的 Splay 的根
printf("%u\n", N[C].Sum);
break;
}
case 1: { // Link
Access(N + B), Splay(N + B), N[B].Tag ^= 1; // x 为根, 也是所在 Splay 的根
if(Find_Root(N + C) != N + B) {// x, y 不连通, x 在 Fink_Root 时已经是它所在 Splay 的根了, 也是它原树根所在实链顶, 左子树为空
N[B].Fa = N + C; // 父指针
}
break;
}
case 2: { // Cut
Access(N + B), Splay(N + B), N[B].Tag ^= 1; // x 为根, 也是所在 Splay 的根
if(Find_Root(N + C) == N + B) { // x, y 连通
if(N[B].Fa == N + C && !(N[B].Son[1])) {
N[B].Fa = N[C].Son[0] = NULL; // 断边
Update(N + C);
}
}
break;
}
case 3: { // Change
Splay(N + B); // 转到根上
N[B].Value = C; // 改权值
break;
}
}
}
return Wild_Donkey;
}
复杂度分析
因为贡献复杂度的是
因为一开始的点都是独立的, 所以这时什么操作复杂度都是
随着连通块的增大, LCT 的单个 Splay 的复杂度是
后记
LCT 作为顶级树上数据结构, 在代码结构独特如此的情况下着实难调, 坑也不少, 希望考场上能吸取这几天 Debug 的教训.
由于时间线拉的比较长, 后半部分是在写完前半部分后花了