C++详解二叉树遍历:递归与迭代实现及层序遍历应用
1. 项目概述:为什么树的遍历是算法基石
如果你刚开始接触数据结构与算法,或者正在准备技术面试,那么“树的遍历”这个概念,你大概率已经听过无数遍了。它就像学英语时的“主谓宾”结构,看似基础,却构成了理解更复杂算法(比如二叉搜索树的增删查改、平衡树的旋转、图论中的深度优先搜索)的绝对核心。我见过太多朋友,在刷LeetCode时,一遇到二叉树相关的题目就发怵,代码写出来总是差一点,根源往往就在于对几种遍历方式的理解只停留在“背模板”的层面,没有真正吃透它们的思想和变体。
所谓树的遍历,就是按照某种规则,不重复地访问树中的所有节点。这个“规则”的不同,就衍生出了我们常说的四种经典方式:前序遍历、中序遍历、后序遍历和层序遍历。别小看这四种遍历,它们不仅仅是输出节点值的顺序不同,其背后的访问逻辑和应用场景天差地别。前序遍历常用于复制一棵树或序列化;中序遍历是二叉搜索树输出有序序列的不二法门;后序遍历在计算子树属性(如高度、节点数)或进行析构时非常高效;而层序遍历则是解决与“深度”、“宽度”相关问题的最直观工具。
今天,我们就用C++这把“手术刀”,把这四种遍历方式从里到外彻底解剖一遍。我会带你从最基础的递归实现开始,这是理解遍历思想最直观的路径;然后,我们会一步步过渡到迭代实现,因为递归虽然简洁,但在面试或处理深度很大的树时,理解其等价的迭代栈操作是必备技能;最后,我们会深入到层序遍历,并探讨其在解决实际问题时的威力。无论你是刚入门的新手,还是想巩固基础的进阶者,这篇文章都会让你对树的遍历有一个全新的、透彻的认识。
2. 核心思路与递归实现:理解遍历的本质
在开始写代码之前,我们必须先统一思想:什么是“访问”?在树的语境下,访问一个节点,通常意味着读取或处理它的值。而遍历的核心矛盾在于,树是非线性的,一个节点可能有左、右两个孩子,我先访问谁?处理完当前节点后,下一步该去哪里?
递归之所以是讲解遍历的首选,是因为它完美契合了树的定义——一棵树由根节点和若干子树构成。处理整棵树,等价于处理根节点,再递归地处理左子树和右子树。区别仅在于这三者之间的执行顺序。
2.1 二叉树节点的C++定义
任何操作都需要先有数据结构。我们定义一个最标准的二叉树节点:
这个结构体包含一个整型值val,以及指向左、右子节点的指针。构造函数将节点值初始化为x,并将左右指针置为空(nullptr)。这是所有后续操作的基础。
注意:在实际项目中,你可能需要模板化
val的类型以支持泛型,或者添加父节点指针以支持更多操作。但为了聚焦于遍历算法本身,我们使用这个最简单的版本。
2.2 前序遍历:根 -> 左 -> 右
前序遍历的访问顺序是:先访问根节点,然后递归地前序遍历左子树,最后递归地前序遍历右子树。这个顺序可以记为“根左右”。
递归函数实现:
为什么这样写? 递归的核心是“相信递归函数能完成它的工作”。我们假设preorderTraversal这个函数已经能正确地对一棵树进行前序遍历并结果存入result。那么,对于以root为根的树,我们要做的就是按顺序执行三件事:处理根、让函数去处理左子树、让函数去处理右子树。if (root == nullptr)这个判断至关重要,它是递归的终止条件,确保了当遇到空子树时,递归能够正确返回,不会产生无限递归或访问空指针。
一个具体的例子: 对于二叉树 [1,2,3,null,null,4,5](层序遍历表示),其前序遍历结果是 [1,2,3,4,5]。你可以手动模拟一下:从根1开始,访问1;走到左子树根2,访问2(2的左右子节点为空,递归返回);回到根1,走到右子树根3,访问3;走到3的左子树根4,访问4;回到3,走到3的右子树根5,访问5。
2.3 中序遍历:左 -> 根 -> 右
中序遍历的访问顺序是:先递归地中序遍历左子树,然后访问根节点,最后递归地中序遍历右子树。顺序是“左根右”。
递归函数实现:
核心应用:二叉搜索树(BST)的性质。 这是中序遍历最重要的特性。对于一棵二叉搜索树(左子树所有节点值 < 根节点值 < 右子树所有节点值),中序遍历的结果必然是一个升序序列。这个性质被广泛应用于BST的验证、查找第K小的元素等问题。代码和递归思想完全没变,只是访问根节点的时机挪到了中间。
2.4 后序遍历:左 -> 右 -> 根
后序遍历的访问顺序是:先递归地后序遍历左子树,然后递归地后序遍历右子树,最后访问根节点。顺序是“左右根”。
递归函数实现:
为什么后序遍历有用? 因为这种顺序意味着,当你访问一个节点时,它的所有子孙节点都已经被访问过了。这使得后序遍历非常适合处理一些“依赖子节点结果才能计算父节点”的场景。比如,计算二叉树中每个节点的高度(高度取决于左右子树的高度),或者安全地删除一棵树(必须先删除子节点,再删除父节点,否则会出现悬空指针)。在后续我们讨论迭代实现时,后序遍历也是最复杂的一种,因为它需要额外的状态来记录节点是否被访问过。
3. 迭代实现:用栈模拟递归过程
递归虽然清晰,但它隐式地使用了系统调用栈。在树深度很大时,有栈溢出的风险。并且,理解迭代实现能让你更深刻地把握遍历过程的本质。迭代法的核心就是:用我们自己维护的显式栈,来模拟系统栈的函数调用过程。
3.1 前序遍历的迭代法
前序遍历的迭代思路是最直接的,因为它的访问顺序和我们手动模拟的思路一致:遇到一个节点,就访问它,然后优先处理它的左子树,右子树暂时压入栈中“备忘”。
算法步骤:
- 初始化一个空栈
stk,将根节点root压入栈。 - 当栈不为空时循环:
a. 弹出栈顶节点
node。 b. 访问node(将其值加入结果)。 c. 先将node的右子节点压栈(如果存在)。 d. 再将node的左子节点压栈(如果存在)。 - 循环结束,返回结果。
C++实现:
为什么先右后左? 栈是“后进先出”的结构。我们希望下一个被处理(弹出)的节点是左子节点。因此,需要先把右子节点压进去,再把左子节点压进去。这样,左子节点就在栈顶,会先被弹出访问。这个过程完美模拟了递归中“先处理根,然后深入左子树,左子树全部处理完后再回溯处理右子树”的流程。
3.2 中序遍历的迭代法
中序遍历的迭代法比前序要稍复杂一些,因为访问节点的时机不是在入栈或出栈时,而是在“从栈中弹出,但准备转向右子树之前”。我们需要一个指针 curr 来帮助遍历。
算法步骤(“左链入栈”法):
- 初始化空栈
stk,指针curr = root。 - 当
curr不为空 或 栈不为空时循环(这个“或”很关键): a. 一路向左:如果curr不为空,将其压入栈,并令curr = curr->left。这一步模拟递归深入左子树的过程。 b. 访问与转折:如果curr为空(意味着已经走到某条左链的尽头),则: i. 弹出栈顶节点node,它就是当前应该访问的“根”节点。 ii. 访问node。 iii. 令curr = node->right,开始处理右子树。 - 循环结束,返回结果。
C++实现:
理解这个“双循环”结构: 外层循环的条件 curr != nullptr || !stk.empty() 保证了只要还有节点待处理,循环就不会停止。内层的 while 循环负责疯狂向左走,把路径上的节点都压入栈,这是模拟递归调用栈增长的过程。当走到头(curr为空)时,从栈中弹出一个节点访问,然后把这个节点的右子树当作一棵新的树,重复整个过程。这个算法非常优美地模拟了中序遍历的递归过程。
3.3 后序遍历的迭代法
后序遍历是迭代法中最有挑战性的一种,因为一个节点需要在它的左右子树都被访问后才能被访问。我们无法像前序或中序那样,在某个固定时机(刚遇到或从左子树返回时)就访问它。这里介绍一种利用前序变形和反转的取巧方法,以及一种更通用的标记法。
方法一:前序变体 + 反转 我们观察前序遍历是“根左右”,后序遍历是“左右根”。如果我们能实现一种“根右左”的遍历,再把结果反转一下,不就得到“左右根”了吗?而“根右左”的迭代实现,只需把前序遍历迭代法中左右子节点的入栈顺序对调即可。
C++实现:
这种方法代码非常简洁,容易记忆。但它需要一次额外的结果反转操作,并且遍历顺序本身并不是真正的后序,在某些需要边遍历边处理的场景下可能不适用。
方法二:标记法(推荐掌握) 这是更贴近后序遍历本质的迭代方法。我们给栈中的每个元素附加一个状态,标记这个节点是否已经被访问过(即其左右子树是否已处理)。
算法思路: 栈里不直接存 TreeNode*,而是存一个 pair<TreeNode*, bool>,其中 bool 表示该节点是否已被访问。初始时,节点状态为 false(未访问)。当从栈中弹出一个节点时:
- 如果状态为
false,说明它的左右子树还没处理。那么我们将它重新压栈,但状态改为true。接着,按照先右后左的顺序(因为栈是LIFO,我们希望左子树先被处理),将它的右、左子节点(如果存在)以false状态压栈。 - 如果状态为
true,说明它的左右子树都已处理完毕,现在可以访问这个节点了。
C++实现:
这种方法虽然代码稍长,但它清晰地模拟了后序遍历的递归调用栈,通用性更强,且不需要反转结果。理解这种方法,你对栈在遍历中的作用会有质的飞跃。
4. 层序遍历:队列与广度优先搜索
层序遍历,顾名思义,就是按树的层级,从上到下、从左到右依次访问节点。它不属于深度优先遍历的范畴,而是一种广度优先搜索。实现它需要用到另一种数据结构:队列。
4.1 基础层序遍历
队列的“先进先出”特性完美契合层序遍历的需求:我们将一层的节点按顺序放入队列,然后依次取出访问,同时在取出每个节点时,将其子节点放入队列尾部,这样就自然保证了下一层的节点会在当前层所有节点处理完后才被处理。
算法步骤:
- 初始化一个空队列
que,将根节点root入队。 - 当队列不为空时循环:
a. 获取当前队列的大小
levelSize(这代表当前层的节点数)。 b. 循环levelSize次: i. 出队队首节点node。 ii. 访问node。 iii. 如果node有左子节点,将其入队。 iv. 如果node有右子节点,将其入队。 c. 当前层所有节点处理完毕,结果中已保存了这一层的节点值(或者你可以开始处理下一层)。 - 循环结束,返回结果。
C++实现:
关键点解析: 代码中 int levelSize = que.size(); 这一行至关重要。在开始处理一层之前,我们先记录下队列的长度,这个长度就是当前层所有节点的数量。然后我们只循环这么多次,这样就严格区分了当前层和下一层。如果不这样做,随着我们不断出队、入队,队列中会混合不同层的节点,就无法区分层级了。这种写法是解决所有二叉树层序遍历相关问题的标准模板。
4.2 层序遍历的变体与应用
掌握了基础的层序遍历模板,很多LeetCode上的中等难度题目就迎刃而解了。它们本质上都是在这个模板上稍作修改。
变体1:锯齿形(之字形)层序遍历
即先从左到右,下一层从右到左,如此交替。我们只需要在基础模板上,增加一个标志位 leftToRight 来记录当前层的遍历方向,然后在存储当前层结果时,根据方向决定是正向插入还是反向插入。
变体2:寻找每层的最大值/平均值 这更简单了,在遍历每一层时,累加值或比较最大值即可,模板完全不变。
变体3:二叉树的最小深度 最小深度是指从根节点到最近叶子节点的路径上的节点数。用层序遍历做是最高效的,因为一旦我们遇到第一个叶子节点(左右子节点都为空),就可以立即返回当前深度,这就是最小深度。
层序遍历在这里的优势是“广度优先”,它保证我们找到的第一个叶子节点,其深度一定是最小的。如果用深度优先搜索(递归),则需要遍历所有路径才能确定最小值。
5. 综合对比与实战心得
到现在为止,我们已经把四种遍历方式的递归和迭代实现都过了一遍。我们来做一个系统的对比和总结,并分享一些在实战和面试中的心得体会。
5.1 四种遍历方式对比总结
| 遍历方式 | 访问顺序 (递归定义) | 核心思想与应用场景 | 迭代实现关键数据结构 | 迭代实现难点 |
|---|---|---|---|---|
| 前序遍历 | 根 -> 左 -> 右 | “先处理当前,再处理子问题”。用于复制树、序列化、计算目录结构等。 | 栈 | 相对简单,注意入栈顺序(先右后左)。 |
| 中序遍历 | 左 -> 根 -> 右 | “先处理左子问题,再处理当前,最后处理右子问题”。BST相关问题的核心(得到有序序列)。 | 栈 | 需要指针curr配合,理解“一路向左,回溯访问,转向右子树”的循环。 |
| 后序遍历 | 左 -> 右 -> 根 | “先处理所有子问题,再处理当前”。适用于计算子树属性(高度、节点数)、析构树、某些路径问题。 | 栈 | 最复杂。需用“标记法”或“前序变体+反转”来模拟访问时机。 |
| 层序遍历 | 按层,从左到右 | 广度优先搜索。用于求深度、宽度、层最大值、最短路径等与“层”相关的问题。 | 队列 | 需在每层循环前记录队列大小,以区分层级。 |
5.2 递归 vs. 迭代:如何选择?
- 递归:代码简洁,逻辑与树定义高度一致,易于理解和编写。是表达树算法最自然的方式。缺点是存在函数调用开销,深度过大时可能导致栈溢出。在通常的算法题和面试中,如果树深度不大(比如平衡树),递归是首选。
- 迭代:显式使用栈或队列,避免了递归的系统开销,可控性更强。缺点是代码相对复杂,尤其是中序和后序遍历。当题目明确要求不能使用递归,或者树可能非常深(如链状树)时,必须使用迭代法。
我的建议是: 初学者务必先掌握递归写法,深刻理解其分治思想。然后,必须掌握前序、中序、层序遍历的迭代写法,因为它们在面试中极为常见。后序遍历的迭代法(标记法)可以作为加分项去理解。
5.3 常见陷阱与调试技巧
- 空指针判断:这是最常见的运行时错误。在访问
node->left或node->right之前,一定要先判断node是否为空。递归的基准条件if (root == nullptr) return;和迭代中入队/入栈前的判断if (node->left) ...都是为了这个。 - 迭代中栈或队列的状态管理:
- 前序迭代:忘记“先右后左”的入栈顺序,会导致遍历顺序错误。
- 中序迭代:混淆
curr指针和栈顶元素的关系。记住curr代表当前要处理的“子树”,可能是空(意味着该回溯了)。 - 后序标记法:忘记将节点重新压栈并标记为已访问,会导致节点丢失。
- 层序遍历:最经典的错误是在循环内部使用
for (int i = 0; i < q.size(); ++i),因为q.size()在循环中是变化的!必须提前用变量levelSize固定住。
- 理解遍历序列的唯一性:仅凭一种遍历序列(如前序)通常无法唯一确定一棵二叉树的结构。但前序+中序或后序+中序可以唯一确定。这是因为中序提供了左右子树的划分信息。这是一个常见的面试考点。
- 调试小技巧:对于复杂的迭代代码,可以用一个小二叉树(例如
[1,2,3])在纸上手动模拟栈/队列的变化,一步步跟踪变量的值,这是理解算法最有效的方法。
5.4 从遍历到解题:思路的延伸
树的遍历不仅仅是输出节点值。它更是一种访问所有节点并执行某种操作的框架。很多二叉树问题都可以被归结为“在某种遍历顺序下,计算或传递某种信息”。
- 自顶向下:类似于前序遍历。在访问节点时,你将一些信息通过函数参数“带下去”给子节点。例如,“计算从根到每个叶子节点的路径和”。
- 自底向上:类似于后序遍历。你先递归地解决子问题(左右子树),然后将子问题的结果组合起来解决当前节点的问题。例如,“判断一棵树是否是平衡二叉树”(需要子树的高度信息)。
- 层序相关:任何需要按层处理,或求最短路径(最小深度)的问题,层序遍历都是第一选择。
当你拿到一个二叉树问题时,先问自己:解决这个问题,需要在访问节点时知道什么信息?这些信息是从父节点传来更方便(前序),还是从子节点汇总而来更方便(后序)?抑或是需要按层处理(层序)?想清楚了这一点,代码框架就呼之欲出了。
树的遍历是数据结构中最优美、最基础的部分之一。它连接了递归、栈、队列这些核心概念,也是解决无数复杂算法的起点。希望这篇近万字的详解,能帮你把这块基石打牢。剩下的,就是在具体的题目中反复练习和体会了。当你再看到二叉树问题时,能下意识地想到该用哪种遍历框架,那你的修炼就真正入门了。