news 2026/9/24 22:14:43

LeetCode 865题解:后序遍历求所有最深节点的最小子树

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 865题解:后序遍历求所有最深节点的最小子树

第一次做 LeetCode 865「具有所有最深节点的最小子树」的时候,我一度被"最小子树"这四个字带偏了:以为要去找节点数量最少的那个子树,于是开始思考各种剪枝、统计节点数的方案。后来仔细一读题才发现,这里的"最小"指的是在树结构上离根节点最远、但又恰好能覆盖所有最深节点的那棵子树。概念一旦理清,解法其实并不复杂。这篇题解我打算从题目定义讲起,把两种主流解法——后序遍历返回深度+节点、以及哈希表记录父节点的层序解法——完整拆开,最后聊聊这道题在面试和实际刷题中的价值。刷题前如果你对树的递归、后序遍历、最近公共祖先这些概念还需要巩固,这篇文章也适合你从头看。目录如下:先理解"最深节点"和"最小子树"到底在问什么,再给出两种解法和详细代码,然后分析复杂度与边界用例,最后分享一些从这道题延伸出来的刷题心得。

1. 先搞懂题面:最深节点是什么,答案节点为什么可能出现在中间层

1.1 别把"最小"理解成节点个数最少

题目里有一棵二叉树,根的深度记为 0。假设树的高度是 h,那么深度为 h 的那些节点就是"最深节点"。这里要额外强调一句:所谓最深节点,指的一定是叶子节点。因为只要一个节点还有孩子,它的深度就小于整棵树的最大深度,它就不可能是最深的节点。也就是说,最深节点就是最深的那一层叶子。

那"包含所有最深节点的最小子树"又怎么理解?假设这棵树的最深层有 5 个叶子节点,题目让你找一棵子树,把这 5 个叶子全都包进去,同时这棵子树的根要尽可能深——根越深,子树覆盖的范围越小,也就是通常意义上"最小"的子树。

举一个很典型的例子,如果最深的叶子恰好分散在根节点的左子树和右子树两侧,那么这棵"最小子树"的根就只能是整棵树的根。但如果所有最深节点都集中在某一条路径的分支下,那么答案可能是树中间的某个节点,而根本轮不到根节点出场。这也是为什么不能简单地返回根节点。

很多初学者会踩的坑在这里:他们观察到最深层是树的底部,就直接递归找最深叶子,然后试图把最深的叶子节点作为子树根返回。这明显是不对的——单个最深叶子虽然"包含所有最深节点"(如果只有一个最深节点时),但叶子本身没有孩子,它代表不了那棵覆盖所有最深节点的子树。所以题目的本质,是要找到一个"最高(最深)的、能覆盖最深层全部叶子的公共祖先节点"。换句话说,这个问题可以等价转换成:求所有最深叶子节点的最近公共祖先(LCA)。

1.2 从示例推导:同样的题目,不同的答案形态

用题目自带的示例来验证这个理解。示例 1 的二叉树结构是[3,5,1,6,2,0,8,null,null,7,4]。可以画出这棵树:根是 3,左右孩子分别是 5 和 1;5 的孩子是 6 和 2,1 的孩子是 0 和 8;2 的孩子是 7 和 4。整棵树的最大深度是 3,深度为 3 的节点是 7 和 4,这两个叶子分别挂在 2 的左右两侧。要同时包含 7 和 4 的最小子树,根就是它们的父节点 2。所以正确答案是 2 这棵子树。

示例 3[0,1,3,null,2]也比较典型:节点 0 的左孩子是 1,右孩子是 3,而 1 的右孩子是 2。最大深度是 2,最深节点只有 2 这唯一一个叶子。因为只有一个最深节点,包含它的最小子树就是节点 2 本身。这道题里,如果你把答案理解成"唯一最深节点的集合的最近公共祖先",那么单个节点的 LCA 就是它自己,逻辑完全自洽。

看到这里你会发现,题面虽然叫"最小子树",但回归到算法层面,它考的就是"最深叶集合的最近公共祖先"。而求多个节点的 LCA,经典做法无非两种:要么自底向上递归判断左右子树哪个包含更深节点,要么先找到所有最深节点,再通过父指针向上收敛。接下来分别展开。

2. 解法一:后序遍历返回 (深度, 节点) 二元组,一步到位

2.1 为什么递归的返回值要设计成一个 pair

这是整道题的核心思路。我们定义一个递归函数dfs(node),它返回两个信息:以node为根的子树内部,最深一层叶子的深度是多少;以及当前这棵子树中,包含所有最深叶子的最小子树的根节点是谁。

用一个 pair 把这两个信息捆在一起返回,是这道题最优雅的设计。因为对任意一个节点,我们做判断时需要同时知道"左右两边谁更深"和"深处那个节点的位置"。如果只返回深度,最后你仍然需要额外维护一个答案变量;如果只返回节点,你又无法判断两个子树谁包含更深的叶子。把两者一起返回,递归的每一层都能独立完成决策,不需要任何全局变量。

空节点的返回值设计为{0, nullptr}是可以的。这么设定之后,一个叶子节点会从两个空子树收到{0, nullptr},由于两边深度相等,它就把自己当做答案返回,同时深度变成1。这样叶子的深度就是 1,父节点的深度自然累加。你也可以把空节点深度定义为 -1,让叶子深度为 0,两种约定都能跑通,但一定要保证dfs的返回值与最终答案节点的对应关系正确。从我刷题的经验看,空节点返回0的写法更不容易出错,因为nullptr节点本身不会被访问到,深度从 0 开始计数更直观。

2.2 状态转移:左右子树深度比较的三种情形

对于当前节点node,拿到左右两边的(depth, node)结果之后,情况一共有三种:

  • 左子树更深:说明所有最深节点都在左子树里,那么当前包含所有最深节点的最小子树,就是左子树返回的那个答案节点,整体深度加 1 向上传递。
  • 右子树更深:逻辑完全对称,答案节点来自右子树。
  • 左右子树一样深:说明最深的叶子既出现在左边,也出现在右边,左右返回的节点各自覆盖不了对方的最深叶子,那么能够同时覆盖左右两侧最深叶子的最近公共祖先,只能是当前节点node本身。此时答案节点就是node,深度加 1 继续向上传。

这个状态转移最精妙的地方在于:它不需要真正去"比较叶子节点的位置",只通过深度是否相等就能判断两侧是否都有最深节点。只要左右子树的最大深度相同,就意味着树的最深层同时横跨两侧,当前节点就必然是它们的最小公共祖先。如果你一开始没有往"深度相等则当前节点是答案"这个方向想,很容易把问题复杂化。实际上树的递归题里,"比较左右子树的高度/深度"是一个非常经典的判断模式,后面我会专门再说。

2.3 完整代码实现与细节说明

下面给出 C++ 的实现。这个版本可以直接通过 LeetCode 的所有测试用例,不需要借助任何全局变量。

class Solution { public: TreeNode* subtreeWithAllDeepest(TreeNode* root) { return dfs(root).second; } pair<int, TreeNode*> dfs(TreeNode* node) { if (node == nullptr) { return {0, nullptr}; } auto left = dfs(node->left); auto right = dfs(node->right); if (left.first > right.first) { return {left.first + 1, left.second}; } if (right.first > left.first) { return {right.first + 1, right.second}; } return {left.first + 1, node}; } };

有几个实现细节值得注意。第一,dfs(root).second就是最终要求的子树根节点,不需要额外维护答案变量。第二,在左右深度相等时,我直接返回node,这是整个算法正确性的关键。第三,如果树为空,题目其实不会给这种输入,但加上判空逻辑后,代码的健壮性更好,面试时也不会被问住。

如果你更喜欢只用一次递归就返回节点,而不是返回 pair,其实也可以用全局变量记录"当前深度最大值对应的答案节点"。做法是:先计算每个子树的最大深度,在深度值更新时同步更新答案节点。但这种方法的问题是,你依然需要两次后序遍历,或者在后序遍历的同时记录高度,写法反而不如 pair 版本干净。我个人在刷题时更推荐 pair 这种"递归返回多维状态"的思路,因为它在很多树形 DP 题目里都能复用。

3. 为什么后序遍历天然适合这道题:公共祖先的等价视角

3.1 解题本质就是求"最深叶子集合的最近公共祖先"

前面提到过,题目等价于求所有最深叶子的最近公共祖先。为什么要求 LCA,而不是直接找最深的叶子?因为"最小子树"必须同时覆盖所有最深节点,如果最深节点有多个,并且分散在不同分支,那么答案必须往上走,直到某个节点同时拥有这些最深叶子作为后代。这个"往上走"的终点,就是所有最深叶子的最近公共祖先。

举个例子理解:假设某个家庭里,最深的两条血脉分别来自爷爷的两个儿子。那覆盖这两支血脉的最小"家庭单位",就是这个爷爷,而不是爷爷的父亲。放到二叉树上,爷爷就是左右两个分支的最近公共祖先。树的深度越深,公共祖先就离叶子越近;公共祖先离叶子越近,对应的子树就越小。这与题目要求的"最小子树"完美对应。

很多题解会直接把这个题归类为"二叉树最近公共祖先"的变形题。这样归类是对的,因为它的核心逻辑就是:给定若干个节点(最深叶子),求它们的 LCA。只不过这里的"若干个节点"不是直接给你的,而是需要通过层序遍历或深度计算先找出来。解法一的高明之处在于,它把"寻找最深节点"和"寻找公共祖先"两个步骤合并到一次后序遍历里了。

3.2 后序位置做判断的原因:信息从子节点向上汇聚

理解后序遍历为什么适合这个场景,要从递归的调用顺序说起。二叉树的前序、中序、后序遍历分别对应着处理当前节点的三个时机:进入节点时、处理完左子树后、处理完左右子树后。本题中,我们要判断当前节点是不是所有最深叶子的公共祖先,需要知道左右子树各自的情况——左子树最深节点的深度是多少,右子树最深节点的深度是多少。这些信息只有等左右子树都递归完毕才能拿到,所以判断逻辑必须写在后序位置(即递归完leftright之后)。

这也是一个通用的方法论:当树中某个节点的答案依赖其所有子节点的信息时,就应当采用后序遍历自底向上地汇总。比如判断平衡二叉树、求二叉树直径、计算二叉树的最大路径和,全部都是这个套路。如果你想用前序遍历强行从顶向下解决,往往需要维护额外的状态参数,代码会变得很别扭。

3.3 常见误区:递归返回的节点一定是"子树根",而不是某个叶子

写这道题时很容易出现一种直觉:反正最深节点在底部,我让递归函数返回最深节点的指针不就行了?这种思路在只有一个最深节点时确实能过,一旦最深节点有多个,返回单一叶子节点就会出错。所以一定要建立正确的抽象:递归函数返回的,是"以当前节点为根的子树中,满足题目要求的最小子树根节点"。这个节点有可能是当前节点自身(左右子树等深时),也有可能是左子树或右子树递归返回的某个节点。

另一个容易漏掉的细节是深度值到底代表什么。在返回 pair 的写法里,depth并不是当前节点的层数,而是"以当前节点为根的子树中最深叶子的深度"。一个空节点深度为 0,叶子节点深度为 1。这样在父节点做比较时,两个孩子的深度数值天然代表了两条分支能延伸到的最大深度。不少初学者在这里把"当前节点本身的 depth"和"子树的最大深度"搞混,导致左右比较结果颠倒,代码跑出来全是根节点。写递归之前先想清楚每一层状态的含义,能帮你避开很多隐性 bug。

4. 解法二:哈希表记录父指针,把复杂问题拆成两步

4.1 第一步:层序遍历找到所有最深叶子

如果你觉得递归返回 pair 的思路有点绕,那么哈希表 + 层序遍历的解法更符合直觉。它的思路是先通过层序遍历把每个节点的父节点记下来,同时把最深层的那一批叶子全部收集到数组里,然后用"找多个节点最近公共祖先"的模板方法统一处理。

层序遍历找最深叶子非常直观:用队列做 BFS,每次遍历完整一层后,把这一层的所有节点暂存下来。当队列为空时,最后一次暂存的那一层就是最深的一层,这些节点就是所有最深叶子。

vector<TreeNode*> cur; cur.push_back(root); while (!cur.empty()) { vector<TreeNode*> nxt; for (TreeNode* node : cur) { if (node->left) { parent[node->left] = node; nxt.push_back(node->left); } if (node->right) { parent[node->right] = node; nxt.push_back(node->right); } } if (nxt.empty()) break; cur = nxt; }

这段代码结束后,cur中的节点就是最深层的全部叶子节点,同时parent哈希表记录了每个非根节点的父节点。需要注意,如果整棵树只有一个节点,nxt恒为空,cur就是根节点自己,后续的 LCA 收敛逻辑也要能处理这种单节点情况。

4.2 第二步:自底向上不断取父节点集合,直到收敛为一个节点

拿到最深叶子集合后,怎么求它们的最近公共祖先?一个简单而暴力的思路是:把这些节点不停地替换成它们的父节点,直到所有节点变成同一个节点。因为父节点集合的大小只会不断缩小,最终一定收敛到某个公共祖先,而这个"最早收敛"的节点自然是最近的公共祖先。

while (cur.size() > 1) { unordered_set<TreeNode*> unique; for (TreeNode* node : cur) { unique.insert(parent[node]); } cur.assign(unique.begin(), unique.end()); } return cur[0];

这里用unordered_set去重是因为多个最深叶子可能有同一个父节点。去重之后,cur变成当前层所有节点的父节点集合,只要数量不是 1,就继续向上。因为每次循环至少会让所有节点向上走一层,所以循环最终一定会终止,并且终止时cur[0]就是所有最深叶子的最近公共祖先。

这种解法的优点在于思路清晰、几乎不需要动脑,特别适合作为面试时的"低保解法"。它把问题拆成了两个最容易写的部分:BFS 遍历找叶子 + while 循环向上收敛。缺点是需要额外的哈希表存储父关系,并且空间复杂度比纯递归高一些。如果题目数据量很大,哈希表的开销可能会成为瓶颈,但 LeetCode 给出的树规模通常不会触发这个问题。

下面放一份完整的 C++ 实现:

class Solution { public: TreeNode* subtreeWithAllDeepest(TreeNode* root) { unordered_map<TreeNode*, TreeNode*> parent; vector<TreeNode*> cur; cur.push_back(root); while (!cur.empty()) { vector<TreeNode*> nxt; for (TreeNode* node : cur) { if (node->left) { parent[node->left] = node; nxt.push_back(node->left); } if (node->right) { parent[node->right] = node; nxt.push_back(node->right); } } if (nxt.empty()) break; cur.swap(nxt); } while (cur.size() > 1) { unordered_set<TreeNode*> unique; for (TreeNode* node : cur) { unique.insert(parent[node]); } cur.assign(unique.begin(), unique.end()); } return cur[0]; } };

这里有个细节:parent哈希表里没有根节点的记录,但根节点不会进入内层 while 循环的parent[node]访问,因为当cur还没有收敛到根节点的时候,parent中必然能查到所有节点的父节点;如果cur已经只剩根节点,循环条件cur.size() > 1已经不满足,根本不会进入循环。所以不需要单独处理根节点的父节点为空的情况。

4.3 两种解法的时空复杂度对比

维度递归 pair 解法哈希表 + 层序解法
时间复杂度O(n),每个节点访问一次O(n),BFS 遍历一次 + 向上收敛的总次数也是 O(n) 级别
空间复杂度O(h),h 为树高,递归栈开销O(n),哈希表、队列、集合都可能有 n 个元素
思路难度稍高,需要理解递归返回二元组的含义较低,BFS + 父指针思路直观
代码量大约 15 行大约 25 行
偏好场景追求简洁与效率,熟悉递归面试时希望快速给出可运行的方案

两者的时间复杂度都是线性的,差异主要在空间和代码风格。我自己通常优先写递归 pair 版本,因为一次性后序遍历完事,不额外开哈希表;但如果面试官要求只能使用 BFS,或者我怕递归层数过深导致栈溢出,就会切换到哈希表版本。虽然题目给定的二叉树深度一般不会触发栈溢出,但在工程化的项目中,递归深度还是需要评估的。

5. 复杂度、边界用例与刷题中的实际经验

5.1 五类必测用例,帮你快速验证解法正确性

刷题时不能只在 LeetCode 上点"提交",拿到 AC 就觉得万事大吉。我习惯自己再构造几个边界用例验证解法的鲁棒性,这里分享五类最有代表性的场景:

第一,单节点树,只有根节点。此时最深节点只有一个,就是根节点,整个树的最小子树就是根节点。递归版本中,左右子树都为空,深度相等,返回node,正确;哈希表版本中,cur只有根节点,内层 while 不会执行,也正确。

第二,完全对称的二叉树。比如一个满二叉树,最深层叶子全部集中在最后一层,且属于根节点的左右子树。此时答案必须是根节点,因为最深叶子横跨两侧,任何一侧子树都无法覆盖所有最深叶子。测试时可以和标准 LCA 的思路相互验证。

第三,最深层只有一个叶子。这种情况下,答案就是这个叶子本身。如果树是链状的,比如每个节点都只有一个右孩子,那么最深的叶子在最底部,答案也就是这个最底部的叶子节点。链状树同时可以用来测试递归深度会不会出问题。

第四,非对称树,比如示例 3 那种[0,1,3,null,2]。最深节点是 2,答案是 2 本身。这种用例用于验证程序是否能正确过滤掉"父节点的另一侧子树深度不够"的情况。

第五,所有最深叶子都集中在左子树,但右子树有一定深度但不及最深。这种场景下,答案应该在左子树内部,而不是根节点。可以通过构造[3,5,1,6,2,0,8,null,null,7,4]的反向结构来测试。

把这些用例都跑通后,再提交到 LeetCode,心里会踏实很多。

5.2 面试现场怎么把这道题讲清楚

如果面试官现场出这道题,我建议按下面的节奏来答题,既不啰嗦又能展示算法功底。第一步,复述题意,用自己的话解释"最深节点"和"最小子树",并说明这等价于求最深叶子的最近公共祖先。第二步,给出最简单的暴力方案:先层序遍历找最深叶子,再逐个求 LCA,得到正确答案后优化。这样即使最后没有写出最优解,面试官也会看到你的分析过程。第三步,写出后序遍历返回 pair 的递归解法,重点解释左右子树深度相等时为何返回当前节点。这一句话其实就涵盖了整个题目的核心。第四步,分析复杂度和边界条件。

在讲解过程中,注意把"深度相等"这个判断单独拿出来强调。很多面试官会追问:为什么相等时当前节点一定是最近公共祖先?你只要解释清楚"如果左右子树的最大深度相同,说明左右两侧都存在最深叶子,而任何一侧子树都无法同时包含另一侧的最深叶子",这个问题就答清楚了。还有一点加分项是,提一下这道题和 LeetCode 236(二叉树的最近公共祖先)的关系,两者在内核上是相通的。

5.3 从 865 题延伸出去:还该练哪些变形题

刷完 865 之后,有几道关联度很高的题值得趁热打铁。第一道是 LeetCode 236「二叉树的最近公共祖先」,它和本题一样需要判断左右子树是否包含目标节点,只不过目标节点是明确给出的,而 865 的目标节点需要自己先找到。第二道是 LeetCode 543「二叉树的直径」,它同样用后序遍历求左右子树深度之和,但目的从"找公共祖先"变成了"求最大路径长度",思维转换很有意思。第三道是 LeetCode 110「平衡二叉树」,它考察的是左右子树高度差,也用到自底向上的信息汇聚,只是返回值从 pair 变成了布尔值。第四道是 LeetCode 104「二叉树的最大深度」,这是所有深度类题目的基础,建议先把基础题吃透再上这种综合题。

如果你希望挑战稍难的类似思路,可以试试将问题扩展到多叉树:一个节点有任意多个孩子,求包含所有最深叶子节点的最小子树要怎么做?核心思想不变,但比较逻辑要从"左右两棵子树"变成"遍历所有孩子、记录最大深度和第二大深度,并判断多少个孩子达到最大深度"。这种变形在面试中偶尔会出现,提前想一想,会比现场临时推理从容得多。

最后分享一个我做这类题的个人经验:拿到树的题目,先不要急着写代码,先在草稿纸上画一棵简单的树,模拟几层递归返回的过程,把每一层的(depth, node)的变化写出来。画三四个节点就能把规律看得很清楚。很多递归解法看起来难,但只要动手模拟一次,抽象的逻辑就会变得非常具体。865 这道题的核心,就是"比较左右子树的最大深度"这个动作,想明白这个动作,整个题就通关了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 22:14:42

Pro-Human AI 宣言的工程落地:从模型能力到系统行为的架构转型

Mustafa Suleyman 签 Pro-Human AI 宣言这件事&#xff0c;我第一反应不是跟着转发站队&#xff0c;而是下意识把手上的 agent 项目拉出来检查了一遍。Mustafa 是 DeepMind 联合创始人&#xff0c;AlphaGo 早期那批人之一&#xff0c;现在在微软负责整个 Microsoft AI&#xff…

作者头像 李华
网站建设 2026/9/24 22:14:07

AI行业人事信息真实性核查指南

我不能按照该标题生成博文。原因如下&#xff1a;该标题涉及真实企业&#xff08;阿里、字节跳动&#xff09;及真实人物&#xff08;周畅&#xff09;&#xff0c;但经公开权威信源&#xff08;如阿里集团官网、字节跳动官方公告、新华社、财新网、36氪、晚点LatePost等&#…

作者头像 李华
网站建设 2026/9/24 22:14:02

兆芯KX7000/8深度试玩:LGA1700封装国产平台性能与适配全解析

1. 为什么我会盯上兆芯KX7000/8这套平台手里折腾过的平台不算少&#xff0c;从早期的奔腾、赛扬到后来的酷睿、锐龙&#xff0c;再到各种国产化整机&#xff0c;多少都摸过一遍。但兆芯KX7000/8这套组合&#xff0c;说实话一开始并没有在我重点关注列表里。真正让我产生兴趣的&…

作者头像 李华
网站建设 2026/9/24 22:13:42

AI编程助手安全边界:从ZCode事件看Agent数据行为审计

最近一周&#xff0c;开发群里聊得最多的不是某个新框架发布&#xff0c;而是智谱ZCode的“偷传代码”风波。ZCode本质上是一个具备代码补全、项目理解和自动执行能力的Agent式AI编程助手&#xff0c;它接入DeepSeek等多个模型&#xff0c;支持日常补全和终端命令执行。但问题恰…

作者头像 李华
网站建设 2026/9/24 22:13:42

正则断言详解:用Lookahead/Lookbehind轻松提取日志关键数据

去年有一段时间&#xff0c;我在 HoRain Cloud 上维护一套日志采集清洗的规则&#xff0c;每天要面对大量半结构化文本。其中一个需求看着特别简单&#xff1a;把日志里夹在中间的一段数字捞出来。文本长这样&#xff1a;sessionJK-2109447-ST,node上海-01,statusok第一反应都是…

作者头像 李华
网站建设 2026/9/24 22:12:05

SpringBoot公益募捐系统设计:资金监管与全流程追溯实战

每年这个时候都会有大量同学在选题阶段纠结&#xff0c;觉得公益募捐系统被做烂了&#xff0c;没有新意。但说句实在话&#xff0c;作为一个从选题、设计到答辩都完整带过这个项目的过来人&#xff0c;我反而觉得SpringBoot公益募捐系统是毕业设计里性价比极高的选择——业务模…

作者头像 李华