讲递归和二叉树最大深度这个话题之前,先说说我自己的经历。前些年带团队面试候选人,十个里八个写的都是这个版本的代码:
public int maxDepth(TreeNode root) { if (root == null) return 0; return 1 + Math.max(maxDepth(root.left), maxDepth(root.right)); }代码短、逻辑清晰、运行也对,但真到追问环节,能把这个函数讲透的人不超过三成。很多人只会背模板,却说不出递归到底在做什么,更回答不了“如果树有十万层怎么办”“为什么这里不用遍历框架”“报空指针异常到底错在哪”。这篇文章就把递归求解二叉树最大深度这件事拆开揉碎,从思路到实现,从报错排查到题目延伸,一次讲清楚。适合刚接触二叉树和递归的初学者,也适合准备面试但总在细节上翻车的老手。
1. 递归解法的核心思路:为什么树和递归这么搭
1.1 先写出口,再写主逻辑
先把问题定义清楚:一棵二叉树的最大深度,就是从根节点到最远叶子节点的路径上经过的节点总数。空树深度为0,只有一个根节点的树深度为1。这个概念看起来简单,但写代码时容易出事,因为很多人直接跳到“怎么递归”,忘了还有空树这个边界情况。
递归解决树形问题的套路其实非常固定,总共就三步:第一步,明确递归函数接收什么参数、返回什么结果;第二步,找到递归终止条件,也就是最简单的那个输入;第三步,假设子问题已经解决,把当前问题和子问题的关系写成表达式。拿最大深度来说,maxDepth(root)接收一棵树的根节点,返回这棵树的深度;终止条件是root == null时返回0;主逻辑就是当前节点的深度等于左右子树深度较大者加1。
这个“假设子问题已经解决”的说法,初看有点抽象。我换个说法:你不需要真的去想递归调用在底层怎么一层层展开,你只需要相信maxDepth(root.left)就是左子树的深度,maxDepth(root.right)就是右子树的深度,然后当前节点把它们接起来。这也叫“递归信任”——把递归函数当作一个已经写好的黑盒,只关注当前这一层要做什么。
1.2 自底向上的视角:把返回值交给上层
很多人在这一步卡住,是因为脑子里总在想“递归到底怎么倒回去的”。我建议你换个视角:递归的本质是从底向上传递信息。
拿一棵三层的满二叉树举例:
1 / \ 2 3 / \ 4 5调用maxDepth(root)后会发生什么?系统先把当前状态压入调用栈,然后去调用maxDepth(root.left),也就是以节点2为根的那棵子树;节点2又会去调用节点4和节点5,节点4的左右孩子都是null,maxDepth(null)返回0,所以节点4这一层得到1 + max(0, 0) = 1。同理节点5返回1。节点2拿到左右子树的结果后,返回1 + max(1, 1) = 2。最后根节点拿到左子树返回的2和右子树返回的1,得到1 + max(2, 1) = 3。
你会发现,真正“算数”的动作发生在递归返回的路上,也就是后序位置。每个节点都不需要知道整棵树的形状,它只需要知道自己的左右子树有多高。这种“积累返回值给上层”的模式,就是自底向上动态规划在树上的最简单形态。
顺便说一句,这句话也是很多面试官希望你现场说出来的:最大深度问题本质上是在做后序遍历,因为在返回阶段才需要用到左右子树的结果。你要是能主动点出这层关系,再加一句“如果改成自顶向下,就需要在递归时携带深度参数”,那这道题基本就稳了。
2. 从递归到非递归:BFS与栈模拟
2.1 层次遍历的天然计数
递归解法虽然简洁,但它不是银弹。最直接的问题就是:当树的高度非常大时,递归调用会层层压栈,最终抛出StackOverflowError。真实业务里你遇到的不一定是面试题里的漂亮平衡树,可能是从数据库里查出来的一棵组织架构树,深度几百上千都很常见。这时候,非递归解法就成了必需品。
最大深度用广度优先搜索(BFS)来做,直觉上就非常顺:深度就是层数,二叉树的层数就是最大深度。你只需要按层遍历,每处理完一层就把计数加1,直到队列为空。Java实现长这样:
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; }这个for循环是精髓。它利用size = queue.size()在进入循环前先固定当前层的节点数,这样处理完这一层后,队列里剩下的就是下一层的全部节点。如果你写成while (!queue.isEmpty())直接 poll,那就不是在按层计数,而是在数总节点数,返回的结果就全错了。这是我见过最多的写错方式,没有之一。
BFS 的时间复杂度同样是 O(n),空间复杂度是 O(w),w 是树的最大宽度。对于一颗完全二叉树来说,最后一层大概有 n/2 个节点,所以空间占用在满二叉树场景下会更大一些;但对于斜树来说,队列里最多同时只有一个节点,非常省。
2.2 栈模拟带状态遍历
如果既想保持非递归,又想模拟递归那种“带着当前路径深度一直往下钻”的过程,可以用栈来做深度优先搜索(DFS)。核心思路是:栈里存的不是一个节点,而是一个“节点 + 当前深度”的组合。每弹出栈顶,就尝试推入它的左右孩子,并把深度加1,同时维护一个全局最大值。
public int maxDepth(TreeNode root) { if (root == null) return 0; Deque<Object[]> stack = new ArrayDeque<>(); stack.push(new Object[]{root, 1}); int maxDepth = 0; while (!stack.isEmpty()) { Object[] pair = stack.pop(); TreeNode node = (TreeNode) pair[0]; int depth = (Integer) pair[1]; maxDepth = Math.max(maxDepth, depth); if (node.left != null) stack.push(new Object[]{node.left, depth + 1}); if (node.right != null) stack.push(new Object[]{node.right, depth + 1}); } return maxDepth; }这种方案理解起来也不难,你把它想象成拿着一份“路线图”在走迷宫:每到一个新路口,就把“从这里出发的备选路径”压进栈里,先沿着当前路线走到头,然后回来换下一条。这里的深度就是每条路线已经走过的节点数,最大值自然就是最优解。
说到递归转非递归,你可以顺便练习一个经典类比:快速排序的递归版本只要递归区间还有未排序部分,就会递归处理左右两个子区间;非递归版本则是用一个栈保存每个待处理区间的左右边界,循环弹出处理。这两件事的处理思路一模一样,都是“把递归函数的调用上下文显式地保存到栈里”。学会了这个迁移,不光是二叉树,任何递归算法你都能找到对应的迭代版本。
3. 运行时错误排查实录:把这些坑提前踩平
3.1 空指针异常:百分之八十的运行时错误都是这个
写二叉树程序时最容易遇到的运行时错误是什么?答案非常一致:NullPointerException。其中最经典的翻车写法是,一开始就判断if (root.left == null && root.right == null) return 1;然后主逻辑里直接访问root.left和root.right。乍一看很合理,但遇到只有左子树、没有右子树的时候,递归调用maxDepth(root.right)传入 null,下次进函数先执行root.left,直接空指针。
正确做法就一条:递归函数第一行永远先判空。让空节点返回0,把空指针挡在函数门外。这也是为什么很多老手写这个题目时会强调root == null的出口放到所有逻辑之前,而不是在调用方去判断孩子是否为null。
我还见过一种更隐蔽的空指针:在层序遍历里用node.left.val而不先判node.left是否为 null。这个场景常见于“把数组层序反序列化构建二叉树”的题目中,数组里有null占位,构建出来的树有残缺,遍历时没判空就会炸。排查办法很简单,看异常堆栈指向的源码行,八成是对某个对象的字段做了访问,前面却没有null判断。
3.2 栈溢出:递归深度超过虚拟机限制
StackOverflowError是另一个高频问题,尤其当题目里的二叉树不是平衡树,而是退化成了一条链。比如每个节点都只有右孩子,树高等于节点数,递归深度就是十几万层,Java虚拟机默认的栈深度一般在几百到几千层之间,很快就爆。
这类错误光看代码很难发现,因为逻辑完全正确,跑小数据也不报错,一上大数据就崩。排查思路有三步。第一步,看异常堆栈,找以java.lang.StackOverflowError开头的错误,确认是栈溢出而不是普通的空指针。第二步,检查树的高度,你可以临时写一个单独计算树高的递归函数,如果高度确实过大,基本上就能判断是递归深度问题。第三步,换成 BFS 或者显式栈的 DFS,问题立刻消失。
这里给一个我个人的习惯:除非确定树高可控,否则生产代码里我只用迭代解法。面试时可以用递归展示思路,但聊到扩展性时一定要主动提非递归方案。考官想听的往往不是你背得多熟,而是你有没有这个风险意识。
3.3 递归出口和统计逻辑混写
还有一种日志不看仔细根本找不到的问题:代码逻辑没问题,结果却一直比预期大1。典型的错误写法是:
public int maxDepth(TreeNode root) { if (root == null) return 1; // 错误:空树返回了1 return 1 + Math.max(maxDepth(root.left), maxDepth(root.right)); }把递归出口的返回值从0写成1,就会导致每个节点都多算一层,空树直接返回1而不是0。这种错在本地小数据上极难发现,因为单节点树返回2,看起来好像“也没差多少”,直到你用空树或者深度较浅的树做单元测试才暴露。我建议在自测用例中一定要包含空树、单节点、满二叉树、斜树、随机树这五类输入,把这五类全部跑一遍,再小的逻辑偏差都能暴露。
我把这类运行时错误整理成了速查表,方便你对照处理:
| 异常类型 | 典型触发场景 | 排查方向 | 修复方案 |
|---|---|---|---|
| NullPointerException | 递归入口没有判空,或在遍历孩子时不判空 | 查看堆栈行号,寻找字段访问前的null | 统一在函数开头判断root == null返回0 |
| StackOverflowError | 树高过大,递归压栈过深 | 检查树是否退化成链,确认递归深度 | 改用BFS或显式栈模拟DFS |
| 结果恒定多1 | 空树返回1,或递归合并逻辑多加了一次 | 用单节点和空树做边界测试 | 把终止条件的返回值校准为0 |
| ClassCastException | 从Object[]取元素时强转错误 | 检查压栈时存的是什么类型 | 使用Pair或自定义内部类保存节点和深度 |
4. 一道题带出一串题:深度问题在二叉树家族里的位置
4.1 遍历框架与深度参数
最大深度不是孤立的题目,它其实站在二叉树遍历框架的枢纽位置上。前面说过,递归版最大深度利用的是后序位置——先拿到左右子树的结果,再决定当前层的返回值。但你可以在前序位置做同样的事:递归时给每个节点携带一个depth参数,进入下一层就depth + 1,然后更新全局最大值。
这种自顶向下的写法也很有用,尤其当问题需要“对每个节点做一次判断”时,比如判断一棵树是不是平衡二叉树。平衡二叉树的定义是左右子树高度差不超过1,最直观的做法是对每个节点都调用一次maxDepth计算左右子树高度,然后递归检查每个子树是否平衡。这个方法时间复杂度是 O(n log n),因为每个节点都要往下统计子树高度,但思路特别直观,非常适合作为第一版实现。
4.2 平衡二叉树和直径问题
从最大深度再往前走一步,就到了“二叉树直径”问题。直径的定义是任意两个节点之间的最长路径长度,这个路径不一定经过根节点。你可能以为直径就是左子树深度加右子树深度,但考过的人都知道,直径可能完全落在某棵子树的内部。所以正确的解法是:后序遍历中,对每个节点用leftDepth + rightDepth更新全局最大直径,同时返回该节点的最大深度给父节点使用。
看到没有,这里“返回子树的深度”和“更新全局答案”是两件并行的事。你只要把最大深度那道题的返回值利用好,再加上一个max全局变量,直径问题就迎刃而解。这也是为什么我一直强调,最大深度绝对不只是背一道模板题,而是你在理解“能算多少信息并往上传递”的最小单元。深度、高度、层数、路径长度,全是从这个单元演化出去的。
4.3 搜索二叉树与线索二叉树的延伸
再往远看,搜索二叉树(BST)和线索二叉树也和深度、遍历强相关。
判断一棵树是否是搜索二叉树有个经典的递归写法:对每个节点传入一个允许的取值范围(min, max),递归左子树时更新上界为当前节点值,递归右子树时更新下界为当前节点值。这个思路和最大深度的“返回子结果给父节点”在设计上同构,都是通过递归约定子问题边界。另一种更取巧的做法是中序遍历后检查序列是否递增,因为搜索二叉树的中序遍历天然有序。这两种方案一个自顶向下,一个自底向上,刚好能帮你把递归的两种视角串起来。
线索二叉树则解决的是另一个维度的痛点:普通遍历需要栈或者额外空间记录后继节点,线索化通过把空指针改造成指向前驱/后继的线索,让遍历空间降到 O(1)。有一种叫 Morris 遍历的算法,不需要额外空间就能完成中序和前序遍历,原理就是在遍历过程中临时修改树的右指针,构造出“线索”。所以当你看到“最大深度不需要额外空间、不能递归”这类进阶要求时,思路也能往这个方向拓展——只是实践复杂度高不少,普通面试不会要求你手写,但了解它有助于你理解为什么遍历和深度问题本质上都在处理“怎么高效地走完整棵树”。
顺带说一句,二叉树的遍历本身就是深度问题的亲戚。层序遍历天然按层输出,每层恰好对应一个深度值;前序遍历天然适合解题思路是“自上而下处理”的题目;后序遍历天然适配“先要孩子结果再做决策”的题目。你只要把最大深度这题做透,遍历框架的四种写法等于复习了一遍。
5. 实操心得与自测清单
5.1 自测用例设计
分享一个我平时刷题和交付代码之前都会过的自测流程,一把梭下来可以避开九成低级错误。我把测试用例按“形状”分成五类:
空树:null,期望0。单节点树:TreeNode(1),期望1。满二叉树:三层满树,期望3。斜树:每个节点只有右孩子,深度等于节点数,期望n。随机形状树:左右子树高度不一致,手动算好期望值。
这五类覆盖了最大深度题目的所有边界情况。特别是“空树”和“斜树”,一个测出口,一个测递归深度,缺一不可。你只需要把这些用例写成单元测试,或者直接在main函数里手动构建后打印输出,一次跑完。
5.2 我个人的代码习惯
最后说点我自己的习惯。第一,凡是写递归,我都会在函数注释里写清楚“递归函数的作用、入参、返回值、终止条件”,这个注释帮我省了大量回头排查的时间。第二,二叉树的题,我优先考虑能不能用递归表达清楚;如果涉及大深度、高并发或者需要反复执行的线上代码,我直接用迭代方案,不给自己挖坑。第三,排查运行时错误时,我不凭肉眼猜,而是先看异常堆栈定位到具体行号,再对照上面对应的速查表。
还有一个小技巧,是我在一道题上踩过坑之后总结出来的:递归合并时,不要把所有逻辑挤在一行返回里。如果你的主流程很复杂,先拆成局部变量,比如int leftDepth = maxDepth(root.left);int rightDepth = maxDepth(root.right);然后再合并返回。这样一旦结果不对,你用断点就能看到左右子树的返回值分别是什么,排查成本比盯着一行嵌套的1 + Math.max(...)低得多。
这些习惯看似琐碎,但在实际开发和面试中帮了我很多。递归、二叉树、最大深度,这三个关键词组合在一起,背后其实是“以一种简短的代码处理复杂的层级结构”的思维范式。把这道题吃透,你对递归的理解、对栈的把握、对异常边界的敏感度都会明显上一个台阶。下次再有人写错maxDepth或报空指针,你甚至不用看代码,问一句“你判空了吗”就能定位问题——这就是经验带来的效率。