Files
leetcode-go/二叉树/38-翻转二叉树.md
T

11 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
二叉树
深度优先搜索
广度优先搜索
递归
迭代
简单
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的孩⼦换位置)      (根节点先换⼿)
 
 ⾃顶向下递归(前序):先交换再递归孩⼦
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 支持多值赋值,可以直接用一行完成交换,无需临时变量:

// ❌ 常规写法(需要临时变量)
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 多值赋值:

node.Left, node.Right = invertTree(node.Right), invertTree(node.Left)

[!note] ⚡ 多值赋值的正确用法 上面的两行依次赋值写法是错误的(除非你确认左侧递归不依赖原值),但在 Go 中以下写法是正确的——因为多值赋值的右侧先全部求值完毕:

node.Left, node.Right = invertTree(node.Right), invertTree(node.Left)

这是最简洁的「翻转+递归+返回」三合一写法。

方法二:广度优先搜索 BFS(层序遍历迭代)⭐

核心思想

BFS 按层访问节点。对于每一层的每个节点,交换其左右孩子即可。

这种方式的优点是过程非常直观:从左到右、从上到下扫一遍,每到一课就翻一次。

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

/**
 * 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 层序遍历法(参考)⭐

/**
 * 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 节点)下毫无压力,但如果节点数量很大,建议使用双索引方式避免内存泄漏:

head := 0
for head < len(queue) {
    node := queue[head]
    head++
    // ... 处理后追加新元素,queue 只会增长不会缩容
}