ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LCA算法全解析:从倍增到Tarjan,四种经典模板实现与应用场景

2026/8/16 8:24:43 拓冰建站 浏览量
LCA算法全解析:从倍增到Tarjan,四种经典模板实现与应用场景 1. 项目概述从“最近公共祖先”到算法竞赛的基石最近公共祖先简称LCA这个概念听起来有点学术但它在解决很多实际问题时就像一把万能钥匙。想象一下你面前有一棵庞大的家族树你想知道张三和李四最近的共同祖先是谁或者在一个复杂的公司组织架构里两个部门最近的共同上级是哪个。这类问题本质上就是LCA。在计算机的世界里尤其是在处理树形数据结构时LCA的应用更是无处不在。从动态树上的路径查询、计算树上两点间的距离到网络流中某些算法的优化乃至一些字符串匹配的变种问题LCA都扮演着核心角色。对于算法竞赛选手和需要处理图论问题的开发者来说掌握高效求解LCA的方法是一项必备的基本功。这次我们不只讲一种方法而是把四种最经典、最实用的LCA求解模板给你讲透。这四种方法分别是朴素向上标记法、基于倍增思想的在线算法、基于Tarjan算法的离线算法以及结合欧拉序与RMQ区间最值查询的算法。每种方法都有其独特的适用场景和性能特点。朴素法简单直接是理解问题本质的起点倍增法是在线查询的利器能快速回答任意多次的LCA询问Tarjan算法以其巧妙的并查集应用能在离线场景下达到近乎线性的时间复杂度而RMQ方法则提供了一种将LCA问题转化为序列问题的独特视角。掌握这“四板斧”你就能从容应对几乎所有需要LCA的场景。接下来我们就从最基础的概念开始一步步拆解这四种方法的原理、实现细节和那些在实战中才能积累下来的宝贵经验。2. 核心思路与算法选型背后的考量为什么需要这么多种方法来解决同一个问题这背后其实是时间、空间复杂度与问题场景的权衡。LCA问题通常有两种询问模式在线查询和离线查询。在线查询意味着程序需要随时接受任意两个节点的询问并立即给出答案而离线查询则是提前知道所有需要回答的询问对可以一次性处理所有数据后再统一输出答案。不同的场景对算法的要求截然不同。朴素法的思路最为直观从两个节点出发一步一步向父节点回溯直到找到第一个共同的祖先。它的时间复杂度是O(h)其中h是树的高度。在退化成链的极端情况下复杂度会退化到O(n)对于单次查询或许可以接受但对于大量查询比如q次q与n同数量级总复杂度O(nq)是无法承受的。因此朴素法通常只用于理解问题或者在树深度很浅、查询极少时作为临时方案。倍增法正是为了解决在线查询的效率问题而生的。它的核心思想是“跳着走”而不是一步一步走。通过预处理让每个节点存储其向上第2^k级的祖先是谁。这样在查询时我们可以先将两个节点调整到同一深度然后利用倍增数组以2的幂次为步长快速向上跳跃从而将单次查询的复杂度降至O(log n)。预处理复杂度为O(n log n)。这是一种典型的“以空间换时间”和“预处理加速查询”的思路非常适合在线场景。Tarjan算法则走了另一条路它利用深度优先搜索DFS和并查集在遍历树的过程中“顺便”解决所有LCA询问。这是一个离线算法。它的精妙之处在于当DFS回溯时将当前子树的所有节点“合并”到其父节点下并检查是否有与该子树中节点相关的LCA询问。由于并查集操作可以近似看作常数时间整个算法的复杂度接近O(n q)效率极高。但它要求所有询问已知无法处理动态的、未知的在线询问。RMQ欧拉序的方法提供了一个全新的视角。它首先对树进行一次DFS记录下欧拉序每个节点在DFS进入和离开时各记录一次。神奇的是树上任意两点的LCA在欧拉序中对应着这两个点第一次出现的位置所构成的区间内深度最小的那个节点。于是LCA问题被转化为了一个经典的RMQ问题。我们可以用ST表Sparse Table等数据结构在O(1)时间内回答RMQ从而实现O(1)的LCA查询。预处理构建欧拉序和ST表的复杂度是O(n log n)。这种方法查询速度最快但预处理相对复杂且欧拉序长度是2n-1空间开销稍大。选择哪种方法取决于你的具体需求需要在线回答随机查询首选倍增法如果所有询问已知追求极限效率Tarjan算法是绝佳选择如果对查询时间要求极为苛刻常数时间且可以接受O(n log n)的预处理那么RMQ方法值得考虑。理解这背后的权衡比单纯记忆模板更重要。3. 核心细节解析与实现要点3.1 数据结构与预处理的核心无论哪种方法第一步都是建立树的模型。我们通常使用邻接表来存储这棵n个节点的树。为了方便我们假设节点编号从1到n并通常以1号节点为根进行有根树化。vectorint g[MAXN]; // 邻接表 int depth[MAXN]; // 节点深度 int parent[MAXN]; // 父节点用于朴素法和倍增法基础对于倍增法我们需要额外的预处理数组fa[MAXN][LOG]其中LOG是log2(n)的上界通常取20或21对于n10^6的情况已经足够。fa[u][k]表示节点u向上走2^k步所到达的祖先节点。预处理分为两步首先通过一次DFS求出每个节点的直接父节点parent[u]即fa[u][0]和深度depth[u]然后利用动态规划的思想递推fa[u][k] fa[ fa[u][k-1] ][k-1]。这一步是倍增法的基石必须保证完全正确。注意在DFS预处理深度和父节点时一定要记得标记已访问节点防止在无向图中走回头路。对于根节点其父节点可以设为0或-1并在倍增数组fa[root][k]中保持为0在跳跃判断时要防止越界。对于Tarjan算法除了树本身我们还需要存储所有的询问。通常用一个查询列表vectorpairint, int queries或者为每个节点维护一个查询关联列表。核心数据结构是并查集用于在DFS回溯时将子树节点集合合并到其父节点。对于RMQ方法我们需要记录欧拉序列euler[2*MAXN]: 按DFS遍历顺序记录的节点编号。深度序列dep[2*MAXN]: 与欧拉序列对应位置的节点深度。首次出现位置first[MAXN]: 每个节点在欧拉序列中第一次出现的位置。 DFS过程中在进入节点u和离开其每个子节点后都将u加入欧拉序列和深度序列。这样生成的欧拉序列长度为2n-1。3.2 查询逻辑的难点与易错点倍增法查询分为三步。第一步统一深度将深度较大的节点向上跳跃直到与另一个节点深度相同。这里跳跃是利用倍增数组从大步长LOG-1尝试到小步长0。第二步特判如果此时两个节点已经相同那么这个节点就是LCA。第三步同步上跳如果不同则两个节点同时尝试向上跳2^k步但必须保证跳完后两个节点不相同这样才能跳到LCA的紧下方最后LCA就是这两个节点的父节点。int lca(int u, int v) { if(depth[u] depth[v]) swap(u, v); // 第一步统一深度 for(int k LOG-1; k 0; --k) { if(depth[fa[u][k]] depth[v]) { // 注意判断条件 u fa[u][k]; } } if(u v) return u; // 第二步特判 // 第三步同步上跳 for(int k LOG-1; k 0; --k) { if(fa[u][k] ! fa[v][k]) { // 关键不等才跳 u fa[u][k]; v fa[v][k]; } } return fa[u][0]; // 父节点即为LCA }实操心得倍增查询的循环必须从大到小枚举k。因为我们要找的是满足条件的最大跳跃步长从大到小尝试可以保证我们不会“跳过头”。判断条件depth[fa[u][k]] depth[v]中的至关重要它确保了u最后能跳到与v同一深度而不是比v浅。Tarjan算法查询其核心在于DFS的“后序”处理。我们将每个节点视为一个集合。当DFS遍历完节点u的所有子树后将u所在集合与其父节点parent[u]所在集合合并通过并查集的Union操作。然后遍历所有与u相关的查询(u, v)如果节点v已经被访问过即已经结束DFS那么find(v)即v所在集合的当前代表元就是u和v的LCA。为什么因为当v被访问完时它被合并到了它的某个祖先集合中而这个祖先正是DFS回溯路径上v和u尚未分离的那个点也就是LCA。RMQ方法查询查询lca(u, v)时先找到u,v在欧拉序列中首次出现的位置pos_u first[u],pos_v first[v]。假设pos_u pos_v那么LCA就是欧拉序列中区间[pos_u, pos_v]内深度最小的节点对应的编号。这个区间最小值查询RMQ可以通过预处理的ST表在O(1)时间内完成。4. 四种方法模板的完整实现与注释下面给出四种方法的完整C模板实现并附上关键注释。假设树有n个节点根为1边通过g邻接表给出查询次数为q。4.1 模板一朴素向上标记法这种方法虽然效率不高但代码极其简洁是理解LCA问题本质的起点。#include bits/stdc.h using namespace std; const int MAXN 1005; // 节点数较少时使用 vectorint g[MAXN]; int parent[MAXN], depth[MAXN]; // DFS预处理父节点和深度 void dfs(int u, int p) { parent[u] p; for(int v : g[u]) { if(v p) continue; depth[v] depth[u] 1; dfs(v, u); } } // 朴素LCA查询 int lca_naive(int u, int v) { // 步骤1: 将u和v向上回溯到同一深度 while(depth[u] depth[v]) u parent[u]; while(depth[v] depth[u]) v parent[v]; // 步骤2: 同时向上回溯直到相遇 while(u ! v) { u parent[u]; v parent[v]; } return u; // u和v相遇的点就是LCA } int main() { int n, q; cin n; // 读入n-1条边构建无向树 for(int i1; in; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); } depth[1] 0; // 根节点深度为0 dfs(1, 0); // 假设根为1其父节点设为0 cin q; while(q--) { int u, v; cin u v; cout lca_naive(u, v) endl; } return 0; }复杂度分析预处理DFS O(n)单次查询O(h)最坏O(n)。总复杂度O(n q*h)在h很大时不可接受。4.2 模板二倍增算法在线查询首选这是竞赛和工程中最常用的在线LCA算法需要在朴素法的基础上增加倍增数组的预处理。#include bits/stdc.h using namespace std; const int MAXN 100005; const int LOG 20; // 2^20 10^6 vectorint g[MAXN]; int depth[MAXN]; int fa[MAXN][LOG]; // fa[u][k]: u的2^k级祖先 void dfs(int u, int p) { fa[u][0] p; // 直接父节点 for(int i1; iLOG; i) { // 递推计算倍增数组u的2^i祖先 (u的2^{i-1}祖先)的2^{i-1}祖先 fa[u][i] fa[ fa[u][i-1] ][i-1]; } for(int v : g[u]) { if(v p) continue; depth[v] depth[u] 1; dfs(v, u); } } int lca(int u, int v) { if(depth[u] depth[v]) swap(u, v); // 1. 将u跳到与v同深度的祖先 int diff depth[u] - depth[v]; for(int k0; kLOG; k) { if(diff (1k)) { // 利用二进制分解diff u fa[u][k]; } } // 2. 如果此时uv则v就是LCA if(u v) return u; // 3. u和v同步向上跳 for(int kLOG-1; k0; --k) { // 如果祖先不同说明还没跳到LCA或LCA之上可以跳 if(fa[u][k] ! fa[v][k]) { u fa[u][k]; v fa[v][k]; } } // 最后u和v的父节点就是LCA return fa[u][0]; } int main() { int n, q, root 1; cin n; for(int i1; in; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); } depth[root] 0; dfs(root, 0); // 根节点的父亲设为0 cin q; while(q--) { int u, v; cin u v; cout lca(u, v) endl; } return 0; }注意事项初始化时对于不存在的祖先如根节点的2^k祖先k0fa[u][k]应保持为0。在查询函数中判断fa[u][k] ! fa[v][k]是安全的因为当它们都为0时条件不成立循环会继续尝试更小的k。4.3 模板三Tarjan离线算法离线查询最优此算法需要预先知道所有查询利用DFS和并查集一次性解决。#include bits/stdc.h using namespace std; const int MAXN 100005; vectorint g[MAXN]; vectorpairint, int queries[MAXN]; // queries[u] {v, query_id} int ans[MAXQ]; // 存储每个查询的答案 int parent[MAXN]; // 并查集父节点数组 int vis[MAXN]; // 标记节点是否已被访问完成 // 并查集查找带路径压缩 int find(int x) { if(parent[x] ! x) { parent[x] find(parent[x]); } return parent[x]; } // 并查集合并简单合并此处未按秩合并 void unite(int x, int y) { int rx find(x), ry find(y); if(rx ! ry) { parent[ry] rx; // 将ry的集合合并到rx } } void tarjan(int u, int p) { // 1. 初始化当前节点为自身的集合代表 parent[u] u; // 2. 遍历所有子节点 for(int v : g[u]) { if(v p) continue; tarjan(v, u); // 3. 子节点遍历完成后将其集合合并到当前节点u unite(u, v); parent[find(u)] u; // 确保u所在集合的代表元是u } vis[u] 1; // 标记u为已访问已完成其子树遍历 // 4. 处理所有与u相关的查询 for(auto q : queries[u]) { int v q.first, id q.second; if(vis[v]) { // 如果另一个节点v已被访问 ans[id] find(v); // v所在集合的代表元就是LCA } } } int main() { int n, q, root 1; cin n; for(int i1; in; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); } cin q; // 读入所有查询双向存储 for(int i0; iq; i) { int u, v; cin u v; queries[u].push_back({v, i}); queries[v].push_back({u, i}); // 因为查询是无序的 } tarjan(root, 0); for(int i0; iq; i) { cout ans[i] endl; } return 0; }关键点解析Tarjan算法的精髓在于DFS的“后序”操作。vis[u]1的时机是在处理完u的所有子树之后。此时对于任意一个与u相关的查询(u, v)如果v已经被访问过vis[v]1说明v不在u的子树中且LCA必定是v当前所在集合的代表元因为该集合在回溯过程中被不断合并其代表元就是v到u路径上尚未回溯完成的那个最高节点即LCA。4.4 模板四欧拉序RMQST表实现这种方法将LCA问题转化为RMQ问题实现O(1)查询。#include bits/stdc.h using namespace std; const int MAXN 100005; const int MAXE 2*MAXN; // 欧拉序列长度 const int LOGN 20; // 用于ST表 vectorint g[MAXN]; int euler[MAXE]; // 欧拉序列 int depth_seq[MAXE]; // 深度序列 int first[MAXN]; // 节点首次出现位置 int st[MAXE][LOGN]; // ST表存储深度最小的节点在欧拉序列中的下标 int lg2[MAXE]; // 预处理log2值 int idx 0; // 欧拉序列索引 void dfs(int u, int p, int d) { first[u] idx; euler[idx] u; depth_seq[idx] d; idx; for(int v : g[u]) { if(v p) continue; dfs(v, u, d1); euler[idx] u; // 回溯时再次记录u depth_seq[idx] d; idx; } } // 预处理ST表 void build_st() { // 预处理log2数组加速查询 lg2[1] 0; for(int i2; iidx; i) lg2[i] lg2[i/2] 1; // 初始化ST表存储下标 for(int i0; iidx; i) st[i][0] i; for(int j1; (1j) idx; j) { for(int i0; i (1j) - 1 idx; i) { int a st[i][j-1]; int b st[i (1(j-1))][j-1]; // 比较深度保留深度更小的节点对应的下标 st[i][j] (depth_seq[a] depth_seq[b]) ? a : b; } } } // RMQ查询返回欧拉序列中[l, r]区间内深度最小节点的下标 int rmq(int l, int r) { int k lg2[r - l 1]; int a st[l][k]; int b st[r - (1k) 1][k]; return (depth_seq[a] depth_seq[b]) ? a : b; } int lca(int u, int v) { int l first[u], r first[v]; if(l r) swap(l, r); int pos rmq(l, r); // 找到深度最小的位置 return euler[pos]; // 对应位置的节点就是LCA } int main() { int n, q, root 1; cin n; for(int i1; in; i) { int u, v; cin u v; g[u].push_back(v); g[v].push_back(u); } idx 0; dfs(root, -1, 0); // 生成欧拉序和深度序列 build_st(); // 构建ST表 cin q; while(q--) { int u, v; cin u v; cout lca(u, v) endl; } return 0; }实现细节dfs函数在进入节点和离开每个子节点时都记录该节点形成长度为2n-1的欧拉序列。ST表存储的是区间内深度最小的节点在欧拉序列中的下标而不是节点编号本身因为我们需要比较的是深度。查询时通过first数组定位两个节点在欧拉序列中的位置然后查询这个区间内深度最小节点的下标最后映射回节点编号。5. 常见问题、调试技巧与性能优化5.1 边界条件与初始化陷阱根节点的父节点设置在倍增法和朴素法中根节点的父节点通常设为0或-1。在倍增预处理时要确保fa[root][k](k0) 也正确指向0避免数组越界。一种安全的做法是在DFS初始化时将所有fa[u][k]默认设为0然后只更新实际存在的祖先。深度数组初始化depth[root]通常设为0。确保在DFS递归子节点时深度是depth[child] depth[u] 1。邻接表构建对于无向树每条边需要添加两次(u,v)和(v,u)。在DFS遍历时必须判断if(v parent)来防止走回头路形成无限递归。LOG常数的选择LOG值应大于log2(MAX_N)。对于n 10^5LOG17足够因为2^17131072对于n 10^6LOG20是安全的。设置过大会浪费空间过小则可能导致跳跃失败。5.2 典型错误与排查方法错误现象可能原因排查方法程序运行结果错误LCA计算不对1. 倍增数组fa[][]预处理递推公式错误。2. LCA查询函数中深度统一或同步跳跃的逻辑有误。3. 树的存储或DFS遍历出错导致父子关系错误。1. 打印出小规模树如n5的fa数组手动验证是否正确。2. 单步调试LCA函数观察u,v在每一步跳跃后的值。3. 先输出树的BFS或DFS序列检查结构是否正确。程序运行时崩溃段错误1. 数组开太小访问越界。2. 递归DFS过深导致栈溢出对于链状树n很大时。3. 在Tarjan算法中并查集find函数未写路径压缩或parent数组未初始化。1. 检查MAXN,LOG,MAXE等常量是否足够大。2. 尝试将DFS改为非递归栈实现或使用编译命令增加栈空间如-Wl,--stack更大值。3. 在Tarjan算法开始时确保parent[i]i。Tarjan算法答案部分正确部分错误查询对(u,v)只存储了一次单向当先访问v后访问u时无法处理。确保每个查询(u,v)同时添加到queries[u]和queries[v]中。RMQ方法答案错误1. 欧拉序列first[]数组记录的位置不对。2. ST表构建时比较的是节点编号而非深度值。3.rmq函数查询区间[l, r]的k值计算错误。1. 输出first[]数组和欧拉序列核对是否正确。2. 确认ST表存储的是深度最小值的下标并在查询时用这个下标去索引depth_seq进行比较。3. 使用预处理的lg2数组避免每次计算log2。5.3 性能优化与进阶技巧读入优化对于大规模数据n, q 10^5使用scanf或自己实现的快读函数避免cin因同步而带来的开销。并查集优化在Tarjan算法中实现并查集时可以采用路径压缩和按秩合并或按大小合并虽然对于树形结构路径压缩已足够但加上按秩合并能使理论复杂度更优。int parent[MAXN], rnk[MAXN]; // rnk表示秩 void init() { for(int i1; in; i) parent[i]i, rnk[i]0;} int find(int x) { return parent[x]x ? x : parent[x]find(parent[x]); } void unite(int x, int y) { int rxfind(x), ryfind(y); if(rxry) return; if(rnk[rx] rnk[ry]) parent[rx]ry; else { parent[ry]rx; if(rnk[rx]rnk[ry]) rnk[rx]; } }内存优化倍增法的fa数组是int fa[MAXN][LOG]如果MAXN很大如1e6LOG20那么内存占用约为 1e6 * 20 * 4 bytes ≈ 80MB需要注意。可以尝试使用short类型存储深度如果深度65535或者使用vector动态分配第二维但可能牺牲一些速度。查询批处理即使是在线算法如果查询可以分批进行也可以考虑对每批查询使用一次Tarjan离线算法以减少常数开销。树链剖分求LCA除了上述四种树链剖分也可以用来求LCA且能与路径修改、子树修改等操作很好地结合。其思想是将树分成若干条链通过比较节点所在链的顶端节点来快速向上跳。虽然预处理和单次查询复杂度都是O(log n)但常数比倍增法稍大不过在需要支持更多树操作时是更通用的选择。5.4 选择指南与实战心得在实际比赛或项目中如何选择这里有一些个人经验首选倍增法除非有特殊要求否则倍增法是最稳妥、最通用的选择。它在线、好写、好调、效率足够。95%的LCA问题用它都能解决。数据规模极大且查询离线如果n和q在10^6量级并且查询可以离线那么Tarjan算法O(nq)的复杂度优势就体现出来了很可能帮你卡过时间限制。需要极快的常数查询时间如果题目需要每秒进行海量例如10^7次LCA查询且预处理时间不敏感那么RMQST表的O(1)查询就非常有吸引力。树结构动态变化如果树不是静态的会添加叶子节点但根不变倍增法依然可以工作只需要对新节点进行倍增数组的预处理即可。如果树的结构变化剧烈如链接切割则需要使用更高级的动态树数据结构如LCT。调试技巧遇到LCA问题WA时不要急于看代码。先画一棵小树7-8个节点手动模拟你的算法过程一步一步验证预处理数组和查询结果。这是最有效的调试方法。最后理解这四种方法的核心思想比死记模板更重要。朴素法让你理解本质倍增法展示了二进制拆分和预处理的威力Tarjan法体现了离线处理和并查集的巧妙结合RMQ方法则提供了问题转化的经典思路。掌握了这些你不仅能解决LCA问题更能将这些思想应用到其他更广阔的算法领域中去。