Link-Cut Tree
基本概念
\(Link\ Cut\ Tree\) ,简称 \(LCT\),是一种用于维护动态树问题的数据结构。
使用 实链剖分 和 \(Splay\) 来维护一棵树 \(T\) 上的若干条实链,通过实链表示出树 \(T\)
单次修改或查询操作均摊时间复杂度是 均摊 \(\mathcal{O}(logn)\)。
算法思想
实链剖分
对于一棵树 \(T\),假如有一个结点 \(u\) 被执行了 \(LCT\) 的 \(access\) 操作,那么我们称结点 \(u\) 被访问过。
对于一个结点 \(u\),假如其子树内最后一次被访问的结点在 \(u\) 的子结点 \(v\) 的子树内,那么我们称 \(v\) 是 \(u\) 的 \(Preferred\ Child\),又称 偏爱儿子。此时 \(u, v\) 之间的树边称为 \(Preferred\ Edge\),即 实边。
由若干条连续实边构成的树链称为 \(Preferred\ Path\),又名 实链。
注意,偏爱儿子和实链并 不是 不变的。
用若干棵 \(Splay\) 来维护剖分出的实链,这些 \(Splay\) 被称为 辅助树。每个 \(Splay\) 维护其对应实链中的结点,按 深度 为关键字排序。换言之,每棵 \(Splay\) 的中序遍历都是对应重链中的结点按深度升序的序列。
连接这些 \(Splay\) 中的结点的边为实边,连接这些 \(Splay\) 的边则称为 虚边。
如图所示,上图为一棵可能的树 \(T\),其中的绿色边为实边,红色边为虚边。
基本操作
\(LCT\) 支持的 \(8\) 个基本操作有:
-
\(Splay\) 支持的基本操作,即 \(rotate\) 和 \(splay\)
-
\(access\ u\),表示将原树根结点到结点 \(u\) 的路径变成实链。
-
\(make\_root\ u\),表示将 \(u\) 变成原树的根结点。
-
\(find\ x\),表示查询原树的根结点。
-
\(split\ x\ y\),表示将树中 \(x, y\) 之间的路径分裂成一棵独立的 \(Splay\)
-
\(cut\ x\ y\),表示删除原树中的树边 \((x, y)\)
-
\(link\ x\ y\),表示在原树中加入一条新的 树边 \((x, y)\)
\(access\)
因为我们需要把 \(x\) 到根结点的路径变成实链路径,那么假如这条路径原本不是实链,\(Splay\) 中一定会有若干条实边变成虚边。
只需要把这些虚边连接的子树分裂出一棵 \(Splay\),再在原树和分裂出的 \(Splay\) 之间连上虚边。
从 \(x\) 所属的 \(Splay\) 向上跳到父结点 \(f\)。对于 \(f\) 的右实子树,因为要令 \(x\) 作为 \(f\) 的实儿子,所以需要断开(等价于它们之间的连边变成虚边)。然后将 \(x\) 接到 \(f\) 的右子树,表示将这条实链 \(x\) 及以后的部分接到这条实链上。
由于没有修改 \(f\) 原本右实儿子的父亲,所以仍然可以从该节点跳到 \(f\)(认父不认子),保证了正确性。
void access(int x)
{
for (int t = 0; x; t = x, x = fa[x])
{
splay(x);
son[x][1] = t;
push_up(x);
}
}
\(make\_root\)
假如我们要把结点 \(x\) 变成根结点。先对 \(x\) 进行一遍 \(access\) 操作,此时 \(x\) 和根结点在同一条重链(同一棵 \(Splay\))内。因为进行过 \(access\) 操作,所以此时 \(x\) 是 \(Splay\) 内深度最大的结点。
把 \(x\) 旋转到这棵 \(Splay\) 的根结点。因为 \(Splay\) 是 以深度为关键字 的,所以交换每个结点的左右子树,那么大小关系就会逆转,即该实链中所有的边会在原树中反向。等同于将原树中 \(x\) 以上的部分翻折成 \(x\) 的子树,此时 \(x\) 显然是根节点。
交换左右子树类似于文艺平衡树。
void make_root(int x)
{
access(x);
splay(x);
rev[x] ^= 1;
swap(son[x][0], son[x][1]);
}
\(find\)
先 \(access\) 再 \(Splay\),保证根和当前节点在同一实链内。因为根深度最小,且 \(Splay\) 以深度为关键字,所以只需要找 \(Splay\) 内值最小的节点(一直往左儿子跳)。
int find(int x)
{
access(x);
splay(x);
while (son[x][0]) x = son[x][0];
return x;
}
\(split\)
注意 \(split\) 操作的前提是 \(x, y\) 属于同一棵 \(LCT\),即它们在原树上连通。
先把结点 \(x\) 变成所属 \(LCT\) 的根,然后对 \(y\) 进行 \(access\)。此时 \(x, y\) 同属于一棵 \(Splay\) 内并且 \(x\) 是 \(Splay\) 中深度最小的结点(变成了根结点),\(y\) 是 \(Splay\) 中深度最大的结点,所以这棵 \(Splay\) 中仅包含深度在 \(x, y\) 之间的结点。
实链中没有深度相同的节点,所以这棵 \(Splay\) 中包含的一定是原树上 \(x, y\) 路径上的结点。
void split(int x, int y)
{
make_root(x);
access(y);
splay(y);
}
\(cut\)
对 \(x, y\) 进行 \(split\) 操作。因为 \(x, y\) 有直接连边,所以得到的 \(Splay\) 仅包含 \(x, y\)。$此时 \(x\) 应该在 \(y\) 的左儿子的位置,并且 \(x\) 没有右儿子。如果 \(x\) 存在右儿子,说明 \(x, y\) 之间存在路径连接而非树边直接连接。
注意这里是 双向删除 而非像虚边一样保留单向
void cut(int x, int y)
{
split(x, y);
if (son[y][0] == x && son[x][1] == 0) son[y][0] = fa[x] = 0;
}
\(link\)
为了保证 \(x\) 以上的实链不会有节点未被更新,先将 \(x\) 变成根,然后直接连边。
void link(int x, int y)
{
make_root(x);
fa[x] = y;
}
\(splay\)
和 \(Splay\) 的 \(splay\) 操作大体类似,不过需要在 \(splay\) 之前把累积的 \(lazy\) 标记释放,否则会导致树的形态错误。注意 \(splay\) 和 \(rotate\) 时需要注意当前结点是否为根,否则父亲指针会指向其他 \(splay\) 的结点。
void splay(int x)
{
q[top = 1] = x;
for (int i = x; !is_root(i); i = fa[i]) q[++top] = fa[i];
for (int i = top; i; i--) push_down(q[i]);
while (!is_root(x))
{
int y = fa[x], z = fa[y];
if (!is_root(y)) rotate(get(x) == get(y) ? y : x);
rotate(x);
}
}
\(is\_root\)
判断结点 \(x\) 是否时所属 \(LCT\) 对应树的根结点。因为假如 \(x\) 是根结点,那么 \(x\) 连向其父亲的边应该是虚边。虚边不会保存父亲向儿子的指针,所以 \(x\) 父亲的左右儿子指针应该都不是 \(x\)。
bool is_root(int x) { return (son[fa[x]][0] != x) && (son[fa[x]][1] != x); }
参考代码
#include <cstdio>
#include <iostream>
using namespace std;
const int maxn = 1e5 + 5;
int n, m, val[maxn];
namespace fast_io
{
template<typename T>
inline void read(T &res)
{
res = 0;
bool flag = false;
char ch = getchar();
while ((ch < '0') || (ch > '9'))
{
flag |= (ch == '-');
ch = getchar();
}
while ((ch >= '0') && (ch <= '9'))
{
res = (res << 3) + (res << 1) + ch - 48;
ch = getchar();
}
res = (flag ? -res : res);
}
template<typename T>
inline void write(T x)
{
if (x < 0)
{
putchar('-');
x = -x;
}
if (x > 9) write(x / 10);
putchar(x % 10 + 48);
}
}
using fast_io::read;
using fast_io::write;
struct LCT
{
int top, fa[maxn], son[maxn][2];
int sum[maxn], q[maxn];
bool lazy[maxn];
void push_up(int x) { sum[x] = sum[son[x][0]] ^ sum[son[x][1]] ^ val[x]; }
void push_down(int x)
{
if (lazy[x])
{
lazy[son[x][0]] ^= 1;
lazy[son[x][1]] ^= 1;
lazy[x] = false;
swap(son[x][0], son[x][1]);
}
}
bool get(int x)
{
return son[fa[x]][1] == x;
}
bool is_root(int x)
{
return (son[fa[x]][0] != x) && (son[fa[x]][1] != x);
}
void rotate(int x)
{
int y = fa[x], z = fa[y], k = get(x);
son[y][k] = son[x][k ^ 1];
fa[son[x][k ^ 1]] = y;
son[x][k ^ 1] = y;
if (!is_root(y)) son[z][son[z][1] == y] = x;
fa[x] = z, fa[y] = x;
push_up(y);
push_up(x);
}
void splay(int x)
{
q[top = 1] = x;
for (int i = x; !is_root(i); i = fa[i]) q[++top] = fa[i];
for (int i = top; i; i--) push_down(q[i]);
while (!is_root(x))
{
int y = fa[x], z = fa[y];
if (!is_root(y)) rotate(get(x) == get(y) ? y : x);
rotate(x);
}
}
void access(int x)
{
for (int t = 0; x; t = x, x = fa[x])
{
splay(x);
son[x][1] = t;
push_up(x);
}
}
void make_root(int x)
{
access(x);
splay(x);
lazy[x] ^= 1;
}
int find(int x)
{
access(x);
splay(x);
while (son[x][0]) x = son[x][0];
return x;
}
void split(int x, int y)
{
make_root(x);
access(y);
splay(y);
}
void cut(int x, int y)
{
split(x, y);
if (son[y][0] == x && son[x][1] == 0) son[y][0] = fa[x] = 0;
}
void link(int x, int y)
{
make_root(x);
fa[x] = y;
}
} tree;
int main()
{
int opt;
read(n), read(m);
for (int i = 1; i <= n; i++)
{
read(val[i]);
tree.sum[i] = val[i];
}
for (int i = 1; i <= m; i++)
{
int x, y;
read(opt), read(x), read(y);
if (opt == 0)
{
tree.split(x, y);
write(tree.sum[y]);
putchar('\n');
}
else if (opt == 1)
{
int belx = tree.find(x), bely = tree.find(y);
if (belx != bely) tree.link(x, y);
}
else if (opt == 2)
{
int belx = tree.find(x), bely = tree.find(y);
if (belx == bely) tree.cut(x, y);
}
else
{
tree.access(x);
tree.splay(x);
val[x] = y;
tree.push_up(x);
}
}
return 0;
}