☰
LCA最近公共祖先算法详解:倍增法、Tarjan离线与欧拉序RMQ选型指南
2026/10/12 17:17:07 网站建设 项目流程

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)离线查询海量、可离线
欧拉序+STO(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和欧拉序更像是特定场景的优化手段,知道原理、需要时能捡起来就行,不必强求每种都背得滚瓜烂熟。把一种方案写到闭着眼睛都不出错,比每种都半懂不懂要值钱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询