--- 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 只会增长不会缩容 > } > ```