分类
算法
发布于
2026年7月6日
正文
2024 字正文

LeetCode一百题—— 二叉树的层序遍历

从队列和 BFS 的角度拆开 LeetCode 102,理解为什么 levelSize 是二叉树层序遍历里最关键的一刀,并给出 C 语言实现。

有些算法题看起来很温和。

比如 LeetCode 102,二叉树的层序遍历。

它不像动态规划那样一上来就让人怀疑人生,也不像图论题那样名字里自带压迫感。题目只是说,给你一棵二叉树,请你按层把节点值收集起来。

听起来像一件很朴素的事。

第一层放一起,第二层放一起,第三层放一起。

但我觉得这题很适合放进 LeetCode 一百题里。它不只是考一段模板代码。它真正考的是,你有没有意识到,树不是只能从上到下一路递归着看,它也可以像一群排队的人一样,一层一层从眼前走过去。

这就是 BFS。

BFS 的中文常叫广度优先搜索。用更日常的话说,就是先看离你最近的一圈,再看下一圈,不急着往某一条路钻到底。

这题的手感,也正是从这里开始变清楚的。

一、题目真正想考什么

题目要求的是返回一个二维数组。

如果树长这样。

text
      3
     / \
    9  20
       / \
      15  7

那结果应该是这样。

text
[
  [3],
  [9, 20],
  [15, 7]
]

这个结果有两个信息。

一个是节点值。

另一个是层级边界。

只把节点按访问顺序吐出来还不够。3, 9, 20, 15, 7 只是访问顺序,它没有告诉我们哪几个节点属于同一层。

所以这题的重点不是「怎么走完整棵树」。

而是「怎么在走的过程中,知道当前这一层到哪里结束」。

很多人第一次写这题,会卡在这里。

看起来队列能把节点按顺序拿出来,但队列里一边弹出当前层,一边又把下一层塞进去。两层节点混在同一个队列里,边界好像一下就糊了。

这时就轮到 levelSize 出场。

二、为什么队列刚好适合这件事

队列是一种先进先出的结构。

你可以把它理解成排队买咖啡。先来的人先被服务,后来的人排到后面。

二叉树的层序遍历刚好也是这个秩序。

根节点先进去。

取出根节点时,把它的左孩子、右孩子放到队尾。

再取出第二层的节点时,把它们的孩子继续放到队尾。

这样一来,越靠近根节点的节点越早被处理,同一层的节点也会按从左到右的顺序出现。

这不是巧合。

这是队列和层序遍历的性格刚好对上了。

Mermaid 图表

上面这张图里,最关键的不是入队,也不是出队。

是记录当前队列长度。

因为在这一刻,队列里装着的,正好都是当前这一层的节点。

下一层的节点还没被塞进去。

所以只要先记住这个长度,我们就能放心地循环 levelSize 次,把这一层完整切出来。

三、把一层从队列里切出来

我喜欢把 levelSize 理解成一把很小的刀。

它不负责遍历整棵树。

它只负责在每一轮开始时,对队列说一句,这一段属于当前层,后面新来的都先别混进来。

举个例子。

根节点 3 处理完之后,队列里是 920

这一轮开始时,levelSize 等于 2。所以我们只处理两个节点。

处理 9 时,它没有孩子,队列变化不大。

处理 20 时,它的孩子 157 会进入队尾。

但这一轮不会继续处理 157

因为 levelSize 已经提前说好了,这一层只处理两个。

这一点特别重要。

没有 levelSize,你很容易把下一层也顺手处理掉,最后结果就会变成一长串,而不是一层一层的数组。

PlantUML 图表
100%
PlantUML 图表

这张 PlantUML 图表达的是同一件事。

当前层和下一层不靠节点值区分,也不靠深度字段区分。

它们靠一轮开始时的队列长度区分。

四、代码实现

力扣题里已经提供了 TreeNode 结构,所以我们只需要写 levelOrder 函数。

c
int** levelOrder(struct TreeNode* root, int* returnSize, int** returnColumnSizes) {
    if (root == NULL) {
        *returnSize = 0;
        *returnColumnSizes = NULL;
        return NULL;
    }
 
    int capacity = 2000;
    int** result = malloc(sizeof(int*) * capacity);
    *returnColumnSizes = malloc(sizeof(int) * capacity);
    *returnSize = 0;
 
    struct TreeNode** queue = malloc(sizeof(struct TreeNode*) * capacity);
    int head = 0;
    int tail = 0;
    queue[tail++] = root;
 
    while (head < tail) {
        int levelSize = tail - head;
        int* level = malloc(sizeof(int) * levelSize);
 
        for (int i = 0; i < levelSize; i++) {
            struct TreeNode* node = queue[head++];
            level[i] = node->val;
 
            if (node->left != NULL) {
                queue[tail++] = node->left;
            }
 
            if (node->right != NULL) {
                queue[tail++] = node->right;
            }
        }
 
        result[*returnSize] = level;
        (*returnColumnSizes)[*returnSize] = levelSize;
        (*returnSize)++;
    }
 
    free(queue);
    return result;
}

这段代码里,我觉得最值得盯住的只有三处。

root == NULL 处理空树。

levelSize = tail - head 固定当前层长度。

for 循环只跑 levelSize 次,让新加入队列的孩子节点留到下一轮。

其余部分都很直觉。

看见左孩子,就放进队列。

看见右孩子,也放进队列。

因为题目要从左到右,所以左孩子要先入队,右孩子后入队。

五、几个容易错的地方

空树要返回空数组。

这不是特殊癖好,而是题目返回值类型决定的。没有任何层,自然就是 []

单节点树要返回 [[root.val]]

如果你的代码能自然处理这个情况,说明主循环写得比较干净。

左右孩子的顺序不能反。

层序遍历默认是从左到右。如果先把右孩子入队,结果就会在同一层里反过来。

在 C 语言里,队列通常不用真的删除数组头部元素。

headtail 两个下标就够了。head 指向下一个要取出的节点,tail 指向下一个可以放入的位置。

这比每次移动数组元素更干净。

先把层的边界想清楚,比一开始就追求更漂亮的队列实现更重要。

六、复杂度怎么理解

时间复杂度是 O(n)

这里的 n 是树里的节点数量。每个节点会被放进队列一次,也会被取出来一次。

如果把每一层的节点数量记成 wiw_i,整棵树的节点总数就可以写成:

n=i=0hwin = \sum_{i=0}^{h} w_i

层序遍历做的事,就是把这些层从上到下各扫一遍。

空间复杂度也是 O(n)

队列在最宽的一层时可能装下很多节点,结果数组也会保存所有节点值。

如果只看额外队列空间,可以说它和树的最大宽度有关。

但在这道题里,返回值本身就要保存所有节点,所以整体空间按 O(n) 看更直接。

七、这题留下的东西

我觉得 LeetCode 102 的价值,不在于背会一段 BFS 模板。

真正值得留下的是这个动作。

在一轮开始时,先固定当前层的长度。

这句话很短,但它会反复出现在后面的二叉树题里。

比如锯齿形层序遍历。

比如找每一层最大值。

比如把每一层的节点拿出来做额外计算。

很多题只是换了皮,骨架还是这一套。

队列负责让节点按层来到你面前。

levelSize 负责告诉你,这一层到哪里结束。

一个负责顺序。

一个负责边界。

二叉树的层序遍历,就被这两个东西拆开了。

本文采用知识共享 CC BY-NC-SA 4.0 协议发布。

作者
fox
最后更新