队列 + 宽搜(BFS):二叉树层序遍历 算法总结

目录

一、先从零认识树和队列

[1. 什么是二叉树](#1. 什么是二叉树)

[2. 什么是 N 叉树](#2. 什么是 N 叉树)

[3. 什么是层](#3. 什么是层)

[4. 什么是宽度优先搜索 BFS](#4. 什么是宽度优先搜索 BFS)

[5. 什么是队列:先进先出](#5. 什么是队列:先进先出)

[二、最基础的 Java 二叉树 BFS 模板](#二、最基础的 Java 二叉树 BFS 模板)

[1. 必须熟悉的模板](#1. 必须熟悉的模板)

[2. 为什么 size 必须在本层开始时固定](#2. 为什么 size 必须在本层开始时固定)

[3. size 的标准使用方式](#3. size 的标准使用方式)

[4. LinkedList 与 ArrayDeque 怎么选](#4. LinkedList 与 ArrayDeque 怎么选)

LinkedList

ArrayDeque

[5. 常用 Java API 中文解释](#5. 常用 Java API 中文解释)

[三、如何判断题目应该用"按层 BFS"](#三、如何判断题目应该用“按层 BFS”)

[四、429. N 叉树的层序遍历](#四、429. N 叉树的层序遍历)

[五、103. 二叉树的锯齿形层序遍历](#五、103. 二叉树的锯齿形层序遍历)

[六、662. 二叉树最大宽度](#六、662. 二叉树最大宽度)

[七、515. 在每个树行中找最大值](#七、515. 在每个树行中找最大值)

八、四道题放在一起对比

[九、BFS 代码自检清单](#九、BFS 代码自检清单)

十、最后的记忆方式

核心主题:用队列一层一层地访问树,并在"每一层"这个边界上完成统计、反转或编号。


一、先从零认识树和队列

1. 什么是二叉树

二叉树是一种由许多**节点(TreeNode)**组成的树形结构。每个节点通常有三部分信息:

java 复制代码
class TreeNode {
    int val;           // 节点保存的值
    TreeNode left;     // 左孩子
    TreeNode right;    // 右孩子
}

二叉树的"二叉"表示:一个节点最多有两个孩子,分别叫左孩子和右孩子。孩子可以不存在,不存在时对应的引用就是 null

例如:

复制代码
        1              根节点
       / \
      2   3            1 的左、右孩子
     / \
    4   5
  • 1 是这棵树的根节点(root),也就是整棵树最上面的起点。
  • 21 的左孩子,31 的右孩子。
  • 452 的孩子。
  • 没有孩子的节点称为叶子节点。
  • leftrightnull,表示那个方向没有节点。

在 LeetCode 的代码编辑器中,TreeNode 定义通常已经给出,我们只需要实现 Solution 中的方法。为了读懂代码,可以把它想成上面的结构。

2. 什么是 N 叉树

N 叉树和二叉树的区别是:

  • 二叉树:每个节点最多有 leftright 两个孩子。
  • N 叉树:每个节点可以有任意多个孩子,孩子通常放在 List<Node> children 中。
java 复制代码
class Node {
    public int val;
    public List<Node> children;
}

例如:

复制代码
          1
       /  |  \
      3   2   4
     / \
    5   6

节点 1children[3, 2, 4];节点 3children[5, 6]

3. 什么是层

从根节点开始,按照到根节点的距离划分:

复制代码
第 1 层:             1
第 2 层:          2     3
第 3 层:        4   5     6

在本文中:

  • 根节点所在的层称为第 1 层。
  • 同一层的节点从左到右排列。
  • "层序遍历"就是先访问第 1 层,再访问第 2 层,再访问第 3 层;同一层内从左到右访问。

有的讲解把根节点编号为第 0 层。编号从 0 还是 1 不影响算法,但写锯齿遍历时必须始终保持自己的奇偶规则一致。本文使用 level = 1 表示根节点这一层。

4. 什么是宽度优先搜索 BFS

BFS 是 Breadth-First Search 的缩写,中文是宽度优先搜索广度优先搜索。它的特点是:

先把当前距离范围内的节点处理完,再扩展到更远的节点。

在树上,这正好对应"先处理完当前层,再处理下一层"。

与它相对的是 DFS(深度优先搜索):DFS 会沿着一条路径尽量向下走;BFS 则像一圈一圈向外扩散。

5. 什么是队列:先进先出

队列可以类比排队买票:先排队的人先办理,后排队的人后办理。这叫先进先出(FIFO,First In First Out)

队列最常用的三个操作是:

操作 Java 写法 含义
入队 queue.offer(x) x 放到队尾
查看队头 queue.peek() 看队头但不删除
出队 queue.poll() 取出并删除队头

BFS 的顺序正是:

  1. 把根节点放进队列。
  2. 取出队头节点。
  3. 把它的孩子放到队尾。
  4. 不断重复。

因为同一层的节点先入队,它们会先于下一层节点出队,所以自然形成层序访问顺序。


二、最基础的 Java 二叉树 BFS 模板

1. 必须熟悉的模板

java 复制代码
Queue<TreeNode> queue = new LinkedList<>();
if (root != null) {
    queue.offer(root);
}

while (!queue.isEmpty()) {
    int size = queue.size();

    for (int i = 0; i < size; i++) {
        TreeNode node = queue.poll();

        // 处理当前节点

        if (node.left != null) {
            queue.offer(node.left);
        }
        if (node.right != null) {
            queue.offer(node.right);
        }
    }
}

题目要求的最简骨架也可以写成:

java 复制代码
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
    int size = queue.size();
    ...
    queue.poll();
    ...
}

实际使用时,空树要先判断,否则 root == null 时把空引用放进队列,之后访问 node.val 就会产生异常。

2. 为什么 size 必须在本层开始时固定

这是层序遍历最重要的细节。

假设当前队列是:

复制代码
[2, 3]

它们都是当前层的节点,size = 2。处理 2 时,把它的孩子放到队尾;处理 3 时,也把它的孩子放到队尾。队列可能变成:

复制代码
[2, 3, 4, 5, 6]

此时队列中前两个是原来的当前层,后面三个是下一层。如果循环次数使用动态的 queue.size(),就会把刚加入的下一层节点也当作当前层处理,层的边界就丢失了。

所以要在进入本层循环的第一刻写:

复制代码
int size = queue.size();

它表示:

这一轮只处理现在队列中已有的这 size 个节点;本轮处理过程中新入队的节点留到下一轮。

可以把 size 想成给当前层拍的一张"名单快照"。

3. size 的标准使用方式

java 复制代码
while (!queue.isEmpty()) {
    int size = queue.size();       // 先固定本层数量
    List<Integer> level = new ArrayList<>();

    for (int i = 0; i < size; i++) {
        TreeNode node = queue.poll();
        level.add(node.val);

        if (node.left != null) {
            queue.offer(node.left);
        }
        if (node.right != null) {
            queue.offer(node.right);
        }
    }

    answer.add(level);
}

注意入队孩子不会改变本轮的 size,因为 for 循环的结束条件使用的是已经保存下来的整数。

4. LinkedListArrayDeque 怎么选

两者都可以实现 Queue<TreeNode>

java 复制代码
Queue<TreeNode> queue = new LinkedList<>();

或者:

复制代码
Queue<TreeNode> queue = new ArrayDeque<>();
LinkedList
  • 是链表,也实现了 QueueDeque 等接口。
  • 能够保存 null 元素,但 BFS 通常不应该把 null 节点入队。
  • 代码直观,很多题解会使用它。
  • 下面的基础模板和题 429、103 沿用这种写法,便于和常见题解结构对应。
ArrayDeque
  • 使用数组结构实现双端队列,通常有更好的常数性能和更少的节点对象开销。
  • 不允许存放 null,这反而能提醒我们只把真实节点入队。
  • 既可以当普通队列使用,也可以使用 addFirstaddLastpollFirstpollLast 做双端操作。
  • 新代码中,如果不需要存 null,通常优先考虑它。

例如:

java 复制代码
Queue<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
TreeNode node = queue.poll();

选择哪一个不会改变 BFS 的核心逻辑。真正不能错的是:只入队非空节点,并正确固定当前层的 size

5. 常用 Java API 中文解释

  • Queue<TreeNode>:声明一个"元素类型为 TreeNode 的队列"。左边写接口,右边写具体实现,是常见的面向接口编程写法。
  • new LinkedList<>():创建一个空的链表队列。<> 是菱形语法,Java 会根据左边类型推断泛型。
  • offer(value):把元素加入队尾;队列满时会返回 false,比 add 更符合队列语义。
  • poll():删除并返回队头;队列为空时返回 null,不会直接抛异常。
  • isEmpty():判断队列是否为空。
  • size():返回当前队列中元素个数。
  • ArrayList<>():创建可动态增长的数组列表,适合保存每层的答案。
  • add(value):向列表末尾添加一个元素。
  • Collections.reverse(list):把列表中的元素原地反转。
  • Math.max(a, b):返回两个数中较大的一个。
  • Integer.MIN_VALUEint 类型的最小值,适合初始化"本层最大值"。

三、如何判断题目应该用"按层 BFS"

看到下面这些关键词时,优先想到队列和层序遍历:

  • "从上到下""逐层""每一层"。
  • "每个树行""同一深度"。
  • "最左和最右之间的宽度"。
  • "先左到右,再右到左"。
  • "距离根节点最近的节点先处理"。
  • 需要把节点按层分组,或者对每层做一次统计。

统一思考过程如下:

  1. 把根节点放入队列。
  2. int size = queue.size() 固定本层节点数。
  3. 循环 size 次,逐个取出本层节点。
  4. 处理节点本身。
  5. 把非空孩子加入队列,留给下一层。
  6. 本层循环结束后,再把本层答案加入总答案,或更新全局结果。

四道题的区别只在"处理本层节点时额外做什么":

题目 队列中的主要内容 每层的额外动作
429 N 叉树层序遍历 Node 收集本层值;遍历所有 children
103 二叉树锯齿层序遍历 TreeNode 偶数方向时反转本层,或使用双端队列
662 二叉树最大宽度 TreeNode + long 位置编号 用最左、最右编号计算宽度
515 每个树行最大值 TreeNode 用一个变量求本层最大值

四、429. N 叉树的层序遍历

1. 题目描述

给定一个 N 叉树的根节点 root,返回它的层序遍历结果。也就是:

  • 从上到下逐层遍历。
  • 同一层从左到右遍历。
  • 每一层的节点值放在一个列表中。
  • 最终返回一个二维列表。
  • 如果 root 为空,返回空列表。

例如:

复制代码
输入:root = [1,null,3,2,4,null,5,6]
输出:[[1],[3,2,4],[5,6]]

树形结构可以理解为:

复制代码
        1
      / | \
     3  2  4
    / \
   5   6

2. 官方链接

429. N 叉树的层序遍历(leetcode.cn)

3. 算法思路

这道题就是二叉树层序遍历模板的 N 叉树版本。

  1. 创建结果 ret
  2. 如果 root == null,直接返回空结果。
  3. 根节点入队。
  4. 队列不为空时,先记录当前层节点数 sz
  5. 循环 sz 次,取出本层每一个节点,把它的 val 放入 tmp
  6. 遍历当前节点的所有 children,把非空孩子入队。
  7. 当前层处理结束后,把 tmp 加到 ret
  8. 队列中的内容已经全部变成下一层,继续循环。

二叉树每个节点只需要判断 leftright;N 叉树不能只写两个孩子,而要遍历整个 children 列表。

4. 队列中存什么

队列中存的是:

复制代码
Queue<Node> q

也就是一个个 N 叉树节点,不需要存节点值的副本,也不需要存层号。因为 sz = q.size() 已经告诉我们本轮应处理多少个节点。

在处理一个 Node t 时:

  • t 是当前层的节点。
  • t.val 是要收集的值。
  • t.children 中的节点属于下一层,应该入队。

5. Java 简洁代码

下面的结构保留了常见题解中的 retqsztmpt 等变量含义。LeetCode 会提供 Node 定义;如果在本地测试,需要保证 Nodevalchildren 字段。

java 复制代码
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

class Solution {
    public List<List<Integer>> levelOrder(Node root) {
        List<List<Integer>> ret = new ArrayList<>();
        if (root == null) {
            return ret;
        }

        Queue<Node> q = new LinkedList<>();
        q.offer(root);

        while (!q.isEmpty()) {
            int sz = q.size();
            List<Integer> tmp = new ArrayList<>();

            for (int i = 0; i < sz; i++) {
                Node t = q.poll();
                tmp.add(t.val);

                if (t.children != null) {
                    for (Node child : t.children) {
                        if (child != null) {
                            q.offer(child);
                        }
                    }
                }
            }
            ret.add(tmp);
        }

        return ret;
    }
}

6. 关键代码和 API 中文解释

List<List<Integer>> ret

返回值是二维列表:

  • 外层 List:所有层。

  • 内层 List<Integer>:某一层的所有节点值。

例如 [[1], [3, 2, 4], [5, 6]] 就是"一个层列表中放了三个值列表"。

Queue<Node> q = new LinkedList<>()

声明一个存放 Node 的队列。q 是 queue 的常见缩写。

int sz = q.size()

在取出任何本层节点之前固定本层数量。假设当前层有 3 个节点,即使处理前两个节点时有很多孩子入队,本轮也只执行 3 次。

Node t = q.poll()

取出队头节点,并把它从队列中删除。t 可以理解为当前正在处理的节点(temporary/current node)。

tmp.add(t.val)

把当前节点的值放入本层列表。tmp 是 temporary 的缩写,表示临时保存当前层的结果。

for (Node child : t.children)

增强 for 循环,依次访问 t.children 中的每一个孩子。N 叉树的关键就在这里:孩子数量不固定,必须全部检查。

if (child != null)

只有真实存在的节点才能入队。空引用不代表一个需要处理的节点。

ret.add(tmp)

本层全部处理完成后,再把本层列表添加到总结果。不能在处理到一半时加入,否则一层会被拆开。

7. 复杂度

设树中共有 n 个节点,最大层节点数为 w

  • 时间复杂度:O(n)。每个节点入队一次、出队一次,每个孩子引用也只检查一次。
  • 空间复杂度:O(n)。最坏时队列可能保存一整层的节点,结果列表也需要保存所有节点值;若只计算辅助队列,通常写作 O(w)

8. 手算示例

使用:

复制代码
        1
      / | \
     3  2  4
    / \
   5   6

初始:

复制代码
q = [1]
ret = []

第 1 轮

复制代码
sz = 1

取出 1

复制代码
本层 tmp = [1]
孩子 3、2、4 入队
q = [3, 2, 4]

本层结束:

复制代码
ret = [[1]]

第 2 轮

复制代码
sz = 3

依次取出 324

复制代码
tmp = [3, 2, 4]

处理 3 时,把 56 入队;处理 24 时没有孩子:

复制代码
q = [5, 6]
ret = [[1], [3, 2, 4]]

第 3 轮

复制代码
sz = 2
q = []
tmp = [5, 6]
ret = [[1], [3, 2, 4], [5, 6]]

队列为空,返回 ret

9. 易错点

  1. 忘记空树判断root == null 时应返回空的 ret,不是返回 null
  2. 只处理两个孩子 :N 叉树没有固定的 leftright,必须遍历 children
  3. 没有固定 sz :直接在 while 中一直 poll,会把下一层也混入当前层。
  4. children 列表本身当成节点 :应当遍历其中每个 Node child
  5. 把空孩子入队 :后续访问 t.val 会触发空指针异常;使用 child != null 过滤。
  6. 本层没结束就加入 ret :必须等 for 循环完成后再保存 tmp
  7. 孩子顺序写反 :题目要求从左到右,应该按照 children 列表原有顺序入队。
  8. 混淆 addoffer :两者在普通非满队列中都能入队,但本文统一使用更能表达队列语义的 offer

10. 下次遇到类似题的判断依据

如果题目说:

  • N 叉树;
  • 从上到下;
  • 逐层、每层从左到右;
  • 返回二维层列表;

就直接套 levelOrder 模板,唯一需要特别检查的是:

当前节点的孩子是不是放在 children 列表中?如果是,就用增强 for 遍历全部孩子,而不是写 left/right


五、103. 二叉树的锯齿形层序遍历

1. 题目描述

给定二叉树的根节点 root,返回它的锯齿形层序遍历结果:

  • 第 1 层从左到右。
  • 第 2 层从右到左。
  • 第 3 层从左到右。
  • 之后每层交替改变方向。

例如:

复制代码
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[20,9],[15,7]]

对应的树是:

复制代码
        3
       / \
      9   20
          / \
         15  7

普通层序遍历是 [[3], [9, 20], [15, 7]];锯齿遍历只把第 2 层改为反向,所以得到 [[3], [20, 9], [15, 7]]

2. 官方链接

103. 二叉树的锯齿形层序遍历(leetcode.cn)

3. 算法思路:正常 BFS + 每层反转

最容易理解的方法是:

  1. 先按普通层序遍历把当前层从左到右收集到 tmp
  2. level 判断当前方向。
  3. 如果这一层要求从右到左,就执行 Collections.reverse(tmp)
  4. 把处理好的 tmp 放入 ret
  5. 下一层切换方向。

这里不需要改变节点入队顺序。始终先放左孩子、再放右孩子,队列仍然保持普通的从左到右 BFS;只在本层结果列表上做反转即可。

4. 队列中存什么

队列中存普通的二叉树节点:

复制代码
Queue<TreeNode> q

队列只负责保证逐层访问;锯齿方向由 leveltmp 的反转负责。

  • 当前节点出队后,先记录 t.val
  • 左孩子先入队,右孩子后入队。
  • 因此下一轮队列仍然是从左到右排列。

5. Java 简洁代码:使用 level + Collections.reverse

java 复制代码
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

class Solution {
    public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
        List<List<Integer>> ret = new ArrayList<>();
        if (root == null) {
            return ret;
        }

        Queue<TreeNode> q = new LinkedList<>();
        q.offer(root);
        int level = 1;

        while (!q.isEmpty()) {
            int sz = q.size();
            List<Integer> tmp = new ArrayList<>();

            for (int i = 0; i < sz; i++) {
                TreeNode t = q.poll();
                tmp.add(t.val);

                if (t.left != null) {
                    q.offer(t.left);
                }
                if (t.right != null) {
                    q.offer(t.right);
                }
            }

            // 根节点是第 1 层:奇数层左到右,偶数层右到左
            if (level % 2 == 0) {
                Collections.reverse(tmp);
            }
            ret.add(tmp);
            level++;
        }

        return ret;
    }
}

6. 另一种写法:使用双端队列直接决定放入方向

锯齿题也可以不在最后反转,而是使用 Deque<Integer>

java 复制代码
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

class Solution {
    public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
        List<List<Integer>> ret = new ArrayList<>();
        if (root == null) {
            return ret;
        }

        Queue<TreeNode> q = new LinkedList<>();
        q.offer(root);
        boolean leftToRight = true;

        while (!q.isEmpty()) {
            int sz = q.size();
            Deque<Integer> level = new ArrayDeque<>();

            for (int i = 0; i < sz; i++) {
                TreeNode node = q.poll();

                if (leftToRight) {
                    level.addLast(node.val);
                } else {
                    level.addFirst(node.val);
                }

                if (node.left != null) {
                    q.offer(node.left);
                }
                if (node.right != null) {
                    q.offer(node.right);
                }
            }

            ret.add(new ArrayList<>(level));
            leftToRight = !leftToRight;
        }

        return ret;
    }
}

两种方法都正确:

  • 本层反转:更容易理解,代码和普通 BFS 差别小。
  • 双端队列 :直接把值放到队头或队尾,不需要调用反转;需要理解 Deque 的两端操作。

本文后面的手算以第一种方法为主。

7. 关键代码和 API 中文解释

int level = 1

记录当前处理的是第几层。这里规定根节点为第 1 层:

  • level % 2 != 0:奇数层,保持左到右。
  • level % 2 == 0:偶数层,反转为右到左。

如果你把根节点记作第 0 层,也可以反过来判断,但必须和代码的方向约定一致。

Collections.reverse(tmp)

tmp 中已有的元素原地反转。例如:

复制代码
tmp = [9, 20];
Collections.reverse(tmp);
// tmp 变为 [20, 9]

它只改变列表顺序,不改变树和队列。

level++

本层加入结果后,下一次 while 就要处理下一层,所以层号加 1。不要在处理每个节点时加层号,层号应该每层只变化一次。

Deque<Integer> level

Deque 是双端队列(double-ended queue)。常用方法:

  • addFirst(x):从队头加入。
  • addLast(x):从队尾加入。
  • pollFirst():从队头取出。
  • pollLast():从队尾取出。

第二种代码中,节点仍然按照普通顺序从队列取出;方向为反向时,把值用 addFirst 放到前面,就能得到倒序结果。

new ArrayList<>(level)

把双端队列中的当前层内容复制成一个普通列表,加入最终结果。这样之后清理或进入下一轮时,不会影响已经保存的结果。

8. 复杂度

设树中有 n 个节点,最大层节点数为 w

  • 时间复杂度:O(n)。每个节点处理一次;所有层反转的总成本不超过 O(n)
  • 辅助空间复杂度:O(w),队列和本层列表最多保存一层规模的内容。
  • 如果把最终返回结果也计入空间,结果本身需要 O(n)

9. 手算示例

仍使用:

复制代码
        3
       / \
      9   20
          / \
         15  7

初始:

复制代码
q = [3]
level = 1
ret = []

第 1 层

复制代码
sz = 1
tmp = [3]

奇数层不反转:

复制代码
ret = [[3]]
level = 2
q = [9, 20]

第 2 层

固定:

复制代码
sz = 2

先取 9,再取 20

复制代码
tmp = [9, 20]

这是偶数层,反转:

复制代码
Collections.reverse(tmp)
tmp = [20, 9]

处理 20 时,157 入队:

复制代码
ret = [[3], [20, 9]]
level = 3
q = [15, 7]

第 3 层

复制代码
sz = 2
tmp = [15, 7]

奇数层不反转:

复制代码
ret = [[3], [20, 9], [15, 7]]

队列为空,遍历结束。

10. 易错点

  1. 把入队顺序也交替反转:使用"每层反转"的写法时,孩子始终左后右入队即可,不要同时改变入队顺序。
  2. 层号奇偶写反:先明确根节点是第 1 层还是第 0 层,再决定哪一层反转。
  3. 在节点循环中切换方向:方向应在一层结束后切换,不能每取出一个节点就切换。
  4. 忘记固定 sz:锯齿只是输出方向变化,仍然是按层 BFS。
  5. Collections.reverse 写在 ret.add(tmp) 之后:加入结果后再反转,可能改变已经保存的同一个列表引用;应先反转,再加入。
  6. 空树返回 null :题目要求空列表 [],所以返回初始化好的 ret
  7. 双端队列代码中复用同一个可变列表却不复制 :需要用 new ArrayList<>(level) 保存当前层的快照。

11. 下次遇到类似题的判断依据

如果题目仍然是"逐层遍历",只是要求:

  • 一层从左到右;
  • 下一层从右到左;
  • 方向交替;

先写普通层序遍历,再任选一种改造:

  1. List<Integer> tmp 收集后,在指定层调用 Collections.reverse(tmp)
  2. 使用 Deque<Integer>,正向用 addLast,反向用 addFirst

初学者优先选择第一种,因为它把"遍历节点"和"改变展示顺序"分开了。


六、662. 二叉树最大宽度

1. 题目描述

给定一棵二叉树的根节点 root,返回这棵树的最大宽度。

每一层的宽度定义为:在把这棵树想象成一棵完整二叉树后,该层最左端节点和最右端节点(包括两端之间的空位置)之间的长度。也就是说,两端节点之间即使有本层不存在的 null 空位置,也要把这些位置计算进去。

例如:

复制代码
输入:root = [1,3,2,5,3,null,9]
输出:4

树可以画成:

复制代码
          1
        /   \
       3     2
      / \     \
     5   3     9

第 3 层按完整二叉树的位置看是:

复制代码
[5, 3, null, 9]

因此宽度是 4,不是实际非空节点数 3

再例如:

复制代码
[1,3,2,5,null,null,9,6,null,7]

第 4 层可能对应:

复制代码
[6, null, null, null, null, null, 7]

两端位置之间有很多空位置,所以宽度可以是 7

2. 官方链接

662. 二叉树最大宽度(leetcode.cn)

3. 为什么只用 queue.size() 不够

普通层序遍历中的:

复制代码
queue.size()

只能告诉我们当前队列里有多少个真实存在的节点,不能告诉我们这些节点在完整二叉树中的位置是否相隔很远。

例如某层只有两个真实节点:

复制代码
[左端节点, 右端节点]

它们中间可能有 5 个本来应该存在、但实际为空的位置。如果只统计队列节点数,得到 2;题目要求的是从左端位置到右端位置的完整跨度,应该得到 7

因此需要给每个真实节点附带一个"它在完整二叉树中的位置编号"。

4. 带位置编号的核心想法

把二叉树按数组或堆的方式编号:

  • 根节点位置为 0(也可以从 1 开始,本文使用 0)。
  • 如果父节点位置是 index
    • 左孩子位置是 2 * index
    • 右孩子位置是 2 * index + 1

例如根节点位置为 0

复制代码
             1(0)
            /     \
        2(0)       3(1)
        /  \       /  \
    4(0) 5(1)   6(2) 7(3)

为了让位置从"某一层的相对位置"开始,也常把根编号设为 1

  • 左孩子:index * 2
  • 右孩子:index * 2 + 1

两种编号规则都可以,只要前后一致。下面的代码使用从 0 开始的相对编号,并在每一层进行归一化。

5. 队列中存什么

队列中不能只存 TreeNode,而要存一对信息:

复制代码
(真实节点,节点在完整二叉树中的位置编号)

Java 中用自定义的 Pair<TreeNode, Long> 表示:

复制代码
List<Pair<TreeNode, Long>> q

注意:位置编号必须使用 long,因为树的层数增加时,位置编号理论上会按 2 倍增长。即使题目最终答案在 int 范围内,也不能因此就让中间的编号运算使用 int

6. 如何用编号得到一层宽度

假设当前层最左真实节点的编号是 leftIndex,最右真实节点的编号是 rightIndex,那么该层宽度是:

复制代码
rightIndex - leftIndex + 1

为什么要 + 1

复制代码
编号 4 到编号 4:1 个位置
编号 4 到编号 5:2 个位置
编号 4 到编号 7:4 个位置

一般的闭区间长度就是 右端 - 左端 + 1

这里的编号并不是"真实节点的数量",而是完整二叉树数组中的位置,所以中间缺失的空位置自然被包含在跨度中。

7. 为什么不能把所有 null 真的放进队列

一种直观但危险的做法是:

  • 非空节点入队;
  • 非空节点的空孩子也用 null 占位入队;
  • 每层根据队列位置统计宽度。

这在树比较规整时似乎可以工作,但如果树的左边是一条很长的链、右边是另一条很长的链,中间会产生数量巨大的空位置。为了保留两端的距离,队列可能需要保存海量 null,造成内存超限。

更好的方法是:

不保存空节点,只给真实节点保存它在完整树中的位置编号。

编号的间隔已经隐含了空位置,因此既能得到正确宽度,也不会把大量 null 放进队列。

8. Java 简洁代码:节点 + long 位置编号

下面代码使用一个简单的泛型 Pair,这样不依赖并非 Java 标准库的第三方 Pair 类;方法名和主要变量结构保持 widthOfBinaryTreeqrettmpnodeindex 的常见写法。

java 复制代码
import java.util.ArrayList;
import java.util.List;

class Solution {
    private static class Pair<K, V> {
        private final K key;
        private final V value;

        Pair(K key, V value) {
            this.key = key;
            this.value = value;
        }

        K getKey() {
            return key;
        }

        V getValue() {
            return value;
        }
    }

    public int widthOfBinaryTree(TreeNode root) {
        if (root == null) {
            return 0;
        }

        List<Pair<TreeNode, Long>> q = new ArrayList<>();
        q.add(new Pair<>(root, 0L));
        long ret = 0L;

        while (!q.isEmpty()) {
            Pair<TreeNode, Long> t1 = q.get(0);
            Pair<TreeNode, Long> t2 = q.get(q.size() - 1);

            long leftIndex = t1.getValue();
            long rightIndex = t2.getValue();
            ret = Math.max(ret, rightIndex - leftIndex + 1);

            // 下一层使用相对编号,避免编号随着层数无意义地变得过大
            List<Pair<TreeNode, Long>> tmp = new ArrayList<>();
            for (Pair<TreeNode, Long> t : q) {
                TreeNode node = t.getKey();
                long index = t.getValue() - leftIndex;

                if (node.left != null) {
                    tmp.add(new Pair<>(node.left, index * 2));
                }
                if (node.right != null) {
                    tmp.add(new Pair<>(node.right, index * 2 + 1));
                }
            }
            q = tmp;
        }

        return (int) ret;
    }
}

代码与常见题解的对应关系

常见写法会让根节点从 1 开始编号:

复制代码
q.add(new Pair<TreeNode, Integer>(root, 1));

再用:

复制代码
index * 2
index * 2 + 1

计算孩子位置。上面的实现把根设为 0,并在进入下一层时减去本层最左编号;这只是编号起点不同,宽度公式和左右孩子关系完全相同。

如果使用 Pair<TreeNode, Long>,也可以写出从 1 开始的版本:

复制代码
q.add(new Pair<>(root, 1L));

但无论起点是 0 还是 1,重点是:

  • Pair 的第二项必须是 long
  • 2 * index 的运算要在 long 上完成。
  • 不能用 int index 接收可能很大的位置编号。

9. 关键代码和 API 中文解释

List<Pair<TreeNode, Long>> q

这里的 q 虽然用 List 模拟了队列,但它保存的每个元素都包括:

  • getKey():真实节点。
  • getValue():该节点的位置编号。

本层处理完后用 tmp 生成下一层,再执行 q = tmp,因此不会把空节点加入列表。

q.get(0)q.get(q.size() - 1)

由于 BFS 入队顺序是从左到右,所以当前列表的第一个元素就是本层最左真实节点,最后一个元素就是本层最右真实节点。

java 复制代码
Pair<TreeNode, Long> t1 = q.get(0);
Pair<TreeNode, Long> t2 = q.get(q.size() - 1);

rightIndex - leftIndex + 1

计算本层完整跨度,而不是 q.size()。编号之间的空档就代表空位置。

long index = t.getValue() - leftIndex

把本层最左位置平移到 0。例如本层编号是 100105,相对编号就变成 05,宽度仍然是:

复制代码
5 - 0 + 1 = 6

归一化不会改变同一层节点之间的距离,却可以避免编号不断累积变大。

index * 2index * 2 + 1

按照完整二叉树的数组位置关系计算孩子:

  • 左孩子在 2 * index
  • 右孩子在 2 * index + 1

必须让 indexlong,这样结果也会按 long 运算。

Math.max(ret, width)

ret 保存到目前为止遇到的最大宽度;每处理一层,就用当前宽度更新它。

(int) ret

题目保证答案在 32 位有符号整数范围内,所以最终可以转回 int 返回。中间计算仍然使用 long,这是为了防止位置编号和减法加法的中间结果溢出。

10. 复杂度

设真实节点数为 n,最大真实节点层宽为 w

  • 时间复杂度:O(n)。每个真实节点处理一次。
  • 辅助空间复杂度:O(w),队列列表和下一层列表保存真实节点及其编号;宽松写作 O(n)
  • 没有把所有空位置实际存储下来,因此不会因为空位置数量巨大而占用同等数量的内存。

11. 手算示例:空位置会造成宽度

考虑:

复制代码
          1
        /   \
       3     2
      / \     \
     5   3     9

为了说明位置,根从 0 开始:

复制代码
第 1 层:1(0)
第 2 层:3(0)、2(1)
第 3 层:5(0)、3(1)、9(3)

第 3 层真实节点列表只有 3 个:

复制代码
q.size() = 3

但按完整二叉树的位置,它们对应:

复制代码
位置 0:5
位置 1:3
位置 2:null
位置 3:9

所以:

复制代码
width = 3 - 0 + 1 = 4

逐轮记录

初始:

复制代码
q = [(1, 0)]
ret = 0

处理第 1 层:

复制代码
leftIndex = 0
rightIndex = 0
width = 0 - 0 + 1 = 1

孩子入 tmp

复制代码
3 -> 0
2 -> 1
q = [(3, 0), (2, 1)]
ret = 1

处理第 2 层:

复制代码
leftIndex = 0
rightIndex = 1
width = 1 - 0 + 1 = 2

孩子位置:

复制代码
3 的左孩子 5 -> 0 * 2 = 0
3 的右孩子 3 -> 0 * 2 + 1 = 1
2 没有左孩子
2 的右孩子 9 -> 1 * 2 + 1 = 3

于是:

复制代码
q = [(5, 0), (3, 1), (9, 3)]
ret = 2

处理第 3 层:

java 复制代码
leftIndex = 0
rightIndex = 3
width = 3 - 0 + 1 = 4
ret = 4

这就解释了为什么空位置也会造成宽度。

12. long 和归一化为什么都要重视

只用 int 的风险

如果树很深,完整二叉树位置会出现类似:

复制代码
1, 3, 7, 15, 31, ...

不断乘 2 后可能超过 int 的最大值。Java 的 int 溢出不会自动报错,而是绕回负数或错误的小数,最终宽度计算也会出错。

所以至少要写成:

复制代码
long index;
long width;
long ret;

并使用:

复制代码
index * 2L
index * 2L + 1

为什么归一化有帮助

同一层只关心最左和最右编号的差,不关心它们共同加上的偏移量。每一层减去 leftIndex,相当于把本层最左节点重新当作位置 0

复制代码
原编号:1000, 1005
相对编号:0, 5

两种编号得到相同宽度 6,但相对编号更小。归一化和 long 一起使用,能显著降低位置运算的溢出风险。

13. 易错点

  1. q.size() 当宽度:这只统计真实节点个数,忽略了两端之间的空位置。
  2. null 全部入队:极端稀疏的树会产生大量空节点,可能内存超限。
  3. 位置编号使用 int:位置按二叉树规则增长,可能中间溢出。
  4. 只把 ret 改成 long,却仍用 int index :宽度可能没溢出,但孩子位置已经错了;节点编号相关变量和运算都应使用 long
  5. 忘记 +1 :位置 4 到位置 7 的宽度是 4,不是 3
  6. 错误地按真实节点重新连续编号 :如果每一层都把节点编号成 0,1,2...,就丢失了空位置的信息。
  7. 左右孩子编号写错 :左孩子是 2 * index,右孩子是 2 * index + 1;如果采用从 1 开始的编号,也要保持同一规则。
  8. 归一化后仍引用错误的偏移量 :下一层孩子编号应使用"当前层的相对 index",不能把未经处理的超大编号混用。
  9. 空树没有返回 0root == null 时最大宽度是 0
  10. 使用 Pair 却忘记导入或定义Pair 不是 Java 最基础的通用标准类。可以像本文一样定义简单的内部 Pair,或定义自己的 NodeIndex 类。

14. 下次遇到类似题的判断依据

如果题目问的是:

  • 某一层最左节点到最右节点的跨度;
  • 空节点也要计入;
  • 把树看成完整二叉树后求宽度;

那么普通的"每层节点数量"不够,应当想到:

  1. 进行按层 BFS。
  2. 队列元素从"节点"升级为"节点 + 完整树位置编号"。
  3. 当前层用 最右编号 - 最左编号 + 1 计算宽度。
  4. 只让真实节点入队,不能真的保存大量 null
  5. 位置编号用 long,最好每层减去最左编号做归一化。

这是一类非常典型的"BFS + 隐式空位编号"题。


七、515. 在每个树行中找最大值

1. 题目描述

给定一棵二叉树的根节点 root,找出每一层中的最大节点值,并按照从上到下的层序返回。

例如:

复制代码
输入:root = [1,3,2,5,3,null,9]
输出:[1,3,9]

对应树形结构:

复制代码
          1
        /   \
       3     2
      / \     \
     5   3     9
  • 第 1 层最大值是 1
  • 第 2 层最大值是 max(3, 2) = 3
  • 第 3 层最大值是 max(5, 3, 9) = 9

2. 官方链接

515. 在每个树行中找最大值(leetcode.cn)

3. 算法思路

"树行"就是一层,所以直接使用按层 BFS:

  1. 根节点入队。
  2. 每一轮先固定当前层节点数 sz
  3. Integer.MIN_VALUE 初始化本层最大值 tmp
  4. 取出本层每个节点,用 Math.max 更新 tmp
  5. 把非空左右孩子放入队列。
  6. 本层完成后,把 tmp 加入 ret
  7. 队列为空时返回所有层最大值。

初始化为 Integer.MIN_VALUE 很重要,因为节点值可能是负数。不能随便写成 0,否则当一层所有值都是负数时会得到错误结果。

4. 队列中存什么

队列中只存二叉树节点:

复制代码
Queue<TreeNode> q

不需要存层号,因为 sz 会划定当前层;也不需要存每层的所有值,只需用一个变量 tmp 在本层滚动更新最大值。

5. Java 简洁代码

java 复制代码
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

class Solution {
    public List<Integer> largestValues(TreeNode root) {
        List<Integer> ret = new ArrayList<>();
        if (root == null) {
            return ret;
        }

        Queue<TreeNode> q = new LinkedList<>();
        q.offer(root);

        while (!q.isEmpty()) {
            int sz = q.size();
            int tmp = Integer.MIN_VALUE;

            for (int i = 0; i < sz; i++) {
                TreeNode t = q.poll();
                tmp = Math.max(tmp, t.val);

                if (t.left != null) {
                    q.offer(t.left);
                }
                if (t.right != null) {
                    q.offer(t.right);
                }
            }

            ret.add(tmp);
        }

        return ret;
    }
}

6. 关键代码和 API 中文解释

List<Integer> ret

每层只有一个最大值,所以返回值是一维列表:

复制代码
[第 1 层最大值, 第 2 层最大值, 第 3 层最大值, ...]

int tmp = Integer.MIN_VALUE

tmp 表示当前层已经看到的最大值。开始处理一层时还没有看到任何节点,因此把它设为 int 能表示的最小值。

例如这一层是:

复制代码
[-8, -3, -20]

若错误初始化 tmp = 0,结果会一直是 0;正确初始化为 Integer.MIN_VALUE,结果才是 -3

tmp = Math.max(tmp, t.val)

每取出一个节点,就把当前最大值和节点值比较:

复制代码
tmp = max(已经看到的最大值, 当前节点值)

循环结束后,tmp 就是这一层所有节点的最大值。

ret.add(tmp)

必须在本层的 for 循环结束后再加入。若中途加入,会得到多个"半层最大值"。

左孩子和右孩子入队

java 复制代码
if (t.left != null) {
    q.offer(t.left);
}
if (t.right != null) {
    q.offer(t.right);
}

入队顺序保持左后右,这样下一层仍按从左到右处理。对于"求最大值"来说顺序不影响数值,但保持标准顺序有助于统一 BFS 模板。

7. 复杂度

设树中共有 n 个节点,最大层节点数为 w

  • 时间复杂度:O(n)
  • 辅助队列空间:O(w),宽松写作 O(n)
  • 返回结果有一项对应一层,树高为 h 时结果空间是 O(h);如果把结果计入总空间,则总空间仍不超过 O(n)

8. 手算示例一:普通正数

使用:

复制代码
          1
        /   \
       3     2
      / \     \
     5   3     9

初始:

复制代码
q = [1]
ret = []

第 1 层:

复制代码
sz = 1
tmp = Integer.MIN_VALUE
t = 1  -> tmp = max(MIN_VALUE, 1) = 1
ret = [1]
q = [3, 2]

第 2 层:

复制代码
sz = 2
tmp = Integer.MIN_VALUE
取 3 -> tmp = 3
取 2 -> tmp = max(3, 2) = 3
ret = [1, 3]
q = [5, 3, 9]

第 3 层:

复制代码
sz = 3
tmp = Integer.MIN_VALUE
取 5 -> tmp = 5
取 3 -> tmp = 5
取 9 -> tmp = 9
ret = [1, 3, 9]

9. 手算示例二:一层全是负数

复制代码
        -10
        /  \
      -20  -3

第 1 层:

复制代码
tmp = MIN_VALUE
tmp = max(MIN_VALUE, -10) = -10

第 2 层:

复制代码
tmp = MIN_VALUE
取 -20 -> tmp = -20
取 -3  -> tmp = -3

正确返回:

复制代码
[-10, -3]

这就是为什么不能用 0 作为本层最大值的初始值。

10. 易错点

  1. tmp 初始化为 0 :当节点值允许为负数时会出错,应使用 Integer.MIN_VALUE
  2. 没有固定 sz:新入队的孩子会被误算到当前层。
  3. 把全树最大值当成每层最大值 :每一层都要重新初始化 tmp
  4. 过早加入 ret:必须处理完本层全部节点后再保存。
  5. 忘记处理根节点:根节点本身就是第 1 层。
  6. 空树返回 [Integer.MIN_VALUE]:空树应直接返回空列表。
  7. 不判断孩子是否为空 :将 null 入队后会在下一次出队访问字段时出错。
  8. 误以为需要优先级队列 :题目只是求每层最大值,不需要全局排序;普通 BFS 加 Math.max 就够了。

11. 下次遇到类似题的判断依据

如果题目说:

  • 每一层求最大值、最小值、总和或平均值;
  • 按树行输出;
  • 节点需要按深度分组处理;

就用按层 BFS:

  1. size 固定本层数量。
  2. 每层开始时重置统计变量。
  3. 处理 size 个节点并更新统计量。
  4. 把孩子放入下一层。
  5. 本层结束后保存统计结果。

如果是求最大值,特别检查节点值是否可能为负数;若可能,初始化应使用 Integer.MIN_VALUE,而不是 0


八、四道题放在一起对比

题号 方法名 返回值 队列元素 本层核心操作
429 levelOrder List<List<Integer>> Node 收集所有值,遍历 children
103 zigzagLevelOrder List<List<Integer>> TreeNode 收集后按方向反转,或用双端队列
662 widthOfBinaryTree int TreeNode + long index 最右位置减最左位置加 1
515 largestValues List<Integer> TreeNode 每层用 Math.max 求最大值

它们的共同骨架是:

java 复制代码
while (!queue.isEmpty()) {
    int size = queue.size();
    for (int i = 0; i < size; i++) {
        // 取出当前层节点
        // 处理节点
        // 把非空孩子加入队列
    }
    // 当前层结束后的额外操作
}

区别只是"当前层结束后"或"处理当前层时"保存什么:

  • 429 保存一整个 List<Integer>
  • 103 保存一整个列表,并调整方向。
  • 662 保存位置编号,用两端编号计算跨度。
  • 515 只保存一个最大值。

九、BFS 代码自检清单

写完类似题后,可以按顺序检查:

  1. root == null 时返回值是否正确?
  2. 是否只把非空节点放入队列?
  3. 是否在本层开始时写了 int size = queue.size()
  4. for 是否恰好执行 size 次?
  5. 是否先 poll 当前节点,再把孩子入队?
  6. 二叉树是否按 leftright 入队?
  7. N 叉树是否遍历了整个 children 列表?
  8. 本层变量是否每轮重新初始化?
  9. 锯齿方向是否每层只切换一次?
  10. 662 是否按"位置跨度"而非真实节点数计算?
  11. 662 的位置、宽度和结果中间量是否使用 long
  12. 662 是否避免把海量 null 节点放入队列?
  13. 515 的最大值初始值是否能处理负数?
  14. 结果列表是在本层处理完成后才加入的吗?

十、最后的记忆方式

不要把四道题当成四套完全不同的代码。先记住一个稳定的层序模板:

java 复制代码
Queue<TreeNode> queue = new LinkedList<>();
if (root != null) {
    queue.offer(root);
}

while (!queue.isEmpty()) {
    int size = queue.size();
    for (int i = 0; i < size; i++) {
        TreeNode node = queue.poll();

        // 处理 node

        if (node.left != null) {
            queue.offer(node.left);
        }
        if (node.right != null) {
            queue.offer(node.right);
        }
    }
}

然后看到题目目标再替换"处理本层"的部分:

  • 要输出每层节点:建 List<Integer> level
  • 要锯齿:对 level 反转,或使用 Deque
  • 要宽度:给节点附加完整树位置编号,用两端编号求跨度。
  • 要每层最大值:初始化 Integer.MIN_VALUE,循环中更新 Math.max
  • 遇到 N 叉树:把 left/right 改成遍历 children

最关键的一句话是:

size 是本层开始时的节点数;本轮只处理这批节点,新入队的孩子永远留到下一层。

掌握这个边界,再根据题目要求改变"本层统计方式",就能独立处理大多数二叉树层序遍历问题。

相关推荐
Felven1 小时前
A. Riptide
算法·c 算法
m0_739312871 小时前
四元数、李群SO(3)/李代数so(3)的作用及应用场景
算法·机器人·自动驾驶
youm20031 小时前
【学习笔记】认识NoSQL——非关系型数据库
笔记·学习·nosql
A黄俊辉A1 小时前
【无标题】
java
BestHeaker1 小时前
跨企业接口对接:IQDS / CPK 数据解析与协作避坑指南(五)
java·服务器·前端
神明不懂浪漫1 小时前
【第二章】库、表、增删改查操作
开发语言·数据库·经验分享·笔记
artificiali1 小时前
880 第4章多元
人工智能·算法
Felven2 小时前
B. Deja Vu
数据结构·算法
俊昭喜喜里2 小时前
C#中的func<>
java·前端·c#