《塞尔达传说:林克砍树》——Link/cut Tree
林克砍树。
如图:
\(\uparrow\) 这个算非常重要的,一定要先学会 \(\texttt{Splay}\) 。
因为 \(\texttt{LCT}\) 中的 \(\texttt{Splay}\) 与我们平时写的还不完全等同,所以必须要先理解一般的 $\texttt{Splay} $。
关于 LCT 的定义与性质
基本定义
\(\texttt{LCT}\) 全称是 \(\texttt{Link/cut Tree}\) ,是一种维护对象为森林的数据结构,基于实链剖分原理以及 \(\texttt{Splay}\) 的功能得以实现。
于 \(1982\) 年由 \(\text{Daniel Dominic Sleator}\) 与 \(\text{Robert Endre Tarjan}\) 发明(怎么又是你们),也就是 \(\texttt{Splay}\) 的发明者。
我们应用 \(\texttt{LCT}\) 去解决的问题一般被我们成为动态树问题。
实链剖分
学习 \(\texttt{LCT}\) 之前我们来回顾一下链剖分的相关定义。
我们有一颗有根树,对于每个节点的所有子节点划分出符合一定性质的节点。然后将连接的边划分出来。这样对每个节点进行划分,会划分出不同的特殊的链。这样对链做区间问题可以减少一些链上操作的复杂度。
我们常说的树链剖分,其实就是重链剖分。
重链剖分就是
-
对每个点选择子树最大的子节点进行划分,划分出的连接边称为重边,而若干条重边组成一条重链。
-
剖分后我们发现,预处理标号时优先遍历重链,那么每条重链在树上会形成 dfs 序一定且连续的区间。
-
用数据结构维护连续的重链区间可以降低树链查询修改的复杂度。
而 \(\texttt{LCT}\) 则同样基于这样一种称之为实链剖分的操作得以实现。
实链剖分特殊之处在于,对于实链有着非常模糊的定义,我们不是通过特殊性质划分子节点,而是动态地维护这么一条链。
也就是说:
- 实链剖分选择一条边进行剖分,被选择的唯一的边称为实边,其他边为虚边。
- 实链是灵活可变的,实儿子(点通过实边连向的子节点)也是可变的。
- 正是因为可变,所以我们会采用数据结构维护这些动态的节点 (\(\texttt{Splay}\))。
为什么要这样呢,因为既然我们维护的树上问题是动态的,所以每次指定不同的链有不同的目的性。也就是说,动态问题导致划分条件会因为需求不同动态变化,只有通过数据结构才能快速实现这样的转化。
动态树问题
我们引入一个看起来眼熟的数据结构题:
要求维护一棵树,节点个数 \(10^5\) ,支持:
- 修改路径权值,子树权值。
- 查询路径权值和,子树权值和。
嗯,这显然是树剖。
- 加边,删边,保证树的形态,在线查询。
现在这个大致就是一个类动态树问题。
动态树问题概括地说就是:
-
维护一个森林,支持删边,加边,保证保持森林形态。
-
可解决的问题包括但不限于:
- 查询连通性。
- 路径权值和,子树权值和。
- 动态图的割点,桥与连通块信息(通过生成树转化)。
- 树的合并与分裂(所以说一般会是森林)。
- 换根。
辅助树
既然要用 \(\texttt{Splay}\) ,怎么用?既然要用实链剖分,怎么剖?
我们引入辅助树的定义与性质:
- 辅助树对应原森林中的一棵树。
- 辅助树由多个 \(\texttt{Splay}\) 构成。
- 通过维护一定形态的辅助树,我们便可以获得原树的信息。
再看辅助树上的 \(\texttt{Splay}\) 的相关性质:
- 每个 \(\texttt{Splay}\) 维护原树中一条链。
- 要求 \(\texttt{Splay}\) 中序遍历获得的节点序列是严格按照节点深度严格递增的(通俗地说,就是从上到下一条链)。
- 原树中每个节点只能对应一个 \(\texttt{Splay}\) 。
- 划分出的实边包含在 \(\texttt{Splay}\) 中,那么 \(\texttt{Splay}\) 维护的就是一条实链,虚边则负责由一棵 \(\texttt{Splay}\) 的根节点指向另一个节点。这个节点是这棵 \(\texttt{Splay}\) 对应的原树上的链的父亲节点(即链顶的父亲)。
- 可以由 \(\texttt{Splay}\) 的根节点通过虚边连向原树链顶的父亲节点,反之却不能,也就是说认父不认子。
读者可以自行验证以上转化是否符合上述性质。
这样我们巧妙的结合了实链剖分和 \(\texttt{Splay}\) ,就可以尝试实现了。
算法实现与操作
操作图示与代码实现分别参考了 \(\text{FlashHu}\) 的博客 的图和 \(\text{zcysky}\) 的题解 (你明明就是对着抄了一遍!呜呜人家写的太好看了啊)。
以下代码均为 Luogu P3690 【模板】Link Cut Tree (动态树) 的代码实现部分。
变量信息
ch[N][2] | fa[N] | s[N] | a[N] | rev[N] |
---|---|---|---|---|
节点的左右儿子 | 父亲节点 | 子树权值 | 节点权值 | 翻转标记 |
我的代码里有 #define INL inline
见到了不要误会。
Pushup
UPDATE(x);
是所有数据结构都常见的操作,毕竟修改了左右儿子就会修改父亲节点。
INL void UPDATE(int p){s[p]=s[ch[p][0]]^s[ch[p][1]]^a[p];}//Pushup
Pushdown
PD(x);
是区间翻转标记,因为在 \(\texttt{LCT}\) 后续操作中会出现大量的节点翻转互换的需求(换根的时候)。
INL void PD(int p)
{
int L=ch[p][0],R=ch[p][1];
if(rev[p]){rev[L]^=1,rev[R]^=1,rev[p]^=1;swap(ch[p][0],ch[p][1]);}
}
ISroot
在一棵辅助树中,一个节点如果不是一个实儿子,那就不能从父亲从上到下找到它。
一棵 \(\texttt{Splay}\) 的根对于这种情况是没有更上层节点的,也就是说,如果既不是父亲的左儿子也不是右儿子,那就是一棵 \(\texttt{Splay}\) 的根。
INL bool ISroot(int p){return ch[fa[p]][0]!=p&&ch[fa[p]][1]!=p;}
Splay
\(\texttt{LCT}\) 的 \(\texttt{Splay}\) 主要是 Rotate(x);Splay(x);
的操作。
这种 \(\texttt{Splay}\) 只需要支持将 \(x\) 旋转到当前 \(\texttt{Splay}\) 的根的功能来辅助虚实变换的操作。
Rotate(x);
INL void Rotate(int p)
{
int f=fa[p],ff=fa[f];
int L,R;
if(ch[f][0]==p)L=0;
else L=1;//判断左右儿子
R=L^1;
if(!ISroot(f))
{
if(ch[ff][0]==f)ch[ff][0]=p;
else ch[ff][1]=p;
}//LCT 中 Splay 的根节点比较特殊,所以特判
fa[p]=ff;fa[f]=p;fa[ch[p][R]]=f;
ch[f][L]=ch[p][R];ch[p][R]=f;
UPDATE(f);UPDATE(p);//普通 Splay 旋转
}
只需要旋转到根的 Splay(x);
INL void Splay(int p)
{
top=1;stk[top]=p;
for(int i=p;!ISroot(i);i=fa[i])stk[++top]=fa[i];//一直找这条完整的实链
for(int i=top;i;i--)PD(stk[i]);//这里需要下传翻转标记。
while(!ISroot(p))//一直到根
{
int f=fa[p],ff=fa[f];
if(!ISroot(f))//同样是特判
{
(ch[f][0]==p)^(ch[ff][0]==f)?Rotate(p):Rotate(f);
}
Rotate(p);
}
}
Access
Access(x);
操作的本质是使得根节点到 \(x\) 的路径成为一条完整的实链。
\(\texttt{LCT}\) 的实现可以理解为:从上到下,每次筛去每条实链上深度大于等于之前下方实链顶端的节点。这个可以用 \(\texttt{Splay}\) 将下方实链的顶端用虚边连接的,也就是深度往上一层的点,转为其所在 \(\texttt{Splay}\) 的根节点,然后直接筛去右子树,显然就是每条实链上深度大于等于之前下方实链顶端的所有节点。要求的链与链之间转化通过 实边 \(\rightarrow\) 虚边 转化。
可能非常拗口,我们先直接看一遍流程理解一遍。
假设我们有这样一棵划分好的树。
那么辅助树长这样:
现在要使得 \([A,N]\) 这条链成为一条独立完整的实链。也就是 Access(N);
需要这样的转化。
对于 \(\texttt{LCT}\) 的实现流程是这样的:
总结一下 Access(x);
的操作:
Splay(x);
将最低点转到当前 \(\texttt{Splay}\) 的根节点。ch[x][1]=pre;
舍弃右儿子,将之前已经连好的 \(\texttt{Splay}\) 接到当前根的右儿子。UPDATE(x);
更新一下x=fa[x];
沿着虚边往上跳下一条实链。
所以代码实现其实很简单:
INL void Access(int p)
{
for(int t=0;p;t=p,p=fa[p])
Splay(p),ch[p][1]=t,UPDATE(p);
}
Makeroot
光有 Access(x);
肯定是不够的,因为我们查询的链有时候在原树上会有深度相同的点(也就是两端的 \(\text{LCA}\) 是在两端中间的节点) 。
那我们需要通过一种很巧妙的操作将其变成一条从上到下深度严格递增的链。
这个操作就是 换根 。
我们考虑 \(u\rightarrow v\) 的一条链穿过了 \(\mathrm {LCA}(u,v)\) ,那么如果将 \(u,v\) 其中一个和 \(\mathrm{LCA}(u,v)\) 互换,那么可以得到 \(u \rightarrow v\) 的深度严格递增链。对于一棵树,根节点 \((\mathrm{root})\) 一定是所有点的 \(\text{Common ancestor}\) ,也就是说,\(\forall (u,v)\in \mathbf E,(\mathrm {root},\mathrm{LCA}(u,v))\) 都是一条深度严格递增的链。所以显然 \(\forall u\in\mathbf V,(\mathrm{root},u)\) 深度严格递增。
于是我们可以通过将 \(u\) 变成为 \(\mathrm{root}\) ,得到 \((u,v)\) 严格递增链。
操作很简单:
Access(p);
获得原树根节点到 \(p\) 的 \(\texttt{Splay}\) 。这个时候 \(p\) 属于 \(\texttt{Splay}\) 中深度最深的节点;Splay(p);
将 \(p\) 移到当前 \(\texttt{Splay}\) 的根节点,由于深度最大,此时 \(p\) 在 \(\texttt{Splay}\) 上没有右子树;rev[p]^=1;
为根节点 \(p\) 打上翻转记号,也就是对整个 \(\texttt{Splay}\) 翻转,整个左子树移到右子树上。那么 \(p\) 变成深度最小的节点,也就是原树的 \(\mathrm{root}\) 。
这样这个节点到任何其他节点的路径按深度严格递增了。
INL void Makeroot(int p)
{
Access(p);Splay(p);rev[p]^=1;
}
Findroot
找到一个节点在原树中的根。
Access(p);Splay(p);
先分出当前节点到根的路径,用 \(\texttt{Splay}\) 求出深度关系;p=ch[p][0];
一直向左,找到深度最小的节点就是 \(\mathrm{root}\) 。
同根的节点在同一棵树上,所以可以用这个操作判连通性。
INL int Find(int p)
{
Access(p);Splay(p);
while(ch[p][0])p=ch[p][0];//一直向左
Splay(p);
return p;
}
Split
基于 Makeroot(p);
的操作,分离出 \(u\rightarrow v\) 的路径做 \(\texttt{Splay}\) 。
INL void Split(int p,int q)
{
Makeroot(p);Access(q);//保证 p 到 q 严格的深度严格递增。
Splay(q);//最后 q 成了根
}
Link
连接两个点之间的边。因为认父不认子,所以直接让其中一个点变成根节点然后将另一个点及其子树接上去即可。
INL void Link(int p,int q)
{
Makeroot(p);fa[p]=q;
}
一般我们要考虑是否已经联通:
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx!=yy)LCT.Link(x,y);
Cut
断边操作,细节要略多。
Split(p,q);
后,\(q\) 成了根,那么 \(p\) 必须是其子节点才能断开。
ch[p][1]==0
\(p\) 不能再有右子节点,不然中序遍历 \(p\) 与 \(q\) 之间会插入其他节点,则说明 \(p\) 与 \(q\) 没有直接连边。ch[q][0]==p&&fa[p]==q
\(q\) 与 \(p\) 是父子关系。
INL void Cut(int p,int q)
{
Split(p,q);
if(ch[p][1]==0&&ch[q][0]==p&&fa[p]==q)
ch[q][0]=0,fa[p]=0;//直接断开
}
同样要提前考虑本来的连通性。
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx==yy)LCT.Cut(x,y);
至此我们完整实现了 \(\texttt{LCT}\) 。
Luogu P3690 【模板】Link Cut Tree (动态树) 的完整代码实现如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#include<queue>
#include<map>
#include<stack>
//#include<bits/stdc++.h>
#define ll long long
#define ull unsigned long long
#define INL inline
#define Re register
//Tosaka Rin Suki~
using namespace std;
INL int read()
{
int x=0;int w=1;
char ch=getchar();
while((ch<'0'||ch>'9')&&ch!='-')ch=getchar();
if(ch=='-')w=-1,ch=getchar();
while(ch>='0'&&ch<='9')
{x=(x<<1)+(x<<3)+ch-48,ch=getchar();}
return x*w;
}
INL int mx(int a,int b){return a>b?a:b;}
INL int mn(int a,int b){return a<b?a:b;}
const int N=300005;
int a[N],n,m;
struct ReyLCT
{
int stk[N],top;
int ch[N][2],fa[N],s[N],rev[N];
INL void UPDATE(int p){s[p]=s[ch[p][0]]^s[ch[p][1]]^a[p];}
INL void PD(int p)
{
int L=ch[p][0],R=ch[p][1];
if(rev[p]){rev[L]^=1,rev[R]^=1,rev[p]^=1;swap(ch[p][0],ch[p][1]);}
}
INL bool ISroot(int p){return ch[fa[p]][0]!=p&&ch[fa[p]][1]!=p;}
INL void Rotate(int p)
{
int f=fa[p],ff=fa[f];
int L,R;
if(ch[f][0]==p)L=0;
else L=1;
R=L^1;
if(!ISroot(f))
{
if(ch[ff][0]==f)ch[ff][0]=p;
else ch[ff][1]=p;
}
fa[p]=ff;fa[f]=p;fa[ch[p][R]]=f;
ch[f][L]=ch[p][R];ch[p][R]=f;
UPDATE(f);UPDATE(p);
}
INL void Splay(int p)
{
top=1;stk[top]=p;
for(int i=p;!ISroot(i);i=fa[i])stk[++top]=fa[i];
for(int i=top;i;i--)PD(stk[i]);
while(!ISroot(p))
{
int f=fa[p],ff=fa[f];
if(!ISroot(f))
{
(ch[f][0]==p)^(ch[ff][0]==f)?Rotate(p):Rotate(f);
}
Rotate(p);
}
}
INL void Access(int p)
{
for(int t=0;p;t=p,p=fa[p])
Splay(p),ch[p][1]=t,UPDATE(p);
}
INL void Makeroot(int p)
{
Access(p);Splay(p);rev[p]^=1;
}
INL int Find(int p)
{
Access(p);Splay(p);
while(ch[p][0])p=ch[p][0];
Splay(p);
return p;
}
INL void Split(int p,int q)
{
Makeroot(p);Access(q);
Splay(q);
}
INL void Cut(int p,int q)
{
Split(p,q);
if(ch[p][1]==0&&fa[p]==q)
ch[q][0]=0,fa[p]=0;
}
INL void Link(int p,int q)
{
Makeroot(p);fa[p]=q;
}
}LCT;//封装
int main()
{
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
n=read();m=read();
for(int i=1;i<=n;i++)
a[i]=read(),LCT.s[i]=a[i];
while(m--)
{
int opt=read();
int x=read(),y=read();
if(opt==0)
{
LCT.Split(x,y);
printf("%d\n",LCT.s[y]);
}
else if(opt==1)
{
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx!=yy)LCT.Link(x,y);
}
else if(opt==2)
{
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx==yy)LCT.Cut(x,y);
}
else if(opt==3)
{
LCT.Access(x);LCT.Splay(x);
a[x]=y;LCT.UPDATE(x);
}
}
return 0;
}
复杂度略证
我们采用势能分析。
约定
- 以 \(x\) 为根的子树大小为 \(\mathrm {size}(x)\)
- 点 \(x\) 势能函数为 \(r(x)=\lceil\log_2\mathrm {size}(x)\rceil\)
- 全局势能函数 \(\displaystyle \Phi=\sum_{x\in \mathbf V} r(x)\)
- \(\Phi(t)\) 为进行 \(t\) 次伸展后的全局势能。\(\forall t\in [0,T],\Phi(t)\in[n,n\log_2n]\)
Splay 复杂度
之前的博文未对 \(\texttt{Splay}\) 做任何复杂度分析与证明,借此契机补上。
鉴于算法特性,我们只需要分析伸展操作的复杂度。
设有 \(n\) 个节点进行了 \(m\) 次伸展。
对于伸展操作,有 \(6\) 种但是是 \(3\) 组对称操作,所以考虑分析其中 \(3\) 种。
- \(zig\)
- \(zig-zig\)
- \(zig-zag\)
进行一次 \(\texttt{Splay(v)}\) 操作的均摊复杂度则是:
\(n\) 个点 \(m\) 次则是:
这里面的一个 \(\texttt{trick}\) 是分析势能时我们发现会有 \(+1\) 的部分 ,那个其实不是 \(\Delta\Phi\) 而是 \(\Delta T=1\) 也就是耗时,但是我们将其合并进入 \(\Phi\) 。
LCT 复杂度
我们只需要分析 \(\texttt{Access}\) 操作。
这个操作由 \(2\) 个部分组成,\(\texttt{Splay}\) 伸展 和 虚实边转化。
边转化部分:
- 若 \(v\) 是 \(u\) 的儿子节点且 \(\mathrm{size}(v)>\displaystyle\frac{\mathrm{size}(u)}{2}\),则称 \(v\) 是 \(u\) 的重儿子,\((u,v)\) 为重虚边。反之为轻儿子,\((u,v)\) 为轻虚边。
- 势能函数 \(c\) 代表 \(\texttt{LCT}\) 中的重虚边数量。 我们可得均摊复杂度 \(w=\Delta T+\Delta c\),走过的轻虚边上限是 \(\log n\)。
- 那么经过重虚边:\(c-1,T+1\);(不会产生新的重虚边)
- 经过轻虚边:\(c+1,T+1\)(这里 \(c\) 也有可能没有变化)
- 得到每次最多走 \(\log_2 n\) 条轻虚边,也就是 \(c+\max\{\log_2 n\}\) 且 \(T_{\max}=\Theta(\log n)\)
- 而每次 \(c-1\) 就是对应 \(T+1\)。
- 那么 \(n\) 个点 \(m\) 次转化均摊 \(\sum w+c(0)-c(m)\) 得 \(\Theta(m \log n+n)\)
伸展部分:
- 我们只需要吧 \(\mathrm{size}(x)\) 看作辅助树子树的大小,之前的证明方法其实是成立的。
- 所以仍是 \(\log n\)。
那么 \(n\) 个点 \(m\) 次 \(\texttt{Access}\) 的总复杂度为 \(\Theta((n+m)\log n)\) 。
至此基础的 \(\texttt{LCT}\) 学完了,这个东西用途非常多,有很多经典的模型(上面列举的动态树问题都有题),可以去找一些题做。
Reference:
- \(\text{OI Wiki}\) - LCT
- \(\textit{Iking123}\) - splay(伸展树)&LCT(动态树)复杂度略证
- \(\textit{FlashHu}\) - LCT总结——概念篇+洛谷P3690[模板]Link Cut Tree(动态树)(LCT,Splay)