目录
- [LeetCode 111. 二叉树的最小深度](#LeetCode 111. 二叉树的最小深度)
- 一、先理解题目要求
- [二、第一反应:是不是直接使用 min?](#二、第一反应:是不是直接使用 min?)
- [三、为什么不能直接 min(left_depth, right_depth)?](#三、为什么不能直接 min(left_depth, right_depth)?)
- 四、重新梳理递归思路
-
- [情况 1:当前节点为空](#情况 1:当前节点为空)
- [情况 2:当前节点是叶子节点](#情况 2:当前节点是叶子节点)
- [情况 3:只有左子树](#情况 3:只有左子树)
- [情况 4:只有右子树](#情况 4:只有右子树)
- [情况 5:左右都有孩子](#情况 5:左右都有孩子)
- 五、完整代码
- 六、代码关键点解释
-
- [1. 为什么这份代码没有单独判断叶子节点?](#1. 为什么这份代码没有单独判断叶子节点?)
- [2. 为什么空节点返回 0,却不会影响最后的 min()?](#2. 为什么空节点返回 0,却不会影响最后的 min()?)
- [3. 空节点一定要返回 0 吗?能不能换一个值?](#3. 空节点一定要返回 0 吗?能不能换一个值?)
- 七、复杂度分析
- 八、最后复盘
LeetCode 111. 二叉树的最小深度
题目链接:111. 二叉树的最小深度
弄了个网站, 可以看代码是怎么操作达到最终要的结果: 过程可视化
如果还没有看过二叉树递归基础,可以先看:LeetCode 104. 二叉树的最大深度(递归解法)
这道题和 104 最大深度非常相似。
之前 104 要求找到根节点到最远叶子节点的距离。对于每个节点,我们只需要比较左右子树的最大深度,选择更深的一边,再加上当前节点本身的这一层。
因此,递归公式为:
当前节点的最大深度 = 1 + max(左子树最大深度, 右子树最大深度)
而 111 则要求找到 最近的叶子节点。
表面上看,似乎只需要把 max() 换成 min()?
但实际写起来就会发现,这里隐藏着一个容易忽略的问题 ~
一、先理解题目要求
二叉树的最小深度,指的是:
从根节点到最近叶子节点的最短路径上的节点数量。
这里有两个需要注意的地方。
首先,深度计算的是 节点数量 ,而不是经过的边数。例如 1 -> 2 -> 3 经过了两条边,但深度是 3。
其次,路径必须到达叶子节点才能结束。
所谓叶子节点,就是没有左孩子,也没有右孩子的节点。在 Python 中可以这样判断:
node.left is None and node.right is None
例如:
3
/ \
9 20
/ \
15 7
这棵树的叶子节点有 9、15、7,其中距离根节点最近的是 9。
对应路径为:
3 -> 9
这条路径包含 2 个节点,因此最小深度为 2。
所以这道题是要找到 根节点到某个真实叶子节点的最短路径。
二、第一反应:是不是直接使用 min?
看到这道题,我首先想到的是最大深度的递归写法。
如果还不熟悉递归求深度,可以先理解一个过程:
假设当前节点已经知道左子树和右子树各自的深度,那么它就可以在两个结果中选择一个,再加上当前节点本身的这一层。
例如最大深度的计算:
left_depth = find_depth(node.left)
right_depth = find_depth(node.right)
return max(left_depth, right_depth) + 1
其中,find_depth(node.left) 会计算左子树的最大深度,右子树同理。
max() 选择更深的一边,最后 +1 是把当前节点本身也算进去。
既然最大深度选择更长的一边,那么最小深度是不是只要选择更短的一边?
return min(left_depth, right_depth) + 1
乍一看,这个想法完全合理。
例如:
1
/ \
2 3
节点 2 和 3 都是叶子节点,因此它们各自返回的深度都是 1。
到了根节点 1,我们就能得到:
left_depth = 1
right_depth = 1
return min(1, 1) + 1 # 2
结果确实是 2,没有问题。
于是,我最开始写出了这样的递归:
class Solution:
def minDepth(self, root: TreeNode | None) -> int:
def find_depth(node):
if node is None:
return 0
left_depth = find_depth(node.left)
right_depth = find_depth(node.right)
return 1 + min(left_depth, right_depth)
return find_depth(root)
这个写法和求最大深度几乎一样,只是把 max() 改成了 min()。
但是它有一个很明显的漏洞:当一个节点只有左孩子或者只有右孩子时,计算结果就可能出错。
三、为什么不能直接 min(left_depth, right_depth)?
先来看一棵只有左子树的二叉树:
1
/
2
/
3
这棵树只有一个叶子节点 3,所以最短路径只能是:
1 -> 2 -> 3
正确答案应该是 3。
但是如果使用前面的代码,会发生什么?
我们从最底层开始分析。当递归计算到叶子节点 3 时,它的左右孩子都是 None,因此:
left_depth = 0
right_depth = 0
return 1 + min(0, 0) # 1
这个结果没有问题,叶子节点本身的深度就是 1。
接下来递归返回到节点 2。
节点 2 的左子树是节点 3,所以 left_depth = 1。
但是, 问题来了, 它没有右孩子,因此递归到右边时会返回 0。
此时执行:
return 1 + min(1, 0)
得到的结果最终会是 1!
可是节点 2 到最近叶子节点 3 的路径明明是:
2 -> 3
深度应该是 2。
所以, 问题出现了,min() 把不存在的右子树当成了一条更短的路径。
这里的
0原先只是用来表示子树不存在,到了叶子节点了, 在这里却被用于代表右边真的有一条长度为 0 的路径可以到达叶子节点。
因此,我们需要考虑到不存在的子树, 不能让它在直接返回 0 的时候 拿来比较。
所以, 在 104 最大深度中,空子树返回 0 没有问题,而到了 111 却会出错。
因为:
- 求最大深度时,
max(1, 0)会选择真实存在的路径。 - 求最小深度时,
min(1, 0)反而会选择不存在的路径。
所以这道题真正需要处理的,其实并不是如何求 min(),而是如何判断哪条路径可以参与比较。
四、重新梳理递归思路
发现问题之后,我们需要重新确定递归函数的含义。
这里定义:
find_depth(node)
表示以当前节点为根的子树,到最近叶子节点的最小深度。
接下来就需要考虑几种情况。
情况 1:当前节点为空
当 node is None 时,说明当前子树不存在,因此返回 0,作为递归的终止条件。
if node is None:
return 0
不过要注意,这里的 0 不是有效的叶子路径深度。
如果后面需要使用 min(),还得先确认左右子树是否真的存在。
情况 2:当前节点是叶子节点
例如:
1
因为节点 1 没有左右孩子,所以它本身就是叶子节点,最小深度为 1。
我们可以直接判断:
if node.left is None and node.right is None:
return 1
不过,这个条件并不是必须单独写出来的。
如果左右孩子都不存在,那么左右递归都会返回 0。只要我们正确处理单侧为空的情况,最终同样可以得到 0 + 1 = 1。
因此,后面的完整代码会直接使用单侧为空的判断,不再额外添加叶子节点的特殊判断。
情况 3:只有左子树
例如:
1
/
2
节点 1 只有左孩子,右边根本不存在能够到达叶子节点的路径。
既然只有左边可以走,我们就不应该再比较左右深度,而是直接使用左子树的结果:
if node.right is None:
return left_depth + 1
这里的 +1 仍然表示把当前节点本身计算进去。
情况 4:只有右子树
同理,如果当前节点没有左孩子,那么只能沿着右子树寻找叶子节点。
if node.left is None:
return right_depth + 1
情况 5:左右都有孩子
例如:
1
/ \
2 3
当左右子树都存在时,两边才都有可能找到叶子节点。
这时候我们就可以正常比较左右子树的最小深度,选择更短的一边:
return min(left_depth, right_depth) + 1
到这里,整个递归思路就清楚了。
text
递归求出左右子树深度
↓
左孩子为空?
/ \
是 否
↓ ↓
返回 right_depth+1 右孩子为空?
/ \
是 否
↓ ↓
返回 left_depth+1 返回 min(left_depth, right_depth)+1
五、完整代码
class Solution:
def minDepth(self, root: TreeNode | None) -> int:
def find_depth(node):
if node is None:
return 0
left_depth = find_depth(node.left)
right_depth = find_depth(node.right)
# 没有左孩子,只能走右边
if node.left is None:
return right_depth + 1
# 没有右孩子,只能走左边
if node.right is None:
return left_depth + 1
# 左右都有孩子,选择更短路径
return min(left_depth, right_depth) + 1
return find_depth(root)
六、代码关键点解释
1. 为什么这份代码没有单独判断叶子节点?
前面提到,叶子节点没有左右孩子,因此它的最小深度应该是 1。
按照这个思路,我们似乎应该单独写一个判断:
if node.left is None and node.right is None:
return 1
但在完整代码中,我们并没有这样写,为什么仍然能够正确处理叶子节点呢?
先看原来的代码:
left_depth = find_depth(node.left)
right_depth = find_depth(node.right)
if node.left is None:
return right_depth + 1
if node.right is None:
return left_depth + 1
return min(left_depth, right_depth) + 1
假设当前节点就是一个叶子节点:
3
/ \
None None
由于左右孩子都是 None,递归计算后,left_depth 和 right_depth 都等于 0。
接下来,程序遇到第一个判断:
if node.left is None:
return right_depth + 1
这里需要注意,这个条件只要求左孩子为空,并没有规定右孩子必须存在。
所以无论是「只有右孩子」,还是「左右孩子都没有」,都会满足 node.left is None。
当前节点恰好是叶子节点,左孩子当然也是 None,因此条件成立,直接返回:
return 0 + 1 # 1
这样就得到了正确的叶子节点深度。
也就是说,虽然我们最初是为了处理单侧子树不存在的情况才写出这个判断,但它实际上也包含了左右子树都不存在的情况。
因此,叶子节点不是没有被处理,而是在第一个 if 判断中就已经返回了 1,根本不会执行后面的判断。
📒 当然,如果觉得这种写法不够直观,也完全可以单独判断叶子节点。两种写法都正确,只是前者省略了一个可以被现有条件覆盖的判断。
2. 为什么空节点返回 0,却不会影响最后的 min()?
这也是前面一直在尝试解释、最容易产生疑惑的地方。
既然空节点返回 0 会导致 min() 计算错误,那为什么我们仍然保留:
if node is None:
return 0
这里需要区分两个判断的作用。
-
node is None负责结束空节点的递归,并将0传回上一层; -
而下面的判断则负责检查当前节点是否缺少某一侧子树,避免将这个
0当成有效路径参与比较。if node.left is None:
return right_depth + 1if node.right is None:
return left_depth + 1
例如:
1
/
2
当递归计算到节点 1 时,左子树中的节点 2 会返回深度 1,而右子树不存在,因此返回 0。
此时,节点 1 拿到的结果是:
left_depth = 1
right_depth = 0
注意, 0 确实已经传回来了,但程序还没有执行 min()。
接下来,代码会先检查左右孩子是否存在:
if node.left is None:
return right_depth + 1
if node.right is None:
return left_depth + 1
由于节点 1 没有右孩子,第二个 if 条件成立,程序就会直接返回 left_depth + 1,也就是 2。
而一旦执行了 return,当前这一层递归就结束了,后面的 min() 根本不会执行。
所以,问题不在于空节点能不能返回 0,而在于父节点拿到这个 0 后,是否直接把它用于计算。
在我们的代码中,只有当左右孩子都存在时,程序才会执行:
return min(left_depth, right_depth) + 1
这样一来,空节点仍然可以返回 0 作为递归的终止结果,但它不会被错误地当成一条更短的路径。
换句话说,0 可以传回父节点,只是父节点需要先判断它是否代表一条真实存在的路径,再决定如何使用这个返回值。
3. 空节点一定要返回 0 吗?能不能换一个值?
写到这里,我又想到一个问题:既然空节点返回 0 会被 min() 当成最短路径,那能不能换一个特殊值,让它不被选中呢?
例如,使用 -1 或者负无穷 -inf?
但仔细想想就会发现,这样反而不行。因为我们使用的是 min(),它会优先选择更小的数。如果空节点返回 -1,那么 min(2, -1) 仍然会选择不存在的路径。
那反过来,如果使用正无穷 inf 呢?
min(2, float('inf')) # 2
这样一来,空节点虽然仍然参与了计算,但是由于它返回的是正无穷,min() 就会自动选择另一条真实存在的路径,而不需要像之前那样分别判断只有左子树或只有右子树的情况。
不过,这里又出现了一个新的问题。如果当前节点本身就是叶子节点,它的左右孩子都会返回 inf,于是:
1 + min(float('inf'), float('inf')) # inf
这显然不对。
叶子节点的最小深度应该是 1,所以使用这种写法时,我们需要单独判断叶子节点。
另外,整棵树为空时,题目要求返回 0,因此还需要在进入递归前处理空树。
这样就得到另一种写法:
class Solution:
def minDepth(self, root: TreeNode | None) -> int:
if root is None:
return 0
def find_depth(node):
if node is None:
return float('inf')
# 叶子节点的深度为 1
if node.left is None and node.right is None:
return 1
left_depth = find_depth(node.left)
right_depth = find_depth(node.right)
return 1 + min(left_depth, right_depth)
return find_depth(root)
例如,某个节点只有左子树,左子树返回深度 2,而不存在的右子树返回 inf,那么当前节点的结果就是:
1 + min(2, float('inf')) # 3
这样就能够正常得到最小深度。
所以,空节点并不是只能返回 0,而是要看我们准备如何处理这个返回值。
- 返回
0:需要提前判断左右子树是否存在,避免不存在的路径参与min()比较。 - 返回
inf:可以让min()自动选择存在的路径,但需要单独处理叶子节点和整棵树为空的情况。
两种写法都正确,时间复杂度都是 O(n),递归栈空间复杂度都是 O(h)。
这里最值得注意的是,不能只考虑递归终止时返回什么,还要考虑返回值传给父节点之后,会不会影响后面的计算。
这也是为什么有时候我们需要使用特殊值,有时候又需要额外添加判断。重点不在于选择哪个固定的返回值,而在于让返回值的含义和后续计算保持一致。
七、复杂度分析
-
时间复杂度:O(n)
-
在最坏情况下,递归需要访问二叉树中的每一个节点。对于每个节点,我们只进行固定次数的判断和计算,因此整体时间复杂度为
O(n),其中n为二叉树的节点数量。 -
虽然代码同时递归左右子树,但每个节点只会被访问一次,并不会因为有两个递归调用就变成
O(n²)。
-
-
空间复杂度:O(h)
-
递归需要使用调用栈保存当前执行路径,调用栈的最大深度取决于二叉树的高度
h。 -
如果二叉树比较平衡,空间复杂度为
O(log n);如果退化成一条链,最坏情况下则为O(n)。
-
八、最后复盘
回过头来看,这道题最初的想法并没有太大问题。
既然最大深度可以使用:
max(left_depth, right_depth) + 1
那么最小深度自然会想到:
min(left_depth, right_depth) + 1
真正的问题是,我们忽略了一条路径是否真实存在。
- 在求最大深度时,不存在的子树返回
0,不会影响我们选择更长的路径; - 但在求最小深度时,同样的
0却可能被误认为是最短路径。
所以这道题并不是简单地把 max() 换成 min(),而是需要在计算前判断左右子树是否存在。
这也是从 104 到 111 很值得注意的一点:虽然两道题都通过递归计算深度,但是我们不能只照搬递归公式,还需要思考返回值代表什么,以及这个返回值能否参与当前节点的计算。
以后遇到类似的递归题,可以尝试先去想三个问题:
- 当前递归函数返回的结果代表什么?
- 递归什么时候停止,终止时应该返回什么?
- 父节点拿到子树的返回值之后,能不能直接使用?是否存在需要特殊处理的情况?