刨析树上寻路核心:最近公共祖先(LCA)的四大解法与竞赛实战
刨析树上寻路核心:最近公共祖先(LCA)的四大解法与竞赛实战
在图论与树形结构算法中,最近公共祖先(Lowest Common Ancestor, LCA)是一个极具枢纽地位的概念。无论你是要求解树上任意两点的最短距离、维护树上差分,还是进行复杂的树链剖分,LCA 都是绝对的基石。
对于树上的任意两个节点 \(u\) 和 \(v\),它们的 LCA 是距离它们最近的共同祖先节点。基于 LCA,我们可以得出一个极其重要的树上距离公式:
在算法竞赛(ICPC、PTA、洛谷)中,求解 LCA 绝对不是简单的“暴力向上爬”。根据查询是在线还是离线,以及对时间常数的要求,LCA 衍生出了四大经典解法。本文将为你深度刨析这四重境界。
境界一:倍增法(Binary Lifting)—— 最经典的在线查询
这是绝大多数选手最先接触、也是泛用性最广的 LCA 解法。
1. 核心思想
暴力法的痛点是一步一步往上爬太慢。倍增法利用了二进制的思想,预处理出一个 up[j][u] 数组,表示节点 \(u\) 向上跳 \(2^j\) 步到达的祖先。
查询时:
- 先把较深的节点往上跳,使得两个节点处于同一深度。
- 两个节点同时“大步流星”地往上跳(从大到小枚举步长 \(2^j\))。如果跳到的祖先不同,就真跳;如果相同,说明跳过头了或者正好是 LCA,则不跳。
- 循环结束后,两个节点一定处于 LCA 的正下方,此时它们的父节点就是 LCA。
2. 复杂度与评价
- 预处理:\(O(N \log N)\)
- 单次查询:\(O(\log N)\)
- 优点:逻辑清晰,支持在线查询,且可以极其方便地顺手维护树上两点间的最大/最小边权(如 Kruskal 重构树中求路径极值)。
- 缺点:常数略大,在极限卡常的题目中可能会吃亏。
境界二:重链剖分(HLD)—— 常数极小的极致在线
重链剖分不仅能维护树上区间,用来求 LCA 更是降维打击,它的实际运行常数远小于倍增法。
1. 核心思想
通过两次 DFS,将整棵树剖分为若干条“重链”。每个节点记录自己所在重链的“链头”(top)。
查询时:
比较 \(u\) 和 \(v\) 所在重链链头的深度。让链头深度较深的那个节点,直接一步跳到其链头的父节点。
反复执行,直到两个节点处于同一条重链上。此时,深度较浅的那个节点就是 LCA。
2. 复杂度与评价
- 预处理:\(O(N)\)(两次纯粹的 DFS)
- 单次查询:最坏 \(O(\log N)\),但由于树的重链特性,大多数跳跃次数极少。
- 优点:空间复杂度仅 \(O(N)\),且查询的时间常数极小,是追求极致在线性能的标配。
境界三:欧拉序 + ST 表(RMQ)—— 极限的 \(O(1)\) 查询
如果你面对一道极其毒瘤的题目:节点数 \(10^5\),但查询次数高达 \(10^7\) 次,那么 \(O(\log N)\) 的查询也会被 TLE 卡死。此时必须祭出 \(O(1)\) 的杀器。
1. 核心思想
将树上问题转化为区间最值问题(RMQ)。
- 对树进行 DFS 遍历,每当“到达”或“回溯”到一个节点时,都将该节点记录下来。这样会生成一个长度为 \(2N-1\) 的欧拉序。
- 记录每个节点在欧拉序中首次出现的位置
first[u]。 - \(u\) 和 \(v\) 的 LCA,必然是欧拉序中从
first[u]到first[v]这个区间内,深度最小(最靠近根)的那个节点!
有了欧拉序,我们只需用 ST 表(Sparse Table)预处理这个区间最小值,就能实现绝对的 \(O(1)\) 查询。
2. 复杂度与评价
- 预处理:\(O(N \log N)\)
- 单次查询:\(O(1)\)
- 优点:真正的 \(O(1)\) 在线查询,无惧任何海量查询数据的轰炸。
- 缺点:ST 表的空间复杂度为 \(O(N \log N)\),当节点数达到 \(5 \times 10^5\) 以上时,容易面临被卡空间(MLE)的风险。
境界四:Tarjan 离线算法 —— 并查集的艺术
前面三种都是“在线”算法(给一个查询,立刻出一个答案)。但如果允许离线(读入所有查询后再统一输出),我们可以利用 Tarjan LCA 算法达到极致的线性复杂度。
1. 核心思想
Tarjan 算法巧妙地将 DFS 后序遍历与并查集(DSU)结合。
在 DFS 遍历的过程中:
- 访问一个节点 \(u\) 的所有子树。
- 一棵子树访问完毕后,将其并查集的根节点指向 \(u\)。
- 当 \(u\) 的所有子树都访问完后,处理所有关于 \(u\) 的查询 \((u, v)\):如果 \(v\) 已经被访问过,那么此时 \(v\) 在并查集中的根节点,就是 \(u\) 和 \(v\) 的 LCA!
2. C++ 实战核心代码
// 假设 queries[u] 存储了所有与 u 相关的查询:对 {v, query_id}
void tarjan_dfs(int u) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) {
tarjan_dfs(v);
parent[v] = u; // 访问完子树后,将子树的并查集祖先指向当前节点
}
}
// 子树全访问完了,处理与 u 相关的离线查询
for (auto& q : queries[u]) {
int v = q.first;
int id = q.second;
if (visited[v]) {
// v 被访问过,此时 v 的并查集代表元素即为 LCA
ans[id] = find(v);
}
}
}
3. 复杂度与评价
- 预处理 + 查询:\(O(N + Q \cdot \alpha(N))\),\(\alpha\) 为反阿克曼函数,整体几乎是严格线性的 \(O(N+Q)\)。
- 优点:时间和空间常数极优,是处理大规模离线查询的最优解。
- 缺点:必须提前知道所有查询(不支持强制在线,例如查询参数依赖上一次查询结果的题目)。
五、 总结与实战抉择
在算法竞赛的实战中,我们该如何选择?
- 优先推荐:倍增法和重链剖分(HLD)。它们支持在线查询,泛用性极强。尤其是你已经写了 HLD 的情况下,顺手用它求 LCA 是最完美的选择。
- 海量查询特化:当查询次数 \(Q\) 远大于节点数 \(N\)(如 \(Q > 10^6\)),且强制在线时,毫不犹豫地使用 欧拉序 + ST表。
- 离线特化:如果题目不要求在线,且节点数极大,使用 Tarjan 离线算法,让并查集的降维打击为你保驾护航。

浙公网安备 33010602011771号