返回文章列表
数据结构与算法
算法二叉树遍历

二叉树遍历:递归、迭代与层序

递归三要素:先把话说清楚,再写代码

前序、中序、后序的区别只在"访问根节点"的时机:前序(根左右)、中序(左根右)、后序(左右根)。不管哪种顺序,递归函数都可以按三要素展开:

  1. 参数与返回值:传入当前节点和必要的状态,返回值视需求而定(遍历类题目通常没有返回值,用成员变量收集结果);
  2. 终止条件:cur == null 就返回——这是唯一的天然终止条件,别的判断都是画蛇添足;
  3. 单层逻辑:访问当前节点 + 递归左孩子 + 递归右孩子,三行的排列顺序就是遍历顺序。
// 前序遍历:根 → 左 → 右
public void preorder(TreeNode cur, List<Integer> res) {
    if (cur == null) return;        // 终止条件:走到空
    res.add(cur.val);               // ① 访问根
    preorder(cur.left, res);        // ② 递归左子树
    preorder(cur.right, res);       // ③ 递归右子树
}
// 中序:②①③ 交换两行位置;后序:②③① 再交换

所谓"三行代码",就是把单层逻辑的三行按访问时机摆位:中序把 res.add(cur.val) 挪到两次递归中间,后序把它挪到最后。逻辑一行不多一行不少,这就是二叉树递归的全部秘密。

迭代实现:把调用栈显式搬出来

递归的本质是隐式调用栈。迭代写法就是自己用 Deque 模拟这个过程,好处是面试常考、且能处理极深树时的栈溢出隐患。

各写法的思路(前序最容易):根先入栈,弹出访问,再压右孩子、左孩子(注意顺序相反,因为栈是后进先出)。中序则需要"一路向左压栈,走不动了弹出访问,再转向右子树"。后序最绕,常见技巧是按"根右左"的前序变体收集后反转。

但三种顺序各背一套模板太累,推荐统一迭代法:往栈里压入 null 作为访问标记。要处理某个节点时,先压一个 null(表示"轮到访问它了"),再压它的左右孩子(还没轮到):

// 统一迭代:中序遍历,前序/后序只改标记和孩子的入栈顺序
public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> res = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    if (root != null) stack.push(root);
 
    while (!stack.isEmpty()) {
        TreeNode node = stack.peek();
        if (node != null) {
            stack.pop();                     // 先把节点弹掉,再按序安排
            if (node.right != null) stack.push(node.right); // 中序:右最后
            stack.push(node);                // 节点自己重新入栈
            stack.push(null);                // null 标记:下次见到就访问
            if (node.left != null) stack.push(node.left);   // 中序:左最先
        } else {
            stack.pop();                     // 弹出 null 标记
            TreeNode cur = stack.pop();      // 紧随其后的节点正是该访问的
            res.add(cur.val);                // 此处是唯一的"访问"动作
        }
    }
    return res;
}

改成前序:把 null 标记压在节点左孩子入栈之后(即"根 → 左 → 右"的安排顺序里,null 紧跟根);改成后序:null 紧跟在右孩子入栈之前、且左右孩子入栈顺序对调。核心记忆点:标记 null 放在谁后面,谁就是下一个被访问的。三种遍历从此只记一套代码。

层序遍历:BFS 模板逐行注释

层序遍历换了一副骨架:用队列(FIFO 保序)而不是栈。按层处理,每轮循环先记下当前队列的长度——这个长度就是"这一层的节点数":

public List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> res = new ArrayList<>();
    if (root == null) return res;            // 空树直接返回,别让 null 进队
 
    Deque<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);                       // 根节点作为第 1 层唯一成员
 
    while (!queue.isEmpty()) {
        int size = queue.size();             // 固定!此时队列里恰好是整层节点
        List<Integer> level = new ArrayList<>();
        for (int i = 0; i < size; i++) {     // 只处理 size 个,不碰刚入队的下一层
            TreeNode cur = queue.poll();     // 队头出队
            level.add(cur.val);              // 收集本层节点值
            if (cur.left != null) queue.offer(cur.left);   // 左孩子进下一层
            if (cur.right != null) queue.offer(cur.right); // 右孩子进下一层
        }
        res.add(level);                      // 本层完成,挂到结果上
    }
    return res;
}

模板的灵魂只有一处:int size = queue.size() 必须在 for 循环外取。若写在循环条件里,offer 会不断改变队列长度,层的边界就糊了。这行代码把"整层"与"下一层"切开,是所有层序题的通用骨架。

衍生题:只改收集逻辑

102 的模板学会后,一批题目不需要新算法,只需要改"每层结束时怎么用这一层数据":

题号 题名 改动点
107 自底向上的层序遍历 收集完 res.add(0, level)(或最后整体反转)
199 二叉树的右视图 只取本层最后一个节点
637 每层的平均值 对 level 求和除以 size
429 N 叉树的层序遍历 for 循环遍历 children 数组入队

无一例外都是"层序模板 + 几行收集逻辑",这正说明模板学一遍就够,变化只在皮毛。

遍历是绝大多数树题的骨架

把话说透:二叉树专题里八成以上的题目,剥掉包装后就是某一种遍历。

题目 剥开后的内核
226 翻转二叉树 前序(先交换左右孩子,再递归两边)
257 所有路径 前序(路径随根往下带,回溯撤销)
530 BST 最小绝对差 中序(有序序列上比较相邻差)
104 最大深度 后序(左右子树高度取大 +1)
199 右视图 层序(取每层最后一个)
404 左叶子之和 混合(父节点视角判断左叶子,可前可后)

所以判断一道树题从哪入手,先问两个问题:

  1. 我需要在访问某节点时顺手做事(前中后序,选递归/迭代),还是在处理完一层后做事(层序,BFS)?
  2. 我需要的信息在孩子处理完之后才有(后序,自底向上),还是访问节点时就已足够(前中序,自顶向下)?

这两个问题想清楚,遍历顺序自动确定,剩下的只是把单层逻辑填进去。递归写法练熟后,大多数题 10 行以内结束战斗。

LeetCode 题单

题号 题名 一句话考点
144/94/145 前序/中序/后序遍历 递归三行 vs 迭代栈 vs 统一标记法
102 二叉树的层序遍历 BFS 模板,size 固定层的边界
107 自底向上层序 每层插到结果头部
199 二叉树的右视图 取每层末节点
637 每层平均值 求和除以 size,注意用 double
429 N 叉树层序 children 循环入队
226 翻转二叉树 前序换孩子递归
589/590 N 叉树前序/后序 递归框架照搬,孩子列表展开

参考