二叉树最小深度:从递归陷阱到BFS层序最优解
2026/9/17 5:49:37 网站建设 项目流程

刷 LeetCode 的朋友应该对这道题不陌生,LeetCode 111 Minimum Depth of Binary Tree 属于二叉树入门级别的题目,但说实话,它坑过的人远比想象中多。我见过不少能轻松写出最大深度的人,在这道题上交出第一版代码之后直接被 WA,卡住的点几乎都是同一个:没有想清楚“最小深度”和“最大深度”在递归上的本质区别。这道题非常适合用来检验你对二叉树递归的理解是否扎实,也很适合拿来练 BFS 的层序思维。无论你是刚开始刷题、准备面试,还是想系统过一遍二叉树经典题,这道题都值得认真复盘一次。

1. 先搞懂最小深度到底怎么算:两个定义级陷阱

1.1 叶子节点的判断决定了整个递归逻辑

先看 LeetCode 的官方定义:最小深度是从根节点到最近叶子节点的最短路径上的节点数量。

这里面的关键词不是“最小”,而是“叶子节点”。叶子节点指没有子节点的节点,不是“某个节点没有左孩子就算叶子”,也不是“某一层最先出现的节点”。很多错误的递归解法,本质上就是把叶子节点的判断搞错了。

比如一棵树长这样:

1 / \ 2 3 / \ 4 5

1 是根,2 和 3 有孩子,4 和 5 没有孩子。叶子节点是 4 和 5,所以最短路径是 1 -> 2 -> 4,路径上有 3 个节点,最小深度是 3。

这个例子很简单,但真正容易出错的是单子树的情况。比如一棵树只有一个左孩子链:

1 / 2 / 3

根节点 1 没有右孩子。如果按“最大深度的模板”,直接取左右子树的最小值,会得到min(0, 2) + 1 = 1。但 1 不是叶子节点,它的路径必须往下走到 3 才算结束,正确答案是 3。这就是第一个定义级陷阱:根节点没有右孩子,不代表最短路径可以停在根节点,因为根节点自己不是叶子。

1.2 千万别把“不存在的一边”当成“深度 0”

很多第一次写这道题的人,递归体是这么写的:

def minDepth(root): if not root: return 0 return min(minDepth(root.left), minDepth(root.right)) + 1

这个版本对应最大深度是完全没有问题的,因为最大深度求的是“最远能走到哪”,空子树返回 0,往上加 1 就能算出树的高度。但最小深度不能这么玩,原因很简单:空子树代表的是“这条路不存在”,而不是“这条路深度为 0 并且已经到达叶子”。

我把这个坑换成一个生活化的类比:你在一个地下迷宫里找最近的出口,有一条路走到底发现是死胡同,另一条路还没探索。你不能因为死胡同离你近,就直接说“最近的出口就在那个死胡同里”。死胡同代表“没有出口”,必须走到真正有出口的那条路上去看距离。空子树就是那个死胡同,它不是深度为 0 的叶子,而是“此路不通”。

所以在递归处理时,如果某个节点只有一个孩子,深度计算必须往存在的那个孩子方向走,而不是直接把不存在的那个孩子当作 0 参与比较。

1.3 从三个具体例子反推递归关系

我习惯在写递归之前先在草稿上推两个例子,确保递归关系是对的。

第一个例子:空树。

null

没有根节点,路径都不存在,最小深度就是 0。这是题目约定,也对应递归的终止条件。

第二个例子:只有一个根节点。

1

根节点就是叶子节点,路径只有它自己,节点数量是 1,所以最小深度是 1。

第三个例子:根节点只有左孩子。

1 / 2

根节点 1 不是叶子,path 必须往下走,到节点 2 才结束,所以最小深度是 2。

把这三个例子放到递归框架里看,可以总结出这样一套逻辑:

  • 当前节点为空,返回 0。
  • 当前节点左子树为空,右子树不为空,最小深度只可能来自右子树,答案是minDepth(right) + 1
  • 当前节点右子树为空,左子树不为空,最小深度只可能来自左子树,答案是minDepth(left) + 1
  • 左右子树都不为空,答案才是min(minDepth(left), minDepth(right)) + 1

这套逻辑是几个版本代码的共同内核,后面写的每一版递归,本质上都是在对这个逻辑做不同形式的表达。

2. 递归解法:DFS 三种写法的思路对比与代码选择

2.1 最稳的写法:先判空再一一分支

如果希望代码一看就懂、review 的时候不用解释,我最推荐这一版:

def minDepth(self, root: TreeNode) -> int: if not root: return 0 if not root.left and not root.right: return 1 if not root.left: return self.minDepth(root.right) + 1 if not root.right: return self.minDepth(root.left) + 1 return min(self.minDepth(root.left), self.minDepth(root.right)) + 1

这段代码把情况拆成四种:空节点、叶子节点、只有右子树、只有左子树、两边都有。每一步都对应“当前节点是不是叶子”“最短路径应该往哪走”的直观判断,不容易出错。

在纸上推一遍上面那个单链例子:

1 / 2 / 3

调用minDepth(1)时,root 非空,不是叶子,左孩子存在,右孩子为空,所以进入not root.right分支,返回minDepth(2) + 1minDepth(2)继续走同样的分支,变为minDepth(3) + 1minDepth(3)左右孩子都为空,返回 1。逐层往上带,最后得到 3。整个过程很清晰。

2.2 面试里常用的 min/max 技巧写法

另一种非常常见的写法是利用max来处理单子树情况,很多人第一次看到会觉得很巧妙,但其实它只是把上一版的分支合并了:

def minDepth(self, root: TreeNode) -> int: if not root: return 0 left = self.minDepth(root.left) right = self.minDepth(root.right) if not root.left or not root.right: return max(left, right) + 1 return min(left, right) + 1

这里的核心洞察是:对于只有一个孩子的节点,整个子树还没走到叶子,所以不能取空子树那一侧的最小值,只能走存在的孩子,也就是两个递归结果里更大的那一个。max(left, right)在这时候就是“非空子树的方向”。

不过要提醒一句,这种写法对“刚接触二叉树递归”的人来说不是那么直观。面试时如果用了这版,一定要能讲清楚为什么max在这里是合理的,否则面试官一问就可能露怯。我自己更推荐把第一种写法的分支逻辑讲明白,代码用哪种其实都可以。

还有一小撮人会写一个用 INF(一个很大的数)做占位的版本:

def minDepth(self, root: TreeNode) -> int: if not root: return 0 if not root.left and not root.right: return 1 ans = 10 ** 9 if root.left: ans = min(ans, self.minDepth(root.left) + 1) if root.right: ans = min(ans, self.minDepth(root.right) + 1) return ans

这个版本把“只有一边子树”的问题转化为“只递归存在的边,另一边不进入计算”,思路也很干净,但要注意初始值要足够大,而且本质上它比前两个版本多了一层叶子判断,在 LeetCode 上跑起来差别不大,面试时看个人习惯。

2.3 递归的深度代价与栈溢出问题

聊递归写法的时候,经常有人问:这题用递归会不会栈溢出?

二叉树的递归深度等于树的高度。最坏情况下,树退化成一条链,比如每个节点都只有左孩子,那么节点数量是 n 的时候,递归深度也是 n。Python 的默认递归深度限制通常在 1000 左右,如果给一棵一万个节点的单链树,递归版本直接就会抛出 RecursionError。LeetCode 的测试用例一般不会到这种极端程度,但你在本地自测时完全可能遇到。

所以面试被问到“递归有什么缺点”时,不要只说“写起来简单”,要能接上“递归深度受调用栈限制,极端退化树可能溢出,工程上更倾向于用显式的栈或 BFS”。能说出这一层,说明你对递归的理解不是背代码,而是真知道它的边界在哪。

3. BFS 层序遍历:为什么它是这道题的效率最优解

3.1 层序遍历与最小深度的天然匹配

这道题用 DFS 能做,但如果你去翻 LeetCode 的 Discuss 区,会发现时间更优的解法大多是 BFS。原因其实很简单:BFS 天然就是一层一层往下扫,第一次遇到叶子节点的时候,当前层数就是最小深度,可以立即返回,不需要把整棵树走完。

还是拿找钥匙做类比:你在一个多层建筑里找一层楼里藏着的保险箱钥匙,DFS 是拿着手电筒把第一层某个房间全部翻完之后再下到第二层;BFS 则是每层每层地排查,先看第一层所有房间,再看第二层所有房间。如果钥匙藏在很浅的位置,BFS 明显更早找到。最小深度本质上是“离根最近的叶子在哪一层”,这和 BFS 的搜索顺序完全一致。

3.2 Python 层序实现:遇到第一个叶子直接返回

BFS 的实现思路是使用队列,逐层保存节点,每处理完一层就把深度加 1,直到遇到某个节点左右孩子都为空,直接返回当前深度。

from collections import deque def minDepth(self, root: TreeNode) -> int: 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 depth

这套代码的关键点有两个。

第一,for _ in range(len(q))这一行的含义是“只处理当前这一层的节点”,因为在循环之前我已经取到了这一层的节点数量,循环过程中新加入队列的孩子节点不会被本次循环处理,而是留到下一层。这个技巧是层序遍历的标配,一定要理解熟。

第二,遇到叶子节点时return depth,注意 depth 在最外层循环开始时是 1,每处理完一层才加 1。例如根节点本身就既是根又是叶子时,进入循环后第一次 popleft 就发现leftright都是空,直接返回 1,处理得非常干净。

如果用 Java 写,代码风格也差不多:

public int minDepth(TreeNode root) { if (root == null) return 0; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int depth = 1; while (!queue.isEmpty()) { int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); if (node.left == null && node.right == null) { return depth; } if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } depth++; } return depth; }

3.3 BFS 和 DFS 到底怎么选:一张表讲清

有些人会有疑问:BFS 代码比递归长,为什么还要用它?

来看这张对照表,答案就很清楚了:

维度递归 DFS层序 BFS
平均时间需要遍历整棵树才能确定最小值遇到第一个叶子就返回,通常更快
最坏时间O(n)O(n)
空间极端情况下递归栈 O(n)队列最多存一层的节点,极端二叉树最坏也是 O(n)
代码长度短,但分支易错略长,但逻辑直观
对树结构的要求递归深度受限制无递归深度问题

从“找最近叶子”这个任务来看,BFS 更符合直觉,而且在树很“高”但“叶子出现在浅层”时性能优势明显。DFS 的优势在于代码简洁、不需要额外数据结构,在树比较平衡或者题目只要求返回结果不要求最优路径时,写起来更顺手。

面试时如果时间充裕,我建议先给 DFS 的递归版本,再补一句“其实这题用 BFS 更好,因为最早遇到的叶子就是答案”,然后顺手把 BFS 写出来。这会让面试官看到你不是只背模板,而是懂怎么根据题目特征选算法。

4. 面试官喜欢怎么扩展:最大深度、N 叉树与变体

4.1 最大深度 vs 最小深度:只差一个字,代码差很多

最大深度对应 LeetCode 104 Maximum Depth of Binary Tree,几乎人人都会写:

def maxDepth(self, root: TreeNode) -> int: if not root: return 0 return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1

最小深度不能直接套用,原因前面已经说过:空分支不被视为叶子。所以两个题目的核心差异在于“空节点如何参与计算”。最大深度里,空节点的 0 是合理的“走到底”,可以直接参与比较;最小深度里,空节点代表的“无路径”不能参与比较。

这里有个有意思的测试组合:一个完全二叉树,最大深度和最小深度相等。一个单链树,最大深度是 n,最小深度也是 n。一个根节点有左子树很深、右子树为空的树,最大深度取决于深的那一边,最小深度也取决于深的那一边。分析到这你会发现,最小深度的真正难点只在“单子树节点”的处理上,其他场景和最大深度很像。

4.2 N 叉树版本:children 列表怎么处理

LeetCode 还有一道 N 叉树的最大深度题,但最小深度同样可以自己扩展。N 叉树的节点定义不再是 left/right 两个指针,而是一个 children 列表。

def minDepth(self, root: 'Node') -> int: if not root: return 0 if not root.children: return 1 return min(self.minDepth(child) for child in root.children) + 1

如果某个节点是叶子,即 children 为空,说明路径到此结束,返回 1。否则就遍历所有孩子,取最小值加 1。这里能直接“无脑取 min”,因为不存在的孩子根本不会出现在 children 列表里,不会出现二叉树那种“空指针被当成 0 参与比较”的问题。这也是为什么 N 叉树版本反而比二叉树版本更好写。

用队列写 N 叉树的 BFS 也很自然,只需要把“判断 left/right 是否存在”改成“遍历 children”。这个变体在面试里如果被问到,可以直接顺着二叉树的 BFS 思路改,难度不大。

4.3 延伸到工程场景:最小深度的实际含义

这道题看起来非常学术,但“从起点到最近满足条件的节点”这个模型在工程里很常见。

比如后端做服务依赖的“最短调用链”分析时,要寻找离当前服务最近的故障叶子节点,本质上就是一个从根节点开始的 BFS,遇到第一个状态异常的节点就停止。再比如决策树的剪枝,要判断从根节点到最近叶子节点的距离,来决定是否需要合并子树,其实也和这道题同构。

刷题的时候如果能多问一句“这个模型的现实场景是什么”,对算法思维的养成会很有帮助。LeetCode 上的很多题都不是纯粹的脑筋急转弯,而是把现实问题抽象成了树或图上的搜索问题。

5. 从 WA 到一次 AC:边界测试与调试建议

5.1 提交前先列四个必测用例

不管用什么写法,我建议提交之前先把下面几类用例在本地或编辑器的示例测试里跑一遍:

测试用例期望结果为什么测它
[]0空树边界
[1]1根节点就是叶子
[1,2]2根节点只有左孩子,最容易触发错误分支
[3,9,20,null,null,15,7]2正常的多层树,验证常规逻辑

很多人在[1,2]上会栽跟头。如果你写的是“无脑 min”版本,会返回 1,而正确答案是 2。把这个用例背下来,基本就能挡住一多半的错误写法。

5.2 我踩过的两个坑:空指针当零、深度从 1 还是 0

我第一次写这道题时用的是递归,第一版代码:

def minDepth(self, root: TreeNode) -> int: if not root: return 0 return min(self.minDepth(root.left), self.minDepth(root.right)) + 1

提交之后在[1,2]上直接 WA。当时很困惑,因为最大深度代码明明是这么写的。后来仔细检查才意识到,我把“空子树”和“深度为 0 的路径”混为一谈了。空子树不是一条合法的路径结尾,因为空子树没有叶子节点。这个问题很容易被忽视,因为你对着代码看的时候,逻辑上会觉得“min(0, 1) + 1 = 1 很合理”,但树的结构规定了根节点不能作为叶子直接结束。

第二个坑是深度的起点。LeetCode 定义的最小深度返回的是“节点数量”,所以根节点的深度是 1,不是 0。如果写 BFS 时把初始 depth 设成 0,会导致最终的答案比预期少 1。做二叉树题目之前,先确认题目定义的是“路径上的节点数”还是“边的数量”,这决定了返回值要不要加 1。LeetCode 大多数二叉树题用的是节点数,但不同 OJ 或面试官可能约定不同,最好提前问清楚。

5.3 实用调试技巧:打印树 + 自定义测试

写递归题时,如果只在脑子里推演,很容易在层数比较多的时候转晕。我常用的一个办法是写一个简单的辅助函数,把树的前序遍历或层序遍历打出来,先确认树的结构符合预期,再去看递归结果。

比如在本地调试时快速构造一棵树并打印层序:

from collections import deque def build_tree_from_list(values): if not values: return None root = TreeNode(values[0]) q = deque([root]) idx = 1 while q and idx < len(values): node = q.popleft() if idx < len(values) and values[idx] is not None: node.left = TreeNode(values[idx]) q.append(node.left) idx += 1 if idx < len(values) and values[idx] is not None: node.right = TreeNode(values[idx]) q.append(node.right) idx += 1 return root def print_tree(root): if not root: return q = deque([root]) while q: node = q.popleft() print(node.val if node else None, end=' ') if node: q.append(node.left) q.append(node.right) print() root = build_tree_from_list([3, 9, 20, None, None, 15, 7]) print_tree(root)

LeetCode 网页版自带“自定义测试用例”功能,可以直接输入层序序列化后的数组,不用自己造辅助函数。本地练习时,上面这种 build 工具函数会经常用到,建议写一次然后收藏起来,后续二叉树题都会用得上。

5.4 一个通用的二叉树递归模板

最后分享一个我自己用下来很顺手的二叉树递归模板,其实也是从这道题总结出来的:

1. 写终止条件:空节点返回什么 2. 判断当前节点是不是叶子:是叶子就返回什么 3. 根据题目要求: - 找最大深度 -> 无脑 max(left, right) + 1 - 找最小深度 -> 左右子树有一边为空时走非空那边 4. 递归调用左子树和右子树 5. 合并结果

这道题对应到模板里,最关键的是第 3 步和第 5 步。能在写代码之前把这两个问题想清楚,比背任何现成代码都有效。对于面试来说,讲清楚自己的推导过程,比直接甩出一个标准答案更能反映真实水平。

每次做二叉树题,碰到那些“看起来很简单、一提交就出错”的题,我都会想起 LeetCode 111 这道题。它看起来只是最大深度的镜像题,实际却是对“叶子节点”和“空子树”这两个基本概念的最好检验。把这道题吃透,后面再做路径总和、二叉树最近公共祖先这类题目,你会明显感觉到一些共通的东西开始浮现。

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

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

立即咨询