LCT入门

· · 算法·理论

我们有这样一个问题:修改点权,询问链上的点权和。这明显是个树链剖分模版。
但如果还有这些操作呢:断开一条边,连上一条边,保证一直是森林。这就是动态树的一种问题。
而 LCT 就是解决这些问题的优秀数据结构。

前言

建议是会 Splay,虽然 FHQ-Treap 也能写,但是多一个 \log
Splay 只要会一些简单的序列操作和打懒标记就好了。
点进来的都会树剖会吧,不会的话也行。
Splay 表示树,splay 表示伸展操作。

动态树不是 LCT,LCT 是解决动态树问题的数据结构。

实现

实链剖分

在树剖中,最常用的是重链剖分。
而在动态树中,问题的瓶颈变成了怎么让树链的划分状况随树的形态快速修改。
但是,size 显然是难以维护的。
那么只要没有规则,不就可以快速修改了吗?
我们让每个节点的重儿子自己选择,这样在改变树形态的时候可以更自由地维护树链的变化。

这就是实链剖分。
我们选择在一条链上的边叫实边,链接不同链的边叫虚边。
节点用实边链接的是实儿子,其余是虚儿子。

每个节点至多有一个实儿子。
实链剖分和重链剖分的区别在于:一条链不一定会链接到叶子节点

辅助树

现在,这棵树这我们分成了若干实链,现在,我们就需要辅助树维护这些链。而这棵辅助树往往是 Splay。

显然辅助树上的节点和原树上的节点一一对应。
我们还需要在辅助树上的中序遍历就是一条实链。

那么,我们怎么将原树的虚实链对应到辅助树上呢?
我们在 Splay 记录了儿子和父亲两个信息。
那么记录链上的每个点的父亲。
但是对于虚链,不记录其父亲的儿子信息,即认父不认子
对于实链,我们需要修改其父亲的儿子。
每个 Splay 的根是一条实链的顶点,而根也有可能有父亲节点。

显然一棵树的辅助树的形态不止一种。

那么我们得到了辅助树和原树之间的关系:

两种写法没有什么本质区别,但在后面一些地方 pushdown 的顺序写起来不一样。
第一种写法大多时候代码更简单,但有些题,维护的信息与左右儿子的顺序有关,这个时候只能用第二种写法。
第一种:

void pushdown(int x){  
    if(!d[x].rev)return;  
    swap(ls(x), swap(rs(x))), d[ls(x)].rev ^= 1, d[rs(x)].rev ^= 1, d[x].rev = 0;  
}  

第二种

void change(int x){d[x].rev ^= 1, swap(ls(x), rs(x));}  
void pushdown(int x){  
    if(d[x].rev)change(ls(x)), change(rs(x)), d[x].rev = 0;  
}  

alldown

之前进行区间修改的时候,需要查排名,会将从根到 x 路径上的懒标记下放。
但 LCT 不关心排名,我们就需要 alldown 函数来下放。

//递归  
void alldown(int x){  
    if(!isroot(x))alldown(fa(x));  
    pushdown(x);  
}  
//栈  
void alldown(int x){  
    int stk[N], top = 0;  
    do stk[++top] = x, x = fa(x) while(!isroot(x));  
    while(top--)pushdown(stk[top + 1]);  
}  

rotate

rotate 与原义相同,把 x 向上旋转一层。
由于我们要判断 y 是否是根,不能再简单判断 z 是否是 0
y 和父亲的连边断开再判断失效,所以我们把这句话提到前面。

void rotate(int x){  
    int y = fa(x), z = fa(y), c = get(x);  
    if(!isroot(y))d[z].ch[get(y)] = x;  
    fa(d[y].ch[c] = d[x].ch[!c]) = y, fa(fa(d[x].ch[!c] = y) = x) = z;  
    pushup(y), pushup(x);  
}  

Splay

把判断是否为根的语句换成 isroot 即可。注意 Splay 之前要先 alldown 这条路径。

void splay(int x){  
    for(int f = fa((alldown(x), x));f = fa(x), !isroot(x);rotate(x))  
        if(!isroot(f))rotate(get(f) ^ get(x) ? x : f);  
}  

汇总

struct node{  
    int rev, size, ch[2], fa, s, val;  
}d[N];  
#define ls(x) d[x].ch[0]  
#define rs(x) d[x].ch[1]  
#define fa(x) d[x].fa  
#define get(x) (rs(fa(x)) == x)  
#define isroot(x) (rs(fa(x)) != x && ls(fa(x)) != x)  
void pushup(int x){d[x].s = d[ls(x)].s + d[rs(x)].s + d[x].val;}  
void change(int x){d[x].rev ^= 1, swap(ls(x), rs(x));}  
void pushdown(int x){if(d[x].rev)change(ls(x)), change(rs(x)), d[x].rev = 0;}  
void alldown(int x){  
    if(!isroot(x))alldown(fa(x));  
    pushdown(x);  
}  
void rotate(int x){  
    int y = fa(x), z = fa(y), c = get(x);  
    if(!isroot(y))d[z].ch[get(y)] = x;  
    fa(d[y].ch[c] = d[x].ch[!c]) = y, fa(fa(d[x].ch[!c] = y) = x) = z;  
    pushup(y), pushup(x);  
}  
void splay(int x){  
    for(int f = fa((update(x), x));f = fa(x), !isroot(x);rotate(x))  
        if(!isroot(f))rotate(get(f) ^ get(x) ? x : f);  
}  

新的操作

access

access(x) 的作用是把 x 到根的路径上的点放进一棵 Splay 里,且这棵 Splay 里没有这条路径以外的点。
LCT 的所有函数都需要 access 操作
左边是 access(N) 后的原树,右边是辅助树的更改过程。

我们整理一下过程。

注意懒标记和判断先后顺序表示。
找到根后要 splay(x) 来保证复杂度。

int find(int x){  
    access(x), splay(x);  
    while(ls(x))pushdown(x), x = ls(x);  
    return splay(x), x;   
}  

split

split(x, y) 的作用是把 xy 的路径变成一棵 Splay。
我们先 makeroot(x) 接下来执行 access(y),就找到了这条路径。
还有个问题是这样不知道 Splay 的根,所以后面一般会再做一步 splay(y)

void split(int x,int y) {makeroot(x),access(y),splay(y);}  

link

link(x,y) 表示给 xy 之间连一条边。
那么我们先 makeroot(x),让 x 成为自己这棵树的根,然后判断它们两个是否已经连通。
如果不连通,直接把点 x 作为虚儿子单向指向 y 即可。

void link(int x, int y){makeroot(x);if(find(y) != x)fa(x) = y;}  

cut

cut(x, y) 表示把 xy 的边断开。
我们先 Split(x, y),这时候 y 是根,x 一定是它的儿子,双向断开即可。
不过还要判是否有边,我们发现,它们右边当且仅当:

来源 oi.wiki。

LCT 中的大部分操作都基于 access,其余操作的时间复杂度都为常数,因此我们只需要分析 access 操作的时间复杂度。
其中,access 的时间复杂度主要来自于多次 splay 操作和对路径中虚边的访问,接下来分别分析这两部分的时间复杂度。

  1. splay
  1. 访问虚边
    定义两种虚边:
    • 重虚边:从节点 v 到其父节点的虚边,其中 size(v) > \frac{1}{2} size(parent(v))
    • 轻虚边:从节点 v 到其父节点的虚边,其中 size(v) \leq \frac{1}{2} size(parent(v))

对于虚边的处理,可以使用势能分析,定义势能函数 \Phi 为所有重虚边的数量,定义均摊成本 c_i = t_i + \Delta \Phi_i,其中 t_i 为实际操作的成本,\Delta \Phi_i 为势能的变化。

综上所述,LCT 中 access 操作的时间复杂度是 splay 和 虚边访问的复杂度之和,因此最后的均摊复杂度为 O(\log n),即 n 个节点的 LCT,做 m 次 access 操作的时间复杂度为 O(n \log n + m \log n),从而 LCT 操作的均摊复杂度也为 O(\log n)

完整代码

这里给出洛谷模板题 P3690 的代码。

#include<bits/stdc++.h>  
using namespace std;  
#define endl '\n'  
#define FL(a, b, c) for(int a = (b), a##end = (c); a <= a##end; a++)  
#define FR(a, b, c) for(int a = (b), a##end = (c); a >= a##end; a--)  
#define lowbit(x) ((x) & -(x))  
#define eb emplace_back  
constexpr int N = 1e6 + 10;  
struct node{  
    int rev, size, ch[2], fa, s, val;  
}d[N];  
#define ls(x) d[x].ch[0]  
#define rs(x) d[x].ch[1]  
#define fa(x) d[x].fa  
#define get(x) (rs(fa(x)) == x)  
#define isroot(x) (rs(fa(x)) != x && ls(fa(x)) != x)  
void pushup(int x){d[x].s = d[ls(x)].s ^ d[rs(x)].s ^ d[x].val;}  
void change(int x){d[x].rev ^= 1, swap(ls(x), rs(x));}  
void pushdown(int x){if(d[x].rev)change(ls(x)), change(rs(x)), d[x].rev = 0;}  
void alldown(int x){  
    if(!isroot(x))alldown(fa(x));  
    pushdown(x);  
}  
void rotate(int x){  
    int y = fa(x), z = fa(y), c = get(x);  
    if(!isroot(y))d[z].ch[get(y)] = x;  
    fa(d[y].ch[c] = d[x].ch[!c]) = y, fa(fa(d[x].ch[!c] = y) = x) = z;  
    pushup(y), pushup(x);  
}  
void splay(int x){  
    for(int f = fa((alldown(x), x));f = fa(x), !isroot(x);rotate(x))  
        if(!isroot(f))rotate(get(f) ^ get(x) ? x : f);  
}  
void access(int x){for(int s = 0; x; s = x, x = fa(x))splay(x), rs(x) = s, pushup(x);}  
void makeroot(int x){access(x), splay(x), change(x);}  
int find(int x){  
    access(x), splay(x);  
    while(ls(x))pushdown(x), x = ls(x);  
    return splay(x), x;   
}  
void split(int x, int y){makeroot(x), access(y), splay(y);}  
void link(int x, int y){makeroot(x);if(find(y) != x)fa(x) = y;}  
void cut(int x, int y){split(x, y);if(fa(x) == y && !ls(x))fa(x) = ls(y) = 0;}  
int32_t main(){  
    cin.tie(0)->sync_with_stdio(0);  
    int n, m;  
    cin >> n >> m;  
    FL(i, 1, n)cin >> d[i].val;  
    while(m--){  
        int op, x, y;  
        cin >> op >> x >> y;  
        switch(op){  
            case 0: split(x, y), cout<< d[y].s << endl;break;  
            case 1: link(x, y);break;  
            case 2: cut(x, y);break;  
            case 3: makeroot(x), splay(x), d[x].val = y;  
        }  
    }  
    return 0;  
}