如果说力扣hot100里有一道题能同时串起“二叉树遍历”“递归思想”“迭代写法”这几块硬骨头,那“二叉树的最大深度”绝对排得上号。这道题在hot100里被归为简单题,但它的地位一点都不“简单”——很多刷题人第一次真正理解递归、第一次意识到栈溢出问题、第一次分清DFS和BFS,都是从这道题开始的。热词里那句“写二叉树程序时为什么总是报运行时错误”,我几乎天天能看到有人问,这道题恰好就是排查这类问题的最佳实验场。
题目本身一句话就能说清:给定一棵二叉树,找出它的最大深度。最大深度是指从根节点到最远叶子节点的最长路径上的节点数。看起来人畜无害,但它牵出的递归递推公式、层次遍历、复杂度分析,是后续一大堆中等题、难题的地基。这篇文章我不打算只丢三种解法代码让你抄,我会把每一步的“为什么”讲透,包括为什么递归写起来最自然、为什么递归深度太大会炸、为什么BFS能精确卡层级、以及刷hot100到底该怎么安排顺序。
适用的人群很明确:刚开始刷力扣、二叉树遍历老是稀里糊涂的初学者;或者代码能跑但不懂原理、想补底层逻辑的半新手。就算你已经能AC这道题,后半部分的错误排查和刷题策略也值得一看,因为那些经验是从真实提交记录里捞出来的。
1. 题目拆解:最大深度到底在问什么
1.1 从题目定义到代码的翻译过程
原题给的是二叉树节点的定义,通常长这样:
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right最大深度的定义是“根节点到最远叶子节点的最长路径上的节点数”。这里有两个容易踩的坑:第一,是“节点数”不是“边数”,所以空树深度为0,只有一个根节点的树深度是1;第二,是“最远叶子节点”,意味着如果某个节点只有左子树没有右子树,你只能往左子树走,不能因为右子树为空就原地返回。
翻译成代码逻辑时,会得到一个极简的核心关系:一个节点的深度,等于它的左子树深度和右子树深度中较大的那个,再加上1。这是整道题最关键的递推式,后面所有解法都是围绕这句话展开的。
1.2 为什么这道题能进hot100
hot100的选题有一套隐形标准:要么是高频面试题,要么是能承载核心思想的基础题。二叉树的最大深度两个都占。它几乎出现在所有主流面试题库里,同时它又是理解“树的遍历”的最小切口。
你去看hot100的动态规划题,像“打家劫舍”“最长递增子序列”,它们的状态转移方程本质上也是一棵树或一条链上的递推;你再去看“路径总和 III”“二叉树的最近公共祖先”,这些题不过是在遍历的过程中多带了一些上下文信息。所以把最大深度吃透,相当于你拿到了二叉树这一整章的开篇钥匙。
1.3 一句话概括三种解法的本质
递归解法:直接翻译递推式,依赖系统调用栈帮我们“先算子树深度,再回头算当前节点”。
BFS层序遍历解法:用队列逐层扫荡,扫完一层深度加1,天然契合“深度”这个概念。
迭代DFS解法:用显式栈存储“当前节点+当前深度”,手动模拟递归的压栈和弹栈过程。
三种方式时间都是O(n),空间上递归和迭代DFS最坏O(n),BFS最坏O(n)(队列里同时存一整层的节点),这些差异在后面的复杂度分析里细说。
2. 递归解法:最朴素也最需要敬畏的写法
2.1 递推公式的推导与边界条件
递归解法的代码极短,但这道题恰恰是检验你是否“真懂递归”的试金石:
def maxDepth(root): if root is None: return 0 left_depth = maxDepth(root.left) right_depth = maxDepth(root.right) return max(left_depth, right_depth) + 1三行核心逻辑,背后是递归的两个铁律。第一,必须有不依赖递归的终止条件,这里的终止条件就是空节点返回0;第二,每次递归都要向终止条件收敛,递归调用传入的root.left、root.right,一定比原来的root更接近空节点。
为什么空节点返回0而不是返回1?因为空节点不算一棵树,不存在“深度”这个概念。反过来,如果题目问的是“从根到叶的最长边数”,那空节点就要返回-1才能让单节点树的高度为0。这是很多新手混淆的地方,建议你把“节点数”和“边数”两种口径都写一遍加深印象。
2.2 递归过程中的“后序位置”
理解这题递归的关键是看return的位置。它不在调用子节点之前,也不在调用之后立刻执行,而是在左右子树的深度都算完之后才执行。
这就是所谓“后序位置”的用处:你想要知道当前节点的深度,必须先知道左右子树各自的深度,然后才能取最大值加1。这跟做人一样,你得先了解下属的情况,才能算出自己团队的总产出。
对应到二叉树的前中后序遍历,这个return发生在后序位置,也就是说虽然代码里先调用了left、再调用right,但真正“处理当前节点”的动作是在最后。很多进阶题会在这里做文章,比如“平衡二叉树”要同时返回深度和是否平衡,“二叉树的最大路径和”要在后序位置比较左右子树贡献值,所以第一步就得把位置感建立起来。
2.3 时间复杂度与空间复杂度的计算细节
时间复杂度很容易算:每个节点都会被访问一次,递归函数体内做的是常数次比较和加法,所以总时间O(n),n是节点总数。
空间复杂度稍微绕一点。递归调用栈的深度等于树的深度:每往下一层,系统就要压一个栈帧记录当前函数的状态。最好情况下是平衡二叉树,深度是log2(n),空间O(log n);最坏情况下树退化成一串链表,深度是n,空间O(n)。
这也是递归写法的一个隐患。绝大多数在线评测环境里,Python的默认递归深度限制在1000层左右,Java的调用栈也扛不住特别深的递归。一旦测试用例是一棵1000层以上的单链树,你的递归解法会直接报栈溢出错误。这在LeetCode上不常见,但在牛客、公司自研的OJ上偶尔会碰到,所以迭代解法必须掌握。
2.4 递归解法的两个常见变体
有些人喜欢这样写:
def maxDepth(root): if root is None: return 0 return 1 + max(maxDepth(root.left), maxDepth(root.right))省去了两行临时变量,逻辑完全一样,只是可读性稍差。我个人的建议是保留中间变量,因为在真实面试中,面试官很可能让你现场加一个“返回最大深度对应的那条路径”,有临时变量你会更容易改造代码。
还有一个变体是在遍历时带参数维护当前深度:
def maxDepth(root): res = 0 def dfs(node, depth): nonlocal res if node is None: return res = max(res, depth) dfs(node.left, depth + 1) dfs(node.right, depth + 1) dfs(root, 1) return res这种写法没有返回值,而是通过共享变量res记录最大深度,相当于把“深度”当成一路携带的行李。它本质是前序遍历的思想,因为每访问一个节点就尝试更新res。三种变体的结果完全一致,差别在于思维方式:第一种是“结果从底部往上传递”,第二种是“状态从顶部往下传递”。建议你至少把前两种都写一遍,面试时能灵活切换。
3. 迭代解法:用队列和栈手动模拟递归
3.1 BFS层序遍历:深度就是层数
BFS解法是我在工程里更喜欢用的写法,因为它完全绕开了递归栈溢出的风险:
from collections import deque def maxDepth(root): if root is None: return 0 queue = deque([root]) depth = 0 while queue: size = len(queue) for _ in range(size): node = queue.popleft() if node.left: queue.append(node.left) if node.right: queue.append(node.right) depth += 1 return depth这段代码的关键点是size变量的作用。在进入while循环时,queue里恰好是上一层的所有节点,此时len(queue)就是这一层的节点总数。for循环负责把这一层的节点全部出队,同时把它们的孩子入队。一次for循环结束,queue里剩下的就是下一层的所有节点,这时depth加1。
为什么要先记录size再循环?因为如果你直接写while queue,那循环体里新入队的节点也会被当成当前层的节点处理,你根本无法区分“哪一批节点属于同一层”。这个技巧在“二叉树的层序遍历”“N叉树的层序遍历”里同样要用,是BFS的通用基本功。
从复杂度看,时间O(n),空间上队列里最多同时存一整层的节点。对于极端的不平衡树,每层只有一个节点,队列空间O(1);对于完美二叉树,最后一层有n/2个节点,队列空间O(n)。
3.2 迭代DFS:用栈保存“节点+深度”的复合信息
如果你既想保留DFS的深度优先特性,又想避开系统递归栈,那就得自己维护一个显式栈:
def maxDepth(root): 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为什么栈里存的是节点和深度的元组?因为在递归版本里,每个栈帧天然携带了“当前节点”和“当前深度(通过调用链隐含)”,但手动用栈时你只有一个节点对象,深度信息丢了。把depth一起压栈,相当于把系统栈帧里的局部变量显式化。
还有一个细节:这里push左右子树的顺序无所谓,因为求最大深度不关心遍历顺序,只要每个节点都被访问到就行。但如果你在写“二叉树的前序遍历”之类的题,push的顺序就决定了遍历结果,注意区分。
迭代DFS的空间复杂度也是O(n),最坏情况下栈里会同时存一条路径上的所有节点。它比递归优点在于不受系统递归限制,能处理超深树。
3.3 三种解法的横向对比
| 解法 | 时间 | 空间 | 实现难度 | 风险点 |
|---|---|---|---|---|
| 递归 | O(n) | O(h),h是树深,最坏O(n) | 代码最短,最容易理解 | 递归深度超过系统限制会栈溢出 |
| BFS层序 | O(n) | O(w),w是最大层节点数,最坏O(n) | 代码略长,思路直观 | 需要额外记size,容易漏掉 |
| 迭代DFS | O(n) | O(h),最坏O(n) | 中规中矩,需理解显式栈 | 容易忘记保存depth信息 |
实际面试时,我建议你先说递归解法,等面试官追问“如果树特别深怎么办”,再自然切换到迭代解法。这样既展示了你能用最符合直觉的方式解题,又展示了你知道递归的边界和替代方案。
3.4 什么时候用DFS,什么时候用BFS
这是一个很实际的问题。判断标准可以简化成一句话:如果你需要“逐层推进”的信息,比如二叉树的最大宽度、层序遍历输出二维数组,那就用BFS;如果你关心的是“从根到叶子的路径”“深度”“子树关系”,那DFS更自然。
最大深度两种都能做,但如果你想看清楚两者的差异,建议再做一道“二叉树的最小深度”,那道题上BFS有明显优势——因为BFS第一次遇到叶子节点时就能返回,不需要遍历整棵树,而DFS得老老实实把所有叶子都检查一遍。
4. 写二叉树程序为什么总是报运行时错误?——真人排查记录
4.1 空指针错误:几乎所有二叉树的噩梦
运行时错误最常见的就是空指针访问,或者说Python里的AttributeError: 'NoneType' object has no attribute 'left'。
发生场景很有代表性:你在某个函数里写了node.left,但没先判断node是否为空。比如有读者写:
def maxDepth(root): if root is None: return 0 left = maxDepth(root.left) # 如果root.left是None,下次递归会进入if root is None返回0,不会报错 right = maxDepth(root.right) return max(left, right) + 1这段代码其实是对的,因为递归入口有if判断。但如果你把判断写成这样问题就来了:
def maxDepth(root): if root.left is None and root.right is None: return 1 # 当root本身就是None时,访问root.left直接炸典型的错误形态是“只考虑了叶子节点作为终止条件,忽略了空节点本身”。我的建议是:树的递归函数里,第一行永远先写空节点判断,宁可让空节点多走一次递归返回0,也不要试图省这行判断。因为“空节点”是比“叶子节点”更底层的边界,空节点判断能覆盖叶子节点的情况。
4.2 返回值的口径错误:返回0还是返回1的混淆
这是不报错但答案错误的典型。题目问“节点数”,很多新手在叶子节点处返回0,导致单节点树算出的深度是0。检查方法很简单:拿一棵只有根节点的树,手动跑一遍你的代码看看结果是不是1。
如果你用的是递归式maxDepth(root) = max(maxDepth(left), maxDepth(right)) + 1,根节点是叶子时,left和right都是None,返回0,加1得1,正确。如果你改写成left = 0 if root.left is None else maxDepth(root.left),同时把空节点返回-1,那又是另一种口径。建议新手统一用“空节点返回0”的口径,配合最后的+1,这套组合最不容易出错。
4.3 递归深度超限:Python的RecursionError
热词里提到“写二叉树程序时为什么总是报运行时错误”,我猜有相当一部分人遇到的是RecursionError: maximum recursion depth exceeded。
这有两个层次的原因。第一,代码本身的递归边界写错了,导致无限递归。比如你调用maxDepth(root)忘记传root.left,而是再次传root,那就会永远递归下去。第二,测试用例本身就是一颗超深的树,递归深度超出了语言限制。
排查第一个原因的方法是在递归函数开头打印当前节点的值,观察是否出现重复值循环。排查第二个原因的方法是把树的高度打出来做估计:如果树高超过900,Python默认上限1000就很危险。这时要么改用迭代解法,要么在代码开头设置sys.setrecursionlimit(10000)。
不过我要泼盆冷水:刷题时偷偷调大递归上限能过很多OJ,但在真实工程和面试白板里,你没法依赖这个手段。面试官问“如果树有十万层怎么办”,正确回答是切换到BFS或显式栈。调递归上限只适合本地调试,不是根本解法。
4.4 测试用例设计:一棵树要把所有形态都测到
二叉树题目的测试用例设计有固定套路,我建议至少覆盖六种形态:
- 空树:root为None,期望0
- 单节点:期望1
- 完美二叉树:所有叶子在同一层,期望等于层高
- 完全二叉树:底层从左到右填充,验证不会因缺少右孩子而算错
- 单链左倾树:每个节点只有左孩子,期望等于节点数
- 单链右倾树:同上,检验递归方向是否正确
很多人只测普通二叉树,结果在单链树上暴露了栈溢出。如果你跑完这六种用例都能过,代码基本稳了。
4.5 调试技巧:画图与打印遍历顺序
树结构肉眼很难看清,调试时我习惯写一个辅助函数打印前序遍历序列。比如上面那个递归版本,在函数入口加一句print(root.val),就能看到访问顺序。如果输出序列里出现重复值,说明递归出现了环路;如果只打印了左子树节点没打印右子树,说明递归方向被写死。
另一种方式是本地把TreeNode转成数组打印,格式用LeetCode的层序数组表示法。比如[3,9,20,null,null,15,7]对应一棵标准二叉树。这个转换代码网上很多,建议存一份到本地工具库,刷树相关题目能大幅提升排错效率。
5. 从最大深度延伸出去:hot100的树形题目该怎么刷
5.1 先建立遍历体系,再刷具体题目
二叉树的最大深度只是“遍历”的一种应用。以它为起点,我推荐的延伸路径是:前中后序遍历 → 层序遍历 → 最大深度/最小深度 → 路径总和 → 二叉树的最近公共祖先 → 验证二叉搜索树 → 二叉树的序列化与反序列化。
这条路径的逻辑是:遍历是树的骨架,最大深度让你理解DFS和BFS的区别,路径总和让你练习在DFS过程中携带状态,最近公共祖先让你练习后序位置的分治思想,序列化则综合考察遍历与构建。hot100里还有“二叉树展开为链表”“从前序与中序遍历序列构造二叉树”,它们本质上都是遍历的变形题。
5.2 刷题节奏:一天几道最合理
很多刷hot100的人容易陷入两种极端:一天猛刷十几道浅尝辄止,或者一周只抠一道题反复焦虑。我实测下来,对于二叉树这种“套路感强”的专题,每天3道比较合适:一道新题,一道昨天做过的题,一道一周前做过的题。新题负责扩展思路,昨天的题负责巩固记忆,一周前的题负责检验是否真正掌握。
具体做法是:新题不看题解先自己硬做20分钟,做不出来再看题解,然后把题解思路默写一遍;第二天先把同一道题AC一遍再开始新题。这样一轮下来,二叉树的遍历类题目基本不会忘。
5.3 与hot100其他专题的交叉
hot100的动态规划题、回溯题里也藏着树的影子。“打家劫舍 III”是树形DP,“二叉树中的最大路径和”更是树形DP的典型代表。如果你在最大深度这道题里把“后序位置返回聚合结果”这个模式理解透了,后面做树形DP时会轻松很多。
所以我的观点是:不要因为最大深度是简单题就轻视它。它的价值不在于代码量,而在于让你建立“遍历顺序决定问题解法”这个核心认知。这个认知,是你从“能AC简单题”进阶到“能AC中等题”的分水岭。
最后分享一个实战小技巧:如果面试现场时间紧,可以用一句话描述递归解法——“每个节点的深度等于左右孩子深度的最大值加一,空节点深度为零”,然后直接默写代码。考察算法时,清晰的表达比安静写代码更能给面试官留下印象。这道题我刷了不下三遍,每次重刷都能发现新的理解维度,希望你也能把它当成一座桥,而不是一块砖。