Files
leetcode-go/二叉树/40-二叉树的直径.md

421 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: ["LeetCode", "二叉树", "深度优先搜索", "递归", "后序遍历"]
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 条边 ✅
执行流程图解:
```mermaid
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)` 每个节点的详细计算:
```mermaid
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
```
执行流程图解:
```mermaid
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)` 每个节点的详细计算:
```mermaid
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] ⚠️ 常见错误
> 1. **误把直径当作最大深度 × 2**:只有满二叉树/完美二叉树时才碰巧相等
> 2. **忘记处理单边树的情况**:比如左斜树,右深度恒为 0,直径 = 最大深度
> 3. **把直径当成经过根的路径**:直径可能经过任何节点,不只是根
> [!info] 📊 复杂度速查
> | 维度 | 结果 |
> |------|------|
> | 时间复杂度 | **O(n)** — 每个节点恰好访问一次 |
> | 空间复杂度 | **O(h)** — h 为树高,递归栈深度;最坏 O(n)(链状),平均 O(log n)(平衡树) |
> [!connection] 🔗 与相关题目的联系
> - **[37-二叉树的最大深度](./37-二叉树的最大深度.md)**:直径问题的基础,深度是计算直径的子组件
> - **[543. Diameter of Binary Tree](https://leetcode.com/problems/diameter-of-binary-tree/)**:原题(英文)
> 两者本质相同,只是表述不同
> - **最长路径问题**:这是一般图论中「树的直径」的特例。对无权树可通过两次 BFS 求解,但二叉树场景下 DFS 更直接
> [!exercise] 💪 变体练习
> 1. 如果路径长度定义为节点数而非边数,如何修改代码?→ 只需将 `left + right` 改为 `left + right + 1`(当前节点也计入)
> 2. 如果树中有负权边(加权二叉树),还能用此方法吗?→ 不能,需要换成分治或树形 DP
> 3. N 叉树的直径如何求解?→ 取最大的两个子节点深度之和作为直径贡献
---
## 代码
### DFS + 闭包(推荐)⭐⭐⭐
```go
/**
* 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 + 指针传参(函数式风格)⭐⭐
```go
/**
* 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` 或引用包装类。
### 对比:错误思路(仅考虑经过根的直径)❌
```go
// ❌ 错误示范:这样做只能得到"经过根节点的直径"
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 在后序遍历过程中逐个节点尝试,才能覆盖所有可能!**