LeetCode 103. 二叉树的锯齿形层序遍历:解题思路+代码详解

0 阅读7分钟

在二叉树的层序遍历系列题目中,103题的锯齿形遍历算是一个“变种”——它没有遵循常规的“从左到右”逐层遍历,而是要求层与层之间交替方向,先左后右、再右后左,以此循环。今天我们就来拆解这道题,从思路分析到代码实现,再到易错点提醒,帮你彻底吃透这道经典面试题。

一、题目解读

题目要求:给定二叉树的根节点 root,返回其节点值的锯齿形层序遍历。所谓锯齿形,就是 奇数层(从0开始计数)从左往右遍历,偶数层从右往左遍历(或反之,核心是层与层方向交替)。

举个简单例子:

若二叉树为:

    3
   / \
  9  20
    /  \
   15   7

其锯齿形层序遍历结果为:[[3], [20,9], [15,7]]

解释:第0层(根节点)从左到右 → [3];第1层从右到左 → [20,9];第2层从左到右 → [15,7],完美符合“交替方向”的要求。

二、核心解题思路

锯齿形遍历的本质,还是 层序遍历(广度优先搜索BFS),因为我们必须先遍历完当前层的所有节点,才能进入下一层,这是层序遍历的核心逻辑。而“交替方向”只是在每层遍历完成后,对当前层的节点值数组做一个“反转”操作即可。

具体思路拆解:

  1. 初始化一个队列(用于BFS存储当前层的节点),一个结果数组(存储最终的锯齿形遍历结果),以及一个层数标记(用于判断当前层是否需要反转)。

  2. 如果根节点为空,直接返回空数组(边界条件处理)。

  3. 将根节点入队,然后进入循环:循环条件是队列不为空(还有节点未遍历)。

  4. 记录当前层的节点数量(levelSize),初始化一个临时数组(levelValues),用于存储当前层的节点值。

  5. 遍历当前层的所有节点:依次出队,将节点值存入临时数组,同时将该节点的左右子节点(如果存在)入队(注意:入队顺序始终是左→右,因为反转是在遍历完当前层后进行的,不影响子节点的入队顺序)。

  6. 判断当前层数:如果是奇数层(level % 2 === 1),将临时数组反转;如果是偶数层,保持不变。

  7. 将临时数组存入结果数组,层数加1,进入下一层循环。

  8. 循环结束后,返回结果数组。

关键亮点:无需改变节点的入队顺序,只需在每层遍历结束后,根据层数决定是否反转当前层的节点值数组,逻辑简单且高效。

三、完整代码实现(TypeScript)

先定义二叉树节点类(题目已给出,此处复用),再实现锯齿形层序遍历函数:

// 二叉树节点类定义
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

// 锯齿形层序遍历函数
function zigzagLevelOrder(root: TreeNode | null): number[][] {
  // 边界条件:根节点为空,返回空数组
  if (!root) {
    return [];
  }
  // 队列:存储当前层的节点,初始化存入根节点
  const queue = [root];
  // 结果数组:存储最终的锯齿形遍历结果
  const result: number[][] = [];
  // 层数标记:从0开始计数,用于判断是否需要反转
  let level = 0;
  
  // BFS循环:队列不为空则继续遍历
  while (queue.length) {
    // 记录当前层的节点数量,避免后续入队影响遍历次数
    const levelSize = queue.length;
    // 临时数组:存储当前层的节点值
    const levelValues: number[] = [];
    
    // 遍历当前层的所有节点
    for (let i = 0; i < levelSize; i++) {
      // 出队:取出当前层的第一个节点(队列是先进先出FIFO)
      const node = queue.shift()!;
      // 将当前节点值存入临时数组
      levelValues.push(node.val);
      // 左子节点存在则入队
      if (node.left) {
        queue.push(node.left);
      }
      // 右子节点存在则入队
      if (node.right) {
        queue.push(node.right);
      }
    }
    
    // 关键:奇数层反转,偶数层不反转
    if (level % 2 === 1) {
      levelValues.reverse();
    }
    // 将当前层的节点值数组存入结果
    result.push(levelValues);
    // 层数加1,进入下一层
    level++;
  }
  
  return result;
};

四、代码细节解析

1. 边界条件处理

if (!root) { return []; }:当根节点为空时,直接返回空数组,避免后续队列操作报错,这是二叉树题目中最基础也最容易忽略的一步。

2. 队列的作用

队列(queue)用于存储当前层的所有节点,遵循“先进先出(FIFO)”原则,保证层序遍历的顺序。每次循环开始时,用levelSize记录当前层的节点数量,确保我们只遍历当前层的节点,不会提前遍历到下一层的节点。

3. 反转逻辑的时机

反转操作是在遍历完当前层所有节点后进行的,而不是在遍历节点的过程中。如果在遍历过程中改变入队顺序,会导致下一层的遍历顺序出错,反而增加复杂度。

4. 类型标注(TypeScript)

函数参数root标注为TreeNode | null,避免传入非节点类型;result标注为number[][],明确返回值是二维数组(每一层一个数组);levelValues标注为number[],确保存入的是节点值(数字类型),提升代码可读性和健壮性。

五、易错点提醒

  1. 混淆层数计数:题目中的“锯齿形”是层与层交替,建议层数从0开始计数(如示例中第0层不反转,第1层反转),如果从1开始计数,需要调整判断条件(level % 2 === 0时反转),容易出错。

  2. 忘记记录levelSize:如果不记录当前层的节点数量,直接循环queue.length,会因为遍历过程中不断入队(左右子节点)导致循环次数异常,遍历到下一层的节点。

  3. 反转时机错误:在遍历节点时反转,会导致当前层节点值顺序混乱,正确的时机是遍历完当前层所有节点后,再根据层数判断是否反转。

  4. 空节点处理:入队前必须判断节点的左右子节点是否存在(if (node.left)、if (node.right)),否则会将null入队,后续shift()后访问val会报错。

六、复杂度分析

  • 时间复杂度:O(n),其中n是二叉树的节点总数。每个节点都会入队一次、出队一次,反转操作的时间复杂度是O(k)(k是当前层的节点数),所有层的反转操作总时间是O(n),因此整体时间复杂度为O(n)。

  • 空间复杂度:O(n),队列最多存储二叉树的一层节点(最坏情况是满二叉树的最后一层,节点数为n/2),结果数组存储所有节点值,空间复杂度为O(n)。

七、拓展思考

如果题目要求“从右到左开始,再左到右交替”,只需修改反转条件:level % 2 === 0时反转,level % 2 === 1时不反转,核心逻辑不变。

另外,除了BFS,也可以用DFS(深度优先搜索)实现:记录当前层数,在递归遍历左右子节点时,根据层数决定将节点值存入当前层数组的开头还是结尾(奇数层存开头,偶数层存结尾),但BFS的思路更直观,也更符合层序遍历的本质,推荐优先掌握BFS解法。

总结

LeetCode 103题的核心是“层序遍历+层内反转”,没有复杂的算法设计,关键在于理解“锯齿形”的本质是层与层之间的方向交替,而不是改变节点的遍历顺序。掌握BFS的基本逻辑,再加上简单的反转操作,就能轻松解决这道题。

建议大家动手敲一遍代码,尝试修改层数计数方式、反转条件,加深对思路的理解。如果遇到复杂的二叉树案例,也可以画图模拟队列的入队、出队过程,帮助自己理清逻辑。