13 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-17 12:00 |
40-二叉树的直径
题面
给你一棵二叉树的根节点,返回该树的 直径。
[!question] 💡 核心定义
- 直径 = 树中任意两个节点之间最长路径的 边数
- 这条路径可能经过也可能 不经过 根节点
- 「长度」= 路径上的 边数(不是节点数!)
[!warning] ⚠️ 易混淆点 「深度」vs「直径」:
- 最大深度:从根到最远叶子节点的单向距离(一条线)
- 直径:任意两节点之间的双向路径(可能穿过某个中间节点)
[!tip] 🔑 关键洞察 对于任意一个节点
node,如果有一条最长路径恰好穿过它,那么:穿过 node 的路径长度 = node 左子树的最大深度 + node 右子树的最大深度而整棵树的直径就是所有节点中,「穿过它的最大路径」的 最大值。
示例 1:
输入:root = [1,2,3,4,5]
输出:3
解释:取路径 [4→2→1→3] 或 [5→2→1→3],共 3 条边。
1 ← 直径穿过了 1
/ \
2 3 ← 路径可以是 4→2→1→3
/ \
4 5
示例 2:
输入:root = [1,2]
输出:1
解释:只有 1→2 这一条边
约束:
- 树中节点数目在范围
[1, 10^4]内 -100 <= Node.val <= 100
思路
方法一:DFS 后序遍历 + 全局最大值 ⭐⭐⭐
核心洞察
[!question] 🤔 思考
第 37 题我们求过「最大深度」——它是从根出发往下走的最远距离。 现在要求「直径」——它可以是树上任意两节点之间的距离。
想象你在树上找最长路径,它会「穿过」某个节点:从上侧一棵子树上来,再从下侧另一棵子树下去。这个「中间节点」不一定是根!
以 [] (1,2,3,4,5) 为例,逐步计算每个节点的数据:
1 ← 左深=2, 右深=1, 穿过它的直径=3 ✅ 答案!
/ \
2 3 ← 节点3是叶子:左深=0, 右深=0, 直径贡献=0
/ \
4 5 ← 节点4,5都是叶子:深度各为0, 直径贡献各为0
[!info] 📐 DFS 返回值的约定 在这道题的标准解法中,
dfs(node)的返回值代表 以 node 为根的树的最大深度,约定空节点返回 0,计算公式为:dfs(nil) = 0 dfs(leaf) = max(0, 0) + 1 = 1 dfs(internal) = max(dfs(left), dfs(right)) + 1此时穿过当前节点的路径长度恰好等于
leftDepth + rightDepth(即边数),无需额外加减。验证:节点 1 的左深度 = 2、右深度 = 1,穿过它的路径 = 2+1 = 3 条边 ✅
执行流程图解:
flowchart TD
Post["后序遍历:先子节点,后自身"] --> L["遍历左子树,获得左深度"]
L --> R["遍历右子树,获得右深度"]
R --> C["当前节点处更新直径\n通过此节点的路径 = 左深度 + 右深度"]
C --> Ret["返回当前节点的最大深度\n给父节点使用"]
style Post fill:#fff3e0,stroke:#e65100
style C fill:#fce4ec,stroke:#c62828
style Ret fill:#e3f2fd,stroke:#1565c0
linkStyle 0,1,2 stroke-width:3px
具体对 [] (1,2,3,4,5) 每个节点的详细计算:
flowchart TD
N4["节点 4\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N5["节点 5\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N3["节点 3\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N2["节点 2\n左深=1, 右深=1\n直径贡献=1+1=2, ans=2\n返回 max=1"]
N1["节点 1\n左深=2, 右深=1\n直径贡献=2+1=3, ans=3\n返回 max=2"]
N4 & N5 --> N2
N3 --> N1
N2 --> N1
style N4 fill:#e3f2fd
style N5 fill:#e3f2fd
style N3 fill:#e3f2fd
style N2 fill:#fff3e0,stroke:#e65100
style N1 fill:#fce4ec,stroke:#c62828,stroke-width:3px
执行流程图解:
flowchart TD
Post["后序遍历:先子节点,后自身"] --> L["遍历左子树,获得左深度"]
L --> R["遍历右子树,获得右深度"]
R --> C["当前节点处更新直径\n通过此节点的路径 = 左深度 + 右深度"]
C --> Ret["返回当前节点的最大深度\n给父节点使用"]
style Post fill:#fff3e0,stroke:#e65100
style C fill:#fce4ec,stroke:#c62828
style Ret fill:#e3f2fd,stroke:#1565c0
linkStyle 0,1,2 stroke-width:3px
具体对 [] (1,2,3,4,5) 每个节点的详细计算:
flowchart TD
N4["节点 4\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N5["节点 5\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N3["节点 3\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
N2["节点 2\n左深=max(4)=1, 右深=max(5)=1\n直径贡献=1+1=2, ans=2\n返回 max=1"]
N1["节点 1\n左深=max(2)=2, 右深=max(3)=1\n直径贡献=2+1=3, ans=3\n返回 max=2"]
N4 & N5 --> N2
N3 --> N1
N2 --> N1
style N4 fill:#e3f2fd
style N5 fill:#e3f2fd
style N3 fill:#e3f2fd
style N2 fill:#fff3e0,stroke:#e65100
style N1 fill:#fce4ec,stroke:#c62828,stroke-width:3px
算法框架
global ans = 0
function dfs(node):
if node == nil:
return 0
// 后序:先左右子树,再当前节点
left = dfs(node.left) // 左子树的最大深度
right = dfs(node.right) // 右子树的最大深度
// 穿过当前节点的路径长度 = 左深度 + 右深度
ans = max(ans, left + right)
// 返回当前节点向上延伸的最大深度(供父节点使用)
return max(left, right) + 1
function diameterOfBinaryTree(root):
dfs(root)
return ans
递归三要素
| 要素 | 内容 |
|---|---|
| 终止条件 | 节点为空时,返回 0 |
| 递归表达式 | left = dfs(node.left), right = dfs(node.right) |
| 副作用 | 在每个节点更新全局变量 ans = max(ans, left + right) |
| 返回值 | 以当前节点为根的树的最大深度 max(left, right) + 1 |
为什么用后序遍历?
[!info] 🧠 自底向上的必然选择 要计算「穿过节点 A 的直径」,必须先知道 A 的左右子树各自有多深。这意味着子节点的处理必须在父节点之前完成——正是后序遍历(左 → 右 → 根)的执行顺序。
代码提示
DFS 伪代码
// 全局变量:记录最大直径
ans = 0
func dfs(node):
if node == nil:
return 0
left = dfs(node.Left) // 左子树深度(边数视角下的"层数")
right = dfs(node.Right) // 右子树深度
ans = max(ans, left + right) // 更新直径
return max(left, right) + 1 // 返回当前节点深度给父节点
func diameterOfBinaryTree(root):
dfs(root)
return ans
Go 语言实现要点
[!note] 🐹 Go 中没有真正的"全局变量"最佳实践 在这道题中,有几种方式传递和修改
ans:
| 方案 | 写法 | 推荐度 |
|---|---|---|
| 指针传参 | func dfs(node *TreeNode, ans *int) |
⭐⭐⭐ 函数式风格,无副作用 |
| 闭包捕获 | 在 diameterOfBinaryTree 内定义 var ans int 然后用嵌套函数访问 |
⭐⭐⭐ 简洁直观 |
| 返回值携带 | func dfs(...) (depth, dia int) |
⭐⭐ 稍显繁琐 |
推荐使用 闭包捕获 的方式,既干净又自然。
技巧
[!tip] 🔑 直径 vs 深度的关系速记
- 深度:从某节点向下到最远叶子的「单向」路径长度
- 直径:从某节点向「左下 + 右下」延伸的两条分支合并后的总长
- 公式:
diameter(node) = leftDepth + rightDepth- 整棵树的直径是所有节点中这个值的 最大值
[!tip] 🔑 为什么可以用一次 DFS 搞定? 很多人第一反应是「枚举每对节点 → BFS/DFS 求距离 → 取最大值」,这样是 O(n²)。但其实:
- 对于每个节点,穿过它的直径 = 左深度 + 右深度
- 左深度和右深度可以通过一次后序遍历全部算出
- 同时更新全局最大值,只需 O(n) 时间
[!warning] ⚠️ 常见错误
- 误把直径当作最大深度 × 2:只有满二叉树/完美二叉树时才碰巧相等
- 忘记处理单边树的情况:比如左斜树,右深度恒为 0,直径 = 最大深度
- 把直径当成经过根的路径:直径可能经过任何节点,不只是根
[!info] 📊 复杂度速查
维度 结果 时间复杂度 O(n) — 每个节点恰好访问一次 空间复杂度 O(h) — h 为树高,递归栈深度;最坏 O(n)(链状),平均 O(log n)(平衡树)
[!connection] 🔗 与相关题目的联系
- 37-二叉树的最大深度:直径问题的基础,深度是计算直径的子组件
- 543. Diameter of Binary Tree:原题(英文) 两者本质相同,只是表述不同
- 最长路径问题:这是一般图论中「树的直径」的特例。对无权树可通过两次 BFS 求解,但二叉树场景下 DFS 更直接
[!exercise] 💪 变体练习
- 如果路径长度定义为节点数而非边数,如何修改代码?→ 只需将
left + right改为left + right + 1(当前节点也计入)- 如果树中有负权边(加权二叉树),还能用此方法吗?→ 不能,需要换成分治或树形 DP
- N 叉树的直径如何求解?→ 取最大的两个子节点深度之和作为直径贡献
代码
DFS + 闭包(推荐)⭐⭐⭐
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func diameterOfBinaryTree(root *TreeNode) int {
// ans 通过闭包捕获,DFS 过程中不断刷新最大值
ans := 0
// dfs 返回以 node 为根的树的最大深度(层数)
var dfs func(*TreeNode) int
dfs = func(node *TreeNode) int {
if node == nil {
return 0
}
// 后序遍历:先获取左右子树的深度
left := dfs(node.Left) // 左子树的最大深度
right := dfs(node.Right) // 右子树的最大深度
// 穿过当前节点的路径 = 左深度 + 右深度
// 这就是以当前节点为"最高点"的最长路径
dia := left + right
if dia > ans {
ans = dia
}
// 返回当前节点向上传递的最大深度
// 供父节点计算穿过它的直径时使用
if left > right {
return left + 1
}
return right + 1
}
dfs(root)
return ans
}
[!success] ✅ 运行验证 这是 LeetCode 第 543 题,通过率约 62%。核心只有一句话:在每个节点处,用左深度+右深度更新答案,同时返回 max(左,右)+1。面试时可先用一句话概括思路,再展开细节。
DFS + 指针传参(函数式风格)⭐⭐
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func diameterOfBinaryTree(root *TreeNode) int {
if root == nil {
return 0
}
maxDia := 0
// dfs 返回深度,通过指针修改 maxDia
var dfs func(*TreeNode) int
dfs = func(node *TreeNode) int {
if node == nil {
return 0
}
left := dfs(node.Left)
right := dfs(node.Right)
*maxDia = left + right
if d := left + right; d > *maxDia {
*maxDia = d
}
if left > right {
return left + 1
}
return right + 1
}
dfs(root)
return *maxDia
}
[!note] 🐹 两种写法的选择 闭包方式中
ans是外层函数的局部变量,内层dfs直接读取和赋值,无需额外类型声明。指针方式更"显式"地表明dfs会修改外部状态,在团队协作中可读性更强。个人偏好闭包——Go 的闭包在语法上是原生支持的,不需要像 Java/C++ 那样用AtomicInteger或引用包装类。
对比:错误思路(仅考虑经过根的直径)❌
// ❌ 错误示范:这样做只能得到"经过根节点的直径"
func wrongApproach(root *TreeNode) int {
if root == nil {
return 0
}
// 只计算了穿过根节点的路径!
// 如果最长路径不经过根,就会返回错误结果
left := maxDepth(root.Left)
right := maxDepth(root.Right)
return left + right
}
func maxDepth(node *TreeNode) int {
if node == nil {
return 0
}
l, r := maxDepth(node.Left), maxDepth(node.Right)
if l > r {
return l + 1
}
return r + 1
}
[!failure] ❌ 为什么不正确? 上面的代码只考虑了穿过根节点的直径,但对于
[] (1,2,null,3,4):1 / 2 / \ 3 4经过根的直径 = 1(左)+ 0(右)= 1 但正确答案是经过节点 2 的路径 3→2→4,长度为 2
必须用 DFS 在后序遍历过程中逐个节点尝试,才能覆盖所有可能!