二叉树深度的题,我见过太多人三分钟秒掉,也见过同一个人被追问到沉默。今天聊的这道题,来自"每日一题"系列第16期:后序遍历 + 深度计算。题目本身不复杂,无非是求一棵二叉树的最大深度——但围绕这个简单问题,能把递归原理、迭代写法、树形DP思想串在一起讲透,也能暴露不少刷题时容易忽略的知识盲区。
这篇文章想拆清楚一件事:为什么计算深度时,后序遍历是最自然的解法;以及当面试官让你换迭代、换变体、换个问法时,你怎么组织思路。适合正在刷二叉树专题的读者,也适合准备技术考核但不想靠背模板过关的人。我尽量把"理所当然"背后的逻辑掰开揉碎,顺便把踩过的坑一起说出来。
1. 为什么"计算深度"天生就是后序遍历的活
1.1 深度的定义本身就是子问题定义
先看一棵二叉树的深度是怎么定义的:从根节点到最远叶子节点的路径上的节点数。空树深度为0,只有一个根节点的树深度为1。
这个定义非常关键,它暗示了一种递归拆分方式。如果一棵树的根节点不为空,那么整棵树的深度,必然等于"左子树的深度"和"右子树的深度"两者中较大的那个,再加1。写成公式就是:
depth(node) = 0 // node 为空 depth(node) = 1 + max(depth(node.left), // 非空 depth(node.right))注意这个公式的读取顺序:你要算一个节点的深度,必须先知道它左右子树的深度。也就是说,信息流是自底向上的——先算最底层的叶子,再把结果一层层往根上汇总。
这种"先孩子后自己"的处理顺序,对应到二叉树的三种遍历方式里,恰好就是后序遍历:先左子树、再右子树、最后访问自己。
1.2 前中后序在"深度"面前的三张面孔
很多人背过一句话:前序是根左右,中序是左根右,后序是左右根。但背下来不等于理解为什么这道题"就该"用后序。我用下表把三种遍历在深度计算场景下的差异列出来:
| 遍历顺序 | 访问"自己"的时机 | 此时能拿到的信息 | 对深度计算的契合度 |
|---|---|---|---|
| 前序(根左右) | 最先访问自己 | 只知道根到当前节点的路径长度,不知道子树深度 | 能算,但必须带一个"当前深度"参数自顶向下传 |
| 中序(左根右) | 左子树结束、右子树未开始 | 只有左子树结果,右子树信息还没生成 | 不适合直接算深度 |
| 后序(左右根) | 左右子树都处理完 | 左右子树的深度都已就绪 | 最贴合,公式和代码都最短 |
这里重点说一下前序。前序不是不能算深度,而是它的计算视角是"从上往下数层数":根节点在第1层,往下走一层深度加1,走到叶子时记录一个候选值。这种思路需要维护一个函数外的全局变量,或者在递归参数里传递当前深度。
而后序的视角完全不同:它不需要记录"当前在第几层",它只需要在左右子树各自汇报完高度之后,做一次 max 再 +1,把结果返回给父节点。父节点拿到后继续向上汇总。
这就是大多数递归题解只有三行代码的原因:因为问题的定义方式,决定了遍历顺序。
1.3 一棵小树的完整推演
光说公式不够直观,拿一棵具体的树走一遍递归流程:
1 / \ 2 3 / \ \ 4 5 6假设现在调用深度递归函数,根节点是1。
第一步,递归进入节点4。节点4没有左孩子也没有右孩子,于是 max(0, 0) + 1 = 1,节点4返回深度1。
第二步,节点5同样返回深度1。
第三步,节点2拿到左右两个孩子的返回值:max(1, 1) + 1 = 2,节点2返回2。
第四步,节点6返回1,节点3的右孩子深度为1,左孩子为空深度0,所以 max(0, 1) + 1 = 2。
第五步,根节点1拿到左子树深度2、右子树深度2,max(2, 2) + 1 = 3。
最终整棵树的深度是3。注意整个过程中,每个节点都是"先等左右子树出结果,再计算自己",这就是后序遍历的推进节奏。你根本不需要刻意写一个"后序"标志,递归函数的天然顺序就是这个。
2. 递归解法:三行代码与递归栈的心智模型
2.1 递归写法
不管用什么语言,核心代码长这样。我用 Python 演示,因为可读性最好:
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def max_depth(root): if not root: return 0 return max(max_depth(root.left), max_depth(root.right)) + 1如果你更习惯 Java,逻辑完全一样:
public int maxDepth(TreeNode root) { if (root == null) { return 0; } return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; }第一次看这段代码的人容易犯嘀咕:为什么一个空节点返回0,代码里也没有"叶子节点返回1"的分支,结果却能算对?
答案是叶子节点不是靠特判算出来的,是靠公式推出来的。叶子节点的左右子树都是空,left 和 right 两个孩子递归调用都返回0,那么叶子节点本身返回 max(0, 0) + 1 = 1。这个1恰好就是"叶子节点也算一层"的意思。
2.2 为什么空节点返回0、叶子节点不用特判
这里有一个很重要的约定:把空节点的高度定为0。这个约定的好处是,递归基只用处理一种情况,叶子节点被公式自然覆盖。
有人会觉得"我加一个 if 判断叶子节点,返回1,不是更快吗?"多数情况下没问题,但没必要。多写一个判断语句,就等于在递归的最底层多做一次条件判断;而且容易把基线和"空树深度为0"的约定搞混。刷题时宁可多信任公式一点,把边界条件收敛到最小集合。
我见过一种写法,在递归函数开头写:
if not root: return 0 if not root.left and not root.right: return 1这段代码也能跑对,但它和"空节点返回0 + 公式推导"表达的是同一件事,却啰嗦了一圈。面试时如果手写代码,我更推荐只留一个空节点判断,因为越少的条件分支意味着越难写错。
2.3 前序参数版:另一种可能
为了说明"后序为什么好",我把前序版本的实现也写出来。用前序思路求深度,需要带一个 depth 参数,从根往下传:
def max_depth_top_down(root): ans = 0 def dfs(node, depth): nonlocal ans if not node: return ans = max(ans, depth) dfs(node.left, depth + 1) dfs(node.right, depth + 1) dfs(root, 1) return ans这段代码的逻辑是:每次进入一个节点,就用当前深度去更新全局最大值;然后带着 depth + 1 继续往下传。整个过程是"自上而下"的,答案不在返回值里,而是在外部变量里累积。
这个版本和后序版本的核心区别,类似于两种工作方式:
- 前序版是领导从顶层发指令,每到一个层级就记一下"这是第几层",最后取最大记录。
- 后序版是基层员工先汇报,每一层汇总完再往上报,最高层拿到的汇总值就是深度。
面试时如果你能主动说出这两种思路的区别,通常比闷头写代码更有说服力。我自己面试别人时也喜欢问这个点:能分清"自上而下传参数"和"自下而上带返回值",说明是真的理解了递归,而不是背模板。
2.4 复杂度分析
时间上,每个节点恰好被访问一次,所以时间复杂度是 O(n),n 是节点总数。
空间上,递归栈的深度等于树的高度。如果树是平衡的,高度是 O(log n);如果树退化成一条链,高度就是 O(n),此时递归栈会消耗大量内存。
这个"递归空间复杂度等于树高"的结论,经常被拿来追问。所以下面的迭代解法不是炫技,是实打实需要掌握的备选方案。
3. 迭代解法:不依赖系统栈的三种打开方式
面试官一句"能不能不用递归",很多人会当场懵住。原因很简单:平时代码都是背着写,真到要自己维护一颗"栈"来代替系统调用栈时,思路就容易乱。
下面给出三种迭代思路,从易到难,覆盖不同场景。
3.1 栈加深度值:最直观的 DFS 迭代
第一种思路最简单,栈里的每个元素不只是节点,还附带该节点对应的深度。模拟递归的前序或后序都无所谓,因为我们只关心深度最大值:
def max_depth_iterative(root): if not root: return 0 stack = [(root, 1)] ans = 0 while stack: node, depth = stack.pop() ans = max(ans, depth) if node.left: stack.append((node.left, depth + 1)) if node.right: stack.append((node.right, depth + 1)) return ans这段代码的核心是:当一个节点被弹出时,它已经在栈里存好了自己所在的层数。左右孩子入栈时,深度直接加1。这里的遍历顺序其实更接近"前序变体",但因为求的是最大值,不依赖严格的"左右根"访问顺序,所以完全够用。
这种写法的最大优势是几乎不可能写错。栈不空就循环,每弹一个节点就更新 ans,然后把孩子压进去。我刷题初期遇到迭代树问题,默认先写这个,因为它把"层数"这个关键信息牢牢绑在了每个节点上,不会出现"绕了一圈忘了深度怎么传"的情况。
3.2 严格后序的迭代:访问标记法
问题来了:如果题目要求你必须用严格的后序遍历,即左子树处理完、右子树处理完、最后才处理当前节点,那上面的写法算不算数?
严谨地说,不算。它只是深度计算,不是严格的后序序列。真要模拟后序,常见做法是给每个节点标记访问状态,第一次出栈时不处理,先把右孩子、左孩子重新压栈,等左右都处理完,第二次出栈时才计算当前节点深度。
我用一个 Python 版本演示:
def max_depth_strict_postorder(root): if not root: return 0 depth = {None: 0} stack = [(root, False)] while stack: node, visited = stack.pop() if node is None: continue if visited: # 左右子树都已经算完,这里才轮到当前节点 depth[node] = max(depth[node.left], depth[node.right]) + 1 else: # 第一次遇到:把自己标成 visited,再把右左孩子压栈 stack.append((node, True)) if node.right: stack.append((node.right, False)) if node.left: stack.append((node.left, False)) return depth[root]这段代码里,dict 相当于一个"深度账本",key 是节点对象,value 是它的深度。栈里存的是 (node, visited) 二元组。当 visited 为 True 时,说明它的左孩子和右孩子都已经处理完,此时取左右深度做 max + 1,正好对应后序公式。
这个写法体现了"用手动栈模拟回溯"的精髓,代价是代码量明显增加。面试时能在五分钟内写对的人,通常对栈的入栈顺序非常敏感。一个容易出错的地方是压栈顺序:想让弹出顺序是"左、右、根",就要反过来压"根、右、左"。
3.3 BFS 层数:深度和层数的等价性
还有一种思路不看后序,直接看层数。树的深度是多少,就是有多少层。用队列做层序遍历,每处理完一层计数加1,最终计数值就是深度:
from collections import deque def max_depth_bfs(root): if not root: 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) 固定当前层的节点数量。如果你在循环里直接写 range(len(q)),因为每次 popleft 和 append 都会改变队列长度,会一次性多处理或漏处理节点,计数器就乱了。
BFS 的优点是天然不担心递归爆栈,空间复杂度是队列长度,最坏情况是最后一层的节点数;缺点是代码对"层"的感知强,对"子树聚合"的感知弱。需要子树间比较的题目(比如判断平衡、算直径),BFS 往往不如后序顺手。
3.4 三种方法怎么选
| 方法 | 代码量 | 是否严格后序 | 突出优点 | 主要风险 |
|---|---|---|---|---|
| 递归后序 | 最小 | 是 | 表达清晰,逻辑最贴近定义 | 大树高时栈溢出 |
| 栈 + 深度值 | 小 | 否 | 好写好记,不易出错 | 不是严格后序 |
| 访问标记后序 | 中等 | 是 | 完全模拟后序,适合考察原理 | 容易在压栈顺序上写错 |
| BFS 层数 | 小 | 否 | 天然防爆栈,逐层直观 | 不便于处理子树间聚合 |
我的个人建议是:面试先给递归版,被追问则优先写"栈 + 深度值"。如果面试官明确要求"用后序遍历的迭代形式",再切换到访问标记法。背两个版本就够了,不要贪多。
4. 把后序当"聚合模板":四个同源变体一次讲透
很多人刷完最大深度就往下走了,其实这道题的价值远不止答案本身。后序遍历的真正威力是一种"先收集子树信息,再生成父节点信息"的聚合模式。抓住这个模式,下面这些题都是一网打尽的。
4.1 平衡二叉树:用 -1 作为失败哨兵
判断一棵二叉树是否高度平衡,标准定义是:每个节点的左右子树高度差不超过1。暴力做法是每个节点都算一次子树高度,整体复杂度 O(n²)。后序思路可以一趟完成。
核心思想:递归函数返回子树高度,如果发现某棵子树不平衡,直接返回 -1 作为哨兵值,父节点看到 -1 就知道整棵树已经不合格。
def is_balanced(root): def height(node): if not node: return 0 left_h = height(node.left) if left_h == -1: return -1 right_h = height(node.right) if right_h == -1: return -1 if abs(left_h - right_h) > 1: return -1 return max(left_h, right_h) + 1 return height(root) != -1这个题和最大深度的关系非常直接:它就是在算左右子树深度的过程中,顺便做了一次差值的合法性检查。用 -1 而不是 False 的原因是:既要传达"不平衡"这个布尔信息,又要传达"当前高度"这个数值信息,一个整数可以同时表达两者,省掉了额外的结构体。
4.2 最小深度:不能直接套最大深度模板
最小深度是"根节点到最近叶子节点的最短路径上的节点数",叶子节点定义为左右孩子都为空。
这里有个经典陷阱:直接把最大深度里的 max 换成 min,得到 min(min_depth(left), min_depth(right)) + 1,是错的。
错在"空子树参与比大小"。比如一棵只有右子树的链状树,左子树为空返回0,右子树深3。如果取 min(0, 3) + 1,结果是1,而正确答案应该是 3 + 1 = 4。因为空子树这条路径上根本没有叶子节点,它不该被当成"深度0的路径"参与最小化。
正确写法要对空子树做引导:
def min_depth(root): if not root: return 0 if not root.left and not root.right: return 1 if not root.left: return min_depth(root.right) + 1 if not root.right: return min_depth(root.left) + 1 return min(min_depth(root.left), min_depth(root.right)) + 1这个例子很好地说明了一件事:模板能帮你省时间,但保持对模板语义的理解才能真正不翻车。最大深度和最小深度差一个字,代码结构却差了很多,根因在于"空子树"在两种场景下的含义完全相反。
4.3 二叉树直径:在返回高度的同时更新全局答案
二叉树的直径,定义是任意两个节点之间路径上的边数最大值。路径不一定经过根节点,所以它可能藏在任何一棵子树内部。
后序解法是这样的:对每个节点,经过它的最长路径长度 = 左子树深度 + 右子树深度。递归在返回子树深度的同时,顺手用这个"左深 + 右深"去更新全局答案。
def tree_diameter(root): ans = 0 def depth(node): nonlocal ans if not node: return 0 left = depth(node.left) right = depth(node.right) ans = max(ans, left + right) return max(left, right) + 1 depth(root) return ans注意这里我非常喜欢后序的原因:只有等左右子树都返回了,才能知道"经过当前节点的最长路径有多长"。如果你用前序,走到节点时左右子树的深度信息根本还没生成,这个 left + right 就无从算起。
4.4 N 叉树与更多推广
后序聚合模式还能推广到 N 叉树。N 叉树的最大深度,就是把"左右孩子比较"换成"所有孩子里找最大":
def max_depth_nary(root): if not root: return 0 return 1 + max((max_depth_nary(child) for child in root.children), default=0)关键在 default=0,它处理了叶子节点的 children 为空的情况。没有这个默认值,max 函数面对空序列会直接报错。
这一系列变体下来,你会发现它们都在做同一件事:子树返回结果,父节点汇总。想清楚这个模式,树相关的题目就不再是零散的知识点,而是一套可以复用的思考框架。
5. 刷题和面试里最容易翻车的四个细节
5.1 口径要对齐:空树的深度是0还是1
这是一个很容易被忽略的约定问题。有的教材把"根节点深度为0",那么叶子节点深度就是树高减1;而刷题场景里通常约定 root 为 null 时深度是0,最好的做法是在写代码前明确问一句。
如果你在面试现场,直接说"我按空树返回0的口径来写",这句话既能体现边界意识,也能避免和你后面代码的不一致。闷头写然后又改来改去,才是面试官最不愿意看到的。
5.2 递归爆栈:退化树的隐藏风险
极端情况下,一棵二叉树可以退化成链,几千层深。很多语言的递归深度是有限制的,比如 Python 默认递归深度大约在1000层左右,超过会抛 RecursionError。
刷题用例通常不会这么极端,但我在实际处理大规模数据时确实撞到过。如果明确知道树可能很深,最稳妥的做法是用迭代版本。不要依赖 sys.setrecursionlimit 去硬撑,那只是把炸点往后挪了挪,不是真正的解决方案。
5.3 迭代里的 None 与动态 length 陷阱
迭代写法里有两个非常具体、极易踩的坑。
第一个是把 None 孩子压入栈或队列。在"栈 + 深度值"的写法里,如果压入了 None,弹出时会因为尝试访问 None.left 而报错;在 BFS 里,None 混入队列会打断"每层处理固定数量节点"的逻辑。
第二个坑在 BFS 里更隐蔽:
while q: for _ in range(len(q)): # 错误示范:len(q) 在循环中不断变化 node = q.popleft() ...每轮 popleft 和 append 都会改变 len(q),导致循环次数被动态重算。正确做法是入循环前先用 size = len(q) 固定本层数量。这个错误非常容易犯,因为它不报错,只是结果悄悄变错。
5.4 全局答案的写法:从 nonlocal 到可变对象
凡是需要"遍历过程中更新全局答案"的题目,比如二叉树直径、前序版本的最大深度,都会遇到一个问题:怎么在递归内部修改外部变量。
Python 里最常见的是 nonlocal 关键字;也可以用列表,比如 ans = [0],然后在递归里用 ans[0] = max(ans[0], ...)。Java 里可以用成员变量或者 int[1] 数组。
这里有个容易犯的低级错误:有些人把 ans 直接当递归函数的普通参数传下去,然后在里面赋值,以为外层能看到。实际上普通参数是按值传递,改了也传不回来。我在代码评审里见过好几次这种问题,建议第一反应就使用 nonlocal 或可变容器。
一点自己的收尾体会
刷树相关的题目刷到中期,我养成了一个习惯,遇到树先问自己一句:这道题的答案,是要从叶子向上汇总,还是从根向下传参?想清楚这一句,后序还是前序、递归还是迭代,都不用再纠结了。
后序遍历 + 深度计算这道"每日一题#16",本身代码量少得可怜,但它牵出的递归心智模型、迭代栈模拟、树形DP雏形,是一整条知识线。把这根线捋顺了,后面再遇到平衡树、最近公共祖先、树上的路径统计,都会顺畅很多。