降维打击树形结构:从倍增 LCA 到重链剖分 (HLD) 的进阶实战
降维打击树形结构:从倍增 LCA 到重链剖分 (HLD) 的进阶实战
在算法竞赛(如 ICPC 或各类天梯赛)中,处理树上路径问题往往是拉开分数的关键。面对“求树上两点距离”或“对树上某条路径的所有节点加上一个值”,纯暴力的 DFS 会被极端数据(如退化成链的树)瞬间卡成 \(O(N^2)\)。
要优雅且高效地解决树上路径问题,我们需要两大核心武器:倍增法求最近公共祖先(LCA)与重链剖分(HLD)。本文将刨析这两种算法的底层逻辑,并提供 C++17 竞赛级标准模板。
一、 倍增魔法:最近公共祖先 (LCA)
LCA 的核心适用场景非常明确:静态树上的最近公共祖先、两点距离、判断祖先关系,或是需要将一条树上路径拆分到 LCA 处。
1. 核心思想与复杂度
暴力找 LCA 的方式是让两个点一步步往上爬,直到相遇。倍增算法的精髓在于:预处理出每个节点向上跳 \(2^0, 2^1, 2^2 \dots 2^k\) 步到达的祖先。查询时,利用二进制的思想“大步流星”地往上跳。
在复杂度方面,预处理需要 \(O(n \log n)\) 的时间,而每次 lca 或跳祖先查询仅需 \(O(\log n)\),空间复杂度为 \(O(n \log n)\)。
2. 竞赛级 C++17 模板
以下是高度封装的 LCA 模板,内置了深度与带权距离的维护:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
struct LCA {
int n, LOG;
vector<vector<pair<int,ll>>> g;
vector<vector<int>> up;
vector<int> dep;
vector<ll> dis;
LCA(int n = 0) { init(n); }
void init(int n_) {
n = n_; LOG = 1;
while ((1 << LOG) <= n) ++LOG;
g.assign(n + 1, {}); up.assign(LOG, vector<int>(n + 1));
dep.assign(n + 1, 0); dis.assign(n + 1, 0);
}
void addEdge(int u, int v, ll w = 1) {
g[u].push_back({v,w}); g[v].push_back({u,w});
}
void build(int root = 1) {
queue<int> q; q.push(root); up[0][root] = root; // 根的父亲设为根本身
vector<int> vis(n + 1); vis[root] = 1;
while (!q.empty()) {
int u = q.front(); q.pop();
for (auto [v, w] : g[u]) if (!vis[v]) {
vis[v] = 1; dep[v] = dep[u] + 1; dis[v] = dis[u] + w;
up[0][v] = u; q.push(v);
}
}
for (int j = 1; j < LOG; ++j)
for (int v = 1; v <= n; ++v)
up[j][v] = up[j - 1][up[j - 1][v]];
}
int jump(int u, int k) const {
for (int j = 0; j < LOG; ++j) if (k >> j & 1) u = up[j][u];
return u;
}
int lca(int u, int v) const {
if (dep[u] < dep[v]) swap(u, v);
u = jump(u, dep[u] - dep[v]);
if (u == v) return u;
for (int j = LOG - 1; j >= 0; --j)
if (up[j][u] != up[j][v]) u = up[j][u], v = up[j][v];
return up[0][u];
}
ll distance(int u, int v) const {
int p = lca(u, v); return dis[u] + dis[v] - 2 * dis[p];
}
};
避坑指南:
- 在
build初始化时,务必将根节点的父亲设为它自己,这样可以避免跳祖先时越界访问到未初始化的 0 号状态。 - 必须严格区分边数深度
dep和带权距离dis,绝对不能混用。 - 倍增算法的先天限制是只适用于静态树。如果题目包含动态换根或加边操作,则需要另行转化,或者直接上动态树结构(如 LCT)。
二、 极致的拍扁艺术:重链剖分 (HLD)
如果说 LCA 只是查询,那 HLD 就是树上修改的终极杀器。它的适用信号非常强烈:当题目在静态树上反复询问或修改任意两点间的路径,且底层操作能够处理数组区间(如区间和、区间最值、区间加法等)时,就是 HLD 登场的时刻。
1. 核心思想与复杂度
重链剖分通过两次 DFS,将树上的节点根据“子树大小”重新编号,使得每一条“重链”上的节点在内存中是连续的一段区间。这样,HLD 就能将树上任意一条路径巧妙地转成 \(O(\log n)\) 段一维区间。
搭配上线段树等数据结构后,预处理时间为 \(O(n)\),单次路径操作的时间复杂度被死死压在 \(O(\log^2 n)\)。
2. C++17 实战模板
以下是 HLD 的基础骨架,通过传入 lambda 表达式,它可以完美适配各种底层的线段树或树状数组:
#include <bits/stdc++.h>
using namespace std;
struct HLD {
int n, timer = 0;
vector<vector<int>> g;
vector<int> parent, depth, heavy, head, pos, sz;
HLD(int n = 0) { init(n); }
void init(int n_) {
n = n_; g.assign(n + 1, {});
parent.assign(n + 1, 0); depth.assign(n + 1, 0);
heavy.assign(n + 1, -1); head.assign(n + 1, 0);
pos.assign(n + 1, 0); sz.assign(n + 1, 0); timer = 0;
}
void addEdge(int u, int v) { g[u].push_back(v); g[v].push_back(u); }
int dfs1(int u, int p) {
parent[u] = p; sz[u] = 1; int best = 0;
for (int v : g[u]) if (v != p) {
depth[v] = depth[u] + 1; int sub = dfs1(v, u); sz[u] += sub;
if (sub > best) best = sub, heavy[u] = v;
}
return sz[u];
}
void dfs2(int u, int h) {
head[u] = h; pos[u] = ++timer;
if (heavy[u] != -1) dfs2(heavy[u], h);
for (int v : g[u]) if (v != parent[u] && v != heavy[u]) dfs2(v, v);
}
void build(int root = 1) { depth[root] = 0; dfs1(root, 0); dfs2(root, root); }
// 对路径 u-v 的每一段连续位置调用操作 op
template<class F>
void forEachPath(int u, int v, F op) const {
while (head[u] != head[v]) {
if (depth[head[u]] < depth[head[v]]) swap(u, v);
op(pos[head[u]], pos[u]);
u = parent[head[u]];
}
if (depth[u] > depth[v]) swap(u, v);
op(pos[u], pos[v]); // 点权路径默认包含 LCA
}
};
避坑指南:
- 上述模板适用于点权问题。如果题目给定的值在边上,通常的处理手法是将边权赋给较深的一端节点,且在最后操作同链区间时,必须改为
pos[u] + 1 .. pos[v],以此排除掉 LCA 自身。 - 在极长链的数据下,深链的递归操作有爆栈的风险。如果在 Windows 本地或栈空间较小的 OJ 平台上运行,需要改为迭代 DFS 或者手动扩大系统栈。
forEachPath切割出的各段区间,在逻辑上并未严格按照原路径的方向排列。如果维护的是字符串哈希等与方向强相关的信息,必须分别维护正反方向,并自行控制拼接的顺序。
结语
从 LCA 的倍增跳跃,到 HLD 的连续空间映射,树形结构问题的解决思路完美诠释了计算机科学中“空间换时间”与“降维打击”的美学。掌握这两套模板并清晰认识它们的边界,足以应对绝大多数树上路径优化的挑战。

浙公网安备 33010602011771号