☰
二叉树最大深度怎么求?递归与迭代两种解法详解
2026/10/8 20:34:19 网站建设 项目流程

1. 题意拆解:104题到底在问什么

1.1 深度这个“常识概念”的准确定义

刷力扣的二叉树系列,104题几乎是每个人绕不开的第一道门槛。它看起来简单——求一棵二叉树的最大深度,但很多新手在这里摔的第一跤,往往不是不会写代码,而是对“深度”这个概念的理解停留在直觉层面。

二叉树的深度,标准定义是从根节点到最远叶子节点的最长路径上的节点数。注意,这里说的是节点数,不是边数。所以一棵只有一个根节点的树,它的深度是1,而不是0。空树(null)的深度是0。这两个边界值是整道题的基础,也是后续所有递归和迭代写法的根基。

我见过不少人在评论区和题解区问:为什么我写的代码,遇到空树就返回1?答案几乎都是把空子树的返回值定义错了。空子树没有节点,深度就是0,返回1等于凭空多算了一层,而根节点那层已经在“当前递归层”的返回逻辑里加过了。

1.2 为什么这道题值得认真做

这道题在力扣里属于“二叉树的遍历”和“力扣热题100”的双料常客。从难度上看它只是简单题,但它的价值完全不在于“能AC”,而在于它是一把尺子——量出你对递归、分治、层序遍历、树形结构这四件事的掌握程度。

把这道题吃透,你相当于同时想清楚了三件事:二叉树天然适合用递归去描述,因为它本身就是递归定义的结构;树的深度和层数是同一枚硬币的两面;迭代解法中的层序(BFS)和深度优先(DFS)的栈模拟,都在这道题里有一个最朴素的原型。后面你刷到验证二叉搜索树、对称二叉树、二叉树的最小深度、二叉树的直径,甚至二叉树的序列化,都会反复用到这里练出来的手感。

所以别急着“AC完就走”,把104题的递归写发、迭代写发、出错原因、变体思路全部过一遍,这份功夫后面会成倍回馈给你。

2. 第一直觉:递归解法深挖

2.1 递归代码的每一行是怎么来的

递归解法的核心逻辑非常短,力扣官方题解给的版本是这样:

class Solution: def maxDepth(self, root: Optional[TreeNode]) -> int: if root is None: return 0 left_depth = self.maxDepth(root.left) right_depth = self.maxDepth(root.right) return max(left_depth, right_depth) + 1

这段代码只有五行是真正的逻辑,但每一行都必须说得出理由。

第一个if判断是递归的出口。树这个结构是递归定义的:一个节点左牵右挂分别指向两棵子树,子树又各自是树,直到指向空节点。递归函数必须处理这个“空”的情况,否则会在叶子节点上继续往下访问None.left,直接抛AttributeError。

left_depth和right_depth这两行,本质上是在委托子问题。求整棵树的最大深度,等于分别求左右子树各自的最大深度,然后取大的那个。这里体现了分治思想最朴素的形式:拆成子问题、分别解决、合并答案。注意这里我并没有把结果写回全局变量,而是作为返回值逐层向上传递,这是纯函数式写法,踩坑最少。

最后一行max(left_depth, right_depth) + 1,这1就是当前节点本身。你在子树深度上加上当前这一层,才能得到以当前节点为根的整棵子树的高度。忘记加1是新手最常见的错误,后果是返回结果永远比正确答案少1。

2.2 递归过程可视化:一颗最美味的洋葱

理解递归最好的方式,是随手画一个小的递归过程。假设树长这样:

3 / \ 9 20 / \ 15 7

调用maxDepth(3)时,它会先问maxDepth(9),由于9是叶子节点,它的左右孩子都是None,两个递归调用都返回0,于是叶子返回max(0,0)+1=1。

再看右侧:maxDepth(20)会先算maxDepth(15)得到1,再算maxDepth(7)得到1,然后返回max(1,1)+1=2。

回到根节点3,此时left_depth=1,right_depth=2,最终max(1,2)+1=3。答案正确。

这个过程就像剥洋葱,递归调用是一层层往里剥,return的时候再从里往外一层层包回去。很多人觉得递归“绕”,本质上是没有建立这个“先递后归”的画面。我建议新手拿笔在纸上画一次上述过程,代码立刻变透明。

2.3 递归解法的复杂度分析与隐患

时间复杂度是O(n),因为每个节点都恰好被访问一次;空间复杂度是O(height),height是树的高度,最坏情况下是一条链,递归栈会深度n,这也是很多“运行时错误”的源头——当树的节点数达到上万且是一棵退化链状树时,递归深度可能触发Python的递归限制(默认约1000层),或C++、Java的栈溢出。

这个隐患平时做题不容易暴露,因为力扣默认测试数据不会故意给你一条十万层的链。但在真实业务场景,比如解析一个极深的JSON结构、遍历一个深层的DOM树,这类递归写法就可能直接把调用栈打爆。这也是为什么迭代解法不是一个“能AC就行”的备选,而是工程上必须掌握的后手。

3. 换一种思路:迭代法也能优雅求解

3.1 层序遍历(BFS):把深度数成层数

递归解法虽短,但面试官问你“能不能不递归写一遍”的时候,你至少要有两套预案。最直观的迭代思路是层序遍历。

二叉树层序遍历的天然载体是队列。我们逐层把节点放进队列,处理完一层,深度加1,直到队列变空。这里有一个关键细节:如何知道“一层”在哪里结束?很多人的第一版代码是一股脑把左右孩子入队,然后while queue非空就pop一个处理一个,结果深度数成了节点总数。

正确做法是每次进入循环时,先用len(queue)快照当前层的节点数,然后只处理这么多节点,它们处理过程中新加入的左右孩子属于下一层,不在本次循环范围内。

from collections import deque class Solution: def maxDepth(self, root: Optional[TreeNode]) -> int: if root is None: return 0 q = deque([root]) depth = 0 while q: depth += 1 level_size = len(q) for _ in range(level_size): node = q.popleft() if node.left: q.append(node.left) if node.right: q.append(node.right) return depth

这段代码里,depth += 1写在每层循环的开头,意味着“又处理完了一层”。对一棵每个节点只有左孩子的退化链,队列每次循环里只有一个节点,循环次数等于节点数,返回的depth就等于链长,正确。对一棵每个节点都有左右孩子的满二叉树,循环次数等于高度,也正确。

3.2 DFS的迭代写法:用栈模拟系统调用

除了BFS,深度优先搜索同样可以用栈写成迭代版。递归的调用栈是系统帮我们维护的,现在自己用栈显式维护“当前节点”和“当前深度”这对信息。

class Solution: def maxDepth(self, root: Optional[TreeNode]) -> int: if root is None: return 0 stack = [(root, 1)] max_depth = 0 while stack: node, depth = stack.pop() max_depth = max(max_depth, depth) if node.left: stack.append((node.left, depth + 1)) if node.right: stack.append((node.right, depth + 1)) return max_depth

栈的LIFO特性决定了遍历顺序是先处理后压入的节点,但因为我们求的是深度,遍历顺序不影响最终答案,所以不需要额外保证“先左后右”。每次压栈时带上depth+1,弹出时用max_depth记录见过的最深深度。空间复杂度在最坏情况下是O(n),但堆上分配的空间比递归栈好控制得多。

3.3 两种迭代方式的选型建议

层序BFS的优点是直观、好记忆,而且如果后续要处理“每一层的平均值”“每一层最右侧节点”这类题目,这套框架可以直接复用。DFS栈写法的优点是空间效率稳定,适合处理特别深的树。

我的建议是:如果只想记一种迭代写法,优先记BFS。因为“按层处理”这个语义覆盖了太多二叉树变体题,值得形成肌肉记忆。如果想把空间复杂度优化到极致,再额外掌握DFS栈版。二者并不冲突,都是从“递归太深会爆栈”这个痛点出发的自然产物。

4. 常见报错与排查实录

4.1 运行时错误:到底谁在报错

写二叉树程序时,新手遇到最多的不是答案错误,而是运行时错误——代码一提交,屏幕上冒出一串红色提示。按我的经验,运行时错误里占比最高的三类是:空指针访问、递归栈溢出、死循环。

空指针访问的典型场景是:判断root非空之后,在递归或循环里对一个null节点取左孩子或右孩子。比如有的同学会把递归出口写成if root.left is None and root.right is None: return 1,然后递归调用放在这个判断之后,结果遇到空节点直接扑街。解决这类问题没有捷径,只能养成习惯:任何对root.xxx的访问之前,先问自己“root可能是None吗”。

栈溢出在力扣上往往表现为“RecursionError”或“std::bad_alloc”之类的崩溃。这类问题通常不是题目数据故意使坏,而是你的递归出口写错了,导致某些分支永远递归下去。排查方法是print大法:在函数入口打印当前节点的val,立刻看出来它到底朝哪个方向无限深入。

4.2 排查技功速查表

症状常见原因快速定位手段
返回结果比预期小1递归返回时漏了+1检查每一层的返回表达式
空树返回值是1空子树返回了1检查递归出口处的return 0
一直报RecursionError递归出口缺失或条件错误打印节点值观察递归路径
答案等于节点总数BFS层与层未区分检查是否用了level_size快照
明明逻辑对但超时每次递归重复建树/重复扫描确认每个节点是否只访问一次

4.3 一个容易忽略的Python细节

在Python里写递归二叉树解法时,有一个细节极其容易踩坑:默认递归深度限制。Python的sys.setrecursionlimit默认只有约1000层,即使你的算法逻辑天衣无缝,遇到一棵1000+层的链状树也会直接RecursionError。

力扣的题目很少让二叉树退化到这种程度,但在你自己构造测试用例,或者处理某些特殊输入时,这个问题就会冒出来。有人会当场sys.setrecursionlimit(1000000)来应付,这对做题是可行的,但你要明白这只是一种“绕过”,不是“解决”。真正的解决是切换到迭代写法。我在实际写业务代码时从来不主动调高这个限制,因为递归栈爆掉的风险并不会因为限制调大而消失。

5. 从二叉树最大深度延展出去

5.1 判断平衡二叉树:深度思想的直接应用

力扣110题——平衡二叉树,就是104题最典型的变体。它的定义是:一棵二叉树中,每个节点的左右子树高度差的绝对值不超过1。

如果你已经能熟练写出求最大深度的递归,那么平衡二叉树判断的第一版思路很自然:每个节点都算一下左右子树深度,然后检查差值。

class Solution: def isBalanced(self, root: Optional[TreeNode]) -> bool: if root is None: return True def depth(node): if node is None: return 0 return max(depth(node.left), depth(node.right)) + 1 left_depth = depth(root.left) right_depth = depth(root.right) if abs(left_depth - right_depth) > 1: return False return self.isBalanced(root.left) and self.isBalanced(root.right)

但这段代码存在重复计算问题:每个节点都会被上层调用计算深度,同时又被递归调用检查平衡,时间复杂度退化为O(nlogn)甚至O(n^2)(退化树)。更优的写法是从底向上边算深度边判断,一旦发现不平衡就提前返回-1。这个“后剪枝”的思路,核心仍然是对深度计算的深刻理解。

5.2 二叉树深度在真实场景中的投影

学过数据结构的人可能会问:二叉树最大深度在真实业务里到底有什么用?

答案是,凡是需要“判断一个层级结构有多深”的地方,都用得上。比如电商的类目树,一个大型商超的货架分类体系本质上是一棵多叉树(多叉树可以通过左孩子右兄弟表示法转成二叉树),求它的最大深度能帮你评估站点层级是否过深、用户需要点击几次才能找到目标商品。再比如程序里的函数调用链、XML/HTML的DOM嵌套层级、文件系统目录深度,全都可以抽象成树形结构的深度问题。

还有一个在编译原理和结构化存储里更硬核的应用:二叉树的序列化与反序列化。当我们把一棵二叉树保存到磁盘或传给别人时,需要一种方式把它变成字符串,通常的做法是记录前序遍历和空节点标记。反序列化时,要正确重建这棵树,本质上也依赖对深度和位置关系的数学理解。超市货架的场景里,“遍历二叉树”就是从上到下、从左到右盘点库存这个朴素需求的抽象化表达——每层货架对应树的每一层,每件商品对应一个节点。

5.3 线索二叉树:把遍历成本降下来的野心

既然提到了遍历二叉树,就绕不开“线索二叉树”这个概念。普通二叉树的节点只有左右孩子指针,遍历时要么递归、要么手动用栈或队列,每次都要临时维护额外信息。线索二叉树的思路是:把叶子节点上空闲的左右指针利用起来,左指针指向前驱节点,右指针指向后继节点。

这样做的收益是,中序遍历或前序遍历可以不用栈也不用递归,顺着线索一路走完。代价是每个节点需要两个额外的标志位来区分“指针指向的是孩子还是线索”。在求最大深度这道题上,线索二叉树不适用,但它提醒我们一件重要的事:树的深度信息,本身也可以在构造时维护进节点里,比如每个节点额外存一个height字段,这样求最大深度就变成O(1)的字段读取——以空间换时间,是工程上常见的权衡。

6. 刷题攻略:从104题开始,把二叉树一网打尽

6.1 刷题顺序比刷题数量更重要

很多人的刷题路径是从编号最小的题开始刷起,这是低效的。力扣的二叉树系列有清晰的依赖关系,按这个顺序走,每一步都在给下一步打地基:

    1. 二叉树的最大深度(打底)
    1. 平衡二叉树(深度判断的延伸)
    1. 二叉树的最小深度(注意与最大深度的边界差异)
    1. 路径总和(深度搜索的经典应用)
    1. 翻转二叉树(递归框架的镜像操作)
    1. 对称二叉树(两棵树同步递归)
    1. 二叉树的层序遍历(BFS框架成型)

把这一组刷完,你对二叉树递归的肌肉记忆就建立起来了,之后去看其他中难题才会有“原来不过是这个套路换个皮”的感觉。

6.2 三道最容易踩坑的相似题对比

很多读者刷完104题,紧接着去做111题(最小深度),发现答案不对。这里有个经典陷阱:最小深度是指从根节点到最近叶子节点的最短路径节点数,如果一个节点只有左子树没有右子树,那么右子树的深度不能简单当作0去比较,因为空子树不是叶子节点。

同样绕人的还有“最大深度”与“节点个数”之间的区别:最大深度看层数,节点个数看数量。满二叉树第k层的节点数是2^(k-1),整棵树总节点数最多是2^k-1。群里经常有人把深度和节点数搞混,问“最大深度为什么不能直接等于节点数”,答案就在定义里:深度和个数是不同的度量维度。

6.3 写二叉树程序时的高频建议

根据我这几年在编译原理和数据结构相关项目里的实操经验,总结几条写二叉树程序时的高频建议:

  • 写任何树的递归函数前,先想清楚空节点应该返回什么,把它当作函数的第一行代码写下来。
  • 在代码里特别区分“当前层”和“子树层”的职责,比如求深度时+1这个动作放在当前层的返回表达式中,而不是放在递归调用里。
  • 遇到“运行时错误”,优先怀疑空指针和递归出口,而不是怀疑题目数据。
  • 如果递归代码对外层变量有依赖,比如把答案存在self.max_depth里,注意递归分支之间的变量污染。
  • 调试递归时,在每个递归函数入口打一行def debug(node, depth): print(" " * depth, node.val),能极大改善你的调试体感。

7. 最后分享一点我的体会

104题是我在力扣上反复重刷了很多遍的题。起初我以为自己已经完全掌握,但时隔半年再回来,我发现可以用更简洁的框架去重新组织它:递归版本展示分治的精髓,BFS版本展示按层统计的技巧,栈版本展示显式管理空间的方法论。同一道题,三种思路,其实是三个不同的思维杠杆。

我建议你刷完104题之后,不要急着标记“已掌握”,而是把题解区里不同语言的解法各看一遍,尤其用Python、Java、C++各写一次。语言差异会迫使用不同的思路理解同一个问题,比如Python递归方便但要注意限制,C++迭代栈则自然高效。这种跨语言的对照,比刷十道同样难度的题带来的能力提升更大。

二叉树的题目是做不完的,但核心套路极其有限。把104题真正吃透,让它成为你根系的一部分,后面的路会走得顺畅很多。

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

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

立即咨询