【Coel.学习笔记】连切动态树(Link-Cut-Tree)
终于到臭名昭著的连切树了,它也是整个大纲里最难的树……
引入
动态树是一类维护森林的问题,能够在树链剖分的基础上解决动态断连边等修改操作。其中,连切树(\(\text{Link-Cut-Tree}\),以下简称 LCT)为求解动态树问题的一种方式,利用 Splay 实现,发明者为 Sleator 与 Tarjan(嗯,又是他)。
动态树问题还可以使用欧拉环游树、拓扑树等数据结构解决,但连切树是其中最常用的一种。
LCT 的时间复杂度为 \(O(\log n)\),优于树链剖分的 \(O(\log ^2n)\),但常数较大。
概念与性质
LCT 的边可以分成虚边和实边。一个点最多只有一条实边,也可以没有。实边连成的路径叫做实边路径,单独的一个点也叫实边路径。实边与虚边的差别仅在于树链剖分时是否选择了这条边。
- 我们使用 Splay 维护实边路径,每一个 Splay 的中序遍历为它要维护的路径;Splay 维护的路径一定为极大路径。此时 Splay 具有中序遍历按深度递增的性质。
- Splay 的前驱与后继维护 LCT 的父子关系。注意:Splay 和 LCT 不是同一颗树。
- 对于虚边,它一定是实边路径最高点与其父节点的连边。由于 Splay 的根节点一定没有父节点,所以虚边可以直接用根节点维护。此时,只要让最高的实边路径对应的 Splay 根节点指向 LCT 的根节点,其余 Splay 的根节点指向与其相连的虚边向上的点即可。
核心操作
操作太多,所以先写文字,等下放整个代码。
\(\text{access}\) 实边路径建立
这个操作可以使 \(x\) 节点与根节点建立实边路径,即把路径上所有边改成实边,对应的边改成虚边。
先把 \(x\) 节点伸展到根节点,然后更换儿子、更新信息,然后对实边父亲执行这个操作,直到路径建立完成。
\(\text{makeroot}\) 更换根节点
把 \(x\) 换成根节点。
先建立实边路径,这时的 \(x\) 是深度最大的点。随后把 \(x\) 旋转到根节点,翻转整个 Splay 对应的区间。反转后 \(x\) 成了深度最小的点,也就是根节点了。
\(\text{findroot}\) 寻找根节点
找到 \(x\) 所在树的根节点。
先建立实边路径,然后将其旋转到根节点。接下来一直找左儿子,直到左儿子不存在。这时找到的点就是根节点。
\(\text{split}\) 将路径建立为 Splay
传入两个节点 \(x,y\),将它们之间的路径变为实边路径,再把路径建立为 Splay,根节点为 \(y\)。
先通过 makeroot 把 \(x\) 变成根节点,然后建立从根到 \(y\) 的实边路径。
\(\text{link}\) 加边连接
加入边 \((x,y)\)(假设它们不连通)。
先把 \(x\) 换成根节点,然后找到 \(y\) 所在根节点。若为 \(x\) 则两点连通,无需操作;反之让 \(x\) 的父节点变为 \(y\)。
\(\text{cut}\) 删边断开
删除边 \((x,y)\)(假设边存在)。
还是先把 \(x\) 变为根节点,找 \(y\) 所在根节点。然后判断 \(y\) 是否为 \(x\) 的后继,若不是则不存在边,无需删除;反之让 \(x\) 的父节点为空。
\(\text{isroot}\) 判断是否为根
判断节点 \(x\) 是否为所在 Splay 的根节点。
反过来想,如果不是根节点,那么 \(x\) 必然是其父节点的左儿子或右儿子。这样,只需要判断其父节点的左儿子或右儿子是不是 \(x\)。
例题讲解
【模板】动态树
洛谷传送门
给定 \(n\) 个点以及每个点的权值,处理接下来的 \(m\) 个操作。
操作有四种,操作从 \(0\) 到 \(3\) 编号。点从 \(1\) 到 \(n\) 编号。
0 x y
代表询问从 \(x\) 到 \(y\) 的路径上的点的权值的异或和。保证 \(x\) 到 \(y\) 是联通的。1 x y
代表连接 \(x\) 到 \(y\),若 \(x\) 到 \(y\) 已经联通则无需连接。2 x y
代表删除边 \((x,y)\),不保证边 \((x,y)\) 存在。3 x y
代表将点 \(x\) 上的权值变成 \(y\)。
解析:在 Splay 中维护翻转懒标记和异或和,直接按照 LCT 的操作进行即可。
对于修改操作,先把 \(x\) 伸展到根。由于父节点信息改变不影响子节点,所以修改时只改 \(x\) 的信息,做一遍信息汇总即可。
那么,代码如下:
#include <algorithm>
#include <cstring>
#include <iostream>
using namespace std;
const int maxn = 1e5 + 10;
int n, m;
struct node {
int ch[2], p, v;
int sum, rev;
} t[maxn];
int stk[maxn];
void pushrev(int x) { //下传翻转标记
swap(t[x].ch[0], t[x].ch[1]);
t[x].rev ^= 1;
}
void pushup(int x) {
t[x].sum = t[t[x].ch[0]].sum ^ t[x].v ^ t[t[x].ch[1]].sum;
}
void pushdown(int x) {
if (t[x].rev) {
pushrev(t[x].ch[0]);
pushrev(t[x].ch[1]);
t[x].rev = 0;
}
}
bool isroot(int x) { return t[t[x].p].ch[0] != x && t[t[x].p].ch[1] != x; }
void rotate(int x) {
int y = t[x].p, z = t[y].p;
int k = (t[y].ch[1] == x);
if (!isroot(y)) t[z].ch[t[z].ch[1] == y] = x;
t[x].p = z;
t[y].ch[k] = t[x].ch[k ^ 1], t[t[x].ch[k ^ 1]].p = y;
t[x].ch[k ^ 1] = y, t[y].p = x;
pushup(y), pushup(x);
}
void splay(int x) {
int top = 0, r = x;
stk[++top] = r;
while (!isroot(r)) stk[++top] = r = t[r].p;
while (top) pushdown(stk[top--]);
//以上为 LCT 特有的伸展前置操作
/*先把路径上的点全部 pushdown 才能旋转
由于直接写递归不太美观(而且递归常数大)
所以用一个栈来存要 pushdown 的点*/
while (!isroot(x)) {
int y = t[x].p, z = t[y].p;
if (!isroot(y)) {
if ((t[y].ch[1] == x) ^ (t[z].ch[1] == y))
rotate(x);
else
rotate(y);
}
rotate(x);
}
}
//以上为 Splay 操作
void access(int x) {
int z = x;
for (int y = 0; x; y = x, x = t[x].p) {
splay(x);
t[x].ch[1] = y;
pushup(x);
}
splay(z);
}
void makeroot(int x) {
access(x);
pushrev(x);
}
int findroot(int x) {
access(x);
while (t[x].ch[0]) {
pushdown(x);
x = t[x].ch[0];
}
splay(x);
return x;
}
void split(int x, int y) {
makeroot(x);
access(y);
}
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) t[x].p = y;
}
void cut(int x, int y) {
makeroot(x);
if (findroot(y) == x && t[y].p == x && !t[y].ch[0]) {
t[x].ch[1] = t[y].p = 0;
pushup(x);
}
}
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> t[i].v;
while (m--) {
int op, x, y;
cin >> op >> x >> y;
if (op == 0)
split(x, y), cout << t[y].sum << '\n';
else if (op == 1)
link(x, y);
else if (op == 2)
cut(x, y);
else
splay(x), t[x].v = y, pushup(x);
}
return 0;
}
操作很多,但其实很多操作只要两个函数就可以水过去,所以码量并不大。
A + B Problem(?
洛谷传送门 双倍经验 三倍经验
输入两个整数 \(a, b\),输出它们的和(\(|a|,|b| \le {10}^9\))。
注意
- Pascal 使用
integer
会爆掉哦! - 有负数哦!
- C/C++ 的 main 函数必须是
int
类型,而且 C 最后要return 0
。这不仅对洛谷其他题目有效,而且也是 NOIP/CSP/NOI 比赛的要求!
好吧,同志们,我们就从这一题开始,向着大牛的路进发。
任何一个伟大的思想,都有一个微不足道的开始。
解析:嗯,纯粹没事做,顺便致敬用 LCT 写题解的 Treeloveswater 先生。
先给 \(a,b\) 连边,然后求它们的路径和。别忘了信息汇总的时候把异或和改成加和。
为了更好的致敬这个大佬,我们在实际中也用连-切-连的脑瘫操作。
主函数代码如下:
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> t[1].v >> t[2].v;
link(1, 2), cut(1, 2), link(1, 2);
split(1, 2);
cout << t[2].sum;
return 0;
}
[SDOI2008] 洞穴勘测
洛谷传送门
给定一个无向无权图,进行如下操作:
Connect u v
给 \(u,v\) 连边。Destory u v
删除边 \(u,v\)。Query u v
查询 \(u,v\) 是否连通,连通输出Yes
,否则输出No
。
解析:板啊!很板啊!
甚至不需要维护权值, pushup
再您的见!
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
while (m--) {
char op[30];
int u, v;
cin >> op >> u >> v;
if (!strcmp(op, "Connect"))
link(u, v);
else if (!strcmp(op, "Destroy"))
cut(u, v);
else {
if (findroot(u) == findroot(v))
cout << "Yes" << '\n';
else
cout << "No" << '\n';
}
}
return 0;
}
下一题是比较正经的动态树例题了……
[NOI2014] 魔法森林
洛谷传送门
给定一张无向图,每条边都有两个权值 \(a_i,b_i\)。找到一条从起点到终点的路径,使得路径上的 \(a_i\) 最大值与 \(b_i\) 最大值之和最小。
解析:看起来有点像最短路,但可惜不能用最短路做。
考虑枚举 \(a_i\),对于枚举权值的最大值 \(A\),找到 \(b_i\) 对应的最大值和最小值。由于 \(A\) 已经确定,所以我们能使用的边必须满足 \(a_i\leq A\),这样枚举有了方向。
我们先对 \(a_i\) 做一个从小到大的排序(对 \(b_i\) 排序也行),然后逐个枚举 \(A\),与此同时将满足条件的边加入图中,并求出 \(b_i\) 最大值的最小化。当图中出现环时,我们要去掉权值最大的一条边,从而保证最大值最小(类似 Kruskal 的思想)。
去掉最大权值边的操作看起来有点“贪心”,会不会导致错失最优解呢?答案是否定的。利用反证法,假设去掉环上最大边后无法得到最优解,那么这条边换成环内另外几条边时,这个环对应的权值会变大,这与权值最大矛盾,因此去掉最大权值边可以得到最优解。
利用 Splay 可以轻松地动态维护最小值;同时我们要动态地插入、删除边,很容易想到用 LCT。由于权值在边上而 LCT 维护的权值在点上,所以类比网络流,使用“拆边”的技巧,在边上建立一个点,把边权赋给点权。此外判断连通时用 findroot
常数比较大,我们改用并查集维护。
下面是 pushup
函数和主函数的代码:
void pushup(int x) {
t[x].mx = x; //mx 维护子树中权值最大点
for (int i = 0; i < 2; i++)
if (t[t[t[x].ch[i]].mx].v > t[t[x].mx].v) t[x].mx = t[t[x].ch[i]].mx;
}
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int x, y, u, v;
cin >> x >> y >> u >> v;
e[i] = {x, y, u, v};
}
sort(e + 1, e + m + 1);
for (int i = 1; i <= n + m; i++) {
f[i] = i;
if (i > n) t[i].v = e[i - n].b; //拆边
t[i].mx = i;
}
for (int i = 1; i <= m; i++) {
int u = e[i].u, v = e[i].v, a = e[i].a, b = e[i].b;
if (find(u) == find(v)) {
split(u, v);
int mx = t[v].mx;
if (t[mx].v > b) { //有环且存在更小权值,删边并维护更小权值的边
cut(e[mx - n].u, mx), cut(mx, e[mx - n].v);
link(u, n + i), link(n + i, v);
}
} else { //不连通,直接连边
f[find(u)] = find(v);
link(u, n + i), link(n + i, v);
}
if (find(1) == find(n)) { //可以到达终点则更新答案
split(1, n);
res = min(res, t[t[n].mx].v + a);
}
}
if (res == inf) {
cout << -1;
goto Miolic_End;
}
cout << res;
Miolic_End:
return 0;
}
[国家集训队]Tree II
给定一棵初始权值均为 \(1\) 的树,进行以下四个操作:
洛谷传送门
+ u v c
:将 \(u\) 到 \(v\) 的路径上的点的权值都加上自然数 \(c\);- u1 v1 u2 v2
:将树中原有的边 \((u_1,v_1)\) 删除,加入一条新边 \((u_2,v_2)\),保证操作完之后仍然是一棵树;* u v c
:将 \(u\) 到 \(v\) 的路径上的点的权值都乘上自然数 \(c\);/ u v
:询问 \(u\) 到 \(v\) 的路径上的点的权值和,将答案对 \(51061\) 取模。
解析:如果没有乘法操作,这题就是一个裸的动态树问题,直接用上面 A + B Problem 的做法即可。
现在有了乘法操作,关键就在于标记下传。类比【模板】线段树 2 的做法,我们在每次下传标记的时候先传乘法再传加法,最后翻转。由于 Splay 的长度不固定,所以我们还要再维护一个 size。
剩下的就是 LCT 的板子了,注意一点细节即可。这里用了类封装 LCT,看起来更简洁一点……吧?
#include <algorithm>
#include <iostream>
#define int unsigned int //答案会爆 int,但不会爆 unsigned int……
using namespace std;
const int maxn = 5e5 + 10, mod = 51061;
int n, Q;
class Link_Cut_Tree {
private:
struct node {
int ch[2], p, v;
int sum, size;
int rev, add, mul;
} t[maxn];
int stk[maxn];
void pushup(int x) {
t[x].sum = (t[t[x].ch[0]].sum + t[t[x].ch[1]].sum + t[x].v) % mod;
t[x].size = t[t[x].ch[0]].size + t[t[x].ch[1]].size + 1;
}
void pushrev(int x) {
swap(t[x].ch[0], t[x].ch[1]);
t[x].rev ^= 1;
}
void pushadd(int x, int c) { //下传加法
(t[x].sum += c * t[x].size) %= mod;
(t[x].v += c) %= mod;
(t[x].add += c) %= mod;
}
void pushmul(int x, int c) { //下传乘法
(t[x].sum *= c) %= mod;
(t[x].v *= c) %= mod;
(t[x].mul *= c) %= mod;
(t[x].add *= c) %= mod;
}
void pushdown(int x) { //先乘后加再翻转
if (t[x].mul != 1) {
pushmul(t[x].ch[0], t[x].mul);
pushmul(t[x].ch[1], t[x].mul);
t[x].mul = 1;
}
if (t[x].add) {
pushadd(t[x].ch[0], t[x].add);
pushadd(t[x].ch[1], t[x].add);
t[x].add = 0;
}
if (t[x].rev) {
if (t[x].ch[0]) pushrev(t[x].ch[0]);
if (t[x].ch[1]) pushrev(t[x].ch[1]);
t[x].rev ^= 1;
}
}
bool isroot(int x) { return t[t[x].p].ch[0] != x && t[t[x].p].ch[1] != x; }
void rotate(int x) {
int y = t[x].p, z = t[y].p;
int k = (t[y].ch[1] == x);
if (!isroot(y)) t[z].ch[t[z].ch[1] == y] = x;
t[x].p = z;
t[y].ch[k] = t[x].ch[k ^ 1], t[t[x].ch[k ^ 1]].p = y;
t[x].ch[k ^ 1] = y, t[y].p = x;
pushup(y), pushup(x);
}
void splay(int x) {
int top = 0, r = x;
stk[++top] = r;
while (!isroot(r)) stk[++top] = r = t[r].p;
while (top) pushdown(stk[top--]);
while (!isroot(x)) {
int y = t[x].p, z = t[y].p;
if (!isroot(y)) {
if ((t[y].ch[1] == x) ^ (t[z].ch[1] == y))
rotate(x);
else
rotate(y);
}
rotate(x);
}
}
void access(int x) {
int z = x;
for (int y = 0; x; y = x, x = t[x].p) {
splay(x);
t[x].ch[1] = y;
pushup(x);
}
splay(z);
}
void makeroot(int x) {
access(x);
pushrev(x);
}
void split(int x, int y) {
makeroot(x);
access(y);
}
int findroot(int x) {
access(x);
while (t[x].ch[0]) {
pushdown(x);
x = t[x].ch[0];
}
splay(x);
return x;
}
public:
void init(int n) { //初始化,注意乘法标记要初始化为 1
for (int i = 1; i <= n; i++) t[i].mul = t[i].size = t[i].v = 1;
}
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) t[x].p = y;
}
void cut(int x, int y) {
makeroot(x);
if (findroot(y) == x && t[y].p == x && !t[y].ch[0]) {
t[x].ch[1] = t[y].p = 0;
pushup(x);
}
}
void add(int u, int v, int c) { split(u, v), pushadd(v, c); }
void mul(int u, int v, int c) { split(u, v), pushmul(v, c); }
int query(int u, int v) { return split(u, v), t[v].sum; }
} LCT;
signed main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> Q;
LCT.init(n);
for (int i = 1, u, v; i <= n - 1; i++) {
cin >> u >> v;
LCT.link(u, v);
}
while (Q--) {
char op;
cin >> op;
if (op == '+') {
int u, v, c;
cin >> u >> v >> c;
LCT.add(u, v, c);
} else if (op == '-') {
int u1, v1, u2, v2;
cin >> u1 >> v1 >> u2 >> v2;
LCT.cut(u1, v1), LCT.link(u2, v2);
} else if (op == '*') {
int u, v, c;
cin >> u >> v >> c;
LCT.mul(u, v, c);
} else {
int u, v;
cin >> u >> v;
cout << LCT.query(u, v) << '\n';
}
}
return 0;
}