1. 什么是动态树 (Dynamic Trees) 问题?
在许多算法问题中,我们需要维护一棵树,但这棵树的结构是动态变化的:
- 连接 (Link):将两个原本不连通的树连接起来(添加一条边)。
- 切断 (Cut):将一棵树断开(删除一条边)。
- 查询路径信息:查询两点之间路径上的某些信息,例如:
- 路径上所有点权的和。
- 路径上的最大值/最小值。
- 两点间的距离。
- 两点的最近公共祖先 (LCA)。
传统的静态树数据结构(如树状数组、线段树、倍增法求LCA)无法高效处理这些动态操作。Link-Cut Tree (LCT) 就是为了解决这类动态树问题而设计的,它可以在均摊 O(log n) 的时间复杂度内完成上述所有操作。
2. LCT 的核心思想:Preferred Path (偏好路径)
LCT 的核心思想是将一棵树分解成若干条由实边 (Solid Edge) 连接的路径,这些路径称为 Preferred Path (偏好路径)。不在偏好路径上的边称为虚边 (Dashed Edge)。
- 实边 (Solid Edge):表示一个节点与其“偏好儿子” (Preferred Child) 之间的边。一个节点最多只有一个偏好儿子。
- 虚边 (Dashed Edge):连接一个节点与其非偏好儿子,或者连接不同的偏好路径。
关键点:
- 整棵树被分解成多条由实边构成的链。
- 每条链用一棵Splay Tree来维护。
- 不同的链之间通过虚边连接。
偏好儿子 (Preferred Child) 如何确定?
偏好儿子通常是最后被访问的子节点。例如,当我们对某个子树进行 Access 操作时,该子节点就会成为其父节点的偏好儿子。
3. LCT 的基本操作
LCT 的所有操作都基于以下几个核心操作:
(1) Access(u)
将根节点到节点 u 的路径变为一条由实边构成的路径(即变成一条偏好路径)。
过程:
- 将
uSplay 到其所在 Splay 树的根。 - 如果
u有右儿子(在 Splay 树中),说明存在一个原本在u之后的实边,现在需要将其断开(变为虚边)。将这个右儿子v的父节点指向u的指针清空,并将v的父节点设置为u(通过虚边连接)。 - 如果
u有父节点p(通过虚边连接),则将p进行Access操作,然后将p的右儿子设为u(将这条边变为实边),并更新u的父节点为p。 - 重复以上过程,直到到达根节点。
Access 是 LCT 最核心的操作,其他操作都依赖于它。
(2) MakeRoot(u)
将节点 u 设为整棵树的根。
过程:
- 对
u进行Access操作,使其到原根的路径变为实边。 - 将
uSplay 到其 Splay 树的根。 - 将
u对应的 Splay 树进行翻转 (Reverse) 操作(相当于将路径方向反转),这样u就成为了新的根。
(3) FindRoot(u)
找到节点 u 所在树的根节点。
过程:
- 对
u进行Access操作。 - 将
uSplay 到其 Splay 树的根。 - 在 Splay 树中,沿着左儿子一直走到底,找到最左边的节点,这个节点就是
u所在树的根。
(4) Split(u, v)
将节点 u 和 v 之间的路径分离出来,方便进行路径查询或更新。
过程:
- 将
u设为根:MakeRoot(u)。 - 将
v到根u的路径变为实边:Access(v)。 - 此时
u和v在同一个 Splay 树中,且v是 Splay 树的根。v的子树就代表了u到v的路径。
(5) Link(u, v)
将节点 u 和 v 连接起来,即添加一条边。
过程:
- 将
u设为根:MakeRoot(u)。 - 将
u的父节点设为v(通过虚边连接)。在 LCT 中,这相当于将u作为v的一个儿子。
(6) Cut(u, v)
将节点 u 和 v 之间的边断开。
过程:
- 将
u和v之间的路径分离:Split(u, v)。 - 此时
u和v在同一个 Splay 树中,且v是根,u是v的左儿子(因为u是路径的起点)。 - 将
u的父节点清空,并将v的左儿子清空。
4. C++ 实现
下面是一个支持路径求和、路径最大值、连接、切断操作的 LCT 的 C++ 实现。
#include <iostream>
#include <algorithm>
#include <climits>
using namespace std;
const int MAXN = 100005;
struct Node {
int val; // 节点权值
int sum; // 子树权值和
int max_val; // 子树最大值
int rev; // 翻转标记
Node* ch[2]; // 左右儿子 (0: left, 1: right)
Node* fa; // 父节点 (指向同一Splay中的父节点)
Node(int v = 0) : val(v), sum(v), max_val(v), rev(0), fa(nullptr) {
ch[0] = ch[1] = nullptr;
}
// 更新节点信息
void push_up() {
sum = val;
max_val = val;
for (int i = 0; i < 2; ++i) {
if (ch[i]) {
sum += ch[i]->sum;
max_val = max(max_val, ch[i]->max_val);
}
}
}
// 下放翻转标记
void push_down() {
if (rev) {
swap(ch[0], ch[1]);
if (ch[0]) ch[0]->rev ^= 1;
if (ch[1]) ch[1]->rev ^= 1;
rev = 0;
}
}
// 判断是否为所在Splay的根
bool is_root() {
return !fa || (fa->ch[0] != this && fa->ch[1] != this);
}
// 获取是父节点的哪个儿子 (0: left, 1: right)
int get_son() {
return fa && fa->ch[1] == this;
}
};
Node* pool[MAXN]; // 节点池
int node_cnt = 0;
// 初始化节点
Node* new_node(int val) {
pool[node_cnt] = new Node(val);
return pool[node_cnt++];
}
// Splay 操作
void rotate(Node* x) {
Node* y = x->fa;
Node* z = y->fa;
int d = x->get_son();
// 更新父子关系
if (z && !y->is_root()) z->ch[y->get_son()] = x;
x->fa = z;
y->ch[d] = x->ch[d ^ 1];
if (x->ch[d ^ 1]) x->ch[d ^ 1]->fa = y;
x->ch[d ^ 1] = y;
y->fa = x;
y->push_up();
x->push_up();
}
// Splay 操作:将 x 旋转到其所在 Splay 的根
void splay(Node* x) {
// 先将路径上的翻转标记下放
while (!x->is_root()) {
Node* y = x->fa;
if (!y->is_root()) {
y->fa->push_down();
}
y->push_down();
x->push_down();
}
x->push_down();
while (!x->is_root()) {
Node* y = x->fa;
Node* z = y->fa;
if (!y->is_root()) {
if (x->get_son() == y->get_son()) {
rotate(y); // Zig-Zig
} else {
rotate(x); // Zig-Zag
}
}
rotate(x);
}
x->push_up();
}
// Access 操作:将根到 x 的路径变为实边
Node* access(Node* x) {
Node* last = nullptr;
for (Node* y = x; y; y = y->fa) {
splay(y);
y->ch[1] = last; // 断开原来的右儿子(实边)
y->push_up();
last = y;
}
splay(x);
return last;
}
// MakeRoot 操作:将 x 设为根
void make_root(Node* x) {
access(x);
x->rev ^= 1; // 翻转路径
// 注意:这里 splay(x) 会自动处理翻转标记
}
// FindRoot 操作:找到 x 所在树的根
Node* find_root(Node* x) {
access(x);
splay(x);
while (x->ch[0]) {
x->push_down(); // 下放标记
x = x->ch[0];
}
splay(x);
return x;
}
// Split 操作:分离出 u 到 v 的路径
void split(Node* u, Node* v) {
make_root(u);
access(v);
splay(v); // 此时 v 是 Splay 树的根,u 是 v 的左子树中的节点
}
// Link 操作:连接 u 和 v
void link(Node* u, Node* v) {
make_root(u);
u->fa = v;
}
// Cut 操作:切断 u 和 v 之间的边
void cut(Node* u, Node* v) {
split(u, v);
// 此时 u 是 v 的左儿子
v->ch[0] = nullptr;
u->fa = nullptr;
v->push_up();
}
// 查询 u 到 v 路径上的权值和
int query_sum(Node* u, Node* v) {
split(u, v);
return v->sum;
}
// 查询 u 到 v 路径上的最大值
int query_max(Node* u, Node* v) {
split(u, v);
return v->max_val;
}
// 修改节点 u 的权值
void update_val(Node* u, int new_val) {
access(u);
splay(u);
u->val = new_val;
u->push_up();
}
// 释放内存 (可选)
void clear() {
for (int i = 0; i < node_cnt; ++i) {
delete pool[i];
}
node_cnt = 0;
}
// ======== 测试代码 =========
int main() {
// 创建节点
Node* nodes[6];
for (int i = 1; i <= 5; ++i) {
nodes[i] = new_node(i);
}
// 构建初始树: 1-2-3, 2-4, 4-5
link(nodes[1], nodes[2]);
link(nodes[2], nodes[3]);
link(nodes[2], nodes[4]);
link(nodes[4], nodes[5]);
cout << "1 到 3 路径和: " << query_sum(nodes[1], nodes[3]) << endl; // 6 (1+2+3)
cout << "1 到 3 路径最大值: " << query_max(nodes[1], nodes[3]) << endl; // 3
cout << "2 到 5 路径和: " << query_sum(nodes[2], nodes[5]) << endl; // 11 (2+4+5)
cout << "2 到 5 路径最大值: " << query_max(nodes[2], nodes[5]) << endl; // 5
// 修改节点 4 的权值
update_val(nodes[4], 10);
cout << "修改后 2 到 5 路径和: " << query_sum(nodes[2], nodes[5]) << endl; // 17 (2+10+5)
cout << "修改后 2 到 5 路径最大值: " << query_max(nodes[2], nodes[5]) << endl; // 10
// 切断边 2-4
cut(nodes[2], nodes[4]);
// 重新连接 3-4
link(nodes[3], nodes[4]);
cout << "3 到 5 路径和: " << query_sum(nodes[3], nodes[5]) << endl; // 18 (3+10+5)
cout << "3 到 5 路径最大值: " << query_max(nodes[3], nodes[5]) << endl; // 10
clear();
return 0;
}
5. 代码说明
- Node 结构体:存储节点信息,包括权值、子树和、子树最大值、翻转标记、左右儿子和父节点。
- push_up():合并左右子树的信息。
- push_down():下放翻转标记。
- is_root():判断节点是否为 Splay 的根。
- get_son():判断是父节点的左儿子还是右儿子。
- rotate():Splay 的基本旋转操作。
- splay():将节点旋转到 Splay 根,注意要先下放路径上的标记。
- access():核心操作,将根到
x的路径变为实边。 - make_root():通过
access+ 翻转实现。 - find_root():通过
access+splay+ 向左走到底实现。 - split():通过
make_root+access实现路径分离。 - link() 和 cut():基于
make_root和split实现。 - query_sum(), query_max(), update_val():基于
split或access实现查询和修改。
6. 复杂度分析
- 时间复杂度:所有操作的均摊时间复杂度为 O(log n)。
- 空间复杂度:O(n)。
7. 应用场景
LCT 可以解决几乎所有动态树问题,例如:
- 动态维护树的连通性。
- 动态维护树的直径。
- 动态维护生成树(如动态 MST)。
- 网络流中的动态树优化。
- 一些复杂的图论问题。
8. 注意事项
- LCT 代码较长,细节较多,容易出错,需要仔细调试。
- 翻转标记 (
rev) 的处理非常关键,尤其是在splay和access操作中。 access操作后通常需要splay目标节点。- LCT 的常数较大,在
n不是很大的情况下,可能不如其他简单方法高效。
2559

被折叠的 条评论
为什么被折叠?



