文章目录
零、前言
树剖(链分治)有三大类:重链剖分、长链剖分、实链剖分。
其中,前两项都是在树的形态固定的情况下进行操作;而今天所讲的动态树( link-cut tree \text{link-cut tree} link-cut tree)将允许 动态地对树的形态进行更改。
对于一条链,用一种数据结构来维护。但是,我们会经常 更改链剖分。因此,一般使用 s p l a y \tt splay splay 使得均摊复杂度为 O ( log n ) \mathcal O(\log n) O(logn) 。其余平衡树的复杂度会劣化到 O ( log 2 n ) \mathcal O(\log^2 n) O(log2n) 。
壹、思路与实现
1.定义
对于每一条实链,我们使用一个 s p l a y \tt splay splay,键值为深度。那么中序遍历的结果是自顶向下访问的结果。
代码实现上,不需要开很多个 s p l a y \tt splay splay,我们使用这样的操作:认父不认子。
每一个小 s p l a y \tt splay splay 的根节点,指向该实链的顶部的父节点。但是父节点不认为它是儿子,即 father ( x ) = y \text{father}(x)=y father(x)=y,但是 x ∉ s o n ( y ) x\notin son(y) x∈/son(y) 。
似乎很抽象,拿两张
Y
a
n
g
Z
h
e
\rm YangZhe
YangZhe 的论文 中的图片:左边是原树,加粗为实边;右边是
s
p
l
a
y
\tt splay
splay 森林,细边是 “认父不认子” 的(也就是说,加粗的边构成了真正的
s
p
l
a
y
\tt splay
splay 森林)。
本质就是这样的,对于每个小 s p l a y \tt splay splay 来说——
- 维护的是一条链,键值为深度。
- father [ r o o t ( x ) ] \text{father}[root(x)] father[root(x)] 是两条实链之间的虚边的上端点。下端点则是 x x x 的 s p l a y \tt splay splay 中深度最小的点。
譬如图右侧的 ⟨ A , G ⟩ \langle A,G\rangle ⟨A,G⟩ 边,代表的是 t o p ( G ) = C top(G)=C top(G)=C 为下端、 A A A 为上端的虚边,即原树中的 ⟨ C , A ⟩ \langle C,A\rangle ⟨C,A⟩ 。
2.核心操作 a c c e s s \rm access access
我们有实链,但是我们没法用。我们得想办法改变实链!
核心的一个操作,就是 access ( x ) \texttt{access}(x) access(x),这会导致 重新划分出一个顶端为根、底端为 x x x 的实链。
听上去复杂,代码却异常简单。直接上代码,或许有助于理解。
void access(int x){
for(int y=0; x!=0; y=x,x=fa[x])
splay(x), son[x][1] = y, pushUp(x);
}
感受一下这个过程。首先将其旋转至自己的 s p l a y \tt splay splay 的根,这会导致 s p l a y \tt splay splay 变成一个 x x x 为根、左边为其祖先、右边为其子孙的树(请记住, s p l a y \tt splay splay 维护的是一条链)。
而 y y y 维护的是什么呢?意思是,从调用函数时传入的 x x x,记为 x 0 x_0 x0,到 t o p ( y ) top(y) top(y) 已经接成了一条实链,只是 ⟨ father ( y ) , y ⟩ \big\langle\text{father}(y),y\big\rangle ⟨father(y),y⟩ 是一条虚边。此时 x = father ( y ) x=\text{father}(y) x=father(y) 。把 x x x 的右端点,也就是其子孙,换成 y y y,相当于把原来的链断开,接上 y y y 所代表的链。
一旦接上,那么 x 0 → y → x x_0\rightarrow y\rightarrow x x0→y→x 就被打通,所以下一回合中 y = x y=x y=x 。然后迭代地进行处理即可。
形象地感受这一过程。这是作者 缝缝补补 手玩的一组图,不知道怎么做动图。左为原树,右为
s
p
l
a
y
\tt splay
splay 森林。
- 第一次, N N N 将其右儿子 O O O 扔掉。
- 第二次, I I I 将 K K K 当做了其右儿子,然后被 N N N 替换了。
- 第三次, H H H 失去了 J J J,得到了 I I I 。
- 最后一次, A A A 把 B B B 遗弃了,与 H H H 为伍。
仔细地观察这一过程,与我所说的 “丢掉原有的子孙、接上下面的链” 是吻合的(注意看左边的原树)。
3.换根操作
如果我们想要更改树的形态,就需要在树的 “腰” 上加一条边,这在不改变父子关系的情况下不可能做到。所以必须修改原树的根。
怎么让 x x x 成为根呢?众所周知, s p l a y \tt splay splay 支持区间翻转,所以我们要做的就是——
void makeroot(int o){
access(o), splay(o), flip(o);
}
感受一下这个过程——
(不好意思画错了,右边的树中
⟨
A
,
B
⟩
\big\langle A,B\big\rangle
⟨A,B⟩ 是虚边才对。)
我把 N N N 到 A A A(原来的根)的实链的深度翻转了一下,相当于我 捏着 N N N 将这条重链提了起来。
其他的边肯定是不会变的,因为其他重链的 father \text{father} father 数组是不会变的。很形象啊,不是吗?
4.提取路径
普通的重链剖分,查询任意路径时,需要用 l c a lca lca 拆成两条树链。
但我们的根可以改啊!让路径端点成为根,直接 a c c e s s \tt access access 就提取出来了!
5.原树根
直接 a c c e s s \tt access access 之后找当前 s p l a y \tt splay splay 中深度最小的即可。
int findRoot(int x){
access(x), splay(x); pushDown(x);
while(son[x][0] != 0)
pushDown(x = son[x][0]);
splay(x); return x;
}
6.判断联通性
实现方法很多。除了下面这种,也可以直接比较 f i n d R o o t \tt findRoot findRoot 的结果。
bool connected(int x,int y){
makeroot(x);
return findRoot(y) == x;
}
7.连边
没什么好说的,直接将其中一个搞成根,暴力连接一条虚边即可。
bool link(int x,int y){
if(connected(x,y)) return false;
fa[x] = y; return true;
}
注意在
c
o
n
n
e
c
t
e
d
\tt connected
connected 中,已经执行了
m
a
k
e
r
o
o
t
\tt makeroot
makeroot 与
splay
(
x
)
\texttt{splay}(x)
splay(x),所以
x
x
x 已经是树根。另外,
c
o
n
n
e
c
t
e
d
\tt connected
connected 还包含了
a
c
c
e
s
s
(
y
)
{\tt access}(y)
access(y),这也是必要的!原因在于 复杂度
。
8.删边
仍然有多种方法。比如,让某端点成为根之后 a c c e s s \tt access access 它,那么它现在属于大小为 1 1 1 的 s p l a y \tt splay splay,另一端点必然是其 “虚儿子”,直接将 father \text{father} father 设为 n u l l \rm null null 就结束了。
bool cut(int x,int y){
if(!connected(x,y)) return false;
access(x), splay(y), fa[y] = 0;
return true;
}
同理 connected \texttt{connected} connected 中蕴含了 makeroot ( x ) \texttt{makeroot}(x) makeroot(x) 操作。
9.完整代码
温馨提示:我们在进行 s p l a y \tt splay splay 的时候,由于没有了一路向下查找(顺便下传标记)的过程,我们要手动将路径上的每一个点 pushDown \texttt{pushDown} pushDown 一次。
namespace LCT{
int fa[MAXN], ch[MAXN][2];
bool tag[MAXN]; int val[MAXN], w[MAXN];
inline void flip(const int &o){
if(o == 0) return ; // ignored
swap(ch[o][0],ch[o][1]), tag[o] = !tag[o];
}
inline void pushDown(int o){
if(tag[o]) flip(ch[o][0]), flip(ch[o][1]), tag[o] = false;
}
inline void pushUp(int o){ // for example
val[o] = max(w[o],max(val[ch[o][0]],val[ch[o][1]]));
}
# define is_rt(o) (ch[fa[o]][0] != (o) && ch[fa[o]][1] != (o))
# define sonId(o) (ch[fa[o]][1] == (o))
void rotate(int o){
int k = fa[o], d = (ch[k][1] == o);
if((ch[k][d] = ch[o][d^1]) != 0) fa[ch[k][d]] = k;
if((fa[o] = fa[k]) && !is_rt(k)) ch[fa[k]][sonId(k)] = o;
ch[o][d^1] = k, fa[k] = o; pushUp(k), pushUp(o);
}
void downAll(int o){ // stack simulate recursing
static int _sta[MAXN], _top; _sta[_top = 1] = o;
while(!is_rt(o)) _sta[++ _top] = o = fa[o];
while(_top) pushDown(_sta[_top]), -- _top;
}
void splay(int o){
# define _line(o) (sonId(fa[o]) == sonId(o))
for(downAll(o); !is_rt(o); rotate(o))
if(!is_rt(fa[o])) rotate(_line(o) ? fa[o] : o);
}
void access(int x){
for(int y=0; x; y=x,x=fa[x])
splay(x), ch[x][1] = y, pushUp(x);
}
void makeRoot(int x){ access(x), splay(x), flip(x); }
int findRoot(int x){
access(x), splay(x);
while(ch[x][0]) pushDown(x = ch[x][0]);
splay(x); return x;
}
bool is_connected(int x, int y){
makeRoot(x); return findRoot(y) == x;
}
bool link(int x, int y){
if(is_connected(x,y)) return false;
access(y), splay(y), fa[x] = y; return true;
}
bool cut(int x, int y){
if(!is_connected(x,y)) return false;
access(x), splay(y), fa[y] = 0; return true;
}
}
贰、细节
1.复杂度
从这里学来的。显然复杂度都可以放在 s p l a y \tt splay splay 和 a c c e s s \tt access access 上(即,与二者的复杂度同级)。单独调用 splay \texttt{splay} splay 复杂度仍然是 O ( log n ) \mathcal O(\log n) O(logn),可见此文或百度搜索。主要讨论 access \texttt{access} access 。
我们知道 s p l a y \tt splay splay 的势能是 Φ ( T ) = ∑ ϕ ( x ) \Phi(T)=\sum\phi(x) Φ(T)=∑ϕ(x),其中 ϕ ( x ) = 3 log 2 ( ∣ x ∣ ) \phi(x)=3\log_2(|x|) ϕ(x)=3log2(∣x∣),其中 ∣ x ∣ |x| ∣x∣ 表示 x x x 的子树大小。结论是,一次 splay ( x ) \texttt{splay}(x) splay(x) 摊还代价不超过 1 + ϕ ( x ′ ) − φ ( x ) 1+\phi(x')-\varphi(x) 1+ϕ(x′)−φ(x) 。
我们保留这个势能定义,并且,把虚边也纳入统计。也就是说,虚边和实边共同构成类似 s p l a y \tt splay splay 的东西(尽管它实际上成了多叉的)。仔细对比 splay \texttt{splay} splay 的摊还代价,你会发现其放缩过程只用到了两点: x x x 的势能大于其子节点的势能,与核心不等式 2 log 2 ( a + b + 1 ) − log 2 a − log 2 b ⩾ 1 2\log_2(a+b+1)-\log_2 a-\log_2 b\geqslant 1 2log2(a+b+1)−log2a−log2b⩾1,因为某处 ∣ x ∣ = ∣ y ∣ + ∣ z ∣ + 1 |x|=|y|+|z|+1 ∣x∣=∣y∣+∣z∣+1 。
在多叉的意义下,只需改为 ∣ x ∣ = ∣ y ∣ + ∣ z ∣ + l |x|=|y|+|z|+l ∣x∣=∣y∣+∣z∣+l,其中 l l l 是某个点的虚边所连接的子树大小之和 + 1 +1 +1,所以肯定仍然有 2 ϕ ( x ) − ϕ ( y ) − ϕ ( z ) ⩾ 1 2\phi(x)-\phi(y)-\phi(z)\geqslant 1 2ϕ(x)−ϕ(y)−ϕ(z)⩾1,仍然能够将常数 1 1 1 给 “塞进” 势能里。
记
x
i
x_i
xi 为第
i
i
i 次
splay
\texttt{splay}
splay 前的
x
x
x,而
y
i
y_i
yi 则为已
s
p
l
a
y
\tt splay
splay 后的状态。那么每个
splay
\texttt{splay}
splay 摊还代价不超过
1
+
ϕ
(
y
i
)
−
ϕ
(
x
i
)
1+\phi(y_i)-\phi(x_i)
1+ϕ(yi)−ϕ(xi) 。又,根据算法流程,可知
x
i
x_i
xi 是
y
i
−
1
y_{i-1}
yi−1 的父节点,必然有
ϕ
(
x
i
)
⩾
ϕ
(
y
i
−
1
)
\phi(x_i)\geqslant\phi(y_{i-1})
ϕ(xi)⩾ϕ(yi−1) 。所以总摊还代价不超过
∑
i
=
1
k
[
1
+
ϕ
(
y
i
)
−
ϕ
(
y
i
−
1
)
]
=
k
+
ϕ
(
y
k
)
−
ϕ
(
x
1
)
\sum_{i=1}^{k}\big[1+\phi(y_i)-\phi(y_{i-1})\big]=k+\phi(y_k)-\phi(x_1)
i=1∑k[1+ϕ(yi)−ϕ(yi−1)]=k+ϕ(yk)−ϕ(x1)
右侧 ϕ ( y k ) − ϕ ( x 1 ) ⩽ 3 log 2 n \phi(y_k)-\phi(x_1)\leqslant 3\log _2n ϕ(yk)−ϕ(x1)⩽3log2n,所以只需要考虑常数 k k k,也就是经过的虚边数量。它可以用另一个势能分析来说明,由于该势能较简单,甚至可以口述。
定义 重虚边 ⟨ x , y ⟩ \langle x,y\rangle ⟨x,y⟩ 满足 ∣ x ∣ > 1 2 ∣ y ∣ |x|>\frac{1}{2}|y| ∣x∣>21∣y∣ 且 x x x 到 y y y 是虚边,其中 y = father ( x ) y=\text{father}(x) y=father(x) 。注意,它是定义在 s p l a y \tt splay splay 上的,而非原树。势能就是 “重虚边” 的数量。走过 “重虚边” 后,其变为实边,势能 − 1 -1 −1,与复杂度相抵。所以摊还代价都在 “轻虚边” 上。而 “轻边” 最多 log n \log n logn 条,因为 ∣ y ∣ ⩾ 2 ∣ x ∣ |y|\geqslant 2|x| ∣y∣⩾2∣x∣,子树大小至少翻倍。走过 “轻虚边” 同时还可能让 “重虚边” 增加 1 1 1,所以摊还代价不超过 2 log 2 n 2\log_2 n 2log2n 。
佚闻:似乎是先有的 access \texttt{access} access 复杂度证明,再提出的重链剖分。
注意,这个势能与子树大小有关,所以 link \texttt{link} link 和 cut \texttt{cut} cut 也会改变势能。 cut \texttt{cut} cut 会让 ϕ \phi ϕ 减小,不必担心。那 “重虚边” 数量呢?即 c u t \tt cut cut 导致实边变轻,“重虚边” 浮出水面。可是,这说明原本的 “重实边” 现在是 “轻虚边”,只要是轻的就最多 log 2 n \log_2 n log2n 个,倒也没问题。
对于 link \texttt{link} link 操作,则需要谨慎些:若要令 father ( y ) = x \text{father}(y)=x father(y)=x,你应该先 access ( x ) \texttt{access}(x) access(x),这样对势能的影响就局限于 ϕ ( x ) \phi(x) ϕ(x),否则后果不可估量。显然这样对 “重虚边” 的影响也是 Θ ( 1 ) \Theta(1) Θ(1) 级别的。理论上讲,不 access ( x ) \texttt{access}(x) access(x) 会导致 Φ \Phi Φ 异常增大,但我也不会 h a c k \rm hack hack 它……
2.边权
若需要
makeRoot
\texttt{makeRoot}
makeRoot,在点上维护边权难以
f
l
i
p
\tt flip
flip 。我能想到的最好解决方案是,将边设置为虚点,连接两个端点。这样 提取路径
的结果仍然无恙。
3.求最近公共祖先
当然 l c a \tt lca lca 的定义需要原树是有根树,假定已经 m a k e R o o t \tt makeRoot makeRoot 了。
先 a c c e s s ( x ) {\tt access}(x) access(x) 。然后 a c c e s s ( y ) {\tt access}(y) access(y),记录最后一次做 s p l a y \tt splay splay 的点,它就是 l c a lca lca 。
叁、维护动态 d p \tt dp dp
最近两场比赛都用到了 L C T \tt LCT LCT 的 d d p \tt ddp ddp,但是两场我都惨烈爆零,故忧愁幽思而作此章。
一般地,设 d p \tt dp dp 转移是某个算子 Ω \Omega Ω 。那么 s p l a y \tt splay splay 上维护当前子树的 Ω \Omega Ω 复合结果,关键点在于 a c c e s s \tt access access 时切换虚实边的方法。
新的实儿子,即 y y y,其信息恰好存储在 y y y 上,容易获得。而 x x x 被 s p l a y \tt splay splay 后,其重儿子也就是 x x x 在 s p l a y \tt splay splay 上的右儿子所对应的那条链。所以对于该子树,直接求出 x x x 原来的重儿子的信息即可。
若链底的状态向量 v v v 恰可以写成 Ω v ′ \Omega v' Ωv′,那么已知 Ω \Omega Ω 的积时只需代入 v ′ v' v′ 即可知道当前点的 v v v 。否则,我们需要避免最底层的 Ω \Omega Ω 。
一种方法是,求值时直接将链底 s p l a y \tt splay splay,这样其左子树就恰好把自己扔掉了,用左子树的 ∏ Ω \prod\Omega ∏Ω 代入 v v v 求值。或者直接维护 “不含深度最小点的 Ω \Omega Ω 积”,这样的话,麻烦全都扔给了 p u s h U p \rm pushUp pushUp 。
一般而言,代码形如
void access(int x){
for(int y=0; x; y=x,x=fa[x]){
splay(x); // before got
if(ch[x][1] != 0) got(x,ch[x][1]);
if(y != 0) lost(x,y), splay(y);
ch[x][1] = y, pushUp(x);
}
}
在 l o s t \tt lost lost 之后调用的 s p l a y \tt splay splay 并非必要(取决于具体代码实现)。
最后,为了解决 Ω \Omega Ω 构成非交换群的情况,需要维护一个 “自底向上” 的乘积和 “自顶向下” 的乘积,才能完成 f l i p \tt flip flip 。当然在部分无需 m a k e R o o t \tt makeRoot makeRoot 的题目中就不用管。