ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

二叉树最小深度:从递归误区到BFS最优解

二叉树最小深度:从递归误区到BFS最优解 1. 先搞清楚最小深度到底在求什么1.1 题目定义与典型误区LeetCode 111题“二叉树的最小深度”题目描述非常简短很多人扫一眼就觉得这不就是把最大深度反过来写嘛。实际上这道题能在LeetCode上被标为“简单”但让一堆人在周赛和面试里翻车核心问题就出在理解偏差上。先明确官方定义最小深度是从根节点到最近叶子节点的最短路径上的节点数量。这里有两个关键词需要划重点一是“叶子节点”二是“节点数量”。叶子节点是指左右子节点都为空的节点没有孩子的那种。节点数量是包含根节点自身的所以一棵只有一个根节点的树它的最小深度是1而不是0。很多人在做这道题时第一反应是写一个和求最大深度几乎一样的递归函数然后取左右子树较小值加1。这个思路在左右子树都非空的时候没问题但一旦遇到一棵只有左子树、右子树为空的树结果就错了。比如根节点1只有左孩子22又只有左孩子3整棵树长成一条斜线。如果按“左右子树较小值加1”去算右子树为空深度算0整棵树结果变成1可实际上从根节点到最近的叶子节点路径是1→2→3长度是3。这就是本题最大的认知陷阱。要理解这个陷阱的根本原因空子树并不是叶子节点。一个节点如果只有一个孩子那它本身不是叶子但它那个为空的子树上也并没有叶子节点存在。也就是说当某个子树为空时我们根本没有办法从那个方向找到叶子应该沿着另一个非空的方向继续走下去而不是直接用0去参与比较。1.2 与最大深度的本质差异求最大深度的时候递归公式是 max(left, right) 1空子树深度为0完全合理因为0本来就是最小贡献值不会影响取最大值的正确性。但求最小深度时取 min(left, right) 1 就不行了因为空子树的0会被当作一个候选答案参与比较而实际上空子树那里根本没有叶子节点不存在一条合法路径。用一个生活化的类比来解释假设你要从小区大门出发找到离你最近的一个快递柜。小区有两栋楼你只知道“哪栋楼最短路径更近”才能决定往哪边走。但现在东边那栋楼压根没有快递柜西边那栋楼里才有。这时候如果你按“东边距离为0”去算就会误以为东边有柜子这显然是错的。正确的做法是发现东边没有柜子后只能往西边继续走把西边的真实距离作为答案。这道题的价值不在于算法本身有多难而在于它考察你能否准确理解“叶子节点”这个边界定义在递归/迭代过程中产生的连锁反应。搞清楚这一点不仅这道题能过后续做路径总和、二叉树最近公共祖先等题目时对边界条件的敏感度也会明显提升。2. 递归实现写法简单但边界条件才是灵魂2.1 递归三部曲参数、终止条件与单层逻辑递归解法的代码框架很清晰但每一部分都有值得细抠的点。先看参数和返回值参数只需要一个 TreeNode 指针返回值是 int 类型的深度。虽然LeetCode上很多题解会写成int minDepth(TreeNode* root)但面试时建议另外封装一个辅助函数把主函数逻辑保持在“空树返回0”的语义上这样更清晰。终止条件这里有一个需要厘清的地方。很多人直接写if (root nullptr) return 0;这个写法本身没问题但要注意它只能作为“空节点”的终止条件不能作为“叶子节点”的终止条件。叶子节点的判断必须是root-left nullptr root-right nullptr此时应该返回1。两者不能混淆否则就会出现上一节说的空子树参与比较的问题。单层递归逻辑是本题的核心。正确做法是分情况讨论当前节点左右子树都为空返回1当前节点左子树为空、右子树非空递归计算右子树的最小深度再加1当前节点左子树非空、右子树为空递归计算左子树的最小深度再加1左右子树都非空取左右子树最小深度的较小值再加1。这里第2和第3种情况是很多人遗漏的。只有在左右子树都非空时才能放心地取min(leftDepth, rightDepth) 1。这个细节不是语法层面的要求而是逻辑层面的必然。2.2 三种主流写法与个人推荐第一种是标准的分支判断写法可读性最好int minDepth(TreeNode* root) { if (root nullptr) return 0; if (root-left nullptr root-right nullptr) return 1; int leftDepth INT_MAX; int rightDepth INT_MAX; if (root-left) leftDepth minDepth(root-left); if (root-right) rightDepth minDepth(root-right); return min(leftDepth, rightDepth) 1; }这种写法把左右子树的递归调用放在条件判断里为空的子树不会参与计算。初始值设为 INT_MAX这样另一个非空子树的深度就能正确胜出。第二种是简化写法先递归再统一处理int minDepth(TreeNode* root) { if (root nullptr) return 0; int left minDepth(root-left); int right minDepth(root-right); if (left 0 || right 0) return left right 1; return min(left, right) 1; }这个技巧很巧妙当 left 和 right 有一个为0时说明对应子树为空此时left right 1实际上就是非空子树的深度加1因为空子树那边贡献的是0。这个写法代码量更少但可读性略差适合已经吃透逻辑后追求简洁时使用。第三种是后序遍历的变体把空节点判断提前int minDepth(TreeNode* root) { if (root nullptr) return 0; if (root-left nullptr) return minDepth(root-right) 1; if (root-right nullptr) return minDepth(root-left) 1; return min(minDepth(root-left), minDepth(root-right)) 1; }这种写法逻辑更直观左空就往右走右空就往左走两边都不空才取较小值。我实际刷题和面试中比较推荐第一种或第三种因为不需要借助 INT_MAX 这种偏技巧性的初始值代码读起来清晰不容易被面试官追问。2.3 递归的时间与空间复杂度分析时间复杂度是 O(n)其中 n 是二叉树节点总数。因为每个节点最多只被访问一次递归函数对每个节点做常数次判断和调用。空间复杂度取决于递归调用栈的深度也就是树的高度。在最好情况下也就是一棵完全平衡二叉树树高为 O(log n)空间复杂度为 O(log n)。但在最坏情况下比如题目给出的树退化成一条链每个节点只有左孩子递归深度就是 n空间复杂度退化为 O(n)。这里值得说一个实际的面试考点面试官让你分析空间复杂度时很多人机械地回答“O(n)”其实不够准确。更严谨的表述应该是“O(h)其中 h 是树的高度最坏情况下 h n所以是 O(n)”。这种细节能体现你是否真正理解了递归调用栈的本质。我在自己的刷题笔记里特别标注过递归解法在极端情况下比如一棵极度倾斜的树节点数达到10万级会爆栈。LeetCode的测试数据通常不会那么极端但如果你把这段代码搬到生产环境去处理一棵深度非常大的树就可能遇到栈溢出。这也是为什么迭代解法在这道题中并非可有可无的备选方案。3. 迭代实现BFS才是最小深度的天然解法3.1 为什么BFS比DFS更契合这道题如果用深度优先搜索DFS的迭代方式做这道题比如用显式栈模拟递归本质上还是在遍历整棵树只是避免了递归栈溢出的风险。但如果我们用广度优先搜索BFS也就是层序遍历就可以做到理论上更优的“提前终止”。原因很简单最小深度等价于从根节点出发遇到的第一个叶子节点所在的层数。BFS按层遍历天然就是一层一层往深处走的当我们在某一层发现了一个叶子节点这个节点一定是从根节点出发能到达的最近叶子节点因为BFS在进入第 k1 层之前一定已经完整看过第 k 层的所有节点。这就是为什么说BFS是这道题的“天然解法”它不需要遍历完整棵树就能找到答案。这种“提前返回”的优势在两种情况下特别明显一是树的规模很大但最小深度很小比如根节点下挂着一条很深的分支和一条很浅的分支BFS可能只需检查几层就能终止二是面试官追问优化时你能从“遍历完整棵树”和“遍历到最近叶子即停止”这个角度去做对比会让你的回答更有层次。3.2 层序遍历代码实现与逐段拆解BFS的代码实现通常借助队列完成#include queue using namespace std; int minDepth(TreeNode* root) { if (root nullptr) return 0; queueTreeNode* q; q.push(root); int depth 1; while (!q.empty()) { int levelSize q.size(); for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); if (node-left nullptr node-right nullptr) { return depth; } if (node-left) q.push(node-left); if (node-right) q.push(node-right); } depth; } return depth; }这里有一个关键变量levelSize它的作用是记录当前层的节点数量。为什么不用!q.empty()直接循环因为那样就丢失了“层”的概念无法统计当前深度。每次循环开始时队列中恰好包含当前层的所有节点处理完这一层的levelSize个节点后队列里剩下的就是下一层的节点此时 depth 加1。这个技巧在层序遍历相关的题目里非常通用比如锯齿形遍历、每层最大值等掌握一次能复用很多场景。另一个细节是利用了C的隐式类型转换node-left和node-right是 TreeNode*在条件判断中直接使用等价于判断是否为空指针。如果你用Python写对应的判断是if node.left:原理相同。Python版本可以这样写from collections import deque def minDepth(root): if not root: return 0 q deque([root]) depth 1 while q: for _ in range(len(q)): node q.popleft() if not node.left and not node.right: return depth if node.left: q.append(node.left) if node.right: q.append(node.right) depth 1 return depth3.3 递归与迭代的全维度对比很多刚接触算法的读者会有个疑问既然BFS写法看起来更“聪明”那是不是只用BFS就够了我的观点是面试或者实际工程中两种写法都值得掌握它们在不同维度上各有取舍。从时间复杂度上看最坏情况下两者都是 O(n)因为如果树的形状是一个完整的满二叉树BFS也得遍历到最后一层才能找到叶子节点。但平均情况下BFS通常比DFS先找到答案尤其是最小深度远小于最大深度的时候。从空间复杂度上看DFS递归最坏 O(n)退化成链BFS最坏 O(w)w 是二叉树最大宽度对于满二叉树来说最后一层节点数约为 n/2所以 BFS 的空间复杂度也是 O(n)。两者在最坏情况下的空间复杂度同级但 DFS 的栈开销通常比队列小一些实际运行时更省内存。我做了一张表方便你直观对比维度DFS递归BFS迭代核心思路深入子树回溯取较小深度按层推进遇到叶子即返回平均时间复杂度O(n)O(n)但通常提前终止最坏时间复杂度O(n)O(n)最坏空间复杂度O(h)最坏O(n)O(w)最坏O(n)提前终止能力弱需遍历所有路径强首个叶子即返回代码可读性简洁但边界条件易错直观但模板代码稍长栈溢出风险树深时风险高无递归栈风险从实际工程角度看如果树的深度可能非常大我会优先选BFS迭代如果树比较平衡且深度可控DFS递归写起来更快。面试时可以主动和面试官讨论这个取舍这比闷头写代码更能展示你的架构思维。4. 常见问题与排查技巧实录4.1 写二叉树程序时为什么总是报运行时错误很多初学者在刷二叉树相关题目时频繁遇到“运行时错误”或者“空指针异常”其实大部分情况下问题出在三个地方空指针解引用、递归终止条件不完整、对节点值的错误假设。我在辅导新人刷题时总结了一套排查顺序按照这个顺序检查80%的问题能快速定位。首先是空指针问题。比如root-left-val这种写法如果root-left本身为空就会直接崩溃。正确做法是先判断root-left是否为空再访问它的值。在最小深度这题里最常见的空指针写法是if (root nullptr) return 0; int left minDepth(root-left); int right minDepth(root-right); return (left right ? left : right) 1;这段代码本身不会崩溃但逻辑错误。真正会崩溃的是你在判断叶子节点时写成了if (root-left nullptr || root-right nullptr)然后接下来直接访问 root-left 或 root-right 的值。这种“先判断后访问”的顺序问题是树类题目最容易踩的坑。其次是递归终止条件不完整。如果你写了if (root nullptr) return 0;但漏掉了对叶子节点的单独判断那么递归会一直跑到空节点才停止虽然程序不会崩但返回值会错。更隐蔽的情况是你自以为把终止条件写对了但实际上把写成了||导致所有只有一个子节点的节点被误判为叶子。这种错误在代码审查时非常难发现因为单看代码逻辑很自洽只有跑测试用例才会暴露。最后是对节点值的错误假设。这不是最小深度这题的专用坑但二叉树题目经常会混进来。比如题目说节点值都是正整数有人就想着用节点值的大小来做剪枝结果测试数据里出现了0或负数逻辑就直接错了。建议做题前先看清楚题目给的节点值范围和树节点数量范围不要想当然。4.2 题目变体与边界情况的深度讨论最小深度这个考点在面试中经常以变体形式出现。最常见的变体是空树的最小深度应该返回0还是1这个问题的答案取决于题目的具体定义。LeetCode原题中明确说了“从根节点到最近叶子节点的最短路径上的节点数量”所以空树没有路径返回0。但如果你在系统设计或者自定义API中使用了这个概念需要单独和需求方确认语义不要想当然。另一个变体是“判断一棵树是否为满二叉树”或者“是否是完全二叉树”这些题目也依赖于对叶子节点和层序的理解。比如完全二叉树的定义是“除了最后一层外每一层都被填满且最后一层的节点都靠左排列”这个定义用BFS判断非常容易遇到第一个空节点后如果后面还能遇到非空节点就不是完全二叉树。你看BFS的层序遍历在树类题目中的应用远比一道题广泛。还有一个值得提的变化是最小深度计算的是“节点数量”有些类似题计算的是“边的数量”比如求根节点到最近叶子节点的最短路径边数。这时候答案会在节点数基础上减1。这类细节上的差异在代码实现时通常只是返回值的微调但在理解题意时很容易被忽略。4.3 关联考点二叉树遍历、搜索二叉树与顺序存储聊完最小深度本身我想把这个题目放到整个二叉树知识体系中来看。最小深度的解法本质上是二叉树的遍历DFS和BFS各占半壁江山。而二叉树的遍历又和许多看似无关的知识点交织在一起。比如“搜索二叉树”这个概念。搜索二叉树BST的节点具有“左小右大”的特性但这道题的解法并不依赖节点值的大小关系。真正需要BST性质的是“验证二叉搜索树”或“BST中的第K小元素”这类题目。不过有一个联系很重要BST的深度和平衡性直接影响到查找效率如果一棵BST退化成了链表最小深度可能接近最大深度插入/查找的时间复杂度就退化成了O(n)。这解释了为什么工程中普遍使用平衡二叉树如AVL树、红黑树来保证操作效率。再比如“线索二叉树”和“顺序存储”。线索二叉树把空指针利用起来指向中序遍历的前驱和后继它的出现动机就是因为递归遍历在大规模树上效率不高。而顺序存储用数组存完全二叉树下标 i 的左右孩子分别为 2i1 和 2i2在某些场景下能避免指针开销Java的 PriorityQueue 内部就是用数组实现的二叉堆。这些概念看上去和最小深度没什么直接关系但它们背后都是对“如何高效组织和访问树结构”这一核心问题的不同回答。当你理解了这些底层关系回过头再看最小深度这道题会发现自己看到的不是一道孤立的题而是整个树结构知识网络的一个节点。4.4 刷题时的测试用例设计技巧最后分享一个我刷这道题时实际用过的测试用例清单。LeetCode的评判系统虽然会提供测试用例但自己动手设计边界用例是训练代码能力非常有效的方式。我做题时至少会准备以下这些情况空树[]期望结果0只有一个根节点[1]期望结果1完全二叉树[3,9,20,null,null,15,7]期望结果2只有左子树的斜树[1,2,null,3,null]实际数组表示可能需要更谨慎期望结果是3左子树浅但右子树深的树比如根节点左孩子是叶子右孩子下挂一串期望结果2这种用例最能验证BFS的提前终止优势。为什么要特别准备“左子树浅但右子树深”这种情况因为它是递归写法的易错点也是BFS提前终止的最直观体现。如果递归写法在左右子树都非空时没有正确取较小值或者错误地让空子树深度0参与比较这种用例一定能让错误暴露出来。调试的时候如果实在想不通代码哪里错了有个笨但有效的办法在递归函数入口打印出来当前节点值配合缩进展示当前深度。我第一次做这道题时就这样干过输出结果后立刻能看出递归路径哪一步出了问题。这个“打印法”虽然不能用于提交但作为调试手段比盯着代码干想效率高得多。5. 从最小深度延伸出的面试答题思路5.1 如何在面试中展示这道题的完整思路这道题出现在面试中的概率其实挺高因为“简单题中藏着边界陷阱”非常适合考察候选人的基础功底。我建议在面试时按照“定义确认 → 思路展开 → 代码实现 → 测试验证 → 复杂度分析”的顺序来回答这个流程本身就是一个完整的解题闭环。先和面试官确认叶子节点的定义主动问一句“请问叶子节点是指左右孩子都为空的节点对吗”这个问题看似多余实际上能展示你对边界条件的敏感度也能避免后续沟通出现理解偏差。然后说明为什么不能直接套用最大深度的模板把“空子树不等于路径”这个核心洞察清晰表达出来。在讲到递归实现时不需要急着写代码先阐述你的递归三部曲终止条件是什么、叶子节点怎么判断、单层逻辑如何分情况。能把这个讲清楚面试官通常已经认可了你的思路。如果时间允许再补充BFS迭代方案并强调它的提前终止优势。最后给出测试用例特别是单侧为空的场景证明你对自己的代码有把握。这套组合拳下来比闷头写对代码留下的印象要深刻得多。5.2 从一道题到一类问题树的层次思维做一个简单的总结性收尾当然不是那种AI式的总结而是我自己的思考我做题做了几百道之后最大的感触是刷题不只是为了过面试更是在训练一种“用结构化的方式拆解问题”的思维。二叉树最小深度这道题看似简单但它把递归、迭代、边界条件、复杂度分析、测试用例设计这些基本功全部串起来了。最后再分享一个实用的小技巧如果你在递归写法里对边界条件不够自信可以先跑一遍递归深搜再跑一遍BFS对比两个结果是否一致。我在实际开发中经常用这种“双实现互验”的方法来验证复杂树算法的正确性比自己一个人对着测试用例发呆要高效得多。这个习惯从刷题延续到了工作里帮我在好几个项目里躲过了隐性的边界问题。
返回列表