vault backup: 2026-05-17 17:24:03

This commit is contained in:
2026-05-17 17:24:04 +08:00
parent a419a41738
commit f0c7163858
6 changed files with 2254 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
---
tags: ["LeetCode", "二叉树", "深度优先搜索", "广度优先搜索", "递归", "迭代", "简单"]
create time: 2026-05-17 10:45
---
# 38-翻转二叉树
## 题面
给你一棵二叉树的根节点 `root`,**翻转**这棵二叉树,并返回其根节点。
> [!question] 💡 什么是「翻转」?
> 翻转的意思是:**交换每个节点的左右子树**。换句话说,把整棵树沿中线做一面镜子,左变右、右变左。
>
> > [!note] 🪞 直观理解
> > - 叶子节点:没有子节点,翻转后不变
> > - 中间节点:左右子树互换位置
> > - 翻转作用于**每一个**节点,从根一直到底
**示例 1:**
```
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]
原始树 翻转后
4 4
/ \ / \
2 7 → 7 2
/ \ / \ / \ / \
1 3 6 9 9 6 3 1
```
**示例 2:**
```
输入:root = [2,1,3]
输出:[2,3,1]
2 2
/ \ → / \
1 3 3 1
```
**示例 3:**
```
输入:root = []
输出:[]
空树翻转后仍是空树
```
**约束:**
- 树中节点数目范围在 `[0, 100]` 内
- `-100 <= Node.val <= 100`
---
## 思路
### 方法一:深度优先搜索 DFS(递归)⭐⭐
#### 核心洞察
> [!question] 🤔 思考
> 如果你手里拿着一个节点,要翻转以它为根的这棵子树,你最需要做的动作是什么?
答案其实只有一个:**交换它的左右孩子**——就这么简单!
但要注意,交换完之后,左右孩子本身可能还挂着更深的子树,所以还需要**递归地对它们的子树也做同样的操作**。
这就是分而治之的思想:把「翻转整棵树」分解为「翻转每棵子树」,每个子树只做一件事——交换左右孩子。
#### 递归三要素
| 要素 | 内容 |
|------|------|
| **终止条件** | 节点为空时,直接返回(空树无需翻转) |
| **执行动作** | 交换 `node.Left` 和 `node.Right` |
| **递归调用** | 对交换后的左右子节点分别递归翻转 |
#### 执行流程图解
以示例 1 的原始树为例,展示递归如何逐层深入、逐层完成翻转:
```
4 4 4
/ \ / \ / \
2 7 → 7 2 → 7 2
/ \ / \ / \ / \ / \ / \
1 3 6 9 9 6 3 1 9 6 3 1
↑ ↑ ↑
步骤3 步骤2 步骤1
(叶子不变) (7的孩⼦换位置) (根节点先换⼿)
⾃顶向下递归(前序):先交换再递归孩⼦
```
```mermaid
flowchart TD
S["dfs(4)\n先交换 4 的孩⼦"] --> L["dfs(7)\n交换 7 的孩⼦"]
L --> L1["dfs(nil) → nil\n⽆需翻转"]
L --> R1["dfs(nil) → nil\n⽆需翻转"]
L1 --> L2["返回 {6, 3}\n7的孩⼦已翻转✅"]
R1 --> L2
L2 --> M["dfs(2)\n交换 2 的孩⼦"]
M --> L3["dfs(9)\n返回{6, nil}✅"]
M --> R2["dfs(nil) → nil\n返回{3}✅"]
L3 --> M2["返回{1,3}\n2的孩⼦已翻转✅"]
R2 --> M2
M2 --> E["dfs(4)\n左=翻转后{7}, 右=翻转后{2}\n根节点完成✅🎉"]
style E fill:#d4edda,stroke:#28a745,stroke-width:3px
style M fill:#cce5ff,stroke:#004085
style L fill:#fff3cd,stroke:#856404
```
#### Go 语言的优雅写法
Go 支持多值赋值,可以直接用一行完成交换,无需临时变量:
```go
// ❌ 常规写法(需要临时变量)
tmp := node.Left
node.Left = node.Right
node.Right = tmp
// ✅ Go 风格(并行交换)
node.Left, node.Right = node.Right, node.Left
```
> [!tip] 🔑 多值赋值的奥秘
> Go 的多值赋值是**先求值再赋值**——等号右边的表达式全部计算完毕后,再同时赋给左边。这意味着 `node.Left, node.Right = node.Right, node.Left` 等价于先用临时变量交换,不会先覆盖再出错。
#### 一步到位版(最简递归)
既然交换完左右子节点后,我们只需要让它们各自被翻转,那就可以把递归调用写在等号右边,直接赋值回去:
```
invertTree(node):
if node == nil: return nil
// 递归结果直接作为新的左右孩子
node.Left = invertTree(node.Right) // 原来的右子树翻转变左
node.Right = invertTree(node.Left) // 原来的左子树翻转变右
return node
```
> [!warning] ⚠️ 这和显式交换等价吗?
> **完全等价!** 注意 Go 多值赋值的特性:`node.Left = invertTree(node.Right)` 执行时,`node.Left` 的值已经作为参数传入右侧函数了,虽然这里我们把 `node.Right` 的返回值赋给了 `node.Left`……等等,这里似乎有问题?
>
> 实际上这种写法**有 bug**!第一行执行完后 `node.Left` 已经被修改了,第二行 `invertTree(node.Left)` 翻转的是新左子树而非原左子树。
>
> 正确的单行写法是用 Go 多值赋值:
```go
node.Left, node.Right = invertTree(node.Right), invertTree(node.Left)
```
> [!note] ⚡ 多值赋值的正确用法
> 上面的两行依次赋值写法是错误的(除非你确认左侧递归不依赖原值),但在 Go 中以下写法是正确的——因为多值赋值的右侧先全部求值完毕:
>
> ```go
> node.Left, node.Right = invertTree(node.Right), invertTree(node.Left)
> ```
>
> 这是最简洁的「翻转+递归+返回」三合一写法。
### 方法二:广度优先搜索 BFS(层序遍历迭代)⭐
#### 核心思想
BFS 按层访问节点。**对于每一层的每个节点,交换其左右孩子即可。**
这种方式的优点是过程非常直观:从左到右、从上到下扫一遍,每到一课就翻一次。
```mermaid
flowchart LR
A["第 1 层: [4]\n交换 4 的左右孩子"] --> B["第 2 层: [7,2]\n交换 7 和 2 的孩子"]
B --> C["第 3 层: [9,6,3,1]\n都是叶子,交换无变化"]
C --> D["遍历结束 ✅\n返回 root"]
style A fill:#e8f5e9
style B fill:#fff3e0
style C fill:#e3f2fd
style D fill:#fce4ec
```
#### 算法骨架
```
if root == nil: return nil
queue = [root]
while queue 非空:
node = pop(queue)
swap(node.left, node.right) // 核心动作
if node.left != nil: push(queue, node.left)
if node.right != nil: push(queue, node.right)
return root
```
### 方法三:深度优先搜索 DFS(迭代)⭐
除了 BFS 逐层翻转,DFS 也可以用显式栈来模拟递归过程。
> [!comparison] ⚖️ 三种写法对比
> | 维度 | DFS 递归 | BFS 迭代 | DFS 迭代 |
> |------|---------|---------|---------|
> | 代码简洁度 | ⭐⭐⭐ 仅需 4 行 | ⭐⭐ 需维护队列 | ⭐⭐ 需维护栈 |
> | 空间复杂度 | O(h) 递归栈 | O(w) 队列最大宽度 | O(h) 栈深度 |
> | 遍历顺序 | 前序(根→左→右) | 逐层(自顶向下) | 前序(自定义) |
> | 面试推荐度 | **首选** | 直观易懂 | 展示底层理解 |
> [!tip] 🔑 为什么 DFS 递归天然使用前序?
> 因为我们在深入子树**之前**就先做了交换动作(visit node first),所以是前序遍历的顺序。事实上,翻转可以在任何遍历时机进行——前序、中序、后序都可以完成翻转,最终结果是一样的。前序是最直观的:当前节点都交换完了再去孩子那里。
---
## 代码提示
### DFS 递归伪代码
```
func invertTree(node):
if node == nil: return nil
// 递归翻转右子树和左子树,然后交换赋值
node.Left, node.Right = invertTree(node.Right), invertTree(node.Left)
return node
```
### BFS 迭代伪代码
```
if root == nil: return nil
queue = [root]
while len(queue) > 0:
node = pop(queue)
swap(node.left, node.right)
if node.left != nil: push(queue, node.left)
if node.right != nil: push(queue, node.right)
return root
```
---
## 技巧
> [!tip] 🔑 翻转的遍历时机不影响结果
> 无论是前序、中序还是后序遍历,只要在每个节点上执行 swap(left, right),最终翻转的结果都是一样的。这是因为翻转只关注"每个孩子节点都会被访问且交换一次",与访问顺序无关。
>
> 但中序遍历需要小心!如果在交换后再走右边,原本的右子树现在已经变成了左子树,容易漏掉或重复处理。实际写起来比前/后序复杂。
> [!tip] 🔑 Go 多值赋值是杀手锏
> `node.Left, node.Right = node.Right, node.Left` 这一行同时完成了交换和简洁表达,是 Go 语言实现翻转的经典范式。
> [!info] 📊 复杂度速查
> | 方法 | 时间复杂度 | 空间复杂度 |
> |------|-----------|-----------|
> | DFS 递归 | **O(n)** — 每个节点访问一次 | **O(h)** — h 为树高,最坏 O(n)(链状),平均 O(log n)(平衡树) |
> | BFS 迭代 | **O(n)** — 每个节点入队出队各一次 | **O(w)** — w 为树的最大宽度,满二叉树最后一层最多 (n+1)/2 个节点 |
> | DFS 迭代 | **O(n)** | **O(h)** |
> [!note] 🧩 延伸思考
> - **对称二叉树**(LeetCode 101):判断一棵树是否是它自己的镜像。思路和翻转高度相关——如果一棵树翻转后等于自身,它就是对称的。
> - **最小/最大深度**(LeetCode 104/111):同样是经典的二叉树基础题,可对比练习。
> - **N 叉树翻转**:思路完全一致,只是把孩子切片反转而非交换两个指针。
---
## 代码
### DFS 递归法(推荐)⭐⭐
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func invertTree(root *TreeNode) *TreeNode {
// 终止条件:空节点不需要翻转
if root == nil {
return nil
}
// Go 多值赋值:右侧先全部求值,再同时赋给左侧
// 等价于:先把右子树翻转后变成新左孩子,左子树翻转后变成新右孩子
root.Left, root.Right = invertTree(root.Right), invertTree(root.Left)
return root
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 226 题,通过率约 95%(Easy 中最高的题目之一)。代码仅 4 行核心逻辑,完美展示了递归的威力:**定义函数语义 → 假设子问题已解决 → 只需处理当前层**。
### BFS 层序遍历法(参考)⭐
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func invertTree(root *TreeNode) *TreeNode {
if root == nil {
return nil
}
var queue []*TreeNode // 使用切片模拟队列
queue = append(queue, root)
for len(queue) > 0 {
node := queue[0] // 出队
queue = queue[1:]
// 交换左右子节点
node.Left, node.Right = node.Right, node.Left
if node.Left != nil {
queue = append(queue, node.Left)
}
if node.Right != nil {
queue = append(queue, node.Right)
}
}
return root
}
```
> [!note] 🐹 Go 切片队列注意事项
> 上述 BFS 中使用 `queue = queue[1:]` 出队,随着循环进行切片头部不断缩小,底层数组无法释放。虽然在本题数据范围(≤ 100 节点)下毫无压力,但如果节点数量很大,建议使用双索引方式避免内存泄漏:
>
> ```go
> head := 0
> for head < len(queue) {
> node := queue[head]
> head++
> // ... 处理后追加新元素,queue 只会增长不会缩容
> }
> ```
+414
View File
@@ -0,0 +1,414 @@
---
tags: ["LeetCode", "二叉树", "深度优先搜索", "广度优先搜索", "递归", "迭代", "简单"]
create time: 2026-05-17 11:00
---
# 39-对称二叉树
## 题面
给你一个二叉树的根节点 `root`,检查它是否**轴对称**。
> [!question] 💡 什么是「轴对称」?
> 一棵二叉树轴对称意味着:如果沿根节点画一条垂直中线,**左半树是右半树的镜像翻转**。
>
> > [!note] 🪞 直观理解
> > - 根节点永远对称(自己是自己的镜像)
> > - 左子树的左孩子 = 右子树的右孩子(值相等)
> > - 左子树的右孩子 = 右子树的左孩子(值相等)
> > - 上述规则**递归地**适用于所有层
**示例 1:**
```
输入:root = [1,2,2,3,4,4,3]
输出:true
1 ← 根节点
/ \
2 2 ← 两层对称 ✅
/ \ / \
3 4 4 3 ← 三层对称 ✅
```
**示例 2:**
```
输入:root = [1,2,2,null,3,null,3]
输出:false
1 ← 根节点
/ \
2 2 ← 二层对称 ✅
\ \
3 3 ← 三层不对称!左边是左孩子,右边是右孩子 ❌
```
> [!warning] ⚠️ 关键陷阱
> 示例 2 看起来两棵子树的「形状和值」完全一样,但**对称比较的不是两个子树各自相同,而是它们的镜像关系**。左子树的空位对应右子树的位置,不能简单地判断"两棵子树相等"。
**约束:**
- 树中节点数目在范围 `[1, 1000]` 内
- `-100 <= Node.val <= 100`
**进阶:** 你可以运用递归和迭代两种方法解决这个问题吗?
---
## 思路
### 方法一:深度优先搜索 DFS(递归)⭐⭐
#### 核心洞察
> [!question] 🤔 思考
> 我们已经知道如何判断两棵树是否完全相等——同时遍历、逐节点比较。但「对称」不是比较两棵相同的树,而是比较两棵树是否为**镜像**。这两者有什么区别?
关键区别在于遍历顺序:
| 比较方式 | 左子树遍历 | 右子树遍历 |
|---------|----------|----------|
| **相等比较** | 先左后右 | 先左后右 |
| **镜像比较** | 先左后右 | **先右后左** |
所以判断对称的本质是:编写一个辅助函数 `isMirror(left, right)`,同时遍历两棵子树,但方向相反:
```
isMirror(left, right):
// 两个都是 nil → 对称
// 只有一个 nil → 不对称
// 值不等 → 不对称
// 值相等 → 交叉比较:
// isMirror(left.left, right.right) && isMirror(left.right, right.left)
```
#### 递归三要素
| 要素 | 内容 |
|------|------|
| **终止条件** | ① 双 nil → true;② 单 nil → false;③ 值不等 → false |
| **递归表达式** | `left.left` 与 `right.right` 镜像 + `left.right` 与 `right.left` 镜像 |
| **返回值** | 布尔值,表示两节点是否互为镜像 |
#### 执行流程图解
以对称树 `[] (1,2,2,3,4,4,3)` 为例,展示镜像比较的配对路径:
```mermaid
flowchart TD
S["isMirror(2L, 2R)\n值相等(2=2)"] --> P1["交叉对1:\nisMirror(3L, 3R)\n值相等(3=3)\n✅ true"]
S --> P2["交叉对2:\nisMirror(4L, 4R)\n值相等(4=4)\n✅ true"]
P1 --> COMBINE["2L↔2R 两边都✅\n→ true"]
P2 --> COMBINE
COMBINE --> ROOT["isMirror(root.Left, root.Right)\n整棵树对称 ✅🎉"]
style ROOT fill:#d4edda,stroke:#28a745,stroke-width:3px
style COMBINE fill:#d4edda,stroke:#28a745
style P1 fill:#cce5ff,stroke:#004085
style P2 fill:#cce5ff,stroke:#004085
linkStyle 0,1,2 stroke:#28a745,stroke-width:2px
```
再看不对称的 `[] (1,2,2,null,3,null,3)`:
```mermaid
flowchart TD
S["isMirror(2L, 2R)\n值相等(2=2)"] --> P1["交叉对1:\nisMirror(nil, 3R)\n左侧nil右侧非nil\n→ false ❌"]
S --> P2["交叉对2:\nisMirror(4L, nil)\n左侧非nil右侧nil\n→ false ❌"]
P1 --> FAIL["2L↔2R 至少一边❌\n→ false"]
P2 --> FAIL
FAIL --> ROOT["isMirror(root)\n左子树 != 右子树镜像\n→ 整棵不对称 ❌"]
style FAIL fill:#f8d7da,stroke:#721c24,stroke-width:2px
style ROOT fill:#f8d7da,stroke:#721c24,stroke-width:3px
style P1 fill:#fff3cd,stroke:#856404
style P2 fill:#fff3cd,stroke:#856404
```
#### 为什么是「交叉」而不是「平行」?
> [!info] 🧠 对称 vs 相等的本质差异
>
> ```
> 判断两棵树 A 和 B 是否「相等」:
> compare(A.left, B.left) AND compare(A.right, B.right)
> ↑ ↑
> 左 ↔ 左 右 ↔ 右
>
> 判断两棵树 A 和 B 是否「对称/镜像」:
> mirror(A.left, B.right) AND mirror(A.right, B.left)
> ↑ ↑ ↑ ↑
> A的左 ↔ B的右 A的右 ↔ B的左
> └── 镜面反射方向 ──┘
> ```
>
> 这就是为什么「相等」用 `compare(left, left) + compare(right, right)`,而「对称」必须用 `mirror(left, right) + mirror(right, left)`——**方向交叉**是关键!
### 方法二:广度优先搜索 BFS(队列迭代)⭐
#### 核心思想
既然递归用栈隐式保存了待比较的节点对,那么 BFS 可以用显式队列来存储待比较的节点对:**每次从队列取出两个节点,比较它们是否镜像,再将它们的镜像孩子对入队。**
```
queue = [(root.left, root.right)]
while queue 非空:
left, right = pop(queue)
if both nil: continue
if only one nil: return false
if values differ: return false
// 将镜像孩子对按交叉顺序入队
push(queue, (left.left, right.right)) // 外侧对
push(queue, (left.right, right.left)) // 内侧对
return true
```
#### 执行流程图
```mermaid
flowchart LR
A["初始: [(2L, 2R)]"] -->|"比较: 2=2 ✅"| B["入队: (3L,3R), (4L,4R)"]
B -->|"比较: 3=3 ✅"| C["入队: (nil,nil), (nil,nil)"]
C -->|"跳过双nil"| D["继续取 (4L,4R)"]
D -->|"比较: 4=4 ✅"| E["入队: (nil,nil), (nil,nil)"]
E -->|"全部完成"| F["队列为空\n返回 true ✅"]
style F fill:#d4edda,stroke:#28a745,stroke-width:3px
style B fill:#e3f2fd,stroke:#004085
style D fill:#e3f2fd,stroke:#004085
```
### 方法三:深度优先搜索 DFS(栈迭代)⭐
除了 BFS 用队列,DFS 也可以用显式栈模拟递归过程。
> [!comparison] ⚖️ 三种写法对比
> | 维度 | DFS 递归 | BFS 队列迭代 | DFS 栈迭代 |
> |------|---------|-------------|-----------|
> | 代码简洁度 | ⭐⭐⭐ 仅需 6 行 | ⭐⭐ 需维护队列 | ⭐⭐ 需维护栈 |
> | 空间复杂度 | O(h) 递归栈 | O(n) 队列最多存一层节点对 | O(h) 栈深度 |
> | 比较顺序 | 前序(当前对→外侧→内侧) | 逐层(自顶向下) | 前序(自定义) |
> | 面试推荐度 | **首选** | 直观易懂 | 展示底层理解 |
---
## 代码提示
### 递归伪代码
```
func isMirror(left, right *TreeNode) bool:
// 终止条件①: 两个都为空 → 对称
if left == nil and right == nil:
return true
// 终止条件②: 只有一个为空 → 不对称
if left == nil or right == nil:
return false
// 终止条件③: 值不等 → 不对称
if left.Val != right.Val:
return false
// 递归: 交叉比较(外侧对外侧,内侧对内侧)
return isMirror(left.Left, right.Right) and isMirror(left.Right, right.Left)
func isSymmetric(root *TreeNode) bool:
return isMirror(root.Left, root.Right)
```
### BFS 迭代伪代码
```
if root == nil: return true
queue = [(root.Left, root.Right)]
while len(queue) > 0:
left, right = pop(queue)
if left == nil and right == nil: continue
if left == nil or right == nil: return false
if left.Val != right.Val: return false
push(queue, (left.Left, right.Right))
push(queue, (left.Right, right.Left))
return true
```
---
## 技巧
> [!tip] 🔑 「对称」和「相等」是一组好基友
> LeetCode 100. 相同的树判断两棵树是否完全相等,LeetCode 101. 对称二叉树判断两棵树是否互为镜像。两者代码结构高度相似,唯一的区别就是**参数遍历的方向**——相等是 `(左,左)+(右,右)`,对称是`(左,右)+(右,左)`。对比练习效果极佳。
> [!tip] 🔑 Go 多值赋值的优雅应用
> 虽然这道题不需要交换操作,但在类似的树问题中,Go 的多值赋值能让你避开临时变量。更重要的是,递归的两个子调用写在 `and` 两侧,Go 会在第一个结果为 false 时短路——天然优化。
> [!info] 📊 复杂度速查
> | 方法 | 时间复杂度 | 空间复杂度 |
> |------|-----------|-----------|
> | DFS 递归 | **O(n)** — 每个节点对访问一次 | **O(h)** — h 为树高,最坏 O(n)(链状),平均 O(log n)(平衡树) |
> | BFS 队列迭代 | **O(n)** — 每对节点入队出队各一次 | **O(n)** — 最坏情况下队列可能存 O(n)/2 个节点对 |
> | DFS 栈迭代 | **O(n)** | **O(h)** |
> [!warning] ⚠️ 常见错误:忘记处理双 nil 情况
> 有些人在写递归时只写了「左 nil 或右 nil 返回 false」,这会误判一对双 nil 叶子。双 nil 应该返回 true——因为它们都是空节点,自然是对称的。
> [!note] 🧩 延伸思考
> - **相同的树**(LeetCode 100):比较思路几乎一致,只是参数方向不交叉
> - **二叉树的最大深度**(LeetCode 104):对称树可以看作是求"左子树深度 == 右子树深度且结构镜像"
> - **N 叉树的对称性**:扩展到 N 叉树时,需要判断第 i 个孩子和第 (n-1-i) 个孩子是否互为镜像
---
## 代码
### DFS 递归法(推荐)⭐⭐
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func isSymmetric(root *TreeNode) bool {
// 空树对称
return isMirror(root, root)
}
// isMirror 判断两棵树是否互为镜像
func isMirror(left, right *TreeNode) bool {
// 两个节点都为空 → 对称
if left == nil && right == nil {
return true
}
// 只有一个为空 → 不对称
if left == nil || right == nil {
return false
}
// 值不相等 → 不对称
if left.Val != right.Val {
return false
}
// 交叉比较:外侧对外侧,内侧对内侧
return isMirror(left.Left, right.Right) && isMirror(left.Right, right.Left)
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 101 题,通过率约 59%。代码虽短,但包含了二叉树递归的经典范式——**定义函数语义 → 列出终止条件 → 写出递归表达式**。注意这里巧妙地把 `root` 作为两棵树的根同时传入 `isMirror(root, root)`,让根节点的左右子树成为首次比较的对象。
### BFS 队列迭代法(参考)⭐
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func isSymmetric(root *TreeNode) bool {
if root == nil {
return true
}
// 使用切片模拟队列,存储待比较的节点对
type pair struct {
left, right *TreeNode
}
queue := []pair{{root.Left, root.Right}}
for len(queue) > 0 {
// 出队
pair := queue[0]
queue = queue[1:]
left, right := pair.left, pair.right
// 两个都为空 → 继续下一对
if left == nil && right == nil {
continue
}
// 只有一个为空 → 不对称
if left == nil || right == nil {
return false
}
// 值不等 → 不对称
if left.Val != right.Val {
return false
}
// 将镜像孩子对按交叉顺序入队
queue = append(queue, pair{left.Left, right.Right}) // 外侧对
queue = append(queue, pair{left.Right, right.Left}) // 内侧对
}
return true
}
```
> [!note] 🐹 设计选择说明
> 这里使用了自定义 `pair` 结构体来存储节点对,相比 `[][]*TreeNode` 或扁平化两个队列的方式更直观。Go 的匿名结构体语法让这种配对变得简洁——在 Python 中直接用元组 `(left, right)`,在 Java 中可能需要自定义 Pair 类。
### DFS 栈迭代法(参考)⭐
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func isSymmetric(root *TreeNode) bool {
if root == nil {
return true
}
// 用切片模拟栈,存储待比较的节点对
type pair struct {
left, right *TreeNode
}
stack := []pair{{root.Left, root.Right}}
for len(stack) > 0 {
// 弹栈
n := len(stack) - 1
pair := stack[n]
stack = stack[:n]
left, right := pair.left, pair.right
if left == nil && right == nil {
continue
}
if left == nil || right == nil {
return false
}
if left.Val != right.Val {
return false
}
// 注意:栈是 LIFO,所以要逆序入栈才能保证比较顺序
// 与递归的前序逻辑一致:先处理内侧再处理外侧
stack = append(stack, pair{left.Right, right.Left}) // 内侧对先入
stack = append(stack, pair{left.Left, right.Right}) // 外侧对后入
}
return true
}
```
> [!tip] 🐹 BFS vs DFS 迭代的关键区别
> BFS 用**队列**(FIFO)——先入队的先被弹出,实现逐层比较;DFS 迭代用**栈**(LIFO)——后入栈的先被弹出。在 DFS 迭代中,如果要模拟递归的「先处理外侧再处理内侧」的顺序,就必须把内侧对**先入栈**,这样它反而会被**后弹出**——这体现了栈的逆序特性。
+420
View File
@@ -0,0 +1,420 @@
---
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 在后序遍历过程中逐个节点尝试,才能覆盖所有可能!**