ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二叉树最小深度详解:递归与BFS两种写法的暗坑与解法

二叉树最小深度详解:递归与BFS两种写法的暗坑与解法 二叉树的最小深度这道题几乎每个刷过LeetCode的人都会遇到但真正能把递归和迭代两种写法都吃透、还能在面试里讲明白的人其实不多。原因很简单最小深度这个知识点不算难但它恰好踩在一堆基础能力的交汇点上——递归的终止条件设计、树的遍历顺序选择、队列和栈的运用习惯。任何一个环节有模糊地带代码写出来就会在边界用例上翻车。我当年在准备面试时被这道题问倒过一次。当时脑子里只有层序遍历那套模板上来就写了个队列版本结果面试官反问了一句如果根节点只有一个孩子你的代码返回多少当场我就懵了。后来复盘才发现最小深度远不是套模板这么简单叶子的定义、空节点的处理、递归返回值的语义这些细节稍有疏忽就会得出错误结果。这篇文章就把我对这道题的理解、两种写法的完整实现以及那些网上没人跟你讲明白的坑一次说清楚。1. 题目到底在考什么最小深度最容易踩的暗坑1.1 深度和路径的全貌先别急着写代码把定义嚼碎二叉树的最小深度这六个字里最核心的不是深度而是叶子节点。LeetCode官方定义是最小深度是从根节点到最近叶子节点的最短路径上的节点数量。这个定义里有两个关键约束第一路径的终点必须是叶子节点也就是左右孩子都为空的节点第二路径必须完整不能只走一半就停下来。举个最典型的反例一棵树只有根节点和左孩子右孩子为空。很多人的第一反应是最小深度2因为根到左孩子走了两步。但如果深想一步右子树为空的那条路径能算作一条有效路径吗显然不行因为右子树的终点不是叶子节点它只是走到了尽头但尽头处没有节点。所以这棵树的正确答案就是2因为唯一的完整路径就是根到左孩子。再看一种容易晕的情况一棵斜树每个节点只有一个孩子比如一路向左到底。这时候从根到唯一那个末端叶子的路径长度就是整棵树的深度也就是节点总数。最小深度和最大深度在这种形态下完全相等因为压根就没有分叉。如果非要按min(left, right)的公式硬套反而会出错。这个定义层面的差异直接决定了递归和迭代写法的终止条件设计逻辑。很多人报运行时错误、返回结果不对根源都在这。所以第一步先把这个必须抵达叶子节点的语义牢牢记住。1.2 最小深度和最大深度的本质差异为什么不能套用同一个公式先看最大深度大家最熟悉的就是分治思路一棵树的高度等于左子树高度和右子树高度的较大值再加1。递归终止条件是节点为空返回0。这个公式成立的前提是空路径的高度是0任意一条从根到任一节点的路径都可以参与比较反正取大中间节点路径不会影响结果。但最小深度不一样因为它取的是最近叶子的距离。如果你直接用 min(leftDepth, rightDepth)1那么当一个节点只有左子树、右子树为空时min(left, 0)会取出0导致这个节点被算成一个深度只有1的伪叶子——但实际上它并不是叶子。这就是为什么最大深度可以一句话搞定最小深度却必须讨论左右孩子为空的情况。为了更直观我用一个生活类比来解释把二叉树想象成一座迷宫根是入口叶子节点是出口。最大深度要找的是距离入口最远的出口中间走错路没关系总能兜回来最小深度要找的是离入口最近的出口这时候有些走廊尽头是死墙而不是出口你走进死墙还要走回头路这条路的长度就不能算作出口距离。所以防死墙空子树这件事是这道题的灵魂。2. 递归解法从分治思想到正确的终止条件2.1 递归思路拆解把问题交给子问题但边界必须单独处理递归的天然优势在于二叉树的定义本身就是递归的。求一棵树到最近叶子的距离本质上可以拆成两个子问题左子树到叶子的距离、右子树到叶子的距离。然后取其中较小的那个再加上根节点本身占的1层。这个思路听着简单但代码落地时有一个必须单独处理的例外情况如果当前节点的左子树为空、右子树不为空那么最近叶子只能藏在右子树里你不能把空子树的深度0拿进来比。反过来也同理。我用伪代码把这个逻辑表示一下大家感受一下和普通min公式的区别递归函数 depth(node): 如果 node 为空: 返回 0 如果 node.left 为空 and node.right 为空: 返回 1 如果 node.left 为空: 返回 depth(node.right) 1 如果 node.right 为空: 返回 depth(node.left) 1 否则: 返回 min(depth(node.left), depth(node.right)) 1这里单独判断只有一个孩子的分支就是为了避开空子树返回0导致的误判。有人可能会想那我把叶子判断提前在递归里直接判断当前节点是否为叶子是叶子就返回1。这样下面那些单孩子的情况其实也会被吸收——因为单孩子节点不是叶子继续递归如果一路递归到某个叶子自然就会走叶子返回1的分支。这种写法本质上和我上面的一致只是把空判断交给了下一次递归调用时的nodenull处理。2.2 递归实现代码Python和Go两种写法逐行注释先给出我平时用得最多的Python版本class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right def minDepth(root: TreeNode) - int: # 空树的深度按0处理这是递归基 if not root: return 0 # 叶子节点左右孩子都为空深度为1 if not root.left and not root.right: return 1 # 只有一个孩子的情况必须沿非空那侧继续找叶子 if not root.left: return minDepth(root.right) 1 if not root.right: return minDepth(root.left) 1 # 两侧都有孩子取较小的一侧 return min(minDepth(root.left), minDepth(root.right)) 1每一步的作用都一样核心就是避开空子树。如果把中间两个单孩子分支删掉直接写成1 min(minDepth(root.left), minDepth(root.right))遇到那种只有一个孩子的树就会出错比如根节点只有右孩子min(0, 右子树深度) 会把0当结果最后返回1但正确的最近叶子距离是右子树深度1。Go版本我也会在面试或项目里用写法上更显式一些type TreeNode struct { Val int Left *TreeNode Right *TreeNode } func minDepth(root *TreeNode) int { if root nil { return 0 } if root.Left nil root.Right nil { return 1 } if root.Left nil { return minDepth(root.Right) 1 } if root.Right nil { return minDepth(root.Left) 1 } left : minDepth(root.Left) right : minDepth(root.Right) if left right { return left 1 } return right 1 }递归写法有个很容易忽略的点如果树的形态是一条超长的链斜树递归深度会等于链表长度。对于极其畸形的树这可能导致函数调用栈溢出。实际面试时可以先提一句递归实现简单但在极端情况下有栈溢出风险然后再给出迭代版本这会让面试官觉得你有系统性的工程思维。2.3 递归的复杂度分析时间空间都藏在树的形态里时间复杂度很好算每个节点至少要访问一次所以是O(n)n是节点总数。空间复杂度则取决于递归调用栈的深度平衡树的情况下栈深度是O(log n)斜树情况下退化成O(n)。这个n级别的空间占用在节点数上万甚至几十万时是实实在在的开销。很多教材上会写递归解法空间复杂度是O(logn)那只适用于平衡树的平均情况。对于最坏情况我必须强调它可能是O(n)。这也是我建议在真正处理大规模数据、或者作为常驻后台服务的一部分去调用这个算法时优先写迭代版本的原因。3. 迭代解法层序遍历才是最小深度的最优解3.1 为什么说迭代首选BFS而不是DFS迭代解法有两条技术路线。一条是模仿递归思路用显式栈做深度优先遍历DFS同时维护当前深度。另一条是用队列做广度优先遍历BFS一层一层往下走找到第一个叶子直接返回。两条路都正确但工程上BFS有本质优势——因为BFS按层逐层推进天然是靠近根节点的优先访问顺序第一个被发现的叶子必然在最小深度那条路径上。而DFS则必须遍历完整棵树即使第一条路径已经命中了最终的答案你依然要在脑子里维护一个当前最小深度并且遍历所有节点才能确认没有更近的叶子。打个比方你要在一栋楼里找最近的安全出口BFS的做法是从一楼开始一层一层扫第一个扫到的出口就是答案DFS的做法是随便挑一个楼梯一直往下冲冲到地下室后再回到一楼重新试另一条楼梯直到把所有走廊都走过一遍才能下结论。两者都对但BFS明显更贴合问题本身——最短路问题天然用广度优先。3.2 队列实现的层序优先第一个叶子出现就收工队列版本的思路非常清晰我把每一步拆开来讲from collections import deque def minDepth_iterative(root: TreeNode) - int: if not root: return 0 queue deque() queue.append(root) depth 1 while queue: # 取出当前层的所有节点逐个检查 for _ in range(len(queue)): node queue.popleft() # 第一个遇到的叶子就是最近叶子 if not node.left and not node.right: return depth # 把下一层的节点入队 if node.left: queue.append(node.left) if node.right: queue.append(node.right) # 当前层没有叶子继续往下 depth 1 return depth # 实际不会走到这里因为空树已经过滤这段代码有几个关键操作为什么是这样写for _ in range(len(queue))这个循环非常巧妙。在进入这个for循环之前queue里恰好装着当前层的全部节点。len(queue)在循环开始时固定下来所以这个循环只处理当前层不吞掉下一层的节点。处理完当前层后队列里剩下的全是下一层的节点此时depth加1继续下一轮。每次从队列头部取节点后先判断它是不是叶子是就直接返回。因为我们是按层遍历的第一批拿到的节点是离根最近的层所以最早的叶子一定能给出最小深度。这就是第一个叶子出现就收工的含义。这里有一个非常省事的点不需要额外记录每个节点所在层数。因为层数信息被for循环批量消费队列这个结构隐含地维护了。如果你在用其他语言时总想额外存一个(depth, node)的pair说明还没有完全理解层数其实是批次消费的副产品。3.3 复杂度与边界为什么这个写法在工程里最稳BFS的时间复杂度最坏情况仍然是O(n)但很多人没意识到平均情况下它往往提前终止实际访问节点数远小于n。以一棵非常平衡、节点数量上百万的满二叉树为例最小深度很可能在第15层左右就命中了而BFS只需要访问到第15层就结束远远不需要遍历全部节点。如果把这道题放在一个被高频调用的服务里这个提前终止的收益是实打实的性能优化。空间复杂度方面BFS最坏情况要存储某一层的全部节点数量级是O(w)其中w是树的最大宽度。在满二叉树里最底层的宽度接近n/2所以最坏空间O(n)。但平均情况远远小于这个值。DFS显式栈版本虽然最坏栈深度是O(height)但在纯找最小深度这个场景里BFS的整体体验依然更好——因为它把时间快和空间可控结合得很好。边界情况也值得单独说。空树返回0只有一个根节点的树第一层for循环就会命中叶子直接返回1一棵只有一个孩子的链状树BFS会一层层往下最终在末端叶子处返回总节点数。这些情况全部被代码的逻辑覆盖不需要额外写特殊判断。4. 两种思路的完整对比谁更快谁更省面试怎么说4.1 时间、空间、提前终止率的多维对比表可以把递归、DFS栈迭代、BFS队列迭代放在一张表里对比对比维度递归分治DFS显式栈迭代BFS队列迭代时间复杂度最坏O(n)O(n)O(n)最坏空间复杂度O(n)栈深度O(n)显式栈O(n)队列宽度能否提前终止否须遍历完全树否须遍历完全树是命中叶子即停实现难度极简中等需维护深度中等需理解批次处理代码可读性最高一般较高工程应用中默认推荐度高性能场景不推荐不推荐推荐这里有个容易被误会的点DFS显式栈的时间复杂度虽然是O(n)但它其实也可以做剪枝——如果当前路径深度已经超过了当前记录的最小深度就没必要继续往下走了。这确实可以优化但剪枝逻辑一旦加上代码复杂度直线上升。BFS天然不需要这类额外的剪枝因为层序保证第一个叶子就是答案。对于面试场景我更建议把BFS作为主答案把递归作为加分项补充理解。4.2 面试实战如何从递归引到迭代层层递进讲清楚面试考这道题时多数人的习惯是直接给出递归版然后面试官问一句能不能用迭代实现。但可以把节奏控制得更好先讲清楚定义和叶子陷阱再给递归版然后主动说明递归虽然简洁但在最坏情况下栈深度等于树高可能出现栈溢出随后自然引入BFS迭代版。这样一条线走下来面试官能直观看到你对问题的理解层级。递归版的讲法建议这样组织先看递归基空节点返回0叶子节点返回1。如果一个节点只有一个孩子我们必须沿着这个孩子继续往深处找叶子否则把空子树当成0来比较就会出错。两个孩子的普通情况就取左右子树深度的较小值加1。关键是突出什么时候不能直接取min。BFS迭代版的讲法建议这样组织我可以用队列做层序遍历。每一轮处理一整层的节点处理完再进入下一层。只要在这一层发现某个节点的左右孩子都为空就说明遇到了当前最近的叶子直接返回深度。因为层序天然按距离根由近到远推进第一次遇到的叶子就是全局最小深度的终点。至于为什么for循环能保证只处理当前层可以提一句因为进入循环时队列长度已经固定这个长度就是当前层的节点个数。4.3 从这道题延展出的高频变体题最小深度这道题经常作为基础题后面藏着一堆变体。比如求二叉树最大深度——那就是一路到底不需要叶子判断直接min换成max即可。再比如判断一棵树是否为平衡二叉树——需要递归求左右子树高度并检查两边高度差是否超过1。还有寻找二叉树最底层最左边的节点——用BFS从右往左遍历时最后一个被访问的节点就是答案这类题考察的是对层序特性的活用。如果面试官继续加码还可能问如何求N叉树的最小深度——思路完全一致只不过把左右孩子换成children列表需要遍历所有孩子并检查是否所有孩子都为空来判断叶子节点。这些变体的核心仍然是我们最开始讲的那个定义终点必须是叶子节点。5. 写二叉树程序时为什么总是报运行时错误这个热搜词我太有共鸣了。遇到过很多同学写树相关的题明明逻辑看起来没错一提交就报Core Dump或空指针异常。我总结了几个高发原因全部和树的递归结构直接相关。5.1 空指针解引用一切树问题的头号杀手树节点的左右孩子可能为空这是树的基本事实。很多人写完代码后默认每个节点都有两个孩子访问node.left.val或者node.right.val时却没有检查node.left或node.right本身是否为空。运行时错误就这么来了。解决办法只有一个在每个需要访问孩子节点字段的语句之前先确认当前节点是否为null。如果当前节点为null任何node.left、node.right、node.val都是非法操作。所以递归函数第一步永远应该判断if root is None: return ...这个基座是雷打不动的。一个工程上的细节很多静态语言比如C、Go里机制对空指针的访问会直接崩溃动态语言Python、JavaScript则抛异常。崩溃信息虽然不一样但排查方向一致——找没有判空就访问字段的代码路径。5.2 递归死循环几乎都是判断条件写反了另一种典型错误是没有递归基或者递归基永远不触发。比如把递归出口写成了root.left不为空才递归却忘了如果root本身为null第一次访问root.left就已经崩溃。再比如把叶子节点的判断条件写反写成左右孩子存在一个就不深入结果递归回到同一个节点出现死循环程序卡死或栈溢出。排查死循环的办法很机械在递归函数第一行打印当前节点的值看输出是否出现重复节点。如果某个节点被重复打印说明递归路径没有向叶子推进而是又回到了祖先节点——这种bug通常是递归调用里的参数传错了比如把root.left传成了root。5.3 输入格式和二进制的判空约定LeetCode风格隐藏规则很多人本地跑得好好的一提交LeetCode就报错问题往往出在输入格式上。LeetCode给的树的输入是层序序列化的数组比如[3,9,20,null,null,15,7]这里null代表某个位置没有节点。如果你的构建函数没有正确地把null位置的孩子也置为None数组后面那些元素就会被错误地挂到不该挂的位置上最终生成的树和预期完全不同。建议写一个通用的数组转二叉树辅助函数并多次测试边界情况空数组、只有一个元素的数组、数组中包含连续null、整棵树只有一条链。把树的构建器单独测试通过后再测试算法逻辑就能把树的构建问题和算法逻辑问题隔离开。很多同学分不清这两件事混在一起调试废了半天劲。5.4 测试用例的选取最小深度题必须覆盖五个边界不管最后提交到哪个平台我都会在本地先跑这几类用例确保万无一失用例类型输入示例期望输出说明空树[]0递归基和迭代入口要一致单节点[1]1根节点就是叶子只有左子树[1,null,2,3]3验证单子树分支标准满树[1,2,3,4,5,6,7]2根到第二层叶子距离链状树[1,2,null,3,null,4]4退化成链表形态这里尤其要注意只有左子树这种非对称用例。很多网上的题解没有讲清楚这个case导致照抄的代码在遇到这种树时返回错误结果。我自己就吃过这个亏两年前第一次提交时返回了1就是因为没有处理单子树分支。6. 从这道题看代码工程化一些实战中沉淀的经验6.1 递归的调用栈和队列的内存选择哪种方案看场景面试题答案和工程代码其实是两种审查标准。面试时递归版的短短几行最有表现力能迅速传达我懂递归分治。但在把这段逻辑放进真正的服务、处理不可控树规模的数据时BFS迭代版更稳。原因是递归版本受限于调用栈大小——实际环境中线程栈通常在1MB~8MB之间一个斜树节点上万就可能导致栈溢出。而这个概率并不低尤其是从数据库读取到的树形数据往往形态极其不均匀。如果对性能有更高要求还可以在BFS基础上做队列复用优化不创建全新的队列而是复用两个数组来回倒数据避免频繁的内存分配和GC压力。想象一下这个实现路径用两个list一个存放当前层节点一个存放下一层节点。处理完当前层后交换两者的引用。这在Go和Java里做起来特别顺手内存开销也更平滑。6.2 速查清单提交前的最后检查写完了代码提交前我习惯按这个清单过一遍空树是否返回0特殊处理是否只针对root为None。叶子定义是否严格为左右孩子都为空。递归版是否处理了只有一个孩子的情况。迭代版是否在每一层检查了叶子而不是跳过一层。测试数组是否覆盖了单左边链、单右边链、满树、空树。本地运行的树构建器是否能处理层序数组中的null。这些检查项看起来简单但每一条都对应一个真实发生过的事故。尤其是第三条最短的几分钟就能写完代码但少了它会浪费一小时的调试时间。6.3 后续扩展从最小深度到更多二叉树算法题的迁移路径最小深度这道题练完之后我建议按这个顺序往下刷层序遍历模板题、右视图、最大深度、平衡二叉树、路径总和I/II、最近公共祖先。你会发现这些题大量共享同一个基础设施队列的批次处理、递归的返回值语义、判空预防。把最小深度的两种写法吃透之后这些题的骨架基本上都能一秒钟搭出来。我个人实际工作中的体会是二叉树算法题的价值不在于会写几道题而在于它逼着你反复思考递归终止条件、共享可变状态的隐患、以及如何在有限资源下选择遍历顺序。这些能力在写业务代码时同样重要——比如解析树形配置、渲染嵌套组件、处理层级菜单逻辑结构和二叉树题大差不差。等你把最小深度这道题彻底想明白了后面很多为什么报错为什么这么写的疑问会迎刃而解。
返回列表