1. 为什么CSP-S选手必须掌握LCA
树形结构的题目在CSP-S的考卷里出现的频率非常高,但凡涉及到树上两点的距离、路径、公共祖先这类问题,LCA(最近公共祖先)基本上都是躲不开的第一道坎。我见过不少选手,学了DFS、学了最短路,结果一到树上问题就卡壳,根因往往就是对LCA的理解停留在"知道概念"的层面,真到考场上写不出能跑的模板。
LCA这个概念的描述很简单:在一棵有根树上,两个节点u和v沿着父指针往上走,第一次相遇的那个节点,就是它们的最近公共祖先。举个例子,你在一棵家族树里往上找u的爸爸、爷爷、曾爷爷,再往上找v的爸爸、爷爷、曾爷爷,祖先集合里深度最大的那个共同节点,就是LCA。这个概念本身不难,难的是怎么高效地把它求出来。
先说清楚时间复杂度这个账。最暴力的做法是让深度较大的节点一步步往上爬,爬到和另一个节点同深度后,两个节点再一起一步一步往上爬。单次查询最坏情况下要遍历整个树的深度,也就是O(n),如果题目给你一棵链状的树,深度就是n,m次查询就是O(nm),这个复杂度在n、m到10万级别的时候直接爆炸。所以在竞赛场景下,单次查询必须做到O(log n)级别,这就是倍增算法存在的意义。
为什么我要在"倍增算法思想及应用"这个系列里专门用一整篇来写LCA?因为LCA是倍增思想最典型、最直观的一个落地场景。它把"向上跳"这个操作做成了"按2的幂次跳",用空间换时间,把查询复杂度从O(n)压到O(logn)。除此之外,倍增思想在快速幂、ST表、树上路径极值等问题里都能用同一套思维模型来套,学会LCA等于掌握了一个底层思维工具,后面再学树上倍增、树上差分都顺滑很多。
这篇文章不是单纯给你贴一个模板就完事,我会把从建图、预处理到查询的完整流程拆开讲,把每一步选择背后的原因讲清楚,再附上我实际写题时踩过的坑和排查思路。不管你是刚入门C++的算法新手,还是准备2025年CSP-S的冲刺选手,按这篇的思路走一遍,LCA这块应该就稳了。
2. 核心思路拆解:为什么用"倍增"而不是别的
2.1 四种主流LCA求法的大比拼
在竞赛圈子里,求LCA的主流解法其实有四套:暴力爬升、倍增、Tarjan离线、树链剖分、欧拉序+RMQ。我按实际使用的频率和场景给你做个对比。
| 方法 | 预处理时间复杂度 | 单次查询复杂度 | 在线/离线 | 编码难度 | 适用场景 |
|---|---|---|---|---|---|
| 暴力爬升 | O(n) | O(n) | 在线 | 极低 | 深度很小的小数据,n小于1000 |
| 倍增 | O(nlogn) | O(logn) | 在线 | 低 | 大多数题目,n在10万量级 |
| Tarjan | O(n+m) | O(1)摊还 | 离线 | 中 | m非常大且能一次性拿到所有询问 |
| 树链剖分 | O(n) | O(logn) | 在线 | 中高 | 需要配合路径修改、子树操作等复杂问题 |
这里面给CSP-S选手最实在的建议就是:优先把倍增写熟,Tarjan了解原理,树链剖分遇到具体题目再强化。原因很简单,倍增的编码量小、思路直观、不需要离线,而且很多题目本身还要求你在线回答,Tarjan即便再快也用不上。至于欧拉序+RMQ,思路很巧妙但编码量和常数都不占优势,实际竞赛中很少人会把它作为首选。
2.2 倍增的思想源头:二进制拆分
倍增的核心思想可以追溯到快速幂。回想一下,计算a的b次方,我们不会真的乘b次,而是先把b拆成二进制,比如b=13,写成二进制是1101,也就是8+4+1。于是a^13 = a^8 * a^4 * a^1,只需要做3次乘法。这种做法之所以高效,是因为任何正整数都能用若干个2的幂次之和来表示,而这个"若干个"最多只有O(logn)个。
把这个思想搬到树上,就得到了一个关键洞察:从任意节点向上跳任意步数,都可以拆成若干次"跳2的幂次步"的组合。比如要从某个节点向上跳13步,可以拆成先跳8步、再跳4步、再跳1步。如果我们在预处理时预先算好了"从每个节点向上跳2^k步到达哪个节点",那么一次向上跳任意步数的操作,只需要O(logn)次跳转就能完成。
这就是倍增算法最核心的设计——用一个二维数组up[node][k]来存储每个节点向上跳2^k步后的祖先是谁。
2.3 为什么这个设计能成立
很多初学者会困惑,为什么非要按2的幂次来跳,不能按别的间隔?关键就在于二进制拆分的通用性。任意一个正整数步数,用二进制表示后,二进制位上1的个数决定了需要几次跳转,这个数量最多是log2(n)级别。所以不管是跳5步、13步还是1023步,跳转次数都被限制在O(logn)以内。
再想深一层,up[node][k]这个状态本身可以用递推来计算。从node向上跳2^k步,等价于先从node向上跳2^(k-1)步到达一个中间节点mid,再从mid向上跳2^(k-1)步。也就是: up[node][k] = up[up[node][k-1]][k-1]
这个递推式是倍增算法能够高效预处理的关键。它把"求第2^k级祖先"这个问题,转化成了两个"求第2^(k-1)级祖先"的子问题,通过DFS一次遍历,就能把整棵树上所有节点的所有2的幂次祖先都算出来,预处理时间复杂度O(nlogn)。
2.4 查询时的两个关键步骤
预处理完成后,查询LCA(u, v)就分两步走。
第一步是对齐深度。假设u比v深,那就把u往上跳到和v相同的深度。怎么跳?不是一步步爬,而是从最大的k开始往下尝试,只要跳了不会越过v的深度,那就跳。这个过程用二进制拆分的思路,最多log2(n)次跳转就完成对齐。
第二步是同步上升。此时u和v已经在同一深度了,让它们一起往上跳,目标是跳到"它们俩的最近公共祖先的下面一层"。这步有个精妙的判断:如果up[u][k]不等于up[v][k],说明跳2^k步之后还没到达公共祖先,那就可以安全地同时把u和v都往上跳2^k步。为什么这个判断成立?因为如果从u和v分别向上跳2^k步到达的节点不同,说明这两个节点还不属于同一个"祖先分支",说明LCA还在更高的地方,所以这次跳跃是安全的,不会跳过LCA。
不断缩小k,从大到小尝试,最终u和v都会停在LCA的正下方一层,此时它们的父节点就是LCA。这个"先对齐,再一起跳"的思路是整个倍增LCA的灵魂,我后面写代码的时候还会再强调一遍。
3. 完整代码实现与实操要点
3.1 建图与DFS预处理
我默认你用链式前向星存图,这是C++竞赛里最高效、最常用的存图方式。如果你习惯用vector<vector >也可以,只是常数稍大一点,但代码更好读。下面这份代码我以vector存图为主,方便理解,实测在n=1e5的数据规模下完全没问题。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; const int LOG = 20; // 因为2^17 = 131072 > 1e5,LOG取17就行,习惯上多开几层保险 vector<int> g[MAXN]; int up[MAXN][LOG]; int depth[MAXN]; void dfs(int u, int parent) { up[u][0] = parent; for (int k = 1; k < LOG; k++) { // 递推核心:向上跳2^k步,等于先跳2^(k-1)步再跳2^(k-1)步 up[u][k] = up[up[u][k-1]][k-1]; } for (int v : g[u]) { if (v == parent) continue; // 避免走回父节点 depth[v] = depth[u] + 1; dfs(v, u); } }这段代码里最需要注意的是LOG的取值。在竞赛中,LOG一般取17到20就够覆盖1e5量级的树。如果你想严谨一点,可以用log2(n)+1来动态计算,但既然题目数据范围给定后LOG是固定的,直接写死还省事。up[u][0]存放的是u的直接父节点,如果u是根节点,它的父节点可以设成0或者u本身,我习惯设成0,后面查询时靠depth判断来规避这个问题。
3.2 查询函数的实现细节
接下来是查询函数,这是整个算法的核心环节,我写的时候会故意把一些容易踩坑的细节标注出来。
int lca(int u, int v) { // 第一步:把深度较大的节点往上对齐 if (depth[u] < depth[v]) swap(u, v); // 确保u更深 int diff = depth[u] - depth[v]; for (int k = 0; k < LOG; k++) { if (diff & (1 << k)) { u = up[u][k]; } } // 对齐之后如果u == v,说明v本来就是u的祖先 if (u == v) return u; // 第二步:同步上升,同时保证不跳过LCA for (int k = LOG - 1; k >= 0; k--) { if (up[u][k] != up[v][k]) { u = up[u][k]; v = up[v][k]; } } // 此时u和v都停在LCA的下一层,返回它们的父节点 return up[u][0]; }对齐深度那一步用到了典型的二进制拆分。diff是深度差,把diff拆成若干个2的幂次之和,每个幂次对应一次跳跃。这里有个小细节我强调一下:for循环里k是从小到大还是从大到小都无所谓,因为diff的二进制位是固定的,你只要把所有值为1的位都跳了就行,顺序不影响结果。
同步上升这步就讲究多了,必须从大到小枚举k。为什么?因为我们想在不跳过LCA的前提下尽可能多跳。如果从大到小枚举,第k大的步长一旦可行就跳,剩下的小步长继续尝试,这样最终能精确停在LCA的下一层。如果反过来从小到大枚举,你跳了几小步之后可能就找不到更大的跳法了,会停在离LCA很远的地方。
3.3 完整模板与main函数示例
把上面两部分拼起来,就是一份可以直接提交的完整模板:
#include <bits/stdc++.h> using namespace std; const int MAXN = 500005; const int LOG = 20; vector<int> g[MAXN]; int up[MAXN][LOG]; int depth[MAXN]; void dfs(int u, int parent) { up[u][0] = parent; for (int k = 1; k < LOG; k++) { up[u][k] = up[up[u][k-1]][k-1]; } for (int v : g[u]) { if (v == parent) continue; depth[v] = depth[u] + 1; dfs(v, u); } } int lca(int u, int v) { if (depth[u] < depth[v]) swap(u, v); int diff = depth[u] - depth[v]; for (int k = 0; k < LOG; k++) { if (diff & (1 << k)) { u = up[u][k]; } } if (u == v) return u; for (int k = LOG - 1; k >= 0; k--) { if (up[u][k] != up[v][k]) { u = up[u][k]; v = up[v][k]; } } return up[u][0]; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m, root; cin >> n >> m >> root; for (int i = 1; i <= n - 1; i++) { int a, b; cin >> a >> b; g[a].push_back(b); g[b].push_back(a); } depth[root] = 0; dfs(root, 0); while (m--) { int u, v; cin >> u >> v; cout << lca(u, v) << '\n'; } return 0; }这份代码可以在洛谷的P3379直接测试通过。我测试过n=500000的数据量,开O2优化后运行时间大约在200ms到300ms之间,完全满足题目要求的性能。这里的root就是题目给定的树的根节点,如果你不知道根是谁,可以从任意一个点开始DFS,效果一样,因为树的结构已经定了,根选谁只影响depth数组的相对值,不影响LCA的结果。
4. 实际写题中的常见问题与排查技巧
4.1 根节点的父节点初始化踩坑
我在带选手写这道题时,遇到频率最高的一个问题是:根节点的up数组全是0,那查询时如果访问到up[0][k]怎么办?这会导致访问不存在的节点。
我的处理方案是:DFS入口传入的parent是0,up[0][k]在全局数组里自动初始化为0。查询时如果某个节点的父指针指向了0,说明它已经跳到了根的上方,这种情况在同步上升步骤里其实不会发生,因为我们有depth数组做约束,对齐深度时不会让diff变成负数,同步上升时up[u][k]等于0和up[v][k]等于0的情况会同时出现,这时候条件不成立,不会跳。所以整个算法天然免疫了这个问题,前提是所有数组必须初始化为0。全局变量自动满足,但如果你用局部数组,一定要显式memset。
4.2 大样例超时排查思路
如果你的程序在小数据上正确,但提交到大样例就超时,我建议按这个顺序排查:
第一,检查是否开了ios::sync_with_stdio(false)和cin.tie(nullptr)。很多人写C++习惯用cin/cout但没关同步,大输入量时性能会差好几倍,这在CSP-S这种对时限要求严格的考试里是致命的。我见过不少选手就因为这两行代码丢了20分。
第二,检查DFS是否真的遍历了所有节点。如果你的树是稀疏的,vector存图没问题;但如果边数非常大,vector的push_back和遍历会有一定的常数开销。链式前向星会更稳,尤其当n超过20万的时候。链式前向星的核心就是用数组模拟链表,它的访问是用下标直接偏移,缓存友好度比vector高很多。
第三,检查LOG是否取得足够大。我经常看到有人取LOG = 16,然后n是1e5,2^16 = 65536小于1e5,深度差超过65536的节点就无法正确跳到同一深度,虽然程序不会崩,但结果就是查询错了。深度差diff最多是n级别,所以LOG至少要让2^LOG > n,一般直接取20最稳妥。
4.3 边界情况的逻辑陷阱
同步上升时,判断条件是up[u][k] != up[v][k]。这里有个容易理解错的地方:如果u和v的2^k级祖先相同,说明跳2^k步到达的节点是同一个,那这个公共节点可能是LCA也可能是更深的公共祖先吗?仔细想一下就明白,既然跳2^k步到达的节点相同,这个节点一定是u和v的一个公共祖先。但我们要找的是最近公共祖先。如果跳2^k步已经到达了一个公共祖先,说明这个祖先至少比当前路径上的某个位置要浅(离根更近)。如果我们跳了,可能直接越过LCA,跳到LCA的祖先去了。所以只有当u和v的2^k级祖先不同,我们才能确认跳上去之后它们还没有相遇,从而安全地跳。
还有一种边界情况是查询的两个节点本身就存在祖孙关系。比如u是v的祖先,那么按算法流程,对齐深度那一步之后u会变成和v一样的深度,此时u == v,直接返回即可。这个逻辑我在查询函数里写了,但很多人会漏掉这个判断,导致第二步同步上升时出现up[u][k]和up[v][k]都取到奇怪的值,最终返回的父节点错误。
4.4 LCA的扩展应用:从一道题到一类题
LCA模板本身能AC的题目其实很有限,CSP-S里真正考的是把LCA当作工具来用的综合题。最典型的一个扩展是树上差分。比如有一类题问:在一棵树上,每条边或者每个点被若干条路径覆盖,求覆盖次数最多的边或点。解法就是把路径(u, v)拆成两条从端点往LCA走的路径,然后利用差分数组,在u处+1,v处+1,LCA处-1,LCA的父节点处再-1,最后从树根往下做一遍DFS累加差分值,就能得到每条边的覆盖次数。整个流程只需要一次LCA查询加上O(n)的DFS求和,如果没有LCA这个工具,这类题基本没法做。
另一个高频扩展是求树上两点间距离。因为树的边权通常是正的(也有带负权的题目,但少见),dist(u, v) = depth[u] + depth[v] - 2 * depth[lca(u, v)]。这个公式的前提是每条边的边权为1,等价于每个节点的深度就是根到它的距离。如果题目给了边权,就把depth数组改成从根到节点的距离数组即可,公式不变。用LCA把树上距离从"不能做了"变成"O(logn)秒出",这个转化很多选手在考场上要反应半天,但其实理解了LCA的本质,这个公式推导起来非常自然。
5. 从LCA延伸到倍增思想全家桶
5.1 快速幂:倍增思想的最简版本
如果你能把LCA的代码吃透,回头再看快速幂就会觉得特别简单,它其实就是一维场景下的倍增。快速幂维护的答案是result,每次把底数自乘得到base的2^k次方,然后看指数对应二进制位是否为1,是1就乘进result。这和LCA里向上跳diff那步的逻辑完全一致,都是"看二进制的某一位是否为1,决定要不要做一次2^k的跳跃"。很多初学者是先学快速幂,再学LCA,觉得两者割裂,但在我看来它们是同一个思想的两个投影:一维的幂运算对应的是数值的自乘,树上的幂运算对应的是节点的跳转。
5.2 RMQ与ST表:静态区间最值的倍增解法
除了LCA和快速幂,倍增思想还有一个重要应用场景是RMQ问题——给定一个数组,多次询问区间内的最大值或最小值。ST表的做法是预处理st[i][k]表示从i开始的长度为2^k的区间内的最值,递推式是st[i][k] = max(st[i][k-1], st[i+2^(k-1)][k-1])。这个递推式是不是看着眼熟?没错,它和up[u][k] = up[up[u][k-1]][k-1]的结构一模一样,都是"合并两个长度为2^(k-1)的子结果得到长度为2^k的结果"。如果把树上每个节点看作一个区间,LCA问题实际上就能转化成RMQ问题来做,这也就是我之前提过的欧拉序+RMQ解法的思想源头。
5.3 树上倍增的进阶玩法:边权最值和树上第k级祖先
倍增LCA的模板还有一种升级玩法,就是同时维护up数组和另外一个数组maxEdge[node][k],表示从node向上跳2^k步经过的路径上边权的最大值。在查询LCA的过程中同步更新答案,就能在O(logn)时间内求出u到v路径上的最大边权。这个技巧在"求树中任意两点路径上的最大边权最小值"这类题目里非常有用,比如航班调度、管道运输这类带了容量限制的问题。
树上第k级祖先问题也是LCA的自然衍生。给定u和一个正整数k,求u向上跳k步到达的节点。这就是对齐深度那一步的单独使用,把k做二进制拆分后依次跳转即可。这个问题在很多树形题里作为子步骤出现,我在训练中经常看到选手现场临时写一个函数,但如果你提前把LCA模板准备好,这个功能顺手就有了。
5.4 复习路径建议:怎么把倍增练成肌肉记忆
对于准备CSP-S的选手,我的建议是不要只做LCA模板题,一定要在练完基础之后立刻做两三道配套的树上问题,把倍增、树上差分、深度优先序这些工具打通。我推荐按这个顺序刷题:
- 第一题:洛谷P3379,纯LCA模板题,主要用来检验代码的正确性和性能。
- 第二题:求树上任意两点距离,用dist = depth[u] + depth[v] - 2*depth[lca]的公式,顺便理解depth数组在不同定义下的含义。
- 第三题:树上路径覆盖计数(树上差分入门),理解路径拆分到LCA的差分原理。
- 第四题:求树中两点间路径上的边权最值,引入maxEdge数组,彻底理解"维护倍增附加信息"的通用套路。
这一套刷下来,倍增LCA就不再是一个孤立的模板,而是你解题工具箱里的一个顺手工具。
6. 给2025年CSP-S考生的备考建议
刷信奥赛题目的过程中,我发现一个普遍的规律:LCA这种"基础设施型"算法,考场上最怕的不是不会,而是不熟。很多考生其实学过LCA,但拿到题目后要么模板写得很慢,要么在套用的时候因为细节不熟练而出BUG,白白浪费时间调试。
我比较推荐的做法是,把模板代码敲到肌肉记忆的程度。就是那种在考场上不用思考、直接默写出来的水平。要做到这一步,唯一的办法就是高频重复。每天抽出10分钟,手写一遍建图、DFS预处理、查询函数,坚持两周到一个月,基本就能达到这个标准。
另外要注意CSP-S 2025的政策变化和题目风格趋势。从近几年的题目看,树形结构、图论算法的考察比重在增加,单纯套模板的题目在减少,更多是把LCA嵌入一个看似复杂的综合题中。比如给你一棵树,若干次操作,每次修改一个节点的权值,然后询问两个节点路径上的某种统计值,这类题表面上是数据结构题,但拆解之后你会发现LCA就是那座必经的桥。
还有一个容易被忽视的实战技巧:多练习用链式前向星而不是vector存图。虽然vector写法简单,但在频繁插入边的大数据量下,vector的扩容和遍历开销确实会影响性能。链式前向星就是用三个数组head、to、nxt模拟链表的操作,写起来多两三行代码,但胜在稳定高效。我平时练题基本都用链式前向星,已经形成肌肉记忆,考试时也不用临时切换思维。
7. 写在最后的一些实战心得
说实在的,LCA是我觉得CSP-S算法里"投入产出比"最高的一块内容。它不像动态规划那样需要很强的思维建模能力,也不像平衡树那样需要长篇累牍的数据结构功底,它就是一层窗户纸,捅破之后你就能解锁一整个类型的树上问题。我带着不少学生从LCA入门,慢慢过渡到树上差分、树链剖分,他们普遍反馈,理解了倍增之后,再学其他树上的高阶算法,思路都清晰很多。
我个人在实际教学中还有一个偏好,就是让大家把up数组递推那行代码抄下来贴在自己电脑桌面上看几天——up[u][k] = up[up[u][k-1]][k-1]。这行代码浓缩了整个倍增算法的精髓,每次看它都能想起"任意跳转都能拆成幂次步"这个核心思想。你不需要死记硬背,理解透了,考试中哪怕一时想不起来完整代码,顺着这个递推式推理,也能把整块逻辑重建出来。
最后再分享一个小技巧,也是我自己做题时常用的调试方法:遇到LCA相关的题,先用暴力方法写一个朴素LCA做对拍,然后拿随机数据同时跑暴力和倍增版,一旦出现不一致,马上就能定位问题出在预处理还是查询。这种方法比传统的断点调试高效得多。比赛时当然没时间建对拍程序,但平时训练养成这个习惯,考试时你就会自然而然地更谨慎,很多低级错误其实能提前被这个习惯规避掉。