> # 最近公共祖先(Lowest Common Ancestor, LCA)算法详解 本文将详细解释您提供的 C++ 代码,该代码用于解决计算机算法竞赛中关于求解树上两个节点的最近公共祖先(LCA)的问题。通过这篇文章,您将深入理解 LCA 算法的原理、实现细节以及如何在高效地处理大规模数据时避免暴力方法带来的性能瓶颈。 ## 1. 问题概述 **题目**:给定一棵有根多叉树,回答多个询问,每个询问要求出指定两个节点的最近公共祖先。 **输入**: - 第一行:三个整数 $N,M,S$,分别表示树的节点数、询问次数和根节点的编号。 - 接下来的 $N-1$ 行:每行两个整数 $x, y$,表示节点 $x$ 和节点 $y$ 之间有一条边。 - 接下来的 $M$ 行:每行两个整数 $a, b$,表示询问节点 $a$ 和节点 $b$ 的最近公共祖先。 **输出**: - 对每个询问输出一个整数,表示两个节点的最近公共祖先的编号。 **样例解释**: 通过样例可以看出,最近公共祖先是位于两个节点之间的最近的共同祖先节点。 ## 2. 算法选择:二进制跳跃(Binary Lifting) 为了高效地回答多次 LCA 查询,我们采用 **二进制跳跃** 技术。该方法预处理树的信息,使得每次查询的时间复杂度降至 $O(\log N)$。 ### 2.1 原理 **二进制跳跃** 通过预计算每个节点在祖先链上的 2 的幂次方祖先,允许在查询过程中迅速地跳跃到高位的祖先,从而高效地找到两个节点的最近公共祖先。 具体步骤如下: 1. **深度优先搜索(DFS)**:遍历树,记录每个节点的深度和直接父节点。 2. **预处理祖先表**:构建一个表 `fa[u][k]`,表示节点 $u$ 的第 $2^k$ 个祖先。 3. **LCA 查询**: - **调整深度**:将较深的节点提升到与较浅节点相同的深度。 - **同时跳跃**:从最高位开始,同时跳跃两个节点,直到找到最近的不同祖先。 - **返回结果**:最终的最近公共祖先即为两节点的共同祖先。 ## 3. 代码详解 以下是您提供的 C++ 代码,并附有详细注释和解释: ```cpp #include #include #include #include #include using ll = int64_t; // 定义 ll 为长整型 using std::cin, std::cout; // 常量定义 const ll maxn = 500000 + 5; // 节点最大数 constexpr ll L = 20; // 二进制跳跃的最大深度,2^20 > 500,000 ll n, m, s; // n: 节点数, m: 询问数, s: 根节点编号 ll fa[maxn][L]; // fa[u][k]: 节点 u 的第 2^k 个祖先 ll dpt[maxn]; // dpt[u]: 节点 u 的深度 std::vector adj[maxn]; // 邻接表表示的树 /** * @brief 深度优先搜索,计算每个节点的深度和直接父节点 * * @param fth 当前节点 u 的父节点 * @param u 当前遍历的节点 */ void dfs(const ll &fth, const ll &u) { fa[u][0] = fth; // 记录当前节点的直接父节点 for(auto &v : adj[u]) { // 遍历当前节点的所有邻居 if(v != fth) { // 避免回到父节点 dpt[v] = dpt[u] + 1; // 子节点的深度为父节点深度加一 dfs(u, v); // 递归遍历子节点 } } } /** * @brief 预处理祖先表,填充 fa[u][k] 表 */ void initat() { for(ll k = 1; k < L; ++k) { // 对每一个 2^k 的倍数 for(ll u = 1; u <= n; ++u) { // 对每个节点 if(fa[u][k-1] != 0) { // 如果 u 有 2^(k-1) 个祖先 fa[u][k] = fa[fa[u][k-1]][k-1]; // u 的 2^k 个祖先是 u 的 2^(k-1) 个祖先的 2^(k-1) 个祖先 } // 如果 fa[u][k-1] == 0,表示 u 的 2^(k-1) 个祖先不存在,则 fa[u][k] 保持为 0 } } } /** * @brief 计算两个节点的最近公共祖先 * * @param u 节点 u * @param v 节点 v * @return 最近公共祖先的节点编号 */ ll lca(ll u, ll v) { if(dpt[u] < dpt[v]) std::swap(u, v); // 确保 u 是更深的节点 ll tmp = dpt[u] - dpt[v]; // 深度差 for(ll j = 0; tmp; j++, tmp >>= 1) { // 将 u 提升到与 v 同样的深度 if(tmp & 1) { // 如果当前位为 1,则提升 u u = fa[u][j]; } } if(u == v) return u; // 如果提升后 u 和 v 相同,则直接返回 for(ll j = L - 1; j >= 0; j--) { // 从高到低的位进行跳跃 if(fa[u][j] != fa[v][j]) { // 如果 u 和 v 的 2^j 祖先不同 u = fa[u][j]; // 同时提升 u 和 v v = fa[v][j]; } } return fa[u][0]; // 返回 u 和 v 的直接父节点,即最近公共祖先 } int main(){ // 加速读写 std::iostream::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); cin >> n >> m >> s; // 读取节点数、询问数和根节点 for(ll i = 1; i < n; i++){ // 读取 n-1 条边 ll u, v; cin >> u >> v; adj[u].emplace_back(v); // 添加到邻接表 adj[v].emplace_back(u); } dfs(0, s); // 从根节点 s 开始 DFS,假设根节点的父节点为 0 initat(); // 预处理祖先表 while(m--){ // 处理每一个询问 ll u, v; cin >> u >> v; cout << lca(u, v) << '\n'; // 输出 LCA 的结果 } } ``` ### 3.1 代码结构解析 - **数据结构**: - `fa[maxn][L]`:二维数组,存储每个节点的 $2^k$ 个祖先。 - `dpt[maxn]`:记录每个节点的深度。 - `adj[maxn]`:邻接表表示的树结构。 - **函数**: - `dfs(fth, u)`:深度优先搜索,计算每个节点的深度和直接父节点。 - `initat()`:预处理祖先表,填充 `fa[u][k]`。 - `lca(u, v)`:计算两个节点的最近公共祖先。 ### 3.2 关键步骤详解 #### 3.2.1 深度优先搜索(DFS) ```cpp void dfs(const ll &fth, const ll &u){ fa[u][0] = fth; // 记录直接父节点 for(auto &v: adj[u]){ if(v != fth){ dpt[v] = dpt[u] + 1; // 设置子节点的深度 dfs(u, v); // 递归遍历子节点 } } } ``` - **作用**:通过 DFS 遍历,计算每个节点的深度和直接父节点。 - **参数**: - `fth`:当前节点 `u` 的父节点。 - `u`:当前遍历的节点。 - **逻辑**: 1. 设置当前节点 `u` 的直接父节点为 `fth`。 2. 遍历 `u` 的所有邻居节点 `v`。 3. 如果 `v` 不是父节点,则更新其深度为 `u` 的深度加一,并递归遍历 `v`。 #### 3.2.2 预处理祖先表 ```cpp void initat(){ for(ll k = 1; k < L; ++k){ for(ll u = 1; u <= n; ++u){ if(fa[u][k-1] != 0){ fa[u][k] = fa[fa[u][k-1]][k-1]; } } } } ``` - **作用**:通过动态规划填充 `fa[u][k]` 表,使得 `fa[u][k]` 存储节点 `u` 的第 $2^k$ 个祖先。 - **逻辑**: 1. 对于每一个可能的 $k$(从 $1$ 到 $L-1$),计算每个节点的第 $2^k$ 个祖先。 2. 如果节点 `u` 的第 $2^{k-1}$ 个祖先存在(即 `fa[u][k-1] != 0`),则 `fa[u][k]` 是节点 `u` 的第 $2^{k-1}$ 个祖先的第 $2^{k-1}$ 个祖先。 3. 如果 `fa[u][k-1] == 0`,表示 `u` 没有第 $2^{k}$ 个祖先,保持 `fa[u][k] = 0`。 #### 3.2.3 最近公共祖先查询 ```cpp ll lca(ll u, ll v){ if(dpt[u] < dpt[v]){ std::swap(u, v); // 确保 u 是更深的节点 } ll tmp = dpt[u] - dpt[v]; // 计算深度差 for(ll j = 0; tmp; j++, tmp >>= 1){ if(tmp & 1){ u = fa[u][j]; // 将 u 提升到与 v 同样的深度 } } if(u == v) return u; // 如果 u 和 v 已经相同,返回 u for(ll j = L - 1; j >= 0; j--){ if(fa[u][j] != fa[v][j]){ // 找到 u 和 v 的第一个不同的祖先 u = fa[u][j]; v = fa[v][j]; } } return fa[u][0]; // 返回最近公共祖先 } ``` - **作用**:计算两个节点 `u` 和 `v` 的最近公共祖先。 - **逻辑**: 1. **调整深度**:如果 `u` 比 `v` 更浅,则交换 `u` 和 `v`,确保 `u` 是更深的节点。 2. **提升深度**:计算 `u` 和 `v` 的深度差,通过二进制跳跃将 `u` 提升到与 `v` 相同的深度。 3. **寻找 LCA**: - 如果提升后 `u` 和 `v` 相同,则 LCA 即为 `u`。 - 否则,从高位开始,同时提升 `u` 和 `v`,直到找到两个节点的祖先不同的位置。 - 最终,LCA 为 `u` 和 `v` 的直接父节点 `fa[u][0]`。 ### 3.3 主函数流程 ```cpp int main(){ // 加速输入输出 std::iostream::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); cin >> n >> m >> s; // 输入节点数、询问数和根节点 for(ll i = 1; i < n; i++){ // 输入 n-1 条边 ll u, v; cin >> u >> v; adj[u].emplace_back(v); adj[v].emplace_back(u); } dfs(0, s); // 从根节点 s 开始 DFS,假设根节点的父节点为 0 initat(); // 预处理祖先表 while(m--){ // 处理每一个询问 ll u, v; cin >> u >> v; cout << lca(u, v) << '\n'; // 输出最近公共祖先 } } ``` - **流程**: 1. **输入处理**: - 读取节点数 `n`、询问数 `m` 和根节点 `s`。 - 读取 `n-1` 条边,将树结构存储在邻接表 `adj` 中。 2. **预处理**: - 通过 DFS 遍历树,计算每个节点的深度和直接父节点。 - 预处理祖先表 `fa[u][k]`。 3. **处理询问**: - 对于每一个询问,读取节点 `u` 和 `v`,计算并输出它们的 LCA。 ## 4. 重要概念总结 ### 4.1 二进制跳跃(Binary Lifting) 二进制跳跃是一种预处理技术,用于在树上高效地回答 LCA 及其他相关查询。其核心思想是通过预计算每个节点的 $2^k$ 个祖先,使得在查询过程中可以通过二进制表示逐步跳跃到目标位置,从而降低查询时间复杂度。 **优点**: - **高效性**:预处理时间为 $O(N \log N)$,每次 LCA 查询时间为 $O(\log N)$。 - **适用性广**:适用于静态树(树结构不变的情况下)的多次查询。 **关键点**: - 需要记录每个节点在不同层次的祖先。 - 利用二进制表示,快速提升节点到目标深度。 ### 4.2 最近公共祖先(Lowest Common Ancestor, LCA) **定义**:在树中,两个节点的最近公共祖先是距离这两个节点最近的一个共同祖先。 **应用场景**: - 计算树上两个节点之间的距离。 - 解决家族关系、层级关系等问题。 ### 4.3 深度优先搜索(DFS) **作用**:遍历树结构,计算每个节点的深度和直接父节点。 **特点**: - **递归或迭代实现**。 - **时间复杂度**:$O(N)$,适用于大规模树的遍历。 ## 5. 复杂度分析 - **预处理**: - **DFS**:$O(N)$。 - **祖先表填充**:$O(N \log N)$。 - **每次查询**:$O(\log N)$。 - **总体**: - **总预处理时间**:$O(N \log N)$。 - **所有查询时间**:$O(M \log N)$。 对于本题的约束条件 ($N, M \leq 500,000$),二进制跳跃确保了程序的高效性,避免了 $M \times N$ 的暴力查询带来的性能问题。 ## 6. 代码优化与注意事项 - **输入输出优化**: - 使用 `std::ios::sync_with_stdio(false);` 和 `cin.tie(nullptr);` 加速输入输出,避免使用 `std::printf` 或 `std::scanf`。 - **常量设置**: - 确保 `L` 足够大,使得 $2^L \geq N$。对于 $N \leq 5 \times 10^5$,$L = 20$ 足以,因为 $2^{20} = 1,048,576 > 500,000$。 - **避免递归深度过大**: - 对于 $N = 500,000$,递归深度可能达到 $500,000$。在一些编译器中,这可能导致栈溢出。可以通过优化编译器选项或使用非递归 DFS 实现来避免。 ## 7. 扩展阅读与学习建议 - **Tarjan 算法**:另一种用于解决 LCA 问题的算法,基于并查集,适合离线查询。 - **线性时间 LCA**:通过 Euler Tour 和 RMQ,可以在 $O(N)$ 的预处理时间下,实现 $O(1)$ 的查询时间。 - **HLD(重链剖分)**:将树分解为重链和轻链,适用于多种树上的查询问题,包括 LCA、路径查询等。 建议深入理解二进制跳跃原理,并尝试实现相关算法。同时,通过大量的实践题目来巩固知识,增强对不同 LCA 算法的应用能力。 ## 8. 总结 通过以上的详细解析,您已经了解了二进制跳跃法在 LCA 问题中的应用,并掌握了对应代码的具体实现方式。关键在于: 1. **预处理**: - 使用 DFS 计算每个节点的深度和直接父节点。 - 使用动态规划填充祖先表 `fa[u][k]`。 2. **查询**: - 调整节点深度,使得两节点处于相同的层次。 - 通过二进制跳跃同时上升,找到最近的共同祖先。 这种方法在大规模数据下表现出色,避免了暴力方法带来的性能瓶颈,非常适合竞赛环境中的实时查询需求。