(笔记)动态树(LCT)
动态树与 LCT
什么是 LCT?
这是一种能用于处理有根森林中各棵树的动态操作的数据结构。它能以 \(O(n\log n)\) 的优秀均摊复杂度完成操作。
具体地,LCT 的核心思想是“原树实链从上到下,对应 Splay 树从左到右”。受到树剖的启发,先把树动态地分成多条链,其中边分为实链和虚链,可以理解为实链是双向的,而虚链是单向的,只有儿子连向父亲的边。LCT 实际上就是把原树通过一些技巧转化为能存储的一颗辅助树(Splay),把所有节点存在多个以深度为 \(key\) 的二叉搜索树(BST)中,BST 内节点通过实边两两相连,并且这些 BST 通过虚边同样相连。
这里我们采用深度分析法,建树过程将节点深度视为要维护的 BST 中的 \(key\)(如果你不了解这种平衡树的维护方式:(简记)FHQ Treap)。然后类似笛卡尔树(本质都是 BST),树的中序遍历就可以得到一个深度递增的序列。
具体地,每个节点 \(u\) 的右儿子代表的子树(如果有的话)就类似重链剖分中重儿子所在的子树(注意这里强调子树,因为真正的重儿子,或者 LCT 中实儿子,可能不与 \(u\) 直接相连),每次从 \(y\) 开始下往上找的过程中建立实链,每经过一条虚链就会丢掉原先实儿子,然后把 \(y\) 子树接在上面。注意我们不希望 LCT 的平均深度变得太大,所以经过虚链时要先 \(splay(x)\) 然后再将 \(y\) 子树接在 \(x\) 上。
知道这个 Splay 是怎么来的,接下来要怎么维护呢?
如何维护 LCT?
这里有几种操作,根据旋转 Treap 和 Splay 的原理,在改变节点位置的时候,这两种维护方式都不会改变 \(key\) 的中序遍历固有顺序(当然改变位置肯定会不符合大根堆性质,但是不需要管这个)。
\(rotate(x)\space splay(x)\)
Splay 经典操作,将辅助树上的 \(x\) 旋转到当前树的根上,这个操作不会影响原树,而且可以改善二叉树形态。具体来说,就是保证该树的中序遍历不变(针对 \(key\))的情况下改变父子关系,把 \(x\) 往上提一级,直到当前 Splay 树根为止(上面可能仍有虚边)。
void rotate(int x){
int y=t[x].fa;
int z=t[y].fa;
bool k=(t[y].ch[1]==x);
if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
t[x].fa=z;
t[y].ch[k]=t[x].ch[k^1];
if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
t[y].fa=x;
t[x].ch[k^1]=y;
pushup(y);
}
void splay(int x){
int y,z;
push(x);
while(!isRoot(x)){
y=t[x].fa,z=t[y].fa;
if(!isRoot(y))
(t[z].ch[0]==y)^(t[y].ch[0]==x)?rotate(x):rotate(y);
rotate(x);
}
pushup(x);
}
具体地,\(rotate(x)\) 每次编码时画图会更好理解,就是 zig 和 zag 操作的简化版。
关于 \(splay(x)\) 内部的分讨,这是因为这一步能改善平衡性。设想节点 \(x,y,z\),满足 \(y\) 对于 \(z\) 和 \(x\) 对于 \(y\) 是同一个方向的儿子(一条链)。这样如果每次只提 \(x\),完成左右操作时就还是一条链的状态,非常不美观。但是如果遇到这种情况先提 \(y\) 再提 \(x\),就可以一定程度上减少深度,维护平衡性,具体过程请读者自行模拟。
\(access(x)\)
在原树上建一条实链,使得链头是 \(root\),链尾是 \(x\)。如何实现?
只需要在下一个实链把链尾提到实链链头,把这个实链的链头接到下一个实链链头的右儿子即可(这样做是为了改善平衡性,避免深度过大)。如果这个实链为空,那么下一个实链链头的右儿子也为空,因为链尾得是 \(x\)。
为什么要是右儿子?因为辅助树上虚链代表了原树中的深度偏序关系,如果一个节点通过虚链是另一个节点的父亲,那么这个节点深度肯定比另一个节点浅,显然就应该接在右儿子上。
void access(int x){
for(int child=0;x;child=x,x=t[x].fa){
splay(x);
rc=child;
pushup(x);
}
}
\(makeroot(x)\)
这个操作能够改变原树形态。注意,这里的改变形态是指改变根节点,但和换根 DP 一样,如果将新树和原树都看做无根树,那么它们实际上应该是等价的(同构的)。
该操作有三步:\(access(x)\rightarrow splay(x)\rightarrow reverse(x)\)
第一步和第二步是为了创造辅助树上以 \(x\) 为根的 Splay,第三步则是主要操作。想象一下树上如果要将 \(x\) 提为根,那么 \(root\rightarrow x\) 这段路径上的节点深度的递增顺序都应该变成递减顺序。理解了这一步以后,我们只需要在 Splay 树上把实链上所有左右儿子都交换就可以完成这种关系的改变。
void makeroot(int x){
access(x);
splay(x);
reverse(x);
}
具体实现
主要操作如上,剩下的就是根据性质变形得到的求解问题的方法。注意如果要查询路径 \(x\rightarrow y\) 上的信息,那么需要执行一次 \(split(x,y)\) 操作,在原树上以 \(x\) 为起点,\(y\) 为终点生成一条实链,然后路径上的信息就是这条实链上的所有信息了。
下面给出P3690 【模板】动态树(LCT)的完整代码,具体细节参照代码:
#include<bits/stdc++.h>
using namespace std;
const int N=3e5+5;
struct Node{int ch[2],sum,val,tg,fa;}t[N];
#define lc t[x].ch[0]
#define rc t[x].ch[1]
bool isRoot(int x){
int g=t[x].fa;
return t[g].ch[0]!=x&&t[g].ch[1]!=x;
}
void pushup(int x){
t[x].sum=t[x].val^t[lc].sum^t[rc].sum;
}
void reverse(int x){
if(!x)return ;
swap(lc,rc);
t[x].tg^=1;
}
void pushdown(int x){
if(t[x].tg){
reverse(lc);
reverse(rc);
t[x].tg=0;
}
}
void push(int x){
if(!isRoot(x))push(t[x].fa);
pushdown(x);
}
void rotate(int x){
int y=t[x].fa;
int z=t[y].fa;
bool k=(t[y].ch[1]==x);
if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
t[x].fa=z;
t[y].ch[k]=t[x].ch[k^1];
if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
t[y].fa=x;
t[x].ch[k^1]=y;
pushup(y);
}
void splay(int x){
int y,z;
push(x);
while(!isRoot(x)){
y=t[x].fa,z=t[y].fa;
if(!isRoot(y))
(t[z].ch[0]==y)^(t[y].ch[0]==x)?rotate(x):rotate(y);
rotate(x);
}
pushup(x);
}
void access(int x){
for(int child=0;x;child=x,x=t[x].fa){
splay(x);
rc=child;
pushup(x);
}
}
void makeroot(int x){
access(x);
splay(x);
reverse(x);
}
void split(int x,int y){
makeroot(x);
access(y);
splay(y);
}
void link(int x,int y){
makeroot(x);
t[x].fa=y;
}
void cut(int x,int y){
split(x,y);
if(t[y].ch[0]!=x||rc)return ;
t[y].ch[0]=t[x].fa=0;
pushup(y);
}
int findroot(int x){
access(x);splay(x);
while(lc)pushdown(x),x=lc;
return x;
}
#undef lc
#undef rc
int n,m;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>t[i].val,t[i].sum=t[i].val;
while(m--){
int opt,a,b;
cin>>opt>>a>>b;
if(!opt)split(a,b),cout<<t[b].sum<<'\n';
else if(opt==1){if(findroot(a)!=findroot(b))link(a,b);}
else if(opt==2)cut(a,b);
else splay(a),t[a].val=b;
}
return 0;
}
Q & A
- Q:为什么 \(access(x)\) 的过程破坏了链的形态(可能有一个节点同时存在左右儿子)却不影响后续操作?
- A:并没有破坏链的形态,该问题属于结构混淆,辅助树上的一棵 Splay 维护的就是一条实链中的所有节点。
- Q:为什么 \(makeroot(x)\) 不可以只将 \(root\) 的左右儿子互换,而是要全体 \(reverse\)?
- A:参照前文讲解,手动模拟原树提根,只有深度都倒过来才不会影响原树结构。
- Q:为什么 \(cut(x,y)\) 操作中 \(split(x,y)\) 操作中 \(splay(y)\) 可能在上升过程中先 \(rotate\) 其父节点再 \(rotate\) 它自己导致它的左儿子不是 \(x\) 而不用特殊判断?
- A:这也是直接写 Splay 和 LCT 的一大区别,因为 \(cut\) 操作保证了 \(x,y\) 是原树上相邻节点,所以建实链,链上 \(x,y\) 之间必然不存在其他节点,也就不会有上面的问题了。
- Q:为什么 \(findroot(x)\) 操作可以直接判断是否存在左儿子再
pushdown(x)
? - A:?
pushdown(x)
更新的是 \(x\) 的儿子的信息,和 \(x\) 没关系。 - Q:有哪些容易写错的地方?
- A:\(rotate(x)\) 内判断 \(y\) 是否为根、查询路径信息 \(split(x,y)\) 后没有意识到 \(x\) 只是在 \(y\) 左子树内而不一定是 \(y\) 的左儿子,直接返回了 \(x\) 的信息等。
LCT 经典应用
LCT 维护子树信息(虚子树)
本题有非常好的题解解释有关此 trick 的种种细节,故不在叙述。一个容易忽略的点,在模板问题的 \(link(x,y)\) 操作中,我们把 \(x\) 接到 \(y\) 的一个虚儿子上,这并不会影响 \(y\) 的任何维护值所以可以不执行makeroot(y)
。但是本题则不然,如果不执行,那么 \(y\) 及其所有祖先的信息都没有得到及时更新,所以需要多写一步,这是值得注意的。
定根 LCT
无 makeroot 这一特点建立在原树的形态结构唯一的基础上。如果一个点在全局修改中可能有不止一个父亲,那么就不符合该条件。经典应用是SP16549 QTREE6 - Query on a tree VI的 LCT 解法。在该问题中,由于建模的独特性,致使不能改变原树形态,所以可以采取无 makeroot 的 LCT 并且不用担心父亲节点矛盾的问题。
边权 LCT 动态维护最小生成树
边权 LCT 解决方法:将每条边多加一个虚点,边权赋在点权上连接即可。(注意:\(cut\) 操作中也需要删除虚点与实点的边)。
动态维护最小生成树:每次查询动态树链信息,找到最大边权,如果替换掉完全更优那么先断掉原来更劣的边,然后直接连上新边。
P4172 [WC2006] 水管局长
考虑到删除困难,加入容易,时光倒流维护最小生成树即可。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+5,INF=1e9+1;
int n,m,q;
struct edge{int u,v,w;}op[N];
struct node{int ch[2],val,mx,id,fa;bool tg;}t[N*2];
inline bool isRoot(int x){
int f=t[x].fa;
return t[f].ch[0]!=x&&t[f].ch[1]!=x;
}
void pushup(int p){
t[p].mx=max(t[p].val,max(t[t[p].ch[0]].mx,t[t[p].ch[1]].mx));
if(t[p].mx==t[p].val)t[p].id=p;
else if(t[p].mx==t[t[p].ch[0]].mx)t[p].id=t[t[p].ch[0]].id;
else t[p].id=t[t[p].ch[1]].id;
}
void mktag(int p){
if(!p)return ;
t[p].tg^=1;
swap(t[p].ch[0],t[p].ch[1]);
}
void pushdown(int p){
if(t[p].tg){
mktag(t[p].ch[0]);
mktag(t[p].ch[1]);
t[p].tg=0;
}
}
void push(int p){
if(!isRoot(p))push(t[p].fa);
pushdown(p);
}
void rotate(int x){
int y=t[x].fa,z=t[y].fa;
if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
t[y].fa=x;t[x].fa=z;
bool k=(t[y].ch[1]==x);
if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
t[y].ch[k]=t[x].ch[k^1];
t[x].ch[k^1]=y;
pushup(y);
}
void splay(int x){
int y,z;
push(x);
while(!isRoot(x)){
y=t[x].fa,z=t[y].fa;
if(!isRoot(y))
((t[z].ch[1]==y)^(t[y].ch[1]==x))?rotate(x):rotate(y);
rotate(x);
}
pushup(x);
}
void access(int x){
for(int child=0;x;child=x,x=t[x].fa){
splay(x);
t[x].ch[1]=child;
pushup(x);
}
}
void makeroot(int x){
access(x);
splay(x);
mktag(x);
}
int findroot(int x){
access(x);splay(x);
while(t[x].ch[0])pushdown(x),x=t[x].ch[0];
return x;
}
void split(int x,int y){
makeroot(x);
access(y);
splay(y);
}
void link(int x,int y){
makeroot(x);
t[x].fa=y;
}
void cut(int x,int y){
split(x,y);
assert(t[y].ch[0]==x);
t[y].ch[0]=0,t[x].fa=0;
pushup(y);
}
pair<int,int>query(int x,int y){
if(findroot(x)!=findroot(y))return make_pair(INF,0);
split(x,y);
int pos=y;
return make_pair((t[y].mx?t[y].mx:INF),t[y].id);
}
map<int,map<int,int> >vis;
map<int,map<int,bool> >mp;
int X[N],Y[N],W[N],ans[N];
void Link(int w){
pair<int,int>res=query(X[w],Y[w]);
if(res.first>W[w]&&res.second>n){
int id=res.second-n;
cut(X[id],id+n);
cut(id+n,Y[id]);
}
if(res.first>W[w]){
link(w+n,X[w]);
link(Y[w],w+n);
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m>>q;
for(int i=1;i<=m;i++)
cin>>X[i]>>Y[i]>>W[i],
t[i+n].mx=t[i+n].val=W[i],
t[i+n].id=i+n,
vis[X[i]][Y[i]]=vis[Y[i]][X[i]]=i;
for(int i=1;i<=q;i++){
cin>>op[i].w>>op[i].u>>op[i].v;
if(op[i].w==2)mp[op[i].u][op[i].v]=mp[op[i].v][op[i].u]=1;
}
for(int i=1;i<=m;i++)
if(!mp[X[i]][Y[i]])
Link(i);
for(int i=q;i>=1;i--){
if(op[i].w==2)Link(vis[op[i].u][op[i].v]);
else ans[i]=query(op[i].u,op[i].v).first;
}
for(int i=1;i<=q;i++)
if(op[i].w==1)cout<<ans[i]<<'\n';
return 0;
}
P2387 [NOI2014] 魔法森林
最小生成树维护:比上一题多个扫描线,这个题对 \(a,b\) 中任意一维做一个扫描线,然后另一维直接动态最小生成树,记录路径信息是路径上边权最大的点及其编号,如果替换更优那么直接替换,然后找到 \(1\) 到 \(m\) 可行路径上最大边权 \(+\) 扫描线所处位置就是本次答案,将所有答案取 \(\min\) 即可。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=5e4+5,M=1e5+5,INF=1e9;
struct node{int ch[2],fa,mx,id,val;bool rev;}t[N+M];
inline bool isRoot(int x){
int f=t[x].fa;
return t[f].ch[0]!=x&&t[f].ch[1]!=x;
}
void pushup(int x){
t[x].mx=max(t[t[x].ch[0]].mx,t[t[x].ch[1]].mx);
t[x].mx=max(t[x].mx,t[x].val);
if(t[x].mx==t[x].val)t[x].id=x;
else if(t[x].mx==t[t[x].ch[0]].mx)t[x].id=t[t[x].ch[0]].id;
else t[x].id=t[t[x].ch[1]].id;
}
void rev(int x){
if(!x)return ;
t[x].rev^=1;
swap(t[x].ch[0],t[x].ch[1]);
}
void pushdown(int x){
if(t[x].rev){
rev(t[x].ch[0]);
rev(t[x].ch[1]);
t[x].rev=0;
}
}
void push(int x){
if(!isRoot(x))push(t[x].fa);
pushdown(x);
}
void rotate(int x){
int y=t[x].fa,z=t[y].fa;
if(!isRoot(y))t[z].ch[t[z].ch[1]==y]=x;
t[x].fa=z,t[y].fa=x;
bool k=(t[y].ch[1]==x);
if(t[x].ch[k^1])t[t[x].ch[k^1]].fa=y;
t[y].ch[k]=t[x].ch[k^1];
t[x].ch[k^1]=y;
pushup(y);
}
void splay(int x){
int y,z;
push(x);
while(!isRoot(x)){
y=t[x].fa,z=t[y].fa;
if(!isRoot(y))
((t[z].ch[1]==y)^(t[y].ch[1]==x))?rotate(x):rotate(y);
rotate(x);
}
pushup(x);
}
void access(int x){
for(int child=0;x;child=x,x=t[x].fa){
splay(x);
t[x].ch[1]=child;
pushup(x);
}
}
void makeroot(int x){
access(x);
splay(x);
rev(x);
}
int findroot(int x){
access(x);splay(x);
while(t[x].ch[0]||t[x].ch[1]){
pushdown(x);
if(t[x].ch[0])x=t[x].ch[0];
else break;
}
return x;
}
void cut(int x,int y){
makeroot(x);
access(y);splay(y);
if(t[y].ch[0]==x){
t[y].ch[0]=0;
t[x].fa=0;
pushup(y);
}
}
void link(int x,int y){
makeroot(x);
t[x].fa=y;
}
pair<int,int>query(int x,int y){
if(findroot(x)!=findroot(y))
return make_pair(INF,0);
makeroot(x);
access(y);splay(y);
return make_pair(t[y].mx,t[y].id);
}
int n,m,X[N+M],Y[N+M],ans=INF,cnte,ncnt;
struct edge{int u,v,a,b;}e[M];
bool cmp(edge x,edge y){return x.b<y.b;}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;ncnt=n;
for(int i=1;i<=n;i++)
t[i].id=i;
for(int i=1;i<=m;i++)
cin>>e[i].u>>e[i].v>>e[i].a>>e[i].b;
sort(e+1,e+1+m,cmp);
for(int i=1;i<=m;i++){
if(e[i].u==e[i].v)continue;
pair<int,int>res=query(e[i].u,e[i].v);
if(res.first<=e[i].a)continue;
if(res.second){
cut(X[res.second],res.second);
cut(res.second,Y[res.second]);
cnte--;
}
t[++ncnt].val=e[i].a;
t[ncnt].mx=t[ncnt].val;
t[ncnt].id=ncnt;
X[ncnt]=e[i].u,Y[ncnt]=e[i].v;
link(e[i].u,ncnt);link(ncnt,e[i].v);
cnte++;
if(findroot(1)==findroot(n))ans=min(ans,query(1,n).first+e[i].b);
}
cout<<(ans>=INF?-1:ans);
return 0;
}
全局平衡二叉树(静态 LCT)
题解 P4115 【Qtree4】
科技。该结构本质上是重链剖分+实链剖分的应用。
对树进行重链剖分。对于每条重链,每次 build
求它的加权重心(每个点的点权是轻儿子 \(siz+1\)),然后递归建树。
每次从儿子跳到父亲的子树大小就至少翻倍,可以稍微分类讨论一下。如果经过一条轻边,那么子树大小至少翻倍。如果经过一条重链内部的边(BST 上的边),那么由于每次找重心进行 build
,子树大小也是翻倍的。这样我们的树高就是 \(O(\log n)\) 的。
全局平衡二叉树可以帮我们解决许多问题,让我们可以摆脱树剖 \(O(\log ^2 n)\) 的单次操作,变成 \(O(\log n)\)。
P4751 【模板】动态 DP(加强版)
但是我不将原树二叉化,只纯朴地建一颗全局平衡二叉树又如何呢?我们在 Qtree4 中,由于维护信息的特殊性,要额外在树上开一些线段树节点。但是一般情况下,如本题中不需要按一定秩序开出这些节点,直接朴素建树即可。需要注意的是,递归轻儿子建树时与上面不同,每次 dfs
前都要重置链首(因为这个调了 1h)。
关于调错
了解 LCT 代码错误的最好方式是知道 Splay 树的构造。因此,不妨写一个打印函数帮助你更好地调试。
void pr(int x){
if(lc)pr(lc);
cout<<x<<' ';
if(rc)pr(rc);
}
void print(int x){
pr(x);
cout<<'\n';
}
当然啦实际上如果你的 LCT 没出什么离谱大锅,多数时候程序运行卡住了都是因为连边出现了环,可以尝试在每个 \(link(x,y)\) 和 \(cut(x,y)\) 操作中输出连边删边的提示然后判断哪里出了问题。
关于 tag(血的教训)
本题按照标准的 \(O(n\log n)\) 的思路,需要在 Splay 每个节点维护两个信息。第一个是当前子树内深度最深且 \(val\) 不为 \(1\) 的点,第二个是当前子树内深度最深且 \(val\) 不为 \(2\) 的点。这两个信息在打 tag 修改的时候需要进行交换。 需要注意的是,本题不能直接使用覆盖 tag,这样可能会导致被两个不同的 tag 同时覆盖以后本来应该没有任何效果,但是 pushdown 判断自动交换了这两个信息。
解决方法是用一个反转 tag(用异或维护信息),在翻转两次时自动视为没有翻转。
本题调错耗时 3days。