二叉树层序遍历:从BFS基础到高频变体实战解析

📅 2026/8/15 1:28:00
二叉树层序遍历:从BFS基础到高频变体实战解析
1. 项目概述从一道题到一类题的思维跃迁“二叉树的层序遍历”是LeetCode上的一道经典题目编号102。很多朋友拿到这道题第一反应可能是“这不就是BFS广度优先搜索吗用一个队列不就行了” 确实从表面上看这道题的解法非常直接甚至可以说是数据结构课程中最基础的实践之一。但如果你仅仅把它当作一道孤立的题目来“刷”完那就错过了它背后巨大的价值。这道题之所以被无数面试官青睐被列为高频中的高频正是因为它是一个绝佳的“思维锚点”和“解题模板”。它考察的远不止是你会不会写BFS而是你能否将一种基础的遍历思想灵活应用到各种复杂的变体问题上比如计算每层的最大值、执行锯齿形之字形遍历、找到每层最右侧的节点甚至是解决二叉树右侧视图、二叉树最小深度等问题。可以说掌握了层序遍历及其变体的核心思想与代码模板你就掌握了解决一大类二叉树问题的钥匙。这篇文章我将从一个多年刷题和面试官的角度带你深度拆解层序遍历不仅给出标准解法更会剖析其底层逻辑、多种实现变体、常见陷阱以及如何将其转化为解决其他问题的利器。2. 核心思路与算法选型为什么是队列在深入代码之前我们必须先搞清楚层序遍历的本质。二叉树层序遍历顾名思义就是按层、从左到右地访问树中的每一个节点。它的访问顺序是严格遵循“先来后到”的先被访问的节点的子节点也会先被访问。这种特性完美契合了“先进先出”FIFO的数据结构——队列。2.1 深度优先 vs. 广度优先场景决定选择这里涉及一个根本性的选择为什么不用深度优先搜索DFSDFS通常使用栈或递归会沿着一条分支一直深入到底再回溯。虽然通过记录深度DFS也能输出层序结果但代码逻辑不如BFS直观尤其是在需要严格区分每一层边界的时候。BFS层序遍历的优势在于直观性算法过程与我们的视觉认知一层一层看完全一致。易于处理层信息在遍历过程中我们能清晰地知道当前正在处理的是哪一层以及这一层有多少个节点这对于解决需要层级信息的变体题至关重要。最短路径关联在无权图中二叉树可视为特殊图BFS首次到达某个节点的路径就是最短路径。这关联到了“二叉树的最小深度”这类问题。因此对于“按层处理”或“寻找最短路径”这类需求BFS队列是我们的首选武器。2.2 队列操作的底层逻辑我们用队列来模拟这个过程初始化队列将根节点入队如果根节点存在。当队列不为空时循环执行 a. 记录当前队列的长度levelSize。这一步是关键中的关键它代表了当前层节点的数量。 b. 创建一个临时列表用于存储当前层的节点值。 c. 循环levelSize次每次从队首弹出一个节点将其值加入临时列表然后将其非空的左子节点和右子节点依次入队先左后右保证顺序。 d. 将存储了当前层节点值的临时列表加入最终的结果列表。返回结果列表。这个“记录当前队列长度”的操作是区分不同层的核心技巧。如果不记录队列中就会混杂着不同层的节点无法区分。注意在循环开始前记录队列长度而不是在循环内动态判断queue.size()是因为内层循环中队列的长度是在不断变化的弹出旧节点加入新子节点。提前固定循环次数就相当于为当前层拍了一张“快照”。3. 标准解法与代码精讲我们以LeetCode 102题为例提供Python、Java和C三种语言的实现并逐行解析。3.1 Python实现使用 collections.dequefrom collections import deque from typing import List, Optional class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right class Solution: def levelOrder(self, root: Optional[TreeNode]) - List[List[int]]: if not root: return [] result [] queue deque([root]) # 使用deque作为队列初始化时放入根节点 while queue: level_size len(queue) # 关键步骤记录当前层的节点数 current_level [] # 存储当前层的值 for _ in range(level_size): node queue.popleft() # 弹出队首节点 current_level.append(node.val) # 将子节点入队先左后右 if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) # 将当前层结果加入最终列表 return result代码精讲与避坑点deque的使用Python中list的pop(0)操作是O(n)复杂度因为需要移动后续所有元素。collections.deque的popleft()和append()是近似O(1)的操作性能更优是实现队列的首选。类型提示Optional[TreeNode]表明root参数可以是TreeNode类型或None这符合LeetCode的输入规范空树输入[]。边界检查第一行的if not root:处理了空树的特殊情况直接返回空列表。这是一个良好的防御性编程习惯。循环变量内层for循环使用了_作为变量名这是一个惯例表示我们不在意循环索引的值只关心循环次数。3.2 Java实现使用 LinkedList 作为 Queueimport java.util.*; class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val val; this.left left; this.right right; } } class Solution { public ListListInteger levelOrder(TreeNode root) { ListListInteger result new ArrayList(); if (root null) { return result; } QueueTreeNode queue new LinkedList(); queue.offer(root); // 入队根节点 while (!queue.isEmpty()) { int levelSize queue.size(); // 关键步骤 ListInteger currentLevel new ArrayList(levelSize); // 预设容量小优化 for (int i 0; i levelSize; i) { TreeNode node queue.poll(); // 出队 currentLevel.add(node.val); if (node.left ! null) { queue.offer(node.left); } if (node.right ! null) { queue.offer(node.right); } } result.add(currentLevel); } return result; } }代码精讲与避坑点Queue接口Java中Queue是一个接口LinkedList是其常用实现。使用offer()入队和poll()出队这些方法在队列为空时返回null比add()/remove()更安全。泛型与类型安全ListListInteger和QueueTreeNode使用了泛型保证了编译时的类型安全。初始化容量new ArrayList(levelSize)在创建当前层列表时预设了初始容量。虽然对于小数据量影响不大但这是一种好的优化习惯可以避免ArrayList在内部数组扩容时的数据拷贝开销。3.3 C实现使用 std::queue#include vector #include queue using namespace std; struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode() : val(0), left(nullptr), right(nullptr) {} TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} }; class Solution { public: vectorvectorint levelOrder(TreeNode* root) { vectorvectorint result; if (root nullptr) { return result; } queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); // 关键步骤 vectorint currentLevel; currentLevel.reserve(levelSize); // 预留空间优化 for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); currentLevel.push_back(node-val); if (node-left ! nullptr) { q.push(node-left); } if (node-right ! nullptr) { q.push(node-right); } } result.push_back(move(currentLevel)); // 使用move转移避免拷贝 } return result; } };代码精讲与避坑点std::queueC标准库中的queue是一个容器适配器默认基于deque实现。使用push()入队front()获取队首pop()出队。reserve优化currentLevel.reserve(levelSize)为vector预留内存空间避免在push_back时多次重新分配和拷贝提升性能。使用moveresult.push_back(move(currentLevel))将currentLevel的内容“移动”到result中而不是拷贝。循环结束后currentLevel本身失效但数据已转移这消除了不必要的拷贝开销是C11及以后版本的常用优化技巧。指针与空指针注意C中使用指针TreeNode*判断空指针使用nullptr。4. 复杂度分析与为什么它高效理解算法复杂度是评估解法的关键也能帮助你在面试中脱颖而出。时间复杂度O(n)。其中n是树中的节点数量。每个节点恰好会被访问一次出队一次并且每个节点最多会被放入队列一次当其父节点被访问时。因此主循环和内部循环的总操作次数与节点数成线性关系。空间复杂度O(n)。空间消耗主要来自队列queue。在最坏情况下即当树是一棵完全二叉树时队列中最多会容纳大约n/2个节点最后一层之前的节点都已出队最后一层的节点几乎全部在队列中。因此空间复杂度是 O(n)。这个复杂度对于二叉树遍历来说是最优的因为你至少需要访问每个节点一次。BFS队列实现在时间和空间上都达到了理论下限。5. 经典变体与应用场景实战掌握了标准模板我们就可以像玩“乐高”一样通过微调来解决一系列问题。这才是层序遍历真正的威力所在。5.1 变体一二叉树的锯齿形层序遍历LeetCode 103问题描述要求先从左到右下一层再从右到左以此类推呈锯齿形或之字形顺序遍历。解题思路 核心在于区分奇数层和偶数层。我们仍然使用队列进行BFS来保证层级顺序但在将每一层的结果存入最终列表时根据层数的奇偶性决定是正序添加还是逆序添加。设置一个布尔标志is_left_to_right初始为True表示第一层从左到右。在每层遍历结束后根据is_left_to_right的值决定是否反转currentLevel列表然后再加入result。每处理完一层将is_left_to_right取反。Python代码片段def zigzagLevelOrder(self, root: Optional[TreeNode]) - List[List[int]]: if not root: return [] result [] queue deque([root]) left_to_right True while queue: level_size len(queue) current_level [] for _ in range(level_size): node queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) # 根据方向决定是否反转当前层结果 result.append(current_level if left_to_right else current_level[::-1]) left_to_right not left_to_right # 切换方向 return result实操心得这里选择在存储最终结果时反转列表而不是在遍历节点时改变入队顺序。因为改变入队顺序如先右后左会影响下一层节点的正确顺序破坏BFS的层级结构让问题复杂化。在结果层进行反转是更清晰、更安全的做法。5.2 变体二二叉树的层平均值LeetCode 637问题描述计算二叉树每一层节点值的平均值。解题思路 这几乎是标准层序遍历的“简化版”。我们不需要保存每一层的所有值只需要在遍历每一层时累加该层所有节点的值最后除以该层节点数即可。Java代码片段public ListDouble averageOfLevels(TreeNode root) { ListDouble result new ArrayList(); if (root null) return result; QueueTreeNode queue new LinkedList(); queue.offer(root); while (!queue.isEmpty()) { int levelSize queue.size(); double levelSum 0.0; // 使用double防止整数溢出和精度丢失 for (int i 0; i levelSize; i) { TreeNode node queue.poll(); levelSum node.val; if (node.left ! null) queue.offer(node.left); if (node.right ! null) queue.offer(node.right); } result.add(levelSum / levelSize); } return result; }注意事项levelSum务必使用double类型。即使节点值是整数平均值也可能是小数。使用int会导致向下取整得到错误结果。5.3 变体三找树左下角的值LeetCode 513问题描述给定一个二叉树的根节点找出该二叉树最后一行的最左边的值。解题思路 层序遍历非常适合解决这个问题因为我们能清晰地知道最后一层是什么。只需要在遍历每一层时记录该层的第一个节点即最左边的节点。当遍历完成时最后记录的那个节点值就是答案。因为BFS是一层一层向下的最后一层记录的第一个节点就是最后一行最左边的节点。C代码片段int findBottomLeftValue(TreeNode* root) { if (root nullptr) return -1; // 根据题意假设树非空这里处理边界 queueTreeNode* q; q.push(root); int bottomLeftVal root-val; // 初始化根节点就是第一层最左 while (!q.empty()) { int levelSize q.size(); // 每次循环开始队列里的第一个节点就是当前层的最左节点 bottomLeftVal q.front()-val; for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); // 注意这里只需要在循环开始前记录最左值循环内正常入队子节点即可 if (node-left) q.push(node-left); if (node-right) q.push(node-right); } // 循环结束时如果队列为空说明刚才处理的就是最后一层 // 此时 bottomLeftVal 记录的就是最后一层的最左值 } return bottomLeftVal; }踩坑记录我曾见过一个常见的错误实现是在内层for循环中通过判断i 0来记录每一层的最左值这本身没错。但关键在于循环结束后bottomLeftVal存储的是当前层的最左值。当整个while循环结束时bottomLeftVal自然就是最后一层的最左值。不需要额外的变量或标志位来判断是否是最后一层BFS的过程已经保证了这一点。5.4 变体四填充每个节点的下一个右侧节点指针LeetCode 116问题描述给定一个完美二叉树将所有同一层的节点用next指针连接起来。解题思路 这题将层序遍历的应用提升到了“修改树结构”的层面。我们依然使用队列进行BFS。在遍历每一层时除了第一个节点当前出队的节点node的next指针应该指向此时队列的队首元素因为队首元素是同一层的下一个节点。当遍历到该层最后一个节点时其next应指向null在代码中自然达成因为下一轮循环队首已是下一层的节点。Python代码片段Node定义略def connect(self, root: Optional[Node]) - Optional[Node]: if not root: return None from collections import deque queue deque([root]) while queue: level_size len(queue) for i in range(level_size): node queue.popleft() # 关键如果不是该层最后一个节点则连接next if i level_size - 1: node.next queue[0] # 队首是下一个节点 # 入队子节点 if node.left: queue.append(node.left) if node.right: queue.append(node.right) return root核心技巧连接next指针的动作发生在节点出队之后、其子节点入队之前。我们利用i当前节点在层内的序号和level_size来判断它是否是当前层的最后一个节点。queue[0]巧妙地指向了同层的下一个节点因为我们已经将当前节点pop出去了。6. 常见问题、调试技巧与性能优化6.1 为什么我的代码陷入了死循环或结果重复可能原因及排查忘记处理空树这是最常见的错误。如果root为null/None应直接返回空结果否则尝试访问root.left或root.val会导致运行时错误。队列中混入了None在将子节点入队时没有判断子节点是否为空。这会导致队列中出现None后续对None进行.val或.left操作时崩溃。务必在if node.left:和if node.right:判断后再入队。levelSize计算位置错误必须在while循环开始、内层for循环之前计算levelSize。如果放在内层循环中动态获取queue.size()队列长度在循环中是变化的会导致逻辑混乱。使用了错误的数据结构在Python中错误地使用list并执行pop(0)虽然逻辑正确但在大数据集上会因性能问题“假死”感觉像死循环。调试技巧在关键位置打印日志。例如在while循环开始时打印队列长度: len(queue)在内层循环打印处理节点值: node.val。这能帮你清晰看到算法的执行流程。使用一个小型二叉树例如[3,9,20,null,null,15,7]手动模拟算法在纸上的运行一步步跟踪队列和结果的变化。6.2 如何应对非常大的二叉树空间优化思考标准BFS的空间复杂度是 O(n)在最坏情况下完美二叉树队列需要存储约一半的节点。如果树非常大例如百万级节点这可能会成为内存瓶颈。优化思路DFS递归层高记录虽然DFS不是按层遍历的天然选择但我们可以通过递归传递深度信息将节点值添加到对应深度的列表中。这样空间复杂度取决于递归栈的深度即树的高度 O(h)在平衡树中为 O(log n)。但缺点是结果列表中的层序可能不是严格的“从左到右”取决于递归顺序前序、中序、后序需要小心处理。双指针法Next Pointer优化后对于像LeetCode 116连接Next指针这样的问题一旦我们利用next指针将同一层连接成了链表就可以不使用队列进行下一层的遍历空间复杂度可以降为 O(1)。但这依赖于题目允许修改树结构。结论在绝大多数面试和实际场景中使用队列的BFS解法是标准、清晰且完全可接受的。只有在面试官明确追问空间优化时再引出DFS或其他方法进行讨论。6.3 如果面试官问“能用递归做吗”这是一个很好的进阶问题旨在考察你对问题本质和不同算法范式的理解。递归DFS解法思路我们定义一个递归函数dfs(node, depth)其中depth表示当前节点所在的深度根节点深度为0。如果当前节点为空返回。如果结果列表result的长度等于depth说明我们是第一次到达这个深度需要在result中为这一层新建一个空列表。将当前节点的值node.val添加到result[depth]对应的列表中。递归处理左子树和右子树深度depth 1。Python递归代码def levelOrder(root): result [] def dfs(node, depth): if not node: return if len(result) depth: # 该层尚未创建列表 result.append([]) result[depth].append(node.val) # 将节点加入对应层 dfs(node.left, depth 1) dfs(node.right, depth 1) dfs(root, 0) return result与BFS对比优点代码简洁空间复杂度为递归栈深度 O(h)。缺点结果列表中每一层节点的顺序取决于递归遍历的顺序这里是前序根-左-右。对于层序遍历要求的严格“从左到右”顺序前序DFS是满足的因为总是先访问左子节点再访问右子节点。但理解起来不如BFS直观且对于极不平衡的树退化成链表递归深度可能导致栈溢出。在面试中可以先给出BFS解法然后主动提及“这道题也可以用深度优先搜索的递归方式来解其核心思想是……”。这展示了你的知识广度。7. 从层序遍历到更广阔的图景通过以上拆解我们可以看到“二叉树的层序遍历”绝不仅仅是一道题。它是一个强大的算法模板和思维模型。横向关联队列Queue是BFS算法的核心数据结构。BFS在图论中用于寻找无权图的最短路径在二维矩阵中用于“岛屿数量”、“腐烂的橘子”等扩散类问题。二叉树层序遍历是你理解BFS算法最直观、最基础的训练场。纵向深入基于这个模板你可以轻松解决LeetCode上大量的衍生题目107. 二叉树的层序遍历 II自底向上输出只需将标准结果列表反转。199. 二叉树的右视图每层只取最后一个节点。429. N叉树的层序遍历将处理两个子节点的逻辑变为遍历一个子节点列表。515. 在每个树行中找最大值在每层遍历中维护一个最大值。1161. 最大层内元素和计算每层和并找出最大值所在的层。我个人的体会是刷题切忌孤立地记忆每一道题的答案。像层序遍历这样抓住一个核心模板深入理解其每一种变体并思考其背后的原理和适用场景才是高效学习的方法。下次当你遇到一道新的二叉树问题时不妨先问问自己“这个问题和树的层级有关吗能不能用层序遍历的框架来思考” 很多时候思路会豁然开朗。最后分享一个我自己的小习惯在实现BFS时我总是先在纸上画出队列和树的结构手动模拟前两层的运行过程确认levelSize的用法和子节点入队的逻辑无误后再开始写代码。这个习惯帮我避免了许多低级错误。