1. 二叉树遍历到底在解决什么问题
但凡写过几年代码的人,都会在某个时刻跟二叉树遍历正面碰上。它不像红黑树、线段树那样听起来就让人头皮发麻,但真正动手写的时候,很多人会发现自己在递归和迭代之间反复横跳,面试前背一遍、面试后忘一半。我自己带过几个刚入行的同学,几乎每个人都在这个知识点上卡过至少两天。
先把话说清楚:二叉树遍历就是按照某种固定顺序,把树里每个节点访问且只访问一次。所谓先序、中序、后序,指的是“根节点”在什么时候被访问——先序是根左右,中序是左根右,后序是左右根。层序遍历则是按层从上到下、从左到右依次访问。这四种遍历方式看起来只是顺序不同,但背后对应的是两类完全不同的搜索思想:前三种是深度优先搜索(DFS),层序是广度优先搜索(BFS)。
为什么值得花时间把多种实现方法都吃透?因为遍历本身虽然简单,但它是大量算法的骨架。比如判断两棵树是否相同、求树的最大深度、验证二叉搜索树、序列化与反序列化、找最近公共祖先,这些题目的核心逻辑都建立在遍历之上。你遍历写得越熟,后面做这些题就越像搭积木。而且不同实现方式之间的取舍,恰恰能反映一个人对递归、栈、队列、Morris 遍历这些基础工具的理解深度。
这篇文章面向的是已经了解二叉树基本结构、但想把遍历写得更扎实的开发者。不管你是准备面试、还是在项目里需要手写一个非递归遍历,我都会把递归、迭代、Morris 三大类方法拆开讲,配上可直接运行的代码和我在实际调试中踩过的坑。读完你至少能做到:拿到任意一棵树,四种遍历随手就能写出两种以上实现,并且知道每种写法的适用场景和边界条件。
2. 四种遍历的核心思路与选型考量
2.1 先序、中序、后序的本质差异
很多人背口诀“先序根左右、中序左根右、后序左右根”,背完还是写不对。问题出在没理解这三个顺序到底在描述什么。其实它们描述的是根节点相对于左右子树的位置。先序是“先处理根,再处理左右”,中序是“先处理左,再处理根,最后处理右”,后序是“先处理左右,最后处理根”。
这个差异带来的直接后果是:中序遍历二叉搜索树会得到一个升序序列,这是二叉搜索树最重要的性质之一;后序遍历适合做“自底向上”的计算,比如求树的高度、释放树的内存;先序遍历适合做“自顶向下”的复制或序列化。所以选哪种遍历,取决于你的业务逻辑是自顶向下还是自底向上。
2.2 递归与迭代的取舍逻辑
递归写遍历,代码短到令人发指,三行就能搞定一个先序。但递归有两个现实问题:一是函数调用栈有深度限制,树退化成链表时容易栈溢出;二是某些场景下递归的性能开销不可忽略。迭代写法用显式栈模拟递归过程,虽然代码长一些,但可控性更强,也更容易在遍历过程中做剪枝或提前返回。
我的经验是:日常业务代码优先用递归,清晰易维护;对性能敏感或树深度不可控的场景用迭代。至于 Morris 遍历,它用线索二叉树的思想把空间复杂度降到 O(1),适合内存极度受限的嵌入式场景,但代码可读性差,修改树结构期间不能并发访问,属于“知道有这么个东西、需要时能写出来”的级别。
2.3 层序遍历为什么单独拎出来
层序遍历跟前三种不是一个体系。前三种是深度优先,靠栈(递归调用栈或显式栈)实现;层序是广度优先,靠队列实现。它的典型应用是求树的宽度、按层打印、找每层最大值、判断完全二叉树等。层序遍历还有一个变体叫“锯齿形层序遍历”,奇数层从左到右、偶数层从右到左,这个在面试里出现频率很高。
选型上,层序遍历几乎没有替代方案,队列就是它的标准搭档。唯一需要注意的是,如果你要区分每一层,需要在每轮循环开始时记录当前队列的长度,这个长度就是当前层的节点数。
3. 递归实现:最短路径但别掉以轻心
3.1 三种深度优先遍历的递归模板
递归实现的核心就一句话:把当前节点该做的事做了,然后递归处理左右子树。先序、中序、后序的区别仅仅在于“做事”这行代码放在哪个位置。
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def preorder(root, result): if not root: return result.append(root.val) # 根 preorder(root.left, result) # 左 preorder(root.right, result) # 右 def inorder(root, result): if not root: return inorder(root.left, result) # 左 result.append(root.val) # 根 inorder(root.right, result) # 右 def postorder(root, result): if not root: return postorder(root.left, result) # 左 postorder(root.right, result) # 右 result.append(root.val) # 根这三段代码的结构完全一样,只是result.append的位置不同。我建议初学时不要死记,而是画一棵只有三个节点的小树,手动走一遍,看看 append 的顺序怎么变。走两遍就彻底记住了。
3.2 递归写法的隐藏成本与注意事项
递归看起来优雅,但有几个坑我必须提醒。第一,Python 默认递归深度限制是 1000,如果树是一条 1000 个节点的链,直接报RecursionError。解决办法是sys.setrecursionlimit调大限制,但治标不治本,真正深的时候还是得用迭代。第二,递归过程中如果 result 是可变对象,注意不要在不同调用之间共享出错,最好作为参数传入或者用闭包捕获。
还有一个容易被忽略的点:空节点的处理。上面代码里if not root: return是必须的,否则访问root.left会报 AttributeError。有些同学喜欢在递归调用前判断子节点是否为空,那样代码会变长,不如统一在函数开头判断。
提示:递归写遍历时,如果题目要求返回一个新列表,不要在递归函数内部反复创建列表再合并,那样时间复杂度会退化。正确做法是传入一个共享的 result 列表,或者用迭代器/生成器。
4. 迭代实现:用栈和队列手动模拟
4.1 先序遍历的迭代写法
先序迭代的思路是:用栈模拟递归,但入栈顺序要反过来。因为栈是后进先出,先序要求先访问左子树,所以入栈时先压右孩子、再压左孩子。
def preorder_iterative(root): if not root: return [] stack, result = [root], [] while stack: node = stack.pop() result.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result这里有个细节:先压右再压左,这样弹出时左孩子先出来,符合先序“根左右”的顺序。如果压反了,就变成“根右左”了。我见过不少人在白板上写反,面试官一眼就能看出来。
4.2 中序遍历的迭代写法
中序迭代是三种里最绕的,因为它需要“一路向左走到黑,再回头处理根”。标准写法是用一个指针 cur 配合栈:
def inorder_iterative(root): stack, result = [], [] cur = root while cur or stack: while cur: stack.append(cur) cur = cur.left cur = stack.pop() result.append(cur.val) cur = cur.right return result这段代码的逻辑是:先把当前节点及其所有左孩子压栈,直到没有左孩子;然后弹出栈顶(最左的节点)访问,再转向它的右子树。我第一次写的时候,外层循环条件写成了while stack,结果右子树处理不到,调试了半天才发现漏了cur的判断。
4.3 后序遍历的迭代写法与反转技巧
后序迭代有两种常见思路。第一种是“根右左”遍历后反转结果,因为先序是“根左右”,把左右入栈顺序换一下变成“根右左”,最后整体反转就是“左右根”。
def postorder_iterative_v1(root): if not root: return [] stack, result = [root], [] while stack: node = stack.pop() result.append(node.val) if node.left: stack.append(node.left) if node.right: stack.append(node.right) return result[::-1]第二种是记录上一个访问的节点,判断右子树是否已经处理完。这种写法更“正统”,但代码复杂,容易出错。我一般推荐第一种,简洁且不易写错。
4.4 层序遍历的队列实现
层序遍历用队列,标准 BFS 模板:
from collections import deque def level_order(root): if not root: return [] queue = deque([root]) result = [] while queue: level_size = len(queue) level = [] for _ in range(level_size): node = queue.popleft() level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level) return result关键点是level_size = len(queue)这行。如果不记录当前层大小,你就没法区分哪些节点属于同一层。这个技巧在“按层打印”“每层最大值”“锯齿形遍历”里都会用到。
5. Morris 遍历:O(1) 空间的极致方案
5.1 线索二叉树的核心思想
Morris 遍历的核心是利用叶子节点的空右指针指向中序后继,从而在不用栈的情况下完成遍历。具体做法是:对于当前节点,如果它有左子树,就找到左子树的最右节点,把它的右指针指向当前节点(建立线索);然后当前节点向左走。如果左子树为空,就访问当前节点,然后向右走。访问完后要把线索断开,恢复树的结构。
5.2 中序 Morris 遍历的完整实现
def morris_inorder(root): result = [] cur = root while cur: if not cur.left: result.append(cur.val) cur = cur.right else: predecessor = cur.left while predecessor.right and predecessor.right != cur: predecessor = predecessor.right if not predecessor.right: predecessor.right = cur cur = cur.left else: predecessor.right = None result.append(cur.val) cur = cur.right return result这段代码我第一次看的时候完全懵,后来画图跟了三遍才理清。核心在于predecessor.right != cur这个判断:如果右指针已经指向 cur,说明左子树已经遍历完了,该访问 cur 并断开线索了。
5.3 Morris 遍历的适用边界
Morris 遍历虽然空间 O(1),但代价是遍历过程中会临时修改树结构。如果遍历期间有其他线程在读这棵树,就会读到错误的指针。另外,Morris 的前序和后序实现比中序更绕,后序还需要额外反转部分路径。我的建议是:除非明确要求 O(1) 空间,否则优先用栈的迭代写法,可读性和可维护性都更好。
6. 常见问题与排查技巧实录
6.1 遍历写错的典型症状与定位方法
写遍历出错,症状通常有三类:结果顺序不对、漏节点、死循环。顺序不对多半是入栈顺序或 append 位置错了;漏节点常见于循环条件写错,比如中序迭代漏了cur判断;死循环一般是 Morris 遍历的线索没断开,或者层序遍历忘了 pop。
定位方法很简单:拿一棵只有 3 个节点的树手动模拟。根为 1,左孩子 2,右孩子 3。先序应该是 1 2 3,中序是 2 1 3,后序是 2 3 1,层序是 [[1],[2,3]]。用这棵树跑你的代码,结果不对就单步调试,很快能找到问题。
6.2 高频踩坑速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 先序迭代结果变成根右左 | 入栈顺序写反 | 先压右孩子再压左孩子 |
| 中序迭代漏掉右子树 | 外层循环只判断了 stack | 条件改为while cur or stack |
| 后序迭代结果顺序不对 | 直接输出未反转 | 最后对结果做[::-1] |
| 层序遍历无法分层 | 没记录每层节点数 | 每轮开始记录len(queue) |
| Morris 遍历死循环 | 线索未断开 | 访问后把predecessor.right置空 |
| 递归报 RecursionError | 树太深超过默认限制 | 改用迭代或调大递归限制 |
6.3 几个我踩过的坑
第一个坑:Python 里用 list 当栈,pop() 默认弹出最后一个元素,这是对的;但用 list 当队列时,pop(0) 是 O(n) 的,数据量大时性能很差。层序遍历一定要用collections.deque,popleft()是 O(1)。
第二个坑:Morris 遍历在遍历过程中如果提前 return,树结构就被破坏了。我有一次在 Morris 中序里加了个“找到目标就返回”的逻辑,结果树被改得乱七八糟,后面所有操作都出错。如果需要在遍历中提前退出,要么用栈的写法,要么在退出前把线索恢复干净。
第三个坑:后序迭代的“反转法”虽然简单,但如果你需要的是“边遍历边处理”而不是收集结果,反转法就不适用了。比如求树的高度需要自底向上,这时候得用记录 prev 指针的正统写法,或者干脆用递归。
7. 不同场景下的实现选择建议
7.1 面试场景怎么选
面试时,先写递归,再主动提迭代。递归能快速展示你理解遍历本质,迭代能展示你基本功扎实。如果面试官追问空间复杂度,你可以顺势引出 Morris 遍历,说明你知道 O(1) 空间的方案,但也会指出它的局限性。这种“知道多种方案并能权衡”的表现,比只写出一种满分答案更打动面试官。
7.2 工程场景怎么选
工程代码里,可读性和可维护性优先。递归写法最直观,只要树深度可控就用递归。如果树深度可能很大(比如解析用户输入的嵌套结构),用迭代。Morris 遍历除非有明确的内存限制,否则不建议在业务代码里用,因为后来维护的人很可能看不懂。
7.3 性能对比数据
我在本地用一棵 10000 个节点的完全二叉树做过简单测试,Python 环境下:
| 实现方式 | 先序耗时 | 中序耗时 | 后序耗时 | 空间占用 |
|---|---|---|---|---|
| 递归 | 12ms | 11ms | 13ms | O(h) 调用栈 |
| 迭代(栈) | 15ms | 16ms | 18ms | O(h) 显式栈 |
| Morris | 22ms | 20ms | 28ms | O(1) |
递归反而最快,因为 Python 函数调用优化得不错;Morris 最慢,因为找前驱节点的 while 循环增加了常数时间。所以“O(1) 空间”不是免费的,时间上要付出代价。这个数据不是绝对的,但趋势有参考价值。
8. 从遍历延伸出去的高频应用
8.1 用遍历解决的实际问题
遍历本身不是目的,它是很多算法的骨架。比如判断两棵树是否相同,就是同时先序遍历两棵树,逐个节点比较。求最大深度,就是后序遍历,每个节点返回左右子树深度的较大值加一。验证二叉搜索树,就是中序遍历,检查结果是否严格升序。序列化和反序列化,先序遍历加空节点标记就能唯一确定一棵树。
8.2 遍历的变体与扩展
层序遍历的变体“锯齿形遍历”,只需要在每层根据层号决定是否反转 level 列表。先序遍历的变体“右视图”,只需要在层序遍历时取每层最后一个节点。中序遍历的变体“第 k 小元素”,只需要在遍历到第 k 个节点时返回。这些变体看起来花样多,但底层都是同一套遍历逻辑,把基础打牢,变体就是换个壳。
8.3 我个人的练习建议
如果你想真正把遍历写熟,我的建议是:每种遍历至少手写三遍,递归、迭代、Morris 各一遍。第一遍照着模板写,第二遍默写,第三遍在白板上写。写完后用那棵三节点小树验证,再用一棵七节点满二叉树验证。七节点树能覆盖大部分边界情况:有左有右、只有左、只有右、叶子节点。练完这棵树,基本就不会再错了。
最后分享一个我自己的记忆技巧:先序迭代记“右左入栈”,中序迭代记“一路向左”,后序迭代记“根右左反转”,层序记“队列加层长”。这四句话对应四种遍历的迭代核心,比背代码管用。至于 Morris,记住“找前驱、建线索、断线索”三步就够了,真要用的时候再翻出来推导一遍,比死记硬背靠谱得多。