在力扣上刷树的题,很多人第一个正儿八经接触的遍历就是层序遍历。二叉树那版 102 做完之后,顺手点开 429《N 叉树的层序遍历》,第一反应一般是:这题不是更简单吗?二叉树还得考虑左右子树,N 叉树直接给一个children数组,不就是把两次 push 改成一个 for 循环?结果真写起来,一批人反而卡住了。卡的点不在"N",而在对"层"这个字的分寸感:到底该在哪个循环里新建子数组,size该在什么时候取,循环边界怎么划才算一层的完整结束。429 就是专门考验这个分寸感的题目,也是面试里层序遍历最容易被拿来变体的题,适合刚接触树结构的新手,也适合刷题中段想系统归纳 BFS 的同学。
1. 为什么二叉树层序能一次过,N 叉树却一堆人翻车
1.1 二叉树模板直接套,错的不是代码而是对"层级"的定义
先回顾一下二叉树层序遍历的经典模板:
from collections import deque class Solution: def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]: if not root: return [] ans = [] q = deque([root]) while q: level = [] size = len(q) for _ in range(size): node = q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) ans.append(level) return ans这段代码几乎是标配,刷过 102 的人闭眼都能写出来。为什么换成 N 叉树,很多人会愣一下?因为二叉树的左右子节点是写在if里的,你天然知道一层最多只有两个分支;N 叉树呢,每个节点携带一个长度不确定的children列表,分支数量是一个未知数。
此时最容易出现的错误是直接这样写:
while q: level = [] for node in q: level.append(node.val) for child in node.children: q.append(child) ans.append(level)看起来很有道理,但实际它会死循环:你在用 for 遍历q的同时不断往q里追加新节点,Python 的迭代器会一路跟着新加进来的元素往下走,队列永远清不空。这个错误的根源就是用遍历代替了"快照"。正确的理解是:每次进入 while 循环时,队列里剩余的元素必须是且只能是"当前这一层"的所有节点,新加入的子节点要留给下一轮处理。这就是层级边界。
1.2 层序和前序的区别,是"按组收"和"挨个走"的区别
很多人在搜索这道题时会连带搜到"层序遍历和前序遍历的区别",这其实是一个非常关键的混淆点。前序遍历的访问顺序是根、左、右,对应的代码结构是这样:
def preorder(node): if not node: return print(node.val) for child in node.children: preorder(child)如果只看遍历输出的一维数组,前序和层序在某些树形态下有相似之处,但两者在语义上是完全不同的:前序是深度优先,一路往下钻到叶子再回头;层序是广度优先,先把当前层全部收完,再进下一层。放到 N 叉树上就更有意思了——前序遍历的递归代码和层序遍历的递归写法只差一个参数,但输出的数据结构直接就多了一层维度。这个"纵深与横向"的分野,就是搜索关键词背后大家真正想搞明白的东西。
层序遍历本质上是广度优先搜索(BFS)在树结构上的直接体现。"层"这个概念不是一个附加条件,而是 BFS 最自然的产出单位。只要你维护了一个先进先出的队列,并且每次只取固定的队列前缀,你就在做层序遍历。
2. 解法一:BFS 队列,关键是"先锁行长,再扫整层"
2.1 队列 BFS 的骨架:为什么必须先取 len(q)
写 BFS,第一原则是:队列里同时只存在"当前层"的节点。如果队列里混入了下一层节点,你就再也分不清边界了。
所以代码的核心逻辑只有三行:
- 进入 while 循环后,先
size = len(q),把这一层的人数锁死; - 然后只处理
size个节点,每个节点弹出后把它的所有子节点推入队尾; - 这
size个节点处理完,队列自然就只剩下一层的节点,重复第一步。
这里最重要的习惯是:永远用快照长度做循环边界,而不要在循环里反复量队列长度。这样做能直接从结构上避免前面那种"一边遍历一边扩容"导致的死循环。一层的节点数在进入循环前是确定的,处理过程中凡是新进入队列的都是下一层预备役,哪怕它们长得很像(都是 Node 对象),也必须留在下一轮。
2.2 完整代码和一步步拆解
from collections import deque from typing import List class Solution: def levelOrder(self, root: 'Node') -> List[List[int]]: if not root: return [] ans = [] queue = deque([root]) while queue: level = [] size = len(queue) for _ in range(size): node = queue.popleft() level.append(node.val) for child in node.children: queue.append(child) ans.append(level) return ans我们拿一棵简单的三叉树走一遍:
- 根节点值为 1,children 为 [3, 2, 4],其中 3 又有 children 为 [5, 6]。
- 初始队列
queue = [1]。第一轮 while:size = 1,弹出 1,level = [1],然后把 3、2、4 推入队列。 - 第二轮 while:队列为
[3, 2, 4],size = 3。依次弹出 3、2、4,level = [3, 2, 4];其中 3 的孩子 5、6 被推入队尾。 - 第三轮 while:队列为
[5, 6],处理这一层,得到[5, 6]。 - 最终输出
[[1], [3, 2, 4], [5, 6]]。
每一层在ans里都是一个独立的子数组,子数组的顺序天然是每层从左到右,因为我们是按入队顺序逐个弹出的。这个"按顺序推进、按顺序弹出"的机制,就是队列这个数据结构最朴素的价值。
2.3 Python 写这道题的几个细节
第一个细节:children是否为None。力扣的 N 叉树定义里,中间节点可能没有 children,也就是空列表[]。直接 for 遍历空列表没有任何问题,所以不需要对该字段做额外的判空。真正需要判空的是root。
第二个细节:使用deque还是list。list 的pop(0)是 O(n) 的时间复杂度,因为弹出头部之后剩余元素都要搬移。虽然力扣测试数据规模不大,单次 pop(0) 看似没问题,但 BFS 是一个高频操作,队列长度可能很大,每个节点一次 pop(0) 累积起来就是 O(n²),完全可以用大测试用例卡死你。所以我强烈建议直接用collections.deque,popleft()是 O(1)。
第三个细节:在 Python 中,for _ in range(size)中的size在进入循环时已经固定,之后对队列的操作不会改变它。这一点在课堂上经常被讲成"坑",实际上 Python 的语法已经帮你规避了,但如果你用别的语言,比如 JavaScript,就得特别小心:for (let i = 0; i < q.length; i++)会在循环体执行中不断读取新的q.length,导致层级边界被打破。同一个逻辑,不同语言有不同的坑,这也提醒了我要口头说出"锁住快照长度"这个本质原因,而不是只背循环写法。
提示:面试时如果只写了 BFS 一种解法,八成会被追问"还有没有别的办法"。所以下面这种 DFS 写法,你不但要会,还要能讲清楚它和队列 BFS 的差异。
3. 解法二:递归 DFS,不排队也能把层序逼出来
3.1 从直觉上讲,层序不一定非得靠队列
听上去有点反直觉:层序遍历天然要求"一层一层扫",而 DFS 是"一条路走到黑",这两者怎么可能兼容?答案是:给递归加一个"层数"参数,让每个节点根据自己所在的深度,直接落进ans的对应索引里。遍历顺序依旧保持 DFS 的纵深感,但输出结构完全符合层序的要求。
这种做法的哲学是:**顺序只是访问路径,层级才是最终的分组依据。**只要我在访问任何节点时能知道它来自第几层,我就可以直接放进对应的小组。这个思路在解决"锯齿形层序""每层平均值"等问题时同样适用。
3.2 递归解法完整代码和执行过程
from typing import List class Solution: def levelOrder(self, root: 'Node') -> List[List[int]]: ans = [] def dfs(node: 'Node', depth: int) -> None: if not node: return if len(ans) == depth: ans.append([]) ans[depth].append(node.val) for child in node.children: dfs(child, depth + 1) dfs(root, 0) return ans关键点在于这一句:if len(ans) == depth: ans.append([])。这是"第一次踏入某个新深度"的信号。这根节点访问时 depth=0,ans里还没有第 0 层列表,所以新增一个空列表放当前层;访问到某个第一层节点时,depth=1,而ans只有一个元素(索引 0),于是再补一个空列表;等访问到第二层时,ans已经有两个元素了,直接往索引 2 的位置追加即可。
执行流程和队列法看着完全不一样,但输出依然正确。原因就在于:depth是递归过程中随身携带的"楼层号",而ans是一个足够长的"楼房",每进入一个还没建好的楼层就顺手浇筑一层,后续的节点按楼层号入住。
3.3 两种解法的时间复杂度和空间复杂度对比
时间复杂度两种解法完全一致,都是 O(N),每个节点恰好访问一次。
空间复杂度就值得说一说了:
- BFS 队列法:空间消耗取决于"最宽的一层包含多少节点"。极端情况下根节点的所有子节点都在同一层,队列里最多会同时存在约 N 个节点(在完全多叉树中很夸张),所以最坏情况是 O(N)。
- DFS 递归法:空间消耗取决于树的高度,最坏情况下树退化成一条链(每个节点只有一个 child),递归栈深度为 N,空间复杂度同样是 O(N);但在相对平衡的 N 叉树里,高度远小于节点数,DFS 的空间表现往往更好。
所以在内存敏感的场景里,DFS 是个很有意思的备选方案。但在实际面试中,我更推荐先把 BFS 写到滚瓜烂熟,DFS 写法会讲就行。因为 BFS 是层序的"标准答案",面试官接下来大概率会问其他变体,BFS 模板的适应性更广。
4. 复杂度和边界条件,以及面试官喜欢追问的三个点
4.1 先说清楚"每个节点访问一次"为什么是这个复杂度
很多人讲复杂度时只丢一句"O(N)",但面试官追问"为什么不是 O(NlogN)"就直接卡壳。其实核心论据就这么几句:
- 每个节点只有一次入队和一次出队(或一次递归调用),每次操作都是常数时间;
- 每个节点的
children又会被完整遍历一次,恰好也属于度的总和为 N-1 的范畴; - 对于一棵有 N 个节点的树,无论是什么叉树,总子节点数永远是 N-1,所以 BFS 里内部循环的总执行次数也是 N-1。
所以总操作次数是 N + (N-1),也就是 O(2N-1),忽略常数项就是 O(N)。空间复杂度上面已经拆过,不再重复。
4.2 面试追问的三个隐藏考点
第一问:如果要求自底向上的层序怎么办?
这个对应力扣 107。最简单粗暴的办法就是 BFS 做完后,把整个ans反转一下。有人会问这样复杂度是不是涨了,其实没有,反转也是 O(N)。如果你不想用额外的反转操作,也可以在 BFS 时改用列表头部插入(ans.insert(0, level)),但这在 Python 里是 O(k) 级别,k 为当前结果长度,整体会退化到 O(N²),所以不推荐。
第二问:如果要求锯齿形层序遍历呢?
这个对应力扣 103。思想也很直接:加一个方向标志,偶数层正序收集,奇数层逆序收集。在实现上,可以收集完再反转某层的结果,也可以用deque的双端特性,奇数层直接往头部插入。
第三问:如果不允许递归,DFS 怎么改成迭代?
这就回到栈了。用栈模拟递归时,为了保持层序输出顺序,可以存(node, depth)二元组,并且把子节点按任意顺序压栈,因为每一层的结果是靠depth索引聚合的,访问顺序影响不了最终分组。
这三个问题表面上是"换个层序花样",实际上潜台词全是"你到底懂不懂 BFS 的本质"。只是背模板的话,换一层皮就可能被问懵。
4.3 边界条件:空树、单节点、深度极大
刷题时最容易忽略的就是边界输入:
root为None:直接返回[],不是[None],也不是[[]]。这个必须先写。- 只有一个根节点:返回
[[root.val]]。对应 BFS 流程就是第一轮 while 里level收集到根值,队列清空后循环结束,"每个节点独立成层"的常规逻辑自然满足。 - 深度极大(比如 10^4 级别):递归 DFS 可能爆栈,Python 默认递归深度限制约 1000,这种情况下必须用 BFS 或者把 DFS 改成迭代+栈。所以我一直强调,队列法在实际工程里往往比递归更稳,就是这个原因。
另外还有一个容易被忽略的口径问题:力扣对返回值的通常情况下不要求你处理children为None的情况,但如果自己造测试样例或者处理其他 OJ 题,最好预先做一次归一化:node.children = node.children or [],防止遍历时直接扑空或者引发TypeError。这种防御性写法在实际项目中同样重要,因为树结构的输入永远不可控。
5. 把 429 放回层序题族:它训练的不是 N 叉树,是抽象的层
5.1 从 102 到 107、103、637,一条线全串起来
这道题在整个力扣题库里的位置很有意思。它处于"层序遍历"家族的核心,直接相邻的好几道题全都长在同一个框架上:
| 题号 | 题目 | 核心变化 |
|---|---|---|
| 102 | 二叉树的层序遍历 | 标准 BFS 模板 |
| 107 | 二叉树的层序遍历 II | 自底向上,最后反转 ans |
| 103 | 二叉树的锯齿形层序遍历 | 按层切换方向 |
| 637 | 二叉树的层平均值 | 层内求和求均值 |
| 116 | 填充每个节点的下一个右侧节点指针 | 在 BFS 里连 q 内相邻节点 |
| 429 | N 叉树的层序遍历 | children 数组替代 left/right |
你会发现它们底层循环骨架完全一致:先把size锁死,再扫一层,再往下一层推。429 的独特贡献在于,它强迫你丢掉"left/right"这种二元思维惯性,面对任意数量的分支同样能维持秩序感。二叉树天然带着"一左一右"的确定性,写代码时可以靠直觉觉得没问题;N 叉树直接把这种确定感抽掉,逼着你抽象出"凡是子节点就往队列里推,凡是当前快照内的节点就都属于本层"这个通用原则。
5.2 顺手把 N 叉树的前序、后序遍历也纳入学习版图
刷完 429,力扣的 589(N 叉树前序遍历)和 590(N 叉树后序遍历)也就顺理成章了。它们连循环都不需要,递归写起来区区几行:
def preorder(node): if not node: return [] res = [node.val] for child in node.children: res.extend(preorder(child)) return res然后你会意识到一个有趣的结论:N 叉树没有中序遍历。因为中序的位置定义,依赖"左中右"这种二元结构。多叉树的分支多了,谁算左、谁算左子树都说不清了。从二叉树到 N 叉树,真正被拿掉的不是复杂度,而是"一左一右"的天然次序感。这份失去的确定感,要靠更抽象的"每一个节点都会产生若干后续分支"来替代。
5.3 通过这道题训练的核心能力:把树的形状和遍历逻辑解耦
说句实话,大部分刷题者最后真正的问题不是不会 BFS,而是不会"把树的形状抽离出来看层序"。
二叉树里,你的遍历逻辑和 left/right 深度绑定;N 叉树里,你必须突然意识到:left/right 只是 children 列表的一个特例。二叉树的 BFS 不过是在 children 列表长度为 2、且每个 child 都有固定含义的情况下的一种简化形态。把这个逻辑抽干净之后,图的 BFS、网格的最短路径、拓扑排序的层数统计,都跟 429 处在同一个认知模型上——只是两个分支变成任意选择,队列天然保证顺序,层级的边界用快照长度来圈定。
这正是力扣热题 100 里面层序类题目反复出现的原因:它考的不是你会不会写循环,而是你有没有建立起一种"横向扫描"的空间感。你手头所有关于"层"的直觉,第一次真正变成一个连通的算法体系,往往就是从 429 这道题开始的。
在做题的最后,分享一个我自己的小习惯:做完 429 之后,不要急着点下一题,试着只改三处代码,让它变成二叉树的层序遍历;再改一处,让它变成锯齿形层序遍历;再改一处,让它输出每层平均值。这三轮改动如果都能在五分钟内完成,说明你的层序遍历基本功是真的过关了,而不是只背熟了一个模板。