☰
翻转二叉树:递归、迭代与二叉树运行时错误的完整拆解
2026/10/13 14:38:54 网站建设 项目流程

刷力扣hot100题的时候,我最先跳过的一道题就是它——翻转二叉树。题目短得可怜,就是给你一棵二叉树的根节点 root,把这棵树的左右子树全部互换一遍,再返回根节点。往前翻这道题的时候,很多人的第一反应是“就这?”但它在力扣hot100里的热度一直居高不下,面试考场上碰到它的概率也相当高。

我第一次刷这道题的时候,心里想的是:这不就是一个递归交换左右子树吗?确实,核心逻辑就十行左右。但真正把它吃透之后,我才意识到这道题背后藏着一整套二叉树的基本功:递归遍历怎么写才不丢子树、迭代方案怎么用栈和队列替代隐式调用栈、空指针为什么会成为大多数二叉树报错的根源、以及“二叉树深度”和“递归栈深度”之间的纠缠关系。这篇文章就把这道题从里到外拆一遍,连热词里大家最头疼的“写二叉树程序时为什么总是报运行时错误”一起解决掉。不管你是在力扣上刷题,还是准备面试白板题,这篇文章都值得完整看一遍。

1. 题目拆解:翻转二叉树到底在考什么

1.1 题目描述与核心思路

力扣原题给的定义非常干净:给你一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。所谓的翻转,就是把每个节点的左右子树互换。从视觉上看,整棵树会像照镜子一样发生左右反向,所以这道题在英文社区里也叫 Mirror Tree 或者 Invert Binary Tree。

这里要提一个很多刷题新人容易忽视的点:翻转的是“子树指针”,不是“节点值”。意思是你不能只把每个节点的左右孩子 val 对调,而是要把整棵子树引用都换位置。比如某个节点的左子树是三层,右子树是单节点,翻转后左边要挂原来的右树,右边要挂原来完整的左树,整个三层子树的内部结构也必须跟着翻完。所以这道题从本质上讲,不是一道简单“交换”题,而是一道“遍历并重组子树”的题。

为什么这么说?因为翻转这个动作发生在每个节点上,而你需要用一种方式把整棵树的所有节点都访问到,并且对每个节点执行左右交换。这意味着无论你用什么顺序遍历(前序、后序、层序),只要完整覆盖每个节点并且交换其左右孩子,最终得到的结果都是一样的。这一点非常关键,它直接解释了为什么这道题能同时有递归、队列、栈好几种解法,而且结果完全相同。

1.2 为什么翻转二叉树能进hot100

说实话,hot100里比这道题“看起来难”的题多了去了,但翻转二叉树的地位非常特殊。它不考复杂算法,不考动态规划,只考察你对递归结构最核心的理解。面试官非常喜欢在这道题上做文章:你写完了递归,他会问“那不用递归行不行”;你写完了迭代,他会问“如果树深度特别大,你的方案还能扛得住吗”;你全部写完,他还会顺着问“翻转后还是二叉搜索树吗”。

这些追问的落点,其实都在考察“遍历二叉树的能力”以及“对递归调用栈的理解”。换句话说,翻转二叉树是一道完美的“敲门砖”题,它能快速暴露一个人对树的递归直觉到底是真的还是背的。这也是为什么它常年排在力扣hot100里,学长学姐推荐刷题攻略时也总是把它放在“二叉树专题第一天”的位置。

2. 递归解法:先想清楚出口,再写交换

2.1 递归函数的三要素

写递归翻转,我习惯用后序顺序:先翻转左子树,再翻转右子树,最后交换当前节点的左右孩子。这样做的好处是:你递归进入左右子树的时候,当前节点的结构还没被破坏,子树内部翻完再回来交换,逻辑非常自然。

递归函数有三个要素必须想清楚:

  • 返回值:返回翻转之后的子树根节点,类型是 TreeNode。
  • 终止条件:当前节点为 None 时,直接返回 None。
  • 单层逻辑:交换当前节点的左右孩子。

其中终止条件是最容易写错的地方。很多人一上来就写root.left, root.right = root.right, root.left,完全没考虑root可能为空。力扣的测试用例里永远有空树,这一跑,运行时错误就来了。所以在递归函数的第一行写判空,是二叉树题的保命习惯。

2.2 完整代码与手动模拟执行过程

后序递归的 Python 实现如下:

# Definition for a binary tree node. # class TreeNode: # def __init__(self, val=0, left=None, right=None): # self.val = val # self.left = left # self.right = right class Solution: def invertTree(self, root: TreeNode) -> TreeNode: if root is None: return None # 先翻转左右子树 left = self.invertTree(root.left) right = self.invertTree(root.right) # 再交换当前节点的左右子树 root.left = right root.right = left return root

这段代码看起来简洁,但如果你只是在力扣上按“提交通过”就完事,那它对你的帮助其实很有限。我强烈建议你拿一棵三层树,在纸上把调用过程完整展开一遍。比如原树长这样:

1 / \ 2 3 / \ 4 5

调用 invertTree(1) 后,先进入 invertTree(2),节点2又先进入 invertTree(4) 和 invertTree(5)。4和5没有孩子,直接返回自身。于是节点2交换左右孩子,变成:

2 / \ 5 4

继续回到节点1,接着调用 invertTree(3),节点3没有孩子,返回自身。最后节点1交换左右孩子,左边挂上原来的3,右边挂上翻转后的2。最终结果:

1 / \ 3 2 / \ 5 4

这个过程走完一遍,你对递归的“自底向上”会有一个很明显的体感。所谓的递归,其实就是系统帮你维护了一个调用栈,一层层往下钻,钻到底再一层层往回返。每返一层,就处理当前节点的交换。

2.3 复杂度与递归深度问题

时间复杂度是 O(n),因为每个节点只被访问一次。空间复杂度取决于递归调用栈的深度:平衡树的情况下是 O(log n),但如果二叉树退化成一条链(每个节点只有一个孩子),那递归深度就变成了 n,空间复杂度直接飙升到 O(n),并且 n 一大会触发 Python 默认的递归深度限制。

说到二叉树的深度,热词里也有这条。我实测过,在力扣环境下递归版翻转二叉树的测试数据基本不会深到触发 RecursionError,但如果你在本地用一条超长的链去测试,就会看到类似RecursionError: maximum recursion depth exceeded的报错。这个问题在 4.3 节我还会详细说,这里先记住一个结论:递归方案很美,但深度大的场景存在栈溢出风险,迭代方案是必须掌握的替补方案。

3. 迭代解法:用队列和栈实现同样的翻转

3.1 队列层序翻转:BFS 思路

面试官在看完你的递归解法后,下一句大概率是“能不能不用递归写一个”。这时候你如果能快速写出一版队列层序翻转,印象分会非常加。

层序翻转的核心逻辑是:把根节点放进队列,然后循环弹出节点,每弹出一个节点就交换它的左右孩子,再把它的非空孩子继续入队。为什么这样能翻转整棵树?因为队列保证了每个节点都会被访问一次,而交换左右孩子这个操作只依赖当前节点本身,不依赖树的整体顺序。

from collections import deque class Solution: def invertTree(self, root: TreeNode) -> TreeNode: if root is None: return None queue = deque([root]) while queue: node = queue.popleft() # 交换当前节点的左右孩子 node.left, node.right = node.right, node.left # 孩子入队 if node.left: queue.append(node.left) if node.right: queue.append(node.right) return root

这里有一个细节值得单独说:交换之后再入队,入队的是已经换过位置的孩子。下一轮处理的时候,这些节点依然处于“左右孩子需要再交换”的状态,所以整个循环的状态非常一致,不需要额外标记。

层序翻转给人最直观的感受,就像一排人从头走到尾,每经过一个人就把他和旁边的人换位置,走完整个队列,整排人的顺序就整个反过来了。树的层次结构在这里只是决定了访问顺序,并不影响交换的正确性。

3.2 栈模拟深度优先:DFS 迭代版

除了队列,用栈也能完成同样的工作。递归本质上是隐式栈,我们只是把系统帮忙维护的栈换成自己手写的显式栈。用栈的代码和队列几乎一模一样,只把容器从 deque 换成 list,弹出方式从 popleft 换成 pop。

class Solution: def invertTree(self, root: TreeNode) -> TreeNode: if root is None: return None stack = [root] while stack: node = stack.pop() node.left, node.right = node.right, node.left if node.left: stack.append(node.left) if node.right: stack.append(node.right) return root

用栈实现的遍历顺序接近深度优先,但这道题根本不关心你是先深入左子树还是先处理右子树,因为每个节点的交换都是独立操作。理解到这一层,你会发现翻转二叉树在思路上其实非常简单:你需要一种容器把节点按顺序收集起来,每次弹出一个就交换,然后继续收集它的孩子,直到容器清空。

3.3 递归和迭代怎么选

我在实际刷题中总结了一套选择标准,用一个表格对比一下:

对比项递归方案迭代方案
代码量约10行,简洁清晰约15行,需要多考虑容器操作
空间开销依赖系统调用栈,最坏O(n)显式栈/队列,同样最坏O(n)
深度风险深树可能触发RecursionError不依赖系统栈,稳定性更好
面试效果体现对递归结构的理解体现对栈和队列的掌控
推荐场景日常刷题、基础教学面试追问、生产环境代码

我的建议是:面试时先流畅写完递归版,用两句话把递归出口和交换顺序说清楚,紧接着主动说“我再写一个迭代版,避免递归深度过大”。这样既展示了你的递归基本功,也体现出了工程上对栈溢出的敏感度,比被动等面试官追问要好得多。

4. 运行时错误排查:二叉树题的常见坑

4.1 空指针访问:最常见的报错来源

关于热词里很多人问的“写二叉树程序时为什么总是报运行时错误”,我先给一个最典型的错误案例:

# 错误示范:没有判空直接访问 left def invertTree(self, root): root.left, root.right = root.right, root.left self.invertTree(root.left) self.invertTree(root.right) return root

这段代码在 root 为 None 的时候,会抛AttributeError: 'NoneType' object has no attribute 'left'。力扣的测试数据里几乎必含空树,所以这种写法一提交就会挂。

根治方法就是第 2 节说的:递归函数里第一行判空;迭代版本里,入队/入栈前判断孩子是否为空。养成了这个习惯,二叉树题的运行时错误至少能少掉一大半。别觉得这个坑太基础,我在本地 debug 别人的代码时,见到的报错十有八九都是这类空指针访问。

4.2 覆盖式交换导致丢子树

第二种高频报错不是崩溃,而是“结果不对”。典型写法是:

# 错误示范:直接覆盖,丢了原左子树 node.left = node.right node.right = node.left

这里第一行把 node.left 覆盖成了原来的右子树,原来的左子树引用就丢了;第二行再把 node.right 赋值为 node.left,结果是左右子树全部变成了原来的右子树。整棵树翻完,左边和右边都是同一棵子树,树形直接乱了。

注意:这种覆盖式错误是二叉树题里最经典的“表面看着没问题,一跑就丢子树”的案例。本地调试时如果发现翻转后的树少了半边节点,九成的元凶就是它。

正确做法有两种。一种是 Python 的元组交换:node.left, node.right = node.right, node.left,右侧先计算,再统一赋值,天然安全。另一种是手动引入临时变量:tmp = node.left; node.left = node.right; node.right = tmp,C++、Java 里就得这么写。面试时如果语言是 Python,我推荐直接写元组交换,一行搞定还不会出错。

4.3 RecursionError 和 Time Limit Exceeded 的区别

运行时错误里还有两类非常容易被混淆:RecursionError和Time Limit Exceeded。

RecursionError 是递归深度超过系统限制,在 Python 里默认是 1000 层。树退化成链状的时候最容易触发,解决思路有两种:一是改成迭代写法,二是使用 sys.setrecursionlimit 调高限制。但我个人不建议把调高限制当常规方案,因为它只是把阈值往后推,并没有根治深度问题,生产环境里递归深度不可控时依然会爆栈。

Time Limit Exceeded 绝大多数情况是死循环,而不是“代码跑得慢”。二叉树题目里出现死循环,通常是因为某个节点反复进入容器但没有任何终止条件,比如你把交换后的孩子又重复加入了队列。我排查超时问题时,通常先打印每个节点的访问顺序,一旦发现同一节点出现两次,基本就是处理顺序出了问题。

4.4 二叉树调试排查清单

围绕“为什么报运行时错误”,我整理出一套非常顺手的排查顺序,照着走一遍基本能解决问题:

  1. 先看是否有空树用例:递归入口一定先判空,迭代入容器前先判空。
  2. 再看是否有覆盖赋值:检查你是否在交换时丢失了原来的左右子树引用。
  3. 接着看递归深度:树是否可能退化成链状,检测递归深度是否逼近语言限制。
  4. 然后看容器操作:队列有没有正确出队入队,栈会不会无限重复加入同一节点。
  5. 最后用本地最小用例复现:构造一个三层小树,打印节点访问顺序,和你的预期对比。

这套清单在处理其他二叉树问题时也一样通用,建议收藏。

5. 从翻转二叉树延伸出去:对称、BST与面试变形

5.1 翻转与对称二叉树的关系

翻转二叉树做完之后,有一个非常自然的延伸题:判断一棵二叉树是否对称,也就是力扣 101 题。对称二叉树的定义是“左子树和右子树互为镜像”,从某种角度来说,判断对称本质上就在比较一棵树和它翻转后的结果是否一致。

def isSymmetric(root): def isMirror(t1, t2): if t1 is None and t2 is None: return True if t1 is None or t2 is None: return False return (t1.val == t2.val and isMirror(t1.left, t2.right) and isMirror(t1.right, t2.left)) return isMirror(root, root)

把翻转和对称放在一起对比学习,非常划算。翻转是“生成一棵新树”,对称是“判断两棵树的关系”,但它们都建立在同一个基本功上:递归地比较或调整左右子树的位置。

5.2 翻转之后怎么在本地验证结果

力扣上提交通过不代表你彻底理解了。如果你想在本地验证自己的翻转逻辑,我建议写一个打印树结构的辅助函数,把空节点也打印出来,这样翻前翻后的变化一目了然。

from collections import deque def print_tree(root): if root is None: print("[]") return res = [] queue = deque([root]) while queue: node = queue.popleft() if node: res.append(node.val) queue.append(node.left) queue.append(node.right) else: res.append(None) while res and res[-1] is None: res.pop() print(res)

用这个函数在本地观察翻转前后的层序输出,能帮你省掉大量依靠肉眼核对树结构的时间。我在本地验证树形题时,这个辅助函数几乎是保底工具。

5.3 面试官常见的变形追问

围绕翻转二叉树,我在面试中遇到过以及听朋友反馈过的高级追问,大致有这么几种:

  • 如果只翻转某一层的子树,该怎么做?比如只翻转第 3 层及以下,上层不动。解法是递归时额外传深度参数,在深度达到条件后再交换。
  • 如果某些节点没有左孩子或右孩子,翻转逻辑要不要改?不需要,None 也正常参与交换,这是递归出口天然处理掉的边界。
  • 翻转之后做中序遍历,序是什么特征?对于一棵二叉搜索树,翻转后中序遍历会从升序变成降序,说得清楚这条,很能体现你对遍历和顺序的理解。
  • 如何用 Morris 遍历实现 O(1) 空间复杂度的翻转?这是进阶版,利用叶子节点的空指针线索来遍历,不借助栈和队列,空间复杂度压到常数级。
  • 如果树的节点非常多,递归挂了,迭代方案的极限在哪里?常规栈方案空间仍然是 O(n),如果希望空间更优,还是要走 Morris 路线。

其中 Morris 遍历和热词里的“线索二叉树”关系很大,核心思想是让叶子节点的空闲指针指向某种遍历顺序下的后继节点,从而不需要额外容器。笔试面试一般不会强制要求到这个深度,但能主动提出来,说明你对“二叉树的遍历”理解已经超出了背模板的层次。

5.4 翻转二叉搜索树时发生了什么

这里单独拎出来说,因为二叉搜索树(BST)被翻转后,会有一个很强的反直觉现象:翻转后的树不再是一棵二叉搜索树。比如一棵 BST:

4 / \ 2 6 / \ / \ 1 3 5 7

翻转后变成:

4 / \ 6 2 / \ / \ 7 5 3 1

你观察根节点 4:左孩子是 6,右孩子是 2,完全破坏了“左小右大”的规则。但这个翻转后的结构作为二叉树依然是合法的,只是 BST 的有序性没了。

这就引出一个高频追问:如何保持翻转后仍然是 BST?单靠交换节点做不到,你需要根据 BST 的中序遍历特性来重建:BST 中序遍历是升序,翻转后中序变成降序,所以可以按降序序列重新构造一棵 BST。这道题把翻转、遍历、BST 性质全部串起来,是连环追问的经典套路。

5.5 场景化联想:翻转在真实开发中的应用

最后说说这道题在实际业务里的意义,免得总觉得刷题和上班是两码事。我在前端项目里做过一个树形组件的镜像布局,需求是把左侧树形菜单整体平移到右侧,同时子节点展开方向反过来。当时我第一反应就是用类翻转的思路:递归遍历整棵树,按镜像规则重新组织节点顺序。

后来做双栏对照组件的时候,右侧目录要和左侧目录呈镜像关系,我也没有单独调接口,而是直接把左侧的数据结构通过翻转思路转换生成。树结构的基本功,真的会以你想象不到的方式出现在日常开发里。所以别觉得这道题短就轻视它:递归、迭代、栈、队列、层序遍历、深度优先、空指针防护、BST 性质,这一整套知识全都浓缩在翻转二叉树这十行代码里。hot100 选它当常客,不是因为你写得快,而是因为它足够浓缩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询