1. 内容整体设计与思路拆解
1.1 这道题到底在考什么
先说说这道题的定位。LeetCode 429 题,N 叉树的层序遍历,在二叉树的层序遍历基础上,把每个节点最多两个孩子这件事,扩展成了“每个节点最多 N 个孩子”,除此之外核心逻辑几乎一模一样。
很多人第一次看到 N 叉树,第一反应是“这题是不是很难”,但实际上真不是。树结构相关的算法题,往往不是考察你有多聪明的脑洞,而是考察你对遍历框架的熟悉程度。你能不能在 10 秒内从“层序遍历”这四个字,直接映射到“BFS + 队列”这个固定组合,这比你能不能徒手写出一个红黑树重要得多。
这个题适合谁刷?正在准备校招、实习面试的同学,尤其是目标岗位是后端开发、客户端开发、测开这类需要手写代码的岗位,这道题属于必须掌握的入门偏中档题。它并不难,但如果你连递归的两种 DFS 写法和迭代的 BFS 写法都分不清,那面试官大概会在你写完这题后,追加一道“那你能不用队列实现吗”的追问直接把难度拉满。
1.2 为什么 BFS 是层序遍历的默认答案
先问一个问题:什么叫做“层序遍历”?
按层从上到下,每一层从左到右依次访问所有节点。这句话听起来很简单,但如果你拿递归去实现,就会发现天然的劣势——递归天生是深度优先的,它会把一条分支走到黑,然后回头再走另一条分支。
所以说,BFS(广度优先搜索)是层序遍历最自然、最符合直觉的解法。BFS 的核心数据结构是队列,先进先出。从一个根节点出发,先把根节点放进队列,然后循环:出队一个节点,把这个节点的所有子节点依次入队。
这个过程听起来像排队打饭。你站在队伍最前面,打完饭走人,与此同时你身后又排进来几个人,队伍继续往后走。最终每个人都按“排队的先后顺序”打完饭,而这个顺序恰好就是从上到下、从左到右的层序。
这个类比虽然朴素,但它直接点破了 BFS 的本质:队列保证了访问顺序的公平性——先来的节点先被处理,后来的节点排在后面。
1.3 从二叉树到 N 叉树,变化在哪里
如果你已经刷过二叉树的层序遍历,比如 LeetCode 102 题,那这道 429 对你来说几乎是送分题。唯一的区别在于:二叉树用node.left和node.right取子节点,N 叉树用node.children取一个子节点列表。
代码上的变化就一句话:把
if node.left: queue.append(node.left) if node.right: queue.append(node.right)换成
for child in node.children: queue.append(child)剩下的逻辑——队列初始化、按层记录宽度、清空临时列表——完全一样。
不过,如果只是简单地说“题很简单”,写这篇笔记就没什么价值了。我在实际刷题和带新人复盘的时候发现,大家在这道题上真正容易丢分的地方,反而不是“会不会写 BFS”,而是下面这些细节:
- 不会处理
null根节点; - 忘记按层分组,最后输出一个一维数组而不是二维数组;
- 不知道
children可能为空列表,但空列表和null是不同的处理逻辑; - 用递归实现时,depth 参数传错了位置,导致某一层数据错乱;
- 面试官追问“不用队列怎么做”时,直接懵住。
这些都是后面要展开讲的内容。
2. 核心细节解析与实操要点
2.1 题目输入输出的隐藏信息
LeetCode 429 的输入是一个Node对象。题目通常给出这样的定义:
class Node: def __init__(self, val=None, children=None): self.val = val self.children = children注意这里的children是一个列表,列表里每个元素都是Node类型。有些语言里,children默认是None,有些语言里默认是空数组[]。你在本地自测时,经常会遇到children=None的情况,而在 LeetCode 平台上,测试数据一般会保证非空。
这就带来一个非常实际的坑:如果你在代码里直接写:
for child in node.children:当node.children是None时,直接抛TypeError: 'NoneType' object is not iterable。虽然 LeetCode 的评测数据可能不会触发这个错误,但你自己构造测试用例的时候,几乎一定会踩上。
所以我在写题解时,习惯加上一行保险:
if node.children: for child in node.children: queue.append(child)或者更规范的写法:
for child in (node.children or []): queue.append(child)这两行代码不仅让程序更健壮,也让你在面试中显得更专业——你考虑到了边界情况,而不仅是“把题跑通就行”。
2.2 返回值的结构决定你的代码结构
这道题的返回值是List[List[int]],也就是一个二维数组。每一层对应一个一维数组,数组里的数字是该层所有节点的值,从左到右排列。
这个返回值结构,直接决定了 BFS 的写法里必须在循环内再套一层循环。你不能像最朴素的 BFS 那样,只用一个while queue循环,出队一个处理一个,否则你得到的是一个一维序列,而没法把它们按层切分。
正确的做法是:在每一轮循环里,先读取当前队列的长度size = len(queue),然后连续出队size次。这size个节点,就是同一层的所有节点。
这里有一个点很多人第一次不理解:为什么不能在出队的时候动态判断队列长度?
因为队列是动态变化的。你在出队一个节点的同时,又把它的孩子不停入队。如果边出队边取len(queue),那这个长度会随着孩子的入队不断变化,你根本没法确定“这一层”的边界在哪里。
打个比方,你站在自助餐厅门口数人数。一开始队伍有 10 个人,你开始一个一个放进去。但每放进去一个人,他身后的朋友又跑来排队了,队伍长度永远在变,你永远数不到 10 这个数。
所以必须先“拍照定格”当前长度,再按这个长度循环。
2.3 什么时候用递归,什么时候用迭代
看到这里,可能有读者会有疑问:层序遍历不是也可以用递归吗?
严格来说,可以。如果你往递归函数里传一个depth参数,每访问一个节点,就把它放到result[depth]这个列表里,那么递归结束后,result就是一个按层分组的二维数组。
代码大概长这样:
def traverse(node, depth): if not node: return if len(result) == depth: result.append([]) result[depth].append(node.val) for child in (node.children or []): traverse(child, depth + 1)这段代码也能跑通,而且逻辑很简洁。但我想说的是,面试中如果考官让你写层序遍历,默认答案应该是 BFS 迭代,而不是递归。
原因有两个。
第一,递归本质是 DFS。你是“碰巧”利用深度信息完成了“按层分组”,但访问节点的顺序并不是严格的层序。比如在第三层,你可能是先访问了左子树深处的节点,再访问右子树深处的节点,只不过最终因为按深度归类,输出结果看起来是正确的。但如果你在递归中打印访问顺序,你会看到它跟层序并不一致。
第二,递归的缺点在于树的深度。如果树的深度极端——比如一个 N 叉树退化成链表——递归栈可能会爆掉。在面试场景下,迭代 BFS 永远比递归更稳。
所以我的建议是:这道题别整花活,就老老实实写 BFS。面试官要的并不是“你会多少种解法”,而是“你能不能把最合适的解法写得滴水不漏”。
3. 实操过程与核心环节实现
3.1 题解代码:Python 版本,逐行拆解
直接上代码。这是我个人在刷题时最常用的写法,简洁、清晰、不绕弯。
from collections import deque from typing import List, Optional class Solution: def levelOrder(self, root: Optional["Node"]) -> List[List[int]]: if not root: return [] result = [] queue = deque([root]) while queue: level_size = len(queue) current_level = [] for _ in range(level_size): node = queue.popleft() current_level.append(node.val) if node.children: for child in node.children: queue.append(child) result.append(current_level) return result分段拆开看。
第一段:if not root: return []。这一步是防御性处理。root为None时,直接返回空列表。没有这行,后面所有逻辑都会崩。实际上很多人在本地跑测试时,一旦测试数据是空树,就报AttributeError,十有八九就是漏了这行。
第二段:queue = deque([root])。这里我用了collections.deque,而不是普通列表。原因很实际:deque的popleft()是 O(1) 操作,普通列表的pop(0)是 O(n) 操作。虽然 LeetCode 的测试数据不至于因为这点差异超时,但工程上好习惯要养起来。
第三段:while queue:是 BFS 的主循环。只要队列不为空,就说明还有节点没被访问。
第四段:level_size = len(queue)。这里拍的“快照”是整层的宽度,是本解法的关键。它决定了这一轮循环要处理的节点数量。
第五段:for _ in range(level_size)。这个内部循环负责把当前层的所有节点全部出队,并把它们的子节点全部入队。等这个循环结束,当前层就已经处理完了,队列里剩下的全是下一层的节点。
第六段:如果node.children不为空,就遍历它,把所有子节点追加到队列尾部。
第七段:result.append(current_level)。把当前层收集到的所有节点值,作为一个列表加到结果里。
最终返回result,二维数组,每一层对应一个列表。
3.2 题解代码:Java 版本,面试手写模板
Java 版本的思路完全一样,但有几个细节要注意。
class Solution { public List<List<Integer>> levelOrder(Node root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) { return result; } Queue<Node> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> currentLevel = new ArrayList<>(); for (int i = 0; i < size; i++) { Node node = queue.poll(); currentLevel.add(node.val); if (node.children != null) { for (Node child : node.children) { queue.offer(child); } } } result.add(currentLevel); } return result; } }这里用LinkedList实现队列,offer和poll是队列接口的标准操作。在 Java 里,Queue接口虽然也有add和remove,但这两者在队列已满或为空时会抛出异常,而offer和poll会返回特殊值(false或null)。面试时用offer和poll,比add和remove更安全,也显得你更懂 API 设计背后的意图。
另一个 Java 细节:这道题给的节点定义通常长这样:
class Node { public int val; public List<Node> children; public Node() {} public Node(int _val) { val = _val; } public Node(int _val, List<Node> _children) { val = _val; children = _children; } }注意children是List<Node>,不是数组。所以遍历时直接用增强 for 循环就行,不需要处理数组下标。
3.3 进阶思考:不用队列能实现层序遍历吗
面试官最爱问的追加问题之一就是:你不用队列,能不能层序遍历?
这个问题的意图很明显,他想看你是否真的理解了“层序遍历”的本质,而不仅仅停留在“套模板”阶段。
答案是:可以,但效率不如队列。
一种不用队列的思路是:先算出这棵树的层数(或最大深度),然后对每一层,写一个递归函数,把所有深度等于target_depth的节点收集起来。
伪代码如下:
def collect_at_depth(node, current_depth, target_depth, collected): if not node: return if current_depth == target_depth: collected.append(node.val) return for child in (node.children or []): collect_at_depth(child, current_depth + 1, target_depth, collected) def level_order_without_queue(root): depth = max_depth(root) result = [] for d in range(depth): collected = [] collect_at_depth(root, 0, d, collected) result.append(collected) return result这个方法能跑,但时间复杂度是 O(n²)——最坏情况下,每一层都要把整棵树访问一遍。如果树有 n 层,每层递归访问 n 个节点,总复杂度就是 n 的平方。
这个解法可以作为“思维拓展”讲给面试官听,展示你理解多种方案,但最终要落地的还是 BFS。因为面试官问这个问题的目的,大概率是想引导你对比时间复杂度和空间复杂度,而不是真的让你在生产环境里写一个 O(n²) 的层序遍历。
3.4 复杂度分析,别只会写代码不会说原理
在面试中,代码写完只是第一步,紧接着必问的就是:时间复杂度多少?空间复杂度多少?
BFS 版层序遍历的时间复杂度是 O(n),n 是树中节点的总数。每个节点恰好入队一次、出队一次,入队出队都是 O(1),所以总时间正比于节点总数。
空间复杂度稍微复杂一点。队列中最多同时保存多少节点?答案是某一层中节点数量的最大值,也就是“最大层宽度”。在最坏情况下,比如一棵满 N 叉树,最底层可能有 n/2 个节点,所以空间复杂度是 O(n)。这个 n 是理论上限,不是平均值,所以回答时要说“最坏情况下 O(n)”而不是“平均 O(logn)”。
很多人会把空间复杂度和二叉树递归遍历的空间复杂度搞混。递归版本的空间复杂度是 O(h),h 是树高,因为递归调用栈的深度等于树高。BFS 版本的空间复杂度是 O(w),w 是最大层宽度。两者在不同形态的树面前各有优劣——细长型的树,递归更省空间;宽胖型的树,BFS 更省空间。
这个对比如果能在面试中讲出来,观感会很不一样。
4. 常见问题与排查技巧实录
4.1 “我明明按模板写了,为什么输出顺序不对”
这个问题我见过不少次。代码逻辑看起来没问题,但输出结果的层级顺序是乱的。
排查思路先把树的结构画出来。N 叉树节点的children列表顺序,就是你要访问的子节点顺序。如果题目给的输入是:
root = [1,null,3,2,4,null,5,6]对应的树结构是根节点 1,有 3 个子节点:3、2、4。节点 3 又有两个子节点:5、6。
正确层序输出是:
[[1], [3, 2, 4], [5, 6]]如果你发现输出变成了[[1], [5, 6, 2, 4]]之类的顺序,那问题大概率出在你把children顺序搞反了,或者你在递归时先访问了子树深处的节点,再访问兄弟节点。
解决办法很简单:回到 BFS 的“队列先入先出”逻辑,严格保证“先入队的节点先出队”,不要在中途做任何逆序操作。
4.2 根节点是 null 时的边界处理
LeetCode 的测试用例里可能包含root = []这种情况。输入是一个空树,预期输出是[]。
很多人会在这里跌一跤:如果不做if not root: return []的判断,代码会在queue = deque([root])这一行创建包含一个None的队列,然后进入循环后尝试访问node.val,直接AttributeError。
这种错误非常不优雅,在白板面试中一旦发生,会严重影响印象分。所以边界判断一定是第一步就写。
4.3 children 为空但结果里出现空列表
还有一种情况:树不是空树,但某一层确实没有节点,比如所有叶子节点的children都是空列表。这时候如果代码写成:
if node.children is not None: for child in node.children: queue.append(child)在children是[]而不是None的情况下,这个循环不会进入,所以不会向队列添加任何节点,也不会产生空层。
但如果你的写法是:
if node.children: queue.extend(node.children)[]被视为 False,循环同样不会执行。这两种写法都能正确处理空列表。
真正的问题出现在另一种写法:
if len(node.children) == 0: queue.append(None)有人为了避免children=None出错,强行把空列表当成一个特殊节点入队,这会导致队列里塞进None,后面处理时又得各种判断,纯属给自己添乱。
4.4 不使用 collections.deque 会怎样
如果你真的用普通 list 写:
queue = [root] while queue: node = queue.pop(0)在 LeetCode 上大概率也能通过,因为测试数据的节点数量没多到让 O(n) 的pop(0)成为性能瓶颈。但当你以后面对真实业务场景,处理大规模数据时,这种写法会明显拖慢速度。
更重要的是,在面试中,用普通 list 模拟队列,在出队操作上不是最优解,面试官有理由质疑你数据结构功底不扎实。所以别偷懒,Python 用deque,Java 用LinkedList,这是标准答案。
4.5 实战现场:我在本地跑测试时踩过的坑
拿到这道题后,我第一次在本地测试的代码长这样:
class Node: def __init__(self, val=None, children=None): self.val = val self.children = children然后我按照 LeetCode 输入格式手动建树:
node5 = Node(5) node6 = Node(6) node3 = Node(3, [node5, node6]) node2 = Node(2) node4 = Node(4) root = Node(1, [node3, node2, node4])这里没问题,但问题出在我写children参数时,漏写了默认值,导致后面遍历时出现AttributeError: 'NoneType' object has no attribute 'val'。
这个错误让我花了五分钟排查。最后才发现是Node(5)没有传children,导致默认值为None,然后在 BFS 循环里访问node.children时,for child in None直接报错。
这个坑非常适合作为经验分享:在本地构造测试数据时,一定要记得给每个叶子节点的children赋默认空列表,或者在代码里做空值保护。两种方式选一种就行,推荐在代码里做保护,因为你不能保证后续所有调用方都按你的预期传参。
4.6 刷题心得:这道题在面试中的实际定位
说实话,429 并不是一道压轴难题。它的难度在 LeetCode 里算中等偏低,大多数本科生刷题两周后都能独立写出 BFS 解法。但正因为它简单,面试官才有机会在你身上挖掘更多信息。
我见过一些候选人,这道题写得很顺,但一被追问“为什么空间复杂度是 O(n) 而不是 O(logn)”就开始支支吾吾;也有候选人代码几分钟写完了,但让他解释一下level_size为什么要快照,他说“这是套路”。
这两种回答都暴露了一个问题:不是不会写代码,而是不理解代码背后的数据结构和算法逻辑。
面试不是做题比赛。面试官看的是你解决问题的思维方式,遇到一个具体的树结构,能不能拆解成“访问顺序”和“分组边界”两个子问题,然后分别用队列和快照机制解决。
所以,与其纠结自己能不能把这道题默写出来,不如把 BFS 的每一个细节吃透。这才是“剑斩 OFFER”的意义——不是背题,而是通过简单题建立起对算法结构的敏锐度。
5. 横向延伸:从 429 看 N 叉树的通用遍历法
5.1 一个模板通吃:前序、后序、层序
N 叉树虽然节点多了些,但遍历框架跟二叉树完全一致。如果你已经掌握了二叉树的 DFS 和 BFS,N 叉树根本不需要额外学。
前序遍历的递归模板:
def preorder(node): if not node: return visit(node) for child in (node.children or []): preorder(child)后序遍历的递归模板:
def postorder(node): if not node: return for child in (node.children or []): postorder(child) visit(node)注意区别:前序遍历是先访问节点,再遍历子节点;后序遍历是先遍历子节点,再访问节点。就这么一层顺序上的差异,代表了两种完全不同的访问策略。
而层序遍历,就是我们这篇笔记主题里反复强调的 BFS 模板:
def level_order(root): if not root: return [] queue = deque([root]) result = [] while queue: level_size = len(queue) cur = [] for _ in range(level_size): node = queue.popleft() cur.append(node.val) for child in (node.children or []): queue.append(child) result.append(cur) return result这三个模板,是 N 叉树所有遍历题型的地基。
5.2 一些相关的题目,推荐按什么顺序刷
如果你打算把 N 叉树这块吃透,建议按这个顺序刷:
- LeetCode 589:N 叉树的前序遍历
- LeetCode 590:N 叉树的后序遍历
- LeetCode 429:N 叉树的层序遍历
- LeetCode 102:二叉树的层序遍历(BFS 模板源头)
- LeetCode 107:二叉树的层序遍历 II(从底向上,其实就是反转 result)
- LeetCode 199:二叉树的右视图(BFS 变体,取每层最后一个节点)
这几道题刷完,你对“层序遍历”这个词的敏感度会高很多。以后看到“右视图”“自底向上”“之字形遍历”这类题目,脑子里马上能反应出来:先 BFS 分层,再对结果做加工。
5.3 什么时候该想到递归而不是 BFS
虽然层序遍历默认 BFS,但如果你遇到的是“求树的最大深度”“判断树是否对称”这类问题,BFS 反而可能不如递归直观。
最典型的例子:LeetCode 104,二叉树的最大深度。
递归版:
def maxDepth(root): if not root: return 0 return 1 + max(maxDepth(child) for child in (root.children or []))五行代码搞定,本质是“树的高度 = 1 + 子树的最大高度”。这种问题你用 BFS 做也能做,但代码明显更长,思维也更绕。
所以,选择递归还是迭代,不是机械的“DFS 用递归、BFS 用迭代”,而是看哪种解法更能反映问题本身的递归结构。
5.4 一道隐藏在 429 背后的真实面试场景
我在带人模拟面试时,给面试者出过一道基于 429 的变体:把 N 叉树的每一层节点值倒序输出。
要求:不能先正常层序遍历再反转result,必须在遍历过程中就实现倒序。
面试者一开始毫无思路,我提示他:倒序的本质是什么?
倒序的本质不是取反,而是“先访问最右边的节点,再访问左边的节点”。在 N 叉树里,每一层的子节点是按children列表顺序排列的。如果你在 BFS 上一层节点时,不是从左到右遍历children,而是从右到左遍历,那下一层入队的顺序就自然反了。
这个变体很好地体现了“对模板的理解程度”。如果只会套模板,遇到这种改动可能就懵了;如果把“层序 = 队列顺序 + 子节点入队顺序”这两件事拆开,就会觉得这种题不过是顺手改一个循环方向。
所以 429 作为基础题的价值,不在于它本身能给你的简历增加什么亮点,而在于它帮你建立起一套树的遍历思维框架,而框架中的每一个变量——队列、快照、孩子入队顺序——都可以在真实场景中做文章。
6. 一点个人经验谈
刷到 429 这道题,是不少人算法之路上的“舒适区验证题”。如果你 102 题(二叉树层序遍历)已经吃透,这道题基本就是换个皮;如果你连 102 都没写过,那建议先停下来,把二叉树的 BFS 模板写熟,再来碰 N 叉树。
我自己的经验是,每次刷树相关的题,都不要满足于“AC 了就下一题”。大概率面试官不会直接拿原题考你,而是会出各种变体——右视图、锯齿形层序、层内分组再聚合。这些变体的核心都是 BFS 分层,而你真正需要掌握的,就是level_size这个快照的语义。
在做这题时,还可以顺手练一件事:把递归版的 DFS 层序也写一遍。你不用把它当成最优解,但写过一遍之后,你会彻底明白为什么 BFS 更适合做层序——因为递归版虽然也能输出正确结果,但它绕了一个大弯。
最后一个小技巧送给大家:在本地调试时,把树的每一层打印成一行的循环,同时打印queue的长度变化,你能非常直观地看到“每层快照”到底做了什么。这个动态过程看一次,比空想十遍都管用。刷题这种事,光看永远不够,一定要亲手敲一遍代码,亲眼看到队列的进出变化,才算真正把这道题嚼碎了。
祝各位刷题顺利,面试场上遇到 429 这一类的题,都能稳稳拿下。