二叉树的三种递归遍历,说是数据结构里最基础的内容,但我在带新人、辅导面试者的过程中发现,能把这三行代码真正讲透、写对、还知道为什么对的人,其实并不多。很多人背下了"根左右、左根右、左右根"的口诀,但一遇到"为什么我的程序栈溢出了""为什么输出顺序不对""层序和前序到底差在哪"这类问题,照样卡壳。这篇就把前序、中序、后序递归遍历的来龙去脉一次性讲清楚,从递归结构、代码模板到手算验证,再到最常见的报错排查,全部涵盖。适合正在学数据结构的学生、准备算法面试的开发者,以及工作几年想回头补基础的朋友。
1. 递归遍历的整体思路:二叉树为什么天生适合递归
1.1 二叉树的递归定义决定了递归解法
学二叉树绕不开一个事实:二叉树这个数据结构本身,就是用递归定义的。
一棵二叉树要么是空树,要么由一个根节点加上左右两棵互不相交的二叉树组成。注意这个定义里,"由左右两棵二叉树组成"这句话,又用到了"二叉树"这个词本身。这就是典型的递归定义——用一个事物的子结构来描述它自己。
因为定义是递归的,处理它的最自然方式也就是递归。你可以把递归遍历想象成逛一个博物馆:博物馆入口是一个中央大厅,左右各连接着两个分馆,分馆里还有更小的分展厅。你想把所有展品都看一遍,最省心的办法就是走一套固定流程,每到一个厅,按规则看完再走。这个流程套用到每个分馆都一样,于是你只需要记住一套流程,就能逛完整个博物馆。
这也解释了为什么线性结构适合循环、树形结构适合递归。数组链表就是一条路走到黑,用for循环、while循环效率最高;二叉树是逐层嵌套的,用递归可以最大程度保持代码和定义的一致性,好写、好读、好改。
我个人一直觉得,递归遍历的精髓不是"递归了不起",而是"遍历一棵树 = 遍历根节点 + 遍历左子树 + 遍历右子树"。这个等式一旦刻进脑子里,三种遍历全都迎刃而解了。
1.2 三种遍历顺序的真正含义
很多初学者最纠结的事情是:前序、中序、后序,到底谁前谁后、谁中谁后?
答案非常朴素。说的其实是根节点被访问的时机。
前序遍历,是"根节点最先被访问"的遍历。具体顺序是:
- 访问根节点
- 前序遍历左子树
- 前序遍历右子树
中序遍历,是"根节点在中间被访问"的遍历。具体顺序是:
- 中序遍历左子树
- 访问根节点
- 中序遍历右子树
后序遍历,是"根节点在最后被访问"的遍历。具体顺序是:
- 后序遍历左子树
- 后序遍历右子树
- 访问根节点
用逛博物馆的比喻再来一遍:假设你进到中央大厅必须先决定,"我到底是进门就看中央大厅的展品,还是先去左边分馆逛完再回来,还是两个分馆都逛完了最后看中央大厅"——这就是前中后三种策略的全部差异。
命名里的"前、中、后"指的不是时间先后,而是根的位置。记住这一点,比背十遍口诀都管用。
为了更直观,这里提前放出一个小型样例树,后文所有计算和验证都用它:
1 / \ 2 3 / \ 4 5- 前序遍历结果:1 2 4 5 3
- 中序遍历结果:4 2 5 1 3
- 后序遍历结果:4 5 2 3 1
现在先不用管怎么算出来的,后面第3部分我会带着你一步步推一遍。你需要先有的概念是:三种遍历只是"访问根节点"这个动作在时序上挪了个位置,左子树和右子树的内部顺序始终保持先左后右,不会颠倒。
2. 三种遍历的核心原理与代码模板
2.1 递归函数的三要素,一个都不能少
写任何递归函数,本质上都在回答三个问题:
- 终止条件是什么?
- 递归调用怎么缩小问题规模?
- 当前这一层要做什么处理?
对应到二叉树递归遍历,这三个问题分别变成:
- 终止条件:当前节点为空,直接返回。这就是递归出口。
- 缩小问题规模:把节点换成它的左孩子和右孩子,子树规模逐层减小。
- 当前层处理:访问(打印、收集、计数)当前根节点,或者什么都不做。
其中最容易出问题的就是第一条。我见过很多初学者第一反应是判断"左右孩子是否为null",然后写出一堆if嵌套,又乱又容易漏。正确且优雅的做法,是直接在函数入口统一判断当前节点是否为null。
if (root == null) { return; }这一句是所有二叉树递归遍历的灵魂。只要保证每个递归调用进来都先检查空指针,左右子树的递归调用根本不需要额外判断。
举一个反面例子。如果你写成这样:
void preorder(TreeNode root) { System.out.println(root.val); // 没判空,直接崩 preorder(root.left); preorder(root.right); }只要遍历到叶子节点的左右子树,root为null,立刻就会抛出空指针异常。所以"先判空,再处理"这个顺序,必须牢牢焊死在脑子里。
2.2 三行代码的排列组合:位置决定遍历方式
很多教材会把三种遍历写成三个不同的函数,看起来像是三套方法。其实它们的骨架完全相同,唯一差别是"访问根节点的语句"放在哪里。
我用Java写一个完全体的模板,你对比看就明白了:
// 前序遍历:根 → 左 → 右 void preorder(TreeNode root) { if (root == null) return; System.out.print(root.val + " "); // 处理根 preorder(root.left); // 再左 preorder(root.right); // 最后右 } // 中序遍历:左 → 根 → 右 void inorder(TreeNode root) { if (root == null) return; inorder(root.left); // 先左 System.out.print(root.val + " "); // 处理根 inorder(root.right); // 再右 } // 后序遍历:左 → 右 → 根 void postorder(TreeNode root) { if (root == null) return; postorder(root.left); // 先左 postorder(root.right); // 再右 System.out.print(root.val + " "); // 最后处理根 }看到了吧,三段代码的差别,就是那句System.out.println的位置不同。这也是为什么我强烈建议你用一个统一的处理动作来理解,比如"访问节点"这个动作本身,而不是死记硬背整段代码。
如果你用Python刷题,逻辑完全一样,只是写法更紧凑些:
def preorder(root): if not root: return print(root.val, end=' ') preorder(root.left) preorder(root.right) def inorder(root): if not root: return inorder(root.left) print(root.val, end=' ') inorder(root.right) def postorder(root): if not root: return postorder(root.left) postorder(root.right) print(root.val, end=' ')Python版本里的if not root等价于Java里的if (root == null),后面我会一直以Java为主讲解,但思路完全通用。
这里还有一个常见误解值得拿出来说。很多人觉得"左子树、右子树也是递归调用,那代码到底哪部分是先执行的"。记住一点就够了:递归调用是一个整体动作,进入左子树的递归,会先完整遍历完左子树内部的所有节点,才会返回,然后才执行下一步。所以前序遍历里,preorder(root.left)这一行一旦执行,它会一口气把左子树全部打印完,再回到当前函数继续执行preorder(root.right)。理解这个"一口气跑完"的语义,是读懂三种遍历结果的关键。
2.3 为什么中序遍历在搜索二叉树里等于"从小到大"
搜索二叉树(也叫二叉搜索树、BST)有一条铁律:任一节点的左子树所有值都小于它,右子树所有值都大于它。
把这条性质和"左根右"的顺序放在一起看,会发现一个极其优雅的结果:
- 先遍历左子树 → 拿到的是比根小的所有值,而且它们内部依然满足左小右大的规则,所以输出仍然是升序
- 再访问根节点 → 根的值比左子树所有值都大
- 最后遍历右子树 → 拿到的是比根大的所有值,同样内部升序
所以对搜索二叉树做中序遍历,输出的就是一个严格递增的序列。
这个性质应用极广:判断一棵树是不是搜索二叉树,最朴素的思路之一就是中序遍历,然后检查结果是否单调递增;找第k小元素,中序遍历到第k个节点就是答案;二叉搜索树转成有序链表,中序遍历就是天然骨架。
正因为中序遍历实用频率太高,教材里还会提到"中序线索二叉树"这种优化方向——它利用节点中空闲的左、右指针,把中序遍历中的前驱和后继节点串起来,让遍历过程不再依赖递归栈。这条知识线现在不用深挖,你只要知道中序在BST场景下的特殊价值,后续学线索化时会轻松很多。
顺带说一句,前序和后序遍历也有它们的经典用途。前序遍历常用于复制二叉树、序列化树结构,因为根在前,重建结构时方便;后序遍历则常用于删除二叉树、自底向上计算,比如计算整棵树的高度,本质上就是一个后序过程。这些场景背后的原因其实都指向同一个点:遍历顺序决定了处理的先后时机。
3. 完整代码实现与手算验证
3.1 一个可以直接跑起来的完整示例
光看模板还不够,我直接给一个可以在本地运行的最小demo。包含二叉树节点定义、三种遍历、main方法构造样例树并打印结果。
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } } public class TreeTraversalDemo { // 前序遍历:根 左 右 static void preorder(TreeNode root) { if (root == null) return; System.out.print(root.val + " "); preorder(root.left); preorder(root.right); } // 中序遍历:左 根 右 static void inorder(TreeNode root) { if (root == null) return; inorder(root.left); System.out.print(root.val + " "); inorder(root.right); } // 后序遍历:左 右 根 static void postorder(TreeNode root) { if (root == null) return; postorder(root.left); postorder(root.right); System.out.print(root.val + " "); } public static void main(String[] args) { // 构造样例树 TreeNode n1 = new TreeNode(1); TreeNode n2 = new TreeNode(2); TreeNode n3 = new TreeNode(3); TreeNode n4 = new TreeNode(4); TreeNode n5 = new TreeNode(5); n1.left = n2; n1.right = n3; n2.left = n4; n2.right = n5; System.out.print("前序遍历: "); preorder(n1); System.out.println(); System.out.print("中序遍历: "); inorder(n1); System.out.println(); System.out.print("后序遍历: "); postorder(n1); System.out.println(); } }运行输出:
前序遍历: 1 2 4 5 3 中序遍历: 4 2 5 1 3 后序遍历: 4 5 2 3 1先别急着说"这不就是答案吗",建议你在自己的IDE里亲手敲一遍。敲的过程会逼你注意每个括号的位置、每行递归调用的顺序,这些细节比你想象中重要得多。另外我特意在类名里用了TreeTraversalDemo,没有用常见的Main,目的是鼓励你直接新建文件运行,而不是Ctrl+C/Ctrl+V完事。
3.2 递归调用栈是如何一步步展开的
理解代码运行过程,最有效的方法是跟着递归调用栈走一遍。我们取前序遍历为例,分析它访问第一个节点1时的完整路径。
调用preorder(n1)后,执行流程如下:
preorder(1):root非空,打印1;然后调用preorder(2)。注意此刻preorder(1)还没有执行完,它只是执行到了调用左子树的这一行,整个函数被挂在栈上等待。preorder(2):打印2;然后调用preorder(4)。preorder(4):打印4;调用preorder(null)。preorder(null):root为null,直接return,回到上一层。- 回到
preorder(4),继续执行preorder(null)(右子树),return。 preorder(4)执行完毕,整体返回preorder(2)。preorder(2)继续执行preorder(5),打印5,再分别访问左右空子树,返回。preorder(2)执行完毕,整体返回preorder(1)。preorder(1)继续执行preorder(3),打印3,然后访问空子树,返回。- 整个遍历结束。
每一步调用都对应栈上压入一层函数帧,返回时再弹出。这个过程中,"当前执行到哪一行"一直是靠栈帧记录的,这也是为什么递归天然就是后进先出的结构。
可能你会问,如果树的深度特别深,比如一万层、十万层,这个栈会不会爆?答案是会。每次递归调用都要占用调用栈空间,深度达到一定级别就会出现StackOverflowError。这也是大量实际业务场景中要避免深层递归的原因,特别是在树退化成链表的时候,问题会尤其明显。这部分放到第4部分的运行时错误里详细说。
3.3 手算一遍三种遍历结果
跟着代码跑一遍之后,我们再回到那张样例树,用纯手工方式推导一遍三种遍历结果。这个练习对于建立直觉非常重要,你不需要依赖编译器,一张纸一支笔就能完成。
先回顾树的形状:
1 / \ 2 3 / \ 4 5前序遍历,规则是根左右
- 访问根节点
1 - 进入左子树(以
2为根),按整棵子树的逻辑继续:访问根2,再进入它的左子树 - 左子树根是
4:访问4,它的左右子树为空,结束 - 回到节点
2的右子树(以5为根):访问5,结束 - 节点
2整棵子树遍历完毕,回到根节点1 - 进入右子树(以
3为根):访问3,结束
最终结果:1 2 4 5 3
中序遍历,规则是左根右
- 进入根节点
1的左子树(以2为根) - 继续进入节点
2的左子树(以4为根) - 节点
4没有左子树,于是访问4 - 节点
4右子树为空,返回节点2 - 访问
2 - 进入节点
2的右子树(以5为根),节点5没有左子树,访问5 - 节点
2的整棵子树完成,返回根节点1 - 访问
1 - 进入右子树(以
3为根),节点3没有左子树,访问3
最终结果:4 2 5 1 3
后序遍历,规则是左右根
- 进入根节点
1的左子树(以2为根) - 进入节点
2的左子树(以4为根),节点4的左右子树都为空,访问4 - 进入节点
2的右子树(以5为根),访问5 - 返回节点
2,访问2 - 返回根节点
1的右子树(以3为根),节点3左右子树为空,访问3 - 最后返回根节点
1,访问1
最终结果:4 5 2 3 1
你可能会注意到,后序遍历的最后一个节点一定是整棵树的根,而前序遍历的第一个节点一定是整棵树的根。这个性质后面学习"根据中序+前序/后序重建二叉树"时会派上大用场。类似的规律还包括:中序遍历中,根节点把左右子树的结果分在两边……这些现在先不用背,等做题遇到自然就熟了。
3.4 复杂度分析和二叉树深度的关系
三种递归遍历的时间复杂度都是O(n),因为每个节点恰好被访问一次。空间复杂度看起来是O(1),但别忘了递归调用栈本身要占空间,所以严格来说是O(h),h是二叉树高度。
- 平衡二叉树:h ≈ log₂(n+1),空间复杂度O(log n)
- 退化链表树:h = n,空间复杂度O(n),最坏情况
空间复杂度被递归栈吃掉这件事,是很多人初学时忽略、实际面试中常被追问的点。顺着"高度"这个问题,再扩展一下:递归遍历和二叉树深度其实关系密切。计算一棵树深度,天然就是一个后序遍历思路——先算左子树深度,再算右子树深度,然后取最大值加1。
static int maxDepth(TreeNode root) { if (root == null) return 0; int leftDepth = maxDepth(root.left); int rightDepth = maxDepth(root.right); return Math.max(leftDepth, rightDepth) + 1; }这个函数把"后序遍历"用得非常典型:左右子树的结果都有了,最后才处理根。我建议你把这段代码和前面的后序遍历对照着看,能明显感觉到"处理时机"的威力。计算深度是面试高频题,同时也是解决很多树形DP问题的原型。
另外一个容易被忽略的点是内存占用。递归遍历隐藏着一个隐患:每次函数调用都生成栈帧,栈帧里保存参数、局部变量、返回地址。对几万层的树来说,栈内存消耗非常可观,甚至可能触发栈溢出。这也是为什么一些对性能和稳定性要求高的项目,会选择用显式栈或Morris遍历来替代递归。
4. 常见问题与排查技巧实录
4.1 "为什么总是报运行时错误":三大典型原因
很多人在刷题平台写二叉树代码时,反复遇到运行时错误,而错误类型就那么几种。我按出现频率排一下,几乎都是这些坑。
第一类:空指针异常。访问了null节点的属性,最常见就是开头忘记判空。这类错误在遍历到叶子节点时必然触发,因为叶子节点的左右孩子都是null。典型报错信息是NullPointerException或AttributeError。解决办法就是牢记递归函数的出口:进来先判断root == null再返回。
第二类:栈溢出。也就是StackOverflowError。一个经典触发场景是你构造了一棵极度不平衡的树,比如一棵纯粹的链表式树。另一种场景是递归终止条件写错,比如判断写成了root.left == null才返回,递归永远走不到出口。排查时先打印树的深度看看,如果树深超过了递归栈能力,就要考虑改用迭代方式实现遍历。
第三类:结果莫名不对,但其实不是运行时错误。比如左子树和右子树递归调用写反了,输出顺序看起来不符合任何一种遍历。这种错误跑起来很顺畅,但结果就是不对,是逻辑错误中最常见的。写代码时养成习惯:先左后右,永远先左后右。顺便说一句,构建测试数据时也要小心,很多人写n1.left = n2; n1.right = n3;时把n2和n3搞反,树本身就不对,遍历结果自然全乱。
我把排查这类问题的通用思路整理成一个简单流程:
- 先看报错类型。
NullPointerException就是访问了不该访问的null,StackOverflowError就是递归没出口或树太高。 - 缩小测试范围。用一个只有两三个节点的小树,逐步打印每一步的节点值,看崩溃发生在哪个节点。
- 手画一遍自己的样例树。很多时候报错是因为测试数据构造错了,不是算法错了。
- 实在看不出问题,把三处递归调用全打印出来,对照手算结果。
4.2 空树、单节点、退化链:三个必须跑的测试用例
经验丰富的开发者在写完遍历代码后,一定会跑三个基础测试。这三个用例专门用来暴露最常见的设计漏洞。
第一个是空树。直接传入null,如果代码没有在最前面判空,立刻崩。正确的代码应该什么都不打印,安全返回。这个用例是递归出口最直接的检测。
第二个是单节点树。只有一个根节点,三种遍历都应该只输出该节点本身。这个用例能检查你有没有因为左右子树为空而产生多余输出。
第三个是退化成链表的树。比如每个节点只有左孩子,一路到底。这个用例除了验证输出正确性,还能提前暴露出递归深度过深的问题。如果你在极端链上遇到栈溢出,就得认真考虑是否该换非递归方案。
我建议你把这三个用例作为二叉树代码的"最小回归测试集",每写完一个遍历函数,立刻跑一遍。这套习惯能帮你省下大量在调试器里苦找bug的时间。
4.3 层序遍历和前序遍历到底差在哪里
网络热词里出现了"层序遍历和前序遍历",这两个概念经常被放在一起比较,但它们的思路完全不同。
前序遍历是深度优先遍历。它沿着一条路径走到黑,再回头走另一条路径。递归调用天然是深度优先,因为每次都会优先深入左子树。它的特点是"先到先出"——先访问根,再深入,也就是所谓的DFS思想。
层序遍历是广度优先遍历。它一层一层从上往下扫描,先访问根,然后把根的孩子放进队列,再逐个取出队列里的节点,并把它们的孩子再放进去。这种"先进先出"的访问方式就是典型的BFS思想。
用同一个样例树对比最直观。树的形状还是:
1 / \ 2 3 / \ 4 5- 前序遍历:1 2 4 5 3
- 层序遍历:1 2 3 4 5
看到没,前序在打印1之后立刻深入到了2和4,而层序则是一行一行往下读。用队列实现层序的核心代码长这样:
static void levelOrder(TreeNode root) { if (root == null) return; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { TreeNode node = queue.poll(); System.out.print(node.val + " "); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } }两种遍历各有适用场景。层序遍历适合"按层"处理的问题,比如求每层最大值、打印二叉树的层状结构;前序遍历适合需要"深度优先"处理的问题,比如序列化、复制树。搞清楚DFS和BFS的区别,很多中等难度的树题目都能迎刃而解。
4.4 记忆口诀与调试技巧
最后分享几个我实践中觉得真正有用的习惯,而不是干巴巴的结论。
记忆上,最常见的口诀就是"根左右、左根右、左右根"。但有前提:你得知道这里的"根"指的是"处理当前节点"这个动作,而不是真的以为根节点物理位置在中、在左、在右。把这个含义理解透了,口诀才是加速度,否则只是死记。
调试技巧上,强烈推荐"缩进打印法"。在三个遍历函数里,给每次递归传一个depth参数,打印节点前先输出对应数量的缩进空格。这样运行结果会展示出递归调用的层级结构,一眼就能看出遍历路径是否正确。示例如下:
static void preorderWithDepth(TreeNode root, int depth) { if (root == null) return; for (int i = 0; i < depth; i++) System.out.print(" "); System.out.println(root.val); preorderWithDepth(root.left, depth + 1); preorderWithDepth(root.right, depth + 1); }跑这个函数,你能直接在控制台看到类似这样的结构:
1 2 4 5 3这就是前序递归的真实展开过程,比任何脑内模拟都直观。遇到树相关的问题,我几乎都会先写个带缩进的调试版,确认遍历路径无误,再改成正式逻辑。
还有一个排查技巧:在递归函数里临时加一行输出,专门打印"进入节点"和"离开节点"两个位置。这能看到递归是先一路向下、再逐步回退的过程,对理解栈帧很有帮助。调试器里断点设多了容易乱,打印反而更快。
把常踩的坑整理成一张速查表,方便你快速定位问题:
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 还没访问就抛空指针 | 函数入口没判空 | 第一行补if (root == null) return; |
| 遍历到一半崩溃 | 某个节点指针指向错误 | 检查建树代码和测试数据 |
| 输出顺序奇怪 | 左右递归调用写反 | 统一“先左后右”的顺序 |
| 栈溢出报错 | 树太高或出口错误 | 检查终止条件,考虑迭代实现 |
| 单节点树输出多条 | 出口条件写错 | 确认 null 判断覆盖空子树 |
这些坑我都踩过,尤其是刚上手那段时间,“先判空再访问”这个习惯没养成,空指针异常几乎是每日必修课。后来我把递归函数的三要素当成检查清单,每次写完先自查一遍,再跑最小测试,出错率直线下降。
把前、中、后序递归遍历彻底吃透之后,你会发现自己看二叉树相关的后续内容都轻松了不少:迭代遍历、线索二叉树、Morris遍历、树的序列化与反序列化,底层都是同一个"访问时机"问题。我个人体会是,这几种遍历不要死记硬背,而是找一棵小一点的树,拿纸笔把递归过程完整画一遍,画完自然就通了。这个方法帮助过不少人,你要是卡在理解上,不妨也从手画开始。