广度优先搜索是一种探索图的替代方法,它像波浪一样从起始节点向外扩展。与深度优先搜索优先探索最近发现的节点不同,广度优先搜索优先探索在搜索中较早被发现的节点。这个简单的优先级变化导致搜索算法表现出截然不同的行为,并带来了许多有用的特性。
广度优先搜索的核心思想是采用先进先出(FIFO)的顺序探索节点,通常用队列来实现。每当搜索遇到一个之前未访问过的节点时,就将该节点加入到待探索的队列中。下次准备探索下一个节点时,不是直接查看当前节点的邻居,而是从队列前端取出节点,也就是说,总是优先选择等待时间最长的节点。
在上一章中,我们把深度优先搜索比作探险者在迷宫中探路。现在可以把广度优先搜索想象成同一个探险者采用另一种策略,细致地通过一个未来要探查的房间列表逐步前进。为了尽快访问未知领域,探险者将所有未探索的房间列在一个清单中,每发现一个新房间,就把它加到清单末尾。探险者不会贸然放弃计划冲进新发现的房间,而是先查看清单,优先访问清单顶部的未访问选项。
本章介绍广度优先搜索及其性质。特别地,广度优先搜索能找到无权图中从一个节点到所有可达节点的最短路径,使其成为许多更复杂算法中的重要组成部分。
应用场景
广度优先搜索自然契合许多现实任务,如学习新知识或探索新城市。
学习新知识
广度优先搜索为学习新知识提供了系统化的方法,强调先构建基础,再深入探索各个领域。假设你正在学习一门新编程语言。图中的每个节点代表你必须掌握的概念,节点间的边表示概念之间的关联。比如你正在阅读 Python 的章节,涉及语法和执行模型。这些概念就是当前章节的邻居,你可以立刻探索,也可以先加入待学清单,后续再学。
广度优先搜索式的学习方法会优先学习待学清单中最早加入的概念。你可能从 Python 语言的总体概念开始,接着列出语法、执行模型和运行示例程序等临近主题加入清单,逐一学习。在学习语法时,遇到列表、集合、字典等概念的引用。你不会马上跳过去看相关章节,而是把这些新概念加到清单末尾,先继续学习清单顶部的下一个主题。这样,你可以在深入学习 lambda 表达式之前,先完成一个简单的“Hello, world!”程序。
探索新城市
假设你正在探索一座新城市,逐步建立起已探索区域的知识库,并不断向未知区域扩展。某天下班后,你多走一条街区,去尝试街角的咖啡店。另一天,你怀着好奇心问:“那座山后面是什么?”随着你发现新街区,你会记录下新发现但尚未访问的地方,准备未来去探险。
相比之下,深度优先搜索的做法则是冒险者沿着一条路一直走到城市边界。这种方法可以快速扩展已知区域,但会推迟访问距离更近的地方。如果你总是选择往北走,最终可能走到离家北边 50 个街区的地方,却完全不知道家南边一个街区内就有一家绝佳的咖啡馆。
广度优先搜索算法
广度优先搜索通过维护一个节点队列,迭代地探索队列中的节点,直到队列为空。队列中的节点是从已访问节点可达但自身尚未被访问的节点。
广度优先搜索从将起始节点插入队列开始。在许多应用中,起始节点的选择是显而易见的。比如,你在浏览一部关于咖啡研磨机的在线百科,全书的第一页就是起始节点;如果你要寻找从酒店到最近咖啡店的路径,起始节点就是酒店;如果你想通过社交网络找到能帮你买演唱会票的人,起始节点就是你自己,网络的中心。在本章的示例中,我们随意选用节点0作为起始点。
在广度优先搜索的每次迭代开始时,算法会从队列中取出第一个节点并访问它。随后检查该节点的所有出边,将所有之前未访问的邻居节点加入队列。这个过程一个节点一个节点地进行,直到队列为空为止。
如果被探索的图不是完全连通的,搜索将在未访问所有节点前终止。在很多情况下,这正是我们想要的。比如,寻找酒店到咖啡店的路径时,我们并不关心无法到达的咖啡店。假设你在一个热带岛屿上,岛上有一条道路网把酒店和10家咖啡店连接起来,搜索会扩散到岛上的各个咖啡店,但不会延伸到邻近岛屿的咖啡馆。
然而,有时我们需要更全面的搜索。比如搜索关于咖啡研磨机的信息时,我们不希望因为某个页面未添加超链接就错过重要内容。我们可以通过在队列空时将第一个未探索节点加入队列,来扩展广度优先搜索以穷尽所有节点。
代码实现
广度优先搜索的代码由一个 while 循环组成,循环探索新节点直到待处理队列为空:
def breadth_first_search(g: Graph, start: int) -> list:
seen: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
pending: queue.Queue = queue.Queue()
❶ pending.put(start)
seen[start] = True
while not pending.empty():
index: int = pending.get()
current: Node = g.nodes[index]
for edge in current.get_edge_list():
neighbor: int = edge.to_node
❷ if not seen[neighbor]:
pending.put(neighbor)
seen[neighbor] = True
last[neighbor] = index
return last
breadth_first_search() 函数首先创建辅助数据结构,包括记录节点是否被访问的列表 seen,表示搜索路径的前驱节点列表 last,以及一个队列 pending。这里的队列使用了 Python queue 库中的 Queue 数据结构,因此需要在文件开头 import queue。当然,也可以用 Python 内置的列表来实现队列。在主循环开始前,代码将起始节点加入 pending 队列,并标记为已访问 ❶。
函数通过 while 循环不断探索节点,直到队列为空。每次循环,从队列前端取出一个节点,并通过 for 循环检查它的邻居。如果邻居未被访问 ❷,则将邻居加入队列,标记为已访问,并更新其前驱节点。函数最后返回 last 列表,记录了搜索过程中路径的信息。
与第4章中深度优先搜索的实现不同,后者在访问节点时才标记为已访问,本广度优先搜索在首次将节点加入队列时即标记为已访问,从而避免了队列中重复入队。
示例
图5-1展示了在一个含有10个节点的图上进行广度优先搜索的步骤。阴影节点表示已被标记为访问。每个子图显示 last 数组的状态和队列的内容,队列的前端在左侧。
图5-1(a)表示搜索开始前的状态。起始节点(0)已标记为访问并放入队列,其他节点仍标记为未访问。所有节点的回溯指针(last)均为-1,包括起始节点。
搜索在图5-1(b)中正式开始,这时它从队列中取出节点0并对其进行探索。它发现了三个邻居(节点1、5和7),将它们标记为已访问,并放入队列以便稍后探索。这样,队列就像是每天的待办事项清单:我们完成一个任务,发现它会引出更多任务,然后把这些任务添加到清单末尾。我们还更新了 last 数组,指明通往这些节点的路径。值为0表示节点0是这些节点路径上的前驱节点。
在图5-1(c)中,搜索访问节点1。该节点只有一个未访问的邻居(节点2),因为节点0已经被访问过。算法将节点2标记为已访问,加入队列,并将其在 last 数组中的值设置为1。
图5-1(d)开始显示该搜索与深度优先搜索的不同。广度优先搜索不会继续沿当前路径深入,而是探索最早被发现但尚未探索的节点。在这里,它移动到节点5。用探索迷宫的冒险者来比喻,这相当于探索冒险者待办列表顶部的房间。诚然,这在现实中可能效率不高,冒险者可能需要绕回迷宫的大部分路径才能到达那个房间。但在计算机领域,这不成问题,只要有节点的索引,我们可以轻松地加载它到内存中。
在图5-1(d)中,算法探索节点5,发现两个新的邻居,节点6和8。它将这两个节点标记为已访问,并更新它们在 last 数组中的值指向节点5。随后将6和8加入待探索节点列表末尾,稍后再探索它们。
搜索在图5-1(e)中继续,访问节点7,完成了节点0的所有邻居探索。虽然现在我们已经看到图中大部分节点,但访问过的节点都距离节点0不超过一步。搜索如同从起始节点发出的波浪一样扩散,先访问所有近邻节点,再逐渐向远处蔓延。
搜索继续进行,在后续图示中,每一步都从队列前端取出一个等待时间最长的节点进行探索,访问它,检查并处理新的邻居。搜索在图5-1(k)中完成,当最后一个节点从队列中被取出时。
寻找最短路径
广度优先搜索的一个主要优点是,它能找到起始节点到所有可达节点的最短路径(针对无权图而言)。无权图中的“最短路径”指边数最少的路径。广度优先搜索能做到这一点,得益于其优先探索路径的策略。通过使用先进先出(FIFO)的数据结构,算法有效地优先探索离起始节点最近的未探索节点。
图5-2演示了这种行为,展示了图5-1中的图,并用虚线标出了到每个节点的步数。在探索起始节点时,广度优先搜索将所有一步可达的节点入队。接着按顺序依次探索这些节点。当探索距离一步的节点时,可能会发现两步远的新节点,但这些节点总是加入队列尾部,因此在所有一步远的节点都被访问后才被探索。结果就是,广度优先搜索会先访问所有距离为 k 步的节点,然后再考虑距离为 k+1 步的节点。
在带权图中,最短路径的概念通常指的是边权重之和最小的路径。由于广度优先搜索不考虑边的权重,因此它无法找到按代价计算的最短路径。
举例来说,考虑图5-3。在探索节点2的过程中,广度优先搜索会先后访问节点1和节点2。无论通向这些节点的边权是多少,它们都会被标记为已访问,加入队列,并将路径中前驱节点设为0。算法不会寻找或发现通过节点1到达节点2的较低权重路径,但它依然能找到边数最少的路径。
我们将在本书后面章节中讨论边权重和最短路径的问题。第7章介绍了多种带权图上的最短路径算法,第8章则探讨了考虑边权重的启发式搜索算法。
简单路径规划
广度优先搜索能找到边数最少的路径,这使它在有限的路径规划任务中非常有用。考虑一个老式电子游戏中,在一个二维平面网格上规划路径的问题,其中部分格子被巨石阻挡。本节将讨论如何构建图来表示这个问题,以及运行广度优先搜索时得到的解决方案。
在有障碍物的平面上进行广度优先搜索路径规划是理解广度优先搜索操作的一个很好的例子。它还介绍了如何用图的形式来表示路径规划问题,为后续章节介绍更优的路径规划算法做准备。这些算法包括考虑边权重以模拟不同地形成本的最低代价路径算法,以及通过优先搜索最有希望路径来提升搜索效率的启发式搜索算法。
从网格构建图
构建规则网格的图表示有多种用途,从路径规划到计算机视觉。基础网格可能代表地图上的空间区域(用于路径规划或科学计算)、图像中的像素(用于计算机视觉),甚至只是节点的排列。为了本节的目的,我们聚焦于电子游戏地图。
我们通过为网格中的每个格子创建一个节点,并为每对相邻格子之间创建一条无向边,来生成基于网格的图。对于一个行数为height、列数为width的网格,首先分配height × width个节点。我们可以用网格模式在视觉上表示它们,如图5-4(b)所示,但实际上它们被存储在图数据结构中的一个列表里。
我们可以将网格中第 r 行、第 c 列的坐标映射到对应的节点索引,计算方式如下:
由于边是无向的,我们可以从左上角扫描到右下角,依次为当前节点右侧或下方的节点插入边。如果用两个循环遍历网格的 r 和 c 值,判断是否插入边的条件如下:
- 如果 ,该节点在右侧有一个邻居,索引为 。
- 如果 ,该节点在下方有一个邻居,索引为 。
清单 5-1 展示了构建基于网格的图的代码:
def make_grid_graph(width: int, height: int) -> Graph:
num_nodes: int = width * height
g: Graph = Graph(num_nodes, undirected=True)
for r in range(height):
for c in range(width):
❶ index: int = r * width + c
❷ if (c < width - 1):
g.insert_edge(index, index + 1, 1.0)
❸ if (r < height - 1):
g.insert_edge(index, index + width, 1.0)
return g
清单 5-1:创建网格的图表示
make_grid_graph() 函数首先创建一个无向图 g,为网格中的每个格子创建一个节点。接着通过两个嵌套的 for 循环遍历所有格子,计算对应的节点索引 ❶,并检查是否需要为当前节点右侧 ❷ 或下方 ❸ 插入边。最后返回构建好的图 g。
添加障碍物
在平坦平面上进行路径规划并不是一个特别有趣的任务。即使是在描述广度优先搜索时,也不过是观察访问节点的边界在网格上不断扩展而已。为了让例子更有趣,我们给网格添加障碍物。障碍物通过元组 (r, c) 的形式传入代码,表示障碍物在网格中的行和列位置。这样我们就可以创建如图 5-5 所示的网格,开放的格子是可通行的,阴影圆圈表示障碍物。
代码形式与清单 5-1 类似,但增加了对障碍物的处理:
def make_grid_with_obstacles(width: int, height: int,
obstacles: set) -> Graph:
num_nodes: int = width * height
g: Graph = Graph(num_nodes, undirected=True)
for r in range(height):
for c in range(width):
❶ if (r, c) not in obstacles:
index: int = r * width + c
❷ if (c < width - 1) and (r, c + 1) not in obstacles:
g.insert_edge(index, index + 1, 1.0)
❸ if (r < height - 1) and (r + 1, c) not in obstacles:
g.insert_edge(index, index + width, 1.0)
return g
和清单 5-1 一样,代码先创建一个无向图 g,为每个网格格子创建一个节点。然后通过两个嵌套的 for 循环遍历所有网格单元。对于每个单元格,代码先检查当前位置是否被障碍物阻挡 ❶。如果被阻挡,该节点不会有任何进出边;如果没有阻挡,则计算该节点在列表中的索引,检查当前节点右侧是否应有边 ❷,以及下方是否应有边 ❸。两次边的检查都额外要求相邻的格子不能被障碍物阻挡。
这个函数展示了通用图结构表示的强大能力,它允许我们完整捕捉环境结构,包括有效的迁移和障碍物。如果我们可以直接从一个点迁移到另一个点,图中对应的两个节点之间就有边;否则不允许迁移。我们不必单独保存图的尺寸或障碍物列表。类似方法还可以用于模拟迷宫中的墙壁。正如后续章节将介绍的,我们还能利用边权重和边的方向性来增强建模能力。
运行广度优先搜索
对于上面两节中构造的基于网格的图,我们可以直接运行广度优先搜索,无需修改。毕竟这两个函数生成的都是标准的 Graph 对象。图 5-6 显示了在图 5-5 网格上运行广度优先搜索的结果,其中 S 表示起始节点,位置在第 0 行第 0 列。
图 5-6(a) 中的网格显示了搜索访问每个网格单元的顺序。搜索从左上角开始,像一团缓缓扩散的软体生物一样向外蔓延,试图吞噬整个世界。虽然其他单元格在直线距离上很近,但搜索需要绕开障碍物,因此花费一定时间才能访问到它们。
图 5-6(b) 显示了图中每个节点的 last 指针。通过这些指针,我们可以从任意目标节点反向重建到起始节点的最短路径。例如,位于第 3 行第 2 列(标记为 9)的单元格可以通过向上、向左、向上、向左再向上的移动回到起点。我们可以反转这些指针,从而得到从起点到任意可达目标节点的最短路径。
为什么这很重要
广度优先搜索提供了一种不同的图搜索机制,表现出不同的行为。由于它探索节点的顺序是按照与起点节点之间边的数量优先排序的,它优先访问离起点最近的节点。因此,广度优先搜索会从起点向外扩展,形成一个前沿,并能找到到每个节点的最短边数路径。这种行为使得该搜索成为许多复杂图算法中的重要组成部分。
此外,广度优先搜索既简单又高效。与常见的递归实现的深度优先搜索不同,广度优先搜索的标准实现使用迭代的 while 循环而非递归函数调用来操作队列。
接下来的章节将介绍另一类图搜索算法:用于带权图中寻找最短路径的算法。这些算法是在我们目前介绍的基础搜索之上构建的,能开拓出一系列新的应用场景。