深度优先遍历:前序、中序、后序与递归迭代统一框架

摘要

二叉树专题真正的起点不是“会背三种遍历顺序”,而是理解:递归函数进入一个节点时,究竟有哪三个天然的处理时机。LeetCode 144、94、145 看似只是基础题,实际上决定了后面所有树题的思考方式。本文从递归语义出发,统一讲解前序、中序、后序三种遍历,并扩展到显式栈迭代写法,建立一套可以迁移到 二叉搜索树、树形 DP、路径问题的 DFS 框架。


第 1 章 为什么遍历顺序不是死记硬背

1.1 一个节点天然有三个处理时机

对于任意节点 root,递归函数的骨架天然是:

void dfs(TreeNode root) {
    if (root == null) return;
    // 时机 A:进入左子树之前
    dfs(root.left);
    // 时机 B:左子树处理完,右子树开始前
    dfs(root.right);
    // 时机 C:左右子树都处理完之后
}

这三个时机对应:

顺序访问节点的时机典型用途
前序进入左子树前复制树、序列化、记录路径前缀
中序左子树后右子树前二叉搜索树 有序遍历
后序左右子树都结束后汇总左右子树信息、删除树、树形 DP

所以遍历顺序的本质不是“三个词”,而是“节点处理位置”。只要这个语义想清楚,后面很多题都不再需要记模板。

1.2 为什么树题天然适合递归

因为树本身就是递归定义的:一棵树由根节点、左子树、右子树组成。你在根节点看到的结构,和你在左子树、右子树里看到的结构是同构的。换句话说,同一个函数既能解决整棵树,也能解决任意子树。

递归的关键不是“自己调自己”

真正关键是:函数的输入和输出,在子问题上保持同样语义。只要这个语义稳定,递归就成立。


第 2 章 三种递归遍历

2.1 LeetCode 144:前序遍历

前序顺序是:根、左、右。

public List<Integer> preorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    dfs(root, result);
    return result;
}
 
private void dfs(TreeNode node, List<Integer> result) {
    if (node == null) {
        return;
    }
    result.add(node.val);
    dfs(node.left, result);
    dfs(node.right, result);
}

前序最适合“先处理当前节点,再把状态带给子节点”的场景,例如:

  • 记录根到当前节点的路径前缀
  • 构造树的序列化字符串
  • 复制当前节点并继续复制左右子树

2.2 LeetCode 94:中序遍历

中序顺序是:左、根、右。

public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    dfs(root, result);
    return result;
}
 
private void dfs(TreeNode node, List<Integer> result) {
    if (node == null) {
        return;
    }
    dfs(node.left, result);
    result.add(node.val);
    dfs(node.right, result);
}

中序最重要的价值在于:对 二叉搜索树 来说,它会产生严格递增序列。Validate BST、Recover BST、本专栏后面的大量搜索树题都依赖这个性质。

2.3 LeetCode 145:后序遍历

后序顺序是:左、右、根。

public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    dfs(root, result);
    return result;
}
 
private void dfs(TreeNode node, List<Integer> result) {
    if (node == null) {
        return;
    }
    dfs(node.left, result);
    dfs(node.right, result);
    result.add(node.val);
}

后序最适合“当前节点的答案依赖左右子树答案”的场景,比如:


第 3 章 迭代写法为什么容易乱

3.1 递归的本质是系统栈

递归并不神秘,它只是把“下一步去左子树还是右子树、回来后该做什么”这些控制流信息交给系统调用栈保存。改写成迭代,本质上就是你自己维护这个栈。

3.2 前序遍历的迭代写法

前序的逻辑最自然:弹出节点就访问,然后把右孩子、左孩子依次压栈。之所以先压右再压左,是因为栈后进先出,保证左子树先处理。

public List<Integer> preorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if (root == null) {
        return result;
    }
 
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
 
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        result.add(node.val);
 
        if (node.right != null) {
            stack.push(node.right);
        }
        if (node.left != null) {
            stack.push(node.left);
        }
    }
    return result;
}

3.3 中序遍历的迭代写法

中序的难点是:节点不能在第一次遇到时立刻访问,必须一路向左下钻,直到左子树为空,再开始回退。

public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode current = root;
 
    while (current != null || !stack.isEmpty()) {
        while (current != null) {
            stack.push(current);
            current = current.left;
        }
 
        current = stack.pop();
        result.add(current.val);
        current = current.right;
    }
    return result;
}

这里的核心不是代码,而是状态语义:

  • current 负责持续向左推进
  • stack 保存“还没访问,但左边已经处理完后需要回来访问的节点”

3.4 后序遍历的迭代写法

后序最难,因为节点必须在左右子树都处理后才能访问。常见写法有两种:

  1. 双栈法:一个栈做遍历,一个栈反转输出
  2. 单栈 + prev 指针:判断右子树是否访问过

面试里推荐先讲双栈法,逻辑更直白。

public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if (root == null) {
        return result;
    }
 
    Deque<TreeNode> stack1 = new ArrayDeque<>();
    Deque<TreeNode> stack2 = new ArrayDeque<>();
    stack1.push(root);
 
    while (!stack1.isEmpty()) {
        TreeNode node = stack1.pop();
        stack2.push(node);
        if (node.left != null) {
            stack1.push(node.left);
        }
        if (node.right != null) {
            stack1.push(node.right);
        }
    }
 
    while (!stack2.isEmpty()) {
        result.add(stack2.pop().val);
    }
    return result;
}

为什么可行?因为 根-右-左 反过来正好就是 左-右-根


第 4 章 一个统一视角:递归函数到底该定义什么

很多人学树题越学越乱,是因为把“遍历顺序”和“函数返回值”混为一谈。更稳的方式是先问两个问题:

  1. 这道题要求我在遍历过程中收集什么
  2. 当前函数要不要给父节点返回信息

如果只是遍历收集序列,返回值可以是 void。 如果父节点需要子树高度、子树是否合法、对子树的最大贡献值,那返回值就必须承载这些信息。

遍历顺序只决定“什么时候处理当前节点”

函数返回值决定“你要把什么带回给父节点”。 这是所有树题最重要的一层抽象。


第 5 章 与后续专题的连接

5.1 前序常见延伸

5.2 中序常见延伸

5.3 后序常见延伸

这也是为什么,尽管 144/94/145 很基础,它们却是后续所有树题的模板库。


下一篇预告

02 广度优先遍历:层序、倒序层序与锯齿层序 会把视角从“沿深度向下钻”切到“按层横向扫描”。你会看到,队列在树题里真正保存的不是节点本身,而是“当前层边界尚未处理完的工作集”。