ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

二叉树最小深度全解析:递归与迭代边界条件及调试实战

二叉树最小深度全解析:递归与迭代边界条件及调试实战 先说个我印象挺深的场景面试题库里“二叉树的最大深度”大家刷得飞起递归三行收工。可一旦把“最大”换成“最小”不少人就卡住了——同样的树换成求最小深度怎么递归出来的答案莫名其妙少了一层尤其是当一棵树只有左子树、没有右子树时结果直接变成1怎么看怎么不对劲。这篇就把二叉树的最小深度彻底拆开讲明白递归怎么写、迭代怎么写、两者各自的边界条件在哪、为什么会报运行时错误以及我实际调试这类代码时踩过的坑。无论你是在准备面试还是刚学完二叉树遍历想把这部分补扎实这篇应该都能帮上忙。1. 最小深度到底是怎么定义的先把这个说清楚1.1 口径统一算节点数还是算边数最小深度的标准定义是从根节点到最近的叶子节点的最短路径上的节点数。注意这里说的是节点数不是边的条数。所以根节点自身的深度是1一棵只有根节点的树最小深度就是1空树的最小深度是0。这个口径在LeetCode、牛客这类刷题平台上是默认的但实际面到这个问题时我建议先跟面试官确认一句“深度的计算按节点数来算对吧”因为有的面试官习惯用“边数”描述路径长度这种情况下答案要整体减1。先对齐口径后面代码写起来才不会出现“为什么我的答案总是比预期多1”的尴尬。1.2 什么节点才算叶子定义里的另一个关键词是叶子节点。叶子节点是左右孩子都为空的节点不是“有一个孩子为空”的节点。这一个差别就是最小深度题和最大深度题最大的分水岭。先看一棵最简单的树1 / \ 2 3这棵树的最小深度是2因为根节点1到节点3的路径长度是2到节点2的路径长度也是2。再看这棵1 / 2 / \ 4 5根节点1只有左孩子2没有右孩子。此时最小深度是3——路径是1-2-4或者1-2-5。如果你按“看到某个孩子为空就停”的思路写很容易返回2甚至1这就是著名的单边陷阱。1.3 为什么不能直接套用最大深度的模板最大深度题目的递归写法几乎是肌肉记忆def max_depth(root): if root is None: return 0 return max(max_depth(root.left), max_depth(root.right)) 1有人想当然地把max换成min以为就完事了def min_depth_wrong(root): if root is None: return 0 return min(min_depth_wrong(root.left), min_depth_wrong(root.right)) 1这版代码在单边树上铁定出错。比如根节点1只有左孩子2代入计算左子树返回min_depth(2)1右子树为空返回0于是整体结果是min(1, 0) 1 1。但真实的最小深度是2。问题就出在空子树深度0参与了min比较被当成了“最短路径”。最大深度用max时空子树0永远不会被选中最小深度用min时空子树0却总是被选中。一句话总结最大深度看两条路谁更长最小深度看两条路谁先到叶子但空路不算路。2. 递归解法思路三句话代码两种写法2.1 递归前先想清楚三个终止条件递归解这个题其实就是在回答三个问题当前节点为空返回什么返回0这是递归出口也表示空树的深度。当前节点是叶子左右孩子都为空返回什么返回1只有它自己这一层。当前节点只有一边有子树怎么办不能再看空的那边而是直接递归非空的那边再加回当前这一层。只有左右孩子都非空时才像最大深度那样取左右子树的min但因为两边都非空min就不会被0干扰。2.2 第一版代码逻辑直白面试首选class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right def min_depth(root): if root is None: return 0 if root.left is None and root.right is None: return 1 if root.left is None: return min_depth(root.right) 1 if root.right is None: return min_depth(root.left) 1 return min(min_depth(root.left), min_depth(root.right)) 1这段代码的好处是每个分支都对应一个明确的场景面试时讲起来很顺。我自己给别人讲这个题时也喜欢先用这版因为它把“只有左子树”“只有右子树”“两边都有”这三种情况拆得明明白白不会让人困惑。这里还有个小细节先判断叶子节点再判断单边情况。顺序不能反因为叶子节点其实也属于“两边都空”但如果先做单边判断会漏掉叶子节点的情况返回011虽然碰巧对了逻辑上却是不完整的。2.3 第二版写法用0值合并分支代码更短如果你已经理解上面的思路可以看一个更精简的版本def min_depth_compact(root): if root is None: return 0 left min_depth_compact(root.left) right min_depth_compact(root.right) if left 0 or right 0: return left right 1 return min(left, right) 1这版的巧妙之处在于当一个孩子为空时对应的深度是0left right 1自动等于“非空那一侧深度 1”。左右都为空时0 0 1 1正好是叶子节点的深度。两边都非空时才进入min分支。不过我不太建议初学者一上来就背这个版本因为它把边界条件隐藏在加法里看着很短但理解起来需要绕一个弯。刷题场景下能讲清楚思路比写最短的代码更重要。2.4 递归最容易犯的错min模板直接套我在前面已经放出了错误模板这里再细看一个典型场景。假设有这样一棵树1 / 2 \ 3正确的最小深度是3路径1-2-3。错误模板算出来是1根节点1的左子树返回2路径2-3右子树为空返回0min得到0加1等于1。这个错误一旦出现很难靠肉眼看出来因为代码语法完全正确。所以我给自己定了一个规矩任何二叉树递归题写完先拿“只有左子树”“只有右子树”这两种不对称用例自测。这两个用例能筛掉绝大多数边界条件错误。3. 迭代解法层序遍历才是这个题的最优选择3.1 标题里的“迭代”到底指什么很多人看到“迭代”两个字会先想到Python的迭代器iterator但在这里是另一个意思。递归是函数自己调用自己靠调用栈一层层深入迭代则是用循环配合显式的数据结构最常见的是栈或队列来模拟遍历过程。二叉树里的迭代解法本质上是把“系统栈”换成“自己的栈”或者用“队列”实现一层层扫描。为什么最小深度用迭代有优势因为递归版的深度优先搜索DFS天然要把所有路径都探索完才能比较出最短路径而层序遍历BFS不一样它从根节点一层一层往外扫一旦在某一层遇到叶子节点这一层就是最小深度——前面的层都没有叶子说明不存在更浅的叶子可以直接返回。打个比方你在一栋楼里找最早出现的空房间从一楼往上逐层找某一层遇到了第一间空房你不需要再上楼确认了。递归则像把整栋楼每个房间都登记一遍再做比较。3.2 BFS核心代码和关键细节from collections import deque def min_depth_bfs(root): if root is None: return 0 q deque([root]) depth 1 while q: for _ in range(len(q)): node q.popleft() if node.left is None and node.right is None: return depth if node.left is not None: q.append(node.left) if node.right is not None: q.append(node.right) depth 1 return depth这里有几个细节值得单独拿出来说。第一个是for _ in range(len(q))。这个写法在进入循环时固定了当前层的节点数循环过程中往队列尾部追加的节点都属于下一层不会干扰本次遍历。如果不用这个固定长度而是直接while q逐节点弹出就没办法区分“当前层”和“下一层”depth的递增节奏也就乱了。第二个是叶子判断放在最前面。每弹出一个节点先看它是不是叶子是就直接返回当前depth这就是提前终止。注意这个判断不能放在把孩子入队之后再检查否则可能会多算一层。第三个是入队前先做非空判断。队列里永远不存空节点这样后面处理时就不用担心对None取.left报错也减少了不必要的判断。3.3 depth的初始值为什么是1这是BFS版本最容易错的地方。depth初始值必须是1不是0。根节点这一层在进入while循环时还没被遍历但这一层已经是第1层了。只有当整层扫描完都没有发现叶子节点depth才会加1表示要往第2层走。如果你把初始值写成0结果会整体少1。比如只有一个根节点的树入队后进入循环直接命中叶子判断返回0但正确答案是1。这个错误在空树用例上不会暴露因为空树直接走了if root is None的分支所以很容易漏掉。注意BFS中depth的递增时机是“整层扫完都没有叶子”而不是“每处理一个节点都加1”。计数逻辑写错的话结果会变成一个完全没意义的大数而且很难一眼看出来。3.4 栈版迭代DFS递归的另一种替代方案如果因为递归深度受限而想改用迭代但又不想用BFS也可以用显式栈模拟递归def min_depth_stack(root): if root is None: return 0 stack [(root, 1)] min_depth float(inf) while stack: node, depth stack.pop() if node.left is None and node.right is None: min_depth min(min_depth, depth) if node.left is not None: stack.append((node.left, depth 1)) if node.right is not None: stack.append((node.right, depth 1)) return min_depth这个版本没有提前终止的优势因为DFS必须先探索完所有路径才能确定最小值。但它的好处是不受系统递归栈深度限制——当树是一条上万层的链时递归版会直接爆栈而这个栈版能稳稳跑完。实际工程中遇到不可信的输入数据时我一般优先考虑这类不带系统递归的写法。4. 复杂度对比与场景选型4.1 时空复杂度对照解法时间复杂度空间复杂度核心特点递归DFSO(n)最坏O(n)平均O(log n)代码最直观但极端深度会爆栈迭代BFSO(n)但通常提前终止最坏O(n)找最小深度的实际体验最好迭代栈DFSO(n)最坏O(n)不受递归深度限制无提前终止严格来说BFS的时间复杂度仍然是O(n)因为最坏情况——比如一棵满二叉树所有叶子都集中在最后一层——它还是要遍历到最后一层才能返回。但平均场景下BFS往往比DFS更快遇到叶子尤其当树比较“宽”的时候提前终止的概率很高。空间复杂度上BFS的队列最多存某一层的全部节点。满二叉树最后一层的节点数约等于总节点数的一半所以空间复杂度也是O(n)。递归版的空间由调用栈深度决定链状树是O(n)平衡树是O(log n)。4.2 面试和工程分别怎么选面到这个题时我的回答节奏是先给递归版因为它逻辑最清晰、代码量最少面试官跟得上然后主动补一句“如果树的深度非常大递归可能栈溢出我可以用层序遍历改写”顺势把BFS版写出来。两个版本都展示出来面试官会觉得你有边界条件意识而不是背了一道题。工程场景的选择则更实际一点。如果树的规模明确很小几百几千节点递归完全没问题代码也最好维护。如果输入是用户可控的、可能构造出深度极大的树比如从外部接口读入的XML/JSON解析树或者文件系统目录树那就优先BFS或栈迭代把栈溢出风险从根上掐掉。4.3 顺手提几个相关变体求最大深度递归max(left, right) 1没有单边陷阱直接套模板即可。求N叉树最小深度思路完全一致遍历children数组时统计非空孩子的数量。求“所有根到叶路径”典型的DFS回溯和最小深度递归属于同一家族。求“从根到最近叶子的路径节点”可以在BFS返回时把路径记录下来。另外你可能会看到“快速排序非递归”这种说法它本质上就是用显式栈保存待处理区间把递归函数改写成循环——这跟二叉树迭代DFS是同一个思想凡是递归能写的通常都能用显式栈改写成迭代。这就是为什么不少排序、遍历题目会专门要求非递归实现。5. 调试实录运行时错误、栈溢出和死循环5.1 最常见的运行时错误对空节点取属性有一个热搜词是“写二叉树程序时为什么总是报运行时错误”我太有共鸣了。这类报错绝大多数都出在同一句话上在节点可能为None时直接访问了.left、.right或者.val。比如有人会把叶子判断写成if node.left.val is None and node.right.val is None:这行代码在node.left本身是None的时候直接抛AttributeError因为None没有.val属性。正确的写法是if node.left is None and node.right is None:我的排查习惯是在任何访问.left、.right、.val之前先问自己“这个节点会不会是None”。相应地递归函数的开头一律先判空哪怕是空树输入也能安全返回0而不是让程序在深层的某一行崩溃。5.2 递归栈溢出RecursionError当树是一条链深度达到几千层时递归版会抛RecursionError: maximum recursion depth exceeded。Python默认递归深度限制是1000所以几千层的树足够让递归版当场崩溃。这种报错不是逻辑错误而是写法不适合极端输入。排查方法很简单看到RecursionError就找递归函数然后改成BFS或栈迭代。LeetCode上有不少“运行时错误”的提交实际原因就是递归爆栈改写成迭代后直接通过。这个坑在最大深度的递归版里同样存在只是最小深度这题更容易碰到链状测试用例。5.3 BFS死循环depth增长节奏错了BFS版本按理说不容易出现死循环因为二叉树题目默认没有环。但如果你把depth 1写进了for循环内部就会出现一个很隐蔽的问题每处理一个节点depth就加1而不是每扫完一层加1。结果返回的深度比真实值大得多且完全没有规律。另一种死循环嫌疑是队列里混入了某个节点的父节点或重复节点——这在普通二叉树里不太可能但如果你把入队条件写反了比如在节点非空时反而不入队、为空时入队就会搞出奇怪的行为。诊断死循环我一般用打印法在每一层末尾打印当前队列的长度和depth打印几次基本就能定位是“层没分层”还是“入队条件错了”。5.4 自测用例清单不管用哪种解法我建议写完代码至少跑这六组用例用例期望结果验证重点空树0根判空是否处理只有一个根节点1叶子节点返回1只有左子树且左孩子是叶子2单边陷阱是否规避只有右子树且右孩子是叶子2对称的单边陷阱左右都有但左边短右边长左边深度min逻辑是否生效满二叉树树高BFS逐层扫描是否正常其中“单边”两类用例是重头戏能同时检验递归版和迭代版的核心边界逻辑。很多人在线提交通过率不高往往就是没拿这两种形状的树自测过。最后分享一个我自己用的小技巧现在写最小深度我已经不靠纸笔画树来验证了而是用几十行代码随机生成一堆二叉树然后递归版、BFS版、栈版三个写法同时跑比对结果是否完全一致。三个版本结果一致基本说明代码没问题。这种“多解法交叉验证”的思路比死记某一种答案要可靠得多——无论刷题还是做工程能写出来只是第一步能解释为什么这样写、边界在哪才是真正把这道题吃透。
返回列表