题解:洛谷 P3384 重链剖分
【题目来源】
【题目描述】
如题,已知一棵包含 \(N\) 个结点的树(连通且无环),每个节点上包含一个数值,需要支持以下操作:
-
1 x y z,表示将树从 \(x\) 到 \(y\) 结点最短路径上所有节点的值都加上 \(z\)。 -
2 x y,表示求树从 \(x\) 到 \(y\) 结点最短路径上所有节点的值之和。 -
3 x z,表示将以 \(x\) 为根节点的子树内所有节点值都加上 \(z\)。 -
4 x,表示求以 \(x\) 为根节点的子树内所有节点值之和。
【输入】
第一行包含 \(4\) 个正整数 \(N,M,R,P\),分别表示树的结点个数、操作个数、根节点序号和取模数(即所有的输出结果均对此取模)。
接下来一行包含 \(N\) 个非负整数,分别依次表示各个节点上初始的数值。
接下来 \(N-1\) 行每行包含两个整数 \(x,y\),表示点 \(x\) 和点 \(y\) 之间连有一条边(保证无环且连通)。
接下来 \(M\) 行每行包含若干个正整数,每行表示一个操作。
【输出】
输出包含若干行,分别依次表示每个操作 \(2\) 或操作 \(4\) 所得的结果(对 \(P\) 取模)。
【输入样例】
5 5 2 24
7 3 7 8 0
1 2
1 5
3 1
4 1
3 4 2
3 2 2
4 5
1 5 1 3
2 1 3
【输出样例】
2
21
【核心思想】
-
问题分析:给定一棵包含 \(N\) 个节点的有根树,每个节点有权值,需要支持四种操作:路径加、路径求和、子树加、子树求和,所有结果对 \(P\) 取模。由于树的结构复杂,直接处理路径和子树操作非常低效,需要一种数据结构能将树上的路径和子树转化为连续的区间,从而利用线段树等区间数据结构高效处理。这就是树链剖分(Heavy-Light Decomposition, HLD) 的经典应用场景。
-
算法选择:
- 树链剖分:通过轻重链剖分,将树分解成若干条链,使得每个节点被分配一个连续的 DFS 序(
dfn),且满足:- 任意一条路径可以被分解为 \(O(\log N)\) 条重链上的连续区间。
- 任意一个子树对应一个连续的区间
[dfn[x], dfn[x] + sz[x] - 1]。
- 线段树:维护剖分后的序列(
dfn序),支持区间加和区间求和,配合懒标记实现 \(O(\log N)\) 的区间操作。 - 操作映射:
- 路径加/求和:将路径 \((u, v)\) 分解为若干条链,对每条链的连续区间进行线段树操作。
- 子树加/求和:直接对
[dfn[x], dfn[x] + sz[x] - 1]进行线段树操作。
- 树链剖分:通过轻重链剖分,将树分解成若干条链,使得每个节点被分配一个连续的 DFS 序(
-
关键步骤:
- 第一次 DFS(
dfs1):从根节点 \(R\) 开始,计算每个节点的父节点fa[u]、深度dep[u]、子树大小sz[u],并找出重儿子son[u](子树最大的儿子)。 - 第二次 DFS(
dfs2):按重儿子优先的顺序分配 DFS 序id[u](即剖分后的新编号),记录每个节点所在链的顶端top[u],并将节点权值按新编号存入数组nw。 - 建线段树:用
nw数组构建线段树,维护区间和与懒标记。 - 路径操作(
update_path/query_path):- 当
top[u] != top[v]时,选择链顶深度较大的节点,将[id[top[u]], id[u]]这一段(链顶到 u)在线段树上操作,然后令u = fa[top[u]]跳到上一条链。 - 当
top[u] == top[v]时,处理剩余同一链上的区间(深度较小的在前)。
- 当
- 子树操作(
update_tree/query_tree):直接使用区间[id[x], id[x] + sz[x] - 1]。 - 所有结果对 \(P\) 取模输出。
- 第一次 DFS(
-
时间/空间复杂度:
- 时间复杂度:预处理 \(O(N)\),每次路径操作 \(O(\log^2 N)\)(实际上为 \(O(\log N)\) 条链,每条链线段树操作 \(O(\log N)\)),子树操作 \(O(\log N)\)。总复杂度 \(O((N+M)\log^2 N)\) 或 \(O((N+M)\log N)\)(取决于实现,通常可接受)。
- 空间复杂度:\(O(N)\)。
-
树链剖分的核心思想:
- 将树上问题转化为序列问题:利用轻重链剖分,使重链上的节点编号连续,从而将路径拆分为若干连续区间,用线段树等数据结构高效维护。
- 轻重链的性质:每个节点到根的路径上最多经过 \(O(\log N)\) 条轻边,因此任何路径最多分解为 \(O(\log N)\) 条重链,保证了操作的高效性。
- 线段树的辅助:区间加和区间求和是线段树的基本操作,配合懒标记实现 \(O(\log N)\) 的修改和查询。
- 适用场景:适用于需要频繁处理树上路径和子树的修改/查询问题,是解决这类问题的标准模板。
【算法标签】
提高+ #树链剖分
【代码详解】
#include <bits/stdc++.h>
using namespace std;
#define int long long // 使用 long long 防止溢出
const int N = 100005;
#define lc u << 1 // 线段树左孩子
#define rc u << 1 | 1 // 线段树右孩子
int n, m, r, p; // n: 节点数, m: 操作数, r: 根, p: 取模数
int w[N]; // 每个节点的初始权值
vector<int> g[N]; // 邻接表存储树
int fa[N], dep[N], sz[N], son[N]; // 父节点, 深度, 子树大小, 重儿子
int top[N], id[N], nw[N], cnt; // 链顶, dfs序编号, 新编号对应权值, 时间戳
// 线段树节点结构
struct tree
{
int l, r; // 区间左右端点
int add, sum; // 懒标记, 区间和
} tr[N * 4];
// 第一次 DFS: 预处理父节点、深度、子树大小、重儿子
void dfs1(int u, int father)
{
fa[u] = father;
dep[u] = dep[father] + 1;
sz[u] = 1;
for (int v : g[u])
{
if (v == father) continue;
dfs1(v, u);
sz[u] += sz[v];
if (sz[son[u]] < sz[v]) // 更新重儿子(子树最大的儿子)
son[u] = v;
}
}
// 第二次 DFS: 树链剖分,分配 dfs 序,确定每条链的顶端
void dfs2(int u, int t)
{
top[u] = t; // 当前节点所在链的顶端
id[u] = ++cnt; // 分配 dfs 序(剖分后的新编号)
nw[cnt] = w[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); // 轻儿子单独开一条链
}
}
// 线段树: 向上更新父节点区间和
void pushup(int u)
{
tr[u].sum = tr[lc].sum + tr[rc].sum;
}
// 线段树: 下传懒标记
void pushdown(int u)
{
if (tr[u].add)
{
tr[lc].sum += tr[u].add * (tr[lc].r - tr[lc].l + 1);
tr[rc].sum += tr[u].add * (tr[rc].r - tr[rc].l + 1);
tr[lc].add += tr[u].add;
tr[rc].add += tr[u].add;
tr[u].add = 0;
}
}
// 线段树: 建树
void build(int u, int l, int r)
{
tr[u] = {l, r, 0, nw[r]}; // 初始化,叶子节点 sum = nw[l](此处 nw[r] 与 nw[l] 等价)
if (l == r) return;
int mid = (l + r) >> 1;
build(lc, l, mid);
build(rc, mid + 1, r);
pushup(u);
}
// 线段树: 区间加
void update(int u, int l, int r, int k)
{
if (l <= tr[u].l && tr[u].r <= r) // 完全覆盖
{
tr[u].add += k;
tr[u].sum += k * (tr[u].r - tr[u].l + 1);
return;
}
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
if (l <= mid) update(lc, l, r, k);
if (r > mid) update(rc, l, r, k);
pushup(u);
}
// 操作1: 将路径 x -> y 上所有节点值加 z
void update_path(int u, int v, int k)
{
while (top[u] != top[v]) // 不在同一条链上
{
if (dep[top[u]] < dep[top[v]]) swap(u, v);
update(1, id[top[u]], id[u], k); // 更新当前链的顶端到 u 这一段
u = fa[top[u]]; // 跳到链顶的父亲
}
// 此时在同一条链上
if (dep[u] < dep[v]) swap(u, v);
update(1, id[v], id[u], k);
}
// 操作3: 将以 x 为根的子树内所有节点值加 z
void update_tree(int u, int k)
{
update(1, id[u], id[u] + sz[u] - 1, k);
}
// 线段树: 区间查询和
int query(int u, int l, int r)
{
if (l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = (tr[u].l + tr[u].r) >> 1;
int res = 0;
if (l <= mid) res += query(lc, l, r);
if (r > mid) res += query(rc, l, r);
return res;
}
// 操作2: 查询路径 x -> y 上所有节点值之和
int query_path(int u, int v)
{
int res = 0;
while (top[u] != top[v])
{
if (dep[top[u]] < dep[top[v]]) swap(u, v);
res += query(1, id[top[u]], id[u]);
u = fa[top[u]];
}
if (dep[u] < dep[v]) swap(u, v);
res += query(1, id[v], id[u]);
return res;
}
// 操作4: 查询以 x 为根的子树内所有节点值之和
int query_tree(int u)
{
return query(1, id[u], id[u] + sz[u] - 1);
}
signed main()
{
cin >> n >> m >> r >> p; // 输入 N, M, R, P
for (int i = 1; i <= n; i++)
cin >> w[i]; // 输入每个节点的初始权值
for (int i = 1; i < n; i++)
{
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs1(r, 0); // 预处理父节点、深度、重儿子
dfs2(r, r); // 树链剖分,分配 dfs 序
build(1, 1, n); // 建线段树
while (m--)
{
int op, x, y, z;
cin >> op;
if (op == 1) // 1 x y z:路径加
{
cin >> x >> y >> z;
update_path(x, y, z);
}
else if (op == 2) // 2 x y:路径求和
{
cin >> x >> y;
cout << query_path(x, y) % p << endl;
}
else if (op == 3) // 3 x z:子树加
{
cin >> x >> z;
update_tree(x, z);
}
else // 4 x:子树求和
{
cin >> x;
cout << query_tree(x) % p << endl;
}
}
return 0;
}
【运行结果】
5 5 2 24
7 3 7 8 0
1 2
1 5
3 1
4 1
3 4 2
3 2 2
4 5
1 5 1 3
2 1 3
2
21
浙公网安备 33010602011771号