倍增求 LCA
通过倍增预处理祖先表,在 O(log n) 时间内查询两个节点的最近公共祖先。
一句话算法
倍增 LCA 先让深的点向上跳到同一层,再让两个点一起从大步到小步向上跳。
问题模型
给定一棵有根树,需要多次查询两个点
树必须先选定根。根不同,祖先关系也可能不同,所以如果题目给出了根节点
最近公共祖先
在一棵有根树中,
常见扩展查询:
- 两点距离:
。 - 判断祖先关系。
- 树上路径拆分。
- 树上差分。
核心直觉
如果一个点比另一个点深,两个点不在同一层,就不能直接比较祖先。
第一步先把深的点往上提到同一深度。为了不一步一步跳,我们预处理:
这样任意距离都可以拆成若干个二进制位。例如要上跳
就依次跳
算法步骤
预处理
任选一个根节点,一般取
DFS 整棵树,求出:
:节点 的深度。 :节点 的父亲。 :节点 的 级祖先。 - DFS 序的进入时间和离开时间:如果需要
判断祖先关系,可以额外维护 in[u]和out[u]。
倍增表的层数 LOG 要满足
转移式:
直觉是:
第 k 祖先
倍增表不仅能求 LCA,也能回答“节点
把
1
2
3
for (int j = 0; j < LOG; ++j) {
if (k & (1 << j)) u = up[u][j];
}
例如
查询 LCA
查询
- 如果
比 浅,交换它们,保证 更深。 - 让
上跳 层,使两点同深。 - 如果此时
,说明浅点就是 LCA。 - 从大到小枚举
: - 如果
,说明还没跳过 LCA,可以同时上跳。 - 如果相同,说明这一步会跳到 LCA 或 LCA 之上,暂时不能跳。
- 如果
- 循环结束时,
和 停在 LCA 的两个不同儿子子树中,答案是 。
祖先关系判断
判断
第一种直接用 LCA:
第二种用 DFS 序。预处理进入时间 in[u] 和离开时间 out[u] 后:
1
2
3
bool is_ancestor(int u, int v) {
return in[u] <= in[v] && out[v] <= out[u];
}
如果文章只需要基础 LCA,第一种足够;如果后续还要做子树判断、虚树或路径包含判断,第二种更方便。
算法证明
一、同深不丢答案
假设
LCA 一定是
因此同深后,LCA 仍然是两个点的公共祖先。
二、从大到小同时跳是正确的
同深后,如果
从大到小枚举
- 如果
,两者跳完仍在 LCA 下方的不同分支,可以安全跳。 - 如果
,这次跳会把它们汇合到同一个祖先,可能已经到达或越过 LCA,所以不能跳。
循环结束后,已经不能再让两个点保持“不同祖先”地向上跳。此时它们正好在 LCA 的下面一层,所以父亲就是 LCA。
复杂度分析
设树有
- 预处理时间复杂度:
。 - 单次查询时间复杂度:
。 - 空间复杂度:
。
如果额外维护 DFS 序,预处理时间仍是
扩展:路径信息一起倍增
很多题不只问 LCA,还会问路径上的最大边、最小边、边权和等信息。这时可以在 up[u][j] 旁边再维护一个同样大小的表。
例如维护从
它的含义和 up 表完全一样:一段长度
查询两点路径信息时,仍然先把深点提到同深,再同时上跳;每一次跳跃时,把对应的路径信息合并进答案即可。
常见扩展:
| 维护信息 | 表示含义 | 典型用途 |
|---|---|---|
mx[u][j] |
向上跳 |
严格次小生成树、瓶颈路 |
mn[u][j] |
向上跳 |
路径瓶颈最小值 |
sum[u][j] |
向上跳 |
树上路径长度或费用 |
扩展时的边界
如果维护的是边权,节点跳到 LCA 时不要把 LCA 的父边算进去。通常只在实际发生上跳时合并这段跳跃对应的信息。
代码实现
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
#include <algorithm>
#include <vector>
#include <cstring>
const int maxn = 1e6 + 5;
using Graph = std::vector<int>;
Graph tree[maxn]; // 全局邻接表数组:直接向 tree[u] 加无权边
// 如何使用:
// 1. 读入边:tree[u].push_back(v); tree[v].push_back(u);
// 2. BinaryLCA lca(n);
// 3. lca.build(root); // 构建/预处理 DFS 深度和倍增表(默认 root = 1)
// 4. int p = lca.query(a, b); // 查询 a 和 b 的 LCA(或使用 lca.lca(a, b))
// 5. int d = lca.dist(a, b); // 查询 a 和 b 在树上的距离
// 倍增算法求最近公共祖先(LCA)与树上距离
struct BinaryLCA {
constexpr static int maxn = 1e6 + 5;
constexpr static int max_log = 20; // 支持最多 2^20 = 1,048,576 个节点
int n;
int depth[maxn];
int up[maxn][max_log]; // up[u][j] 表示节点 u 的 2^j 级祖先
explicit BinaryLCA(int n = 0) : n(n) {
memset(up, 0, sizeof(up));
for (int i = 0; i <= n; ++i) depth[i] = 0;
}
// DFS 预处理每个节点的深度 depth 以及 2^j 级祖先表 up
void dfs(int u, int fa) {
up[u][0] = fa;
depth[u] = depth[fa] + 1;
for (int j = 1; j < max_log; ++j) {
up[u][j] = up[up[u][j - 1]][j - 1];
}
for (int v : tree[u]) {
if (v == fa) continue;
dfs(v, u);
}
}
// 构建/预处理 LCA 倍增表,默认以 root 为根节点
void build(int root = 1) {
depth[0] = 0;
dfs(root, 0);
}
// 查询节点 u 的第 k 级祖先(向上跳 k 步)
int kth_ancestor(int u, int k) {
for (int j = 0; j < max_log; ++j) {
if (k & (1 << j)) u = up[u][j];
}
return u;
}
// 查询节点 a 和节点 b 的最近公共祖先(LCA)
int lca(int a, int b) {
//保证 a 是较深节点
if (depth[a] < depth[b]) std::swap(a, b);
a = kth_ancestor(a, depth[a] - depth[b]);
if (a == b) return a;
for (int j = max_log - 1; j >= 0; --j) {
if (up[a][j] != up[b][j]) {
a = up[a][j];
b = up[b][j];
}
}
return up[a][0];
}
// 计算节点 a 和节点 b 在树上的距离
int dist(int a, int b) {
int c = lca(a, b);
return depth[a] + depth[b] - 2 * depth[c];
}
};
测试用例
树结构:
1
/ \
2 3
/ \ / \
4 5 6 7
查询结果:
| 查询 | LCA | 说明 |
|---|---|---|
| 同在 |
||
| 分别在根的左右子树 | ||
| 一个点是另一个点的祖先 | ||
| 路径是 |
应用分类详解
LCA 的本质是把树上两点路径拆成“向上到公共祖先”的两段。
一、树上路径查询
典型模式: 题目反复询问两点路径上的长度、权值和、最大值或最小值。
识别信号: 多次询问
核心建模: 先找到
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 两点距离 | luogu-P3379 | 距离由两点深度和 LCA 深度计算 |
| 路径最大边 | 次小生成树类问题 | 倍增表中同时维护祖先和路径信息 |
二、祖先关系判断
典型模式: 判断一个点是否在另一个点的子树里,或某个点是否位于路径上。
识别信号: 祖先、后代、子树、路径经过。
核心建模: 用 LCA 或 DFS 序判断包含关系。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 判断祖先 | DFS 序 + LCA | |
| 路径包含 | 距离拆分 |
三、树上差分
典型模式: 多次给树上路径加一,最后统计点或边的贡献。
识别信号: 多条路径、路径覆盖次数、最后统一统计。
核心建模: 对路径两端加贡献,在 LCA 和 LCA 的父亲处抵消,然后 DFS 汇总。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 点差分 | luogu-P3258 | |
| 边差分 | 树上路径覆盖 |
四、第 k 祖先与向上跳跃
典型模式: 询问某个点向上走
识别信号: 第
核心建模: 把 up[u][j] 一次跳
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 第 k 祖先 | luogu-P5903 | 预处理祖先表后按二进制位跳 |
| 历史状态跳转 | 撤销类树形结构 | 把操作形成一棵树,在树上找祖先 |
五、路径信息维护
典型模式: 多次询问树上两点路径的最大边、最小边、边权和或瓶颈值。
识别信号: 树上路径、边权最大值、瓶颈、替换一条边、次小生成树。
核心建模: LCA 负责把路径拆成两段;倍增表在跳跃时同步合并路径信息。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 路径最大边 | luogu-P1967 | 跳祖先时维护最大边权 |
| 严格次小生成树 | luogu-P4180 | 非树边加回后查询环上的最大/次大边 |
方法对比
LCA 有多种求法,倍增不是唯一方法,但它最适合当作竞赛里的通用模板。
| 方法 | 预处理 | 单次查询 | 适合场景 |
|---|---|---|---|
| 倍增 LCA | 在线查询、容易扩展路径信息 | ||
| 欧拉序 + RMQ | 静态树、只问 LCA | ||
| Tarjan 离线 LCA | 近似线性 | 离线统一处理 | 所有询问提前给出 |
| 树链剖分 | 同时做路径修改和路径查询 |
如果题目只问 LCA,倍增写法已经足够稳定;如果题目还要路径加、路径和、路径最大值等复合操作,再考虑树链剖分或在倍增表里维护额外信息。
易错点
LOG不够大:必须保证。 - 根节点用错:题目给根时,预处理要从题目给定的根开始。
- 忘记处理祖先关系:同深后如果
,应立即返回。 - 根的父亲处理不一致:常见做法是令根的父亲为
0或根自己,但整份代码要统一。 - 递归 DFS 爆栈:当
很大且树退化成链时,递归深度可能达到 。 - 带权扩展多算边:路径信息只在实际上跳时合并,不要把 LCA 上方的边算进答案。
经典例题
- luogu-P3379 【模板】最近公共祖先:倍增 LCA 标准模板题。
- luogu-P3258 松鼠的新家:把连续经过的树上路径转成多次路径加,最后用树上差分统计每个点的经过次数。
- luogu-P1967 货车运输:先建最大生成树,再用 LCA 倍增维护路径最小边权,查询两点间最大载重量。
- luogu-P4180 严格次小生成树:枚举非树边,用 LCA 倍增查询树上路径最大边和严格次大边。
- luogu-P5903 树上 K 级祖先:直接练习
kth_ancestor,把拆成二进制位向上跳。