【算法】LCT
参考资料
前言
第一次学,感觉这玩意挺抽象……只能写下博客巩固一下印象。
概念
前置知识:树链剖分,Splay
给定一棵树,没有任何的更改操作,询问一些有关树上路径问题(如两点之间的权值和),就可以用树上倍增。而如果在此基础上增添了更改某个点的权值的操作,就需要用到树链剖分了。但是再加一个在保证仍然是一棵树的前提下,断开并连接一些边,就变成了动态树问题了。我们一般用 LCT 来求解动态树问题。
我们可以简单的把 LCT 理解成用一些 Splay 来维护动态的树链剖分,以期实现动态树上的区间操作。对于每条实链,我们建一个 Splay 来维护整个链区间的信息。 ——OI-Wiki
实链
这里出现了一个可能之前没见过的词——实链,它是链剖分的一种形式。为什么 LCT 不用重链剖分或者长链剖分?这是由于在 LCT 里面我们要能自由选择儿子,如果是重链剖分或者长链剖分,它的重儿子是所有儿子里面子树最长的或者深度最大的,没有给我们自由选择的权利。而实链剖分中我们可以自由选择哪些是实儿子,哪些是虚儿子。这种灵活可变性就是我们选择实链剖分的重要原因。
辅助树
辅助树可以理解为多个 splay 组成一个结构,其中每一个 splay 都维护了辅助树中的一条边。鉴于大多数情况下动态树问题可能维护的是一个森林,所以 LCT 实际上维护的是这些辅助树组成的森林。
关于辅助树的主要性质如下:
- 中序遍历每一个 splay 得到的序列对应原树中从上到下遍历的一条路径。也可以理解为中序遍历每一个 splay 得到的序列都是一段深度严格递增的序列。
- 每个节点都在且仅在一个 splay 里面出现
- 实链剖分的实边存在于 splay 里面,虚边是由一个 splay 的根节点指向该 splay 中序遍历最靠前的点在原树中的父亲。虚边和实边的区别在于虚边的儿子认父亲,而父亲不认儿子。
实现
重建重路径:access
函数
它的作用相当于重新建一条从 \(x\) 到根节点的实路径,即在同一棵 splay 里面。这个过程中可能会有一些边由虚变实,相应的也会有一些边由实变虚。
实现的方法是从下到上更新 splay,先将 \(x\) 转到当前这棵 splay 的根,这样方便进行之后的连边操作。这一操作导致需要更换 \(x\) 的实儿子以及更新 \(x\) 的信息。然后把 \(x\) 的父亲按照类似的方法转到它所在的 splay 的根,并将它的实儿子更换为 \(x\)。
void access(int x){
for(int las=0;x;x=fa[x]){
splay(x);
tree[x].son[1]=las;
pushup(x);
las=x;
}
}
换根:makeroot
函数
在维护路径信息的时候可能会出现路径深度无法严格递增的情况,这时候这条路径就完全不可能在同一个 splay 中,我们就需要将这条路径的一个端点在原树中换到根节点的位置,这样就能保证这条路径一定是严格递增的。
换根可以抽象理解为一条路径上的一些点的深度被更改了。我们先建出要求换成的节点 \(x\) 到根节点的一条实路径,即进行 access
操作。操作后的 \(x\) 一定和根节点在同一条实链上并且深度最深,导致再将 \(x\) 转上来之后它将没有右子树,所以将这棵 splay 翻转后会将它的左子树(即深度比它小的节点)都翻转到右子树(变成深度比它大的节点),这样 \(x\) 没有左子树就变成了深度最小的节点,成为了根节点。
void makeroot(int x){
access(x);
splay(x);
addtag(x);
}
分割:split
函数
相当于把维护 \(x\) 到 \(y\) 路径的 splay 树给拿出来(但不是真的拿,也就是不是真的把边给断了拿出来)。直接把 \(x\) 换为根节点,再建一条从 \(y\) 到 \(x\) 的实路径,把 \(y\) 旋到根,这样它就在 splay 的最右边没有右儿子了。接着就可以很方便地访问 \(y\) 来获取路径信息。
void split(int x,int y){
makeroot(x);
access(y);
splay(y);
}
找根:find
函数
将 \(x\) 和根节点重建出一条实链后,再把 \(x\) 旋上去。然后一直走它的左儿子,因为左儿子的深度会更小,沿路 pushdown
一下,最后按照 splay 的传统把查询到的点给旋上去。
int find(int x){
access(x);
splay(x);
while(tree[x].son[0]){
pushdown(x);
x=tree[x].son[0];
}
splay(x);
return x;
}
连边和断边:link
函数和 cut
函数
对于连边,首先需要判断需要连边的两个点 \(x\),\(y\) 是否在同一棵子树内,在的话显然不合法。然后将 \(x\) 给改成所在树的根,再将 \(x\) 的父亲改为 \(y\)。
对于断边,先把 \(x\) 改为根节点,然后判断 \(x\),\(y\) 是否有连边(即判断 \(x\),\(y\) 连通,\(x\),\(y\) 的路径上没有其他的链,\(y\) 没有右儿子),然后改一下 \(x\),\(y\) 的父亲儿子信息。
解释一下判断条件,第一条很显然,第二条可以通过判断 \(y\) 的父亲是不是 \(x\),\(x\) 的右儿子是不是 \(y\) 来解决,第三条需要满足的原因是如果 \(y\) 有左儿子,那么就会存在比 \(y\) 深度小但比 \(x\) 深度大的点,那么 \(x\),\(y\) 之前就不可能有直接的链相连。
void link(int x,int y){
makeroot(x);
if(find(y)==x) return;
fa[x]=y;
}
void cut(int x,int y){
makeroot(x);
if(find(y)!=x||fa[y]!=x||tree[x].son[1]!=y||tree[y].son[0]) return;
fa[y]=tree[x].son[1]=0;
pushup(x);
}
由于 LCT 的每种操作时间复杂度基本基于 access
函数,而它的时间复杂度是 \(O(\log_2 n)\),那么每种操作的时间复杂度也是 \(O(\log_2 n)\)。