1. 从“遍历”说起:为什么二叉树遍历是程序员的必修课
如果你刚开始接触数据结构,或者正在准备技术面试,那么“二叉树遍历”这个词你肯定绕不过去。它听起来有点枯燥,不就是把树里的节点都访问一遍吗?但恰恰是这个看似基础的操作,是理解递归、栈、队列乃至更复杂算法(如动态规划、回溯)的绝佳切入点。很多人在学习时,只是机械地背下了“前序、中序、后序”这几个名字和代码,却很少去深究:为什么是这三种顺序?它们各自解决了什么问题?在实际写代码时,除了递归,我们还能怎么玩?
今天,我们不谈空泛的理论,就从最接地气的角度,把二叉树的前序、中序、后序遍历掰开揉碎了讲清楚。我会结合具体的代码示例(以Python为主,思路通用)、面试中高频的变形题,以及我在实际开发和刷题中踩过的坑,让你不仅知道怎么写,更明白为什么要这么写,以及遇到各种“幺蛾子”时该怎么处理。
简单来说,遍历一棵二叉树,就是按照某种规则,不重复地访问树中的每一个节点。而前序、中序、后序,指的就是访问根节点的时机相对于访问其左右子树的时机。记住这个核心,后面的一切都迎刃而解。
2. 三种遍历的“灵魂”:访问根节点的时机
这是理解三种遍历最本质、也最不容易混淆的角度。我们先把二叉树抽象成一个最简单的单元:一个根节点(Root),带着它的左子树(Left Subtree)和右子树(Right Subtree)。
- 前序遍历(Preorder Traversal):根-> 左 -> 右。你首先处理当前这个“根”节点,然后再去处理它的左半边天下(左子树),最后处理右半边天下(右子树)。这是一种“自上而下”的天然顺序。
- 中序遍历(Inorder Traversal):左 ->根-> 右。你先彻底探索完左子树,然后回来处理根节点,最后再去探索右子树。对于二叉搜索树(BST)来说,这个顺序会产生一个递增的序列,这是它最重要的特性。
- 后序遍历(Postorder Traversal):左 -> 右 ->根。你把左右两边的“家务事”都处理干净了,最后再来处理根节点。这种顺序在需要先子节点、后父节点的场景下非常有用,比如计算子树的高度、释放树的内存。
很多人靠死记硬背“前中后”对应“根左右、左根右、左右根”,但更容易混淆。我的建议是,永远用**“根节点的访问时机”**来记忆:前序就是最先访问根,中序就是中间访问根,后序就是最后访问根。左右子树的访问顺序永远是先左后右(除非题目特殊要求),这是约定俗成的。
为了更直观,我们看一个具体的二叉树例子:
1 / \ 2 3 / \ \ 4 5 6- 前序遍历结果:1, 2, 4, 5, 3, 6。 验证:先访问根1,然后遍历左子树(2,4,5),最后遍历右子树(3,6)。在遍历左子树时,以2为根,同样遵循“根左右”,所以是2,4,5。
- 中序遍历结果:4, 2, 5, 1, 3, 6。 验证:先遍历左子树(4,2,5),然后访问根1,最后遍历右子树(3,6)。左子树的中序遍历是4,2,5。
- 后序遍历结果:4, 5, 2, 6, 3, 1。 验证:先遍历左子树(4,5,2),然后遍历右子树(6,3),最后访问根1。
3. 递归实现:最直观的“分治”思想
递归是实现这三种遍历最符合直觉、代码也最简洁的方式。它完美体现了“分而治之”的思想:要遍历整棵树,我先访问根节点(根据时机不同),然后把遍历左子树和右子树的任务,分别看作两个规模更小的、完全相同的遍历问题。
下面是用Python定义的经典二叉树节点,以及三种遍历的递归实现:
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def preorder_recursive(root, result): """前序遍历(递归)""" if not root: return result.append(root.val) # 访问根节点 preorder_recursive(root.left, result) # 遍历左子树 preorder_recursive(root.right, result) # 遍历右子树 def inorder_recursive(root, result): """中序遍历(递归)""" if not root: return inorder_recursive(root.left, result) # 遍历左子树 result.append(root.val) # 访问根节点 inorder_recursive(root.right, result) # 遍历右子树 def postorder_recursive(root, result): """后序遍历(递归)""" if not root: return postorder_recursive(root.left, result) # 遍历左子树 postorder_recursive(root.right, result) # 遍历右子树 result.append(root.val) # 访问根节点递归实现的要点与坑点:
- 递归终止条件:
if not root: return。这是最重要的,没有它就会无限递归下去。它对应着走到了空节点,意味着当前这条分支已经探索完毕。 - 参数传递:
result列表作为一个“全局”记录者,在递归过程中不断被修改。你也可以让递归函数直接返回一个列表,但那样在拼接列表时会产生额外的空间开销。面试时两种写法都要会,通常传递一个引用更高效。 - 空间复杂度:递归调用需要使用系统栈,空间复杂度与树的高度成正比。在最坏情况(树退化成一条链)下,空间复杂度是O(N)。这是递归方法的主要缺点。
踩坑实录:很多新手在写递归时,容易在
result.append(root.val)这一行犯错,比如忘记append,或者错误地return result.append(...)(append方法返回None)。记住,递归函数的目的是“完成任务”(遍历并收集),而不是“返回结果”(除非是自底向上的计算)。另外,一定要先判断root是否为空,否则访问root.val会直接导致AttributeError。
4. 迭代实现:手动模拟栈,理解递归的本质
递归虽然简洁,但有时我们需要更精细地控制遍历过程,或者担心递归深度过深导致栈溢出。这时,迭代法就派上用场了。迭代法的核心是用我们自己的栈(Stack)来模拟系统调用栈的过程。
迭代法比递归难理解一些,因为我们需要手动管理节点的访问和压栈顺序。三种遍历的迭代写法各有特点,尤其是中序遍历,与前序/后序有显著不同。
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 result4.2 中序遍历的迭代实现
中序遍历是“左根右”,不能像前序那样简单。我们需要用一个指针(curr)来模拟“一路向左到底”的过程,同时用栈来保存“回退”的路径。
- 从根节点开始,当前节点
curr不为空,就将其压栈,然后curr指向左孩子。这相当于深入左子树。 - 当
curr为空时,说明已经走到最左下了。此时从栈中弹出节点(这就是当前子树的“根”),访问它。 - 访问完后,将
curr指向该节点的右孩子,开始处理右子树。 - 重复上述过程。
def inorder_iterative(root): """中序遍历(迭代)""" stack, result, curr = [], [], root while curr or stack: # 一路向左,把节点压入栈 while curr: stack.append(curr) curr = curr.left # 弹出栈顶节点并访问 curr = stack.pop() result.append(curr.val) # 访问根节点 # 转向右子树 curr = curr.right return result4.3 后序遍历的迭代实现
后序遍历是“左右根”。我们可以利用前序遍历“根左右”的迭代版本稍作修改。前序是“根左右”,如果我们改成“根右左”,然后将结果反转,不就得到“左右根”了吗?这是一个非常巧妙的技巧。
- 模仿前序遍历,但调整左右子节点入栈顺序,得到“根右左”的访问序列。
- 将得到的序列反转。
def postorder_iterative(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]迭代实现的要点与坑点:
- 前序最简单:理解“右先左后”的压栈顺序是关键。
- 中序最经典:
while curr or stack这个循环条件要记牢。内层的while curr负责向左深入,外层的循环负责处理栈和右子树。 - 后序取巧但有效:理解其本质是对前序的变形和反转。面试时如果要求写非递归后序,这种方法通常是可以接受的。当然也有更接近递归逻辑的双栈法,但理解起来更复杂。
- 空节点处理:迭代法中,对于空树的判断(
if not root)依然重要。在中序遍历中,curr初始化为root,即使root为空,while curr or stack也能正确处理。
实操心得:我强烈建议在理解递归的基础上,亲手画图模拟一遍迭代法的执行过程。拿一张纸,画一个简单的二叉树,然后一步步模拟栈的变化和
curr指针的移动。这个过程能极大地加深你对遍历顺序和栈这个数据结构作用的理解。面试时,如果被问到“不用递归怎么做”,你能清晰地画出这个过程,比干巴巴地背代码要加分得多。
5. 莫里斯遍历:一种空间复杂度为O(1)的神奇方法
无论是递归还是迭代,我们都需要O(H)的额外空间(H为树高)。有没有可能只用常数空间呢?有,这就是莫里斯遍历(Morris Traversal)。它的核心思想是利用树中大量的空指针,临时将当前节点的前驱节点(中序遍历下的前一个节点)的右孩子指向自己,从而在遍历完左子树后能通过这个临时链接返回到根节点,省去了栈的空间。
莫里斯遍历主要应用于中序遍历,它稍微修改后也能用于前序。后序的莫里斯遍历非常复杂,一般不要求掌握。我们重点看中序。
算法步骤(中序莫里斯遍历):
- 初始化
curr指向根节点。 - 当
curr不为空时: a. 如果curr没有左孩子,则访问curr,并将curr指向其右孩子。 b. 如果curr有左孩子: i. 找到curr在中序遍历下的前驱节点pre。即curr左子树中最右边的那个节点。 ii. 如果pre的右孩子为空,将其右孩子设置为curr(建立临时链接),然后将curr指向其左孩子。 iii. 如果pre的右孩子已经是curr(说明左子树已被遍历过),则断开这个临时链接(将pre.right置为None),访问curr,然后将curr指向其右孩子。
def inorder_morris(root): """中序遍历(莫里斯) - 空间O(1)""" result = [] curr = root while curr: if not curr.left: # 如果没有左孩子,直接访问当前节点,然后转向右孩子 result.append(curr.val) curr = curr.right else: # 找到当前节点在中序遍历下的前驱节点 pre = curr.left while pre.right and pre.right != curr: pre = pre.right if not pre.right: # 建立临时链接,指向当前节点 pre.right = curr curr = curr.left else: # 临时链接已存在,说明左子树已遍历完 pre.right = None # 恢复树的结构 result.append(curr.val) curr = curr.right return result莫里斯遍历的优缺点:
- 优点:空间复杂度为O(1),对于内存严格受限的环境或巨型二叉树有优势。
- 缺点:修改了树的结构(尽管是临时的)。这在多线程环境或不允许修改原数据的场景下是致命的。同时,代码逻辑比递归和迭代复杂,容易出错。
注意事项:除非面试官明确要求,或者问题有严格的O(1)空间限制,否则在工程实践中优先使用递归或迭代法。莫里斯遍历更像一种炫技的算法,用于展示对遍历过程的深刻理解。一定要在代码注释中写明它修改了树的结构。
6. 层序遍历:另一种维度的遍历方式
虽然标题聚焦于前中后序,但相关热词中频繁出现“层序遍历”,它同样至关重要。层序遍历不属于深度优先搜索(DFS)的范畴,而是**广度优先搜索(BFS)**在树上的应用。它按照树的层级,从上到下、从左到右访问节点。
实现层序遍历的核心数据结构是队列(Queue)。
- 将根节点放入队列。
- 当队列不为空时: a. 记录当前队列的长度
level_size(即当前层的节点数)。 b. 循环level_size次,每次从队列中取出一个节点访问,并将其非空的左右子节点依次加入队列。 c. 当前层所有节点处理完毕,结果中保存当前层的节点值列表,然后继续下一层。
from collections import deque def level_order_traversal(root): """层序遍历""" if not root: return [] result = [] queue = deque([root]) while queue: level_size = len(queue) current_level = [] for _ in range(level_size): node = queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) return result对于之前的例子树,层序遍历的结果是:[[1], [2, 3], [4, 5, 6]]。
层序遍历的妙用:
- 求二叉树的最大宽度:在遍历每一层时,记录该层的节点数,取最大值。
- 找到二叉树每层的最大值。
- 判断是否是完全二叉树:在层序遍历中,如果遇到一个空节点之后又遇到了非空节点,则不是完全二叉树。
- 锯齿形(Z字型)层序遍历:偶数层将结果反转即可。
7. 遍历序列的威力:重构二叉树与解决实际问题
知道怎么遍历还不够,更重要的是能利用遍历序列解决问题。一个经典面试题是:已知两种遍历序列,能否唯一确定一棵二叉树?
- 前序 + 中序:可以唯一确定。
- 原理:前序序列的第一个元素一定是根节点。在中序序列中找到这个根节点,其左边就是左子树的中序序列,右边就是右子树的中序序列。根据左右子树的节点数量,可以在前序序列中划分出左右子树的前序序列。递归进行即可。
- 后序 + 中序:可以唯一确定。
- 原理:后序序列的最后一个元素一定是根节点。后续步骤与前序+中序类似。
- 前序 + 后序:一般不能唯一确定,除非是满二叉树或真二叉树。因为无法区分左右子树的边界。
重构二叉树的代码示例(前序+中序):
def build_tree(preorder, inorder): """根据前序和中序遍历序列构建二叉树""" if not preorder or not inorder: return None # 前序第一个是根节点 root_val = preorder[0] root = TreeNode(root_val) # 在中序中找到根节点的位置 root_index_in_inorder = inorder.index(root_val) # 划分左右子树的中序序列 left_inorder = inorder[:root_index_in_inorder] right_inorder = inorder[root_index_in_inorder + 1:] # 划分左右子树的前序序列(长度与中序子树相同) left_preorder = preorder[1:1 + len(left_inorder)] right_preorder = preorder[1 + len(left_inorder):] # 递归构建 root.left = build_tree(left_preorder, left_inorder) root.right = build_tree(right_preorder, right_inorder) return root避坑指南:上面的代码为了清晰,使用了
list.index()和列表切片,这在递归过程中会创建大量新列表,空间效率不高。在面试或性能要求高的场景下,应该使用索引指针来避免复制。即传递(pre_start, pre_end, in_start, in_end)这样的参数范围,在原始数组上操作。
8. 遍历的应用场景与高频面试题变形
最后,我们来聊聊遍历在实战和面试中的具体应用。死记硬背代码没用,关键是理解每种遍历顺序带来的特性。
前序遍历的应用:
- 复制一棵树:先创建根节点,再递归复制左右子树。这天然符合前序顺序。
- 序列化二叉树(如JSON化):将树的结构转化为字符串或数组,前序是一种很直观的方式。
- 求从根到叶子的所有路径:在深度优先遍历中,前序顺序可以方便地在向下探索时记录路径。
中序遍历的应用:
- 二叉搜索树(BST)的相关操作:这是中序遍历的“主场”。BST的中序遍历结果是一个升序数组。利用这个特性可以:
- 验证一棵树是否是BST。
- 在BST中查找第K小的元素。
- 恢复一棵出错的BST(两个节点被错误交换)。
- 表达式树求值:对于表达式树(运算符是根,操作数是叶子),中序遍历能得到中缀表达式(可能需要加括号)。
后序遍历的应用:
- 释放二叉树内存:必须先释放左右子树,才能释放根节点。C/C++中的
free或delete操作。 - 计算二叉树的高度/深度:树的高度 = 1 + max(左子树高度, 右子树高度)。这需要先知道子树高度,是典型的后序逻辑。
- 判断一棵树是否是平衡二叉树:在计算高度的同时判断平衡性。
- 计算二叉树中任意两个节点的最近公共祖先(LCA):一种经典的递归解法就是后序遍历。
- 路径总和问题(判断是否存在从根到叶子节点路径和等于目标值):虽然前序也能做,但后序在回溯时撤销状态更清晰。
层序遍历的应用:
- 寻找二叉树的最大/最小深度。
- 寻找二叉树每层的最大值/平均值。
- 二叉树的右视图/左视图:即每一层最右边/最左边的节点。
- 判断是否是完全二叉树。
一道经典变形题:迭代版后序遍历的另一种写法(双栈法)前面提到了修改前序+反转的方法,这里介绍更直观的双栈法,它模拟了“左右根”的访问顺序,更容易理解后序的本质。
- 使用栈
s1,先将根节点压入。 - 循环从
s1弹出节点,将其压入另一个栈s2。 - 然后将该节点的左孩子、右孩子(如果存在)依次压入
s1。 - 重复步骤2-3,直到
s1为空。此时s2中节点的出栈顺序就是后序遍历顺序。
def postorder_iterative_two_stack(root): """后序遍历(迭代 - 双栈法)""" if not root: return [] s1, s2 = [root], [] result = [] while s1: node = s1.pop() s2.append(node) if node.left: s1.append(node.left) if node.right: s1.append(node.right) while s2: result.append(s2.pop().val) return result这个方法的妙处在于,s1的压栈顺序是“根 -> 右 -> 左”,弹出压入s2后,s2的压栈顺序是“左 -> 右 -> 根”,最后从s2弹出时自然就是“左右根”。它虽然用了两个栈,但逻辑非常清晰。
遍历二叉树远不止前中后序这三种方式,它们是理解树形结构操作的基石。从递归到迭代,从栈到队列,再到莫里斯遍历的奇技淫巧,每一种实现都在加深我们对程序控制流和数据结构的理解。下次当你再看到遍历相关的题目时,不妨先问自己:这个问题需要以什么样的顺序访问节点?是父节点优先(前序),是子节点优先(后序),还是需要按层级处理(层序)?想清楚了这一点,选择何种遍历方法,以及采用递归还是迭代,就都有了明确的依据。