目录
[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 怎么选)
[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),也就是整棵树最上面的起点。2是1的左孩子,3是1的右孩子。4、5是2的孩子。- 没有孩子的节点称为叶子节点。
left或right为null,表示那个方向没有节点。
在 LeetCode 的代码编辑器中,TreeNode 定义通常已经给出,我们只需要实现 Solution 中的方法。为了读懂代码,可以把它想成上面的结构。
2. 什么是 N 叉树
N 叉树和二叉树的区别是:
- 二叉树:每个节点最多有
left、right两个孩子。 - N 叉树:每个节点可以有任意多个孩子,孩子通常放在
List<Node> children中。
java
class Node {
public int val;
public List<Node> children;
}
例如:
1
/ | \
3 2 4
/ \
5 6
节点 1 的 children 是 [3, 2, 4];节点 3 的 children 是 [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 的顺序正是:
- 把根节点放进队列。
- 取出队头节点。
- 把它的孩子放到队尾。
- 不断重复。
因为同一层的节点先入队,它们会先于下一层节点出队,所以自然形成层序访问顺序。
二、最基础的 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. LinkedList 与 ArrayDeque 怎么选
两者都可以实现 Queue<TreeNode>:
java
Queue<TreeNode> queue = new LinkedList<>();
或者:
Queue<TreeNode> queue = new ArrayDeque<>();
LinkedList
- 是链表,也实现了
Queue、Deque等接口。 - 能够保存
null元素,但 BFS 通常不应该把null节点入队。 - 代码直观,很多题解会使用它。
- 下面的基础模板和题 429、103 沿用这种写法,便于和常见题解结构对应。
ArrayDeque
- 使用数组结构实现双端队列,通常有更好的常数性能和更少的节点对象开销。
- 不允许存放
null,这反而能提醒我们只把真实节点入队。 - 既可以当普通队列使用,也可以使用
addFirst、addLast、pollFirst、pollLast做双端操作。 - 新代码中,如果不需要存
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_VALUE:int类型的最小值,适合初始化"本层最大值"。
三、如何判断题目应该用"按层 BFS"
看到下面这些关键词时,优先想到队列和层序遍历:
- "从上到下""逐层""每一层"。
- "每个树行""同一深度"。
- "最左和最右之间的宽度"。
- "先左到右,再右到左"。
- "距离根节点最近的节点先处理"。
- 需要把节点按层分组,或者对每层做一次统计。
统一思考过程如下:
- 把根节点放入队列。
int size = queue.size()固定本层节点数。- 循环
size次,逐个取出本层节点。 - 处理节点本身。
- 把非空孩子加入队列,留给下一层。
- 本层循环结束后,再把本层答案加入总答案,或更新全局结果。
四道题的区别只在"处理本层节点时额外做什么":
| 题目 | 队列中的主要内容 | 每层的额外动作 |
|---|---|---|
| 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. 官方链接
3. 算法思路
这道题就是二叉树层序遍历模板的 N 叉树版本。
- 创建结果
ret。 - 如果
root == null,直接返回空结果。 - 根节点入队。
- 队列不为空时,先记录当前层节点数
sz。 - 循环
sz次,取出本层每一个节点,把它的val放入tmp。 - 遍历当前节点的所有
children,把非空孩子入队。 - 当前层处理结束后,把
tmp加到ret。 - 队列中的内容已经全部变成下一层,继续循环。
二叉树每个节点只需要判断 left、right;N 叉树不能只写两个孩子,而要遍历整个 children 列表。
4. 队列中存什么
队列中存的是:
Queue<Node> q
也就是一个个 N 叉树节点,不需要存节点值的副本,也不需要存层号。因为 sz = q.size() 已经告诉我们本轮应处理多少个节点。
在处理一个 Node t 时:
t是当前层的节点。t.val是要收集的值。t.children中的节点属于下一层,应该入队。
5. Java 简洁代码
下面的结构保留了常见题解中的 ret、q、sz、tmp 和 t 等变量含义。LeetCode 会提供 Node 定义;如果在本地测试,需要保证 Node 有 val 和 children 字段。
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
依次取出 3、2、4:
tmp = [3, 2, 4]
处理 3 时,把 5、6 入队;处理 2、4 时没有孩子:
q = [5, 6]
ret = [[1], [3, 2, 4]]
第 3 轮
sz = 2
q = []
tmp = [5, 6]
ret = [[1], [3, 2, 4], [5, 6]]
队列为空,返回 ret。
9. 易错点
- 忘记空树判断 :
root == null时应返回空的ret,不是返回null。 - 只处理两个孩子 :N 叉树没有固定的
left、right,必须遍历children。 - 没有固定
sz:直接在while中一直poll,会把下一层也混入当前层。 - 把
children列表本身当成节点 :应当遍历其中每个Node child。 - 把空孩子入队 :后续访问
t.val会触发空指针异常;使用child != null过滤。 - 本层没结束就加入
ret:必须等for循环完成后再保存tmp。 - 孩子顺序写反 :题目要求从左到右,应该按照
children列表原有顺序入队。 - 混淆
add和offer:两者在普通非满队列中都能入队,但本文统一使用更能表达队列语义的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. 官方链接
3. 算法思路:正常 BFS + 每层反转
最容易理解的方法是:
- 先按普通层序遍历把当前层从左到右收集到
tmp。 - 用
level判断当前方向。 - 如果这一层要求从右到左,就执行
Collections.reverse(tmp)。 - 把处理好的
tmp放入ret。 - 下一层切换方向。
这里不需要改变节点入队顺序。始终先放左孩子、再放右孩子,队列仍然保持普通的从左到右 BFS;只在本层结果列表上做反转即可。
4. 队列中存什么
队列中存普通的二叉树节点:
Queue<TreeNode> q
队列只负责保证逐层访问;锯齿方向由 level 和 tmp 的反转负责。
- 当前节点出队后,先记录
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 时,15、7 入队:
ret = [[3], [20, 9]]
level = 3
q = [15, 7]
第 3 层
sz = 2
tmp = [15, 7]
奇数层不反转:
ret = [[3], [20, 9], [15, 7]]
队列为空,遍历结束。
10. 易错点
- 把入队顺序也交替反转:使用"每层反转"的写法时,孩子始终左后右入队即可,不要同时改变入队顺序。
- 层号奇偶写反:先明确根节点是第 1 层还是第 0 层,再决定哪一层反转。
- 在节点循环中切换方向:方向应在一层结束后切换,不能每取出一个节点就切换。
- 忘记固定
sz:锯齿只是输出方向变化,仍然是按层 BFS。 - 把
Collections.reverse写在ret.add(tmp)之后:加入结果后再反转,可能改变已经保存的同一个列表引用;应先反转,再加入。 - 空树返回
null:题目要求空列表[],所以返回初始化好的ret。 - 双端队列代码中复用同一个可变列表却不复制 :需要用
new ArrayList<>(level)保存当前层的快照。
11. 下次遇到类似题的判断依据
如果题目仍然是"逐层遍历",只是要求:
- 一层从左到右;
- 下一层从右到左;
- 方向交替;
先写普通层序遍历,再任选一种改造:
List<Integer> tmp收集后,在指定层调用Collections.reverse(tmp)。- 使用
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. 官方链接
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 类;方法名和主要变量结构保持 widthOfBinaryTree、q、ret、tmp、node、index 的常见写法。
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。例如本层编号是 100 和 105,相对编号就变成 0 和 5,宽度仍然是:
5 - 0 + 1 = 6
归一化不会改变同一层节点之间的距离,却可以避免编号不断累积变大。
index * 2 与 index * 2 + 1
按照完整二叉树的数组位置关系计算孩子:
- 左孩子在
2 * index。 - 右孩子在
2 * index + 1。
必须让 index 是 long,这样结果也会按 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. 易错点
- 用
q.size()当宽度:这只统计真实节点个数,忽略了两端之间的空位置。 - 把
null全部入队:极端稀疏的树会产生大量空节点,可能内存超限。 - 位置编号使用
int:位置按二叉树规则增长,可能中间溢出。 - 只把
ret改成long,却仍用int index:宽度可能没溢出,但孩子位置已经错了;节点编号相关变量和运算都应使用long。 - 忘记
+1:位置4到位置7的宽度是4,不是3。 - 错误地按真实节点重新连续编号 :如果每一层都把节点编号成
0,1,2...,就丢失了空位置的信息。 - 左右孩子编号写错 :左孩子是
2 * index,右孩子是2 * index + 1;如果采用从 1 开始的编号,也要保持同一规则。 - 归一化后仍引用错误的偏移量 :下一层孩子编号应使用"当前层的相对
index",不能把未经处理的超大编号混用。 - 空树没有返回 0 :
root == null时最大宽度是0。 - 使用
Pair却忘记导入或定义 :Pair不是 Java 最基础的通用标准类。可以像本文一样定义简单的内部Pair,或定义自己的NodeIndex类。
14. 下次遇到类似题的判断依据
如果题目问的是:
- 某一层最左节点到最右节点的跨度;
- 空节点也要计入;
- 把树看成完整二叉树后求宽度;
那么普通的"每层节点数量"不够,应当想到:
- 进行按层 BFS。
- 队列元素从"节点"升级为"节点 + 完整树位置编号"。
- 当前层用
最右编号 - 最左编号 + 1计算宽度。 - 只让真实节点入队,不能真的保存大量
null。 - 位置编号用
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. 官方链接
3. 算法思路
"树行"就是一层,所以直接使用按层 BFS:
- 根节点入队。
- 每一轮先固定当前层节点数
sz。 - 用
Integer.MIN_VALUE初始化本层最大值tmp。 - 取出本层每个节点,用
Math.max更新tmp。 - 把非空左右孩子放入队列。
- 本层完成后,把
tmp加入ret。 - 队列为空时返回所有层最大值。
初始化为 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. 易错点
tmp初始化为 0 :当节点值允许为负数时会出错,应使用Integer.MIN_VALUE。- 没有固定
sz:新入队的孩子会被误算到当前层。 - 把全树最大值当成每层最大值 :每一层都要重新初始化
tmp。 - 过早加入
ret:必须处理完本层全部节点后再保存。 - 忘记处理根节点:根节点本身就是第 1 层。
- 空树返回
[Integer.MIN_VALUE]:空树应直接返回空列表。 - 不判断孩子是否为空 :将
null入队后会在下一次出队访问字段时出错。 - 误以为需要优先级队列 :题目只是求每层最大值,不需要全局排序;普通 BFS 加
Math.max就够了。
11. 下次遇到类似题的判断依据
如果题目说:
- 每一层求最大值、最小值、总和或平均值;
- 按树行输出;
- 节点需要按深度分组处理;
就用按层 BFS:
size固定本层数量。- 每层开始时重置统计变量。
- 处理
size个节点并更新统计量。 - 把孩子放入下一层。
- 本层结束后保存统计结果。
如果是求最大值,特别检查节点值是否可能为负数;若可能,初始化应使用 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 代码自检清单
写完类似题后,可以按顺序检查:
root == null时返回值是否正确?- 是否只把非空节点放入队列?
- 是否在本层开始时写了
int size = queue.size()? for是否恰好执行size次?- 是否先
poll当前节点,再把孩子入队? - 二叉树是否按
left后right入队? - N 叉树是否遍历了整个
children列表? - 本层变量是否每轮重新初始化?
- 锯齿方向是否每层只切换一次?
- 662 是否按"位置跨度"而非真实节点数计算?
- 662 的位置、宽度和结果中间量是否使用
long? - 662 是否避免把海量
null节点放入队列? - 515 的最大值初始值是否能处理负数?
- 结果列表是在本层处理完成后才加入的吗?
十、最后的记忆方式
不要把四道题当成四套完全不同的代码。先记住一个稳定的层序模板:
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是本层开始时的节点数;本轮只处理这批节点,新入队的孩子永远留到下一层。
掌握这个边界,再根据题目要求改变"本层统计方式",就能独立处理大多数二叉树层序遍历问题。