尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

二叉树层序遍历:从队列原理到BFS实战应用

二叉树层序遍历:从队列原理到BFS实战应用 1. 从“遍历”到“层序”一个被低估的视角提到二叉树的遍历很多人脑子里蹦出来的第一反应就是前序、中序、后序这三种“老朋友”。无论是刷题、面试还是日常开发这三种深度优先的遍历方式几乎成了标配。但如果你认为遍历二叉树就等于这三种方式那可能就错过了一个极其重要且实用的工具——层序遍历。层序遍历顾名思义就是按“层”来访问二叉树中的节点。从根节点开始一层一层、从左到右地“扫描”整棵树。听起来很简单对吧但它的价值恰恰被这种“简单”的表象所掩盖了。它不仅仅是另一种遍历方式更是连接“树形结构”与“线性思维”的一座关键桥梁。当你需要处理具有层级关系的数据比如打印树的结构、计算树的宽度、寻找每层最大值甚至是解决一些看似与树无关但本质是层级扩散的问题如最短路径变种时层序遍历往往是那把最顺手的钥匙。我见过不少开发者对递归实现前中后序遍历如数家珍但一遇到需要按层处理的问题思路就容易卡壳要么试图用深度优先遍历强行记录深度把代码写得复杂无比要么干脆无从下手。这其实反映了一个问题我们对“遍历”的理解可能过于局限在“深度优先”这一种范式里了。今天我们就来彻底拆解层序遍历不仅让你掌握它的标准写法更要理解它背后的“队列”思想以及如何用它优雅地解决一系列实际问题。无论你是正在准备技术面试的新手还是希望夯实基础的中级开发者这篇文章都会让你对二叉树有一个新的认识。2. 核心原理为什么是队列而不是栈要理解层序遍历首先要理解它的核心数据结构队列。这是一个关键点也是它区别于前中后序遍历通常使用递归栈或显式栈的根本原因。我们可以把二叉树想象成一个组织架构图。根节点是CEO它的左右子节点是部门总监再下一层是经理以此类推。现在CEO要召开一个全员大会要求所有人按级别、同级别内按先左后右的顺序入场。你会怎么组织最直观的做法就是先让CEO第一层入场。CEO入场后他需要通知他的直接下属第二层准备。但为了保证顺序我们不能让总监们一接到通知就立刻入场因为那样会打乱层级。所以我们让CEO把两位总监的名字A和B记在一个“等待名单”上并且按照接到通知的顺序排队。CEO入场后我们从“等待名单”的最前面请出A总监入场A总监入场后同样地把他的下属第三层的名字追加到“等待名单”的末尾。接着我们再从“等待名单”中请出B总监……这个“等待名单”就是队列。它的特性是“先进先出”完美契合了我们“先访问的节点其子节点也先被访问”的需求。这个过程是循环的从队列头部取出一个节点访问。将这个节点的左、右子节点如果存在依次放入队列尾部。重复步骤1和2直到队列为空。我们用一段最基础的Python代码来演示这个过程假设我们有一个简单的二叉树节点类class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right def level_order_traversal(root): if not root: return [] result [] # 用于存储遍历结果 queue [root] # 初始化队列放入根节点 while queue: node queue.pop(0) # 从队列头部取出节点模拟出队 result.append(node.val) # 访问该节点 # 将该节点的子节点按顺序加入队列尾部 if node.left: queue.append(node.left) if node.right: queue.append(node.right) return result这段代码会输出一个一维列表包含了按层序从左到右的所有节点值。例如对于二叉树[3,9,20,null,null,15,7]输出是[3, 9, 20, 15, 7]。这就是层序遍历最朴素的形式。注意上面代码中queue.pop(0)在Python列表操作中时间复杂度是O(n)对于算法题而言效率不高。在实际编码中我们通常会使用collections.deque来获得O(1)时间复杂度的popleft操作。这里为了原理清晰先这样写后文会进行优化。那么为什么不能用栈呢因为栈是“后进先出”的。如果我们用栈假设根节点先入栈出栈访问后我们将其右子节点、左子节点依次入栈为了保证左先于右访问需要先入右再入左。那么下一次出栈的将是左子节点访问它之后又会把它的子节点入栈……这实际上就变成了深度优先遍历具体是前序的一种变体节点访问顺序会沿着一条分支深入到底无法实现我们想要的“按层平铺”的效果。所以队列的“先进先出”特性是层序遍历能够“广度优先”地扫描树结构的根本保证。理解这一点就掌握了层序遍历的灵魂。3. 标准模板与关键变体如何区分每一层基础版本虽然能按顺序访问所有节点但它丢失了一个关键信息节点属于哪一层。在很多应用场景下这是必须的。比如题目要求返回[[3], [9,20], [15,7]]这样的二维列表每一层是一个子列表。这就需要我们对标准模板进行升级。核心思路是在每一轮循环开始时我们都能知道当前队列的长度这个长度就是当前层节点的个数。我们只要在循环内部再使用一个内层循环处理完恰好这么多节点就能保证每次内层循环处理的就是同一层的节点。下面是使用deque优化后的、能够区分层级的标准模板代码from collections import deque def level_order_traversal_by_level(root): 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这个模板是解决绝大多数二叉树层序相关问题的起点。它的妙处在于level_size这个变量。在进入内层for循环之前队列里装的全是当前层的节点。我们通过len(queue)获取这个数量然后在内层循环中精确地弹出这么多节点进行处理。在处理这些节点的过程中我们会把它们的子节点即下一层节点加入队列但这些新加入的节点不会在本轮内层循环中被访问保证了层与层之间的隔离。一个常见的坑如果你在循环中直接使用while queue:并在内部popleft而不使用level_size来控制那么随着子节点的加入队列长度在变化你就无法区分哪些节点是同一层的了。结果就会变成一个扁平的一维列表或者需要引入额外的标记如插入空节点来分层后者会让代码变得复杂且不直观。所以记住这个“固定当前层长度”的技巧是写出正确层序遍历代码的关键。基于这个模板我们可以轻松衍生出一些常见的变体问题自底向上的层序遍历只需在得到result后执行return result[::-1]即可。锯齿形Z字型层序遍历在将current_level加入result时判断当前层索引的奇偶性如果是奇数层假设根节点为第0层则对current_level进行反转current_level.reverse()。计算树的深度最大层数result列表的长度就是树的深度。或者我们可以在循环中计数而不存储每层的值更省空间。4. 实战应用不止于“打印”更是解题利器掌握了标准模板我们就可以把它应用到具体问题中。层序遍历的价值在解决以下三类问题时体现得尤为明显。4.1 场景一获取二叉树的属性宽度、深度、最值很多关于树属性的问题用层序遍历来解决思路会非常清晰。问题示例二叉树的最大宽度题目要求是找到二叉树所有层中节点数的最大值。用深度优先遍历来做你需要记录每个节点的位置编号逻辑稍显绕。而用层序遍历思路直截了当在标准模板的每一层current_level的长度就是该层的宽度。我们只需要在遍历过程中用一个变量max_width不断更新记录最大值即可。def width_of_binary_tree(root): if not root: return 0 from collections import deque queue deque([(root, 0)]) # 队列中存储节点位置编号 max_width 0 while queue: level_size len(queue) _, first_pos queue[0] # 当前层第一个节点的位置 _, last_pos queue[-1] # 当前层最后一个节点的位置 max_width max(max_width, last_pos - first_pos 1) for _ in range(level_size): node, pos queue.popleft() # 为子节点分配位置编号左子节点为 2*pos右子节点为 2*pos1 if node.left: queue.append((node.left, 2 * pos)) if node.right: queue.append((node.right, 2 * pos 1)) return max_width这里引入位置编号是为了处理中间有空节点的情况计算的是该层两端非空节点之间的跨度这是该问题的一个变体。对于简单的每层节点数最大值直接用level_size更新max_width即可。4.2 场景二在二叉树中搜索与验证层序遍历的“广度优先”特性使其天然适合寻找最短路径或最近关系。问题示例二叉树的最小深度最小深度是指从根节点到最近叶子节点的最短路径上的节点数量。注意叶子节点是指没有子节点的节点。如果用深度优先遍历递归你需要遍历所有路径才能找到最短的或者用递归返回值比较代码需要仔细处理单子树的情况。而层序遍历是解决这个问题的最佳方案因为它是一层一层向外扩散的第一次遇到叶子节点时当前的层数就是最小深度。def min_depth(root): if not root: return 0 from collections import deque queue deque([root]) depth 0 while queue: depth 1 # 进入新的一层深度加1 level_size len(queue) for _ in range(level_size): node queue.popleft() # 判断是否为叶子节点 if not node.left and not node.right: return depth # 找到第一个叶子节点立即返回当前深度 if node.left: queue.append(node.left) if node.right: queue.append(node.right) return depth这种解法效率很高一旦找到目标就立即返回避免了不必要的搜索。相比之下用递归求最小深度代码虽然简洁min(minDepth(left), minDepth(right)) 1但需要理解递归终止条件处理单子树时深度是非空子树的最小深度1对初学者反而容易出错。4.3 场景三构造与操作二叉树层序遍历的结果尤其是包含空节点的完全序列化可以用来唯一地表示一棵二叉树并且可以方便地反序列化构造二叉树。问题示例二叉树的序列化与反序列化LeetCode 297题要求设计一个算法来序列化和反序列化二叉树。层序遍历是其中一种非常直观的方法。序列化时我们使用标准模板进行层序遍历但对于空节点我们也用一个特殊标记如“null”放入结果列表。这样得到的序列是一个包含所有节点包括空节点的完全二叉树列表。def serialize(root): if not root: return [] from collections import deque queue deque([root]) result [] while queue: node queue.popleft() if node: result.append(str(node.val)) queue.append(node.left) # 即使子节点为空也入队 queue.append(node.right) else: result.append(null) # 空节点用“null”表示 # 去除末尾连续的“null”使序列更简洁可选但常见做法 while result and result[-1] null: result.pop() return [ ,.join(result) ] def deserialize(data): if data []: return None vals data[1:-1].split(,) # 去掉括号分割字符串 root TreeNode(int(vals[0])) queue deque([root]) i 1 # 指针指向待分配子节点的值 while queue and i len(vals): node queue.popleft() # 构造左子节点 if vals[i] ! null: node.left TreeNode(int(vals[i])) queue.append(node.left) i 1 # 构造右子节点 if i len(vals) and vals[i] ! null: node.right TreeNode(int(vals[i])) queue.append(node.right) i 1 return root反序列化时我们同样利用队列。第一个值是根节点。然后我们依次读取序列中的值队列头部的节点就是当前需要分配子节点的父节点。我们按顺序为其分配左、右子节点并将非空的子节点入队等待后续为它们分配它们的子节点。这个过程完美复现了层序遍历构建树的过程。5. 深度优先 vs 广度优先场景化选择与性能考量现在我们已经深入了解了层序遍历广度优先搜索BFS的方方面面。是时候把它和它的“老对手”深度优先搜索DFS对应前中后序遍历放在一起看看如何根据场景做出最佳选择。核心思想对比DFS递归/栈一条路走到黑走不通再回头。它探索的是树的“深度”适合解决需要遍历所有路径、检查是否满足某种性质如路径总和、或者需要回溯的问题。它的空间复杂度通常与树的高度成正比递归调用栈的深度在树比较“瘦高”时可能有优势。BFS队列一圈一圈向外扩散。它探索的是树的“广度”适合解决“最短路径”、“最近关系”、“按层处理”的问题。它的空间复杂度取决于树最宽的那一层因为队列需要存储一整层的节点。在树比较“扁平”时空间消耗可能很大。选择策略需要结果的顺序与层级相关时选BFS。这是最直接的理由。比如“二叉树的层序遍历”、“找每层最大值”、“锯齿形遍历”BFS是天然且最简单的解法。寻找最短路径或最小深度时选BFS。正如前面最小深度的例子BFS的扩散特性保证了第一次找到目标时的路径就是最短的。DFS则需要遍历所有可能路径再比较。问题规模未知或树可能极度不平衡时需谨慎选择。如果树可能非常深例如一条链DFS的递归可能导致栈溢出而BFS的空间消耗队列中始终只有一个节点会很小。此时BFS更安全。如果树可能非常宽例如完全二叉树BFS在底层时队列需要存储海量节点约N/2可能导致内存不足。而DFS的栈深度仅为树高logN此时DFS更有优势。需要序列化/反序列化时BFS的层序序列化更直观。虽然DFS也可以如前序但层序序列化生成的字符串更容易被人眼阅读和调试反序列化的逻辑也相对直白。单纯需要遍历所有节点执行某个操作时两者皆可。此时更考虑代码简洁性。递归实现的DFS代码通常非常简短而BFS需要手动维护队列。如果操作本身简单递归DFS是更优雅的选择。一个综合案例判断二叉树是否对称这个问题可以很好地展示两种思路。一棵对称的二叉树其左子树和右子树是镜像的。BFS解法我们可以进行层序遍历在每一层检查该层的节点值序列是否对称回文。需要注意的是空节点也要用特殊值占位并参与对称性检查。DFS解法设计一个递归函数isMirror(left, right)判断两个树是否镜像。递归条件是两个根节点值相等且left.left与right.right镜像且left.right与right.left镜像。两种解法的时间复杂度都是O(n)。BFS解法需要额外的队列空间来存储节点而DFS解法需要递归栈空间。代码风格上DFS更为简洁优雅。但对于一些对递归深度有严格限制的环境BFS的迭代解法可能是更稳妥的选择。6. 避坑指南与性能优化实战理论懂了模板也会了但在实际编码尤其是在在线判题系统上解题时还是会遇到一些坑。这里分享几个我踩过的以及常见的陷阱。6.1 坑一忽视空树和单节点树的边界条件这是最基础的错误但也是最多人忘记检查的。你的函数入口必须判断if not root: return ...。对于返回列表的通常返回空列表[]对于返回整数的如深度、宽度返回0或1需根据题目定义仔细斟酌。单节点树也要确保你的循环能正常处理不会出现访问None属性的错误。6.2 坑二在循环中错误地修改遍历对象这是一个经典的Python陷阱但在其他语言中也可能遇到类似问题。看这段有问题的代码def wrong_traversal(root): result [] nodes [root] # 假设root不为None for node in nodes: # 遍历nodes列表 result.append(node.val) if node.left: nodes.append(node.left) # 错误在遍历过程中修改了正在被迭代的列表 if node.right: nodes.append(node.right) return result在for循环中nodes是一个固定的列表视图。向nodes追加元素不会影响当前正在进行的迭代但会导致逻辑混乱且可能引发无限循环或结果错误。正确的做法永远是使用队列deque并在while循环中通过popleft来动态处理。6.3 坑三使用低效的列表作为队列正如开头提到的在Python中使用list的pop(0)操作是O(n)的因为需要移动其后所有元素。当树节点很多时这会成为性能瓶颈。务必使用collections.deque。from collections import deque queue deque([root]) # 初始化 node queue.popleft() # O(1)出队 queue.append(child) # O(1)入队这是编写高效层序遍历代码的一个必须养成的习惯。6.4 性能优化空间复杂度优化技巧标准的BFS需要存储一整层节点空间复杂度在最坏情况下完全二叉树最后一层是O(n)。对于某些特定问题我们可以进行优化。双端队列Deque的另一种用法BFS的变体有些问题如“找树左下角的值”我们可能不需要严格按层处理只需要知道最后一层的第一个节点。这时我们可以采用一种“先右后左”入队的BFS变体。这样队列中最后一个出队的节点就是最后一层最左边的节点。这样我们只需要常数空间来记录当前节点而不需要存储整层节点。def find_bottom_left_value(root): from collections import deque queue deque([root]) node None while queue: node queue.popleft() # 先右后左入队保证最后访问的是最左下的节点 if node.right: queue.append(node.right) if node.left: queue.append(node.left) return node.val # 最后出队的节点即为所求DFS辅助BFS对于一些既要深度信息又要广度顺序的问题有时可以用DFS递归来模拟BFS。例如在层序遍历收集结果时我们可以在递归过程中传递当前深度level然后将节点值添加到对应level的列表中去。这样避免了使用队列空间复杂度是递归栈的深度O(h)但代码逻辑可能没有BFS直观。def level_order_dfs(root): result [] def dfs(node, level): if not node: return if len(result) level: # 第一次到达该层新建子列表 result.append([]) result[level].append(node.val) # 将节点值放入对应层 dfs(node.left, level 1) dfs(node.right, level 1) dfs(root, 0) return result这种方法在树比较“高瘦”时能节省空间但失去了BFS“找到最短路径立即返回”的优势。需要根据具体问题权衡。7. 从二叉树到更广阔的图BFS思想的延伸最后我想强调的是层序遍历的精髓——广度优先搜索BFS——远不止应用于二叉树。它是图论中最基础的算法之一。二叉树只是一种特殊的图每个节点最多有两个子节点的有向无环图。你在二叉树层序遍历中学到的“队列”、“按层扩散”、“访问标记”在二叉树中父子关系明确无需额外标记等概念是理解更复杂BFS算法的基石。当你需要处理网格中的最短路径如迷宫问题、社交网络中的好友关系六度空间、状态空间搜索如滑动拼图时背后的核心算法往往就是BFS。在这些场景中“节点”变成了网格坐标、人物、游戏状态“边”变成了上下左右移动、好友关系、合法操作。你需要一个队列来维护待访问的“边界”需要一个集合visited来记录已访问状态防止重复和死循环。例如求一个二维网格中从起点到终点的最短步数其BFS框架与二叉树层序遍历惊人地相似def shortest_path(grid, start, end): from collections import deque rows, cols len(grid), len(grid[0]) directions [(0,1), (0,-1), (1,0), (-1,0)] # 上下左右四个方向 queue deque([(start[0], start[1], 0)]) # (行列步数) visited set([(start[0], start[1])]) # 标记已访问 while queue: x, y, steps queue.popleft() if (x, y) (end[0], end[1]): return steps for dx, dy in directions: nx, ny x dx, y dy if 0 nx rows and 0 ny cols and grid[nx][ny] ! 障碍物 and (nx, ny) not in visited: visited.add((nx, ny)) queue.append((nx, ny, steps 1)) return -1 # 无法到达你看queue依然在while queue循环依然在核心逻辑依然是“取出当前扩展下一批标记已访问”。只不过“子节点”变成了四个方向上的邻居“层数”变成了“步数”。所以学好二叉树的层序遍历绝不仅仅是为了应付那几道算法题。它是在为你打开“广度优先搜索”这扇大门门后是一个可以解决无数实际问题的算法世界。下次当你面对一个需要“一圈圈扩散”、“寻找最短距离”、“按层次处理”的问题时不妨想一想这个问题能不能用队列来解
返回列表