Files
leetcode-go/二叉树/39-对称二叉树.md
T

415 lines
13 KiB
Markdown
Raw Normal View History

2026-05-17 17:24:04 +08:00
---
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 迭代中,如果要模拟递归的「先处理外侧再处理内侧」的顺序,就必须把内侧对**先入栈**,这样它反而会被**后弹出**——这体现了栈的逆序特性。