☰
深入理解二叉树最近公共祖先:递归原理、调试技巧与工程应用
2026/10/6 9:54:43 网站建设 项目流程

Hot 100里有一道题,我前后刷了三遍才真正吃透,就是236.二叉树的最近公共祖先。题目难度标的是中等,但在大厂面试里出现频率非常高,因为它同时考三样东西:对二叉树结构的理解、递归调用栈的把握、以及边界条件的处理。很多人递归背下来了,换一道变体就懵;也有人代码写出来了,一追问原理就露馅。这篇文章就围绕二叉树的最近公共祖先(LCA)问题,把题目拆开、把递归讲透、把排查运行时错误的经验一起整理出来,适合刚开始刷hot100的读者,也适合准备面试想把这题讲清楚的读者。

1. 题目到底在考什么:LCA问题的本质与要求解析

1.1 LCA的定义与题目里的三条边界条件

最近公共祖先的定义并不复杂:在一棵二叉树里,给定两个节点p和q,找一个深度最深的节点,使它同时是p的祖先和q的祖先。这里有个容易忽略的点——“祖先”包含节点自身。也就是说,如果p本身就是q的祖先,那p就是答案。这个“包含自身”的约定在递归里特别重要。

LeetCode 236这道题有三个边界条件,很多人不仔细看:

  1. p和q一定存在于树中,所以不用考虑“找不到”的情况,代码里不需要额外兜底。
  2. p和q不相同,所以不会出现两个节点完全一样导致二义性。
  3. 这里的判定依据是节点指针本身,而不是节点值。虽然力扣原题为了保证测试方便,通常说明了所有节点值唯一,但在实际工程里值重复太常见了。如果你用root->val == p->val去判断,遇到两个值相同的节点就会落到错误分支。所以写代码时一律用root == p来判断,这是这道题最容易踩的隐形坑。

生活化一点理解:你可以把二叉树想象成一份家谱,每个节点是一个家族成员,从子节点往父节点找,最终能画出一条“向上追溯链”。LCA就是两条链第一次交汇的地方。如果p就在q的“长辈链”上,那交汇点就是p自己。

1.2 为什么它会出现在hot100必刷清单里

hot100题目很多,但像236这样“小身材、大容量”的题不多。它表面只问一个节点查找问题,实际上把二叉树的遍历框架、递归返回值设计、自底向上的信息汇总全串起来了。刷这题时你大概率会用到后序遍历;面试官顺着这题还能追问“如果是搜索二叉树怎么做”“如果给一堆节点求公共祖先怎么做”“如果树特别深递归爆栈了怎么办”。一题能引出五六道变体,所以它才会被放在必刷清单里。

从工程角度来说,LCA概念在很多领域都有用:比如分析代码里的调用栈、寻找两个模块的共同依赖、判断两个类在继承体系中最近的公共基类,甚至Git合并分支时寻找共同祖先节点,本质都是LCA。理解了二叉树上的LCA,再去看这些场景会容易很多。

2. 解法一:后序递归,面试最稳的写法

2.1 核心思想:让每个节点回答“我这边找到了谁”

网上这题的递归代码极短,但理解起来并不容易。我第一次刷的时候,代码背下来了,可面试官一追问“为什么这么写”,我就开始含糊。后来我把递归树完整画了一遍才明白,关键在于搞清楚每个递归函数向上层返回的到底是什么。

每个递归函数代表“在以root为根的子树里检索p和q”,它的返回值有三种含义:

  • 返回nullptr:当前子树里既没找到p,也没找到q。
  • 返回p或者q:当前子树里只找到了其中一个目标节点。
  • 返回其他节点:说明在这棵子树里已经找到了LCA,返回的就是答案。

当前节点要做的决策分三步。第一步,如果root本身就是nullptr,天然返回nullptr。第二步,如果root就是p或者q,那直接返回root,此时这棵子树至少有一个目标节点,要把这个事实报告给上层。第三步,先递归左子树和右子树,拿到两个返回值left和right,然后看结果:如果left和right都不为空,说明p和q分别出现在当前节点的左右两棵子树里,那么当前节点就是它们唯一的交汇点,直接返回root;如果只有一边不为空,就返回非空的那一边——它可能是找到的某个目标节点,也可能是更下层已经确认的LCA。

很多人卡在第二步:为什么root是p就直接返回,不再往下找q了?这里要反过来想。如果root就是p,而q在root的左子树或右子树里,那么“最近公共祖先”本来就是root自己,因为root已经是p的祖先,同时也是q的祖先,而且没有比root更深的节点能同时包含p和q。如果q不在root的子树里,那这棵树的上层会继续把root作为“只找到了p”的结果往上传,上层节点再结合其他子树的信息做判断。这套机制是靠“自底向上”的信息汇总完成的,所以它天然就是后序遍历。

2.2 完整代码与逐段解说

C++的写法很经典,如下:

class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if (root == nullptr || root == p || root == q) { return root; } TreeNode* left = lowestCommonAncestor(root->left, p, q); TreeNode* right = lowestCommonAncestor(root->right, p, q); if (left != nullptr && right != nullptr) { return root; } return left != nullptr ? left : right; } };

Python版本也贴一下,结构一模一样:

class Solution: def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode': if not root or root == p or root == q: return root left = self.lowestCommonAncestor(root.left, p, q) right = self.lowestCommonAncestor(root.right, p, q) if left and right: return root return left if left else right

逐段说明。第一行if (root == nullptr || root == p || root == q)是终止条件,把“空节点”和“命中p或q”合并处理。这是算法简洁的关键,但是也是新手最容易写错的地方。有人会把root == p单独拎出来判断后直接返回,逻辑上没问题,但和空判断合并写更清晰,也防止先访问了root->val才判空。

关键的一句是if (left != nullptr && right != nullptr) return root;。这句之所以是分水岭,是因为它用空间换时间:把“左右子树各找到一人”作为判定LCA的充分条件。这里只要左右各有一个非空返回值,就不需要再区分具体是谁,当前节点一定是最近的那个交汇点。

最后的return left != nullptr ? left : right;负责把非空结果向上传递。如果两边都空,返回空;如果只有一边有东西,就把那边的东西往上送。仔细看会发现,这个返回值实际上同时承担了“找到了某个目标节点”和“找到了最终答案”两种语义,正是这种混合让代码变得很短。

2.3 复杂度、递归树和一组自测用例

时间复杂度是O(N),因为最坏情况下要访问整棵树的每个节点才能确定p和q的位置。空间复杂度是O(H),H是二叉树的高度。这里要注意,网上很多答案写O(N),那是把最坏情况(树退化成链表)的空间复杂度当作一般情况了。平衡二叉树时递归栈深度只有O(logN),所以严格说应该是O(H),H可以视为树高。

强烈建议自己动手画一棵递归树,把每个节点的返回值标出来。用LeetCode官方的示例树来走一遍:

  • 根节点3,左子树包含5、6、2、7、4,右子树包含1、0、8。
  • 查p=5、q=1。左子树里已经命中5,返回节点5;右子树里命中1,返回节点1;根节点发现左右都不为空,返回3。
  • 查p=5、q=4。节点5本身就是目标节点,直接返回5;根节点右子树没有命中返回空;于是5一路向上传到根,最终返回5。

再看几个容易读的边界用例:如果树只有一个节点,且p和q不可能是同一个节点,那这种情况在题目约束下不会出现,但写代码时也要保证不崩。如果p是根节点,另一个在深层,那么递归在根节点就直接返回root,不会继续往下走。如果树是个左斜树,递归深度会变成N,测试时可能栈溢出,这时就可以引出迭代方案。

3. 解法二:哈希表存父节点,更工程化的思路

3.1 核心操作:先把树变成一张父节点指针表

递归后序虽然简洁,但面试官如果追问“树特别深怎么办”,就需要给出迭代思路。最容易讲清楚、也最接近工程实践的,是“父节点表”方案。思路很简单:先遍历一遍二叉树,把每个子节点和它的父节点记录到哈希表里,相当于给每个节点配了一张“向上跳转表”。然后从p开始,沿着父节点指针一路向上走,把经过的所有祖先节点都记录到一个集合里;再从q开始向上走,遇到的第一个“p也走过”的节点,就是LCA。

这就像两个人约碰头:一个人先沿着楼梯往上爬,每经过一层就放一个标记;另一个人也从自己的位置往上爬,他踩到的第一个有标记的楼层,就是两个人离得最近的交集层。

完整C++代码如下:

class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { unordered_map<TreeNode*, TreeNode*> parent; stack<TreeNode*> stk; parent[root] = nullptr; stk.push(root); while (!parent.count(p) || !parent.count(q)) { TreeNode* node = stk.top(); stk.pop(); if (node->left) { parent[node->left] = node; stk.push(node->left); } if (node->right) { parent[node->right] = node; stk.push(node->right); } } unordered_set<TreeNode*> ancestors; while (p != nullptr) { ancestors.insert(p); p = parent[p]; } while (!ancestors.count(q)) { q = parent[q]; } return q; } };

这段代码里有个细节:第一个while循环的条件是!parent.count(p) || !parent.count(q),意思是只要p和q的父节点信息还没都收集齐,就继续遍历。这样能保证在极端情况下,比如q是根节点时,也能尽早退出循环,不用白跑完整棵树。这里的遍历顺序其实无所谓,DFS、BFS都可以,因为只是收集父子关系,不涉及状态判定。

第二次循环用unordered_set记录p的祖先链,然后让q向上走,直到命中集合里的节点。这里必须用unordered_set而不是普通数组,因为判断“某个节点是否出现过”的时间要控制在O(1),否则整体复杂度退化。

这个方案的时间复杂度是O(N)遍历加O(H)找LCA,空间复杂度是O(N)。虽然空间上比递归的O(H)大一些,但好处非常明显:没有递归栈溢出的问题,而且一次建表之后,如果同一棵树上要反复查询多组节点对的LCA,每次查询只需要从节点向上走一遍即可,不需要重新遍历整棵树。这在交互式可视化、树形结构编辑器这些场景里很有价值。

3.2 迭代模拟后序的原理与实现要点

有人会问,能不能用纯迭代栈“硬模拟”递归后序的方案,不建父节点表?答案是能,但代码会非常啰嗦。递归版本之所以短,是因为系统帮我们维护了每一层递归的“栈帧”,里面积累了左右子树的返回值。要改成迭代,就必须自己设计一个栈帧结构,包含当前节点、访问到哪个阶段、左子树返回值、右子树返回值。手写这样一套栈模拟,长度通常是递归版本的3到4倍,而且容易在“取子栈帧返回值”这个环节出错,因为子栈帧弹出后返回值需要临时保存到父栈帧里。

所以我在面试时候的策略是:讲清楚“后序递归”的思路,然后补充一句“如果担心栈溢出,可以采用父节点表法,虽然空间换时间,但更稳”。面试官不会因为你没现场写出迭代后序就扣分,反而会认可你具备工程上的权衡能力。父节点表方案就是我在工程实践里更常用的迭代替代品。

3.3 两种方案如何取舍

给一张选型对照表,面试和工程都能直接参考:

维度递归后序法父节点表法
代码量约10行约25行
时间复杂度O(N)O(N)
额外空间O(H),H为树高O(N)
栈溢出风险有,斜树会爆无
多次查询每次重新遍历整棵树建表后可复用,每次查询O(H)
理解难度返回值混合语义,略抽象向上爬楼梯,直观
适用场景刷题、单次查询工程、多次查询、深树

如果是应付算法面试,递归后序法是首选,因为代码短、容易讲、能展示对递归的理解。如果讨论到工程落地或者超大规模树,就切到父节点表法。两种都掌握,面试时可以从容切换。

4. "写二叉树老是报运行时错误":问题排查与调试实录

4.1 最常见的五类运行时错误根因

写二叉树程序报运行时错误真的太常见了,尤其是刚刷题的前期,我几乎每题都要在本地调试半天。根据我的经验,这类报错一般逃不出五个原因:

第一类,空指针访问。这是最典型的运行时错误。比如递归进入时没有判空就直接写root->val,一旦root是nullptr,直接报错。力扣常见的报错文本是“member access within null pointer of type 'TreeNode'”。

第二类,递归边界条件错误导致栈溢出。常见写法是把root == nullptr的终止条件漏掉,或者条件写错,导致递归永远不收敛。另一种情况是自建测试数据时,树的某个节点左右指针指回自己,形成环,也会让递归无限循环。

第三类,用值比较代替节点身份比较。前面说过,如果树里有重复值,root->val == p->val会把不同节点当成同一个,叠加错误后很容易访问到空节点,或者返回错误答案。这种错误不好查,因为报错信息不直观。

第四类,全局变量或者静态变量污染。刷题时如果为了省事,在类里写了一个成员变量存答案,然后多组测试用例共用同一个实例,就可能出现上一轮的结果残留。LeetCode每次调用都会新建对象,问题不大;但在本地写测试框架时,全局变量不清零会非常痛苦。

第五类,树结构构造错误。用数组转树时,经常有人漏掉了nullptr占位,导致树的形状和预期的完全不一样。比如给定[3,5,1],有人写成根节点3的左孩子是1、右孩子是5,结果所有测试全错。节点本身没错,但整体结构错了,运行到某个叶子再往下访问时就变成了空指针。

4.2 现场排查技巧与自查清单

我的调试习惯是“先打印,再断点”。在力扣上刷题时,写个辅助打印函数非常有用。最简单的做法是在递归函数入口加一行:输出当前节点值、当前要查的p和q的值。比如:

cout << "visit node: " << root->val << ", p: " << p->val << ", q: " << q->val << endl;

这样你能直观看到递归从根一路走到叶子的路径,也能确认递归是否在某个分支上无限循环。调试完再把这行注释掉就行。

另外,推荐一个我自己常写的辅助函数,用于验证一棵树的形态是否正确。它做层序遍历,输出每个节点和它的左右孩子,方便和题目给的数组结构做对照:

void printTree(TreeNode* root) { queue<TreeNode*> q; q.push(root); while (!q.empty()) { TreeNode* node = q.front(); q.pop(); if (node == nullptr) { cout << "null "; continue; } cout << node->val << " "; q.push(node->left); q.push(node->right); } cout << endl; }

这个函数还有一个好处:如果树里存在环,它会一直死循环,立刻就能暴露问题。很多二叉树程序的运行时错误,根源就是“树长错了”,先确认树本身没问题,再去找算法问题,效率高很多。

自查清单可以浓缩成一张表:

检查点说明
递归入口判空了吗root == nullptr必须放在节点操作之前
终止条件覆盖全了吗是否同时处理了p和q自身就是祖先的情况
比较的是指针还是值必须用root == p,不要用root->val == p->val
全局变量是否清零多组测试时特别注意静态变量、成员变量残留
树的构造是否正确用层序打印函数验证树形与预期一致
递归是否可能死循环检查节点指针是否成环,比如左右孩子指向祖先

4.3 一个从报错到修复的真实排查过程

我用236本身演示一个真实debug过程。假设我手滑把递归终止条件写成了:

if (root->val == p->val || root->val == q->val) { return root; }

第一反应觉得没问题,因为示例树没有重复值。测试[3,5,1]、p=5、q=1时确实能过。但换到[3,5,5]这种有重复值的用例,p=5、q=5(仅用于本地测试),根节点3的左孩子是第一个5,右孩子是第二个5。递归到根节点时,root->val是3,没命中,于是往左右子树递归。左子树第一个5命中p,返回节点A;右子树第二个5命中q,返回节点B;根节点发现左右都不为空,返回3。可实际p和q是同一个值的两个节点,并不存在公共“值”,正确答案不是3。这个例子说明值比较会导致逻辑彻底错乱。

随后我把判断改成指针比较,但还是报空指针错误。打印后发现,递归函数在访问root->val之前没有判空,某个叶子节点的左孩子是nullptr,递归进入空节点后先执行比较逻辑就崩了。调整成先判空、再比较指针,问题才彻底解决。

这个案例看着简单,但很典型:运行时错误往往不是一个独立原因,而是“值比较+缺少判空”两个问题叠加。排查时要一层层剥开。所以我的建议是,每次遇到报错,先看当前节点是不是空,再看比较方式对不对,最后再怀疑算法逻辑。

5. 延伸:BST优化、多节点LCA与遍历框架联想

5.1 搜索二叉树的LCA:从O(N)降到O(H)

把这题改一下:如果二叉树变成了搜索二叉树(BST),能不能利用有序性质加速?可以。BST的定义是左子树所有节点值小于根节点,右子树所有节点值大于根节点,所以从根节点出发,如果p和q的值都小于当前节点,说明两个节点都在左子树里,直接往左走;如果都大于当前节点,说明都在右子树里,直接往右走;如果p、q一个在左一个在右,或者其中一个等于当前节点,那当前节点就是分叉点,也就是LCA。

代码很短:

class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { while (root) { if (p->val < root->val && q->val < root->val) { root = root->left; } else if (p->val > root->val && q->val > root->val) { root = root->right; } else { return root; } } return nullptr; } };

这里可以安全地比较值,因为BST节点值唯一。复杂度从普通二叉树的O(N)降到了O(H),平衡情况下是O(logN)。面试时这一下就能展示你对数据结构的敏感性。刷完236后,建议紧接着刷235,两题对比着做,会把BST的区间分割思维记得特别牢。

5.2 从两个节点扩展到一组节点

再扩展一点点:如果给的是一组节点,要一次性求出这个集合的LCA,怎么做?最简单的思路是两两合并:先取两个节点求LCA,再用结果和下一个节点求LCA,依次类推。原理是LCA运算满足结合律,所以迭代合并是正确的。复杂度是O(kN),k是集合大小。

更好一点的做法是递归统计。让递归函数返回一个pair,表示“当前子树命中了几个目标节点”和“是否已经找到LCA”。当某个节点命中数量等于集合大小时,它就是整组节点的LCA。这个思路其实就是236递归的自然推广,只是把left != nullptr && right != nullptr这个二元判断,换成了“命中计数达到目标数量”的判断。它能用一次遍历O(N)解决,比两两合并省事。

5.3 线索二叉树、深度计算与二叉树遍历的联动复习

热词里还有个“线索二叉树”,我也想顺便说一下。线索二叉树的核心是改造叶子节点的空指针,让遍历时不用递归和栈,直接沿线索找后继。它和LCA没有直接关系,但如果你掌握了线索二叉树,理解“二叉树遍历有多少种框架”会很清晰。LCA递归本身是后序遍历的典型应用,而后序遍历又有对应的迭代写法,比如用双栈法或者带状态位的栈法。所以只要把遍历框架打通,LCA迭代也就不难。

二叉树的深度也是同一套递归思维。比如最大深度:

int maxDepth(TreeNode* root) { if (!root) return 0; return max(maxDepth(root->left), maxDepth(root->right)) + 1; }

先拿左右子树的深度,再在根节点汇总加一。这和236的“先拿左右子树结果,再判断当前节点是否为LCA”是完全一致的后序结构。所以刷题时不要一题一题孤立地背,要把这类“靠子树状态计算根节点结果”的题归到同一框架里。

5.4 我刷这道题反复踩坑后的一些体会

我的体会是,只背代码真的不行。我第一次刷这题时,把答案背得滚瓜烂熟,但面试官问了句“为什么root本身是p时直接返回,却不会漏掉q在另一棵子树的情况”,我当场愣住了。回去之后我花了一晚上画递归树,给每个节点标注返回值,再对照代码走了一遍,才算真正通透。这个画树的过程比刷三道题都值。

另外分享一个小技巧:如果你要给别人讲明白这题,就用“上报”这个比方。每个节点是侦察兵,向上面汇报“我这片区域只看到了p”或者“我这片区域两个都看到了,LCA就是我”。一旦左右两边各有一个侦察兵说有发现,当前节点立刻举手。这个比方能让人秒懂递归的返回值设计,也能让你自己在面试时讲得更自信。

刷完236之后,我建议顺手把235(BST的LCA)、1123(最深叶节点的LCA)一起做掉。三道题放在一起,你会发现二叉树LCA这个知识点基本就焊死在脑子里了。之后遇到任何需要“自底向上汇总信息”的题,都可以回头参考这题的递归结构,收益会持续放大。

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

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

立即咨询