二叉树遍历是面试和算法里非常高频的一类基础题。
表面上看只是三种遍历顺序:
- 前序:根 -> 左 -> 右
- 中序:左 -> 根 -> 右
- 后序:左 -> 右 -> 根
但真正关键的,不只是把代码写出来,而是要理解:
- 递归写法为什么天然好写
- 非递归写法到底在模拟什么
- 为什么不同遍历的非递归写法差异这么大
这篇文章就把前序、中序、后序遍历,按照 递归思路 + 非递归思路 + 代码实现 的方式,系统整理一遍。
一、为什么递归遍历天然适合二叉树
二叉树本身就是一种递归结构。
因为每个节点下面,左孩子本身又是一棵树,右孩子本身也又是一棵树。
所以遍历二叉树,本质上就是在做一件事:
对当前节点处理一次,再按某种顺序去处理左子树和右子树。
因此递归写法会非常自然:
- 先定义当前节点该做什么
- 再递归处理左子树
- 再递归处理右子树
区别只在于:访问根节点这一步,放在左子树递归前、两次递归中间,还是两次递归后。
二、前序遍历
前序遍历顺序:
根 -> 左 -> 右
也就是说,每到一个节点,先访问当前节点,再处理左子树,最后处理右子树。
1. 前序遍历递归
思路
递归思路非常直接:
- 先输出根节点
- 再递归左子树
- 再递归右子树
因为前序遍历要求“根最先访问”,所以访问语句放在最前面。
代码
public static void preOrder(TreeNode root){
if(root == null) return;
System.out.print(root.val);
preOrder(root.left);
preOrder(root.right);
}
理解重点
这段代码的核心不是背,而是要理解它和前序定义一一对应:
System.out.print(root.val);对应 根preOrder(root.left);对应 左preOrder(root.right);对应 右
所以递归写法本质上就是:把遍历定义直接翻译成代码。
2. 前序遍历非递归
思路
非递归的核心,是用栈来模拟递归调用过程。
前序顺序是 根 -> 左 -> 右。
因为栈是 后进先出,所以如果想让左子树先处理,就必须:
- 先压右子节点
- 再压左子节点
这样出栈时,左节点会先出来。
所以整体流程就是:
- 根节点先入栈
- 每次弹出栈顶节点,立即访问
- 先压右子节点,再压左子节点
- 循环直到栈为空
这其实是在模拟:
访问根后,优先进入左子树;左子树处理完后,再处理右子树。
代码
public static void dequePreOrder(TreeNode root){
if(root == null) return;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while(!stack.isEmpty()){
TreeNode node = stack.pop();
System.out.print(node.val);
if(node.right != null) stack.push(node.right);
if(node.left != null) stack.push(node.left);
}
}
理解重点
这里绝对不能写成“先压左再压右”,这是错的。
原因就在于:
- 栈是后进先出
- 想先处理左,左就得后压
所以一定是:
if(node.right != null) stack.push(node.right);
if(node.left != null) stack.push(node.left);
前序非递归本质
前序非递归其实是三种非递归遍历里最好理解的一种。
因为它的特点是:
节点一弹出就能访问,不需要等。
这也是为什么前序非递归写起来最顺。
三、中序遍历
中序遍历顺序:
左 -> 根 -> 右
也就是说,必须先一路处理到最左边,回退时再访问根节点,然后再去处理右子树。
1. 中序遍历递归
思路
递归思路也非常直观:
- 先递归左子树
- 再访问根节点
- 最后递归右子树
代码
public static void inOrder(TreeNode root){
if(root == null) return;
inOrder(root.left);
System.out.print(root.val);
inOrder(root.right);
}
理解重点
和前序一样,本质仍然是把定义翻译成代码:
inOrder(root.left);对应 左System.out.print(root.val);对应 根inOrder(root.right);对应 右
2. 中序遍历非递归
思路
中序非递归比前序难一点,因为不能“节点一出来就访问”。
中序要求的是:
先把左边全部走到底,走不动了,才能访问当前节点。
所以这里的栈,不是简单地“存待处理节点”,而是在模拟递归调用链。
具体过程:
- 从当前节点开始,一路向左,把沿途节点全部压栈
- 左边走到底后,弹出栈顶节点并访问
- 访问完后,转去处理它的右子树
- 对右子树重复上述过程
这模拟的正是递归里的:
先递归到最左,再回退访问根,再转向右子树。
代码
public static void dequeInOrder(TreeNode root){
if(root == null) return;
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
while(cur != null || !stack.isEmpty()){
while(cur != null){
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
System.out.print(cur.val);
cur = cur.right;
}
}
理解重点
这里有两个 while:
第一个 while(cur != null)
作用是:
一路向左走,把当前路径上的节点全部压进去。
因为中序要先处理左子树,所以不能一开始就访问节点,而是先压栈等待。
第二个外层 while(cur != null || !stack.isEmpty())
作用是:
只要当前节点还有路可走,或者栈里还有没处理完的祖先节点,遍历就要继续。
为什么访问完后要 cur = cur.right
因为中序顺序是:
左处理完 -> 访问根 -> 再去右
所以弹出并访问当前节点后,下一步不是回头,而是转到它的右子树继续重复同样过程。
中序非递归本质
中序非递归的关键就一句话:
先一路压左,弹出访问,再转右。
这句如果彻底理解了,中序非递归就不会乱。
四、后序遍历
后序遍历顺序:
左 -> 右 -> 根
它是三种遍历里最绕的一种。
因为根节点必须最后访问,也就是说:
只有当左子树和右子树都处理完后,当前节点才能输出。
这也是为什么后序非递归最容易写乱。
1. 后序遍历递归
思路
递归写法仍然最自然:
- 先递归左子树
- 再递归右子树
- 最后访问根节点
代码
public static void postOrder(TreeNode root){
if(root == null) return;
postOrder(root.left);
postOrder(root.right);
System.out.print(root.val);
}
理解重点
访问语句放在最后,正好对应后序定义里的“根最后”。
2. 后序遍历非递归写法一:单栈 + prev 指针
思路
后序最大的问题在于:
栈顶节点不能立刻访问,因为你不知道它的右子树处理完没有。
所以这里要做两件事:
- 先一路压左
- 查看栈顶节点时,判断它的右子树是否已经处理完
如果满足下面任意一种情况,就可以访问当前节点:
- 当前节点没有右子树
- 当前节点的右子树已经访问过了
这里就需要一个 prev 指针,记录“上一个访问完成的节点”。
这样当看到:
node.right == prev
就说明当前节点的右子树刚刚处理完,现在可以访问当前节点了。
代码
public static void dequePostOrder(TreeNode root){
if(root == null) return;
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
TreeNode prev = null;
while(cur != null || !stack.isEmpty()){
while(cur != null){
stack.push(cur);
cur = cur.left;
}
TreeNode node = stack.peek();
if(node.right == null || node.right == prev){
System.out.print(node.val);
stack.pop();
prev = node;
} else {
cur = node.right;
}
}
}
理解重点
这里最关键的是这句:
if(node.right == null || node.right == prev)
它表示:
- 没有右子树,可以直接访问
- 右子树已经处理完,也可以访问
否则就不能访问,因为后序要求“右也要先处理”。
这时要做的是:
cur = node.right;
转去处理右子树。
为什么要用 peek() 而不是 pop()
因为栈顶节点现在不一定能访问。
要先看一下它的右子树情况,确认能访问后才能真正弹出。
所以这里必须先 peek(),判断完后再决定要不要 pop()。
后序单栈写法本质
后序单栈的本质就是:
先压左,再看栈顶;右边没处理完就去右边,右边处理完了再访问根。
这是真正在模拟“左右都结束后才能访问根”。
3. 后序遍历非递归写法二:根右左 + 头插反转
如果说单栈 + prev 的写法更贴近“严格模拟递归”,
那第二种写法更像一种“巧解”。
思路
后序顺序是:
左 -> 右 -> 根
这个顺序不太适合直接用栈硬写。
但我们可以反过来想:
如果先得到:
根 -> 右 -> 左
然后再把结果反转,不就得到:
左 -> 右 -> 根
了么?
而“反转结果”这个动作,可以通过 LinkedList 的 addFirst() 头插法来完成。
所以流程变成:
- 栈先压根节点
- 每次弹出一个节点,把它头插进结果集
- 为了让弹出顺序变成 根 -> 右 -> 左,需要先压左,再压右
- 最终结果自然就是 左 -> 右 -> 根
代码
public List<Integer> postorderTraversal2(TreeNode root) {
LinkedList<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
res.addFirst(node.val); // 头插,相当于最后反转
if (node.left != null) {
stack.push(node.left);
}
if (node.right != null) {
stack.push(node.right);
}
}
return res;
}
理解重点
这里最容易写反的是压栈顺序。
因为我们想要弹出顺序是:
根 -> 右 -> 左
而栈是后进先出,所以必须:
- 先压左
- 再压右
这样右节点才会先弹出来。
所以这段代码中:
if (node.left != null) {
stack.push(node.left);
}
if (node.right != null) {
stack.push(node.right);
}
不能写反。
这种写法的优点
相比单栈 + prev,这种写法通常更容易记忆,也更适合 LeetCode 上直接写答案。
因为它避免了复杂的“右子树是否访问完成”的判断。
本质上是通过遍历顺序变换 + 结果逆置来间接得到后序。
五、三种遍历非递归写法的核心区别
很多人学遍历时,容易把代码记混。
本质原因是没有抓住三者的“访问时机”。
其实可以统一成一句话:
1. 前序:节点弹出时立刻访问
因为顺序是:
根 -> 左 -> 右
所以拿到节点就能先访问,不需要等。
2. 中序:左边走到底后,回退时访问
因为顺序是:
左 -> 根 -> 右
所以必须先一路向左压栈,等左边没了,才能访问当前节点。
3. 后序:左右都处理完后,才能访问
因为顺序是:
左 -> 右 -> 根
所以当前节点最晚访问,判断最复杂。
一句话总结三种遍历
前序
- 顺序:根左后
- 特点:节点弹出时立刻访问
- 非递归重点:先压右,再压左
中序
- 顺序:左根右
- 特点:先一路压左,回退时访问
- 非递归重点:弹出访问后转向右子树
后序
-
顺序:左右根
-
特点:左右处理完后才能访问根
-
非递归重点:
- 要么用
prev判断右子树是否处理完成 - 要么用“根右左 + 头插反转”技巧
- 要么用