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

13 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
二叉树
深度优先搜索
广度优先搜索
递归
迭代
简单
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) 为例,展示镜像比较的配对路径:

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):

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

执行流程图

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 递归法(推荐)⭐⭐

/**
 * 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 队列迭代法(参考)⭐

/**
 * 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 栈迭代法(参考)⭐

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