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