1. 问题背景与定义
最近公共祖先(Lowest Common Ancestor,简称LCA)是二叉树算法中的经典问题。在力扣(LeetCode)题库中,这道题编号为236,被归类为中等难度,但实际考察频率相当高。我刷题时发现,很多面试官特别喜欢用这道题考察候选人对递归和二叉树遍历的理解深度。
所谓"最近公共祖先",指的是在二叉树中两个节点p和q,找到距离它们最近的共同祖先节点。举个例子,假设我们有以下二叉树:
code复制 3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
- 节点5和1的LCA是3
- 节点5和4的LCA是5
- 节点7和8的LCA是3
这个问题看似简单,但实际处理时需要特别注意几种边界情况:
- p或q本身就是LCA的情况(如上面的5和4)
- p是q的祖先或反之的情况
- 树为空或p/q不在树中的情况(虽然力扣原题假设p/q一定在树中)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与优化思路
2.1 朴素解法:记录路径法
最直观的思路是先分别找到从根节点到p和q的路径,然后比较这两条路径,最后一个相同的节点就是LCA。这种方法容易理解,但需要额外的存储空间。
具体实现步骤:
- 从根节点开始深度优先搜索(DFS),记录到p和q的路径
- 比较两条路径,找到最后一个相同的节点
python复制def lowestCommonAncestor(root, p, q):
def getPath(node, target):
path = []
def dfs(node):
if not node:
return False
path.append(node)
if node == target:
return True
if dfs(node.left) or dfs(node.right):
return True
path.pop()
return False
dfs(node)
return path
path_p = getPath(root, p)
path_q = getPath(root, q)
lca = None
for u, v in zip(path_p, path_q):
if u == v:
lca = u
else:
break
return lca
这种方法的时间复杂度是O(n),但需要O(n)的额外空间存储路径。在面试中,面试官通常会期待更优的空间复杂度解法。
2.2 优化方向:后序遍历法
更优雅的解法是利用二叉树的后序遍历特性,在递归过程中同时处理p和q的查找。这种方法不需要存储完整路径,空间复杂度优化到O(1)(不考虑递归栈空间)。
核心观察点:
- 如果一个节点的左右子树分别包含p和q,那么这个节点就是LCA
- 如果只在左子树或右子树找到其中一个节点,那么先找到的那个就是LCA
3. 最优解法实现与解析
3.1 递归解法代码实现
下面是经过优化的递归解法,也是面试中最推荐的写法:
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
这段代码虽然简短,但包含了几个精妙的设计点:
- 基线条件:如果当前节点为空或是p/q之一,直接返回当前节点
- 递归查询左右子树
- 如果左右子树都有返回值,说明当前节点就是LCA
- 否则返回非空的那个子树结果
3.2 时间复杂度分析
该算法的时间复杂度是O(n),其中n是树中节点的数量。在最坏情况下(树退化为链表),空间复杂度也是O(n)(递归栈深度)。
3.3 迭代解法实现
虽然递归解法简洁,但有些面试官会要求用迭代方式实现。我们可以使用后序遍历的迭代版本:
python复制def 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 q
这种方法使用哈希表记录每个节点的父节点,然后通过回溯找到共同祖先。虽然时间复杂度仍是O(n),但实际运行效率可能略低于递归版本。
4. 常见错误与调试技巧
4.1 新手常犯的错误
在实现LCA算法时,我见过(也犯过)以下几种典型错误:
- 忽略节点自身是祖先的情况:比如当p是q的祖先时,应该返回p而不是继续向上查找
- 错误处理递归返回值:没有正确处理左右子树都找到节点的情况
- 边界条件处理不当:比如树为空或p/q不在树中的情况(虽然力扣题目假设存在)
4.2 调试技巧
当你的LCA代码出现问题时,可以尝试以下调试方法:
- 可视化小例子:画一个3-5个节点的小二叉树,手动模拟算法执行过程
- 打印递归路径:在递归函数中添加打印语句,输出当前访问的节点和递归深度
- 单元测试:准备多个测试用例,包括:
- p和q在左右子树
- p是q的祖先
- q是p的祖先
- 树只有两个节点
4.3 一个真实调试案例
我曾经遇到一个bug:当p和q是同一个节点时,算法返回了错误的祖先。经过调试发现是因为基线条件中只检查了root == p or root == q,没有考虑p和q是同一节点的情况。修正方法是确保p和q是不同的节点(根据题目描述)。
5. 算法扩展与变种问题
掌握了基本LCA解法后,可以尝试解决一些变种问题:
5.1 二叉搜索树中的LCA
对于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是树高,空间复杂度是O(1)。
5.2 带父指针的树节点
如果树节点包含指向父节点的指针,问题可以转化为两个链表的交点问题:
- 从p和q分别向上遍历到根节点,记录路径
- 找到两条路径最后一个相同的节点
5.3 多次查询优化
如果需要多次查询不同节点对的LCA,可以使用Tarjan的离线算法或二进制提升法进行预处理,将每次查询时间优化到O(1)。
6. 面试实战建议
在面试中遇到LCA问题时,建议采取以下策略:
- 先确认问题细节:询问面试官p和q是否一定在树中、树是否为BST、节点是否有父指针等
- 从暴力解法开始:先提出路径记录法,分析时间空间复杂度
- 逐步优化:引出递归解法,解释其正确性和复杂度
- 讨论边界情况:主动提出各种边界条件,展示全面思考
- 准备变种问题:如果时间允许,可以讨论BST等变种情况
我在面试候选人时,最看重的不是能否一次性写出最优解,而是:
- 能否清晰地分析问题
- 能否从简单解法自然过渡到优化解法
- 对边界条件的考虑是否全面
- 代码风格是否整洁
7. 力扣刷题进阶路线
如果你想系统性地提升二叉树问题的解决能力,我建议按照以下顺序刷题:
- 基础遍历:144前序、94中序、145后序、102层序
- 递归思维:104最大深度、110平衡二叉树、543直径
- 路径问题:112路径总和、113路径总和II、437路径总和III
- 构造问题:105前序+中序构造、106中序+后序构造
- 祖先问题:236LCA、235BST的LCA
- 序列化:297二叉树的序列化与反序列化
每道题都尝试用递归和迭代两种方式实现,并分析时间空间复杂度。坚持这样训练2-3周,你会发现自己对二叉树问题的解决能力有明显提升。
