1. 先把这道题吃透:它为什么能进hot100
hot100里有一类题,是真正的“基础分水岭”,104.二叉树的最大深度就是其中之一。这道题让很多刚开始刷二叉树的人第一次接触到递归三板斧,也让不少刷题有段时间的同学第一次被“运行时错误”整得怀疑人生。最大深度本身不难,但如果你只记住了一个递归模板,却没有想清楚“深度是怎么一层层传上来的”,后面做平衡二叉树、直径、路径和这类题都会卡壳。这篇文章不只想讲AC代码,更想聊聊为什么写二叉树程序时总是报运行时错误,以及怎么把“深度”这个概念迁移到hot100一系列相关题上。
先说这道题本身的定位。LeetCode 104题“二叉树的最大深度”,本质上问的是:从根节点出发,沿着一条路径一直往下走,最多能走多少层。根节点算一层,空树算0层。很多教程把它归类为“二叉树遍历”的入门题,但我更愿意把它当成“递归状态设计”的启蒙题——因为它的解法和后序遍历天然绑定,而一旦你接受了这个绑定,后面一大半二叉树题目的思路都会自动打开。
这道题适合谁来刷?我的建议是:刚学完数组、链表、哈希表,准备进入树形结构的人;以及已经写过不少题,但遇到二叉树就只会背模板、说不清原理的人。前者能通过它建立递归的直觉,后者能通过它补上“回溯过程”这一课。总之,它绝不只是“一道简单题”,而是hot100里二叉树模块的一个枢纽节点。
2. 解法背后的核心思路:递归、后序、深度传递
2.1 递归三要素:先想清楚“子问题”是什么
很多人写递归时有个习惯:拿到题就开始写函数体,写到一半发现边界条件没想清楚,再回头改。这个习惯在简单的题上问题不大,但深度一旦超过两层,就会漏掉分支。我建议所有二叉树递归题都先回答三个问题:这个函数要返回什么、当前节点要做哪些事、空节点怎么处理。
对应到最大深度这道题:
- 函数定义:
maxDepth(node)表示以node为根的子树的最大深度。 - 当前节点要做的事:比较左子树和右子树的深度,取较大者,再加1。
- 空节点处理:如果node是null,深度为0。
一旦这三个问题想清楚了,代码几乎是直接翻译,不需要“感觉”。那为什么一定要先算左右子树,再算当前节点?因为一个节点能提供的深度信息只有一个:它本身的1层,加上下面最长那条路径的层数。你没有左右子树的结果,就拼不出这个答案。这种“先孩子、后自己”的处理顺序,正是后序遍历的逻辑。
2.2 从归并思维理解深度:把答案从叶子一层层抬上来
后序遍历最直观的理解,可以想象成公司里层层上报数据。叶子节点手上没有下属,所以它们上报“我这里深度是1”。中间节点接到左右两个下属上报的数字后,取一个较大的,再加上自己这层,继续往上汇报。根节点最后拿到整个树的深度。整个过程不是“从根一路往下数”,而是“从叶子一路往回算”,这是初学二叉树时最容易拧巴的地方。
我用一个生活化的场景解释:你站在一棵树的根部,想知道树有多高。你不会真的从根爬到树顶,边爬边数。更靠谱的做法是问左、右两个主要枝桠各自多高,取高的那个,再加上从地面到分叉点这段高度。递归做的事就是这个——“高度”是由子枝桠决定的,不是由根自己决定的。很多同学在纸上画递归过程时会画出一棵向下开的“调用树”,但真正的返回过程是反着往上走的,理解了这一点,递归代码就不会写得莫名奇妙。
2.3 递推公式的推导:为什么是1 + max(leftDepth, rightDepth)
如果一定要给这道题总结一个公式,那就是:
maxDepth(node) = 0, 当 node == null maxDepth(node) = 1 + max(maxDepth(node.left), maxDepth(node.right)), 当 node != null这个公式里最容易被忽略的是那个“1”。它代表当前节点自身这一层。很多人背代码时会把Math.max(leftDepth, rightDepth) + 1里的+1漏掉,一提交发现结果总是比答案小1,这就是没想清楚“当前节点自身也算一层”这个含义。另一个常见问题是把空节点深度错算为-1或者1,这会让所有结果整体偏移,而这在LeetCode的测试用例里往往直接判错。
那为什么空节点必须是0,而不是-1?因为深度计算的基点是“没有节点就没有层数”。如果你把null的深度设成-1,那么1 + max(-1 + 1, -1 + 1)会导致叶子节点深度变成0,根节点深度变成0,整体少一层。反过来设成1,会让空子树也被当成一层,答案偏大。所以空节点返回0,是这个公式能自洽的地基。
2.4 复杂度其实也值得说两句
时间复杂度是O(n),因为每个节点都会被访问一次;空间复杂度是O(h),h是树的高度。递归调用栈的深度就是树高,最坏情况下树退化成一条链,空间复杂度为O(n)。这个看似“顺便一提”的结论,其实是后面应对“运行时错误”的关键——很多栈溢出问题就是栽在“树高等于节点数”这个特殊情况上。
3. 写二叉树程序时为什么总是报运行时错误:一次完整的排查实录
3.1 先分清错误类型:StackOverflow、NullPointer、还是结果不对
在讨论具体错误前,我想先强调一个容易被忽略的事实:很多同学把“答案不对”也统称为报错,但运行时错误(Runtime Error)和答案错误(Wrong Answer)在LeetCode上是两种完全不同的反馈。运行时错误意味着程序在执行过程中崩了,常见的是StackOverflowError、NullPointerException;答案错误则是程序跑完了,只是结果不对。排查思路完全不同,前者先找“哪一行崩了”,后者先找“逻辑哪里偏了”。
以104题为例,如果你写了这样的代码:
public int maxDepth(TreeNode root) { if (root.left == null && root.right == null) { return 1; } return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; }当root为null时,第一行就会访问root.left,直接抛NullPointerException。LeetCode的测试用例里几乎必然包含空树,所以这种代码提交必挂。这时候先别急着改逻辑,你的问题是缺少“空节点保护”。
3.2 最隐蔽的坑:栈溢出与递归深度失控
另一种极常见的运行时错误是StackOverflowError。如果你输入的不是一颗稍微倾斜的普通树,而是一棵退化成链的树(每个节点只有左孩子或只有右孩子),递归深度就会等于节点数量。假设一棵二叉树有10万个节点且全部偏向一侧,递归函数调用10万层时,Java默认的虚拟机栈很可能会溢出。
遇到这种问题,很多人的第一反应是“是不是我递归写错了”,其实你的逻辑完全正确,只是“递归深度太深,超出栈的容量”。这种时候有两个方向:一是把递归改成迭代;二是在面试场景中跟面试官说明“递归解法在退化成链时可能会栈溢出,所以实际工程中我更倾向用BFS”。如果你能主动说出这句话,比背十道代码都加分。
我记得有一次在本地IDE里测一个深度很大的用例,直接报StackOverflowError,破案方式是把异常栈打出来,发现溢出发生在maxDepth方法的递归调用处。那一刻才真正理解了“空间复杂度O(h)”不是一句空话——h是什么,h就是递归调用栈的深度,而栈的深度是有物理上限的。
3.3 本地能过、提交就挂:全局变量没清空的典型翻车
还有一种“运行时错误”非常阴险:代码逻辑没问题,但你在类里定义了一个成员变量用来记录答案,比如:
class Solution { int ans = 0; public int maxDepth(TreeNode root) { dfs(root, 1); return ans; } }如果ans没有在每次调用maxDepth前重置,第一次跑一个深度为5的树后,ans变5;第二次跑一个深度为3的树,ans可能仍然是5,因为dfs内部只在比当前ans大的时候更新它。在LeetCode的评测环境下,每次测试会创建新的Solution实例,所以这个问题不算特别致命,但如果你在本地用同一个对象连续跑多个测试用例,就会得到非常诡异的结果。
解决方式很简单:能用局部变量就不要用全局变量;非要全局变量,就在入口函数里先重置。这个习惯在写其他更复杂的二叉树题(比如直径、路径和)时尤为重要,因为那些题更依赖“在递归过程中更新外部变量”。
3.4 一套通用的二叉树BUG排查方法论
结合踩过的坑,我总结了一套排查“二叉树报错”的固定流程:
- 先看异常类型:如果是
NullPointerException,八成是某个节点为null时还访问了它的子节点,检查递归入口和终止条件。 - 如果是StackOverflowError,优先怀疑递归深度过深,尝试用迭代或增加递归基。
- 如果是答案错误,找一个最小用例(比如只有根节点的树、左单链树),在纸上画出递归过程,手算一遍期望值。
- 善用打印:在递归函数开头打印当前节点值、当前深度,能快速看出递归是否走到了预期分支。
- 用极端用例测试:空树、只有根节点、左右子树深度差巨大的树、彻底倾斜的链状树。这五个用例覆盖了二叉树八成以上的边界bug。
4. 迭代方案与递归的取舍:别只会背递归模板
4.1 BFS层序遍历:数一层、加一层,最直观的深度计数
既然递归在某些场景下会栈溢出,迭代解法至少要知道一种。最简单的是层序遍历,用队列实现。它的思路很直白:把根节点入队,然后一层一层往外弹,每处理完一整层的节点,深度就加1。
public int maxDepth(TreeNode root) { if (root == null) return 0; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int depth = 0; while (!queue.isEmpty()) { int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } depth++; } return depth; }这里的核心技巧是int size = queue.size()。由于队列在循环过程中会不断加入新节点,如果直接在while里动态判空,你就不知道当前层到底有多少节点。先锁住size,再一次性处理完这一层,才能保证每轮循环恰好对应一层。我见过很多同学卡在这一步,把queue.size()放进for循环里每次重新获取,结果边界错乱,深度永远多一层。
4.2 DFS迭代:用双栈模拟递归过程
如果你想保留“深度优先”的访问顺序,也可以用两个栈,一个存节点,一个存当前深度。每弹出一个节点,就尝试把它的左右孩子压入,同时把深度加1。
public int maxDepth(TreeNode root) { if (root == null) return 0; Deque<TreeNode> stack = new ArrayDeque<>(); Deque<Integer> depthStack = new ArrayDeque<>(); stack.push(root); depthStack.push(1); int max = 0; while (!stack.isEmpty()) { TreeNode node = stack.pop(); int depth = depthStack.pop(); max = Math.max(max, depth); if (node.left != null) { stack.push(node.left); depthStack.push(depth + 1); } if (node.right != null) { stack.push(node.right); depthStack.push(depth + 1); } } return max; }双栈的结构实际上是把“函数调用栈”手动搬到了堆上,所以不受虚拟机栈大小的限制。有人可能会问:为什么要用双栈而不是把(node, depth)拼成一个对象压入?因为双栈在某些语言里性能更优,也方便理解“同步弹出”的配对关系。如果你更习惯封装一个Pair,也没有任何问题,核心逻辑是等价的。
4.3 核心区别对比:什么时候用递归,什么时候用迭代
| 维度 | 递归 | 迭代BFS | 迭代DFS |
|---|---|---|---|
| 代码可读性 | 极高,和递推公式一致 | 高,需要理解层计数技巧 | 中等,需要维护深度栈 |
| 空间复杂度 | O(h),受系统栈限制 | O(w),w是最大层宽度 | O(h),但使用堆空间 |
| 退化链状树风险 | 容易栈溢出 | 安全 | 安全 |
| 面试观感 | 最符合直觉 | 体现对树结构的理解 | 体现代码掌控力 |
训练建议是:先用递归把逻辑练到滚瓜烂熟,再熟悉一种迭代解法。面试时如果只允许写一种,递归通常最快;但如果面试官追问“最坏情况下空间会不会有问题”,你能立刻切到BFS版本,就是明显的加分项。
5. 从一道题拓展到一类题:深度问题的变体与迁移
5.1 最小深度:为什么不能直接套max的模板
hot100和力扣题库里,紧接着最大深度最常见的就是最小深度。很多同学把最大深度代码里的Math.max换成Math.min就交了,结果翻车。原因在于:当某个节点的左子树为空时,它的最小深度不等于0,而应该去看右子树的最小深度。因为“从根到最近叶子节点”的路径中,空子树并不是一条有效路径。
正确的思路是分情况讨论:
public int minDepth(TreeNode root) { if (root == null) return 0; if (root.left == null) return minDepth(root.right) + 1; if (root.right == null) return minDepth(root.left) + 1; return Math.min(minDepth(root.left), minDepth(root.right)) + 1; }这个题特别适合拿来检验自己是否真的理解了“深度”的定义,而不只是背了一个求最大值的模板。我在实际给朋友讲题时发现,很多人会想当然地认为空子树深度是0,所以最小值也该是0。这里的坑在于:空子树并不包含叶子节点,而最小深度要求的是“包含叶子节点”的最短路径。
5.2 平衡二叉树:深度判断+后序返回结构
另一个高频变体是判断平衡二叉树(110题),它要求每个节点左右子树高度差不能超过1。这题的解法就是在后序遍历中同时返回“当前子树高度”和“是否平衡”。如果你只从最大深度里学会了Math.max(left, right) + 1,那再学这题会非常顺:一个节点算完深度后,顺手检查左右高度的差值。
这类“返回结构升级”的思路很关键。最大深度只需要返回int,但很多树形DP问题都需要返回一个更复杂的结构,比如(深度, 是否平衡)或者(深度, 直径)。先把104题的单值返回练熟,后面才能驾驭多值返回,这是二叉树递归进阶的必经之路。
5.3 N叉树最大深度与直径题:同一套思路的不同外衣
N叉树的最大深度(559题)几乎是把二叉树版本平移到多叉树:遍历所有孩子,取最大深度,再+1。如果你用的是List<Node> children,只需要加一个for循环,逻辑完全不变。这让我意识到“最大深度”这个考点本质是“树的深度的定义和递归计算”,并不限定二叉树。
再往后走,二叉树的直径(543题)就更有意思了。直径可以理解为“经过某个节点的左右子树深度之和的最大值”。求解时同样是在后序遍历中拿到左右子树的深度,但更新答案用的是leftDepth + rightDepth,而返回给父节点的是Math.max(leftDepth, rightDepth) + 1。这个小小的“返回值与答案不同”的设计,很多人一开始会想不明白。但只要你想通了“当前节点既要向父节点汇报自己的高度,又要顺便计算经过自己的直径”,整道题就豁然开朗了。
5.4 从深度到路径:hot100二叉树题的串联学习方法
如果你正在刷hot100,我建议把二叉树相关题按这个顺序串在一起:先做104最大深度,再做226翻转二叉树,然后做101对称二叉树,接着做112路径总和,再做102层序遍历。你会发现它们都共享同一个“遍历框架”,只是在不同时机处理不同的业务逻辑。
深度相关的题练完后,可以尝试把“最大路径和”(124题)当成一个进阶目标。这道题经常被人在分类里标成动态规划,但它其实更像“后序遍历+状态归并”。你从104题里学到的“返回子树深度”,在124题里变成“返回子树能提供的最大贡献路径和”,整个思维迁移非常自然。这也是为什么我一直强调,104题不只是让你AC,而是让你建立一套“树形结构问题”的思考框架。
6. 我的刷题节奏与个人心得
6.1 一道题值得反复刷三次
在hot100里,104是一个可以反复利用的题目。我的建议是分三遍:第一遍只看题解,用递归AC,目标是理解后序遍历;第二遍隔几天后,在不看代码的情况下手写出BFS版本;第三遍再间隔一周,把这道题讲给一个完全不懂递归的人听,要求对方能理解“深度从叶子往上算”。
第三遍听起来有点玄学,但确实高效。讲题的过程会逼你把模糊的认知变成清晰的表达,尤其是“为什么必须取左右子树结果后再计算当前节点”这件事,讲得清楚说明真懂了。如果只是背代码,很容易在第三遍被问住。
6.2 一个让我印象深刻的翻车现场
有一次我在本地做测试,Solution类里有一个全局变量ans,连续跑了三个测试用例,前两个结果都正确,第三个深度更小但输出的还是上一个用例的答案。我当时第一反应是代码逻辑问题,后来一查发现全局变量没有重置。从那次以后,我在任何递归题里都养成了“先重置全局状态”的习惯,也尽量避免使用成员变量,能用局部变量就绝不外提。
还有一次是帮别人排查代码,对方报“运行时错误”,我看了一眼,他在递归函数里写了两个终止条件,其中一个return了ans,另一个没有返回值,编译器直接报错。这种情况在LeetCode上有时会表现为missing return statement,本地IDE会直接标红,但只要换成有些平台编译信息不友好,就会让人误以为是自己逻辑错了。所以写递归时,确保每个if分支都有明确的return,是我反复强调的底线。
6.3 最后分享一个小技巧
刷二叉树题时,强烈建议养成“先画图、再写码”的习惯。哪怕脑筋里画一遍也行:标出根节点、左右子树、递归边界。只盯着代码看是看不出问题的,但一比对着图写递归条件,空指针和边界漏判基本能规避大半。104这道题看上去简单,却是养成这个习惯成本最低的一道题。等你把画图变成肌肉记忆,再遇到hard难度的树形题时,就不会慌到无从下手。