在树形数据结构的高频面试与算法竞赛中,最近公共祖先(Lowest Common Ancestor, LCA)是一道无论如何都绕不开的必考考点。无论是求解树上任意两点之间的最短路径距离、网络拓扑路由的最短交汇点,还是高阶的树上差分统计(Tree Difference),其底层核心都依赖于对任意一对节点 $(u, v)$ 的公共祖先的高效检索。
如果采用朴素的解法:先将较深的节点一步一步爬到与较浅节点相同的深度,然后两个节点同时单步向上回溯,单次查询的最坏时间复杂度为 $O(N)$。当树退化为一条链且查询次数达到 $Q = 10^5$ 时,总复杂度 $O(Q \cdot N)$ 会瞬间让程序在评测机上超时(TLE)。
为了将单次查询压缩到对数级别 $O(\log N)$,**树上倍增算法(Binary Lifting)**以其优雅的二进制拆分思想、对在线查询的原生支持,以及极其方便维护路径区间信息(如路径边权最大值、路径权值和)的工程优势,成为了工业界与算法竞赛中最主流的 LCA 解法。
二进制拆分与倍增递推的本质
倍增算法的核心哲学在于:任何一个正整数距离,都可以被唯一地拆解为若干个 2 的非负整数次幂之和。
例如,从节点 $u$ 向上跃升 13 步,朴素做法需要执行 13 次指针跳转,但在二进制下 $13 = (1101)_2 = 2^3 + 2^2 + 2^0 = 8 + 4 + 1$。这意味着,我们只需分别跳跃 8 步、4 步和 1 步,仅仅通过 3 次跳转就能精确抵达目标深度。
状态定义与递推方程
为了实现任意幂次的跳跃,我们定义二维数组:
up[u][i]:表示节点 $u$ 的第 $2^i$ 代祖先节点(即从 $u$ 出发向上走 $2^i$ 步所到达的节点)。
如何预处理出所有的up[u][i]?既然 $2^i = 2^{i-1} + 2^{i-1}$,那么节点 $u$ 向上跳 $2^i$ 步,等价于先跳 $2^{i-1}$ 步到达中间节点,再从该中间节点继续向上跳 $2^{i-1}$ 步。
由此得到极为简洁的动态规划状态转移方程:
$$\text{up}[u][i] = \text{up}[\text{up}[u][i - 1]][i - 1]$$
- 边界初始状态:
up[u][0]即为节点 $u$ 在树中的直接父节点parent[u];若某个祖先已经超出根节点,则将其置为 0(虚拟根节点或哨兵空节点)。 - 时间与空间复杂度:对于拥有 $N$ 个节点的树,最大深度不超过 $N$,指数上限 $K = \lfloor \log_2 N \rfloor + 1$。预处理时间复杂度严格为 $O(N \log N)$,空间复杂度为 $O(N \log N)$。
树上倍增两步法求解 LCA 详细时序
在预处理完所有节点的深度depth[u]和倍增数组up[u][i]之后,面对任意两点 $(u, v)$ 的 LCA 查询,算法分为清晰的两步:
第一步:深度对齐(Depth Alignment)
假设 $depth[u] < depth[v]$,我们交换 $u$ 和 $v$,确保 $u$ 始终是深度较深(离根节点较远)的那个节点。
计算两者的深度差距 $\Delta = depth[u] - depth[v]$。
我们从最大幂次 $k = K \dots 0$ 从大到小检查,如果深度差满足 $\Delta \ge 2^k$(即 $\Delta$ 的二进制第 $k$ 位为 1),则执行:
$$u = \text{up}[u][k], \quad \Delta = \Delta - 2^k$$
当循环结束时,$u$ 和 $v$ 已经被精确对齐到同一深度。
特判边界:如果对齐后发现 $u == v$,说明原先较深的节点本来就位于较浅节点的子树内部,此时 $v$ 本身就是 LCA,直接返回即可。
第二步:齐头并进,逼近 LCA 的直接子节点
当 $u \neq v$ 且两者处于相同深度时,它们共同的祖先一定在更上方。
我们再次从大到小枚举跳跃幂次 $k = K \dots 0$:
- 若
up[u][k] != up[v][k]:说明跳跃 $2^k$ 步之后尚未达到公共祖先(或者还没跳过头)。此时是安全合法的跃升,我们将两者同时向上迈步:$u = \text{up}[u][k], v = \text{up}[v][k]$; - 若
up[u][k] == up[v][k]:说明跳跃 $2^k$ 步后已经到达了公共祖先,但这个祖先可能“太高了”(并非最近公共祖先,而是更高层的公共祖先)。为了保证不错过最近的那个分叉点,此时坚决不能跳,继续尝试更小的步长。
当循环枚举完毕后,$u$ 和 $v$ 恰好停留在 LCA 的正下方直接子节点位置(即两者仅差一步到达 LCA)。最终返回:
$$\text{LCA}(u, v) = \text{up}[u][0]$$
工业级 C++20 完整实现(集成路径边权最大值)
在实际后端网络链路质量分析或监控系统中,求 LCA 的同时往往需要获取路径上的度量信息(例如:两台服务机器在内部网络树状拓扑中的最大瓶颈时延)。倍增算法极其适合同步维护这一信息。
以下是使用 C++20 编写的完整且工程规范的 LCA 求解器,支持 $O(\log N)$ 同步返回两点路径上的最大边权。
#include <iostream> #include <vector> #include <cmath> #include <algorithm> #include <cassert> struct Edge { int to; int weight; }; class TreeLCA { public: // 节点编号约定为 1 ~ n,0 作为哨兵节点 explicit TreeLCA(int n, int root = 1) : n_(n), root_(root), max_log_(std::log2(n) + 2) { adj_.resize(n_ + 1); depth_.resize(n_ + 1, 0); up_.assign(n_ + 1, std::vector<int>(max_log_, 0)); max_weight_.assign(n_ + 1, std::vector<int>(max_log_, 0)); } void add_edge(int u, int v, int weight) { assert(u >= 1 && u <= n_ && v >= 1 && v <= n_); adj_[u].push_back({v, weight}); adj_[v].push_back({u, weight}); } // 预处理深度与倍增数组 (DFS 遍历) void build() { dfs_init(root_, 0, 0, 0); } // 查询 u 与 v 的 LCA 节点编号 int query_lca(int u, int v) const { if (depth_[u] < depth_[v]) { std::swap(u, v); } // 阶段一:将 u 提升到与 v 相同深度 int diff = depth_[u] - depth_[v]; for (int k = max_log_ - 1; k >= 0; --k) { if ((diff >> k) & 1) { u = up_[u][k]; } } if (u == v) { return u; } // 阶段二:齐头并进逼近 LCA 的子节点 for (int k = max_log_ - 1; k >= 0; --k) { if (up_[u][k] != up_[v][k]) { u = up_[u][k]; v = up_[v][k]; } } return up_[u][0]; } // 扩展查询:计算 u 到 v 路径上的最大边权 int query_max_path_weight(int u, int v) const { int max_w = 0; if (depth_[u] < depth_[v]) { std::swap(u, v); } int diff = depth_[u] - depth_[v]; for (int k = max_log_ - 1; k >= 0; --k) { if ((diff >> k) & 1) { max_w = std::max(max_w, max_weight_[u][k]); u = up_[u][k]; } } if (u == v) { return max_w; } for (int k = max_log_ - 1; k >= 0; --k) { if (up_[u][k] != up_[v][k]) { max_w = std::max({max_w, max_weight_[u][k], max_weight_[v][k]}); u = up_[u][k]; v = up_[v][k]; } } max_w = std::max({max_w, max_weight_[u][0], max_weight_[v][0]}); return max_w; } private: void dfs_init(int u, int p, int d, int w_to_parent) { depth_[u] = d; up_[u][0] = p; max_weight_[u][0] = w_to_parent; // 倍增状态转移 for (int i = 1; i < max_log_; ++i) { int mid = up_[u][i - 1]; up_[u][i] = up_[mid][i - 1]; max_weight_[u][i] = std::max(max_weight_[u][i - 1], max_weight_[mid][i - 1]); } for (const auto& edge : adj_[u]) { if (edge.to != p) { dfs_init(edge.to, u, d + 1, edge.weight); } } } int n_; int root_; int max_log_; std::vector<std::vector<Edge>> adj_; std::vector<int> depth_; std::vector<std::vector<int>> up_; std::vector<std::vector<int>> max_weight_; }; int main() { // 构造一棵 7 个节点的树 // 1 // / \ // 2(4) 3(2) // / \ \ // 4(1) 5(7) 6(9) // \ // 7(3) TreeLCA tree(7, 1); tree.add_edge(1, 2, 4); tree.add_edge(1, 3, 2); tree.add_edge(2, 4, 1); tree.add_edge(2, 5, 7); tree.add_edge(3, 6, 9); tree.add_edge(6, 7, 3); tree.build(); // 测试 1: LCA(4, 5) 应为 2 int lca_4_5 = tree.query_lca(4, 5); std::cout << "LCA(4, 5) = " << lca_4_5 << "\n"; assert(lca_4_5 == 2); // 测试 2: LCA(4, 7) 应为 1 int lca_4_7 = tree.query_lca(4, 7); std::cout << "LCA(4, 7) = " << lca_4_7 << "\n"; assert(lca_4_7 == 1); // 测试 3: 路径最大权值 Path(5, 7) 经过 5->2(7), 2->1(4), 1->3(2), 3->6(9), 6->7(3),最大为 9 int max_w_5_7 = tree.query_max_path_weight(5, 7); std::cout << "Max weight on path(5, 7) = " << max_w_5_7 << "\n"; assert(max_w_5_7 == 9); std::cout << "All assertions passed successfully.\n"; return 0; }算法权衡与选型总结
在面对树上路径问题时,业界通常有三种主流方案:
- 树上倍增(Binary Lifting):
- 预处理复杂度:$O(N \log N)$,单次查询:$O(\log N)$;
- 优点:支持纯在线查询,代码实现最简明,维护路径区间最值/和(RMQ)极为方便;
- 缺点:空间常数略大,对于 $N \ge 10^7$ 的极限超大规模内存较为吃紧。
- 重链剖分(树链剖分 Heavy-Light Decomposition):
- 预处理复杂度:$O(N)$,单次查询:$O(\log N)$;
- 优点:常数极小(通常在 3~5 次跳转内解决),结合线段树可支持动态修改点权和边权;
- 缺点:代码量较大,调试成本高。
- Tarjan 离线 LCA:
- 时间复杂度:$O(N + Q \cdot \alpha(N))$;
- 优点:理论时间复杂度最低,利用并查集一次 DFS 完成所有离线批处理;
- 缺点:必须一次性提前获取所有查询(不支持实时流式请求),无法自然扩展动态区间统计。
理解二进制拆分在树上倍增中的运用,不仅是攻克 LCA 问题的关键,更是掌握稀疏表(Sparse Table)、跳表(SkipList)以及各种对数级快速跃迁数据结构的思维源泉。