二叉树最近公共祖先(LCA)算法详解与面试应用

📅 发布时间:2026/8/20 2:31:57
二叉树最近公共祖先(LCA)算法详解与面试应用
1. 问题背景与核心概念最近在准备算法面试的同学一定对LeetCode 236题不陌生——二叉树的最近公共祖先。这道题在各大科技公司的面试中出现频率极高据不完全统计在近6个月的面试中考察率超过40%。我当年面试时就曾被这道题卡住后来专门花了三天时间研究了所有可能的解法。所谓最近公共祖先(Lowest Common Ancestor, LCA)指的是二叉树中两个节点p和q最近的共同祖先节点。举个例子假设我们有一个家族树你想知道自己和表妹最近的共同祖先是谁这个问题就类似于在二叉树中寻找LCA。2. 基础解法递归法2.1 递归思路解析最直观的解法是使用递归。算法思路其实很符合直觉如果当前节点是p或q那么它就是LCA分别在左右子树中查找p和q如果p和q分别位于左右子树当前节点就是LCA如果只有一边找到返回找到的那边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 right2.2 时间复杂度分析这个解法的时间复杂度是O(n)因为最坏情况下需要遍历所有节点。空间复杂度是O(h)h是树的高度由递归栈深度决定。注意递归解法虽然简单但在面试中往往会被追问更优的解法所以我们需要掌握其他方法。3. 进阶解法父指针哈希表法3.1 算法原理这个方法分为两个步骤使用DFS遍历树记录每个节点的父节点从p节点开始向上回溯到根节点记录路径从q节点开始向上回溯第一个出现在p路径中的节点就是LCAdef lowestCommonAncestor(root, p, q): parent {root: None} stack [root] 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) ancestors set() while p: ancestors.add(p) p parent[p] while q not in ancestors: q parent[q] return q3.2 适用场景这种方法特别适合需要多次查询LCA的情况因为我们可以预先建立好父指针哈希表后续查询只需要O(h)时间。4. 高阶解法RMQ转化法4.1 欧拉序与RMQ这是比较高级的解法将LCA问题转化为RMQ区间最小值查询问题对树进行欧拉遍历记录访问顺序和深度LCA问题转化为在欧拉序列中找两个节点首次出现位置之间的最小深度节点class Solution: def __init__(self): self.euler [] self.depth [] self.first_occurrence {} def lowestCommonAncestor(self, root, p, q): self.dfs(root, 0) self.build_sparse_table() # 转换为RMQ查询 # ...省略RMQ实现部分... def dfs(self, node, current_depth): if not node: return self.first_occurrence[node.val] len(self.euler) self.euler.append(node.val) self.depth.append(current_depth) # ...继续遍历左右子树...4.2 性能分析预处理时间O(n)查询时间O(1)。适合需要大量查询的场景但实现较为复杂。5. Tarjan离线算法5.1 算法思想Tarjan算法是一种并查集应用的离线算法使用DFS遍历树将已访问但未处理完的节点标记为正在访问处理完的节点使用并查集合并到父节点查询与当前节点相关的所有问题def tarjan_olca(root, queries): # 初始化并查集 parent {node: node for node in tree_nodes} rank {node: 0 for node in tree_nodes} ancestor {} visited set() def find(u): while parent[u] ! u: parent[u] parent[parent[u]] u parent[u] return u def union(u, v): u_root find(u) v_root find(v) if u_root v_root: return if rank[u_root] rank[v_root]: parent[v_root] u_root else: parent[u_root] v_root if rank[u_root] rank[v_root]: rank[v_root] 1 def dfs(node): visited.add(node) ancestor[node] node for child in [node.left, node.right]: if child and child not in visited: dfs(child) union(node, child) ancestor[find(node)] node for v in queries.get(node, []): if v in visited: print(fLCA of {node} and {v} is {ancestor[find(v)]}) dfs(root)5.2 适用场景当需要处理大量离线查询时Tarjan算法非常高效时间复杂度接近O(nα(n))其中α是反阿克曼函数。6. 迭代法使用栈模拟递归6.1 实现思路对于不喜欢递归或者担心栈溢出的情况可以使用显式栈来模拟递归过程def lowestCommonAncestor(root, p, q): stack [root] parent {root: None} 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) ancestors set() while p: ancestors.add(p) p parent[p] while q not in ancestors: q parent[q] return q6.2 性能对比这种方法与递归法时间复杂度相同但避免了递归的系统开销适合深度很大的树。7. 各方法对比与选择建议方法时间复杂度空间复杂度适用场景实现难度递归法O(n)O(h)单次查询树不深★★父指针法O(n)O(n)多次查询★★★RMQ转化O(n)预处理O(1)查询O(nlogn)大量查询★★★★★Tarjan离线O(nα(n))O(n)离线批量查询★★★★迭代法O(n)O(h)避免递归树不深★★★在实际面试中我建议按照以下顺序展示先给出递归解法最简单然后给出父指针法中等难度如果面试官继续追问再讨论RMQ或Tarjan8. 常见错误与调试技巧空指针问题总是检查节点是否为null特别是在处理左右子树时节点相等情况当p就是q的祖先时容易忽略直接返回p的情况重复计算在递归解法中避免对同一子树重复计算路径记录错误在使用父指针法时确保正确记录和比较路径调试时可以构造以下测试用例p或q就是根节点p是q的祖先p和q在不同子树p和q在相同子树树为空或只有一个节点9. 实际工程中的应用虽然LCA问题看起来是纯算法题但在实际工程中有重要应用Git中寻找两个分支的最近共同提交DOM树中寻找两个元素的最近共同容器网络路由中寻找两个节点的最近交汇点生物信息学中寻找物种进化树中的共同祖先理解这些应用场景可以帮助我们在面试中更好地解释算法的实际价值。10. 扩展思考如果问题扩展到N叉树或者需要处理大量查询上述哪些方法仍然适用实际上递归法可以很容易扩展到N叉树父指针法同样适用RMQ方法需要调整欧拉遍历方式Tarjan算法基本保持不变对于超大规模树的处理可以考虑使用分布式算法或者结合树链剖分等高级技巧。