1. 从一次线上故障说起:为什么LCA值得你花时间搞懂
去年帮一个朋友排查他们内部工具的一个性能问题,场景很典型:一个组织架构树,大概三万多节点,每次要计算两个员工在组织树里的"最近共同上级"。他们最初的实现是暴力往上爬——把A的所有祖先塞进一个集合,然后B从自己开始往上走,遇到的第一个在集合里的节点就是答案。单次查询看着不慢,几十毫秒,但架不住前端一个页面要批量算几百对,页面直接卡到转圈。
这个问题的本质就是LCA(Lowest Common Ancestor,最近公共祖先)。听起来像是算法竞赛里的东西,但只要你处理过任何树形结构——组织架构、文件目录、DOM节点、分类体系、依赖关系图——你迟早会撞上它。而且一旦数据量上去,暴力解法就会原形毕露。
这篇内容我打算把LCA这件事从头到尾讲透。不是那种"背个倍增模板就完事"的讲法,而是把为什么会有多种解法、每种解法到底在权衡什么、什么场景该选哪个、写的时候哪里最容易翻车这些真正影响你落地的东西说清楚。适合已经会写基础树遍历、但一遇到"树上路径查询"就发怵的开发者,也适合想系统梳理一遍这块知识的老手。看完你应该能做到:拿到一个问题,先判断它是不是LCA,再根据数据规模和查询次数直接选出合适的方案,而不是无脑套模板。
2. LCA到底在解决什么问题:把"共同祖先"翻译成人话
2.1 用家族树理解最近公共祖先
先把这个概念用最朴素的方式说清楚。想象一棵家族树,根节点是最老的祖先,往下每一层是下一代。现在给你两个人,问:他俩血缘关系最近的那个共同长辈是谁?
注意"最近"两个字是关键。往上数,所有共同的长辈都算公共祖先,但我们要的是**深度最大(离他俩最近)**的那一个。比如你和你堂哥,你们的爷爷是LCA,但曾祖父也是共同祖先,只不过曾祖父更远,不是"最近"的。
形式化一点:给定一棵有根树,对于节点u和v,LCA(u, v)是同时满足"是u的祖先"且"是v的祖先"的节点中,深度最大的那个。这里"祖先"通常包含节点自己——也就是说如果u本身就是v的祖先,那LCA就是u。这个约定很重要,很多实现细节都跟它有关。
2.2 为什么暴力爬树会崩:复杂度账要算清楚
最直觉的做法有两种。第一种是刚才说的集合法:把u到根的路径全标记,然后v往上走找第一个标记点。第二种是先把两个节点提到同一深度,然后一起往上走直到相遇。
这两种做法单次查询的复杂度都是O(树高)。问题就出在这个"树高"上。如果树比较平衡,高度是log级别,那还行;但现实中的树经常退化成一条链——比如一个层层审批的组织、一个深度嵌套的目录、一条长依赖链。这时候树高就是节点数n,单次查询O(n),查询m次就是O(nm)。三万个节点、几百次查询,直接就是千万级操作,卡顿是必然的。
所以LCA的核心矛盾就一句话:能不能把单次查询从O(树高)压到O(log n)甚至O(1),代价是预处理。后面讲的所有算法,本质上都是在这条权衡线上找不同的位置。
2.3 哪些真实场景会用到LCA
别以为这只是刷题用的。我梳理几个实际会碰到的:
- 组织架构/权限系统:判断两个员工的共同上级,用于审批流、权限继承、汇报关系展示。
- 文件系统:求两个路径的最近公共目录,做相对路径计算、文件同步的差异定位。
- 前端DOM操作:找两个节点的最近公共父节点,用于事件委托、选区处理、虚拟DOM diff。
- 版本控制/依赖分析:在提交历史或依赖树里找共同基点,判断分支从哪分叉。
- 生物信息学:进化树里找物种的共同祖先。
- 网络路由:在某些树形拓扑里找共同上游节点。
看出来了吧,只要你的数据是树形(或者能转成树形),LCA就是个绕不开的基础操作。理解了它,等于拿到了一把处理树形查询的万能钥匙。
3. 倍增法:最通用的LCA解法,也是面试最爱问的
3.1 倍增的核心思想:用二进制跳跃代替一步步爬
倍增法(Binary Lifting)是我最推荐的入门方案,因为它通用、好写、好调,而且能顺带解决一堆衍生问题(比如求路径上第k个祖先、路径长度等)。
它的核心思想特别巧妙:与其一步一步往上爬,不如一次跳2的幂次步。就像你要走1000米,不会一米一米挪,而是先走512米、再256米……用二进制拆分快速到达。
具体做法是预处理一个跳表:up[i][j]表示节点i往上跳2^j步后到达的祖先。这个表可以递推出来:
up[i][0] = parent[i] // 跳1步就是父节点 up[i][j] = up[ up[i][j-1] ][j-1] // 跳2^j步 = 先跳2^(j-1)再跳2^(j-1)第二行的递推是整个算法的灵魂。它把"跳一大步"拆成"跳两小步",从而能用已经算好的小步结果推出大步结果。这就是典型的动态规划思想,也是倍增法名字的由来。
3.2 预处理:一遍DFS把跳表建起来
预处理分两步。第一步是DFS/BFS求出每个节点的深度depth[i]和直接父节点parent[i]。第二步就是按上面的递推式填up表。
关于j的上界,取LOG = ceil(log2(n)) + 1就够了。因为树最高n层,任何跳跃需求都能用不超过log2(n)位的二进制表示。多留一位是防止边界越界。
import sys sys.setrecursionlimit(300000) def preprocess(n, adj, root): LOG = (n).bit_length() # 等价于 ceil(log2(n+1)) depth = [0] * (n + 1) up = [[0] * LOG for _ in range(n + 1)] # 迭代式DFS,避免递归爆栈 parent = [0] * (n + 1) order = [] stack = [root] visited = [False] * (n + 1) visited[root] = True while stack: u = stack.pop() order.append(u) for v in adj[u]: if not visited[v]: visited[v] = True parent[v] = u depth[v] = depth[u] + 1 stack.append(v) # 填跳表 for i in range(1, n + 1): up[i][0] = parent[i] for j in range(1, LOG): for i in range(1, n + 1): up[i][j] = up[ up[i][j-1] ][j-1] return depth, up, LOG这里有个实操细节值得说:我强烈建议用迭代式DFS而不是递归。递归写起来是简洁,但树一深(比如退化成链),Python默认递归深度1000直接爆栈,C++虽然栈大一些但几万层也悬。迭代式虽然代码长一点,但稳。这个坑我踩过不止一次,线上环境可不会给你调递归深度的机会。
3.3 查询:先对齐深度,再一起往上跳
查询LCA(u, v)分两个阶段,逻辑非常清晰:
阶段一:把深的那个提到和浅的一样深。假设depth[u] > depth[v],那就把u往上跳depth[u] - depth[v]步。这个差值用二进制拆解,对应到跳表上就是:如果差值的第j位是1,就跳2^j步。
阶段二:如果此时u == v,说明v本来就是u的祖先,直接返回。否则两个节点一起往上跳,从大到小尝试。关键技巧是:只要跳完之后两个节点还不相等,就跳。这样最后一定会停在LCA的正下方,再往上一步就是答案。
def lca(u, v, depth, up, LOG): if depth[u] < depth[v]: u, v = v, u # 阶段一:对齐深度 diff = depth[u] - depth[v] for j in range(LOG): if diff & (1 << j): u = up[u][j] if u == v: return u # 阶段二:一起往上跳 for j in range(LOG - 1, -1, -1): if up[u][j] != up[v][j]: u = up[u][j] v = up[v][j] return up[u][0]阶段二那个"从大到小"的循环方向特别重要,必须从高位往低位试。因为我们要尽可能跳得远,但又不能跳过LCA。从大往小试,能保证最后停在最近的位置。如果从小到大试,会跳得不够远,逻辑就错了。这个细节很多人第一次写会搞反。
3.4 倍增法的复杂度与适用边界
预处理时间O(n log n),空间O(n log n),单次查询O(log n)。这个复杂度对于绝大多数场景都够用了。
它的优势是通用性极强:不要求树是静态的(虽然动态更新跳表比较麻烦),能处理任意形态的树,还能顺便支持"求第k个祖先"这类操作。缺点是空间是n log n,如果n到了百万级,内存会有点吃紧。
提示:如果n特别大(比如10^6以上)而查询次数不多,其实暴力法反而更划算。别迷信"高级算法",选型要看具体数据规模。
4. Tarjan离线算法:查询多、树不动时的效率之王
4.1 离线思维:把所有查询攒起来一起算
倍增法是"在线"的——来一个查询答一个。但如果你的场景是一次性拿到所有查询,然后批量出结果,那Tarjan离线算法往往更快。
Tarjan的核心是DFS + 并查集。它利用DFS的回溯特性,在遍历树的过程中顺便把所有查询都解决了。单次查询的均摊复杂度接近O(1),整体是O(n + m·α(n)),其中α是并查集的反阿克曼函数,实际可以当成常数。
这个思路的巧妙之处在于:它把"查询"这件事融进了"遍历"里,而不是遍历完再单独查。对于查询量巨大的场景(比如m和n同量级甚至更大),这个优势非常明显。
4.2 并查集在这里扮演什么角色
理解Tarjan的关键,是搞懂并查集维护的是什么。
在DFS过程中,每个节点有三种状态:未访问、正在访问(在递归栈里)、已访问完(子树处理完毕)。并查集维护的是"已访问完的节点"的集合,并且每个集合的代表元指向它当前能追溯到的、还在递归栈里的那个祖先。
当DFS访问到节点u,处理所有跟u相关的查询(u, v)时:
- 如果v还没访问,跳过,等以后v被访问时再处理这对查询。
- 如果v已经访问完了,那么v所在并查集集合的代表元,就是LCA(u, v)。
为什么?因为v已经处理完,说明v的整棵子树都遍历过了,而u还在栈里,说明u是v的某个祖先方向上的节点。此时v能追溯到的、还在栈里的最深祖先,恰好就是它和u的分叉点,也就是LCA。
4.3 完整实现与关键细节
def tarjan_lca(n, adj, root, queries): # queries[u] = [(v, query_id), ...] parent = list(range(n + 1)) # 并查集 ancestor = [0] * (n + 1) # 集合代表元对应的祖先 visited = [False] * (n + 1) answer = [0] * len(queries) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] # 路径压缩 x = parent[x] return x def dfs(u): visited[u] = True ancestor[find(u)] = u for v in adj[u]: if not visited[v]: dfs(v) parent[find(v)] = find(u) # 合并子树到u ancestor[find(u)] = u # 处理与u相关的查询 for (v, qid) in queries[u]: if visited[v]: answer[qid] = ancestor[find(v)] dfs(root) return answer几个容易翻车的点:
第一,ancestor的更新时机。每次合并完子树后,要把合并后集合的ancestor重新指向u。因为合并后代表元可能变了,不更新就会拿到错误的祖先。
第二,查询要存双向。因为处理(u,v)时可能v还没访问,得等v被访问时从v那边再处理一次。所以queries[u]和queries[v]都要存这条查询。
第三,递归深度问题。跟倍增法一样,深树会爆栈。生产环境建议改迭代或者手动加大栈空间。
4.4 什么时候该选Tarjan而不是倍增
判断标准很简单:
| 维度 | 倍增法 | Tarjan离线 |
|---|---|---|
| 查询模式 | 在线,来一个答一个 | 离线,必须预先知道所有查询 |
| 单次查询 | O(log n) | 均摊O(α(n))≈O(1) |
| 预处理 | O(n log n) | 无独立预处理 |
| 空间 | O(n log n) | O(n + m) |
| 实现难度 | 中等 | 中等偏上(并查集易错) |
| 适用场景 | 查询少、需在线 | 查询海量、可离线 |
如果你的查询次数m远大于n,Tarjan的均摊优势会非常明显。但如果查询是流式的、必须即时回答,那只能选倍增。选型的第一原则永远是先搞清楚你的查询模式。
5. 欧拉序 + RMQ:把LCA变成区间最值问题
5.1 一个漂亮的转化:LCA等价于区间最小值
这是我觉得LCA里最优雅的一个思路。它把树上问题转化成了数组上的区间查询问题。
做法是:对树做一次DFS,记录欧拉序(也叫DFS序的扩展版)——每次进入一个节点记一次,每次从子树返回父节点时也记一次。这样得到的序列长度是2n-1。同时记录每个节点在序列中第一次出现的位置first[u]。
关键结论:LCA(u, v)就是欧拉序中,first[u]到first[v]这段区间里深度最小的那个节点。
为什么?因为从u走到v,在欧拉序上必然经过它们的LCA,而且LCA是这段路径上深度最小的点。这个性质把LCA问题完美地转化成了**RMQ(区间最小值查询)**问题。
5.2 稀疏表(ST表)实现O(1)查询
既然变成了RMQ,那就可以用经典的ST表(Sparse Table)来做到O(1)查询。ST表的预处理是O(n log n),查询O(1)。
import math def build_euler(n, adj, root): euler = [] # 欧拉序,存节点 depth_arr = [] # 对应深度 first = [0] * (n + 1) visited = [False] * (n + 1) def dfs(u, d): visited[u] = True first[u] = len(euler) euler.append(u) depth_arr.append(d) for v in adj[u]: if not visited[v]: dfs(v, d + 1) euler.append(u) depth_arr.append(d) dfs(root, 0) return euler, depth_arr, first def build_st(depth_arr): m = len(depth_arr) LOG = m.bit_length() # st[i][j] 存区间 [i, i+2^j-1] 中深度最小的节点在depth_arr中的下标 st = [[0] * LOG for _ in range(m)] for i in range(m): st[i][0] = i for j in range(1, LOG): for i in range(m - (1 << j) + 1): left = st[i][j-1] right = st[i + (1 << (j-1))][j-1] st[i][j] = left if depth_arr[left] <= depth_arr[right] else right return st def query_lca(u, v, first, depth_arr, st, euler): l, r = first[u], first[v] if l > r: l, r = r, l length = r - l + 1 k = length.bit_length() - 1 left = st[l][k] right = st[r - (1 << k) + 1][k] idx = left if depth_arr[left] <= depth_arr[right] else right return euler[idx]5.3 欧拉序方案的取舍
这个方案的优势是查询O(1),比倍增的O(log n)还快。而且思路清晰,一旦理解了欧拉序的性质,代码其实不难写。
但它有几个明显的代价:
- 空间更大:欧拉序长度是2n-1,ST表又是(2n) log(2n),比倍增的n log n大一倍左右。
- 预处理常数大:建ST表的双重循环,实际跑起来比倍增的预处理慢。
- 只适合静态树:树一变,整个欧拉序和ST表都要重建。
所以我的经验是:如果查询量极大且树完全静态,欧拉序+ST表是最优解;否则倍增法更省心。别为了那点查询速度提升,把代码复杂度拉高一大截,维护成本也是成本。
注意:欧拉序的DFS同样有递归深度问题。而且欧拉序长度是2n,如果n很大,光是存这个序列就要不少内存,选型前先估一下。
6. 四种解法横向对比:一张表帮你做选型决策
前面讲了四种主流方案,这里做个系统对比,方便你直接查表决策。
| 方案 | 预处理 | 单次查询 | 空间 | 在线/离线 | 最适合的场景 |
|---|---|---|---|---|---|
| 暴力爬树 | O(1) | O(树高) | O(n) | 在线 | 树浅、查询极少 |
| 倍增法 | O(n log n) | O(log n) | O(n log n) | 在线 | 通用首选,查询中等 |
| Tarjan离线 | 无 | 均摊O(α(n)) | O(n+m) | 离线 | 查询海量、可离线 |
| 欧拉序+ST | O(n log n) | O(1) | O(n log n) | 在线 | 查询极多、树静态 |
选型的决策链路我一般是这么走的:
第一步,看查询模式。流式在线查询,排除Tarjan;能批量离线,Tarjan值得考虑。
第二步,看数据规模。n小(几千以内)且查询少,暴力法最省事,别过度设计。n大且查询多,上倍增或欧拉序。
第三步,看树是否静态。树会动态变化,倍增法改起来相对可控(虽然也麻烦),欧拉序基本要重建。
第四步,看查询次数。查询次数远超n,欧拉序的O(1)优势才体现得出来;否则倍增的O(log n)完全够用。
我见过太多人一上来就写欧拉序+ST表,结果数据规模才几千,纯属杀鸡用牛刀,代码还容易写错。选型要匹配问题规模,这是工程思维和刷题思维最大的区别。
7. 实战中那些文档不会告诉你的坑
7.1 根节点的父节点处理
这是最经典的坑。根节点没有父节点,那up[root][0]填什么?填0(假设节点编号从1开始)还是填自己?
填0的话,如果查询时不小心跳到了0,后续up[0][j]全是0,可能掩盖逻辑错误。填自己的话,跳过头会原地打转。我的建议是填0,并且在查询逻辑里保证永远不会真正用到0这个越界值——因为对齐深度和一起跳的过程,理论上不会让节点跳到根之上。但为了保险,可以在填表时把up[0][j]都设成0,让越界访问有个确定的兜底。
7.2 深度对齐时的循环方向
阶段一对齐深度时,for j in range(LOG)从小到大还是从大到小?其实都可以,因为我们是按二进制位判断的,每一位独立。但阶段二一起跳的时候,必须从大到小。这两个循环方向不一样,很多人写着写着就混了。记住口诀:对齐深度看位,一起跳看大小。
7.3 多组测试数据的初始化
如果是在竞赛或批量处理场景,多组测试数据之间一定要彻底重置所有数组。我见过因为up表没清干净,上一组数据残留导致答案错误的案例。Python里重新分配数组最省心,C++里记得memset或者重新构造。
7.4 节点编号从0还是从1开始
这个看似小事,实则影响巨大。从1开始的话,0可以当哨兵;从0开始的话,就没有天然的哨兵值了。我个人的习惯是统一从1开始编号,把0留作哨兵,能省掉很多边界判断。如果输入是从0开始的,读入时统一加1。
7.5 递归改迭代的通用套路
前面反复提到递归爆栈。改迭代的通用做法是手动维护一个栈,栈里存(节点, 父节点, 状态),状态标记是"第一次访问"还是"回溯"。或者更简单:先用BFS求出遍历顺序和父节点,再按逆序处理回溯逻辑。对于倍增法,其实只需要父节点和深度,BFS一遍就够了,根本不需要DFS。这个优化能同时解决爆栈和效率问题。
8. 从LCA延伸出去:几个高频衍生问题
搞懂了LCA,很多树上查询问题都能顺手解决。这里列几个最常见的延伸,算是给你指个方向。
树上两点距离。有了LCA,距离公式就是depth[u] + depth[v] - 2 * depth[lca(u,v)]。这个公式极其常用,路径长度、树上最远点对之类的问题都靠它。
树上路径第k个节点。从u到v的路径上第k个点,先判断k是否超过u到LCA的距离,分两种情况用倍增跳。这是倍增法跳表能力的直接应用。
判断一个点是否在两点路径上。点w在u-v路径上,当且仅当lca(u,w)==w且lca(w,v)==w(w是u的祖先且是v的祖先方向),或者更准确地用距离公式验证。这类判断在路径覆盖、树上差分里很常见。
树上差分。给u到v的路径上所有点加一个值,可以用差分:diff[u] += 1, diff[v] += 1, diff[lca] -= 1, diff[parent(lca)] -= 1,最后子树求和。这个技巧配合LCA能解决一大批路径统计问题。
这些延伸的共同点是:LCA是它们的地基。地基打牢了,上面的楼怎么盖都行。所以别急着刷各种花哨的树上问题,先把LCA这几种解法吃透,后面会轻松很多。
我个人在实际项目里的体会是,真正高频用到的其实就是倍增法这一种,因为它通用、在线、好维护。Tarjan和欧拉序更像是特定场景的优化手段,知道原理、需要时能捡起来就行,不必强求每种都背得滚瓜烂熟。把一种方案写到闭着眼睛都不出错,比每种都半懂不懂要值钱得多。