树链剖分

YZY Lv3

树链剖分

想象一下,一颗结构复杂的树,节点间的路径查询、修改操作如果每次都暴力DFS/BFS,时间复杂度是难以承受的。树链剖分(Heavy-Light Decomposition, HLD)的核心思想,就是将这棵树“剖分”成若干条“重链”和“轻链”,使得任何一条路径都可以被拆解成少数几段重链上的连续区间和少数几条轻边。这样,我们就可以利用序列数据结构(如线段树)来维护这些重链上的信息,从而实现快速的路径操作。

一、核心概念:重与轻的艺术

在深入代码之前,我们必须理解几个核心概念:

  1. 子树大小 (Size): 对于节点 ,其子树大小 定义为以 为根的子树中节点的总数(包括 自身)。这个可以通过一次DFS轻松求得。

  2. 重儿子 (Heavy Child): 对于一个非叶节点 ,其所有儿子节点 中,子树大小 最大的那个儿子 就是 重儿子,记为 。如果有多个儿子的子树大小相同且最大,则任选一个作为重儿子。连接 的边称为**重边 (Heavy Edge)**。

  3. 轻儿子 (Light Child): 节点 的儿子中,除了重儿子以外的其他儿子都是轻儿子。连接 和轻儿子的边称为**轻边 (Light Edge)**。

  4. 重链 (Heavy Path): 由若干条首尾相连的重边构成的路径。严格来说,一条重链的顶端要么是树的根,要么是通过一条轻边连接到其父节点的。重链会一直向下延伸,直到遇到一个叶子节点,或者下一个节点不再是当前节点的重儿子。

看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
graph TD
A(1) --- B(2)
A --- C(3)
A --- D(4)
B --- E(5)
B --- F(6)
C --- G(7)
D --- H(8)
D --- I(9)
D --- J(10)
J --- K(11)

subgraph Subtree Sizes
S1[1: 11]
S2[2: 3]
S3[3: 2]
S4[4: 5]
S5[5: 1]
S6[6: 1]
S7[7: 1]
S8[8: 1]
S9[9: 1]
S10[10: 2]
S11[11: 1]
end

style A fill:#f9f,stroke:#333,stroke-width:2px
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#bbf,stroke:#333,stroke-width:2px
style D fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#bbf,stroke:#333,stroke-width:2px
style F fill:#bbf,stroke:#333,stroke-width:2px
style G fill:#bbf,stroke:#333,stroke-width:2px
style H fill:#bbf,stroke:#333,stroke-width:2px
style I fill:#bbf,stroke:#333,stroke-width:2px
style J fill:#f9f,stroke:#333,stroke-width:2px
style K fill:#f9f,stroke:#333,stroke-width:2px

linkStyle 0 stroke-width:4px,stroke:red;
linkStyle 6 stroke-width:4px,stroke:red;
linkStyle 8 stroke-width:4px,stroke:red;
linkStyle 9 stroke-width:4px,stroke:red;

假设节点旁边是其编号,括号内是其子树大小。

  • 节点1的儿子:2(3), 3(2), 4(5)。 (重儿子)。(1,4)是重边。 (1,2), (1,3)是轻边。
  • 节点2的儿子:5(1), 6(1)。任选一个,比如 。 (2,5)是重边。(2,6)是轻边。
  • 节点4的儿子:8(1), 9(1), 10(2)。。(4,10)是重边。(4,8), (4,9)是轻边。
  • 节点10的儿子: 11(1)。。(10,11)是重边。

于是,我们得到了几条重链:

  • (1-4-10-11)
  • (2-5) (假设5是2的重儿子)
  • (3) (3自己是一条重链,它的顶端通过轻边(1,3)连接到1)
  • (6) (6自己是一条重链)
  • (7) (7自己是一条重链)
  • (8) (8自己是一条重链)
  • (9) (9自己是一条重链)

(上图中的红色边是重边,粉色节点是重链上的节点。为了区分,这里假设2的重儿子是5,则2-5是重链;如果2的重儿子是6,则2-6是重链。)

树链剖分的神奇之处在于它的一些优美性质:

  1. 从根节点到任意节点的路径上,轻边的数量不超过 条。
  2. 从根节点到任意节点的路径上,重链的数量(经过的不同重链的段数)也不超过 条。

为什么?考虑从节点 走到它的一个轻儿子 。因为 是轻儿子,所以 。又因为所有儿子的子树大小之和小于 ,且 至少占了一半(如果只有一个儿子,那它就是重儿子;如果有多个,重儿子大小 其他任一儿子大小,所以 if is light and there’s a heavy child satisfying 。更准确地说, 。如果 的轻儿子,那么 。因为如果 ,那么 必然是重儿子(或者并列重儿子)。所以,每经过一条轻边,当前节点所在子树的大小至少减半。因此,路径上的轻边数量是 级别的。

二、两次DFS:奠定剖分基石

树链剖分的实现依赖于两次DFS。

DFS第一遍:预处理基本信息

第一次DFS的目的是计算出每个节点的父节点 fa[u]、深度 dep[u]、子树大小 sz[u],并确定其重儿子 son[u]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vector<int> g[N]; // 邻接表存树
int fa[N], dep[N], sz[N], son[N]; // 父节点,深度,子树大小,重儿子

void dfs1(int u, int p, int d) {
fa[u] = p;
dep[u] = d;
sz[u] = 1;
int mxs_sz = -1; // 记录最大儿子子树大小,用于找重儿子
son[u] = 0; // 0表示没有重儿子 (比如叶子节点)

for (int v : g[u]) {
if (v == p) continue;
dfs1(v, u, d + 1);
sz[u] += sz[v];
if (sz[v] > mxs_sz) {
mxs_sz = sz[v];
son[u] = v;
}
}
}

调用时通常从根节点开始:dfs1(root, 0, 1); (假设根节点为1,其父节点为0,深度为1)。

DFS第二遍:链式剖分与编号

第二次DFS是树链剖分的核心。它要确定每个节点所在重链的顶端节点 top[u],并为每个节点分配一个新的DFS序编号 dfn[u]。这个 dfn 序非常关键,它将树上操作映射到序列上。同时,我们还需要一个映射 rnk[t],表示 dfn 序为 t 的节点是哪个原始节点。

top[u]:节点 所在重链的链顶节点。
dfn[u]:节点 在DFS序(也是将来线段树中的位置)中的编号。
rnk[t]:DFS序中第 个位置对应的原始树节点编号。
clk (或 tim, idx):全局时间戳,用于分配 dfn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int top[N], dfn[N], rnk[N]; // 链顶,DFS序,DFS序反向映射
int clk; // 时间戳

void dfs2(int u, int t) {
top[u] = t; // u所在重链的顶端是t
dfn[u] = ++clk; // 分配DFS序
rnk[clk] = u; // 记录dfn[u]对应的原始节点

if (!son[u]) return; // 如果是叶子节点(或没有重儿子)

// 优先处理重儿子
// 这样可以保证一条重链上的节点在dfn序中是连续的
dfs2(son[u], t);

// 处理轻儿子
for (int v : g[u]) {
if (v == fa[u] || v == son[u]) continue;
dfs2(v, v); // 轻儿子v开始一条新的重链,所以链顶是v自己
}
}

调用时通常从根节点开始:clk = 0; dfs2(root, root); (根节点自己形成一条重链的顶端)。

关键点:在dfs2中,我们优先递归重儿子,并且传递相同的top值 (t)。这意味着重儿子和它的父节点(如果父子边是重边)属于同一条重链,并且它们的dfn序是连续的。当遇到轻儿子时,它将成为一条新重链的顶端,所以传递v作为新的top值。

经过这两遍DFS,树就被我们“拉直”了。同一条重链上的节点,它们的dfn序是连续的一个区间。这是一个至关重要的性质,因为它允许我们使用线段树等数据结构来维护重链上的信息。

三、路径操作:化曲为直

有了上述预处理,我们就可以在树上执行路径查询和修改了。假设我们要操作路径

核心思想是:不断地将 中深度较大的那个节点向上跳,每次跳跃都选择跳到当前节点所在重链的顶端的父节点。在跳跃过程中,我们会经过若干条重链(的片段)和若干条轻边。

  1. 选择深度大的节点往上跳:不妨设 (如果不是,则交换 )。这意味着 所在的重链在 所在的重链的“下方”(或者 在同一条重链上,但 更深)。
  2. 处理当前重链:当前要处理的节点是 。它所在的重链是 。这个区间在 dfn 序中是连续的:。我们就在线段树上对这个区间进行操作(查询或修改)。
  3. 跳到下一条链:操作完当前重链后,将 更新为其所在重链顶端的父节点:。这相当于越过了一条轻边(或者到达了LCA,如果的父节点是的祖先或本身)。
  4. 重复:重复步骤1-3,直到 跳到同一条重链上,即
  5. 处理最后一段:此时 在同一条重链上。它们之间的路径在dfn序中也是连续的。假设 ,则区间为 ;否则为 。在线段树上操作这个区间。

整个过程中,我们一共会操作 段重链区间(因为每次跳过一条轻边或到达LCA)。如果线段树单次操作的复杂度是 ,那么一次树上路径操作的总复杂度就是

子树操作

树链剖分后,子树操作变得异常简单。由于DFS序的性质,以 为根的子树中的所有节点,其 dfn 序恰好构成一个连续的区间:。因此,对子树 的操作可以直接转化为对线段树上这个区间的操作,复杂度为

四、实战演练:模板题与代码实现

我们来看一个经典的树链剖分应用场景:

题意概括:
给定一棵 个节点的树,每个节点有一个初始权值。有 个操作,操作分为两种:

  1. 1 u v w: 将路径 上所有节点的权值增加
  2. 2 u v: 查询路径 上所有节点的权值之和。
    (为了简化,我们先考虑点权修改和点权查询,如果是边权,可以将其下放给深度较大的端点来处理,或者对边进行编号。)

或者更基础一点:

  1. 1 u w: 将节点 的权值修改为
  2. 2 u v: 查询路径 上所有节点的权值和。
  3. 3 u w: 将以 为根的子树中所有节点的权值增加
  4. 4 u: 查询以 为根的子树中所有节点的权值和。

我们就以这个更全面的版本为例。这里需要一个支持区间加、区间和查询的线段树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
#include <bits/stdc++.h>

using namespace std;
using ll = long long;

const int N = 1e5 + 5; // 节点数量上限

vector<int> g[N];
int n, m, rt; // 节点数,操作数,根节点
ll val[N]; // 原始节点权值

// HLD 变量
int fa[N], dep[N], sz[N], son[N];
int top[N], dfn[N], rnk[N]; // rnk[i]是dfn序为i的节点编号
int clk;

// --- DFS1: 预处理 ---
void dfs1(int u, int p, int d) {
fa[u] = p;
dep[u] = d;
sz[u] = 1;
int mxs = -1; // max son size
son[u] = 0;
for (int v : g[u]) {
if (v == p) continue;
dfs1(v, u, d + 1);
sz[u] += sz[v];
if (sz[v] > mxs) {
mxs = sz[v];
son[u] = v;
}
}
}

// --- DFS2: 剖分与编号 ---
void dfs2(int u, int t) {
top[u] = t;
dfn[u] = ++clk;
rnk[clk] = u; // 记录映射关系
if (!son[u]) return;
dfs2(son[u], t); // 优先重儿子
for (int v : g[u]) {
if (v == fa[u] || v == son[u]) continue;
dfs2(v, v); // 轻儿子开始新链
}
}

// --- 线段树部分 ---
// 这里以一个支持区间加、区间求和的线段树为例
struct SegNode {
ll sum, lz; // 区间和,懒惰标记
} tr[N << 2]; // 线段树数组,大小一般为4N

// ls, rs 只是为了代码简洁
#define ls (id << 1)
#define rs (id << 1 | 1)

void push_up(int id) {
tr[id].sum = tr[ls].sum + tr[rs].sum;
}

void push_down(int id, int l, int r) {
if (tr[id].lz == 0) return;
int mid = (l + r) >> 1;
// 更新左儿子
tr[ls].sum += tr[id].lz * (mid - l + 1);
tr[ls].lz += tr[id].lz;
// 更新右儿子
tr[rs].sum += tr[id].lz * (r - (mid + 1) + 1);
tr[rs].lz += tr[id].lz;
// 清除当前节点懒标记
tr[id].lz = 0;
}

void build(int id, int l, int r) {
tr[id].lz = 0;
if (l == r) {
tr[id].sum = val[rnk[l]]; // 线段树的叶子节点l对应原树的rnk[l]节点
return;
}
int mid = (l + r) >> 1;
build(ls, l, mid);
build(rs, mid + 1, r);
push_up(id);
}

// 区间更新 [ql, qr] 增加 v
void upd_rng(int id, int l, int r, int ql, int qr, ll v) {
if (ql <= l && r <= qr) {
tr[id].sum += v * (r - l + 1);
tr[id].lz += v;
return;
}
push_down(id, l, r);
int mid = (l + r) >> 1;
if (ql <= mid) upd_rng(ls, l, mid, ql, qr, v);
if (qr > mid) upd_rng(rs, mid + 1, r, ql, qr, v);
push_up(id);
}

// 点更新 pos 权值修改为 v (实际是增加 v - 原值)
// 这里为了通用性,可以直接用区间更新upd_rng(1, 1, n, dfn[u], dfn[u], v - val[u])
// 然后更新val[u] = v。
// 或者,我们可以写一个专门的点修改,但如果同时需要区间加,则点修改也需考虑懒标记。
// 简单起见,如果题目是“修改为w”,可以先查询原值,再计算差值进行区间加。
// 或者,线段树直接支持“区间赋值”操作。
// 为简单,这里假设题目允许我们把“修改为w”变成“增加 w-old_w”
// 如果是单点修改为w,而不涉及区间加:
void upd_pt(int id, int l, int r, int pos, ll v) {
if (l == r) {
tr[id].sum = v; // 直接赋值
return;
}
push_down(id, l, r); // 如果有区间加,点修改前也要下放懒标记
int mid = (l + r) >> 1;
if (pos <= mid) upd_pt(ls, l, mid, pos, v);
else upd_pt(rs, mid + 1, r, pos, v);
push_up(id);
}


// 区间查询 [ql, qr] 的和
ll qry_rng(int id, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) {
return tr[id].sum;
}
push_down(id, l, r);
int mid = (l + r) >> 1;
ll res = 0;
if (ql <= mid) res += qry_rng(ls, l, mid, ql, qr);
if (qr > mid) res += qry_rng(rs, mid + 1, r, ql, qr);
return res;
}

// --- HLD 操作函数 ---

// 路径 (u,v) 上节点权值增加 x
void upd_path(int u, int v, ll x) {
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
upd_rng(1, 1, n, dfn[top[u]], dfn[u], x); // 更新u所在重链从top[u]到u的部分
u = fa[top[u]]; // u跳到链顶的父节点
}
//此时 u,v 在同一条重链上
if (dep[u] > dep[v]) swap(u, v);
upd_rng(1, 1, n, dfn[u], dfn[v], x); // 更新 dfn[u]到dfn[v] (已保证u深度浅)
}

// 查询路径 (u,v) 权值和
ll qry_path(int u, int v) {
ll ans = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
ans += qry_rng(1, 1, n, dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
if (dep[u] > dep[v]) swap(u, v);
ans += qry_rng(1, 1, n, dfn[u], dfn[v]);
return ans;
}

// 子树 u 所有节点权值增加 x
void upd_sub(int u, ll x) {
upd_rng(1, 1, n, dfn[u], dfn[u] + sz[u] - 1, x);
}

// 查询子树 u 权值和
ll qry_sub(int u) {
return qry_rng(1, 1, n, dfn[u], dfn[u] + sz[u] - 1);
}

// 单点 u 修改为 x
// 注意:如果原题是val[u]=x,而线段树支持的是区间加,则需要先求出旧值old_val = qry_rng(1,1,n,dfn[u],dfn[u])
// 然后 upd_rng(1,1,n,dfn[u],dfn[u], x - old_val)。
// 如果线段树的upd_pt是直接赋值,则可以直接调用:
void upd_node(int u, ll x) {
upd_pt(1, 1, n, dfn[u], x); // 假设upd_pt是直接赋值,并且会处理懒标记
val[u] = x; // 别忘了更新原始数组(如果后续还需要基于原始值计算差量)
}
// 对于本例的线段树(区间加,区间和),单点修改为x可以这样做:
// 1. 先查出原始值(如果val[]没有及时更新): ll old_v = qry_rng(1, 1, n, dfn[u], dfn[u]);
// 2. 计算差值: ll delta = x - old_v; (如果val[u]是准确的,则delta = x - val[u])
// 3. 区间更新这个点: upd_rng(1, 1, n, dfn[u], dfn[u], delta);
// 4. 更新val[u]: val[u] = x; (若需要)
// 简单起见,如果题目是“修改点u的值为w”,而线段树是区间加,则上面的upd_pt(直接赋值)可能不适用。
// 需要用upd_rng(1,1,n, dfn[u], dfn[u], w - 当前u的值)
// 为了演示“单点修改为w”,我们改写upd_node
void set_node_val(int u, ll new_val) {
ll current_val_in_seg_tree = qry_rng(1, 1, n, dfn[u], dfn[u]); // 获取线段树中该点当前值
ll delta = new_val - current_val_in_seg_tree;
upd_rng(1, 1, n, dfn[u], dfn[u], delta); // 增加差值
val[u] = new_val; // 更新备份的原始值数组
}


int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);

cin >> n >> m >> rt; // 读入节点数,操作数,根节点
// 读入各点初始权值
for (int i = 1; i <= n; ++i) cin >> val[i];

// 读入边,建树
for (int i = 0; i < n - 1; ++i) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}

// HLD 预处理
dfs1(rt, 0, 1); // 假设根为rt, 父为0, 深度为1
clk = 0; // 初始化时间戳
dfs2(rt, rt); // 从根开始剖分,根自己是一条重链的顶

// 构建线段树
build(1, 1, n); // 线段树操作区间是[1, n],对应dfn序

// 处理操作
for (int i = 0; i < m; ++i) {
int op, u, v;
ll w;
cin >> op;
if (op == 1) { // 路径(u,v)增加w
cin >> u >> v >> w;
upd_path(u, v, w);
} else if (op == 2) { // 查询路径(u,v)和
cin >> u >> v;
cout << qry_path(u, v) << "\n";
} else if (op == 3) { // 子树u增加w
cin >> u >> w;
upd_sub(u, w);
} else if (op == 4) { // 查询子树u和
cin >> u;
cout << qry_sub(u) << "\n";
}
// 如果还有单点修改为w (比如 op == 0, u, w)
// else if (op == 0) {
// cin >> u >> w;
// set_node_val(u, w);
// }
}

return 0;
}

/*
复杂度分析:
- DFS1, DFS2: O(N)
- 线段树构建: O(N)
- 路径操作 (upd_path, qry_path): 每次操作最多分解为 O(logN) 段重链区间,每段区间在线段树上操作为 O(logN)。总复杂度 O(log^2 N)。
- 子树操作 (upd_sub, qry_sub): 子树对应dfn序上一个连续区间,线段树操作 O(logN)。
- 单点修改 (set_node_val): 本质上是查一次点值(O(logN))再更新一次点值(O(logN)),所以是O(logN)。如果用专门的点修改线段树函数且处理好懒标记,也是O(logN)。

空间复杂度: O(N) (邻接表、HLD各种数组、线段树数组)
*/

代码说明与注意事项:

  1. 变量名:遵循了短名称的原则,如 g (graph), sz (size), clk (clock/timer), ls/rs (left son / right son for segment tree)。
  2. DFS序与线段树:线段树的下标 直接对应 dfn 序。build 函数中,线段树叶子节点 l(其 dfn 值为 l)对应的原始树节点是 rnk[l],其权值为 val[rnk[l]]
  3. push_down的时机:在线段树的查询和(可能分裂的)更新操作中,访问子节点前,务必先 push_down 父节点的懒惰标记。
  4. upd_pathqry_path 的逻辑:核心是 while (top[u] != top[v]) 循环。每次选择深度更深的链顶对应的节点(比如 )向上跳,处理 dfn[top[u]]dfn[u] 这一段,然后令 u = fa[top[u]]。当 top[u] == top[v] 时,它们就在同一条重链上了,再处理最后一段 dfn[u]dfn[v](注意谁的 dfn 小就在前面)。
  5. 根节点:代码中假设根节点是 rtdfs1(rt, 0, 1)dfs2(rt, rt)fa[rt] 会是0。
  6. 单点修改与区间修改的协调:如果线段树同时支持点修改(直接赋值)和区间修改(增量),懒标记的处理会更复杂。示例代码中的线段树是区间加、区间和。set_node_val 通过先查询再区间加差值的方式实现单点赋值,这在有懒标记的区间加线段树上是标准做法。如果只有单点修改和区间查询(无区间修改),线段树会简单很多,不需要懒标记。

五、边权的处理:巧妙转化

在之前的例子中,我们处理的是点权。但很多题目涉及的是边权。树链剖分同样能优雅地处理边权问题。核心思想是:将每条边的权值下放到它连接的两个节点中深度较大的那个点上。

假设有一条边 ,其中 的儿子(即 )。我们将这条边的权值视为节点 的一个属性。这样,对一条边的修改就变成了对点 的修改,对一条边权值的查询也变成了对点 权值的查询。

  • 建树与DFS:与点权版本基本一致。dfs1 计算 fa, dep, sz, sondfs2 计算 top, dfn, rnk
  • 权值存储:在线段树 build 的时候,线段树叶子节点 dfn[x] 存储的是原树中与 fa[x] 相连的那条边的权值(如果 x 不是根的话)。根节点没有与其父节点相连的边,其权值可以视为0或一个特殊标记。
    或者说,我们让 val[i] 存储节点 i 与其父节点 fa[i] 之间的边权。那么在线段树 build 时,dfn 序为 k 的位置(即节点 rnk[k])就存储 val[rnk[k]]
  • 路径操作
    • 修改路径 上的边权:当我们沿着路径 跳链时,例如从 跳到 ,我们修改的区间是 。这些 dfn 对应的点,除了 这个点本身之外,其他点 的权值都代表了边 的权值。那么 呢?如果 不是LCA,那么我们实际上是想修改 这条轻边。它的权值已经赋给了
      跳到同一条重链上时,假设 ,路径是 。涉及的边是 。对应的点是 的某个儿子,…,一直到 。这些点的 dfn 序是 。或者说,如果我们修改的是 区间内的点,那么 对应的点权值是 之间的边,这可能不是我们想要的。
      更准确地说:要修改路径 上的所有边权,找到 。路径分为 两段。对于 段(),我们操作的是 这些点所代表的边。
      一个更简洁的处理方式是:当我们在 upd_path(x, y, w)qry_path(x, y) 时,对于从 跳到 在重链上的祖先),我们操作的区间是 。如果 不是 的直接父亲,这个区间包含了多个点。这些点 的权值代表了边
      关键点:路径 上的边,对应的是 路径上除了LCA之外的点,和 路径上除了LCA之外的点。
      所以,当 在同一条链上,比如 ,需要操作的边对应的点是 序在 区间内的点 (如果 的祖先)。或者说,是原始树中从 的某个儿子(在路径上)一直到 这些点。
      实际操作时,upd_pathqry_path 的逻辑和点权版本几乎一样。只是在最后 在同一条链上时,若 ,则操作的区间是 (因为 对应的权值是 的边,这条边不在 路径上,除非 就是LCA且我们只看 的一半)。
      最常用的方法是:将边 的权值赋给深度较大的那个点,比如 (假设 的儿子)。那么路径 查询时,如果 ,比如是 ,那么路径上的点是 。对应的边权点是 在路径上的儿子,直到 。这个区间就是线段树上的 '_' allowed only in math mode[dfn[\text{son_of_u_on_path_to_v}], dfn[v]]。如果 不是 ,那么路径 的边权对应点集为 '_' allowed only in math modeu \to \text{child_of_LCA_towards_u}'_' allowed only in math modev \to \text{child_of_LCA_towards_v}
      通常,upd_path(u,v)qry_path(u,v) 的实现,在处理从 这段重链时,操作的区间是 。如果边权下放到点,那么 对应的点是 ,它代表 这条边。这是正确的。
      在同一条链,比如 ,操作区间 。这里的 对应的点 的权值是 。如果 ,这条边就不属于路径 上的边(因为路径是从 开始的)。所以,此时操作区间应为 ,或者更简单地,操作 (如果 的重儿子恰好在路径上,那么 )。
      一个约定俗成的简单做法:修改或查询路径 时,照常操作点权版本 。如果 最后在同一条链上,比如 的祖先,那么操作的区间是 。但我们知道 这个点对应的权值是 的边权。如果路径是从 开始,不包含 ,那么 就不应该被操作。所以,实际操作的区间应该是 (对于 路径上的边)。
      这意味着,LCA 那个点所关联的“向上的边”不被统计。

更清晰的边权处理方法:
为每条边 (设 )指定一个唯一的映射,比如就用 来代表这条边。在 dfs1 阶段记录每条边的权值到 val[v]
dfs1dfs2 和之前一样。
build 时,tr[dfn[i]] 的初始值设为 val[i] (即 这条边的权值)。根节点 rtval[rt] 可以设为0。
路径操作 upd_path(u, v, x) / qry_path(u, v)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void upd_path_edge(int u, int v, ll x) {
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
// top[u] 到 u 这条链,边对应点 dfn[top[u]] 到 dfn[u]
// 但是 (fa[top[u]], top[u]) 这条边对应的点是 top[u]
upd_rng(1, 1, n, dfn[top[u]], dfn[u], x);
u = fa[top[u]];
}
// u, v 在同一条重链
if (dep[u] > dep[v]) swap(u, v);
// 现在 u 是 v 的祖先 (或者 u=v)
// 路径 u-v 上的边,不包括 (fa[u],u) 这条边
// 对应点是 u 的儿子 (在路径上) ... v
// 即 dfn[son_of_u_on_path], ..., dfn[v]
// 如果 u == v,则没有边,不操作
if (u == v) return;
upd_rng(1, 1, n, dfn[u] + 1, dfn[v], x); // 注意这里的 +1
}

ll qry_path_edge(int u, int v) {
ll ans = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
ans += qry_rng(1, 1, n, dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
if (dep[u] > dep[v]) swap(u, v);
if (u == v) return ans; // 如果最后u=v,说明LCA是u或v,之前的都已经加完了
ans += qry_rng(1, 1, n, dfn[u] + 1, dfn[v]); // 注意这里的 +1
return ans;
}

这个 dfn[u]+1 的处理方式是最常见的。它假设 dfn[son[u]] (如果 在路径上) 恰好是 dfn[u]+1,这在重链上是成立的。如果路径从 走向一个轻儿子 ,那么操作的区间是
所以,当 在同一条重链,且 的祖先时,路径 上的边对应着点 ,其中 在路径 上的儿子。这些点在线段树上的区间是 。因为重链剖分保证了 (如果 的重儿子) ,所以 是一个很好的近似。如果 是轻儿子,这个区间仍然是

另一种更精确的边权映射:
不把边权直接赋给点 val[v],而是在读入边 权值为 时,如果 则交换 。然后,令 initial_val[dfn[v_i]] = w_i。在 build 线段树时,使用这个 initial_val 数组。
这样,线段树的第 k 个叶子节点,如果 k = dfn[x],那么它存储的就是边 的权值。
此时,upd_pathqry_path 逻辑可以和点权版本几乎一致,但在处理最后 同链的情况时,如果 的祖先(即 ),则路径上的边对应点 。这个区间是 。 如果 这个位置要被操作,它代表
所以,dfn[LCA] 这个点在线段树中对应的值 (即 的边权) 不应该被包含在路径 的操作中。
那么,修改/查询 路径上的边:

  1. 正常跳链,对 操作。
  2. 时,设 是深度较浅的那个。操作区间
  3. 然后,如果 处的边被多加/多查询了,要减掉/去掉。
    这个太复杂了。

最稳妥和常用的方法:将边 的权值赋给 (假设 )。
dfs1 中,假如我们读入的是边 (a,b,w)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设边信息存储在 edges 数组里: edges[i] = {u, v, w}
// dfs1 计算出父子关系后
for (int i = 1; i <= n; ++i) { // 对每个节点 i (除了根)
if (fa[i] == 0) { // i是根
val[i] = 0; // 根没有指向它的边,或者特殊处理
continue;
}
// 找到连接 i 和 fa[i] 的那条边的权值
// 这个需要在读入边时处理好,比如用map或者邻接表存边权
// 假设有一个 get_edge_w(u, p) 函数能获取 (u,p) 的边权
// val[i] = get_edge_w(i, fa[i]);
}
// 更简单的是,在读入边 u-v 权w时,就确定谁是谁的儿子。
// 如果在dfs1之前无法确定父子,可以在dfs1内部赋值:
// void dfs1(int u, int p, int d, ll edge_w_to_u) { // edge_w_to_u 是 (p,u)的边权
// fa[u] = p; dep[u] = d; sz[u] = 1;
// val[u] = edge_w_to_u; // 将(p,u)的边权赋给u
// ...
// }
// 调用时 dfs1(root, 0, 1, 0);
// for (edge e : adj[u]) if (e.v != p) dfs1(e.v, u, d+1, e.w);

假设 val[i] 已经存好了节点 i 与其父节点 fa[i] 连接的边的权值 (根节点的 val 为 0)。
线段树 build(1, 1, n) 时,tr[id].sum = val[rnk[l]]l==r
路径操作函数 upd_path(u, v, x)qry_path(u, v):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 路径 (u,v) 上节点权值增加 x
// 对于边权,这里是路径(u,v)上所有边的权值增加x
void path_op_edge(int u, int v, ll x, bool is_update) { // is_update=true for update, false for query
ll res_for_query = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
// 区间 [dfn[top[u]], dfn[u]] 对应 top[u]...u 这些点
// 这些点的值代表 (fa[top[u]], top[u]), ..., (fa[u],u) 的边权
if (is_update) upd_rng(1, 1, n, dfn[top[u]], dfn[u], x);
else res_for_query += qry_rng(1, 1, n, dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
// u, v 在同一条重链上
if (dep[u] > dep[v]) swap(u, v);
// u 是 v 的祖先 (或者 u=v)
// 路径 u-v 上的边是 (u, s1), (s1, s2), ..., (sk, v)
// 这些边对应的点是 s1, s2, ..., v
// 即 dfn 序从 dfn[u]+1 (如果son[u]在路径上) 或 dfn[child_of_u_on_path] 到 dfn[v]
// 如果 u == v,说明路径只含一个点,没有边,不操作
if (u != v) { // 只有u和v不同,才有边
if (is_update) upd_rng(1, 1, n, dfn[u] + 1, dfn[v], x); // 关键:dfn[u]+1
else res_for_query += qry_rng(1, 1, n, dfn[u] + 1, dfn[v]);
}
// 如果是查询,返回结果
if (!is_update) return res_for_query; // 需要修改函数签名返回ll
}

// 使用示例:
// upd_path(u, v, val); // 调用 path_op_edge(u,v,val,true);
// ll sum = qry_path(u, v); // 调用 path_op_edge(u,v,0,false) 并获取返回值;

修改后的 upd_pathqry_path 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 之前点权版本的代码作为基础
// ... dfs1, dfs2, segment tree ...

// 边权: val[i] 存储 (fa[i], i) 这条边的权值。根的val[rt]=0.
// build 时,对于叶子节点 l (dfn序),其值为 val[rnk[l]]

void upd_path_e(int u, int v, ll x) { // 更新路径(u,v)上所有边的权值
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
// 操作区间 [dfn[top[u]], dfn[u]]
// top[u] 这个点代表 (fa[top[u]], top[u]) 这条边
upd_rng(1, 1, n, dfn[top[u]], dfn[u], x);
u = fa[top[u]];
}
// u, v 在同一条重链, 且 dep[u] <= dep[v]
if (u == v) return; // 没有边
// 路径是 u -- s1 -- ... -- v. 边是 (u,s1), (s1,s2)...
// 对应的点是 s1, s2, ..., v
// s1 是 u 在路径上的儿子。其 dfn 是 dfn[u]+1 (若s1是重儿子) 或某个 dfn[light_child]
// 这个区间是 [dfn_of_s1, dfn[v]]
// 通用处理是 dfn[u]+1 到 dfn[v]
upd_rng(1, 1, n, dfn[u] + 1, dfn[v], x);
}

ll qry_path_e(int u, int v) { // 查询路径(u,v)上所有边的权值和
ll ans = 0;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) swap(u, v);
ans += qry_rng(1, 1, n, dfn[top[u]], dfn[u]);
u = fa[top[u]];
}
if (u == v) return ans;
if (dep[u] > dep[v]) swap(u, v); // 确保 u 是 v 的祖先
ans += qry_rng(1, 1, n, dfn[u] + 1, dfn[v]); // 从 u 的儿子开始到 v
return ans;
}

// 子树操作仍然是对点操作,边权模型下子树操作的定义可能需要明确。
// 如果是“子树u中所有点到其父节点的边的权值”,那么就是操作 dfn[u]..dfn[u]+sz[u]-1 这些点
// 但要注意,dfn[u]这个点是 (fa[u],u)的边,它可能不在“子树内的边”这个定义里。
// 通常子树操作还是针对点权。如果题目要求对子树内的边操作,要看具体定义。
// 例如,“子树u内的所有边”,即两端点均在子树u内的边。
// 这相当于对子树u中所有非根节点v,修改(fa[v],v)的边权。
// 即修改 dfn[v] for v in subtree(u) AND v != u。
// 这可以通过修改区间 [dfn[u]+1, dfn[u]+sz[u]-1] 实现 (如果子树u中所有点都作为“子节点”角色)。
// 或者更准确地,遍历子树中所有点 v (除了u),对 dfn[v] 进行操作。
// 这个区间就是 [dfn[u]+1, dfn[u]+sz[u]-1] (如果u的儿子都在这个dfn区间内)
// 实际上,子树u中,除了u本身,其他所有点v,其(fa[v],v)边都在子树内。
// 这些点v的dfn范围是 [min_dfn_child, max_dfn_child_in_subtree]
// 就是从 dfn[u]+1 到 dfn[u]+sz[u]-1。 (如果u不是叶子)
void upd_sub_e(int u, ll x) { // 修改子树u中所有节点v (v!=u) 的 (fa[v],v) 边权
if (sz[u] == 1) return; // 叶子节点没有子树内的边往下
upd_rng(1, 1, n, dfn[u] + 1, dfn[u] + sz[u] - 1, x);
}
ll qry_sub_e(int u) { // 查询子树u中所有节点v (v!=u) 的 (fa[v],v) 边权和
if (sz[u] == 1) return 0;
return qry_rng(1, 1, n, dfn[u] + 1, dfn[u] + sz[u] - 1);
}

// 使用时,需要先将边权存到 val 数组
// 假设输入有 m 条边 (a, b, w)
// 先建邻接表,然后 dfs1(rt, 0, 1) 计算fa, dep等
// 然后再遍历所有非根节点 i,val[i] = weight_of_edge(fa[i], i)
// 之后 dfs2, build...
// 另一种初始化val的方式:
// 邻接表存 struct Edge { int to, w; };
// void dfs1_val(int u, int p, int d) {
// fa[u] = p; dep[u] = d; sz[u] = 1; /* son, mxs init */
// for (auto& edge : g_edge_w[u]) { // g_edge_w是存带权边的邻接表
// int v = edge.to;
// int w = edge.w;
// if (v == p) continue;
// val[v] = w; // 边(u,v)的权值赋给v
// dfs1_val(v, u, d + 1);
// sz[u] += sz[v]; /* update son */
// }
// }
// dfs1_val(rt,0,1); val[rt]=0;
// 这样val数组就绪,然后dfs2, build。

这种 dfn[u]+1 的处理方式是目前最常用且相对简洁的。它要求线段树的 dfn 对应的是“点”,这个点存储的是它和它父亲之间那条边的信息。路径查询 时,LCA点 对应的 (即边 ) 不会被计入。

题意概括 (边权模板):
给定一棵 个节点的树,每条边有一个初始权值。有 个操作:

  1. 1 u v w: 将路径 上所有边的权值增加
  2. 2 u v: 查询路径 上所有边的权值之和。

对应的核心代码片段修改如下(假设 val[i] 已存好边 (fa[i],i) 的权值, val[root]=0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// (在之前的点权代码基础上)
// dfs1, dfs2, 线段树结构不变
// build 时:
// void build(int id, int l, int r) {
// tr[id].lz = 0;
// if (l == r) {
// tr[id].sum = val[rnk[l]]; // rnk[l]是dfn序为l的节点, val[rnk[l]]是 (fa[rnk[l]], rnk[l]) 的边权
// return;
// }
// // ... (同前)
// }

// 路径修改和查询函数使用 upd_path_e, qry_path_e
// main 函数中读入边权,并赋给 val 数组
// 例如:
// vector<tuple<int, int, int>> edges;
// for (int i=0; i<n-1; ++i) { cin >> u >> v >> w; add_edge_to_adj_list(u,v); edges.emplace_back(u,v,w); }
// dfs1(rt,0,1); // 先跑一遍dfs1确定父子关系
// for(const auto& edge : edges) {
// int u1,v1,w1;
// tie(u1,v1,w1) = edge;
// if(fa[v1] == u1) val[v1] = w1; // v1是u1的儿子
// else if(fa[u1] == v1) val[u1] = w1; // u1是v1的儿子
// }
// val[rt] = 0; // 根节点没有向上的边,其val值不应影响边权计算
// clk=0; dfs2(rt,rt);
// build(1,1,n);

最关键的是理解边权到点权的映射,以及路径操作时,区间的端点选择,特别是LCA附近的处理。 dfn[u]+1 的技巧比较常用。

六、LCA 与树链剖分

树链剖分不仅能处理路径信息,还能顺便高效地求解最近公共祖先 (LCA)。回忆一下我们路径操作的过程:不断将深度较大的点跳到其所在重链顶端的父节点,直到两点在同一条重链上。

第一次跳到同一条重链时(即 top[u] == top[v]),深度较浅的那个节点就是它们的LCA。

让我们看看 qry_path(u, v)(或 upd_path)的循环:

1
2
3
4
5
6
7
8
9
// while (top[u] != top[v]) {
// if (dep[top[u]] < dep[top[v]]) swap(u, v); // 令 u 为要跳的点
// // ... 操作 [dfn[top[u]], dfn[u]] ...
// u = fa[top[u]];
// }
// // 此时 top[u] == top[v]
// // if (dep[u] > dep[v]]) swap(u, v);
// // LCA is u (or v if dep[u] > dep[v] before swap)
// // 最终深度较小的那个就是LCA

所以,LCA的求解函数可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
int get_lca(int u, int v) {
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]]) {
v = fa[top[v]]; // v 向上跳
} else {
u = fa[top[u]]; // u 向上跳
}
}
// 现在 u 和 v 在同一条重链上
// 深度较小的那个就是LCA
return (dep[u] < dep[v]) ? u : v;
}

这个 get_lca 函数的复杂度也是 ,因为每次跳跃至少跨过一条轻边(当 不是根时, 之间是轻边连接的)或者到达LCA。

对比专门的LCA算法:

  • 倍增法LCA:预处理 ,查询 。常数较小。
  • 树链剖分求LCA:预处理 (两次DFS),查询 。常数可能比倍增略大一点,但如果题目本身就需要树链剖分,那么这个LCA几乎是“免费”的。
  • Tarjan离线LCA是查询数),但需要离线。

在竞赛中,如果题目已经明显需要用树链剖分处理路径修改/查询,那么用树链剖分的方法求LCA是非常自然和方便的,避免了引入另一套LCA的代码。如果只是单纯求LCA,没有复杂路径操作,倍增法可能更直接。

七、进阶应用与技巧

树链剖分的强大之处在于它提供了一个通用框架,可以将树上问题转化为序列问题。这意味着,只要你的序列数据结构能支持相应的操作(合并、修改、查询),就能应用到树上。

  1. 维护更复杂的信息

    • 路径/子树最大/最小值:线段树节点维护区间最值即可。
    • 路径/子树颜色段数量/种类数:线段树节点维护区间左右端点颜色、区间答案。合并时需要仔细考虑跨越中点的情况。这类问题通常需要更精细的线段树节点设计。例如,SPOJ的GSS系列题目(最大子段和)如果放到树上,就可以用树链剖分+维护最大子段和的线段树来解决。
    • 动态维护树的直径/重心:这类问题通常更复杂,单纯的HLD可能不够,可能需要结合点分治或其他技巧。但如果只是查询固定树上某条路径是否经过特定点集,或路径长度等,HLD有效。
  2. 与可持久化数据结构结合

    • 查询历史版本的路径信息:如果操作带时间戳,可以对树链剖分后的序列建立可持久化线段树。每次修改在可持久化线段树上产生新版本。查询 在版本 的信息时,就用版本 的线段树根节点进行 次区间查询。总复杂度
    • 树上路径第K大/K小:将树链剖分与主席树(可持久化权值线段树)结合。每个节点 处的主席树版本继承自 的版本,并在 处计数加1。查询路径 的第K大时,利用LCA,将路径信息差分出来,在主席树上二分。复杂度
  3. 树上倍增的替代与补充
    对于一些只查询、不修改的路径信息(如路径和、路径异或和、路径瓶颈边等),倍增法通常是 查询。树链剖分也能做到 (如果线段树用树状数组实现特定操作)或 (通用线段树)。当存在路径修改时,树链剖分的 修改和查询通常优于需要更复杂维护方式的倍增。

  4. 换根操作的思考
    标准的树链剖分预处理是基于一个固定根的。如果题目要求频繁换根并查询换根后的子树信息或路径信息,HLD会失效,因为 fa, son, dep, top, dfn 都变了。
    然而,有些关于“以 为根时,子树 的信息”的查询,可以通过不换根的HLD结合LCA来巧妙处理:

    • 如果 ,则查询的是整棵树。
    • 如果 不在 到原根 的路径上,且 ,则 在以 为根时的子树中。这个子树就是原树中以 为根的子树。用HLD查询 dfn[y]dfn[y]+sz[y]-1
    • 如果 ,则 在以 为根时的子树中。这时 的祖先。以 为根时, 的子树是整棵树除去 方向的那个分支。即,找到 的哪个儿子 使得 的子树中(或者 )。以 为根时, 的子树就是原树除去子树 的部分。这可以用两次线段树查询(整个树 - 子树 )得到。
    • 其他情况, 在以 为根时的子树中,等价于原树中 在子树 中,并且 不在子树 中,且 不在 到根路径上 的“上方子树”中。
      这类问题需要仔细分析LCA和子树关系,不换根也能做。但如果操作真正改变了树的拓扑结构(如 LCT 处理的 link, cut),则标准HLD不适用。
  5. 边压缩/点压缩优化 (在某些特定问题上):
    如果树的形态比较特殊,例如一条长链带一些短分支,或者询问的路径总是在某些关键点之间。可以考虑对非关键点进行路径压缩,或者只对“骨架”部分进行剖分。这属于更 spécifique 的优化,不具有通用性。

八、常见错误与调试锦囊

树链剖分代码长,细节多,很容易出错。以下是一些常见的坑点和调试建议:

  1. DFS1 阶段错误

    • sz[u] 计算错误:忘记 sz[u]=1 初始化,或者 sz[u] += sz[v] 遗漏。
    • son[u] 找错:比较 sz[v] 时,忘记更新 mxs_szson[u],或者 mxs_sz 初始值不对(应为-1或0,确保任何正儿子都能覆盖它)。叶节点的 son[u] 应为0(或一个非法节点标记)。
    • dep[u]fa[u] 记录错误。
  2. DFS2 阶段错误

    • 递归顺序必须先递归重儿子 dfs2(son[u], t),再递归轻儿子 dfs2(v,v) 这是保证一条重链上dfn序连续的关键。如果顺序反了,dfn序会交错,线段树区间就不是连续的了。
    • top[u] 传递错误:递归重儿子时,top值不变 (t);递归轻儿子时,轻儿子自己成为新的链顶 (v)。
    • dfn[u] 分配遗漏或重复:确保 clk (或 tim) 正确递增并赋值。
    • rnk[dfn[u]] = u 映射忘记记录。
  3. 线段树与 dfn 序的配合

    • 线段树操作的区间是 dfn 序的区间,即 [1, n]
    • build(1, 1, n) 时,叶子节点 dfn_idx 对应的值是原树节点 rnk[dfn_idx] 的权值 val[rnk[dfn_idx]]。或者如果是边权,是 val[rnk[dfn_idx]] 代表的边权。
    • 路径操作时,从 得到的线段树区间是 [dfn[a], dfn[b]](或 [dfn[a]+1, dfn[b]] 对于边权)。千万不要直接用 作为线段树下标。
  4. 路径操作 (upd_path, qry_path) 逻辑

    • while (top[u] != top[v]) 循环条件。
    • if (dep[top[u]] < dep[top[v]]) swap(u, v); 确保每次跳的是链顶深度较大的那个点。
    • 跳链操作:u = fa[top[u]]; (或 v = fa[top[v]];)。
    • 区间端点:操作的是 [dfn[top[u]], dfn[u]]
    • 最后 同链:if (dep[u] > dep[v]) swap(u, v); 确保 是深度较浅的。操作区间是 [dfn[u], dfn[v]] (点权) 或 [dfn[u]+1, dfn[v]] (边权,且 )。
    • 边权处理时,dfn[LCA] 对应的边是否应该计入,要根据问题定义和边权映射方式仔细判断。 +1 的技巧很常用。
  5. 线段树本身实现错误

    • push_up:是否正确合并左右儿子信息。
    • push_down:懒标记是否正确下传给左右儿子,并且当前节点懒标记是否清除。下传的值和区间长度是否正确计算。
    • 区间边界:mid = (l+r)>>1,左儿子 [l, mid],右儿子 [mid+1, r]。查询/更新时,ql <= midqr > mid (或 qr >= mid+1) 的判断。
    • 数组大小:线段树数组 trN << 2 (即 )。HLD的各种数组 fa, dep, sz, son, top, dfn, rnk, valN
  6. 变量名混淆与笔误

    • u, v, x, y 等在不同函数或循环中意义可能变化,注意区分。
    • dfn[u]u 本身。
    • sz[u] (子树大小) 和 dep[u] (深度)。
  7. 1-based vs 0-based 索引
    竞赛中通常节点编号从1到N。确保所有数组访问和循环都与之对应。如果根节点父节点设为0,深度从1开始,这是常见的。

调试锦囊

  • 静态检查:肉眼仔细比对模板,检查上述常见错误点。
  • 打印中间信息
    • dfs1 后打印 fa[], dep[], sz[], son[]
    • dfs2 后打印 top[], dfn[], rnk[]
    • 对于小样例,手动模拟剖分过程,与程序输出对比。
  • 分模块测试
    • 单独测试线段树的正确性(用简单序列数据)。
    • 再集成到树链剖分框架中。
  • 构造特殊数据
    • 链状树:1-2-3-...-N。此时只有一条重链。路径操作退化为单次序列操作。
    • 菊花图:一个中心点连接 N-1 个叶子。此时中心点有很多轻儿子。
    • 完全二叉树。
  • 对拍:写一个暴力算法(如 的路径DFS)和HLD进行对拍,随机生成数据找不同。
  • GDB/调试器:单步跟踪,观察变量变化,尤其是在路径操作的跳链过程中。
  • 简化问题:如果路径修改+路径查询都WA,先试试单点修改+路径查询,或者路径修改+单点查询,逐步定位问题。

耐心和细致是调试HLD的关键。一旦写对一次,形成自己的模板,后续就会顺畅很多。

九、树链剖分与其他知识点的联系

树链剖分并非孤立存在,它常常与其他算法和数据结构结合,形成强大的解题工具。

  1. 核心搭档:线段树/树状数组

    • **线段树 (Segment Tree)**:最常用的伙伴。功能强大,支持区间加/乘/赋值、区间和/最值/GCD等多种操作,以及更复杂的合并(如最大子段和)。是HLD的“标准配置”。
    • **树状数组 (BIT / Fenwick Tree)**:如果操作满足差分性质(如单点修改、区间求和;或区间加、单点求和),可以用树状数组替代线段树。常数更小,代码更短。例如,只要求路径和查询、单点修改,BIT可以胜任。若要路径修改、路径查询,则需要两棵BIT或更复杂的技巧,不如线段树直接。
  2. 图论概念的深化

    • **LCA (Lowest Common Ancestor)**:如前所述,HLD可以高效求LCA。
    • 树的直径/重心:HLD本身不直接求这些,但如果题目需要在找到直径/重心后,在其上进行路径操作,HLD就能派上用场。
    • 点双连通分量/边双连通分量:在处理缩点后的树结构时,可能会用到HLD。
  3. 动态规划 (DP on Trees)

    • 某些树形DP问题,如果状态转移涉及到对子树或路径上的信息进行修改和查询,且这些操作符合线段树等数据结构的处理范围,可以考虑用HLD优化DP的转移。例如,DP状态 可能依赖于 到根路径上某些性质的聚合,如果这些性质可以修改,HLD可能有用。这类结合通常比较高级。
  4. 莫队算法 (Mo’s Algorithm on Trees)

    • 树上莫队是处理树上离线路径查询的另一种方法。它通常基于树的欧拉序,将路径查询转化为序列上的区间查询。与HLD的剖分方式不同,各有优势。HLD在线,支持修改;树上莫队离线,通常不支持修改(或支持方式复杂),但在某些查询问题上可能常数更优或能处理更复杂的问题(如不满足常规数据结构合并性质的询问)。
  5. 可持久化 (Persistence)

    • 结合可持久化线段树/主席树,HLD可以处理带历史版本查询的树上路径问题,或树上路径第K大等。

理解这些联系,有助于你在遇到复杂问题时,能想到将HLD作为其中一个模块,与其他工具组合使用。

十、写在最后

树链剖分,其核心思想——将树结构巧妙地拆解为若干链,再利用序列数据结构高效处理——展现了算法设计的智慧。它以 的预处理,换来了路径操作 (或特定情况下 )以及子树操作 的优异性能。

熟练掌握并能灵活运用树链剖分,意味着:

  • 能够攻克一类重要的树上路径/子树信息维护问题。
  • 拥有了将复杂问题转化为熟悉模型的能力。
  • 在代码实现上具备了处理中等长度模板的经验和调试技巧。
  • Title: 树链剖分
  • Author: YZY
  • Created at : 2025-06-16 23:08:41
  • Updated at : 2025-06-16 23:08:41
  • Link: https://blog.dtoj.team/2025/06/16/树链剖分/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments