1. 拿到这道题先别急着递归,先聊聊它到底在考什么
LeetCode Hot 100里面的题,说实话不是每一道都值得精刷,但"二叉树的最大深度"绝对值得。它排在第三十六题(题号104),属于你看题目列表一眼扫过去觉得"这题我会",但真到面试手写的时候,很多人却栽在了一些莫名其妙的地方。
这道题的核心考点不是"怎么求深度",而是三件事:递归思想扎不扎实、遍历框架熟不熟、能不能随手写出非递归版本。面试官问这道题,往往不是想看你背没背过答案,而是想通过这棵树看看你对栈、对递归调用过程、对层序思想的理解到了哪个层次。
先给没刷过这道题的朋友说清楚题目到底在问什么:给你一棵二叉树,求它的最大深度。最大深度是指从根节点到最远叶子节点的最长路径上的节点数。比如一棵只有根节点的树,深度是1;空树,深度是0。就这么简单的一句话,却可以引申出递归DFS、迭代BFS层序、迭代DFS栈模拟、N叉树版本、变体题(直径、平衡树、最小深度)……一整套二叉树的题目泛化体系。
我在实际刷题和带新人复盘的时候,经常说一句话:**"二叉树的最大深度"是二叉树的"hello world",它简单到可以在一分钟内写出来,但真正理解它的人,后面写路径和、建树、序列化这些题都会顺很多。**如果只是把它当一个"背递归模板"的题目刷过去,那你这道题算是白刷了。
这篇文章我不打算只贴一个递归答案然后说"完事",我会把递归的每一步走栈过程拆开给你看,再把迭代写法为什么要这么写讲明白,最后把和它相关的变体以及我在实战中常遇到的运行时错误一并梳理掉。保证看完之后你不仅能AC这一题,还能顺手把一串二叉树题都打通。
2. 递归解法不是"背模板",关键在理解递归栈的返回过程
2.1 自底向上的思考方式,是递归解法的灵魂
求二叉树的最大深度,最经典的写法就是那个三行代码:
def maxDepth(root): if root is None: return 0 return 1 + max(maxDepth(root.left), maxDepth(root.right))很多教程把这段代码当作"标准答案"甩出来,然后说"递归嘛,就是不断往下走"。如果你也这么理解,那大概率是似懂非懂的。因为这段代码的精髓不在"往下走",而在"往上返回"。
必须要搞清楚:递归函数不是一条路走到黑,而是走到底之后逐层往上带回来结果。以一棵最简单的三层树为例:
1 / \ 2 3 / \ 4 5- 从根节点1开始,调用
maxDepth(1),要等maxDepth(2)和maxDepth(3)都返回之后才能算1 + max(...)。 - 于是走到节点2,调用
maxDepth(2),又要等maxDepth(4)和maxDepth(5)。 - 走到节点4,它的左右都是空,直接返回0,于是节点4的深度 = 1 + max(0, 0) = 1。
- 节点5同理返回1,节点2的深度 = 1 + max(1, 1) = 2。
- 节点3是叶子节点,返回1,于是根节点1的深度 = 1 + max(2, 1) = 3。
注意:最大值是在返回的过程中,一层一层"比"出来的,不是"算"出来的。1 + max(...)这一步看起来简单,但它的含义是:当前节点这一层贡献1个深度,然后从左右子树的深度中挑一个更大的,继续向上提交。
我在给新手复盘的时候,最好用的一个类比是:**递归就像公司里从底层往上汇报工作。**叶子节点是基层员工,他们跟主管说"我这边深度是1";主管把自己的1加上,跟高层说"我这边是2";高层再把两个下属提供的数值中大的那个加上自己这一层,如实汇报。整个过程没有一个人需要知道整棵树长什么样,每个人只需要管好自己这一层和自己的孩子传上来的结果。
2.2 递归终止条件的"边界感",空节点返回0为什么是对的
很多人在写递归的时候,总会在终止条件上犹豫:到底是if root is None: return 0,还是if root.left is None and root.right is None: return 1?这两种写法在结果上往往一样,但在逻辑上完全不同。
我建议统一用"空节点返回0"这个版本,原因有两个:
第一,空节点返回0更符合数学上的递推定义。深度公式是f(node) = 1 + max(f(left), f(right)),如果叶子节点的左右孩子都不存在,那它们各自的f应该为0,叶子节点才算成1。这样推导链是连续完整的,不需要单独为叶子节点开特例。
第二,代码更简洁,不容易漏判。如果你写"只有左右都为空才返回1",那在处理只有一个子树的节点时还得小心翼翼。比如一个节点只有右子树,你要是没处理好空指针,分分钟给你抛个AttributeError,这恰恰是很多人"写二叉树程序时总是报运行时错误"的一大来源。
你可以把空节点理解成"一栋楼的负一层"——它不是不存在,而是深度为0的默认起点。每次从父节点下来,先站在负一层,然后往上爬一层才算到了父节点本身。
2.3 递归的时间复杂度和空间复杂度,面试必问不要卡壳
这道题虽然简单,但面试官顺手就会追问一句"复杂度是多少"。别小看这个问题,答不上来很减分。
- 时间复杂度:每个节点都被访问一次,每个节点只做常数级别的比较和加法,所以是O(n),n是节点总数。
- 空间复杂度:递归调用的深度取决于树的高度h,最坏情况下是退化链表(一条线),h等于n;最好情况下平衡树,h等于log n。空间复杂度从这个意义上说是O(h),准确说最坏O(n)。
有一个非常容易误解的点:空间复杂度不是指"开了一个数组存结果",而是系统调用栈的深度。递归每往下走一层,就要在栈上压一帧,保存当前函数的局部信息和返回地址。树越深,栈上堆积的帧越多。这也是我们接下来要说的"运行时错误"出现的重要原因。
3. 为什么总是报运行时错误?——递归被栈溢出打倒的真相
3.1 从"运行时错误"到Stack Overflow,到底发生了什么
标题相关热词里有一条特别扎眼:"写二叉树程序时为什么总是报运行时错误"。这真的是二叉树新手绕不开的坎。我自己见过太多人,代码逻辑看起来完全正确,一提交就报Runtime Error,心态直接炸掉。
运行时错误的原因有很多种,但放到"最大深度"这道题里,最常见的元凶就是递归深度过大导致的栈溢出。
这里需要澄清一个概念:LeetCode的判题环境里,虽然题目给定的二叉树通常不会深到几万层,但你自己构造测试数据的时候,完全可能造出一棵深度几万层的退化树。这时候用递归写法,程序就会一路往深处递归,每一层调用都要占用一部分调用栈内存,直到栈空间耗尽,程序崩溃。
在Python里还有另一层隐患:Python默认的递归深度限制大约是1000层,即便你的系统栈没有爆,Python解释器自己也会先抛出RecursionError: maximum recursion depth exceeded。我在本地跑极端用例时专门验证过,一棵深度为1500左右的链表式二叉树,递归版本必挂。这在Java里通常表现为StackOverflowError,C++里直接段错误,各语言表现不同,但本质都一样:函数调用有栈帧成本,深度太大就是扛不住。
3.2 一个真实翻车的排查过程,你应该也遇到过
我记忆里特别深的一个案例:某次刷题活动,一个朋友代码写得干干净净,递归版maxDepth,本地测试普通的树也完全正常,但一提交LeetCode就报运行时错误。他第一反应是"我代码哪里访问了空节点",然后各种加判空、加日志,折腾半天毫无进展。
我看了一眼他的测试代码,问题不在maxDepth函数本身,而在他额外写了一个二叉树构建函数,用来从数组还原树。他为了测试极端情况,造了一条长度一万的"链条",然后直接调用maxDepth——递归一路扎到底,栈就爆了。这个过程总结起来很经典:
- 构建了一个深度为10000的链表式二叉树;
- 调用递归版
maxDepth,函数开始逐层压栈; - 压到第1000层左右时,Python直接抛
RecursionError; - 看上去像是"某种运行时错误",其实和题目本身一点关系都没有。
排查结论:不是算法写错了,是递归这种实现方式在极端输入下有物理极限。这也直接引出了迭代写法的必要性。
3.3 头和尾都要小心:空指针、整数溢出很少被认真对待
除了栈溢出,二叉树题目里还有一个非常典型的运行时错误来源:在构建测试用例时对空节点处理不当。比如用数组表示二叉树,像[3,9,20,None,None,15,7]这种格式,如果你没有正确把None跳过,而是试图对这个节点调用root.left,立刻就是空指针异常。
另外,关于数的大小:这道题的深度最大值等于节点数,正常题目给的范围不会超过几万,所以不会出现整数溢出。但如果你在变体题里做"路径总和"或者"最大路径和",那就得小心累加值超出int范围了。在这道题上,重点还是栈溢出和空指针这两个坎。
再补一个很多人忽略的细节:递归函数的返回值类型要统一。你在写Python时可能觉得不写类型注解无所谓,但如果所有分支返回值表达不一致(比如有的返回bool、有的返回int),调用方拿到结果做max()比较时会非常痛苦。我建议从一开始就坚持写类型注解,Optional[TreeNode]参数配int返回值。
4. 迭代解法:用BFS层序把"深度"变成"层数",顺便告别栈溢出
4.1 BFS层序遍历的直观逻辑:数一数有几层楼
递归DFS是一条路走到黑再回头,而BFS则是一层一层扫。如果把二叉树想象成一栋楼,BFS就是逐层清点每层有多少个房间,每扫完一层深度加1,直到扫完整栋楼。
用Python写BFS版最大深度非常模板化:
from collections import deque def maxDepth(root): if root is None: return 0 q = deque([root]) depth = 0 while q: size = len(q) for _ in range(size): node = q.popleft() if node.left: q.append(node.left) if node.right: q.append(node.right) depth += 1 return depth注意这行size = len(q)——很多新手会问:为什么要先取size,不能直接while q然后popleft吗?因为这里必须保证"当前这一层"完整出队之后,深度才加1。如果你不锁定size,直接用for node in q这种写法,动态变化的长度会干扰层与层的边界,深度就乱套了。
这个BFS版的优势很明显:
- 空间复杂度最坏O(w),w是树的最大宽度,在二叉树里最大宽度大约n/2,但相比递归的O(h)在极端链表树下反而是优势;
- 完全没有递归栈溢出的风险,因为用的是显式队列,不依赖系统调用栈;
- 它的代码结构几乎是"层序遍历模板",后面做**"二叉树的最小深度"、"层序遍历输出二维数组"、"右视图"**这些题直接复用,性价比极高。
4.2 另一种迭代路径:DFS手动维护栈,思路更接近于"模拟递归"
BFS解法直观,但有些场景(比如既要深度又要路径信息)BFS就不够灵活了。这时候可以自己用栈模拟递归的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就是从根到它的路径长度。当一个节点没有左右孩子,或者左右孩子都被处理完后,它就是当前这条路径的末端,此时用它的depth去刷新max_depth即可。
用栈模拟递归还有一个额外好处:你想收集路径时,可以在栈里再多存一个path列表,比如"二叉树的所有路径"那道题,就是在这个模板上加了路径收集而已。所以,花十分钟把这个栈版本吃透是值得的,它是后面一系列DFS迭代题的基础。
4.3 BFS和DFS版本如何取舍?面试现场的建议
如果在面试中遇到这道题,我的建议是:
- 先说递归DFS,代码最短,逻辑最清晰,面试官听了也放心——这是"我会递归"的信号;
- 然后主动补充"如果树的深度特别大,递归会有栈溢出风险,我可以改成BFS或栈模拟DFS"——这是"我懂底层"的信号;
- 面试官大概率会接着问"那你写一下BFS吧",你直接交出上面的
deque版本,这就是"我实战能力在线"的信号。
这三个信号递进递推,一道Easy题也能面出Medium的效果。很多候选人就是栽在只背了递归答案,面试官追问一句"递归会不会爆栈",当场哑火——这一题直接就从"刷过"变"没刷过"。
5. 从最大深度延伸出去:变体题是真正的提分点
5.1 二叉树的直径、平衡二叉树、最小深度,和最大深度有什么关系
"最大深度"这道题的延伸范围比我见过的大多数入门题都要广。把它刷透之后,下面这三道题能顺出七成思路:
第一道:二叉树的直径(LeetCode 543)。直径定义是任意两节点间路径上最多的节点数或边数,核心思路是在递归求深度的过程中顺便记录"左深度 + 右深度"的最大值。也就是说,最大深度解的递归框架,加一个全局变量,就变成了直径题。
第二道:平衡二叉树(LeetCode 110)。判断一棵树是不是高度平衡的,本质上是要求每个节点的左右子树深度差不超过1。你可以在递归返回深度的同时,检查左右子树返回的深度是否相差超过1,一旦出现就标记false。它甚至不需要额外遍历一次——一次递归同时完成"求深度"和"做判断"。
第三道:二叉树的最小深度(LeetCode 111)。这道题是经典的"看似很简单,实际有坑"。很多人直接把max改成min就交了,结果发现对于"根节点只有右子树"这种树,1 + min(0, right_depth)会直接算出1——错得离谱。最小深度必须找从根到最近叶子节点的路径长度,空节点不能当作叶子节点来凑数。在这里,BFS层序反而是更优解,因为层序遍历找到的第一个叶子节点所在的层就是最小深度,一旦遇到叶子直接返回,不需要遍历完整棵树。
5.2 N叉树的最大深度:从二叉树到多叉树的思维迁移
LeetCode上有个变体是N叉树的最大深度(题号559),思路几乎一样,只是子树从left/right变成了children数组。对于递归解法,把max(maxDepth(left), maxDepth(right))换成对children列表遍历取最大值即可;BFS层序更是完全一样,连层内循环都不用改,只是入队的从最多两个节点变成多个节点。
很多人在这一步卡住,其实不是不会写,而是思维没转过来——仍然死守着root.left和root.right两个字段。只要意识到树形结构的关键是"子节点集合",N叉树版本和二叉树版本几乎没有区别。
5.3 用"求深度"统一框架,串起一系列题:我的刷题顺序建议
我个人比较推荐的刷题路线是:
- 104 二叉树的最大深度——理解递归返回值和DFS/BFS框架;
- 111 二叉树的最小深度——理解"边界条件"的陷阱,BFS先找到叶子即返回;
- 110 平衡二叉树——理解"递归里同时做计算和判断";
- 543 二叉树的直径——理解"全局变量伴随递归收集信息";
- 559 N叉树的最大深度——理解"从二叉树泛化到N叉树"。
这五道题按顺序刷完,你对树的深度类题目就会形成一张知识网。再回头去看层序遍历、右视图、路径总和,你会发现很多代码结构都似曾相识。
6. 本地构建二叉树避坑指南:测试用例搭不对,什么算法都白搭
6.1 从层序数组构建二叉树,常见的几个致命细节
刷LeetCode的人经常遇到一个问题:题目给的是形如[3,9,20,null,null,15,7]的层序数组,但本地调试的时候你得自己把它还原成二叉树。很多人在这里栽跟头,因为LeetCode题面里数组和树的对应关系有隐含约定:null表示该位置没有节点,但是还原逻辑写错一丁点,构建出来的树就是歪的。
我给出一个稳妥的构建模板(Python版),它用队列逐层填充节点:
def build_tree_from_list(data): if not data or data[0] is None: return None from collections import deque root = TreeNode(data[0]) q = deque([root]) idx = 1 while idx < len(data): node = q.popleft() if idx < len(data) and data[idx] is not None: node.left = TreeNode(data[idx]) q.append(node.left) idx += 1 if idx < len(data) and data[idx] is not None: node.right = TreeNode(data[idx]) q.append(node.right) idx += 1 return root这个写法的关键点是:索引idx始终只向前移动,每处理一个父节点,就顺序消费数组里的两个值作为其左右孩子。如果发现是None,那就只跳索引,不建节点。这里最容易出错的地方有两个:
- 忘记把新建的左右子节点加入队列,导致后面的节点无处安放;
data[idx]是None时直接套TreeNode(None),然后后续访问它的left字段立刻崩。
我用这个模板在本地跑了大量测试,包括[1,2]、[1,None,2]、[1,2,3,None,None,4,5]等边界情况,都能正确还原为对应的树结构。建议你直接收藏,省得每次刷树题都重写一遍。
6.2 测试"链表式二叉树":如何快速构造一棵深度很大的退化树
如果你要测试非递归版本在深度极大的情况下的表现,需要一棵"一条道走到黑"的退化树,可以快速构造:
def build_skewed_tree(depth): root = TreeNode(0) cur = root for i in range(1, depth): cur.right = TreeNode(i) cur = cur.right return root这棵树没有左子树,每个节点只有右孩子,从根往下形成一条链。用它在本地测试时可以直观地验证一件事:maxDepth递归版在depth=1100时直接报RecursionError,而BFS版和栈DFS版都能轻松跑到depth=10000以上。这个实验我建议每个人都亲手做一次,只有亲眼看到栈溢出,才会对"迭代写法不是炫技而是必要"有深刻记忆。
6.3 如何打印二叉树,快速肉眼验证你的算法结果
本地调试二叉树时,一个特别实用的工具是写一个简单的层序打印函数:
def print_tree_level_order(root): if not root: print("empty tree") return q = deque([root]) while q: level = [] for _ in range(len(q)): node = q.popleft() level.append(str(node.val) if node else "#") if node: q.append(node.left) q.append(node.right) print(" ".join(level))能看到树的真实结构之后,调试效率会翻倍。比如构建完一棵树发现深度算出来不对,最直接的排查方式就是把它打出来,用肉眼对照"树长什么样"和"深度应该是什么"。很多时候问题不在于算法本身,而是建树时左右孩子挂错了。
7. 三种语言的实现对比:Python、Java、C++的注意点各不相同
7.1 同步给出三份核心代码,附复杂度说明
这道题在面试中可能被测各种语言,我这里把三种最常见语言的核心解法都写一遍,方便你对照。
Python递归:
class Solution: def maxDepth(self, root: Optional[TreeNode]) -> int: if root is None: return 0 return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))Java递归:
class Solution { public int maxDepth(TreeNode root) { if (root == null) return 0; return 1 + Math.max(maxDepth(root.left), maxDepth(root.right)); } }C++递归:
class Solution { public: int maxDepth(TreeNode* root) { if (!root) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); } };三份代码的时空复杂度完全一致:时间O(n),空间最坏O(n)(树退化为链表时),平均O(log n)(平衡树)。
7.2 Java/C++面试中被追问的细节,别露怯
用Java写这道题时,面试官可能会问TreeNode类的定义。标准定义是:
public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } }这个构造器的重载其实很有讲究:三个构造方法分别覆盖"无参构建(为了序列化框架或反射)""仅填值""带左右孩子完整构建"。你在本地测试时,直接用最简单的一参数版本new TreeNode(1)就够,但要知道多参数版本存在,面试官可能顺手问一句。
C++的话,重点是指针判空。root可能是空指针,访问root->left之前必须先检查root本身。另外C++递归返回时不存在垃圾回收压力,但要注意内存泄漏问题:如果是new出来的树节点,测试完最好delete掉,不过LeetCode判题环境不用操心这个。
还有一个所有语言都通用的细节:不要用全局变量记录深度然后递归累加。比如在Python里定义一个self.depth = 0,然后在递归里self.depth += 1,这是一种常见的错误思路——因为递归回退时你还要手动self.depth -= 1,稍有不慎就多算或少算一层。最大深度的递归式1 + max(...)这种"带返回值向上传递"的方式才是最不容易出错的,因为它天然利用函数返回值表达子问题的结果,而不需要外部变量辅助。
8. 写在最后:这道题刷完,留下什么东西才是真的赚到
回到标题本身——Hot 100第三十六题,二叉树的最大深度。很多人刷完之后可能只记住了一句"递归就完事了"。但我希望你看完这篇文章后,对这道题的印象是立体的:
- 你知道了递归的每一层调用都在系统栈上留下足迹,深度过大时会栈溢出;
- 你知道了BFS层序和栈模拟DFS是递归的两条替代路线,而且各有适用场景;
- 你知道了从最大深度出发,可以线性延伸到直径、平衡树、最小深度、N叉树深度这一整个系列;
- 你知道了本地测试二叉树时,构建和打印树的工具函数比算法本身更容易翻车。
我个人的实操心得很简单:把这道题当作"二叉树的度量尺"。后面每学一个新的树算法,比如Morris遍历、线段树、二叉搜索树操作,我都会先问自己一句"这棵树的深度在这个算法下是变大了还是变小了"——这个念头就是最大深度这道题留给我的资产。
如果你正准备刷Hot 100,我建议不要只盯着题号进度,而是每到一道题都问自己三个问题:这题考什么、递归解法爆栈怎么办、变体题怎么改。能把这三个问题都回答清楚,你刷一道题的效果顶别人刷三道。这道最大深度,恰好就是练这三个问题的最佳起点。最后再说一句:去把BFS版代码默写一遍,真的比收藏这篇文章管用。