1. 问题背景与核心概念
二叉树的最近公共祖先(Lowest Common Ancestor, LCA)问题是算法面试中的经典题型,在LeetCode题库中编号为236题。我第一次遇到这个问题是在准备某大厂技术面试时,面试官要求我在白板上15分钟内完成编码和解释。当时虽然勉强给出了解法,但在时间复杂度分析上栽了跟头——这正是这个题目精妙之处:表面看似简单,实则暗藏多个考察维度。
最近公共祖先的正式定义是:对于有根二叉树中的两个节点p和q,LCA是同时为p和q祖先的最深节点(允许节点成为自身的祖先)。举个例子,假设我们有以下二叉树:
code复制 3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
- 节点5和1的LCA是3
- 节点5和4的LCA是5本身
- 节点6和8的LCA是3
- 节点7和4的LCA是2
这个问题之所以被频繁考察,是因为它完美融合了以下几个关键知识点:
- 二叉树的基本遍历方法(前序、中序、后序)
- 递归算法的设计与分析
- 树形结构中的路径追踪
- 时间/空间复杂度的计算技巧
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与初步优化
2.1 路径记录法
最直观的解法是分别记录从根节点到p和q的路径,然后比较两条路径找到最后一个公共节点。这个方法虽然容易理解,但在实际实现时需要特别注意几个细节:
python复制def lowestCommonAncestor(root, p, q):
def get_path(node, target):
path = []
def dfs(n):
if not n:
return False
path.append(n)
if n == target:
return True
if dfs(n.left) or dfs(n.right):
return True
path.pop()
return False
dfs(node)
return path
path_p = get_path(root, p)
path_q = get_path(root, q)
lca = None
for a, b in zip(path_p, path_q):
if a == b:
lca = a
else:
break
return lca
这个方法的时间复杂度是O(n)(每个节点最多被访问两次),空间复杂度也是O(n)(存储两条路径)。虽然能通过LeetCode测试,但在面试中可能会被追问是否有更优解。
2.2 空间复杂度优化
仔细观察会发现,我们其实不需要存储完整路径。在DFS过程中,当首次遇到p或q时,可以立即开始记录潜在的LCA。这种方法被称为"相遇法",可以将空间复杂度优化到O(1)(不考虑递归栈空间):
python复制def lowestCommonAncestor(root, p, q):
self.result = None
def dfs(node):
if not node:
return False
left = dfs(node.left)
right = dfs(node.right)
mid = node == p or node == q
if mid + left + right >= 2:
self.result = node
return mid or left or right
dfs(root)
return self.result
这个版本的精妙之处在于:
- 利用布尔值的加法统计匹配次数
- 当在某个节点发现两个目标节点分布在左右子树时立即记录结果
- 通过返回值向上传递是否找到目标节点的信息
3. 递归解法的深入剖析
3.1 后序遍历的关键作用
上述优化解法本质上是一种后序遍历(左右根顺序)的应用。后序遍历在解决树形结构问题时特别有用,因为它允许我们先处理子节点再处理父节点,这种自底向上的特性非常适合需要汇总子树信息的场景。
在LCA问题中,后序遍历让我们能够:
- 先检查左右子树是否包含目标节点
- 根据子树返回的信息判断当前节点是否为LCA
- 将结果向上传递
3.2 递归终止条件分析
正确的递归终止条件对算法正确性至关重要。在这个问题中,我们需要考虑三种基本情况:
- 当前节点为空:返回False表示未找到
- 当前节点就是p或q:标记为True
- 在某个节点发现左右子树各有一个目标节点:立即设置结果为该节点
注意:一旦找到结果就应该立即返回,避免不必要的计算。但在实际实现中,由于Python的递归特性,我们可以通过类变量来存储结果,而不需要提前终止递归。
3.3 时间复杂度证明
虽然看起来每个节点都被访问了一次,但严格的时间复杂度分析需要考虑最坏情况。对于n个节点的二叉树:
- 最好情况:O(1)(当p或q就是根节点时)
- 最坏情况:O(n)(当p和q都在最深层且分别位于最左和最右时)
- 平均情况:O(n)
空间复杂度取决于递归深度:
- 平衡二叉树:O(log n)
- 退化成链表:O(n)
4. 迭代解法与Morris遍历
4.1 使用父指针的迭代方法
对于不喜欢递归或者担心栈溢出的场景,我们可以用迭代法配合哈希表记录父指针:
python复制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 q
这种方法虽然空间复杂度仍是O(n),但避免了递归带来的潜在问题,适合处理特别深的树结构。
4.2 Morris遍历的优化思路
对于追求极致空间复杂度的场景,可以考虑使用Morris遍历(线索二叉树),将空间复杂度降到O(1)。不过实现较为复杂,在面试中通常不需要展示,但了解这个思路可以体现对算法的深入理解。
Morris遍历的核心思想是利用叶子节点的空指针指向中序遍历的前驱或后继节点,从而在不使用栈或递归的情况下完成遍历。应用到LCA问题时,需要同时跟踪p和q的位置信息。
5. 常见错误与边界情况
5.1 空树或无效输入
总有一些候选人会忽略基本的防御性编程:
python复制if not root or not p or not q:
return None
虽然LeetCode的测试用例通常保证输入有效,但在实际工程中这种检查是必要的。
5.2 节点不在树中
原题假设p和q都在树中,但如果考虑更一般的情况,我们需要额外处理:
python复制def lowestCommonAncestor(root, p, q):
self.found_p = False
self.found_q = False
result = self.findLCA(root, p, q)
return result if self.found_p and self.found_q else None
def findLCA(self, node, p, q):
if not node:
return None
left = self.findLCA(node.left, p, q)
right = self.findLCA(node.right, p, q)
if node == p:
self.found_p = True
return node
if node == q:
self.found_q = True
return node
if left and right:
return node
return left or right
5.3 重复计算问题
在递归解法中,有些候选人会写出这样的代码:
python复制def dfs(node):
if not node:
return False
if dfs(node.left) and dfs(node.right):
self.result = node
return node == p or node == q
这种写法会导致重复计算,因为当左子树返回True时,右子树仍然会被完整遍历,造成不必要的性能损耗。
6. 变种问题与扩展思考
6.1 二叉搜索树的LCA
对于BST,我们可以利用其有序特性进一步优化:
python复制def lowestCommonAncestor(root, p, q):
while root:
if root.val > p.val and root.val > q.val:
root = root.left
elif root.val < p.val and root.val < q.val:
root = root.right
else:
return root
这个解法的时间复杂度是O(h),空间复杂度是O(1),其中h是树的高度。
6.2 多叉树的LCA
对于多叉树,递归解法的思路基本不变,只需要遍历所有子节点即可:
python复制def lowestCommonAncestor(root, p, q):
self.result = None
def dfs(node):
count = 0
if node == p or node == q:
count += 1
for child in node.children:
count += dfs(child)
if count > 1:
break
if count == 2 and not self.result:
self.result = node
return count
dfs(root)
return self.result
6.3 带父指针的树节点
如果树节点包含指向父节点的指针,问题就转化为两个链表的交点查找:
python复制def lowestCommonAncestor(p, q):
a, b = p, q
while a != b:
a = a.parent if a else q
b = b.parent if b else p
return a
这种解法被称为"双指针法",时间复杂度O(n),空间复杂度O(1)。
7. 实际工程中的应用场景
虽然LCA问题看起来像是纯粹的算法题,但它在实际工程中有多种应用:
- 版本控制系统:Git等工具在合并分支时需要找到两个提交的共同祖先
- DOM树操作:浏览器需要计算两个HTML元素的最近共同容器来处理事件冒泡
- 网络路由:在计算机网络中确定两个节点的最近交汇点
- 组织架构:查找公司中两个员工的最近共同上级
理解LCA算法有助于我们在面对这些实际问题时快速找到解决方案。例如,在实现一个简单的DOM diff算法时,找到两个节点的LCA可以帮助确定最小的更新范围。
8. 刷题建议与学习路径
根据我参加多次算法面试和面试他人的经验,针对二叉树类题目,我建议的学习路径是:
-
基础阶段:
- 掌握三种基本遍历(前序、中序、后序)的递归和迭代写法
- 理解DFS和BFS在树结构中的应用
- 完成LeetCode简单难度的二叉树题目(如104、110、112题)
-
进阶阶段:
- 熟练解决中等难度二叉树问题(如本问题的236题,以及105、114题)
- 理解递归算法的时空复杂度分析
- 掌握常见优化技巧(如剪枝、记忆化)
-
高手阶段:
- 解决困难二叉树题目(如124、297题)
- 掌握Morris遍历等高级技巧
- 能够灵活处理各种二叉树变种(BST、平衡树、线索树等)
对于LCA问题本身,我建议按以下步骤练习:
- 先实现暴力解法,确保理解问题本质
- 优化到单次遍历解法,理解递归的巧妙之处
- 尝试迭代解法,掌握栈的应用
- 思考各种边界情况和变种问题
记住,在面试中遇到这个问题时,沟通比立即写出完美代码更重要。建议按照以下流程:
- 明确问题要求和边界条件
- 举例说明理解是否正确
- 提出暴力解法并分析复杂度
- 逐步优化,解释每个优化步骤的考虑
- 最后讨论可能的变种和实际应用
