二叉树的最大深度
根节点不需要看完整棵树。它只问左右孩子各自有多深,选择更深的一边,再把自己这一层加上。
求根节点到最远叶子路径上的节点数。
把整棵树的问题拆成左右子树可以独立回答的小问题。
空树返回 0;非空节点返回 max(左深度, 右深度) + 1。
先说结论:这道题到底解决什么
父节点怎样只依靠左右孩子返回的两个数字,算出整棵子树的最大深度?为什么递推式是 max(left,right)+1,而不是把两边相加?
中心结论:空树返回 0;非空节点返回 max(左深度, 右深度) + 1。
- 1.递归函数应该返回什么,才能让父节点直接使用?
- 2.为什么空节点深度是 0,单节点深度因此是 1?
- 3.根到叶的一条路径为什么只能选择左、右子树中的一边?
完整题目与题意拆解
给定一棵二叉树 root,返回它的最大深度。最大深度是从根节点到最远叶子节点路径上的节点数。
这里数的是节点,不是边。单节点树的最大深度是 1,空树的最大深度是 0。
- • 节点数量范围通常允许空树。
- • 树可能平衡,也可能退化成一条链。
- • 答案只关心最深的一条根到叶路径。
输入:root = [3,9,20,null,null,15,7]
输出:3
解释:最长路径可以是 3 → 20 → 15,共经过 3 个节点。把 maxDepth(node) 定义为:以 node 为根的这棵子树,最深有多少层。这个定义决定了递归应该返回什么。
如果 node 是 nil,它下面没有任何节点,所以返回 0。如果 node 非空,答案至少包含自己这一层。
深度到底在数什么
先把所有根到叶路径分别点亮,并用节点数标注长度,避免把深度误解成节点总数或边数。
第一层方案:暴力做法
直观做法是 DFS 枚举每一条根到叶路径,携带当前 depth,到叶子时更新全局最大值。这种写法能通过,也能做到 O(n)。
它的问题不是复杂度错误,而是需要额外的全局状态,教学上也容易把“访问深度”和“函数返回含义”混在一起。
dfs(node, depth)
到达叶子:answer = max(answer, depth)
否则继续访问左右孩子左右相加为什么变成了另一个问题
把左右返回值先错误相加,再与只选择一侧的真实向下路径对照,显示一条路径不能同时走两边。
优化方向:递归让每个节点只负责一个局部问题。孩子返回高度,父节点合并结果,天然符合树从下到上的结构。
整体地图:先做什么,再做什么
先固定深度定义:从当前节点到最远叶子的节点数。然后把每次递归想成孩子交给父节点的一张“深度回执”,父节点只需选择更大的回执并加上自己。
动画从叶子开始返回,让数字沿调用栈向上汇合。对比 left+right+1 的错误写法,可以看到相加计算的是跨过当前节点的两侧路径,不是单条向下路径。
- • 函数语义先于递推公式。
- • nil → 0 为叶子返回 1 提供自然基线。
- • max 表示选择更深的一侧,+1 表示计入当前节点。
让每棵子树向父节点返回一张深度回执
定义 maxDepth(node) 的唯一职责:返回以 node 为根的子树最大深度。这个返回值必须已经是完整结论,父节点不需要知道孩子内部经过了哪些节点。
空节点返回 0。非空节点分别取得 left 和 right;根到叶路径从当前节点出发后只能进入其中一棵子树,因此选择 max(left,right),再加 1 计入当前节点。
节点 20 的左、右孩子 15 和 7 都返回 1,所以 20 返回 2;根 3 收到左侧 1、右侧 2,最终返回 3。孩子把深度回执交给父节点
叶子返回数字 1,数字沿树边向上移动,父节点等待两张回执后再生成自己的回执。
先深入到空节点,再让答案自底向上返回
递归进入节点后,先处理 nil 基线;随后分别调用左右孩子。只有孩子返回,当前节点才能比较 left 和 right 并生成自己的结果。
在 Go 实现中可用 if left > right 返回 left+1,否则返回 right+1。两边相等时任选一边都不影响结果。
- • 进入阶段负责拆分成左右两个同类子问题。
- • 返回阶段负责合并两个深度数字。
- • 系统调用栈保存尚未完成合并的祖先节点。
- • 链状树会让调用栈达到 O(n),不能声称额外空间恒定。
第一步处理空节点;第二步递归计算左右深度;第三步返回较大深度加一。
这是后序语义:必须先得到孩子的答案,才能计算父节点。代码书写顺序本身就体现了依赖关系。
进入拆分,返回合并
调用栈随递归进入展开,遇到空节点后反向收拢;每次收拢都执行 max 加一。
为什么 max 加一一定得到正确深度
对树的结构做归纳。空树没有节点,深度为 0,基础情况正确。假设递归能够正确得到左右子树的最大深度 left 与 right。
从当前节点出发的任意根到叶路径,第一步要么进入左子树,要么进入右子树,不可能同时进入两边。因此最长路径的剩余部分长度是 max(left,right)。
再把当前节点计入,得到 max(left,right)+1。由结构归纳,函数对任意二叉树都返回正确最大深度。
- • 空树返回 0,符合深度定义。
- • 假设左右递归分别正确返回子树深度,根到叶路径只能选择其中一边。
- • 选择较大子树深度并加上当前节点,得到当前子树的最大深度。
完整执行过程
动画把每次函数调用画成一张“深度回执”。重点观察数字从叶子向根返回,而不是只看节点亮起顺序。
- 1从根 3 进入,当前答案未知,先请求左、右子树的深度回执。
- 2左叶子 9 的两个空孩子都返回 0,因此 9 返回 1。
- 3右节点 20 继续请求 15 和 7,两片叶子都返回 1。
- 420 选择 max(1,1)+1,向根返回 2。
- 5根 3 选择 max(1,2)+1,最终得到最大深度 3。
从叶子 1 层层汇总到根 3
完整播放示例树的返回顺序,持续显示 left、right 和当前节点即将返回的 depth。
把动画和 Go 代码逐行对应
每一帧使用稳定的语义行 ID,不依赖容易漂移的数字行号。先观察分支为什么成立, 再看对应代码,而不是先背模板。
Go 代码如何对应深度回执
逐行高亮 nil 返回、左右递归与较大值加一,并在链状树画面上同步展示调用栈空间。
题目按节点数定义深度,空树才是 0。
父节点自己的答案依赖左右子树,不能提前决定。
叶子自己仍占一层,所以 max(0,0)+1=1。
20 不是叶子,它也要等待自己的左右孩子。
同一个递归规则可以处理所有叶子,不需要额外分支。
向下的一条路径只能选一边,再加上 20 自己。
最深路径经过根、20,再到 15 或 7。
根到叶路径离开节点后只能选择一个方向,不能同时进入两棵子树。
因此时间是 O(n);同时暂停的调用数量由树高 h 决定。
完整 Go 提交代码与最小测试
1func maxDepth(root *TreeNode) int {2 if root == nil {3 return 04 }5 6 left := maxDepth(root.Left)7 right := maxDepth(root.Right)8 9 if left > right {10 return left + 111 }12 return right + 113}// 空树与单节点锁定深度定义
fmt.Println(maxDepth(nil)) // 0
fmt.Println(maxDepth(&TreeNode{Val: 1})) // 1
// 主例
fmt.Println(maxDepth(buildTree([]any{3, 9, 20, nil, nil, 15, 7}))) // 3
// 链状树验证只选一侧且暴露最坏栈深度
fmt.Println(maxDepth(buildTree([]any{1, 2, nil, 3, nil, 4}))) // 4正确性与复杂度
每个节点进入一次并返回一次,合并左右结果只做常数工作。
递归栈深度等于树高。平衡树约为 O(log n),链状树最坏为 O(n)。
最容易写错的地方
把深度按边数计算,导致单节点树返回 0。
写成 left + right + 1,把最大深度混成直径。
忘记 nil 基线,递归无法正确终止。
把系统递归栈忽略,错误声称空间 O(1)。
最后复盘:带走逻辑链
- 1.函数语义:返回当前子树的最大深度。
- 2.基础情况:nil → 0。
- 3.递推关系:max(left,right)+1。
- 4.迁移题:LC111 最小深度、LC110 平衡树、LC543 直径。
- 1.先定义 maxDepth(node) 返回以 node 为根的子树最大深度。
- 2.node 为 nil 时返回 0;否则递归得到左右深度。
- 3.因为一条向下路径只能选一边,所以返回 max(left,right)+1。
- 4.每个节点访问一次,时间 O(n);递归栈由树高决定,空间 O(h)。