二叉树前中后序遍历:递归与非递归彻底搞懂

86 阅读9分钟

二叉树遍历是面试和算法里非常高频的一类基础题。
表面上看只是三种遍历顺序:

  • 前序:根 -> 左 -> 右
  • 中序:左 -> 根 -> 右
  • 后序:左 -> 右 -> 根

但真正关键的,不只是把代码写出来,而是要理解:

  1. 递归写法为什么天然好写
  2. 非递归写法到底在模拟什么
  3. 为什么不同遍历的非递归写法差异这么大

这篇文章就把前序、中序、后序遍历,按照 递归思路 + 非递归思路 + 代码实现 的方式,系统整理一遍。


一、为什么递归遍历天然适合二叉树

二叉树本身就是一种递归结构。
因为每个节点下面,左孩子本身又是一棵树,右孩子本身也又是一棵树。

所以遍历二叉树,本质上就是在做一件事:

对当前节点处理一次,再按某种顺序去处理左子树和右子树。

因此递归写法会非常自然:

  • 先定义当前节点该做什么
  • 再递归处理左子树
  • 再递归处理右子树

区别只在于:访问根节点这一步,放在左子树递归前、两次递归中间,还是两次递归后。


二、前序遍历

前序遍历顺序:

根 -> 左 -> 右

也就是说,每到一个节点,先访问当前节点,再处理左子树,最后处理右子树。


1. 前序遍历递归

思路

递归思路非常直接:

  1. 先输出根节点
  2. 再递归左子树
  3. 再递归右子树

因为前序遍历要求“根最先访问”,所以访问语句放在最前面。

代码

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. 前序遍历非递归

思路

非递归的核心,是用栈来模拟递归调用过程

前序顺序是 根 -> 左 -> 右。
因为栈是 后进先出,所以如果想让左子树先处理,就必须:

  • 先压右子节点
  • 再压左子节点

这样出栈时,左节点会先出来。

所以整体流程就是:

  1. 根节点先入栈
  2. 每次弹出栈顶节点,立即访问
  3. 先压右子节点,再压左子节点
  4. 循环直到栈为空

这其实是在模拟:

访问根后,优先进入左子树;左子树处理完后,再处理右子树。

代码

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. 中序遍历递归

思路

递归思路也非常直观:

  1. 先递归左子树
  2. 再访问根节点
  3. 最后递归右子树

代码

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. 中序遍历非递归

思路

中序非递归比前序难一点,因为不能“节点一出来就访问”。

中序要求的是:

先把左边全部走到底,走不动了,才能访问当前节点。

所以这里的栈,不是简单地“存待处理节点”,而是在模拟递归调用链。

具体过程:

  1. 从当前节点开始,一路向左,把沿途节点全部压栈
  2. 左边走到底后,弹出栈顶节点并访问
  3. 访问完后,转去处理它的右子树
  4. 对右子树重复上述过程

这模拟的正是递归里的:

先递归到最左,再回退访问根,再转向右子树。

代码

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. 后序遍历递归

思路

递归写法仍然最自然:

  1. 先递归左子树
  2. 再递归右子树
  3. 最后访问根节点

代码

public static void postOrder(TreeNode root){
    if(root == null) return;
    postOrder(root.left);
    postOrder(root.right);
    System.out.print(root.val);
}

理解重点

访问语句放在最后,正好对应后序定义里的“根最后”。


2. 后序遍历非递归写法一:单栈 + prev 指针

思路

后序最大的问题在于:

栈顶节点不能立刻访问,因为你不知道它的右子树处理完没有。

所以这里要做两件事:

  1. 先一路压左
  2. 查看栈顶节点时,判断它的右子树是否已经处理完

如果满足下面任意一种情况,就可以访问当前节点:

  • 当前节点没有右子树
  • 当前节点的右子树已经访问过了

这里就需要一个 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 的写法更贴近“严格模拟递归”,
那第二种写法更像一种“巧解”。


思路

后序顺序是:

左 -> 右 -> 根

这个顺序不太适合直接用栈硬写。
但我们可以反过来想:

如果先得到:

根 -> 右 -> 左

然后再把结果反转,不就得到:

左 -> 右 -> 根

了么?

而“反转结果”这个动作,可以通过 LinkedListaddFirst() 头插法来完成。

所以流程变成:

  1. 栈先压根节点
  2. 每次弹出一个节点,把它头插进结果集
  3. 为了让弹出顺序变成 根 -> 右 -> 左,需要先压左,再压右
  4. 最终结果自然就是 左 -> 右 -> 根

代码

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 判断右子树是否处理完成
    • 要么用“根右左 + 头插反转”技巧