1. 问题背景与定义
最近在准备算法面试的过程中,我发现二叉树的最近公共祖先(Lowest Common Ancestor, LCA)问题在各大公司的技术面试中出现频率极高。这道题看似简单,但想要给出最优解并解释清楚背后的算法思想并不容易。今天我就来详细剖析LeetCode第236题,分享五种不同的实现方案,从基础到进阶,帮助大家彻底掌握这个经典问题。
首先明确什么是二叉树的最近公共祖先:对于有根二叉树中的两个节点p和q,最近公共祖先x是同时是p和q祖先的节点中深度最大的那个节点(一个节点也可以是它自己的祖先)。举个例子,在下图二叉树中:
code复制 3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
- 节点5和1的LCA是3
- 节点5和4的LCA是5
- 节点6和8的LCA是3
这个问题在实际开发中有很多应用场景,比如在版本控制系统中查找两个提交的共同父节点,在DOM树中查找两个元素的最近共同容器等。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础解法:递归搜索
2.1 递归思路分析
最直观的解法是通过递归遍历二叉树来寻找LCA。基本思路是:
- 如果当前节点是p或q中的一个,直接返回当前节点
- 分别在左右子树中递归查找p和q
- 如果左右子树都找到了节点(即返回值都不为null),说明当前节点就是LCA
- 如果只有一边找到了节点,返回该边的结果
这种方法的正确性基于二叉树的性质:如果一个节点在左子树中找到,另一个在右子树中找到,那么当前根节点必然是它们的LCA。
2.2 代码实现与复杂度
python复制def lowestCommonAncestor(root, p, q):
if not root or root == p or root == q:
return root
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
if left and right:
return root
return left if left else right
时间复杂度:O(n),每个节点最多被访问一次
空间复杂度:O(h),h为树的高度,递归栈的深度
2.3 边界情况与注意事项
- 当p或q不存在于树中时,这个实现会返回存在的那个节点或None。面试时需要与面试官确认是否需要处理这种情况。
- 对于非常大的树,递归可能导致栈溢出,可以考虑使用迭代方式实现。
- 实际测试时要注意p或q就是根节点的情况。
3. 迭代解法:使用父指针
3.1 迭代思路分析
虽然递归解法简洁,但在实际工程中,我们可能更倾向于使用迭代解法以避免栈溢出风险。迭代解法的核心思想是:
- 使用栈或队列进行遍历,记录每个节点的父节点
- 找到p和q后,回溯它们的祖先路径
- 找到两条路径的第一个交点即为LCA
这种方法更接近我们人工查找LCA的思路:先找到两个节点,然后向上找它们的共同祖先。
3.2 代码实现与优化
python复制def lowestCommonAncestor(root, p, q):
stack = [root]
parent = {root: None}
# 遍历直到找到p和q
while p not in parent or q not in parent:
node = stack.pop()
if node.left:
parent[node.left] = node
stack.append(node.left)
if node.right:
parent[node.right] = node
stack.append(node.right)
# 收集p的祖先路径
ancestors = set()
while p:
ancestors.add(p)
p = parent[p]
# 查找q的路径中第一个出现在p祖先集合中的节点
while q not in ancestors:
q = parent[q]
return q
时间复杂度:O(n)
空间复杂度:O(n),需要存储所有节点的父指针
3.3 性能对比与适用场景
与递归方法相比:
- 迭代方法避免了递归栈的开销,适合深度很大的树
- 需要额外的空间存储父指针
- 代码稍复杂但更易理解执行过程
适合场景:树深度不可预测或非常大的情况下优先使用迭代方法。
4. RMQ转化解法
4.1 RMQ问题简介
RMQ(Range Minimum Query)是指在一个数组区间内查询最小值的问题。我们可以将LCA问题转化为RMQ问题来解决,具体步骤如下:
- 对二叉树进行欧拉遍历(记录每次访问节点时的深度)
- 构建深度数组对应的RMQ结构
- 查询p和q首次出现位置之间的最小深度节点
这种方法的优势在于经过O(n)预处理后,可以在O(1)时间内回答每次LCA查询。
4.2 欧拉遍历与实现
欧拉遍历是在DFS过程中记录每次"访问"节点的情况(进入和离开都记录)。例如对于下面的树:
code复制 A
/ \
B C
欧拉遍历路径为:A→B→A→C→A,对应的深度序列为:1→2→1→2→1
实现代码:
python复制def euler_tour(root):
tour = []
depth = []
stack = [(root, 0, True)]
while stack:
node, d, is_first_visit = stack.pop()
tour.append(node)
depth.append(d)
if is_first_visit:
# 逆序压栈保证处理顺序正确
stack.append((node, d, False))
if node.right:
stack.append((node.right, d+1, True))
if node.left:
stack.append((node.left, d+1, True))
return tour, depth
4.3 RMQ结构构建与查询
常用的RMQ实现有稀疏表(Sparse Table)和线段树。这里展示稀疏表实现:
python复制class SparseTable:
def __init__(self, data):
self.n = len(data)
self.k = self.n.bit_length()
self.st = [[0]*(self.n - (1<<i)+1) for i in range(self.k)]
self.st[0] = data.copy()
for i in range(1, self.k):
for j in range(self.n - (1<<i)+1):
self.st[i][j] = min(self.st[i-1][j], self.st[i-1][j+(1<<(i-1))])
def query(self, l, r):
length = r - l + 1
k = length.bit_length() - 1
return min(self.st[k][l], self.st[k][r-(1<<k)+1])
4.4 完整LCA实现
python复制class LCA:
def __init__(self, root):
self.tour, self.depth = euler_tour(root)
self.first_occurrence = {node: i for i, node in enumerate(self.tour)}
self.rmq = SparseTable(self.depth)
def query(self, p, q):
l = self.first_occurrence[p]
r = self.first_occurrence[q]
if l > r:
l, r = r, l
min_depth_index = self.rmq.query(l, r)
return self.tour[min_depth_index]
时间复杂度:预处理O(n log n),查询O(1)
空间复杂度:O(n log n)
5. Tarjan离线算法
5.1 算法思想
Tarjan算法是一种基于并查集的离线算法,可以在一次遍历中回答所有查询。核心思想是:
- 使用DFS遍历树
- 对遍历到的节点u,处理所有与u相关的查询(u,v)
- 如果v已经被访问过,则v的当前祖先就是u和v的LCA
- 使用并查集来维护节点的祖先关系
5.2 并查集实现
python复制class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, x, y):
x_root = self.find(x)
y_root = self.find(y)
if x_root != y_root:
self.parent[y_root] = x_root
5.3 Tarjan算法实现
python复制def tarjan_offline_lca(root, queries):
ancestor = {}
visited = set()
result = {}
uf = UnionFind(len(get_all_nodes(root)))
node_to_id = {node: i for i, node in enumerate(get_all_nodes(root))}
def dfs(node):
ancestor[node] = node
for child in [node.left, node.right]:
if child:
dfs(child)
uf.union(node_to_id[node], node_to_id[child])
ancestor[uf.find(node_to_id[node])] = node
visited.add(node)
for v in queries.get(node, []):
if v in visited:
result[(node, v)] = ancestor[uf.find(node_to_id[v])]
dfs(root)
return result
5.4 算法分析
时间复杂度:O(n + mα(n)),其中m是查询次数,α是反阿克曼函数
空间复杂度:O(n)
优势:适合需要处理大量查询的场景,所有查询可以在一次遍历中完成。
6. 其他实现方案与对比
6.1 二叉搜索树特例
如果树是二叉搜索树(BST),问题可以简化:
python复制def lowestCommonAncestor(root, p, q):
while root:
if p.val < root.val and q.val < root.val:
root = root.left
elif p.val > root.val and q.val > root.val:
root = root.right
else:
return root
时间复杂度:O(h),h为树高
6.2 五种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 特点 | 适用场景 |
|---|---|---|---|---|
| 递归 | O(n) | O(h) | 实现简单 | 一般情况 |
| 迭代+父指针 | O(n) | O(n) | 避免递归 | 树深度大 |
| RMQ转化 | O(n log n)预处理, O(1)查询 | O(n log n) | 查询快 | 多次查询 |
| Tarjan离线 | O(n + mα(n)) | O(n) | 批量处理 | 大量查询 |
| BST特例 | O(h) | O(1) | 仅限BST | BST情况 |
6.3 面试中的选择建议
在面试场景中:
- 首先实现递归解法,这是面试官最期望看到的
- 如果被要求优化,可以讨论迭代解法
- 如果被问及更高效的实现,可以介绍RMQ或Tarjan算法
- 注意分析时间/空间复杂度,解释清楚trade-off
在实际工程中:
- 单次查询:递归或迭代
- 多次查询:RMQ或Tarjan
- 已知是BST:使用BST特例解法
7. 常见错误与调试技巧
7.1 常见错误模式
-
忽略p或q就是LCA的情况:
- 例如p是q的祖先,应该返回p而不是继续搜索
-
未处理节点不存在的情况:
- 题目通常假设p和q都在树中,但实际面试可能需要处理
-
递归终止条件不完整:
- 必须包含root == p或root == q的情况
-
父指针迭代法中:
- 忘记将根节点的父指针设为None
- 在构建祖先集合时形成无限循环
7.2 调试技巧
- 可视化小例子:
python复制# 构建示例树
root = TreeNode(3)
root.left = TreeNode(5)
root.right = TreeNode(1)
# ... 继续构建其他节点
- 打印中间结果:
python复制print(f"访问节点 {root.val}, left={left.val if left else None}, right={right.val if right else None}")
-
使用LeetCode的二叉树可视化工具:
- 可以直观看到树结构
- 方便验证p和q的位置关系
-
边界测试用例:
- p或q是根节点
- p是q的父节点
- 树退化为链表
- 空树
7.3 性能优化技巧
-
对于递归解法:
- 添加备忘录可以避免重复计算
- 尾递归优化(如果语言支持)
-
对于迭代解法:
- 可以同时搜索p和q,找到后立即终止遍历
- 使用双向BFS优化祖先路径查找
-
对于RMQ实现:
- 根据场景选择适当的RMQ实现(稀疏表/线段树)
- 考虑内存占用与查询速度的平衡
8. 实际应用与扩展
8.1 实际应用场景
-
版本控制系统:
- Git中查找两个提交的共同祖先
- 解决合并冲突时确定修改基点
-
DOM树操作:
- 查找两个HTML元素的最近共同容器
- 事件委托时确定事件处理位置
-
家谱分析:
- 查找两个人的最近共同祖先
- 计算亲缘关系距离
-
网络路由:
- 查找两个节点的最近交汇点
- 确定最优数据传输路径
8.2 问题变种与扩展
-
带父指针的二叉树:
- 可以直接向上遍历,问题简化为链表交点问题
-
N叉树的LCA:
- 递归解法直接扩展
- 迭代解法需要处理多个子节点
-
多次查询场景:
- 使用RMQ或Tarjan算法优化
- 考虑预处理开销与查询频率的平衡
-
动态树结构:
- 树结构可能变化,需要支持动态LCA查询
- 可以使用Link-Cut Tree等高级数据结构
8.3 进阶学习建议
-
图论中的LCA:
- 将问题扩展到一般图中的最近公共祖先
- 需要先进行DAG的拓扑排序
-
区间最值与LCA的关系:
- 深入理解RMQ与LCA的相互转化
- 学习笛卡尔树的相关知识
-
并行算法:
- 研究多线程环境下的LCA计算
- 考虑MapReduce等分布式算法
-
实际工程实现:
- 阅读Git等开源项目中LCA的实现
- 分析不同场景下的性能表现
