快乐方法学图算法-全-

快乐方法学图算法(全)

原文:zh.annas-archive.org/md5/976ae468bca09206d39b31bbd6421399

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是一本面向程序员的图及其算法入门书,旨在帮助读者理解并应用这些算法。图是一种数据结构,广泛应用于数学、计算机科学以及许多其他领域,用来建模和解决各种现实世界中的问题。图的结构使我们能够表示项目之间的连接或关联。理解这种结构对于充分利用图的强大功能并高效地使用它们至关重要。

Graph Algorithms the Fun Way(《有趣的图算法》)源自我之前的书《Data Structures the Fun Way》(《有趣的数据结构》)(No Starch Press,2022)中的图章节,当时我写道:“我们可以为这一单一且深远影响的数据结构专门写一本书。”然而,这本书仍然只是触及了图算法的激动人心且强大的世界的表面,这是一个历史悠久且仍在研究的领域。全面覆盖所有图技术及其相对优点将需要多卷巨著,而且一旦印刷就会过时。相反,这本书旨在为首次接触这一激动人心领域的人提供基础。

本书首先介绍图的组成部分,然后深入探索各种图算法及其在现实世界问题中的应用。这不仅仅是一本常见算法的食谱。它的目标是帮助读者理解算法背后的思想,并培养出将书中概念应用于其他技术的直觉。

这本书适合谁?

本书适用于那些希望深入了解图、图算法及其背后计算思维的程序员。我假设读者没有图或图算法的基础知识。然而,读者应具备通过入门课程、书籍或训练营所能预期的基本 Python 知识。他们应熟悉基本的 Python 编程概念,包括列表和字典等基本数据结构。

我希望这本书对广泛的读者群体都有用,而不仅仅是第一次学习图算法的程序员。书中使用的示例和隐喻旨在提供一种替代视角,以不同于标准数学定义的方式来看待这些主题。高级学生和经验丰富的计算机科学家可能会发现一种新视角,帮助他们理解特别困难或棘手的课题。

类比和示例

本书通过一系列真实世界和荒诞的示例与类比来补充正式的描述和代码。图的结构使它们非常适合用来通过冒险者探索迷宫或规划未知城市的旅行故事来展示算法。这些示例和类比的目标是双重的。首先,它们激发了算法本身,并阐明了我们为什么关心它们所解决的问题。其次,它们提供了另一种可视化这些问题的方法,帮助读者摆脱技术细节和琐碎问题。

语言与编码规范

我选择使用 Python 来展示示例代码,因为它的广泛使用和可读性。然而,其他语言的爱好者不必担心,因为代码背后的概念是与语言无关的。图算法已经在多种语言中实现,本书中的所有代码示例都可以超越 Python 进行适配。

本书中的代码遵循常见的 Python 编程规范。为了让代码更加清晰,我使用了类型提示,如以下代码块所示:

def is_edge(self, from_node: int, to_node: int) -> bool:
    return self.get_edge(from_node, to_node) is not None 

输入参数列出了预期的类型,如 int,函数定义描述了预期的返回类型(bool)。

本书中的代码使用了多个核心 Python 库。由于文件中的多个函数通常使用相同的库,因此单个代码片段并没有明确包含 import 语句。实现代码的用户需要确保导入相关的库。若有歧义,我将在代码的文字描述中指出所需的库。

本书中使用的标准 Python 库包括:

csv

copy

itertools

math

队列

random

typing(用于 Union)

typing 库特别用于一些代码片段,以支持多个返回值的函数类型提示。

此外,附录 B 定义了一个自定义的 PriorityQueue 类,供多个示例使用,附录 C 定义了一个简化的 UnionFind 数据结构。

本书中的代码结构尽可能独立运行,只需要这些核心的 Python 库。这意味着我有时不会利用现有的优秀库。例如,在第一章中,我将矩阵表示为列表的列表,而不是利用专门优化矩阵操作的numpy库。我会指出现有库可以很好地适配的情况,但将这些库的集成作为读者的练习。

我还将书中的许多实现写得比严格必要的更加冗长,以便更好地关注背后的计算概念。这意味着,单个实现可能被分解为额外的阶段,以便说明计算概念,或结构方式与解释相匹配,或者可能没有完全优化。此外,为了保持示例的简洁,我通常省略了对于生产程序至关重要的基本有效性检查。请将这些示例视为一般概念的插图,而不是可以照抄到自己项目中的代码。

术语和定义

由于图算法在多个领域中都被研究,因此有时同一基本概念会有多个术语。例如,图中的连接通常也被称为。我在引入每个概念时会进行定义,并指出读者在其他参考资料中可能会看到的一些替代名称。

在其他情况下,相同的术语在不同领域中有不同的使用方式。特别是,在数学的正式图论和计算机科学研究中,几个关键术语的定义多年来发生了偏差。例如,在数学中,图中的路径不能包含重复的节点,而在计算机科学中,通常是可以的。当定义不同时,我通常使用计算机科学中的常见用法,并在文本中注明差异。

如何使用本书

本书结构是渐进式的,后续章节建立在前面章节的基础上。第一部分建立了后续章节依赖的概念基础:

第一章:图的表示介绍了图的结构,讨论了邻接表和邻接矩阵的图表示,并提供了全书其他部分使用的实现方法。

第二章:邻居与邻域涵盖了邻接节点的核心概念、构建邻居集合的基本算法以及一些理解节点周围局部连接性的基本度量。

第三章:图中的路径讨论图中的路径,并介绍了多种表示方法,包括节点列表、边列表和回指针列表。

后续章节相互独立,但仍会调用前面章节的概念。每一章节都围绕一个主题进行组织。第二部分重点介绍图中的搜索和最短路径:

第四章: 深度优先搜索 介绍了两种深度优先搜索的实现方法,一种是递归方法,另一种是基于栈的迭代方法,同时还讨论了如何在深度优先搜索树中编码搜索信息。

第五章: 广度优先搜索 探讨了广度优先搜索,讨论了其特性,并展示了如何利用该算法在无权图中找到最短路径。

第六章: 解谜 展示了如何使用图来编码谜题,并利用第四章和第五章中的搜索算法来解决这些谜题。

第七章: 最短路径 介绍了三种用于在加权图中寻找最短路径的算法:Dijkstra 算法、Bellman-Ford 算法和 Floyd-Warshall 算法。

第八章: 启发式搜索 描述了两种基于启发式的搜索方法,启发式贪心搜索和 A*搜索,并展示了它们如何利用关于节点潜力的启发式信息。

第三部分重点介绍图中的连通性和顺序问题:

第九章: 拓扑排序 讨论了将图的节点按拓扑顺序排序的问题,并介绍了两种实现该任务的算法:Kahn 算法和深度优先搜索的扩展版本。

第十章: 最小生成树 描述了两种用于寻找图中最小生成树的算法:Prim 算法和 Kruskal 算法,并展示了 Kruskal 算法背后的思想如何扩展到生成可解迷宫或聚类空间数据等问题。

第十一章: 桥和关节点 检视了基于深度优先搜索的算法,用于在图中找到桥和关节点。

第十二章: 强连通分量 探讨了 Kosaraju-Sharir 算法,用于识别有向图中的强连通分量。

第十三章: 随机游走 介绍了图上的随机游走,并讨论了马尔可夫链的概念,接着展示了如何在图中实现随机游走行为并从观察数据中估计出潜在的图结构。

第四部分介绍了图中的流量概念,并用它来解决特定的匹配问题:

第十四章: 最大流算法 定义了图中流量的概念和最大流问题,介绍了一种扩展的图数据结构以支持该问题,并描述了解决最大流问题的 Ford-Fulkerson 算法和 Edmond-Karp 算法。

第十五章: 二分图匹配介绍了图中的匹配任务和二分图的概念,然后重点讨论了在二分图中进行匹配的专门化方法。我们展示了如何使用最大流算法来解决二分图上的一种匹配问题。

第 V 部分通过图探索各种节点分配和路径规划问题:

第十六章: 图着色介绍了为图的节点分配颜色的问题,要求相邻的节点不能共享相同颜色,并探讨了解决该问题的多种算法。

第十七章: 克里克、独立集和顶点覆盖介绍了三种计算上具有挑战性的分配问题的算法:寻找最大克里克、寻找最大独立集和寻找最小顶点覆盖。

第十八章: 图的巡回探讨了三种路径规划问题:找到经过每个节点恰好一次的路径,找到经过每个节点恰好一次且最小化经过的边权重的路径,以及找到经过每条边恰好一次的路径。我们描述了前两个问题的困难性,但第三个问题有高效的解决方案。

附录提供了额外的函数和数据结构,有助于实现本书中的算法:

附录 A描述了程序化创建图的函数,包括从文件加载图。

附录 B定义了本书中算法所使用的可修改优先队列数据结构。

附录 C介绍了一个最小的UnionFind数据结构,必要时用于实现第十章中的一些算法。

在本书中,读者应关注如何?为什么? 如何将这个现实问题映射到图的表述中?为什么某种方法能帮助我们计算解决方案?如何利用图的结构来解决问题?为什么我们关心这个问题?如何将这些算法应用于不同的问题?为什么作者使用那个荒谬的类比?理解这些问题的答案将为你提供有效使用现有算法并在未来开发自己技术的基础。

第一部分 图形基础

第一章:1 图的表示

是一种抽象数据类型,可以通过多种数据结构实现。本章介绍了图的基本组成部分——节点和边,然后展示了如何构建两种最常见的图表示方法:邻接表和邻接矩阵。理解图的结构和组成对于利用其强大功能并设计高效的算法至关重要。

为了实现图,我们定义了几乎本书中所有算法所依赖的 Edge、Node 和 Graph 类。我们讨论了这些类存储的信息,并提供了与这些对象交互的函数。我们还讨论了不同实现之间的权衡,以及可能的替代方案和混合方案。

图的结构

图由两个部分组成:节点和边。节点(也称为 顶点)表示图中的位置或项目。节点通常用于表示具体的实体,如人、城市或计算机。(也称为 链接)将一对节点连接在一起,定义了图中节点之间的相对连接。它们用于表示具体的项目,如城市之间的道路,以及抽象的概念,如两个人之间的友谊。

图 1-1 显示了一个包含五个节点和七条边的示例图。我们使用标准的图形表示法,节点表示为圆形,边表示为连接两个圆形的线。书中的许多图形也在每个圆形内包含标签,以识别各个节点。

七条线,表示边,连接成对的圆形。

图 1-1:一个包含五个节点和七条边的图

用数学符号描述图时,我们使用 V 来表示节点的集合,使用 E 来表示边的集合。节点和边的数量用集合的大小的数学符号表示,这意味着节点的数量是 |V|,边的数量是 |E|。

利用这些简单的组件,我们可以表示出令人惊讶的大量现实世界系统,并回答一系列现实世界中的实际问题。例如,图允许我们建模以下场景:

**交通网络 **节点是城市,边表示路径。我们可以计算两点之间的最短路径,或者寻找单一的故障点,这些故障点会切断网络的某一部分与另一部分的连接。

**迷宫 **节点是交叉口,边是连接它们的走廊。我们可以搜索迷宫中的路径。

教育话题 每个节点是一个话题,边连接两个相关的话题。我们可以根据先修知识对话题进行排序。

社交网络 节点是人,边是他们的友谊连接。我们可以通过网络建模信息流动,以预测谣言如何传播。

我们可以通过允许边提供额外的信息,如方向性和权重,进一步增强图的功能,具体内容将在以下小节和后续章节中讨论。

加权边

在几乎每个现实世界的交通网络中,遍历不同的边会有不同的成本。例如,我们可能会测量这个成本为距离或油费;无论如何,从波士顿开车到纽约的成本要低于从波士顿开车到洛杉矶的成本。成本度量也可能更复杂,例如考虑到在山间窄而弯曲的道路上行驶的压力。或者,对于某些问题,我们希望考虑成本的反面,例如节点之间连接的强度或沿特定边行进的收益。考虑边的成本或收益对于准确解决许多图问题至关重要,例如找到两地之间最短(或最不吓人的)路径。

加权边不仅捕捉节点之间的连接,还反映了遍历这些连接的成本或收益。对于某些应用,权重是显而易见且容易获得的,例如城市之间的距离。例如,我们可以为匹兹堡和克利夫兰之间的边赋予 133 的权重,表示这两个城市之间的公路长度为 133 英里。在其他情况下,我们可能使用权重来表示更抽象的概念,如友谊的强度。例如,Tina 和 Bob 之间的连接权重为 10,可能表示他们是最好的朋友,而 Tina 与 Alice 之间的连接权重为 1,则表示他们只是泛泛之交。通常可以从上下文中明确判断权重是表示成本还是收益。

我们称具有加权边的图为加权图,而没有加权边的图为无权图。我们通过在表示边的线旁边添加数字标签来直观地表示边的权重。例如,在图 1-2 中,三条边的权重为 1.0,一条边的权重为 2.0,剩下的三条边的权重为 3.0。

该图有五个节点和七条边,每条边都标有数字权重。上面两条边的权重分别是 1.0 和 2.0。

图 1-2:一个加权图

如果需要,我们可以使用加权图来模拟无权边,方法是为所有边使用相同的权重(如 1.0),或在算法中忽略权重属性。

有向边

在某些系统中,节点之间的连接不是对称的。例如,考虑从建筑物水加热器到厨房水龙头的管道。除非管道损坏严重,否则水不可能流入水龙头再回到水加热器。

有向边表示两个节点之间连接的方向性。我们使用的术语类似于现实世界的交通网络:有向边开始的节点是起点或 from_node,而有向边指向的节点是终点或 to_node。

虽然有向边可以表示物理方向性,例如单行道,我们也可以用它们来建模更抽象的方向性,例如教育机构中的先决课程。如果每个节点是一个课程,则有向边可能表示我们需要先修计算机科学导论课程,再修高级图算法课程,如图 1-3 所示。

两个框表示课程描述,第一个框是 CS100:计算机科学导论,没有先决条件。第二个框是 CS401:高级图算法,列出了 CS100 和 CS201 作为先决条件。从第一个框指向第二个框的箭头,另外两个箭头指向 CS401,表示两个其他先决条件,并有省略符号。

图 1-3:显示课程先决条件方向性的箭头

我们称带有有向边的图为有向图。没有有向边的图(如图 1-1 和 1-2)被称为无向图,其中包含无向边

我们可以使用有向边来扩展我们之前的社交网络模型。虽然理想情况下所有的友谊都是互惠的,但遗憾的是,情况并非总是如此。蒂娜和鲍勃可能会互称对方为最好的朋友。然而,虽然爱丽丝把蒂娜视为亲密朋友,蒂娜却只是把爱丽丝当作工作上的一个熟人。

图 1-4 展示了一个带有有向边的示例图,其中每条有向边以单箭头表示其方向。

一个包含五个节点和八条有向边的图形。每条边显示为一个或两个箭头。

图 1-4:一个有向图

我们可以通过使用每个方向的一对有向边来表示有向图中节点之间的对称或无向关系,如图 1-4 中底部两个节点之间所示。这使我们能够建模具有有向和无向关系的混合系统。例如,现实世界的交通网络包含单行道和双行道的混合,许多社交网络也包含互相的友谊。通过使用有向图和相应的边对,我们可以完整地建模这些系统。

同时具有权重和方向的边

为了最大化图的表现力,我们可以结合使用带权重的边和有向边,如图 1-5 所示。这种表示方法使得图能够同时捕捉每个连接的方向性以及成本与效益的关系。

一个包含五个节点和八条有向边的图。每条边显示为一个或两个箭头,并且每个箭头标注有代表权重的数字。

图 1-5:一个有向且加权的图

我们必须为两个节点之间的每条有向边指定一个单独的权重,但正如图 1-5 中底部一对节点所示,这些权重不必相等。例如,在建模道路的通行成本时,我们可能会选择为上坡方向设定一个比下坡方向高得多的成本。根据应用的不同,比如规划骑行旅行,上坡道路的成本可能会高很多。类似地,Tina 和 Alice 对她们的友谊赋予了不同的重视程度。

本书中,我们将使用支持权重和方向性的图实现。如果需要,这些数据结构仍然可以用于存储没有加权或没有方向的简单图。尽管这种通用性为数据结构带来了一些小的复杂性,并且可能会为那些不使用所有信息的算法增加额外开销,但这种方法使得数据结构更加灵活,能够被多种算法所使用。

邻接表表示法

本书大部分内容中使用的图表示法是邻接表表示法,它通过为每个节点存储一组邻居的单独列表来表示图的结构。这使得我们能够模拟现实世界中的现象,其中每个节点维护关于其本地连接的信息,比如社交网络中个人为他们的直接朋友维护联系信息。

有多种方法可以实现邻接表表示法。节点和边可以通过关联隐式地表示,或者作为独立的数据结构显式表示。在最简单的实现中,我们可以通过一个单独的列表列表隐式存储图,其中每个节点都有一个数字 ID,每个列表条目对应一个给定节点的邻居。例如,考虑以下代码行:

g: list = [[1,3,4], [0,2,4], [1,4], [0,4], [0,1,2,3]] 

这个列表中的列表g表示图 1-6 中所示的无向、无权图,包含五个节点和七条边。图右侧列表中的第一个条目表示节点 0 有三个邻居:节点 1、3 和 4。每条无向边在两个不同的邻接列表中都有表示,分别对应边的两端节点。

左图显示了一个包含七条边和五个节点的图,节点的标签为 0 到 4。图中的节点 0 与节点 1、3 和 4 相连。右图显示了一个数组,其中每个元素指向一个列表,表示该节点的邻居。数组中的元素 0 指向包含数字 1、3 和 4 的列表。

图 1-6:一个图(左)及其邻接表表示(右)

另外,我们可以创建一个包含不仅是邻接表,还有附加信息的Node数据结构。这可能包括一个标签来标识节点,一个布尔值表示节点是否已处理,或者一个整数表示我们第一次看到该节点的时间。我们还可以通过定义一个Edge数据结构来存储关于方向性和权重的信息,然后在每个节点内存储一个相邻边的列表,从而使表示更加详细。

任何给定使用场景的最佳表示方式在很大程度上取决于数据结构的目的。对于内存有限的环境中的大规模图,像图 1-6 中的邻接表这样的稀疏表示可能是理想的。然而,在建模更复杂的问题时,例如不同路况下的交通流向,我们可能需要存储更多的信息。

本节的其余部分介绍了一种高度结构化的邻接表表示方法,优先考虑通用性和可理解性,以便我们能够在本书的不同算法中重复使用它。我们使用Edge和Node对象来便于存储这两者的各种辅助信息。每个Node对象维护自己相邻的Edge对象的列表,这些边对象存储编码权重和方向性所需的信息。

该实现的一个重要方面是每个节点都有一个独特的数字索引,指示它在整个Graph数据结构中的位置。在本书中,我们将节点及其索引在某种程度上交替使用。例如,我们称索引为 0 的节点为节点 0。我们也可能说一个函数返回一个访问过的节点列表,而实际上返回的是一个索引列表。

正如我们在全书中将看到的那样,这种图形表示法适用于通过逐个节点遍历图的算法,书中大部分算法就是如此。虽然这种实现方式对于说明一系列图算法是有效的,但读者可能希望使用更节省内存或计算效率更高的表示法,以便更好地针对特定问题进行优化。

Edges

我们将 Edge 对象定义为一个简单的容器,用来存储有向加权边的信息:

to_node int存储边的目标节点的索引。

from_node int存储边的起始节点的索引。

weight float存储边的权重。对于某些特定用例,我们将使用值 1 来表示无权边。

如 图 1-7 所示,Edge 对象存储了我们可能需要用来处理边的信息,而不依赖其他类。将 from_node 包含在 Edge 类中似乎有些冗余,因为我们在每个节点的边列表中存储了边,并可以从节点中检索到这些信息。然而,显式地存储这些信息将使我们能够在本书后续部分使用一些与边集独立于节点操作的算法。

一对圆圈通过从左到右的箭头连接在一起。左侧的圆圈标记为“from_node”;右侧的圆圈标记为“to_node”。箭头标记为“weight”。

图 1-7:包含在 Edge 类中的信息

使用 Edge 类的属性,我们定义了一个构造函数,用来复制数据:

class Edge: 
    def __init__(self, from_node: int, to_node: int, weight: float):
        self.from_node: int = from_node
        self.to_node: int = to_node
        self.weight: float = weight 

由于 Edge 类主要用于存储,因此它不包含任何额外的功能。属性直接访问。我们可以通过存储每个节点的两个有向边来表示图中的无向边。也就是说,节点 A 和节点 B 之间的无向边将表现为从节点 A 到节点 B 的有向边和从节点 B 到节点 A 的有向边。虽然这会使无向图中存储的边的数量翻倍,但它强调了灵活性,并允许我们使用相同的类进行多种应用。

Edge 类展示了我们如何在代码中使用数字节点标识符。我们不是存储节点的显式链接,而是存储对应节点的整数索引。当我们需要访问节点中的其他属性时,我们使用这些索引直接从图的 nodes 列表中查找 Node 对象。

节点

我们定义了一个 Node 对象,用于存储与节点相关的信息,并提供对这些信息的基本操作。每个 Node 对象包含以下属性:

index (int**) **存储节点的数字索引

edges (dict**) **存储从节点发出的边

label (int, string, 或 object**) **一个可选的标签,用于标识节点或标记其当前状态

我们不使用列表来存储边,而是使用一个由目标节点的整数索引键入,且以 Edge 对象作为值的字典。这种表示方式使我们能够高效地问“节点 A 和节点 B 之间是否有边?”而不需要遍历节点 A 的所有边。

我们可以将Node对象想象成一个高中社交网络中的一部分。每个学生作为一个节点,用他们的学号作为索引。edges字典表示该学生的朋友列表。正如前面所提到的,每个Edge对象可以是有方向的且加权的,以完全捕捉高中联盟和纷争的复杂性。label字符串可以用于存储有关每个学生的信息,比如他们是否听说了最新的谣言。

与Graph和Edge类一样,我们定义了一个构造函数来设置节点的初始状态,并且定义了一系列辅助函数:

class Node:
    def __init__(self, index: int, label=None): 
        self.index: int = index
        self.edges: dict = {}
        self.label = label

    def num_edges(self) -> int: 
        return len(self.edges)

    def get_edge(self, neighbor: int) -> Union[Edge, None]: 
        if neighbor in self.edges:
            return self.edges[neighbor]
        return None def add_edge(self, neighbor: int, weight: float): 
        self.edges[neighbor] = Edge(self.index, neighbor, weight)

    def remove_edge(self, neighbor: int): 
        if neighbor in self.edges:
            del self.edges[neighbor]

    def get_edge_list(self) -> list: 
        return list(self.edges.values())

    def get_sorted_edge_list(self) -> list: 
        result = []
        neighbors = (list)(self.edges.keys())
        neighbors.sort()

        for n in neighbors:
            result.append(self.edges[n])
        return result 

构造函数将整数索引(index)设置为给定的值。它创建一个空字典(self.edges = {})来存储未来的边,并且初始化一个空标签(self.label = None)。

Node类包含各种辅助函数,方便我们与其交互。当实现图时,必须先定义Edge类,然后再定义Node类。我们还需要从 Python 的typing库导入Union(在文件开头添加from typing import Union),以支持示例代码中使用的可选类型提示。

前两个函数提供对节点边的访问。num_edges()函数返回边的数量。get_edge()函数返回给定的边,如果没有该边则返回None。这样可以将查找和存在性检查合并到一个函数中。

接下来的两个函数修改节点的连接。add_edge() 函数接受目标索引和权重,然后创建并插入相应的 Edge 对象。如果邻居的索引已经出现在字典中,它会覆盖现有的边,从而允许我们更新边的权重。remove_edge() 函数如果边存在,则从字典中删除该边。

最后两个函数是方便的函数,用于将节点的边返回为列表。函数 get_edge_list() 按字典顺序返回边,并在算法需要访问列表时使用。函数 get_sorted_edge_list() 按照邻居索引的升序返回边,主要用于本书中,为示例提供一致的顺序。

虽然这些函数使用字典来存储节点的边(以目标节点为索引),但也可以将每个函数调整为将节点的边存储为列表。仅存储出边的紧凑列表更注重内存使用,而不是查找特定边所需的时间。相比之下,为了优先考虑特定边的查找速度,每个节点可以存储一个长度为|V|的列表,为每个可能的边预留空间,并将不存在的边存储为None。基于字典的方法平衡了这两个竞争的方面。

图类

本书中大多数地方使用的 Graph 类由一个 Node 对象的列表和一些简化常见计算的实用信息组成:

nodes (list**) **存储图中的节点

num_nodes (int**) **存储图中的节点总数

undirected (bool**) **指示这是有向图还是无向图

num_nodes 和 undirected 值可以从图的结构中计算出来,但为了方便,它们被存储了下来。

我们始终存储有向边,并使用布尔值 undirected 来修改在处理有向图和无向图时的行为。最显著的是,如本节后面“访问、构建和修改图”部分所示,当图本身是无向图时,我们将使用 undirected 插入一对有向边。其他常见的实现要么使用单独的函数,如 insert_undirected_edge() 函数,要么为有向图和无向图创建完全不同的实现。再次强调,我们在这里优先考虑数据结构的通用性,而非纯粹的优化。

根据这些信息,我们可以创建一个简单的构造函数,用于构建具有指定节点数且没有边的图:

class Graph:
    def __init__(self, num_nodes: int, undirected: bool=False): 
        self.num_nodes: int = num_nodes
        self.undirected: bool = undirected
        self.nodes: list = [Node(j) for j in range(num_nodes)] 

构造函数初始化便利变量,然后创建一个 Node 对象的列表。该函数不会创建任何边。该实现隐含地假设每个节点都有一个唯一的数字标识符,该标识符对应图的 nodes 列表中的位置。

Graph 类还包括多种函数,用于创建、搜索、访问以及其他处理图的操作。我们将不会在本节中提供一个包含所有图函数的大块代码,而是会在本节中逐步介绍这些通用函数。

访问、构建和修改图

为了方便访问边,我们接下来在 Graph 类中定义一系列辅助函数:

def get_edge(self, from_node: int, to_node: int) -> Union[Edge, None]: 
    if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError
    return self.nodes[from_node].get_edge(to_node) def is_edge(self, from_node: int, to_node: int) -> bool: 
    return self.get_edge(from_node, to_node) is not None

def make_edge_list(self) -> list:
    all_edges: list = []
    for node in self.nodes:
        for edge in node.edges.values():
            all_edges.append(edge)
    return all_edges 

get_edge() 函数接受一个起始索引和一个目标索引,并返回对应的边(如果存在)。它会进行基本的边界检查以验证有效性,然后使用起始节点对应的 get_edge() 函数来检索边,如果存在的话,并返回 None,否则返回。is_edge() 函数只是检查给定起始点和目标点之间是否存在边。最后,make_edge_list() 函数动态地构建并返回图中所有边的列表。

Graph 类的构造函数分配了一定数量的节点,但并未创建任何边。显然,这样并不能生成一个有用的图。为了建模任何有趣的问题,我们的图需要包含节点和边。因此,我们添加了一些额外的函数,用于创建和修改邻接图表示。首先,在 Graph 类中,我们提供了根据起始节点和目标节点的索引来添加和删除边的功能:

def insert_edge(self, from_node: int, to_node: int, weight: float): 
  ❶ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

    self.nodes[from_node].add_edge(to_node, weight)
  ❷ if self.undirected:
        self.nodes[to_node].add_edge(from_node, weight)

def remove_edge(self, from_node: int, to_node: int): 
  ❸ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

    self.nodes[from_node].remove_edge(to_node)
  ❹ if self.undirected:
        self.nodes[to_node].remove_edge(from_node) 

insert_edge()remove_edge() 函数遵循相同的流程:它们首先检查起始节点和目标节点的索引是否对应于图中包含的节点 ❶ ❸。如果节点索引无效,函数会引发一个 IndexError 异常。

如果索引有效,函数会修改起始节点的邻接表。插入函数使用节点的 add_edge() 函数。删除函数使用节点的 remove_edge() 函数。由于我们使用同一个类表示有向图和无向图,因此在无向图的情况下,函数还需要添加 ❷ 或删除 ❹ 相应的反向边。

我们可以将这些函数结合起来,动态创建图。例如,我们可以使用以下代码来创建一个有五个节点的有向图,并插入八条加权边:

g: Graph = Graph(5, undirected=False)
g.insert_edge(0, 1, 1.0)
g.insert_edge(0, 3, 1.0)
g.insert_edge(0, 4, 3.0)
g.insert_edge(1, 2, 2.0)
g.insert_edge(1, 4, 1.0)
g.insert_edge(3, 4, 3.0)
g.insert_edge(4, 2, 3.0)
g.insert_edge(4, 3, 3.0) 

这将生成如 图 1-8 所示的图。

一个包含五个节点的图,箭头连接节点。每个箭头上标有数字。例如,节点 0 有一个指向节点 1 的箭头,权重为 1.0,一个指向节点 3 的箭头,权重为 1.0,以及一个指向节点 4 的箭头,权重为 3.0。

图 1-8:一个有向加权图,节点按其索引标记

虽然我们在构造函数中提供了预分配节点的功能,但对于某些算法,我们需要在探索图时插入新节点。为方便起见,我们还提供了一个插入新节点的函数:

def insert_node(self, label=None) -> Node: 
    new_node: Node = Node(self.num_nodes, label=label)
    self.nodes.append(new_node)
    self.num_nodes += 1
    return new_node 

insert_node() 函数创建一个新节点,并自动将标识号码分配给下一个索引。然后,节点被附加到 nodes 列表中,节点计数增加,并返回新节点。

尽管本节中的函数提供了构建图的基本构件,但手动指定图形的过程将会非常繁琐,需要一长串的insert_node()和insert_edge()调用。附录 A 中探讨了一些基于这些初始函数的示例算法,通过它们可以从文件或常见问题规格程序化地创建图。

复制图

最后,我们还在Graph类中定义了一个辅助函数,用于生成图的副本,供修改图的算法使用:

def make_copy(self): 
    g2: Graph = Graph(self.num_nodes, undirected=self.undirected)
    for node in self.nodes:
      ❶ g2.nodes[node.index].label = node.label
        for edge in node.edges.values():
          ❷ g2.insert_edge(edge.from_node, edge.to_node, edge.weight)
    return g2 

make_copy()代码首先创建一个新的Graph对象(g2),该对象具有与当前图相同数量的节点和无向设置。然后,它使用两个嵌套的for循环遍历每个节点及其出边。对于每个节点,它复制标签❶。对于每条边,它在g2中插入一个等效的边❷。

复制图将允许我们使用那些会破坏性修改图的算法。例如,在第十六章中,我们将介绍一种为图分配颜色的算法,该算法会迭代地从图中移除节点。

邻接矩阵表示法

另一个强大的图表示法是邻接矩阵。虽然我们在本书的大多数算法中主要依赖前面的邻接表表示法,但邻接矩阵表示法对于一类基于数学的算法至关重要。许多算法可以通过矩阵运算进行描述或分析。在考虑图上的随机游走时,我们将在第十三章中使用矩阵表示法。

图的邻接矩阵表示法使用一个矩阵来表示每一对节点之间的边权重。在第i行,第j列的值表示从节点i到节点j的边的权重。值为 0 表示不存在这样的边。作为一个列表的列表表示,下面的矩阵将创建一个无向、无权重的图,其中包含五个节点和七条边:

g = [[0, 1, 0, 1, 1],
     [1, 0, 1, 0, 1],
     [0, 1, 0, 0, 1],
     [1, 0, 0, 0, 1],
     [1, 1, 1, 1, 0]] 

这对应于图 1-9 中显示的图,其中节点 0 的三个连接通过矩阵中相应的非零条目表示。

左图显示一个包含五个节点、标签为 0 到 4 的图。节点 0 与节点 1、3 和 4 相连。右图显示一个 5×5 的矩阵,矩阵填充了 0 和 1 的值。顶行表示从节点 0 的连接,值为 0、1、0、1、1。

图 1-9:一个图(左)及其邻接矩阵表示(右)

连接矩阵可以使用任何值设置。浮动值条目可以用来表示带权重的边。无向边通过一对匹配的值来表示,使得无向图是对称的。

为了创建和存储邻接图,我们将使用本节中介绍的基本 GraphMatrix 类。与 Graph 类一样,我们优化了表示方式,以便于理解,而非计算成本或内存使用。

我们的 GraphMatrix 类包含三部分信息:

connections (list of list**) **存储邻接矩阵

num_nodes (int**) **存储图中节点的总数

undirected (bool**) **指示这是有向图还是无向图

与 Graph 数据结构一样,我们允许 GraphMatrix 表示有向图和无向图。我们使用 undirected 属性来指定包含哪种类型的边。我们定义了一个简单的构造函数,用于构建一个具有指定节点数且没有边的图:

class GraphMatrix:
    def __init__(self, num_nodes: int, undirected: bool=False): 
        self.num_nodes: int = num_nodes
        self.undirected: bool = undirected
        self.connections = [[0.0] * num_nodes for _ in range(num_nodes)] 

代码将 connections 中的每个条目初始化为 0,从而创建一个没有任何边的图。

我们还定义了一个 getter 函数,用于获取两个节点之间连接的权重:

def get_edge(self, from_node: int, to_node: int) -> float: 
    if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError
    return self.connections[from_node][to_node] 

代码检查源节点和目标节点的索引是否有效。如果有效,核心将从数组中返回相应的浮动值。

虽然我们将邻接矩阵存储在列表的列表中以简化示例,但通常最好使用优化过的矩阵操作表示法,比如流行的 numpy 包所提供的那种。这样的数值包会更快,并提供一系列辅助函数。我们将 GraphMatrix 在 numpy 或类似数学包中的实现留给读者作为练习。

与 Graph 类不同,新的 GraphMatrix 对象预分配所有存储边信息的空间到主 connections 矩阵中。我们可以直接在该矩阵中设置条目来添加或移除边:

def set_edge(self, from_node: int, to_node: int, weight: float):
  ❶ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

  ❷ self.connections[from_node][to_node] = weight
  ❸ if self.undirected:
        self.connections[to_node][from_node] = weight 

代码检查源节点和目标节点的索引是否有效,如果无效,则会引发错误 ❶。如果索引有效,函数会设置与该边对应的矩阵项 ❷。如果图是无向图,函数会修改矩阵中的对称项 ❸。

我们可以使用 set_edge() 函数来添加、移除或修改边。通过将条目设置为非零权重来添加新边。如果这两个节点之间已经存在边,函数会更新其权重。我们通过将条目设置为 0 来移除边。例如,我们可以创建图 图 1-8 如下:

g: GraphMatrix = GraphMatrix(5, undirected=False)
g.set_edge(0, 1, 1.0)
g.set_edge(0, 3, 1.0)
g.set_edge(0, 4, 3.0)
g.set_edge(1, 2, 2.0)
g.set_edge(1, 4, 1.0)
g.set_edge(3, 4, 3.0)
g.set_edge(4, 2, 3.0)
g.set_edge(4, 3, 3.0) 
```  ### 为什么这很重要

图的结构及其底层实现构成了本书所有算法的基础,并推动了它们的发展。决定使用哪种表示方式要求我们根据任务的具体情况考虑内存使用、计算效率和复杂性之间的权衡。例如,在我们只想遍历一个节点的直接邻居时,最好的选择可能是邻接表表示,因为我们可以独立地访问邻居列表。相比之下,对于那些更具数学性的算法,我们可能更倾向于使用矩阵表示,因为它可以利用现有的数学库。

本章引入实现的目的是为了展示图的不同思考方式和它们在表示中固有的不同权衡,而不是提供一种单一的标准方法。我们可以对本章中介绍的实现进行多种混合方式或进一步的调整,以优化图的表示,以解决我们关注的问题。

在接下来的章节中,我们将介绍一系列可以使用图解决的问题,并在此过程中继续扩展本章引入的概念和代码。对于每个问题,我们将介绍一些可以直接应用于实际情况的实用算法。在下一章,我们将从引入邻居节点的概念开始,并使用算法构建邻域。




## 第二章:2 邻居与邻域



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

本书中的几乎每个算法都需要与节点的*邻居*进行交互。邻居的概念直观上是非常熟悉的;在无向图中,给定节点的邻居是与该节点共享边的节点。在有向图中,邻居的术语稍微复杂一些,因为邻居的种类取决于边是入边还是出边。

确定给定节点的邻居集合是大多数图算法中的基础步骤,例如,在新的图中搜索路径,以及许多现实世界中的任务。例如,在规划一趟交通网络之旅时,我们可能会问自己,从当前城市可以直接到达哪些城市。

本章介绍了邻居的正式定义,并展示了我们将在本书中使用的一些基本函数。它还介绍了两个基于邻居的度量:节点的度和聚类系数。这些度量提供了有关节点邻域的洞察,帮助我们分析图的特征。节点的度告诉我们它的连接数量,而聚类系数则告诉我们它的邻居之间的互联程度。

### 无向图中的邻居

许多度量和算法需要确定与给定节点*v*直接相邻的节点集合。在无向图中,节点*v*的*邻居*是所有与*v*通过边相连的节点。图 2-1 展示了一个示例图,并列出了每个节点的邻居。节点 0 有三个邻居(1、3 和 4),而节点 3 只有一个邻居(0)。

![六个节点标记为 0 到 5。每个节点都是一个圆圈,边是连接一对圆圈的线。每个节点还列出了它的邻居集合。例如,节点 0 标有集合 {1, 3, 4}。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02001.jpg)

图 2-1:一个无向图,每个节点列出了其邻居

我们可以为我们的Node类添加一个简短的辅助函数,用于计算无向图中邻居的集合,如清单 2-1 所示。

def get_neighbors(self) -> set:
neighbors: set = set()
for edge in self.edges.values():
neighbors.add(edge.to_node)
return neighbors


清单 2-1:确定无向图中邻居节点的集合

清单 2-1 中的get_neighbors()函数创建一个空的set数据结构,然后遍历每个节点的边,将相应的邻居添加到集合中。

考虑一个表示社交网络的图,其中每个人是一个节点,节点 *v* 和节点 *u* 之间的无向边表示这两个人是朋友。我们可以使用节点的邻居来编制派对的宾客名单,或模拟谣言在网络中的传播。

作为确定节点邻居的另一个应用,考虑那个古老的问题,“哪位明星和某个特定的演员一起出演过电影?”这是我们将在本章稍后详细讨论的问题。我们可以构建一个共现图,表示哪些演员一起出现在电影中。每个节点代表一个人。一条边表示两个人曾一起出演过同一部电影。由于这种关系始终是对称的,我们使用无向图来建模这些共现。

### 有向图中的邻居

在有向图中,我们可以将几种类型的节点视为邻居:*v* 的出边终点上的节点、*v* 的入边起点上的节点,或者是有向边两侧的节点。为了解决这个模糊性,我们将此类图的邻居分为两种主要类型。*入邻居*是所有具有以 *v* 为目标的边的节点;换句话说,从 *v* 角度看,边是传入的。例如,在一个朋友关系不对称的有向社交网络中,*v* 的入邻居是那些会告诉他们最新八卦的朋友。*出邻居*是所有有出边指向 *v* 的节点,表示 *v* 会将八卦传给这些朋友。

我们在 Node 类中添加的计算出邻居的代码与清单 2-1 中无向图的代码相同,唯一不同的是函数的名称,如清单 2-2 所示。

def get_out_neighbors(self) -> set:
neighbors: set = set()
for edge in self.edges.values():
neighbors.add(edge.to_node)
return neighbors


清单 2-2:在有向图中确定出邻居集合

get_out_neighbors() 函数遍历所有边,并将目标节点收集到一个集合中,然后返回该集合。社交上的等价物是编制一个列表,列出一个人发送消息的对象。

相比之下,计算入邻居集合的代码需要我们遍历图中的每个节点,因为我们并不维护指向给定节点的边的列表,如清单 2-3 所示。此代码从 Graph 类调用,因此它可以访问所有节点的完整列表。

def get_in_neighbors(self, target: int) -> set:
neighbors: set = set()
for node in self.nodes:
❶ if target in node.edges:
neighbors.add(node.index)
return neighbors


清单 2-3:确定入邻居集合

和其他邻居算法一样,代码从一个最初为空的集合构建邻居。该函数遍历每个节点,检查目标节点是否在该节点的 edges 字典中有条目❶。如果目标节点在该节点的边字典中有条目,则将邻居添加到集合中。

如果我们在无向图上运行清单 2-3 会发生什么?不仅该函数不会失败,而且它还会产生正确的邻居集合。get_in_neighbors() 函数考虑的邻居节点与清单 2-1 中的代码相同,但来自边的相反方向。然而,get_in_neighbors() 在无向图上的效率显著低于 get_neighbors(),因为它会遍历图中的所有节点,而不仅仅是与目标节点连接的那些节点。

### 自环

定义邻居时的另一个复杂性是可能存在的 *自环*,即一条边将一个节点链接到其自身。例如,在图 2-2 中,节点 1 有一条指向自己的边。

![一个包含四个节点的图。节点 1 有一条从节点回到自身的箭头。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02002.jpg)

图 2-2:带自环的图

自环就像是返回起点的环形道路。更具体地说,我们可以在我关于这本书的对话中将其可视化。如果我们使用加权图来表示我与不同的人进行的对话次数,那么最大的加权边就是一个自环,表示我在努力解决某个问题时自言自语的次数。

在邻接表表示中,自环通过包含一条目标与起点相同的边来表示。在邻接矩阵表示中,节点 *v* 的自环通过矩阵对角线上的非零值来表示(行 = *v*,列 = *v*)。

如果节点 *v* 有自环,我们认为它是自己的邻居。在有向图的情况下,这意味着节点 *v* 既是它自己的入邻居,又是它自己的出邻居,因为边从节点 *v* 开始并以节点 *v* 结束。

在本书中,我们采用计算机科学中常见的约定,只在有向图中允许自环。虽然许多算法可以处理带自环的无向图,并且其他大多数算法也能轻松适配,但这些自环在无向图所建模的问题的背景下通常是没有意义的。例如,在第十六章中讨论的图着色问题要求我们为任何两个通过边连接的节点分配不同的颜色。自环在这样的题目设定中没有意义。

### 度数

理解一个节点的连接性时,一个有用的统计量是其*度数*,即与该节点相连接的边的数量。图 2-3 展示了一个无向图示例,图中的每个节点都标注了其度数。节点 0 的度数为 3,而节点 5 的度数为 2。

![一个包含六个节点的图,每个节点都标有度数。节点 0 连接到节点 1、3 和 4,度数为 3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02003.jpg)

图 2-3:一个无向图,显示每个节点的度数

在社交网络中,一个节点的度数表示该人拥有的朋友数量。我们可以将其作为该人受欢迎程度或社交连接的一个粗略代理。

从数学角度来看,在无向图中形成自环的边会被计算两次,因为它们分别与每个端点的节点相接触。尽管在本书的算法中我们不使用无向图中的自环,但在计算度数时为了完整性,我们仍会包括此检查,详见第十八章。

在有向图中,我们将度数的概念分为两个独立的度量,就像邻居一样。一个节点的*出度*衡量该节点的连接数,而其*入度*衡量其他节点指向该节点的边的数量。在社交网络中,您的入度和出度可能分别代表您与多少人分享新闻,以及有多少人向您分享新闻。好的知己是具有高入度和低出度的朋友。好的八卦源则拥有高入度以收集小道消息,并且有高出度,表明他们愿意传播这些小道消息。

在有向图中,形成自环的边具有相同的*起点*和*终点*。它们会分别计算一次入度和一次出度。例如,图 2-4 展示了一个有向图以及每个节点的度数。图的左侧展示了每个节点的出度,而右侧展示了它们的入度。

![两个包含六个节点的图。在两个图中,节点 0 都有指向节点 1 和 3 的出箭头,以及来自节点 4 的入箭头。在左侧图中,节点 0 的标签为 2;在右侧图中,节点 0 的标签为 1。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02004.jpg)

图 2-4:一个有向图,标注了每个节点的出度(a)和入度(b)

在一个连通图中,计算节点的入度或出度需要统计传入或传出的边的数量。为此,我们可以通过保持计数器而不是构建集合,改编前一节中的邻居计算代码。

### 聚类系数

节点的 *聚类系数*(有时称为 *局部聚类系数*)是一个衡量节点邻居之间相互连接程度的指标。在社交网络的背景下,聚类系数实际上是在问:“我的朋友们在多大程度上也互相是朋友?”当该值为零时,表示我们的朋友们互相不喜欢对方,这样的聚会会非常尴尬。另一方面,当值为一时,表示我们的每一个朋友都与每个其他朋友有连接。

正式来说,在一个无向图中,节点 *v* 的聚类系数是 *v* 的邻居之间存在的边占所有可能边的比例。我们找出所有邻居的集合(即与 *v* 共享一条边的所有节点),统计这些邻居之间共享的边数,然后将其除以该集合内所有可能的边数。如果节点 *v* 有 *k* 个邻居,则它们之间可能存在最多 *k* (*k* – 1) / 2 条边。

拥有一个或更少邻居的节点需要特殊处理,因为它们的邻居没有任何可能的连接。如果某人没有朋友,那么计算他们的朋友之间互相喜欢的百分比就没有意义。为了简化处理,在这种情况下我们返回值 0,表示没有局部连接。

我们可以定义一个函数来计算给定节点索引为 ind 的无向图中节点的聚类系数,如列表 2-4 所示。

def clustering_coefficient(g: Graph, ind: int) -> float:
❶ neighbors: set = g.nodes[ind].get_neighbors()
num_neighbors: int = len(neighbors)

count: int = 0
for n1 in neighbors:
    for edge in g.nodes[n1].get_edge_list():
      ❷ if edge.to_node > n1 and edge.to_node in neighbors:
            count += 1

total_possible = (num_neighbors * (num_neighbors - 1)) / 2.0

❸ if total_possible == 0.0:
return 0.0
return count / total_possible


列表 2-4:计算局部聚类系数的代码

`clustering_coefficient()` 函数的代码首先使用来自列表 2-1 的 `get_neighbors()` 函数 ❶ 来生成所有邻居节点的集合。然后,它使用一对嵌套的 `for` 循环检查每一对唯一的邻居。第一个 `for` 循环遍历节点的邻居,第二个则遍历邻居的边。

对于每个包含邻居节点的边,代码会检查边的另一端的节点是否具有大于当前邻居节点的索引,并且是否也是原始节点的邻居 ❷。第一个检查是为了避免重复计数邻居。在邻接列表中,无向边会出现两次,但只应计数一次。每条边 (*u*, *v*) 仅当 *u* < *v* 时才会被计数。如果边通过了这一检查,则会被计数。

`clustering_coefficient()` 函数最终返回的是邻居节点之间观测到的边的总数占所有可能边的比例,同时会避免在节点有一个或零个邻居时出现除以零的情况 ❸。

图 2-5 显示了一个示例图,其中列出了每个节点的聚类系数。

![每个节点在这个六节点图中都标注了它的聚类系数。节点 0 与节点 1、3 和 4 相连。在这些邻居中,只有节点 1 和 4 之间有连接。节点 0 的系数为 1/3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02005.jpg)

图 2-5:带有聚类系数的图

节点 0 有三个邻居(1、3 和 4),它们之间最多可以有三条边,但只有一对邻居(1 和 4)相互连接,这使得它的聚类系数为 1/3。相比之下,节点 5 有两个共享边的邻居,因此它的聚类系数为 1。节点 3 只有一个邻居,因此聚类系数为 0。

#### 计算平均聚类系数

聚类系数仅告诉我们关于单个节点周围图的特征。我们可以通过计算所有节点的*平均局部聚类系数*来扩展这个洞察,这为无向图的局部互联程度提供了一个数值度量。

我们可以通过计算每个邻居的聚类系数,然后取平均值来计算无向图的这个度量,代码如下所示:

def ave_clustering_coefficient(g: Graph) -> float:
total: float = 0.0
for n in range(g.num_nodes):
total += clustering_coefficient(g, n)

if g.num_nodes == 0:
    return 0.0
return total / g.num_nodes 

ave_clustering_coefficient() 函数循环遍历每个节点,对该节点调用 clustering_coefficient(),并将结果累加到一个总数中。只要该函数已经处理过至少一个节点,它就会返回总数除以节点数。例如,图 2-5 中的图有一个局部聚类系数,约为 0.5278。

#### 处理局限性

聚类系数只提供有关邻居节点相对于单个给定节点的连接性的信息,而无法告诉我们这些节点的连接性。例如,考虑图 2-6。节点 0 的聚类系数为 1,表示它的所有邻居都是互相连接的。然而,这并没有告诉我们更远一步的网络情况,更别提*所有*邻居的连接了。

![一个六节点图。节点 0 直接与节点 1 和 2 相连。节点 3、4 和 5 都与节点 1 相连,并且相互之间也有连接;它们被阴影标示出来,表示它们不参与节点 0 聚类系数的计算。]](../images/f02006.jpg)

图 2-6:节点 0 及其直接邻居的相互连接与这些邻居的连接

在图 2-6 中,节点 1 有许多额外的连接,这些连接没有被聚类系数考虑,因为它们没有直接与节点 0 的邻居连接。这些连接以灰色显示,而直接邻居以黑色显示。在这种情况下,节点 1 属于两个不同的互联节点集合,{0, 1, 2}和{1, 3, 4, 5}。

在我们的社交网络示例中,这意味着聚类系数不能告诉我们关于我们朋友的朋友的信息。我们的朋友可能彼此相处得很好,但也可能属于其他群体。实际上,局部聚类系数可以告诉我们我们邀请的派对宾客是否会相互合得来,但它不能告诉我们是否有更大的派对他们会去参加。例如,如果图 2-6 中的节点 0 和节点 4 分别举办派对,节点 1 会喜欢任何一个派对,但在节点 4 的派对上会有更多的朋友。

### 生成邻域子图

我们可以扩展邻居的概念,在无向图中确定一个*邻域子图*,该子图包括邻居节点及其之间的边。根据是否包含原始节点,我们可以定义两种类型的邻域子图。在节点*v*的*开集邻域*子图中,包括节点*v*的邻居以及它们之间的边。而节点*v*的*闭集邻域*子图则包括节点*v*及其所有邻居,以及这些节点之间的边。

#### 代码

我们可以在Graph类中创建一个函数,用于生成给定节点(索引为ind)周围的开集或闭集邻域子图(在无向图中)。这个函数通过确定邻居节点,并利用它们生成一个新的图,然后添加适当的边来操作:

def make_undirected_neighborhood_subgraph(self, ind: int, closed: bool):
❶ if not self.undirected:
raise ValueError

❷ nodes_to_use: set = self.nodes[ind].get_neighbors()
if closed:
nodes_to_use.add(ind)

index_map = {}

❸ for new_index, old_index in enumerate(nodes_to_use):
index_map[old_index] = new_index

g_new: Graph = Graph(len(nodes_to_use), undirected=True)
for n in nodes_to_use:
    for edge in self.nodes[n].get_edge_list():
      ❹ if edge.to_node in nodes_to_use and edge.to_node > n:
            ind1_new = index_map[n]
            ind2_new = index_map[edge.to_node]
            g_new.insert_edge(ind1_new, ind2_new, edge.weight)

return g_new 

make_undirected_neighborhood_subgraph()函数的代码首先检查图是否为无向图,如果不是,则抛出一个ValueError ❶。虽然这不是严格必要的,并且代码对有向图也能产生一些结果,但它有助于确保该函数按照设计使用。接下来,代码通过从清单 2-1 ❷中的get_neighbors()函数提取目标节点的邻居集合。这个集合nodes_to_use包含将用于子图的所有节点。如果子图是闭集邻域子图,代码会将目标节点本身添加到该集合中。

生成邻域子图的代码稍微复杂一些,因为Graph类对节点的索引方式。由于我们的图使用的是范围为[0, |*V*| – 1]的数字索引,其中|*V*|是节点的数量,因此任何子图可能会对给定节点使用不同的索引。为了解决这个问题,代码构建了一个字典index_map,将旧的节点索引映射到新的节点索引 ❸。这允许生成的子图使用没有间隙的数字索引。正如我们稍后在图 2-8 中看到的,我们可以使用替代信息,如标签,来保留节点的身份。

最后,代码通过一对嵌套的for循环创建了新的图。这段代码与清单 2-4 中的本地聚类系数代码类似。第一个for循环遍历nodes_to_use中的节点,而第二个则遍历该节点的边。通过测试邻接节点的索引(edge.to_node)是否大于当前节点n的索引,函数可以确保每条无向边只被插入一次 ❹。只有当两个节点都在nodes_to_use中并且另一个节点尚未被处理时,才会添加一条新的边。Graph类的insert_edge()函数正确地使用新的节点索引插入无向边。

#### 一个示例

考虑从图 2-7 中的图构建一个邻域子图时会发生什么。回到之前关于电影明星网络的例子,这个图可以表示出出现在世界著名的*图论*系列动作惊悚片中的七位明星(爱丽丝、鲍勃、卡尔、丹、爱德华、菲奥娜和格温):*图论*(主演爱丽丝和鲍勃)、*图论 2:新节点*(主演鲍勃和卡尔)、*图论 3:失落的边缘*(主演鲍勃、菲奥娜和格温)等等。每个节点都以明星名字的首字母标记,并映射出他们与其他明星的关系。

![一个包含七个节点的图。节点 1 与节点 0、2、5 和 6 相连。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02007.jpg)

图 2-7:一个表示《图论》系列明星的无向图 图论 系列

为了更好地了解 Bob 和他的共同出演者的出现情况,我们在 Bob(节点 1)周围构建了一个闭邻域子图。这表示了 Bob 曾与之同框的明星,并捕捉了他们之间的互动。图 2-8 展示了构建此图的操作。左列显示了完整的图,其中当前正在处理的节点由虚线圆圈标示,右列则显示了该时刻的新子图。如前所述,子图的节点使用不同的索引;在这种情况下,我们可能会将明星的名字存储在节点的标签中。

![六行显示邻域构建算法步骤,按字母 a 到 f 从上到下标注。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f02008.jpg)

图 2-8:围绕 Bob 构建闭邻域子图的步骤

图 2-8(a)首先创建了一个只包含 Bob 和他的共同演员的新图。邻居集合包括了所有与 Bob 同屏的人。Alice 是从他们在原版《Graph Theory》电影中的合作中加入的,而与明星 Fiona 和 Gwen 的联系则来自该系列的第三部,也是评价最好的一部。

索引在新图中也发生了变化。如图所示,三个人被赋予相同的索引(节点 0、1 和 2),而另外两个人则被分配了新的索引(5 和 6)。Fiona 的节点索引在子图中从 5 改为 3,Gwen 从 6 改为 4。在附录 A 中,我们将讨论如何扩展 Graph 结构,以使用基于字符串的标签,从而无需进行此类索引重映射。

在建立新图后,我们逐一遍历考虑的人员(Bob 和他的共同演员),并向子图添加新的边。当考虑到图 2-8(b)中的节点 0 时,我们只添加了他们的两个边中的一个(0, 1)。这是因为 Alice 和 Bob 都被考虑在内。相反,Edward(节点 4)只和 Alice 一起出演过灾难性的衍生剧《The Golden Vertex》。由于 Edward 从未与 Bob 同屏,他不属于 Bob 的邻域子图。

当我们处理到 Bob,作为该系列的标志性人物时,我们在图 2-8(c)中向三个新的共同演员添加了边。我们没有向 Alice 添加边,因为我们已经处理过该节点及其边。代码继续处理图 2-8(d)中的 Carl,图 2-8(e)中的 Fiona 和图 2-8(f)中的 Gwen。由于 Edward 和 Dan 并未与 Bob 同演,他们不在邻域列表中,因此不会被考虑。最终的子图显示在图 2-8(f)的右侧。

### 为什么这很重要

图的邻居提供了关于给定节点周围局部结构和互联信息的基础。大多数情况下,这些术语的正式定义直观且便于理解。当遍历图时,我们会问哪些节点是当前节点的邻居,因此可以到达。邻居将构成我们在后续章节中讨论图搜索算法的基础,因为许多这些算法共享遍历节点边并查看哪些其他节点与之共享的核心循环。

节点的度数和局部聚类系数等概念提供了关于其直接邻居和邻域的具体度量。这些示例度量只是量化图属性的众多方式中的一部分。已经开发出大量度量标准来分析现实世界图的属性,从它们的互联程度到它们的宽度。对所有图度量的全面回顾超出了本书的范围,但接下来的章节将讨论一些额外的分析方法。

在下一章中,我们将讨论另一个基础的图算法概念:路径。路径描述了图中的移动,并允许我们记录如何从一个节点遍历到另一个节点。




## 第三章:3 图中的路径



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

图中的*路径*概念是我们将在全书中使用的另一个基础性构建块。例如,我们可能对确定两个节点之间的最低成本路径(*最短路径算法*)感兴趣,或者我们可能关心是否可以通过任何路径到达一个节点。接下来的多个章节将专门讨论具有不同属性的路径计算。

路径的概念与其现实世界的对应物相似。就像你最喜欢的公园中的路径为你提供了从一个地方到另一个地方的路线,图中的路径也提供了相同的机制。它们是节点(或边)的序列,允许我们在图中移动。当我们逃脱迷宫或进行公路旅行时,我们通过图的边从一个节点移动到另一个节点。

本章正式定义了我们所说的图中的路径,并探讨了表示这些结构的不同方式。无论是寻找路径还是将其作为算法的一部分,我们都需要能够高效且明确地表示它们。如果你曾经请求过指示,结果得到的是模糊的手势和类似“往那边走,到了第三个或第四个路口右转,你应该能看到的”这样的回答,那么你就体验到了使用不完整路径的情况。

本章介绍了三种明确无误的表示方式。我们还考虑了路径的属性以及一些可以利用路径执行的任务。我们探讨了用于检查路径是否有效以及在加权图中计算路径成本的函数。最后,我们讨论了路径与图中可达性问题的关系。

### 路径

图中的*路径*是由边连接的一系列节点。这些是我们在图中从一个节点移动到另一个节点时经过的途径。就像我们将单个边的端点称为起点和终点一样,我们也用这些术语来描述整个路径的端点:路径中的第一个节点是起点,最后一个节点是终点。图 3-1 展示了由节点[0, 1, 3, 2, 4, 7]组成的路径。路径中每一对相邻的节点对应图中的一条边。

![一张有八个节点的图,图中通过加粗的边从节点 0 到节点 1,再从节点 1 到节点 3,节点 3 到节点 2,节点 2 到节点 4,节点 4 到节点 7,表示有一条路径。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03001.jpg)

图 3-1:从节点 0 到节点 7 的有效路径

本书中,我们采用了计算机科学和算法教材中常见的路径定义(作为节点序列)。这与图论中路径的正式定义不同,后者不允许重复节点。在图论中,允许重复节点的路径称为*游走*。采用更一般的路径定义使我们既能与其他算法文本保持一致,也能反映现实世界中的路径。

路径具有方向性,即使在无向图中也是如此。节点列表展示了我们行进路径的顺序。路径[0, 1, 2, 7]从节点 0*到*节点 1*到*节点 2*到*节点 7。这种方向性将在理解本书中算法结果时非常重要。

### 路径表示

在代码中表示路径有多种方式。与所有表示法一样,我们可以根据当前的问题或算法来定制路径的数据结构。本节将介绍几种在代码中存储路径的方法,并讨论它们各自的优缺点。我们还将探讨每种表示法如何与规划公路旅行中的现实世界对应物相匹配。

在本节中,我们还使用检查路径有效性的问题来说明存储表示如何工作以及代码如何遍历它。对于以下代码的目的,我们将空路径(不包含任何节点或边)视为有效。根据问题领域,你可以轻松调整代码,以排除这些情况。

#### 节点列表

我们通常使用有序的*节点列表*来表示从起始节点到终止节点的路径,这种表示在许多计算机科学教材中都有。我们定义节点列表为闭区间,所以path[0]是起始节点的索引(路径起点),而path[N-1]是路径长度为N时,终止节点的索引(路径终点)。例如,我们可以将图 3-1 中的路径表示为[0, 1, 3, 2, 4, 7]。这种表示法适用于一个具有固定起点和终点的单一路径。

在我们的公路旅行中,节点列表的表示法相当于列出我们沿途会访问的每个城市。假设我们计划从波士顿出发,途经费城、匹兹堡、哥伦布和印第安纳波利斯。如果我们在每个城市的旅游中心停留,并购买一张纪念明信片(包括起点和终点城市),那么这堆明信片就会总结我们这段激动人心的旅程。

我们通过遍历节点列表并确认每一对节点之间存在边来检查路径是否有效,如下代码所示:

def check_node_path_valid(g: Graph, path: list) -> bool:
num_nodes_on_path: int = len(path)
❶ if num_nodes_on_path == 0:
return True
❷ prev_node: int = path[0]
if prev_node < 0 or prev_node >= g.num_nodes:
return False for step in range(1, num_nodes_on_path):
next_node: int = path[step]
❸ if not g.is_edge(prev_node, next_node):
return False
❹ prev_node = next_node
return True


check_node_path_valid() 函数首先检查路径是否为空,如果为空,则返回 True ❶,因为我们定义了零个节点的路径为有效路径。

如果路径不为空,代码会获取路径的起始节点,并检查它是否有效 ❷。然后,代码遍历路径中的其余节点,使用变量 step 来跟踪当前正在检查的步骤。由于代码已经测试了第一个节点,所以从列表中的第二个节点开始(step=1)。对于每个新节点,使用 is_edge() 函数来检查该节点是否有效,并且是否存在一条从前一个节点到该新节点的边 ❸。如果新节点无效或图中没有相应的边,函数会立即返回 False。检查完边后,代码会继续检查路径中的下一个节点 ❹。如果函数在遍历完整个路径时未发现任何无效节点或边,则返回 True。

在我们的公路旅行示例中,这段代码对应于遍历城市列表。我们检查每个城市是否是有效城市,并且是否存在一条直接从前一个城市到当前城市的路(换句话说,是否存在一条边)。例如,对于列表 [Boston, New York, Philadelphia, Pittsburgh],我们会返回 True,而 [Boston, New York, Madrid, Philadelphia, Pittsburgh] 显然会返回 False。

#### 边的列表

另一种表示路径的自然方法是使用 *边的列表*。路径的起点和终点分别对应第一个边的起点和最后一个边的终点。列表中的每一条边表示两个节点之间的转换。

该公式要求附加的约束条件,即每条边的起点(除了第一条边外)必须等于前一条边的终点。我们说只有当边集 [*e*[0], *e*[1], . . . , *e*k] 满足以下条件时,它才是一个有效路径:

*e*[(]i [− 1)].to_node = *e*i.from_node 对于所有 *i* > 0

该定义限制了路径,要求每个段(边)的起点必须与前一个段(边)的终点对齐,从而与现实世界的情况相符。我们不允许路径在一个节点停止后,从另一个不同的节点重新开始。如果朋友给你关于从波士顿到西雅图的公路旅行指示,其中第一天是从波士顿开车到匹兹堡,第二天是从辛辛那提开车到圣路易斯,你很快会意识到有问题:拟定的路线跳过了俄亥俄州的大部分地方。图 图 3-2 中的路径就像我们朋友的错误指示一样,是无效的。

![图中有八个节点,显示了从节点零到节点五、从节点三到节点四、从节点四到节点七的无效路径,这些路径用加粗的边表示。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03002.jpg)

图 3-2:图中无效路径

在将路径表示为边的列表时,我们将每条边表示为元组(from_node, to_node),以便与 Edge 对象相对应。例如,图 3-1 中的路径对应于 [(0, 1), (1, 3), (3, 2), (2, 4), (4, 7)]。

我们通过遍历边的列表来检查路径是否有效,并确认最后一条边的终点是否与当前边的起点匹配,并且边在图中是否存在,如以下代码所示:

def check_edge_path_valid(g: Graph, path: list) -> bool:
❶ if len(path) == 0:
return True

❷ prev_node: int = path[0].from_node
if prev_node < 0 or prev_node >= g.num_nodes:
return False

for edge in path:
  ❸ if edge.from_node != prev_node:
        return False

    next_node: int = edge.to_node
    if not g.is_edge(prev_node, next_node):
        return False

    prev_node = next_node
return True 

check_edge_path_valid() 函数首先检查路径是否为空,如果是,则返回 True ❶,因为我们之前将零边的路径定义为有效。否则,代码将路径的起始节点作为第一条边的起点 ❷。它检查该节点是否具有有效的索引,如果没有,则返回 False。

然后,代码会遍历路径上的所有边。对于每一条边,它首先检查该边的起点是否与上一条边的终点相匹配 ❸。接着,它提取新的目标节点(next_node)的索引,并使用 is_edge() 函数检查节点是否有效,并且该边是否存在。如果新节点无效,或者图中没有相应的边,函数将立即返回 False。检查完边之后,代码会继续处理路径上的下一条边。如果没有发现任何无效的节点或边,函数将返回 True。

#### 前一个节点的列表

本书中的许多算法采用一种更为专业的路径表示方法,将节点映射到路径上前一个节点的列表。这种方法非常适合本书中许多算法通过从一个节点到下一个节点来处理数据的方式。它既有显著的优点,也有显著的缺点。

考虑路径[0, 1, 3, 2, 4, 7],如图 3-3 所示。对于路径中的每个节点,我们可以指示哪个节点在其之前,如列表last所示。值last[4]=2表示我们是*从*节点 2*到*节点 4 的。我们使用特殊值−1 来表示一个节点没有前置节点。这可能是因为该节点要么是路径中的第一个节点(路径的起点),要么根本不属于路径。

![一个包含八个节点的图中,路径通过粗体箭头从节点 0 到节点 1,从节点 1 到节点 3,从节点 3 到节点 2,从节点 2 到节点 4,再从节点 4 到节点 7。图下方是一个数组,标记为 last,包含值−1,0,3,1,2,−1,−1,4。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03003.jpg)

图 3-3:图中的路径及其作为前置节点数组的表示

这种表示方法的主要缺点是,它限制了我们可以表示的路径类型。每个节点最多只能有一个前置节点。我们无法表示许多会重新访问节点的路径,例如[0, 1, 3, 0, 5, 3, 4, 7]。如果我们尝试将此路径表示为前置节点的数组,我们将遇到节点 3 的last条目的问题。节点 3 首先由节点 1 前置,随后又由节点 5 前置。虽然可以定义更复杂的列表来处理这些情况,但我们使用单一的索引列表,因为它非常适合本书中的算法。

这种表示方法的主要优点是,更新路径非常容易,因为我们只需更改一个索引值。在后续章节中,我们将一遍又一遍地使用这种行为来简化代码。另一个优点是,这种表示方法可以捕捉多个分支路径。与前两种使用单一固定起点和终点节点的表示方法不同,前置节点表示法仅需要一个固定的起点。该列表可以用来从*任何*目标节点提取路径,回到起点。

在公路旅行类比中,前一个节点列表的表示相当于列出你*可能*访问的每个城市,以及你从哪个城市出发前往该城市。如果你的计划发生变化,决定访问匹兹堡,你可以通过费城和波士顿追踪前一个节点链。前一次访问的列表提供了从起始城市出发的所有可能停靠点的信息。

另一个有用的类比是,冒险者在探索迷宫时所做的粉笔标记,如图 3-4 所示。

![在一个 4×4 网格上构建的迷宫。起点位于左上角,出口位于左下角。每个网格单元中都有一个箭头,指向起点的方向。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03004.jpg)

图 3-4:迷宫中的粉笔标记,指示你从哪里来

为了不迷路,冒险者在每个交叉口画一个粉笔箭头,指示他们用来到达当前房间的通道。这些标记沿着走过的路径指向后方。这种粉笔标记相比面包屑具有明显的优势,因为它们的整体信息量更大,允许冒险者从地牢中的任何房间找到回到起点的路径。特别是,如果他们知道出口的位置,他们可以从出口重新构建回到起点的路径。

##### 将前一个节点列表转换为节点列表

为了将前一个节点的表示转化为节点列表,我们从给定的目标节点开始,倒着遍历last列表中的指针,如下面的代码所示:

def make_node_path_from_last(last: list, dest: int) -> list:
❶ reverse_path: list = []
current: int = dest

❷ while current != -1:
reverse_path.append(current)
❸ current = last[current] ❹ path: list = list(reversed(reverse_path))
return path


make_node_path_from_last()函数首先以反向的方式编译路径列表,并将其存储在reverse_path中,然后在最后将顺序反转。最初,代码将reverse_path列表设置为空,并从目标节点开始,设置current节点为目标❶。它使用一个while循环遍历路径,直到遇到-1,这表示没有前一个节点❷。在循环的每一步,代码将当前节点附加到reverse_path中,并根据last定义的前一个节点❸进行移动。最后,代码创建一个反向的reverse_path副本(即正确顺序的路径),并返回该列表❹。

让我们考虑当我们将代码应用于 图 3-5(a) 中的图,以及一个表示从节点 0 开始的路径的 last 数组时会发生什么:

[−1, 0, 1, 2, 2, 0, 5, 0, 5, 8]

图 3-5(b) 显示了图中的回溯指针。正如我们所见,箭头指向回到原点节点。图 3-5(c) 反转每个点,以展示如何重建前向路径。

![(A) 显示了一个具有 10 个节点的无向图。 (B) 显示了相同的图,箭头将每个节点连接回距离 0 更近的节点。 (C) 显示了与 (B) 相同的图,箭头被反转。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03005.jpg)

图 3-5:一个示例图(a),广度优先搜索创建的最后指针(b),以及通过图的相应前向路径(c)

如果我们将节点 9 作为目标节点(dest=9),我们按以下方式构建反向路径(表示为节点列表):

[9]

[9, 8]

[9, 8, 5]

[9, 8, 5, 0]

在这种情况下,返回的最终路径是 [0, 5, 8, 9]。

如前所述,前节点表示法使我们能够表示从单个起始节点到图中所有目标节点的路径。因此,通常无法将节点列表或边列表表示法转换为完整的前节点列表表示法。回到 图 3-5,从节点 0 到节点 3 的节点列表路径为 [0, 1, 2, 3]。虽然这告诉我们从节点 0 到节点 3 的路径的所有信息,但它并没有告诉我们从节点 0 到任何*不*在列表中的节点的路径。我们无法用它推导出从节点 0 到节点 5 的路径。

##### 检查前节点列表的有效性

为了测试这种路径类型的有效性,我们遍历列表中的所有条目,并检查前节点是否为 −1,或者边是否存在:

def check_last_path_valid(g: Graph, last: list) -> bool:
❶ if len(last) != g.num_nodes:
return False

for to_node, from_node in enumerate(last):
  ❷ if from_node != -1 and not g.is_edge(from_node, to_node):
        return False
return True 

check_last_path_valid() 代码首先检查 last 列表是否具有正确的条目数量 ❶。即使所有条目都是 −1,仍然需要为每个节点提供一个条目。

然后代码使用 Python 的enumerate()函数遍历列表,其中to_node是当前正在检查的last的索引,from_node是相应的值。代码检查是否from_node为-1,表示路径上没有前一个节点,或者to_node和from_node的组合对应有效边 ❷。如果两者都不成立,代码立即返回False。如果last中的每个条目都是有效的,代码返回True。

在我们的公路旅行示例中,这个函数遍历列表中的每个城市,并询问:“我们能直接从上一个城市到达那里吗?”我们会批准匹兹堡(Pittsburgh)的一个条目,该条目的前一个节点是费城(Philadelphia)或伊利(Erie)。然而,我们会坚决拒绝一个前一个节点为圣塔菲(Santa Fe)的波士顿(Boston)条目。

### 计算路径成本

对于许多使用案例,我们不仅可能对使用哪些边来构建路径感兴趣,还可能对路径整体的效益或成本有一个总体的度量。正如我们在第一章中看到的,我们可以使用边的权重来捕捉两个节点之间的遍历成本,例如在公路旅行中使用它们来模拟城市之间的距离。当我们规划从波士顿到西雅图的航班行程时,可能会跳过波士顿到迈阿密的边,因为它的成本接近 1,500 英里。

我们在加权图中定义*路径的成本*为沿路径的所有边权重的总和。形式上,我们说,对于一个路径[*e*[0], *e*[1], . . . , *e*k]:

*PathCost*([*e*[0], *e*[1], . . . , *e*k]) = *∑*i = [1] to k *e*i.weight

图 3-6 中路径[0, 3, 4, 2]的成本为 1.0 + 3.0 + 5.0 = 9.0。

![一个带有六个节点和加权有向边的图。箭头从节点 0 指向节点 3,标记为 1.0。箭头从节点 3 指向节点 4,标记为 3.0。箭头从节点 4 指向节点 2,标记为 5.0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03006.jpg)

图 3-6:带有六个节点的加权有向图

对于无权图,我们通常使用每条边的单位值 1.0,因此计算成本为使用的边的数量。

如果我们有一个表示路径的边列表,我们可以通过遍历这些边来计算路径的成本,如清单 3-1 中所示。

def compute_path_cost_from_edges(path: list) -> float:
❶ if len(path) == 0:
return 0.0

cost: float = 0.0
prev_node: int = path[0].from_node
for edge in path:
  ❷ if edge.from_node != prev_node:
        cost = math.inf
    else:
        cost = cost + edge.weight
    prev_node = edge.to_node

return cost 

列表 3-1:计算总路径成本的函数

代码首先检查路径是否至少包含一条边,如果没有,则返回成本 0.0 ❶。它还初始化了用于跟踪总成本 (cost) 和之前看到的节点 (prev_node) 的变量。prev_node 变量用于评估路径的有效性。

compute_path_cost_from_edges() 函数计算路径的成本,通过遍历列表中的每条边。它检查当前边的起点是否与最后一个节点匹配 ❷。如果节点不匹配,则路径无效。例如,边列表 [(0, 1), (2, 3), (3, 4)] 就无效,因为没有一条边连接节点 1 和节点 2,无法直接从节点 1 跳到节点 2。如果转换有效,则将边的权重加到成本中。如果转换无效,则使用无穷大的权重。根据实现方式,程序员可能会选择抛出异常、退出程序或使用其他方法来表示错误。代码更新 prev_node 变量来跟踪新的当前位置。

代码继续遍历列表,检查每条边并计算其权重的总和。当它完成列表中的每一条边时,它返回总成本,使用 return cost。

与本章其他函数不同,我们有意没有向此实现传递图,以演示如何仅在纯边列表上操作。这种简单性的缺点是该函数无法验证路径与图本身的一致性。我们可以通过传递图并使用它来验证节点索引和边的存在,扩展该函数以执行额外的检查。执行这些额外检查的代码遵循其他函数中的方法。

### 可达性

我们可以使用路径的公式化来形式化图中的另一个重要问题:“节点 *v* 是否可以从节点 *u* 到达?”这个问题对许多现实世界的问题至关重要。在交通网络中,它转化为问题:“我们能否从城市 *u* 到达城市 *v*?”在社交网络中,它转化为问题:“谣言能否从人 *u* 传播到人 *v*?”在迷宫中,它转化为至关重要的问题:“我能从这里到达出口吗?”

我们说节点 *v* 可以从节点 *u* 达到,当且仅当从节点 *u* 出发并到达节点 *v* 存在一条路径。给定一个候选路径,我们可以使用本章前面介绍的任何有效性检查器来测试该路径是否有效。

假设你被困在图 图 3-7 所表示的城堡地牢中。边表示相邻房间之间有无锁的门。为了逃离邪恶的巫师,你需要找到一间有通往楼上的楼梯的房间。在这里,可达性问题至关重要。如果你从房间 0 出发并且需要到达节点 15,那你就不走运了。节点 15 无法从节点 0 到达。

![一个包含 16 个节点的图被安排为一个 4 × 4 的方形。节点 10、11、14 和 15 彼此相连,但与其他节点没有连接。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f03007.jpg)

图 3-7:表示城堡地牢中各房间连接关系的图

在无向图中,我们可以将节点划分为不相交的集合,称为*连通分量*,使得每个连通分量中的任意节点都可以从该分量中的其他节点到达。给定节点子集 *V*′ ⊆ *V*,我们可以说一个连通分量是一个最大节点集,使得:

*reachable*(*u*, *v*) 对所有 *u* ∈ *V*′ 和 *v* ∈ *V*′成立

在我们来自 图 3-7 的地牢示例中,地图由两个连通分量 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13} 和 {10, 11, 14, 15} 组成。如果楼梯在与当前房间相同的连通分量中,我们就没问题。否则,我们就被困住了。

### 为什么这很重要

在本书中,我们将使用路径的概念来解决从路径规划到优化通过容量有限网络的流量等问题。路径将成为许多算法中使用的基本数据单元,并且是函数计算的最常见结果之一。诸如可达性和路径代价等概念将是众多算法的基础,从 第十一章 中找到强连通分量,到在 第十五章 中对二分图进行匹配。

除了在计算上的实用性,路径还可以帮助我们在现实世界的背景下可视化算法的操作。与其把路径看作抽象的“边的序列”,不如将路径想象成它们在现实世界中的对应物。本章反复使用公路旅行的类比,将边映射为跨越全国的道路。我们同样可以想象,通过本书讨论的许多算法物理地走过这些路径。

下一章将在路径的概念基础上,探讨多种探索图并返回所经过路径的算法。这些路径提供了关于搜索功能和在图中导航能力的重要信息。




# 第二部分 搜索与最短路径






## 第四章:4 深度优先搜索



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

在考虑图搜索算法时,会有几个自然的问题浮现。我们为什么要搜索图?我们在寻找什么?当我们创建图时,难道没有找到所有的节点吗?在某种程度上,*图搜索*这一术语没有充分体现这些算法的普适性。图搜索算法提供了一种系统地遍历图中所有节点的机制。我们可以利用这一能力来搜索特定的节点,比如在迷宫中寻找隐藏的宝藏,或是枚举并分析图的其他属性。

我们将从*深度优先搜索*开始探索图搜索。该算法通过一次探索当前节点出发的每一条边,逐步深入图中,然后回溯并尝试其他路径。这无疑是本书中最强大、最灵活、最有用的图算法之一,为后续章节中许多更高级的算法提供了核心逻辑。

深度优先搜索之所以如此有用,源于其简洁性和适应性。它可以通过一个相对简单的递归函数实现,并且通过少量的改进,它能够编译出关于图的海量信息。这使得它能够像可靠的厨房搅拌机一样为我们服务,帮助我们从基本的面包到婚礼蛋糕的制作。

本章介绍了深度优先搜索的潜在应用场景,然后讲解了该搜索的递归和基于栈的算法。我们展示了如何使用深度优先搜索来确定图的连通分量,并讨论了该搜索的两个有用扩展:深度优先搜索树和迭代加深。

### 使用场景

为了概述深度优先搜索的工作原理及其为何有用,让我们回顾一下在日常生活中可能使用此搜索的一些场景。

#### 探索篱笆迷宫

想象你站在一个巨大的篱笆迷宫入口处。随着紧张感的增加,你提醒自己,这并不是一个古希腊神话中的迷宫,没有怪物等着袭击那些毫无防备的冒险者。你只是在面对一项考验你空间感知和导航能力的多英亩挑战。一旁无聊的青少年工作人员喃喃自语着一句并不太令人放心的安慰:“他们通常会在关门前巡查迷宫,捡起迷路的徒步者。”

在这种情况下,图形搜索相当于从入口节点开始,寻找图中一个特殊节点——出口。如图 4-1 所示,你可以通过多种有效方式将迷宫表示为图。图 4-1(a) 显示了迷宫的形状。如图 4-1(b)所示,你可以将物理空间划分为单元格,并将每个单元格称为一个节点,与可达的相邻空间通过边连接。或者,如图 4-1(c)所示,你只为入口、出口和决策点创建节点。连接这些特殊点之间的路径成为图的边。

![(A) 显示了一个 5×5 网格上的迷宫。(B) 显示了迷宫作为图形的表示,其中 25 个网格单元对应于一个节点。(C) 显示了迷宫作为图形,包含 11 个节点,代表决策点和死胡同。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04001.jpg)

图 4-1:迷宫(a)与两种不同的图形表示(b)和(c)

在本章中,我们将多次回到这个迷宫的例子,作为一种有趣且简单的方式来可视化自己在图中徘徊。迷宫的例子还提供了现实世界中有趣的对应场景,用于标记节点或选择该走哪条边。更重要的是,我们可以随时添加怪物来增加一点刺激:所有最好的迷宫都会涉及一些危险。

#### 学习新学科

学习新学科可以看作是一个图形探索问题。每个节点代表一个感兴趣的子主题,边表示它们之间的关联。图形搜索表示通过各种子主题的学习旅程。目标不是到达某个特定的节点,而是覆盖该主题图的相关部分。

例如,考虑地质学这一总体主题。一位勇敢的学生开始学习关于这一学科的所有知识,从岩石这一主题入手。当他们阅读每个子主题时,他们构建了一个相关知识的图,如图 4-2 所示。他们的学习路径深入到细节中。对火成岩的参考引发了对黑曜岩的兴趣,进而是对火山的兴趣,最后做了经典的小苏打与醋火山科学实验。另一条路径带领他们通过变质岩到大理石,然后他们涉足了室内装饰和地板安装的相关内容。

![表示学习主题的图形。节点代表主题,如火山和岩石,并与相邻主题相连。岩石与火成岩、变质岩和沉积岩共享边缘。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04002.jpg)

图 4-2:从岩石主题开始的子主题图

图 4-2 中的图是极其不完整的。许多有趣的主题(如俯冲带和铝土矿)被省略了,而且图中连接这些主题的边远远不止所显示的数量。许多不同的岩石会与常见元素或矿物共享连接。仅仅探索该领域的整个主题图可能需要一生的时间。正如我们将在本章和下一章看到的那样,使用的搜索类型会对我们接近主题的顺序产生深远影响。

#### 检查可达性

在我们的日常生活中,我们经常想知道是否存在一条从给定起始节点到某个节点的路径。例如,我们可能会使用航班图来检查是否可以在两座城市之间旅行,或者我们可能会使用社交网络来检查一个谣言是否会从一个人传播到另一个人。

考虑图 4-3 中表示的人物网络。每个节点代表一个人,从节点 *u* 到节点 *v* 的边表示 *u* 愿意与 *v* 分享信息。一个由 0 号人物发现的有用信息可以通过图传递给 1、3 和 4 号人物。在 3 号人物的情况下,信息首先通过 1 号和 4 号人物。然而,2 号和 5 号人物完全被排除,因为没有任何信息共享路径通向他们。

![一个有六个节点的有向图。节点 5 有两条指向节点 2 和节点 4 的出边,以及一条从节点 2 指向节点 5 的入边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04003.jpg)

图 4-3: 一个有六个节点的示例有向图

本章稍后我们将探讨*可达性*和*连通组件*的概念,并在第十二章讨论这些问题在有向图中的算法。现在,了解图搜索如何回答可达性问题就足够了。给定起始节点 *S* 和目标节点 *G*,我们只需要从节点 *S* 开始搜索,并检查是否找到了节点 *G*。如果找到了,那么它们之间必定存在某条路径。

### 递归深度优先搜索

我们通常将深度优先搜索实现为一个*递归算法*,其中核心功能会针对每个节点被调用一次。本节展示了此搜索的代码,并通过一个示例图展示了它的执行过程。

#### 代码

清单 4-1 显示了深度优先搜索的简化版本。

def dfs_recursive_basic(g: Graph, ind: int, seen: list):
❶ seen[ind] = True
current: Node = g.nodes[ind]

for edge in current.get_edge_list():
    neighbor: int = edge.to_node
  ❷ if not seen[neighbor]:
        dfs_recursive_basic(g, neighbor, seen) def depth_first_search_basic(g: Graph, start: int):
seen: list = [False] * g.num_nodes
dfs_recursive_basic(g, start, seen) 

清单 4-1: 核心深度优先搜索递归函数

递归辅助函数需要几个信息:图本身 (g),当前正在探索的节点索引 (ind),以及一个映射每个节点是否已访问的列表 (seen)。代码首先标记当前节点为已访问 ❶ 并获取 Node 数据结构。然后通过遍历边的列表检查每个节点的邻居。对于任何尚未访问的节点 ❷,它递归地在该节点上调用搜索。

外部函数设置了 seen 列表,并从一个特定的起始节点索引 (start) 开始深度优先搜索。这个外部函数只从单一的起始节点开始搜索,因此只访问从该节点可以到达的节点。如果我们想访问每一个节点,就需要从每一个之前未访问过的节点开始搜索。正如示例 4-2 所示,我们将外部函数扩展为按顺序遍历节点,并从每个未访问过的节点调用递归的深度优先搜索。

def depth_first_search_basic_all(g: Graph):
seen: list = [False] * g.num_nodes
for ind in range(g.num_nodes):
❶ if not seen[ind]:
dfs_recursive_basic(g, ind, seen)


示例 4-2:一个探索图中所有节点的深度优先搜索

在初始化 seen 列表之后,代码循环遍历每个节点索引,检查该节点是否已在之前的深度优先搜索中被访问过 ❶,如果没有,则从该节点开始新的深度优先搜索。

虽然这段代码执行了深度优先搜索,但它并没有对搜索做任何有趣的处理。这就像在迷宫中散步,但没有记录任何解决方案。让我们考虑简单地添加记录路径的功能。这相当于带着一本笔记本进入迷宫,记录我们走的方向。

记录深度优先搜索过程中经过路径的代码使用了一个额外的列表——last 节点索引,即当前节点之前访问的节点:

def dfs_recursive_path(g: Graph, ind: int, seen: list, last: list):
seen[ind] = True
current: Node = g.nodes[ind]

for edge in current.get_edge_list():
    neighbor: int = edge.to_node
    if not seen[neighbor]:
      ❶ last[neighbor] = ind
        dfs_recursive_path(g, neighbor, seen, last) def depth_first_search_path(g: Graph) -> list: 
seen: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes

for ind in range(g.num_nodes):
    if not seen[ind]:
        dfs_recursive_path(g, ind, seen, last)
return last 

递归函数的开始方式与之前的基础版本相同。当前节点的索引被标记为已访问,当前节点被检索,并且一个 for 循环检查该节点的每个邻居是否已被访问。只有在探索新节点时,行为才有所不同。在递归调用新节点的深度优先搜索之前,代码会记录当前节点(ind)紧接着下一个节点(neighbor) ❶。正如在第三章中讨论的,last 列表提供了所有重建搜索路径所需的信息。

外部函数类似地被修改为初始化并传入这个之前节点的列表 last。last 列表使用一个指示值 -1 来表示没有前置节点。搜索结束时值为 -1 的节点是各种深度优先搜索的起始节点。

#### 一个示例

图 4-4 显示了在一个包含 10 个节点的无向图上进行递归深度优先搜索的示例。每个子图显示当前节点在函数中被标记为已访问后的状态。虚线圆圈表示正在探索的当前节点。阴影节点是已访问(因此标记为已访问)的节点。last 向量展示了搜索过程中图形路径的变化。

搜索从图 4-4(a)中的节点 0 开始,节点 0 有三个邻居:节点 1、5 和 7。我们可以将其可视化为探险者在迷宫中探险(比起树篱迷宫,这更为刺激)。节点 0 代表探险者站在第一个交叉口,考虑前方的三个可能分支。他们不知道哪个分支会通向出口,哪个会导致死胡同。

搜索选择了第一个邻居节点 1,并递归触发深度优先搜索。在图 4-4(b)中探索节点 1 时,我们可以看到 last 已被更新,表示节点 1 是从节点 0 到达的。就像我们的探险者从交叉点 0 走到交叉点 1 时,他们在小笔记本中记录下这一步,以便将来传承下去。

![十个子图展示了搜索的各个阶段。在(A)中,节点 0 被圈出。在(B)中,节点 1 被圈出,并且最后数组中索引 1 的条目从 -1 更新为 0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04004.jpg)

图 4-4:对具有 10 个节点的图进行递归深度优先搜索

深度优先搜索总是从一个节点移动到一个直接相连的节点。类似地,我们的冒险者会一直沿着一条路径前进,直到遇到死胡同。然后,可能充满恐慌和害怕,他们会回溯并尝试其他路径,同时坚定地希望他们不会遇到任何怪物或讽刺的迷宫守卫。由于回溯是返回到相邻房间,它在物理上是合理的。

搜索会继续遍历整个图,按节点索引递增的顺序递归地探索邻居。在迷宫示例中,这对应于我们的探险者深入迷宫并在死胡同处后退。由于图和深度优先搜索的结构,达到某个节点的路径不一定是最短的。例如,虽然节点 5 可以直接从节点 0 到达,但搜索会通过路径[0, 1, 2, 4, 9, 8, 5]到达它。

在这个例子中,所有的节点都可以从一个起始节点到达。然而,正如上一节所指出的,这种情况并不总是成立。如清单 4-2 所示,我们可能需要从不同的初始节点开始多次深度优先搜索,以便完全覆盖图。

深度优先搜索的简洁性可能成为一个缺点。探索哪个邻居是任意的(这里基于索引排序),而不是利用我们可能对世界的了解。如果我们的冒险者身处一个有西出口的迷宫,他们可能会优先向西而不是向东。我们将在第八章中看到一些将此类启发式信息纳入的方式。

### 使用栈的深度优先搜索

我们也可以通过使用*栈*,将深度优先搜索实现为一个迭代函数,而不是使用递归。

#### 代码

清单 4-3 使用标准的 Python list 作为我们的栈(使用 append 来执行传统的栈操作 push)。

def depth_first_search_stack(g: Graph, start: int) -> list:
seen: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
to_explore: list = [] ❶ to_explore.append(start)
❷ while to_explore:
❸ ind = to_explore.pop()
if not seen[ind]:
current: Node = g.nodes[ind]
seen[ind] = True

      ❹ all_edges: list = current.get_sorted_edge_list()
      ❺ all_edges.reverse()
        for edge in all_edges:
            neighbor: int = edge.to_node
            if not seen[neighbor]:
                last[neighbor] = ind
                to_explore.append(neighbor)
return last 

清单 4-3:基于栈的深度优先搜索

迭代深度优先搜索的代码通过初始化我们的辅助数据结构开始。除了 seen 和 last 列表,函数还使用一个名为 to_explore 的栈来跟踪未来需要探索的节点索引。该函数通过将初始节点推送到 to_explore 栈开始 ❶。

函数中的大部分工作是在一个while循环中完成的,该循环遍历<code class="SANS_TheSansMonoCd_W5Regular_11">to_explore</code>中的元素,直到栈为空 ❷。每次迭代时,栈顶的索引被弹出 ❸,如果该索引之前未被访问,则进行探索。与递归函数类似,代码检索节点数据结构并将该索引标记为已访问。接着,代码检索所有边的列表 ❹。一个for循环遍历所有的出边,代码设置所有尚未访问节点的last值,并将它们添加到栈中。

为了与其他示例保持一致,代码在清单 4-3 中反转了列表,以*递减*顺序检查邻居的索引 ❺。这不是算法的必要组成部分。

#### 一个示例

图 4-5 展示了使用栈的迭代深度优先搜索的执行过程。如同图 4-4 所示,当前节点用虚线圆圈表示,已访问的节点被着色。

![十一张子图展示了搜索的各个阶段。在(B)中,节点 0 被圈出,最后的数组在条目 1、5 和 7 的位置上的值为 0。在(C)中,节点 1 现在被圈出,最后的数组中索引 2 的条目已从-1 更新为 1。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04005.jpg)

图 4-5:在一个包含 10 个节点的图上进行的迭代深度优先搜索

这种基于栈的实现与递归实现有两个有趣的不同之处。首先,last 数组会更新,以反映*最新*的通向该节点的路径,直到节点被访问。在图 4-5(b)中,last 列表表示通向节点 5 的路径来自节点 0,因为搜索已发现节点 5 是节点 0 的邻居。然而,随着深度优先搜索的进行,节点 5 的条目被更新。在图 4-5(d)中,搜索发现通过节点 2 到达节点 5 的路径更加新近。在图 4-5(h)中,搜索找到通过节点 8 的另一条路径。

其次,这里要探索的节点栈包含了*重复*的节点,比如图 4-5(h)中出现的三次节点 5。这是因为,如前一段所述,深度优先搜索在深入探索时可能会看到多个通向同一节点的路径。这些重复不会影响算法的准确性,因为我们在从栈中弹出索引时会检查节点是否已被访问。然而,它们会增加内存使用量。通过一些修改,且需要额外的运行时间开销,我们可以扩展代码,只保留栈中最高的那个索引实例。

递归方法和基于栈的方法的区别对应于我们的探索者如何跟踪他们在迷宫中的旅程。在这两种方法中,他们都会记录自己访问过的房间,并在他们的seen笔记本中做标记。然而,在基于栈的方法中,他们还保持一个标记为to_explore的第二个笔记本。与递归方法中仅进入未访问的相邻房间不同,探索者会仔细写下所有与当前房间相邻的未访问房间。在更换房间之前,他们会检查最近添加到笔记本中的房间,并前往那个房间。

### 查找连通分量

我们可以使用深度优先搜索来查找无向图中的连通分量集。如第三章所述,无向图中的连通分量是一组节点,使得集合中的每个节点都可以到达该集合中的其他每个节点。如果我们从图中的一个节点开始深度优先搜索,它只会访问从该起始节点可到达的节点。在无向图中,这些访问过的节点构成一个连通分量。通过从任何未访问的节点重新运行深度优先搜索,正如在清单 4-2 中所示,我们可以映射图中的所有连通分量。

#### 代码

以下代码从每个未访问的节点进行深度优先搜索,同时还维护有关哪个节点属于哪个连通分量的信息:

def dfs_recursive_cc(g: Graph, ind: int, component: list, curr_comp: int):
❶ component[ind] = curr_comp
current: Node = g.nodes[ind]

for edge in current.get_edge_list():
    neighbor: int = edge.to_node
  ❷ if component[neighbor] == -1:
        dfs_recursive_cc(g, neighbor, component, curr_comp)

def dfs_connected_components(g: Graph) -> list:
component: list = [-1] * g.num_nodes
curr_comp: int = 0

for ind in range(g.num_nodes):
    if component[ind] == -1:
      ❸ dfs_recursive_cc(g, ind, component, curr_comp)
        curr_comp += 1

return component 

该代码修改了递归深度优先搜索函数,使其使用一个单一的列表(component)来跟踪节点是否已访问,并且如果已访问,记录它属于哪个连通分量。递归函数通过设置当前节点的连通分量开始 ❶。在探索一个邻居之前,它会检查该邻居是否已经是现有连通分量的一部分(因此已经被访问过) ❷。

外部函数首先设置辅助数据结构,包括一个将每个节点映射到其连通分量编号的列表(component),以及当前连通分量编号的计数器(curr_comp)。与清单 4-2 中的全面深度优先搜索一样,代码接着遍历每个节点并检查它是否已经被访问。如果没有,它将从该节点开始深度优先搜索 ❸。在每次深度优先搜索中,它会填写更多的<	samp class="SANS_TheSansMonoCd_W5Regular_11">component列表的值。

#### 一个示例

图 4-6 展示了在一个有三个连通分量的图上应用此算法的示例。阴影圆圈表示每次迭代后访问的节点,虚线圆圈表示该次迭代搜索的起始节点。

![一个包含八个节点和三个连通分量的图。在子图 B 中,节点 0 被圈出,节点 0、1、2 和 4 被阴影标示。]](../images/f04006.jpg)

图 4-6:基于深度优先搜索的连通分量检测步骤

图 4-6(a) 显示了第一次搜索之前图的状态,此时没有节点被访问过,也没有分配组件编号。如图 4-6(b)所示,第一次搜索从节点 0 开始,找到了组件 {0, 1, 2, 4}。图 4-6(c) 显示第二次搜索从节点 3 开始,3 是第一个未访问的节点,找到了组件 {3, 7}。最终的搜索在图 4-6(d)中显示,搜索从节点 5 开始,找到了组件 {5, 6}。

### 深度优先搜索树与森林

如果我们保存深度优先搜索过程中遍历的边,我们可以捕获关于搜索结构和图本身的有用信息。上一节中的连通分量只是其中的一种信息类型。考虑搜索一个无向图,图如图 4-7(a)所示。

![(A) 显示了一个包含七个节点的无向图。节点 0 与节点 1 和 4 相连。 (B) 显示了一个以节点 0 为根节点,节点 1 和 4 为其两个子节点的树。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04007.jpg)

图 4-7:一个无向图(a)和一个深度优先搜索树的示例(b)

特定搜索在探索节点(和遍历边)时的顺序定义了一种树形结构,称为*深度优先搜索树*(有时也简称为*深度优先树*),该结构总结了搜索的过程。每一条遍历过的边都会被包含在树中。节点的层次结构由深度优先搜索遇到它们的顺序决定。如果搜索从节点 *u* 进展到未访问的节点 *v*,那么 *u* 就是 *v* 在树中的父节点。或者,利用本章代码中的 last 数组,树中索引为 *i* 的节点的父节点是 last[i]。图 4-7(b) 显示了从节点 0 开始的深度优先搜索树。

深度优先搜索树不是唯一的,而是取决于搜索开始的位置,如图 4-8 所示。将搜索从相同的无向图中的节点 2 开始,如图 4-8(a)所示,将导致一个不同的深度优先搜索树,如图 4-8(b)所示。

![(A) 显示与图 4-7(a) 相同的无向图。 (B) 显示一个以节点 2 为根节点,节点 1、3 和 5 为其子节点的树。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04008.jpg)

图 4-8:一个无向图(a)和一个交替的深度优先搜索树(b)

如前所述,单次深度优先搜索可能无法遍历整个图,意味着我们可能需要运行多次深度优先搜索以保证完整性。这就产生了*深度优先搜索森林*(或者简单称为*深度优先森林*)的概念,其中每次单独的深度优先搜索会生成一个以初始节点为根的树形数据结构。森林是这些独立树的集合。如图 4-9 所示,当无向图存在断开的分量时,这种情况自然会出现。图 4-9(a) 中的两个断开分量 {0, 1, 2, 3, 4, 6} 和 {5, 7, 8} 在图 4-9(b) 中形成了两棵不同的树。

![(A) 显示了一个有两个连通分量的图。(B) 显示了两棵树,一棵以节点 0 为根节点,另一棵以节点 5 为根节点。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04009.jpg)

图 4-9:一个有两个非连通分量的无向图(a)及示例深度优先搜索树(b)

在有向图中,是否需要进行多次搜索取决于我们选择的起始节点。图 4-10 显示了一个有向图示例及其深度优先搜索森林。图 4-10(a) 描绘了原始有向图,而图 4-10(b) 显示了通过按递增索引顺序检查节点所得到的深度优先搜索森林。

![(A) 显示了一个有九个节点的有向图。(B) 显示了三个以节点 0、5 和 7 为根节点的树。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04010.jpg)

图 4-10:一个示例图(a)及其对应的深度优先搜索森林(b)

因为我们是按递增索引顺序排列的,所以在检查节点 7 或 8 之前,会先检查节点 5。这导致了一个以 5 为根的独立树,因为从 5 不能到达任何其他节点。然而,节点 5 可以通过节点 7 和 8 到达。如果我们先检查了这些节点,那么节点 5 就会在它们的树中。森林的结构由图的结构以及我们搜索节点的顺序共同决定。

在后面的章节中,我们将利用深度优先搜索树的结构帮助我们理解深度优先搜索本身的行为。现在,只需知道这些树捕捉了深度优先搜索在给定图中进展的相关信息。

### 迭代加深

深度优先搜索的一个主要缺点是,当存在更接近的目标状态时,它可能会浪费时间在长(或深)的死胡同上。想象一下,一个在地下洞穴系统中迷路的洞探者。前方有多个分支路径,有些通向地面,另一些则深入洞穴。为了生还,他们希望使用一种不需要走 10 英里的地下道路最终到达死胡同,然后又得返回并尝试其他选项的搜索策略。

*迭代加深* 是一种在深度优先搜索中限制过深路径的策略。算法不是一直沿着一条路径走到尽头,而是从预定深度开始,达到该深度时就停止探索。如果整个搜索未能找到目标,迭代加深会增加最大深度并重新运行搜索。这个过程会一直持续,直到找到目标或整个图形都被搜索完为止。

假设我们的迷路洞探者把自己绑在一根固定长度的绳子上。洞探者使用这根绳子来限制他们愿意进入洞穴系统的深度。他们沿着一条路径走,直到到达绳子的尽头。即使前面还有更多的通道,他们也会返回并探索其他仍然能通过现有绳索到达的路径。只有在他们已经探索完所有可能的路径后,才会换上一根更长的绳子。这能防止他们在走错方向之前走得太远,而忽略了其他选项。

初看起来,迭代加深可能显得是个巨大的浪费。它会多次探索相邻的节点(使用多个最大深度)。距离起始节点一步之遥的节点每次都会被探索。同样,我们的洞探者也会多次访问第一个交叉口。

然而,在某些情况下,这种方法可能是有用的。考虑一个树形图,如图 4-11 所示。正常的深度优先搜索将沿着单一分支一直向下,直到最后。如果树很深,这可能会涉及很多节点。

![一个有五层的二叉树,从左到右分支。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04011.jpg)

图 4-11:像树一样分支的图形

相比之下,如图 4-12 所示,迭代加深有效地按层级搜索树。在第一次迭代(最大深度为 1)中,只会探索三个节点。在第二次迭代(最大深度为 2)中,探索了七个节点,其中包括四个新节点。

![(A) 显示了图 4-11 中的二叉树,仅根节点被着色。(B) 显示了同一棵二叉树,根节点及其两个子节点被着色。(C) 显示了同一棵树,最左边的七个节点被着色。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f04012.jpg)

图 4-12:迭代加深的前三次迭代

对于像图 4-11 中这样的平衡完整二叉树,每次迭代大约需要前一次迭代的两倍时间,并探索两倍的节点。正如我们将在下一章看到的,迭代加深会产生类似于广度优先搜索的搜索模式。

### 为什么这很重要

深度优先搜索是一个核心图算法,我们将在本书的后续部分使用它,并从其简单的递归形式构建许多扩展。在图算法的世界里,这种搜索是一个基础构件。在本书后面的章节中,我们将使用图搜索算法,包括许多深度优先搜索的变种,来揭示有向图中节点的内在顺序或提出图着色中的节点标记。

不幸的是,深度优先搜索并不总是完美的解决方案。它在选择下一个要探索的节点时不使用启发式方法。更糟糕的是,它容易遍历长时间的死胡同。

接下来的章节将介绍从深度优先搜索构建的技术和避免其一些缺点的替代搜索算法。首先,我们将考虑另一种类型的搜索:广度优先搜索。




## 第五章:5 广度优先搜索



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

*广度优先搜索* 是探索图的一种替代方法,它像波浪一样从起始节点开始推进。与深度优先搜索优先处理最近发现的节点不同,广度优先搜索优先探索在搜索过程中较早发现的节点。这一优先级的简单变化导致搜索算法表现出截然不同的行为,并具有一系列有用的特性。

广度优先搜索的关键思想是,它使用先进先出(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。然而,也可以使用像list这样的内置数据结构。在主循环开始之前,代码将起始节点插入到pending队列中,并将其标记为已访问❶。

该函数使用while循环继续探索节点,直到队列为空。在每次迭代中,它从队列的前端取出一个节点,并使用for循环检查该节点的每个邻居。如果该函数尚未访问该邻居❷,它将其添加到队列中,标记为已访问,并更新指向前一个节点的指针。函数最后返回last列表,该列表记录了搜索过程中经过的路径。

与第四章中深度优先搜索的实现不同,后者在搜索访问到节点时就将其标记为已访问,广度优先搜索的实现是在首次将节点添加到队列时将其标记为已访问。这样可以防止队列中出现重复的条目。

#### 一个例子

图 5-1 显示了通过一个包含 10 个节点的图进行广度优先搜索的步骤示例。被阴影标记的节点已被标记为已访问。每个子图显示了 last 数组的设置以及队列的状态,队列的前端在左侧。

图 5-1(a) 表示在我们开始 while 循环之前,搜索的状态。搜索已经将起始节点(0)标记为已访问,并将其放入队列中。所有其他节点仍然标记为未访问。每个节点的回指针(last)的值为 -1,包括起始节点。

![在(A)中,只有节点 0 被阴影标记,last 数组的所有元素都设置为 –1。在(B)中,节点 0 被虚线圈住,且其邻居(节点 1、5 和 7)被阴影标记。last 数组中元素 1、5 和 7 的条目为 0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05001.jpg)

图 5-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。它标记这两个节点为已访问,并更新它们的最后一个条目指向节点 5。然后,它将 6 和 8 放在待探索节点列表的末尾;它将适时进行调查。

搜索在图 5-1(e)中继续进行,通过访问节点 7 来探索节点 0 的最后一个邻居。虽然我们现在已经看到了示例图中的大部分节点,但我们只访问了距离节点 0 一步或更少的节点。我们的搜索像波浪一样从起始节点扩展,先访问所有靠近的节点,然后再访问更远的节点。

搜索继续在图的其余部分进行。在每一步中,它从队列的前端提取节点进行探索。这个节点是队列中等待时间最长的节点。它访问该节点,检查并处理任何新的邻居。搜索在图 5-1(k)中完成,当它从队列中提取出最后一个节点时。

### 寻找最短路径

广度优先搜索的一个主要优点是,它能够找到从起始节点到所有可达节点的最短路径。处理无权图时,我们使用*最短路径*这一术语来表示边数最少的路径。广度优先搜索能够找到这样的路径,得益于算法在探索路径时的优先级排序。通过使用先进先出的数据结构,算法有效地优先考虑最靠近起始节点的未探索节点。

图 5-2 展示了这一行为,描绘了图 5-1 中的图,虚线表示到达每个节点的步数。在探索起始节点时,广度优先搜索会将所有可以从此节点一步到达的节点加入队列。接下来,它按顺序探索这些节点。在探索一步之遥的节点时,它可能会发现一些距离两步远的新节点。然而,这些节点总是被添加到队列的末尾,因此在所有一步之遥的节点都被访问后才会进行探索。因此,广度优先搜索会先遍历所有*距离 k*步的节点,然后再考虑任何*距离 k+1*步的节点。

![节点 0 位于标为 0 步的区域。节点 1、5 和 7 位于标为 1 步的区域。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05002.jpg)

图 5-2:显示广度优先搜索扩展的等高线

在加权图中,*最短路径*的概念通常用来描述边权重总和最小的路径。由于广度优先搜索不考虑边的权重,因此广度优先搜索不会找到成本最小的最短路径。

作为示例,考虑图 5-3 中的图。无论到达该节点的边权重如何,节点 1 和节点 2 都会在探索节点 2 时通过广度优先搜索被访问。它们都会被标记为已访问,添加到队列中,并将它们的路径上的前一个节点设为 0。该算法并不会寻找或找到通过节点 1 到节点 2 的低权重路径,但它仍然会找到包含最少边数的路径。

![一个三节点图,与图 5-2 相同的等高线。边缘标注了权重。(0, 1)的权重为 1.0,(0, 2)的权重为 10.0,(1, 2)的权重为 1.0。虚线表示到每个节点的步数。节点 0 位于标为 0 步的区域。节点 1 和 2 位于标为 1 步的区域。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05003.jpg)

图 5-3:显示广度优先搜索在加权图上扩展的等高线

我们将在本书后面讨论边权重和最短路径的问题。第七章介绍了各种加权图上的最短路径算法,第八章则研究了考虑边权重的启发式搜索算法。

### 简单路径规划

广度优先搜索能够找到最少边数的路径,使得它在有限的一些*路径规划*任务中非常有用。考虑一下老式视频游戏中的任务:在一个平坦的二维网格上规划路径,其中一些方格被岩石障碍物阻挡。本节讨论了如何创建一个图来表示这个问题,以及运行广度优先搜索时得到的解决方案。

在有障碍物的平面上进行广度优先搜索路径规划,提供了广度优先搜索操作的有用示例。它还介绍了我们如何通过图的形式来表示路径规划问题,为后续章节的更优路径规划算法做准备。这些算法包括考虑边权重的*最低成本路径算法*,用以模拟不同地形的成本差异,以及通过优先选择最有可能的路径来提高搜索本身运行时间的*启发式引导搜索算法*。

#### 从网格构建图

构建规则网格的图形表示有多种用途,从路径规划到计算机视觉。底层的网格可能代表地图上的空间区域(用于路径规划或科学计算)、图像中的像素(用于计算机视觉),甚至只是节点的排列。为了本节的目的,我们将重点讨论视频游戏地图。

我们通过为每个网格方格创建一个单独的节点,并为每对相邻的方格创建一个单独的无向边来生成基于网格的图。对于一个行数 = *height* 和列数 = *width* 的网格,我们首先分配 *height* × *width* 个节点。我们可以将这些节点以网格模式直观地表示,如图 5-4(b)所示,但从技术上讲,它们存储在图数据结构中的一个单一列表中。

![(A) 显示一个 4×4 的网格。(B) 显示一个 4×4 的图形。图中的节点与其上下左右的相邻节点相连接。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05004.jpg)

图 5-4:一个网格(a)和其图形表示(b)

我们可以将行*r*和列*c*的网格坐标映射到相应的节点索引,如下所示:

*index* = *r* × *width* + *c*

因为这些边是无向的,我们可以从左上角扫描节点到右下角,并向右侧或下方的相邻节点插入边。如果我们将其指定为遍历网格的*r*和*c*值,那么我们检查是否包括一条边的条件如下:

如果*c* < *width* – 1,节点右侧有一个相邻节点,索引为*index* + 1。

如果*r* < *height* – 1,节点下方有一个相邻节点,索引为*index* + *width*。

清单 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 中那样的网格,其中开放单元是可遍历的,阴影圆圈表示障碍物。

![障碍物是阴影圆圈。第一行的第二个和第五个单元格中有障碍物。左上角的单元格标记为 s。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05005.jpg)

图 5-5:一个带有八个障碍物的 6×6 网格

代码与示例 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-5 中的 6 × 6 网格。在左侧的图中,未占用的单元格都被编号。左上角的单元格标号为零,下面的单元格标号为一。在右侧的图中,未占用的单元格有箭头指向前一个单元格。第二行的第一个单元格指向左上角的单元格。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f05006.jpg)

图 5-6:广度优先搜索后的探索顺序 (a) 和最后指针 (b)

图 5-6(a) 中的网格显示了搜索访问每个网格单元的顺序。它从左上角开始,像一个渗透的胶状生物一样向外扩展,试图吸收整个世界。虽然其他单元格的直线距离很近,但搜索需要一些时间才能访问它们,因为搜索需要绕过障碍物。

图 5-6(b) 中的网格显示了图中每个节点的 最后 指针。利用这些指针,我们可以从任何目标节点重新构建到起始节点的最短路径。例如,第 3 行第 2 列的单元格(标记为 9)可以通过向上、向左、向上、向左和向上的方式到达起点。我们可以反转这些指针,得到从起始节点到任何可达目标的最短路径。

### 为什么这很重要

广度优先搜索提供了一种不同的图搜索机制,其行为不同。由于它对节点的探索顺序,它优先探索与起始节点距离最近的节点,距离是通过边的数量来衡量的。因此,广度优先搜索从起始节点向外扩展,探索每个节点时经过的边数最少。这种行为使得广度优先搜索成为各种更复杂图算法的重要组成部分。

此外,广度优先搜索既简单又高效。与常见的递归实现深度优先搜索不同,广度优先搜索的标准实现使用迭代的 while 循环,而不是递归函数调用来操作队列。

接下来的章节将探讨另一种类型的图搜索:在加权图上找到最短路径的算法。这些算法基于我们目前介绍的基本搜索,解锁了一系列新的应用。




## 第六章:6 解谜



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

图搜索算法的应用不仅限于搜索物理位置之间的路径或物品之间的虚拟链接。它们还广泛应用于更多抽象的问题,比如解谜或在游戏中制定策略。

许多谜题可以通过一组离散的状态来表示,这些状态捕捉了谜题的不同配置。*解决谜题*可能需要通过一系列步骤,从初始状态过渡到某个预定义的目标状态。这可能对应于在河内塔谜题中移动圆盘,在过河谜题中移动人,或在滑块谜题中重新排列拼图块。在本章中,我们通过将谜题的状态建模为节点,将它们之间的过渡建模为边,来将这些谜题转化为图搜索问题。然后,我们通过搜索到达谜题目标状态的路径来解决它们。

### 状态空间与图

本节描述了我们三个经典谜题的状态空间表示和图表示。

#### 河内塔

河内塔谜题由三个柱子和不同直径的圆盘组成,这些圆盘可以堆叠在柱子上。最初,所有圆盘都在最左边的柱子上,并按直径从大到小堆叠,如图 6-1 所示,其中最宽的圆盘在底部,最小的圆盘在顶部。谜题的目标是一次移动一个圆盘,使所有圆盘最终都按相同的顺序堆放到最右边的柱子上。

![一个有三个柱子的棋盘。第一个柱子上堆着三个圆盘,按直径从大到小排列。其他两个柱子是空的。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06001.jpg)

图 6-1:河内塔谜题的初始状态

有两个约束条件使得这个谜题变得有趣。首先,你只能移动每个堆叠中最上面的圆盘。第二,每一步操作后,每个堆叠必须保持排序。你永远不能将一个较大的圆盘放在较小的圆盘上面。例如,图 6-2 展示了从初始状态到有效第二状态的一个移动。

![一个有三个柱子的棋盘。第一个柱子上堆着两个圆盘,按直径从大到小排列。第二个柱子上有最小的圆盘。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06002.jpg)

图 6-2:移动一个圆盘后的河内塔谜题

我们可以通过多种方式将不同的圆盘配置表示为*状态*。为了说明这一点,假设有一组三个向量,用来跟踪每个柱子上的圆盘顺序,其中组合的向量集代表了谜题的状态。我们用尖括号表示状态。图 6-1 中的状态表示为向量<[3,2,1],[],[]>,而图 6-2 中的状态表示为<[3,2],[1],[]>。

我们可以把这些状态看作是在枚举谜题的所有可能性。当前的圆盘排列可能是<[3],[],[2,1]>。然而,如果我们做出了不同的操作,可能会得到排列<[3],[1],[2]>。我们可以放松心情,设想各种可能的选择,如何在它们之间移动,以及未来操作的影响。

通过将谜题的状态表示为向量,我们可以将问题转化为一个无向图,每个状态用一个节点表示,有效的操作用边表示。这在图 6-3 中进行了说明,该图表示了三个圆盘的汉诺塔,其中的节点是我们解决问题过程中的潜在目标状态。一个节点代表我们起始的状态<[3,2,1],[],[]>,另一个节点代表我们的目标状态<[],[],[3,2,1]>,中间还有许多其他状态。

![每个节点都标有三个数组。最左边的节点标有数组[3, 2, 1]和两个空数组。这个节点有两个右侧邻居。顶部节点标有数组[3, 2]、[1]和一个空数组。底部节点标有数组[3, 2]、一个空数组和数组[1]。](../images/f06003.jpg)

图 6-3:汉诺塔谜题的图形表示部分

由于我们可以通过将最小的圆盘从第一个柱子移动到第二个柱子,从状态<[3,2,1],[],[]>转移到状态<[3,2],[1],[]>,因此图中在这两个节点之间有一条无向边。相比之下,我们不能通过移动单个圆盘从状态<[3,2,1],[],[]>转移到状态<[2,1],[3],[]>,因此这些状态之间没有边。

直到现在,本书中的示例图节点大多表示具体的项目,如物理位置、计算机节点、网页、任务或人物。然而,现在我们的节点表示的是世界的潜在配置。这带来的一个直接后果是节点数量的爆炸。汉诺塔中盘子的潜在配置比物理盘子或柱子的数量要多得多。这意味着我们可能会遇到比以往更大的图,算法的性能变得更加重要。

#### 过河难题

过河难题是一类脑筋急转弯问题,要求将一组人或动物通过一只有容量限制的船运送过河。挑战来自于关于哪些实体可以单独留在岸上的限制。

让我们考虑一个经典的过河难题,我们将其描述为囚犯与看守的难题,其中三名看守和三名囚犯需要过河。可用的工具是一只最多能容纳两人的船。囚犯被戴上手铐,若单独留在岸边无法逃脱。然而,如果岸边囚犯的数量超过看守,囚犯就会联合起来抢夺看守的钥匙。因此,每个岸边上,看守必须至少有*与囚犯相同数量*的人陪伴。

我们可以将这个难题表示为一个*状态空间图*,其中每个节点表示难题的一个状态。该状态由三部分信息组成:左岸的看守数量,左岸的囚犯数量,以及船是否在左岸或右岸。右岸的看守和囚犯数量可以从左岸的数量推导出来,因此我们不需要显式地存储这些信息。问题的起始状态是<3,3,L>,所有六个人和船都在左岸。

图中的每个节点表示一个有效的状态,而边连接那些通过单次移动可以到达的状态。有效的移动包括将任意一组合适的人员或两人送过河:两名看守,一名看守,一名看守和一名囚犯,两名囚犯,或一名囚犯。船不能没有人驾驶而过河,因为需要有人操控它。由于我们可以通过将相同的配置送回原岸来撤销任何移动,图是无向的。

图 6-4 展示了 16 个可能状态的整个图。每个状态是一个单独的节点,既有图形表示,也有文本表示。字母G和P分别表示警卫和囚犯的位置。船的位置在状态底部用R或L表示。对于许多状态,只有两种有效的移动,但对于其他状态,则有多个选择。

![每个 16 个节点都包含拼图状态的图片及其标签。最左侧的节点有状态(3, 3, L),并显示三名警卫、三名囚犯以及河流左侧的船。它有三个邻居,状态分别是(3, 1, R)、(2, 2, R)和(3, 2, R)。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06004.jpg)

图 6-4:囚徒与警卫过河问题的图表示

这个拼图展示了在使用相对较小的图时如何表示状态,并且使得可达状态集的可视化和分析变得容易。在后续的章节中,我们将使用这个拼图展示如何通过编程方式创建图并搜索解决方案。

#### 滑动拼图

滑动拼图由一个方格的瓦片组成,其中一个方格为空缺,形成一个空位。瓦片可以通过滑动相邻的瓦片进入空位来重新排列,实质上允许瓦片和空位交换位置。拼图的目标是将每个瓦片移动到正确的位置。根据拼图的不同,我们可能需要排列一系列数字或拼出一张图片。这个游戏的经典例子是 15 格拼图,如图 6-5 所示,其中每个瓦片标有从 1 到 15 的整数,正确的状态是所有瓦片按照从左上到右下的升序排列。

![一个 4×4 的方格,格子编号为 1 到 15,其中一个格子已灰显。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06005.jpg)

图 6-5:15 格拼图

滑动拼图非常适合使用图表示。每一种可能的瓦片排列都是一个独特的拼图状态,可以通过一个图节点表示。边表示状态之间可能的遍历。每个状态最多有四条无向边,表示通过将该状态的空缺位置填入四个相邻瓦片中的每一个,可以到达的邻居。我们可以将这些边称为上、下、左、右。

图 6-6 展示了一个示例状态及其四个邻居。通过在图中搜索从初始状态节点到目标状态节点的路径,我们可以找到一系列解决拼图的移动步骤。

![由五个节点组成的图,每个节点都包含一个 4 × 4 的滑块难题,类似于图 6-5 中的难题,只是空白方格的位置不同。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06006.jpg)

图 6-6:15 方块难题的一个状态(中间)及其四个邻近状态

15 方块难题展示了状态空间如何迅速膨胀。这个看似简单的难题有超过 20 万亿个状态,其中许多状态在我们寻找解法的过程中根本不需要访问。

### 使用搜索构建图

前几章中的图搜索算法要求我们提供一个完全指定的图。对于难题问题,这可能不可行。在开始搜索之前,我们不想费力地手动列举出它们众多的状态。这不仅会非常耗时,而且很可能会导致错误,并且不小心引发作弊的等价问题——添加一条允许非法移动的边。更糟糕的是,我们会浪费大量的精力生成那些在解法中既不被使用,也无法到达的状态。

相反,我们可以通过扩展广度优先搜索和深度优先搜索算法来动态创建一个图,探索状态空间,并实时添加节点和边。每次发现一个新状态时,我们就添加相应的节点。每次测试状态之间的移动时,我们就添加相应的边。与之前的搜索方式不同,后者需要遍历每个节点或每个节点的边,而使用搜索来构建图则要求算法遍历难题的状态以及每个状态的有效移动。

在本节的其余部分,我们将探讨如何使用过河难题构建这样的图。我们从一个初始状态<3,3,L>开始,并从那里向外探索。在每一步中,我们会问:“如果我们把一些人(两个警卫,一个警卫,一个警卫和一个囚犯,两个囚犯,或一个囚犯)送过河,下一状态是什么?”我们通过简单的算术计算下一状态,并使用难题的规则检查其有效性。如果新状态有效,我们就将其及相应的边添加到我们的图中。在接下来的几个部分中,我们将编写代码来存储状态空间、定义有效转换并构建图。虽然我们专注于过河难题,但这些方法适用于一系列难题。

#### 表示难题的状态

为了定义我们的搜索,首先需要表示拼图的状态。以下代码展示了如何定义一个简单的类 PGState(其中 PG 代表“囚犯和警卫”)来存储拼图的当前状态,并提供一些辅助函数:

class PGState:
def init(self, guards_left: int = 3, prisoners_left: int = 3,
boat_side: str = "L"):
self.guards_left = guards_left
self.prisoners_left = prisoners_left
self.boat_side = boat_side

def __str__(self) -> str:
    return (f"{self.guards_left},{self.prisoners_left},{self.boat_side}") 

变量 guards_left 和 prisoners_left 存储左岸的警卫和囚犯数量。字符串 boat_side 指示船当前位于左岸(L)还是右岸(R)。__str__() 函数允许我们轻松地将状态转换为字符串表示形式,便于存储和显示。

给定 PGState 数据结构,按照以下代码所示,我们可以编程地计算下一次船次移动给定数量的警卫和囚犯的结果:

def pg_result_of_move(state: PGState, num_guards: int,
num_prisoners: int) -> Union[PGState, None]:
❶ if num_guards < 0 or num_prisoners < 0:
return None
if num_guards + num_prisoners == 0:
return None
if num_guards + num_prisoners > 2:
return None

❷ G_L: int = state.guards_left
G_R: int = (3 - state.guards_left)
P_L: int = state.prisoners_left
P_R: int = (3 - state.prisoners_left)
if state.boat_side == "L":
G_L -= num_guards
G_R += num_guards
P_L -= num_prisoners
P_R += num_prisoners
new_side: str = "R"
else:
G_L += num_guards
G_R -= num_guards
P_L += num_prisoners
P_R -= num_prisoners
new_side = "L"

❸ if G_L < 0 or P_L < 0 or G_R < 0 or P_R < 0:
return None

❹ if G_L > 0 and G_L < P_L:
return None
if G_R > 0 and G_R < P_R:
return None
return PGState(G_L, P_L, new_side)


大部分的 pg_result_of_move() 函数用于检查移动是否有效。如果移动无效,函数将返回 None。否则,函数将返回对应于该移动结果的新 PGState。需要注意的是,这要求我们从 Python 的 typing 库导入 Union,以支持多个返回类型的类型提示。代码检查囚犯和警卫的数量是否都是非负的,是否至少有一个人坐在船上,并且船上最多有两个人 ❶。如果任何有效性检查失败,则移动无效,函数返回 None,表示没有下一个有效状态。

如果移动通过了这些初步的有效性检查,代码会计算左岸的囚犯和守卫数量(分别为 P_L 和 G_L),以及右岸的囚犯和守卫数量(分别为 P_R 和 G_R) ❷。这四个数量将用于检查新状态是否有效。代码检查移动是否没有将超过当前岸上的人数移走,通过确认没有任何计数变为负数 ❸。它还检查新状态是否有有效的守卫和囚犯平衡 ❹。如果某一岸上至少有一个守卫,那么该岸上的囚犯数量不能超过守卫数量。然而,岸上只有囚犯是有效的。再一次,如果任何有效性检查失败,新状态将被视为无效,函数将返回 None。如果所有检查都通过,代码将返回一个表示新状态的 PGState 数据结构。

当 pg_result_of_move() 检查并计算单个移动的结果时,我们需要为每个有效的移动构建边缘。我们可以定义一个辅助函数来生成并测试当前状态的所有可能邻居:

def pg_neighbors(state: PGState) -> list:
neighbors: list = []
❶ for move in [(1, 0), (2, 0), (0, 1), (0, 2), (1, 1)]:
❷ n: Union[PGState, None] = pg_result_of_move(state, move[0], move[1])
if n is not None:
neighbors.append(n)
return neighbors


这段代码创建了一个空的邻居列表(neighbors),然后系统地尝试五种可能的移动方式:一个守卫、两个守卫、一个囚犯、两个囚犯和一个守卫加一个囚犯 ❶。每次,代码都会调用 pg_result_of_move() 并检查是否返回有效的邻接状态 ❷。如果是,它会将新状态添加到邻居列表中。

#### 生成图形

现在我们有了算法上确定哪些状态是当前状态邻接状态的组件,我们可以使用修改过的广度优先搜索来生成囚犯与守卫谜题的状态空间图。这个算法将从初始状态开始,沿着边缘向外探索到相邻状态。我们将使用上一节中的 pg_neighbors() 辅助函数来确定当前状态的有效邻接状态集。随着邻居生成函数发现新状态,我们将把这些状态作为新节点添加到图中。

我们在PGState数据结构中跟踪状态信息。为了方便起见,我们将这个状态信息链接为一个分配给节点标签的PGState对象。这使得在搜索过程中可以方便地访问当前的状态数据结构。我们使用__str__()方法生成数据结构的字符串表示,用于辅助数据结构。

除了之前广度优先搜索中使用的数据结构外,我们还需要追踪一项额外的信息:从状态到图中对应节点的映射。如果我们无法找到对应的节点并创建边缘,知道<2,2,R>与<3,3,L>之间存在一条边是没有意义的。我们将这项信息存储在一个字典(indices)中,该字典将状态的字符串表示映射到图中对应节点的索引。

创建囚犯与守卫状态图的代码将我们之前组装的各个部分组合起来:

def create_prisoners_and_guards() -> Graph:
indices: dict = {}
next_node: queue.Queue = queue.Queue()
g: Graph = Graph(0, undirected=True)

❶ initial_state: PGState = PGState(3, 3, "L")
initial: Node = g.insert_node(label=initial_state)
next_node.put(initial.index)
indices[str(initial_state)] = initial.index

while not next_node.empty():
  ❷ current_ind: int = next_node.get()
    current_node: Node = g.nodes[current_ind]
    current_state = current_node.label

  ❸ neighbors: list = pg_neighbors(current_state)
    for state in neighbors:
        state_str: str = str(state)
      ❹ if not state_str in indices:
            new_node: Node = g.insert_node(label=state)
            indices[state_str] = new_node.index
            next_node.put(new_node.index)
      ❺ new_ind: int = indices[str(state)]
        g.insert_edge(current_ind, new_ind, 1.0)

return g 

生成图形的代码首先通过设置必要的数据结构来开始:一个空字典(indices)、一个空队列(next_node)和一个空图(g)。它为初始状态创建一个新的PGState对象,通过Graph类的insert_node()函数将对应的节点插入图中,将初始状态的节点索引添加到队列中,并将字符串与索引的映射添加到字典中❶。

现在我们已经准备好开始搜索了。像其他广度优先搜索一样,我们的囚犯与守卫图生成使用一个节点索引队列(next_node)来控制搜索。下一个节点索引被出队,随后获取对应的节点和状态❷。与之前的广度优先搜索示例不同,算法不能依赖节点的边列表来确定邻居。相反,代码使用pg_neighbors()函数来生成可能的邻接状态❸。

代码通过在indices字典中查找状态的字符串表示来检查每个状态是否之前出现过 ❹。如果状态在表中没有条目(以及有效的节点索引),那么我们既没有见过它,也没有将它添加到图中。对于任何以前未见过的状态,都会创建新节点,并且在当前节点与其邻居之间生成新的边 ❺。

代码最后通过返回完成的图g来结束。由于算法仅从初始状态向外搜索,因此返回的图只会包含那些可以通过有效的移动从初始状态到达的状态。无效或无法到达的状态的节点不会被包含在内。

图 6-7 显示了算法进展的前几步。图中显示了对应于图的初始状态 <3,3,L> 的单一节点,如图 6-7(a)所示。图 6-7(b) 显示了第一个节点访问后的结果:pg_neighbors()函数为当前状态找到了三个有效的邻居,算法为每个邻居创建了新节点。在探索了 <3,1,R> 状态后,代码创建了一个 <3,2,L> 节点,并且生成了相应的边,如图 6-7(c)所示。

![在 (A) 中,图中只有一个节点标记为 (3, 3, L)。在 (B) 中,已经添加了三个邻居,状态为 (3, 1, R)、(2, 2, R) 和 (3, 2, R),每个邻居都有一条返回到第一个节点的边。在 (C) 中,已经创建了一个第五个节点,状态为 (3, 2, L),并且有一条到节点 (3, 1, R) 的边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06007.jpg)

图 6-7: 生成囚徒与警卫谜题图的前三个步骤

算法每当第一次看到节点时就会生成新节点。然而,它并不一定在访问该节点之前生成所有节点的边,这就是为什么在图 6-7(c)中,状态 <3,2,L> 和状态 <2,2,R> 之间没有边的原因。只有在访问 <2,2,R> 或 <3,2,L> 之一时,代码才会在这些节点之间生成边。

### 通过搜索解决谜题

我们可以直接将之前章节的搜索应用到囚徒与警卫的谜题图上。我们将搜索功能添加到相同的囚徒与警卫程序中,基于上一节的函数进行构建。

为了简化逻辑,我们从一个简单的辅助函数开始,该函数创建一个字典,将状态的字符串映射到相应节点的索引:

def pg_state_to_index_map(g: Graph) -> dict:
state_to_index: dict = {}
for node in g.nodes:
state: str = str(node.label)
state_to_index[state] = node.index
return state_to_index


生成的状态字符串到节点索引的映射允许我们直接查找起始和目标的索引,而无需遍历整个图形。我们可以通过字符串 "3,3,L" 查找起始节点的索引(0)。同样,我们也可以通过状态字符串 "0,0,R" 查找目标节点的索引(14)。

这是用于搜索谜题的代码:

def solve_pg_bfs():
❶ g: Graph = create_prisoners_and_guards()

❷ state_to_index: dict = pg_state_to_index_map(g)
start_index: int = state_to_index["3,3,L"]
end_index: int = state_to_index["0,0,R"]

❸ last: int = breadth_first_search(g, start_index)

❹ current: int = end_index
path_reversed: list = []
while current != -1:
path_reversed.append(current)
current = last[current]

❺ if path_reversed[-1] != start_index:
print("No solution")
return

❻ for i, n in enumerate(reversed(path_reversed)):
print(f"Step {i}: {g.nodes[n].label}")


代码首先通过创建谜题的图形表示 ❶。接着,它构建一个字典 state_to_index,将状态字符串映射到索引,并使用该字典查找起始节点和目标节点的索引 ❷。

代码使用标准的广度优先搜索来探索图形,并返回 最后 列表 ❸。最后,它从目标节点开始,反向遍历 最后 列表,直到到达起始节点或死胡同 ❹。如果路径在到达起始节点之前死胡同,函数会显示消息 无解 ❺。否则,代码会向前走路径,并以正确的顺序显示访问过的状态列表 ❻。

图 6-8 显示了生成的图,并标明了节点索引。每个节点上方标有其节点索引,下方标有其状态字符串。

![图 6-4 中的图,标有编号和状态字符串,每个节点都有标签。最左边的节点标签为 0。它的三个邻居从上到下标为 2、3 和 1。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f06008.jpg)

图 6-8:标有节点索引的囚犯与守卫图

给定生成的谜题图形,我们可以直接从第五章运行广度优先搜索。表 6-1 显示了每次节点被探索后,最后 向量的状态。第一行对应于第 0 次迭代,状态为 <3,3,L>。目标(状态 <0,0,R>)在第 14 次迭代时被访问。

表 6-1: 最后 向量

| 步骤 (节点) | 33L | 32R | 31R | 22R | 32L | 30R | 31L | 11R | 22L | 02R | 03L | 01R | 11L | 02L | 00R | 01L |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 0 (3,3,L) | –1 | 0 | 0 | 0 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 1 (3,2,R) | –1 | 0 | 0 | 0 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 2 (3,1,R) | –1 | 0 | 0 | 0 | 2 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 3 (2,2,R) | –1 | 0 | 0 | 0 | 2 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 4 (3,2,L) | –1 | 0 | 0 | 0 | 2 | 4 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 5 (3,0,R) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 6 (3,1,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | –1 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 7 (1,1,R) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | –1 | –1 | –1 | –1 | –1 | –1 | –1 |
| 8 (2,2,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | –1 | –1 | –1 | –1 | –1 | –1 |
| 9 (0,2,R) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | –1 | –1 | –1 | –1 | –1 |
| 10 (0,3,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | –1 | –1 | –1 | –1 |
| 11 (0,1,R) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | –1 | –1 |
| 12 (1,1,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | 12 | –1 |
| 13 (0,2,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | 12 | –1 |
| 14 (0,0,R) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | 12 | 14 |
| 15 (0,1,L) | –1 | 0 | 0 | 0 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | 12 | 14 |

基于广度优先搜索生成的<sup class="SANS_TheSansMonoCd_W5Regular_11">最后</sup>向量,我们可以追踪从初始状态到目标状态所需的移动。记住,广度优先搜索在无权图上返回最短路径,因此它已经找到了要求最少移动的解决方案。

我们可以使用类似的方法来解决汉诺塔问题和滑块谜题。每次,我们都将通过定义状态空间的数据结构以及一个生成状态邻居的算法函数来开始。

### 为什么这很重要

本章介绍了如何将图搜索应用于解决谜题的抽象世界。除了这里涉及的相对简单的谜题外,我们还可以通过结合图的丰富结构来建模日益复杂的问题,包括有向和加权边。例如,本章中的谜题使用无向边表示可逆移动,但我们可以用有向边表示无法撤销的移动。如果我们试图解决一个谜题,涉及到过一座桥,而这座桥在第一次通过后会坍塌,我们就无法直接返回到桥对面并保持桥完好的状态。同样,加权边使我们能够考虑移动的成本。

本章中的代码还展示了我们不必在开始搜索之前生成图形,而是可以通过搜索来创建图数据结构,同时探索不同的状态。在许多情况下,我们甚至可能根本不需要显式地创建图数据结构。一旦我们定义了状态和转换,就可以直接对它们应用图算法,比如广度优先搜索。从本书的剩余部分开始,我们将把建模问题称为图问题,即使在某些情况下我们并没有显式地构建图。

在下一章,我们将回到通过图形计算路径的问题,超越到目前为止我们看到的基于搜索的方法,寻找带权图中最低成本的路径。




## 第七章:7 最短路径



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

寻找图中最低代价路径的问题,通过路径上所有边的权重之和来衡量,天然类似于现实世界中一系列路径规划和优化任务。例如,我们可能有兴趣规划卡车在两个城市之间的路线,以最小化燃料总费用。本章将讨论用于寻找从给定起点出发的最低代价路径的算法。

尽管寻找这些路径的问题通常被称为*最短路径问题*,但将其视为最低代价路径问题更为准确,因为路径的代价并不总是距离的函数。例如,本章还将讨论允许负权重边的版本。我们将在本章中交替使用*最短路径*和*最低代价路径*这两个术语;它们的公式和实现是相同的。

本章介绍了三种寻找最短路径的算法。我们首先介绍*Dijkstra 算法*,它像前几章中的搜索算法一样,从起始节点向外扩展。*Bellman-Ford 算法*通过逐步考虑各个边来改进最佳路径。最后,*Floyd-Warshall 算法*使我们能够找到所有节点对之间的最短路径。

### 最低代价路径

在我们深入讨论确定图中最低代价路径的算法之前,我们必须明确最低代价路径的定义。回想一下第三章,路径的总代价是路径上所有边的权重之和。对于路径 *p* = [*e*[0], *e*[1], . . . , *e*k],我们定义代价为:

*路径代价*(*p*) = *∑*i [= 0 到] k *e*i.weight

我们将*最短路径*定义为从给定起始节点 *u* 到给定目标节点 *v* 的边的序列 *p* = [*e*[0], *e*[1], . . . , *e*k],它最小化路径代价:

*最短路径*(*u*, *v*) = *最小值* p (*路径代价*(*p*)) 使得 *e*[0].from_node = *u* 且 *e*k.to_node = *v*

然后,我们定义两个节点之间的*距离*为这两个节点之间最短路径的代价:

*dist*(*u*, *v*) = *路径代价*(*最短路径*(*u*, *v*))

作为一个具体的例子,考虑从节点 0 到节点 5 的路径,经过具有定向边的六节点图,如图 7-1。每条边的权重已显示。我们的目标是找到从节点 0 到节点 5 的边的序列,以使总成本最小。

![每条边都标有其权重。边 (0, 3) 的权重为 1.0,边 (3, 4) 的权重为 1.0,边 (4, 5) 的权重为 2.0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07001.jpg)

图 7-1:一个带权重的有向图,包含六个节点

表 7-1 列出了从节点 0 到节点 5 在图 7-1 中的一些可能路径的成本,显示我们可以定义多种具有不同成本的路径。在这个例子中,最短路径是[0, 3, 4, 5],其*dist*(0, 5) = *PathCost* ([0, 3, 4, 5]) = 4.0。两个节点之间的最低成本路径可能不会使用最少的边,而是可能采取更多较低成本的步骤。

表 7-1: 从节点 0 到节点 5 的路径成本

| 路径 | 成本 |
| --- | --- |
| 0, 3, 4, 5 | 4.0 |
| 0, 4, 5 | 4.5 |
| 0, 1, 0, 3, 4, 5 | 13.0 |
| 0, 3, 4, 3, 4, 5 | 8.0 |

如表 7-1 所示,路径可能包含环路。如果我们将问题限制为仅使用正边权值,环路将严格增加路径的成本。最短路径算法将避免这些环路。这在现实世界中的例子中是显而易见的,比如冒险者在第四章中探索迷宫的情形。在迷宫中绕圈走不仅需要额外的步伐,而且还会重复面临任何复生怪物的代价。

然而,如果允许负的边权值,问题会变得更加复杂(且更难以直观地可视化)。因此,本章中的算法对负权值的使用进行了限制,要求路径中不包含具有负成本的环路。

一个图中可能有多个最低成本的路径。此章中的算法产生其中之一。

### Dijkstra 算法

**Dijkstra 算法**由计算机科学家 Edsger W. Dijkstra 发明,用于从给定的起始节点到图中所有其他节点找到最低成本路径。它可以在无权图或加权图上运行,前提是边权值没有负值。这个限制反映了现实世界中的路径规划问题,因为我们无法通过增加一个额外的步骤来减少总路径长度(成本)。最典型的例子是规划公路旅行,目的是最小化旅行的总距离。由于不可能有负的距离,我们永远无法通过增加一个步骤来缩短旅行。

Dijkstra 算法通过维护未访问节点的集合并不断更新每个节点的当前估计代价来运行。它通过选择距离起点最近(代价最低)的未访问节点并访问它,持续减少未访问节点的数量。然后它探索该节点并检查是否提供通向每个未访问邻居的更优路径。更详细地说,算法通过将当前节点的代价与邻居的代价相加来计算新提出路径的代价。如果新路径的代价小于迄今为止看到的最佳代价,算法就会更新该代价。

因为算法始终选择下一个要探索的最近(最低代价)节点,我们可以确保每次访问节点时都在走最短的路径。这是因为我们不允许负的边权重,所以增加步骤总是会增加路径的代价。

为了可视化这一点,考虑在访问节点 *v* 之前算法的状态。我们可能会担心通过某个未访问节点到 *v* 的路径更优。毕竟,我们还没有看到图中的所有路径,甚至可能没有访问过 *v* 的所有邻居。然而,任何这样的路径都必须经过一个未访问的节点 *w*。由于 *v* 是首先被选中的,从起点 *u* 到 *w* 的路径代价至少和从 *u* 到 *v* 的路径代价一样大,而这仅仅是到达 *v* 的一部分。随后的从 *w* 到 *v* 的路径只会进一步增加代价。

对于我们从 第四章 中描绘迷宫的冒险者,Dijkstra 算法就像他们逐个房间地清理迷宫。这个冒险者曾是地图学的学生,他在每个访问过的房间里都详细记录了找到的最短路径,因为他的退休计划是编写地下城指南,并将其出售给未来的冒险者。在计划下一步行动时,冒险者会考虑可以到达哪些未访问的房间,然后尝试确定从地下城入口(起点节点)到每个房间的最佳路径。路径的长度是到达相邻房间的代价,然后转移到未探索房间的代价。

#### 代码

Dijkstra 算法的代码使用优先队列来管理未访问节点的集合,如下所示:

def Dijkstras(g: Graph, start_index: int) -> list:
cost: list = [math.inf] * g.num_nodes
last: list = [-1] * g.num_nodes
pq: PriorityQueue = PriorityQueue(min_heap=True)

❶ pq.enqueue(start_index, 0.0)
for i in range(g.num_nodes):
if i != start_index:
pq.enqueue(i, math.inf)
cost[start_index] = 0.0

while not pq.is_empty():
  ❷ index: int = pq.dequeue()

    for edge in g.nodes[index].get_edge_list():
        neighbor: int = edge.to_node

      ❸ if pq.in_queue(neighbor):
            new_cost: float = cost[index] + edge.weight
          ❹ if new_cost < cost[neighbor]:
              ❺ pq.update_priority(neighbor, new_cost)
                last[neighbor] = index
                cost[neighbor] = new_cost

return last 

这段代码依赖于 附录 B 中描述的优先队列的自定义实现,该实现允许动态更新优先级。有兴趣的读者可以在该附录中找到详细信息。现在,仅需要将 PriorityQueue 视为一种数据结构,它允许高效地插入带优先级的元素、移除具有最低优先级的元素、查找元素以及更新元素的优先级。

代码首先创建多个辅助数据结构,包括以下内容:到每个节点的最佳成本列表(cost),指示在访问某个节点之前上一个访问节点的列表(last),以及一个未访问节点的最小堆优先队列(pq)。代码将起始节点的索引(start_index)放入优先队列中,优先级为 0.0,其余节点的优先级为无限大 ❶。起始节点的成本标记为 0.0。

然后,代码逐个处理优先队列中的节点,使用 while 循环迭代,直到优先队列为空。在每次迭代中,代码从优先队列中提取出最小成本节点并进行探索 ❷。

代码使用一个 for 循环来考虑当前节点的每一个邻居,检查该邻居是否仍在优先队列中,使用 in_queue() 函数 ❸。如果节点仍在队列中,说明代码尚未访问过该节点。然后,代码检查是否通过当前节点找到了一条更优的路径通往该邻居 ❹。此检查对应于将当前找到的最优路径成本(cost[neighbor])与经过当前节点的最优路径成本(cost[index] + edge.weight)进行比较。由于代码最初将所有非起始节点的成本设为无限大,它们至少会在第一次被访问时被更新。如果代码找到了通往邻居的更优路径,它将更新该邻居节点在队列中的优先级、该节点的前一个节点在 last 中的记录,以及最优成本 ❺。

代码继续探索未访问的节点(那些仍在优先队列中的节点),直到所有节点都被访问过。如前所述,一旦代码访问了某个节点,就说明已经找到了最短路径,并且不需要重新考虑任何已访问的节点。当代码访问完所有节点后,它返回路径上的前一个节点列表。

给定这里展示的基于*堆*的 Dijkstra 算法实现,我们可以想象该算法如何在更大规模的图中扩展。该算法通过将所有节点插入优先队列并逐个移除节点,确保每个节点只访问一次。使用基于堆的优先队列,每个操作的时间复杂度为 log(|*V*|),因此迭代这些节点的总时间复杂度为|*V*| log(|*V*|)。在访问每个节点时,我们检查其每个邻居是否在优先队列中,并可能更新优先级。由于我们使用了在附录 B 中描述的自定义PriorityQueue,查找操作使用字典,并且在平均情况下是常数时间。更新操作的复杂度为 log(|*V*|)。由于我们每个节点只访问一次,并且只考虑其出边一次,所以最多每条边更新一次,成本为|*E*| log(|*V*|)。因此,算法的总成本为|*V*| log(|*V*|) + |*E*| log(|*V*|) = (|*V*| + |*E*|) log(|*V*|)。

#### 一个示例

图 7-2 展示了 Dijkstra 算法在一个五节点图上的操作。每个子图表示算法完成一步后的状态。虚线圆圈表示刚刚处理的节点,阴影节点表示已经访问过的节点。优先队列(pq)以排序顺序显示,便于查看相对优先级,尽管它实际是按堆排序存储的。

![每个子图展示了图、last 数组、cost 数组和排序后的优先队列。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07002.jpg)

图 7-2:Dijkstra 算法的步骤

图 7-2(a)表示在探索第一个节点之前,算法的状态。冒险者站在入口处,准备开始他们的任务。所有节点的last条目为-1,表示我们尚不知道到达它们的路径。节点 0 的成本为0,因为搜索从这里开始,而所有其他节点的估算成本为无限大,因为我们尚未知道*任何*可能的路径。与深度优先搜索和广度优先搜索算法不同,我们一开始就将所有节点放入优先队列。

在每一步中,算法都会探索队列中剩余的最佳节点。在图 7-2(b)中,算法选择了节点 0(唯一一个没有无限代价的节点),并访问了它。它发现有三条边通向邻居:节点 1、节点 2 和节点 3。搜索比较了通过节点 0 到达其他节点的路径代价与当前代价的大小。由于新路径代价对这三个节点来说都小于无限大,算法更新了它们在last、cost 和 pq 中的条目。这个在优先队列中的更新重新排序了待探索的节点列表。

图 7-2(c)展示了当搜索访问节点 2 时发生的情况。该节点有一个邻居(节点 3),其估算代价为2.0。然而,现在通过节点 2 到达节点 3 的路径提供了一个更优的选择,总代价为 0.5 + 1.0 = 1.5。算法将节点 3 的cost条目更新为1.5,并将其last条目更新为2。

从我们的冒险者角度看,房间 2 提供了一条到房间 3 的更优路径。也许有一只特别强大的怪物守卫着从房间 0 到房间 3 的通道。为了未来的探索者考虑,冒险者会将建议的路径改变,选择通过房间 2 前往房间 3。

搜索继续在剩余节点中进行。当算法访问到图 7-2(d)中的节点 3 时,发现到节点 1 的路径更优。类似地,访问到图 7-2(e)中的节点 1 时,提供了一条到节点 4 的更优路径。搜索在图 7-2(f)中完成,访问了最后一个节点。

#### 断开的图

询问如果某些节点无法从起始节点到达,会发生什么,能够帮助我们理解 Dijkstra 算法在断开图上的表现。考虑图 7-3 中的四节点图,其中从节点 0 出发时,只有节点 0 和节点 1 是可达的。

![该图有三条边:从节点 0 到节点 1,从节点 2 到节点 3,以及从节点 3 到节点 2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07003.jpg)

图 7-3:一个包含不可达节点的图

这对应于迷宫中的不可达房间。从传说中,冒险者知道迷宫有四个房间,但他们只能到达其中两个。房间 0 到房间 2 或房间 3 之间没有路径,所以冒险者只能在笔记中标明这一点。

Dijkstra 算法可以轻松处理这种情况。节点 2 和 3 最初被分配了 last 值 -1 和无限大的成本。由于从节点 0 到这两个节点之间没有路径,当其中一个节点从优先队列中提取出来时,它的成本仍然是无限大的。当算法考虑到该节点的邻居时,通过该节点的估计成本将是无限的,因此算法不会更新任何 cost 或 last。在算法结束时,节点 2 和 3 的最后指针将为 -1。

### 负权边

在现实问题中,边的权重可能是负数,表示负成本(或一种利益)。例如,考虑社交网络中的通信,每对朋友之间的连接就是一条边。每条边的权重表示将谣言从一个人传递到另一个人的成本。这个成本可能是发送短信或聊天所需的时间损失。然而,在某些情况下,边的权重可能为负,表示使用该通信渠道的利益。如果两个朋友有一段时间没有交谈,重新联系并传递一些八卦的成本可能是负的。

另外,我们可以设想路径规划来最小化电动汽车的电池使用。如果一条边代表一条陡峭的下坡路,我们可以利用重力和再生制动来为电池充电。该段路径的电池使用成本是负的。

注意,在负权边的情况下,*最短路径*一词并不完全有意义,因为距离不能为负。无论你在路径规划上多么熟练,你都不能组织一次在出发之前就已经回到家的骑行旅行。然而,为了与广泛的文献保持一致,我们仍然继续称这些问题为*最短路径*。

在考虑带有负权边的图中的最短路径时,我们仍然需要保持一个约束条件:图中不能包含负环。*负环*是指当从一个节点回到自身时,边的权重之和为负数的路径。在存在这样的环时,最低成本路径的概念将失效。例如,考虑图 7-4,其中边 (0, 1) 的权重为 1.0,而边 (1, 0) 的权重为 -2.0。

![一个包含三个节点和三条边的图。边 (1, 2) 的权重为 1.0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07004.jpg)

图 7-4:带有负环的图

如果我们尝试在图 7-4 中找到从节点 0 到节点 2 的最低成本路径,我们会立即遇到问题。如表 7-2 所示,我们可以不断地从节点 0 到节点 1 再到节点 0 添加另一个循环以进一步降低成本。最低成本路径将会永远来回循环。

表 7-2: 图 7-4 中的路径成本

| 路径 | 成本 |
| --- | --- |
| 0, 1, 2 | 2 |
| 0, 1, 0, 1, 2 | 1 |
| 0, 1, 0, 1, 0, 1, 2 | 0 |
| 0, 1, 0, 1, 0, 1, 0, 1, 2 | –1 |
| . . . | . . . |

相反,图 7-5 展示了一个具有负边权但没有负环的图。可以从节点 1 到节点 0 以负成本旅行。然而,从节点 0 返回到自身或从节点 1 返回到自身的任何路径将具有总正成本。

![一个具有四个节点和五条边的图。边 (0, 1) 的权重为 3.5,边 (1, 0) 的权重为 –0.5。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07005.jpg)

图 7-5: 具有负边权但无负环的图

我们如何判断一个图是否具有负环?负环可能非常长,通过图的每个节点循环,因此不会立即显现。Bellman-Ford 算法通过检查图中负环的存在来解决这个问题。

### 贝尔曼-福特算法

Dijkstra 算法的一个主要缺点是它仅限于具有正边权的图。Bellman-Ford 算法消除了这一限制,但必须增加计算成本才能实现。

*Bellman-Ford 算法*通过反复遍历边列表,并利用这些边来更新到达每个节点的最佳成本来运行(这个过程叫做 *松弛*)。与 Dijkstra 算法类似,它维护一个列表 cost,存储从起点到每个节点的当前最佳成本估算。每当 Bellman-Ford 考虑一条边时,它会比较该边的目标节点当前的成本估算(即 cost 中的条目)与通过从边的起点出发(使用起点在 cost 中的条目加上边的权重)得出的估算,查看这条边是否提供了更好的路径。它会不断重复这个测试,不断改进最佳路径的估算。

我们可以将该算法想象为一位非常细致的旅行代理人,正在为即将到来的旅游季节考虑航班选项。代理人从一个起点(例如芝加哥)开始,寻找前往全球每个可能目的地的最便宜路径。显然,代理人不能亲自乘坐每个航班(也就是说,不能遍历整个图)。然而,他们可以轻松地浏览航班和价格列表,更新他们的估算表格。

在对航班列表进行一次扫描后,代理人就能知道从芝加哥到其他每个城市的最佳直飞航班。然后,他们再次扫描列表,询问是否可以利用当前掌握的最佳旅行信息,构建通往每个目的地的更好路径。代理人反复扫描列表,更新他们的估算,直到为每个目的地找到最佳路径。

就像旅行代理人一样,通过 Bellman-Ford 外层循环的每次迭代,我们实际上在构建更好的路径。这个路径逐步构建的过程在 图 7-6 中得到了展示。加粗的线条表示每次迭代后,从节点 2 到节点 0 的最佳已知路径。

![一个包含四个节点和五条边的图。在子图 A 中,边 (0, 3) 的权重为 10.0,已加粗。在子图 B 中,边 (0, 2) 的权重为 5.0 和边 (2, 3) 的权重为 1.0 已加粗。在子图 C 中,边 (0,1),(1, 2) 和 (2, 3),其权重均为 1.0,都已加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07006.jpg)

图 7-6:Bellman-Ford 算法从节点 2 到节点 0 找到逐步更优的路径

图 7-6(a)展示了节点 2 到节点 0 经过每条边一次后得到的最佳路径。由于算法仅查看了每条边一次,它只看到从节点 2 到节点 0 的直接路径,成本为10.0。它没有机会意识到可以通过边(2, 1)和(1, 0)构建更好的路径。在第二次迭代中,算法利用从节点 2 到节点 1 的成本为1.0的路径,构建了一条到节点 0 的路径。节点 0 的最佳路径更新为经过节点 1,成本为2.0,如图 7-6(b)所示。

我们可以将总迭代次数限制为|*V*| – 1,其中|*V*|是图中节点的数量。由于不允许负环,最小成本路径永远不能返回到相同的节点,因为这样做会严格增加路径的成本。这也是现实世界中不同城市间的旅行路线不包含循环的原因——即,不会有多次在同一机场的中转。

由于最小成本路径不能重复经过节点,因此它最多可以触及所有|*V*|个节点,并使用|*V*| – 1 条边。例如,在图 7-7 中,节点 0 到节点 1 的最低成本路径是[0, 3, 4, 5, 2, 1]。虽然存在经过更少步骤的备选路径,但节点 0 到节点 1 的最低成本路径使用了五条边,并访问了图中的所有节点。

![该图从节点 0 到节点 1 有一条权重为 10.0 的边。边(0, 3)、(3, 4)、(4, 5)、(5, 2)和(2, 1)的权重均为 1.0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07007.jpg)

图 7-7:一个包含从节点 0 到节点 1 的五步路径的示例图

Bellman-Ford 算法利用这一约束来停止算法并检测环路。在外循环执行|*V*| – 1 次迭代后,它已经找到了所有可能的最小成本路径。由于该算法使用了两个嵌套的for循环(一个遍历节点数量,另一个遍历每一条边),其成本按|*E*| |*V*|的乘积来扩展。

除非存在负环,额外的边迭代不会有任何帮助。掌握这一知识后,算法会进行一次额外的迭代,并测试是否有任何成本降低。如果有,它就知道图中存在负环。

我们可以将最后的测试想象成我们的旅行代理人对其最便宜航班列表进行最后一次检查。他们注意到,在匹兹堡和波士顿之间添加一段航程进一步降低了价格。感到困惑时,他们回顾航班数据,发现从芝加哥到波士顿,再到匹兹堡,再到波士顿,再到西雅图的行程是目前为止最便宜的选项。从波士顿到匹兹堡再回到波士顿的循环提供了一个负环。显然,航班定价出现了问题,形成了一个有效的负费用循环。旅行代理人赶紧打电话给客户,告诉他们可能有一次免费的 10 站旅行,赶在航空公司修复问题之前。

#### 代码

Bellman-Ford 会对每一条边进行 |*V* | – 1 次迭代。每次迭代,它都会问一个简单的问题:“当前边是否提供到目的地节点的更好路径?”代码使用一对 for 循环来驱动这个搜索:

def BellmanFord(g: Graph, start_index: int) -> Union[list, None]:
cost: list = [math.inf] * g.num_nodes
last: list = [-1] * g.num_nodes
all_edges: list = g.make_edge_list()
cost[start_index] = 0.0 for itr in range(g.num_nodes - 1):
for edge in all_edges:
❶ cost_thr_node: float = cost[edge.from_node] + edge.weight
❷ if cost_thr_node < cost[edge.to_node]:
cost[edge.to_node] = cost_thr_node
last[edge.to_node] = edge.from_node

for edge in all_edges:
  ❸ if cost[edge.to_node] > cost[edge.from_node] + edge.weight:
        return None
return last 

BellmanFord() 函数接受一个 图 和起始索引,返回到每个目的地的最佳路径(使用 last 数组)或如果图中存在负环则返回 None。我们需要从 Python 的 typing 库导入 Union,以便对多个返回值进行类型提示。

这段代码首先创建跟踪数据结构,包括目前为止的最佳费用(cost)和当前路径上前一个节点(last)。它还使用 make_edge_list() 函数提取算法所需的完整边列表,该函数遍历每个节点并组装图中的每一条边。最后,它将起始节点的费用设置为 0.0。

一对嵌套的 for 循环驱动 |*V* | – 1 次遍历边列表。对于每条边,代码评估通过该边到达目的地的费用 ❶。如果该费用小于当前的最佳费用 ❷,代码就会更新最佳费用估算值和到该节点的路径。请注意,减少的费用可能不是由于更改了前一个节点(last);相反,前一个节点的费用可能是由于有了更好的路径而下降了。

当代码完成 |*V* | – 1 次外部循环迭代时,它已经完成了优化。在结束之前,它会检查解决方案是否有效。如果任何成本仍然可以通过再走一步 ❸ 来改进,那么图中必定存在负成本环,在这种情况下,算法返回 None。否则,它返回 last 列表。

#### 一个例子

图 7-8 显示了 Bellman-Ford 算法外部循环的第一次迭代。由于算法需要进行 (|*V* | – 1) |*E*| 步,其中 |*E*| 是边的数量,因此不可能展示所有的 36 步。相反,我们考虑外部循环的第一次迭代,以回顾路径(由 last 表示)和估算成本的变化。每个子图表示算法在检查加粗的边后的状态。

![每个子图显示了一个包含五个节点的图,数组 last 和数组 cost。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07008.jpg)

图 7-8:Bellman-Ford 外部循环第一次迭代的起始状态和九个步骤

图 7-8(a) 显示了算法在检查任何边之前的状态。除了起始节点外,所有节点的估计成本都是无限大。在 图 7-8(b) 中,算法测试了第一条边,并发现它提供了一条到目标节点 1 的更优路径。它将到节点 1 的路径更新为从节点 0 出发的路径,并将最佳成本更新为 3.5。

搜索通过图中的每一条边继续进行,每次迭代时考虑到单个节点——当前边的目标节点的成本。在 图 7-8(c) 中,它发现了一条到节点 2 的更优路径;在 图 7-8(d) 中,它发现了一条到节点 3 的更优路径。在 图 7-8(e) 中,它没有更新任何内容,因为从节点 0 到节点 0 的最佳成本是 0.0,我们不需要通过节点 1 再次绕回起点。

在外部循环的第一次迭代结束时,如 图 7-8(j) 所示,搜索已经检查了每条边并更新了最佳路径和成本估算。然而,算法尚未完成。到节点 4 的真实最佳路径是 [0, 2, 3, 1, 4],其成本为 3.0。直到重新考虑边 (1, 4) 时,算法才会找到这个最终的成本。在第一次循环中,算法还未找到到节点 1 的最佳路径,因此它的成本估算值过大。

随着算法的继续,它会重新检查边,并不断更新最佳路径及其成本。图 7-9 显示了算法的最终步骤。

![边(4, 1)权重为 1.0,已加粗。最后的数组为[-1, 3, 0, 2, 1],成本数组为[0.0, 2.5, 0.5, 1.5, 3.0]。](../images/f07009.jpg)

图 7-9:Bellman-Ford 算法的最终状态

在第四次检查边(4, 1)后,算法已经完成了两个循环。成本和路径已收敛到其真实值。

### 所有节点对最短路径

我们的探险者和旅行社例子都满足于找到从给定起点到图中所有其他节点的最低成本路径的算法。然而,如果我们想要找到图中*任何*节点对之间的最短路径呢?即便是在我们之前的两个类比中,我们也能看到这种方法的吸引力。一旦冒险者绘制出整个魔法迷宫,他们可能希望在任意房间之间来回穿梭,以帮助其他遇到困难的冒险者。同样,我们的旅行社可能想要全球化,规划从任何起点到全球任何目的地的旅行。在这两种情况下,我们都需要找到两个任意节点之间的最低成本路径。

*所有节点对最短路径问题*的目标是找到图中每对节点之间的最短路径。换句话说,我们现在希望构建一个*矩阵*last,使得每一行last[i]包含从节点*i*出发的路径的前驱节点列表。在这个公式中,矩阵的条目last[i][j]是从*i*到节点*j*路径上,紧接着*j*之前的节点。与我们之前在各种搜索算法和其他最短路径算法中使用的last数组公式一样,对于一个固定的起点,我们可以从目的地追溯前驱节点,直到回到起点。

我们可以通过在本章讨论的任何一个算法周围添加一个循环来解决寻找所有节点对最短路径的问题。例如,我们可以使用一个单一的for循环和 Bellman-Ford 算法来填充last矩阵:

last: list = []
for n in range(g.num_nodes):
last.append(BellmanFord(g, n))
return last


由于 Bellman-Ford 算法的成本为|*E*| |*V*|,这种方法的总成本为|*E*| |*V*|²。类似地,我们可以用成本为|*V*|(|*V*| + |*E*|)log(|*V*|)来包装 Dijkstra 算法(如本章所实现)。这相当于在每个城市调用旅行社,询问从该城市出发的最低成本的旅行。通过结合从每个起点到所有可能目的地的最短路径信息,我们可以汇总两个城市之间旅行的成本。

以下部分介绍了一种替代的最短路径算法:Floyd-Warshall。该算法对密集图表现良好,特别是当|*E*|远大于|*V*|时。Floyd-Warshall 算法并非遍历未访问的节点或所有边,而是考虑可能作为中间路径节点的每个节点,并决定是否使用该节点。

### Floyd-Warshall 算法

*Floyd-Warshall 算法*通过迭代地考虑和优化每个起点和终点之间的节点来解决所有点对最短路径问题。*中间路径*由起点之后、终点之前的节点组成。该算法通过考虑将节点包含在中间路径中来有效地构建更好的路径。外部的`for`循环迭代每个节点*u*,并询问:“如果我们在节点*u*处停一下,是否会有更好的路径?”对于每个中间节点*u*,算法会测试每条待考虑的路径,看看它是否有帮助。如果有,它就将其加入。

在整个过程中,我们维护了之前在 Dijkstra 和 Bellman-Ford 算法中使用的`last`和`cost`数组的矩阵版本。这些矩阵的每一行对应一个起始节点的数组,每个条目表示某个目标节点的值(路径的成本或前一个节点)。我们初始化这两个矩阵以表示*没有*任何中间节点的最佳路径。`cost[u][v]`的初始值是如果边(*u*,*v*)存在则为边的权重,如果*u* = *v*则为 0,否则为无穷大。类似地,`last[u][v]`的值是如果边(*u*,*v*)存在且*u* ≠ *v*,则为*u*,否则值为`-1`,表示没有路径。

图 7-10 显示了 Floyd-Warshall 算法状态的示例。左侧的图是参考图,而两个矩阵则展示了当前估算的成本和最佳路径。这个初始状态是计算上等同于旅行社为客户规划只选择直飞航班的情况。只有当从*u*到*v*有直飞航班时,才会考虑城市对(*u*,*v*)。所有其他城市的成本可以看作是无限大。

![一个图,左侧有四个节点,中央是一个 4×4 的成本矩阵,右侧是一个 4×4 的最后路径矩阵。节点 0 有一条权重为 10 指向节点 1 的边,还有一条权重为 1 指向节点 2 的边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07010.jpg)

图 7-10:Floyd-Warshall 算法开始时数据结构的状态

弗洛伊德-沃尔夏尔算法利用一种称为*动态规划*的计算技术,有效地构建了使用{0, 1, ... , *k*}中间节点的最佳路径,这些路径只能包含{0, 1, ... , *k* - 1}中间节点。由于不允许负循环,每个节点在路径中最多只能使用一次。对于每对起点-终点(*u*, *v*),算法检查是否有通过节点*k*的更好路径,该路径仅使用中间节点{0, 1, ... , *k*}。我们可以通过重新使用上一次迭代(*k* - 1)的cost和last矩阵来进行编程。如果通过*k*有更好的路径,从*u*到*k*和从*k*到*v*的最佳路径的组合成本必须小于当前从*u*到*v*的路径成本。我们可以直接从上一次迭代的cost和last矩阵中读取这些路径和成本。

要查看其运作原理,请考虑图中的图形和算法状态图 7-11,这发生在测试了图中可能的中间节点 0、1 和 2 之后。矩阵cost和last表示可以有中间节点{0, 1, 2}的最佳路径。从节点 0 到节点 1 的最佳路径仍然是直接步骤[0, 1],因为我们还不能使用节点 3。

![成本矩阵的顶行为[0.0, 10.0, 1.0, 2.0],最后矩阵的顶行为[–1, 0, 0, 2]。](../images/f07011.jpg)

图 7-11:在测试节点 0、1 和 2 后弗洛伊德-沃尔夏尔算法数据结构的状态

当我们考虑可以使用节点 3 作为中间节点的路径时,我们发现了几条更好的路径,如图 7-12 所示。让我们再次考虑从节点 0 到节点 1 的路径。当我们询问是否有通过节点 3 从节点 0 到节点 1 的更好路径时,我们发现确实有。通过节点 3 的路径成本为 3.0,因为从 0 到 3 的路径成本(通过节点 2)为2.0,从 3 到 1 的路径成本为1.0。

![成本矩阵的顶行为[0.0, 3.0, 1.0, 2.0],最后矩阵的顶行为[-1, 3, 0, 2]。](../images/f07012.jpg)

图 7-12:在测试节点 0、1、2 和 3 后弗洛伊德-沃尔夏尔算法数据结构的状态

通过将节点 3 作为中间节点添加到从节点 0 到节点 1 的路径中,我们还添加了节点 2。最佳路径现在是 [0, 2, 3, 1]。这说明了 Floyd-Warshall 迭代方法的强大之处。我们不仅仅是孤立地考虑中间节点,而是考虑到到达和离开该节点的最佳路径。

由于该算法在每对节点之间测试每个可能的中间节点的改进路径,因此需要对节点进行三重嵌套的 for 循环。因此,算法的成本按 |*V* |³ 的比例增长。虽然这看起来可能很昂贵,但先前方法的相对运行时间取决于边和节点的相对数量。

#### 代码

Floyd-Warshall 算法的核心是三重嵌套的 for 循环,首先遍历每个中间节点(*k*),然后遍历每一对需要路径的节点(*i*, *j*),如下代码所示:

def FloydWarshall(g: Graph) -> list:
N: int = g.num_nodes
cost: list = [[math.inf] * N for _ in range(N)] last: list = [[-1] * N for _ in range(N)]
❶ for i in range(N):
for j in range(N):
if i == j:
cost[i][j] = 0.0
else:
edge: Union[Edge, None] = g.get_edge(i, j)
if edge is not None:
cost[i][j] = edge.weight
❷ last[i][j] = i

for k in range(N):
    for i in range(N):
        for j in range(N):
          ❸ if cost[i][j] > cost[i][k] + cost[k][j]:
                cost[i][j] = cost[i][k] + cost[k][j]
              ❹ last[i][j] = last[k][j]
return last 

代码通过设置初始的 cost 和 last 矩阵开始。使用一对嵌套的 for 循环来遍历每个条目 ❶。对角线上的最佳成本被设置为 0.0 (i == j),通过边连接的节点的边权重被设置为边权值,否则设置为无穷大。代码使用 Graph 类的 get_edge() 函数来检查并检索边,这需要额外导入 Python 的 typing 库中的 Union。类似地,任何通过边相连的节点对的前置节点被设置为源节点,其他节点(包括对角线)设置为 –1 ❷。

代码通过三重嵌套的for循环执行大部分处理。外部循环迭代当前考虑的中间节点k。两个内循环遍历每对节点i和j。对于每一对,代码检查是否可以通过节点k获得更好的路径❸。如果可以,它将更新成本和最后数组以反映这一点。与书中之前的算法不同,最后数组的条目会被更新,以匹配从k到j的*路径*上的最后一步❹。

当代码检查完所有可能的起点和终点对的所有中间节点后,它返回路径的最后矩阵。

#### 一个示例

图 7-13 展示了 Floyd-Warshall 算法在一个包含五个节点的图上的应用示例。前五个子图展示了*在*将虚线节点加入可能的中间节点集合后的数据结构状态。阴影节点已经被加入。因此,图 7-13(a)展示了第一次迭代前的状态,而图 7-13(b)展示了第一次迭代结束后的状态,当时节点 0 已被考虑为一个中间节点。

![每个子图展示了图形、成本矩阵和最后的矩阵。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f07013.jpg)

图 7-13:Floyd-Warshall 算法在一个包含五个节点的图上的迭代

在我们旅行代理征服全球市场计划的背景下考虑这个例子。他们创建了两个电子表格,第一个(cost)将出发地和目的地的组合映射到总成本。第二个(last)将相同的组合映射到目的地之前的城市。由于不知道如何开始,代理填写了如图 7-13(a)所示的直飞航班。cost矩阵包含了任何一对城市之间的飞行成本(同一城市之间的飞行成本为0.0),如果没有直达路径则为无穷大。last矩阵显示出发航班来源的城市,或者如果没有前一个城市则为-1。这是算法的初始状态。

接下来,旅行代理考虑了芝加哥(节点 0),并问道:“如果我通过这个城市来规划路线会怎样?当然,我会使用目前知道的最佳路径,把我的顾客从出发地带到芝加哥,再从芝加哥带到目的地。我只是谈论在行程中增加一个中转站。”如图 7-13(b)所示,这并没有改善任何路径,旅行代理没有改变他们的矩阵。

然后,代理继续考虑纽约(节点 1)并提出同样的问题。如图 7-13(c)所示,可能性扩展了。通过考虑在纽约的中途停留,来自芝加哥(节点 0)的旅客现在可以到达缅因州波特兰(节点 2)。同样,来自波特兰(节点 2)和夏洛特(节点 4)的旅客可以到达芝加哥(节点 0)和匹兹堡(节点 3)。

在成功的鼓舞下,旅行代理立即考虑在波特兰(节点 2)停留。然而,这并没有提供太大帮助,因为将波特兰作为中转站并没有降低任何路径的成本。代理叹了口气,怀疑他们在纽约的成功是否只是偶然,然后继续他们的搜索。

他们的坚持在考虑匹兹堡(节点 3)后得到了回报,正如图 7-13(e)中的矩阵所示。代理发现了从芝加哥(节点 0)、纽约(节点 1)和波特兰(节点 2)到夏洛特(节点 4)的新路径。

到目前为止,代理只找到通往新城市的路径。然而,所有的中途停留并没有减少那些已经有路径的城市之间的成本。因此,考虑到夏洛特(节点 4)是一次开眼界的经历,因为它为多次旅行提供了更好的中途停留。在考虑将夏洛特作为停靠点之前,从芝加哥到波特兰的旅行路径为[0, 1, 2],成本为15。现在,旅行者可以通过路径[0, 3, 4, 2]完成同样的旅行,成本仅为10。即使是从纽约到波特兰的旅行,通过路径[1, 3, 4, 2]也比直接航班便宜。

### 计算图的直径

图的*直径*是一个衡量图中节点之间最大距离的指标。我们将直径定义为图中任意两节点之间的最大距离。

*直径* = argmaxu [∈] E, v [∈] E *dist*(*u*, *v*)

如本章前面所述,*dist*(*u*, *v*)是节点*u*和*v*之间最短路径的成本。我们可以使用本章中的算法来构建这一度量,帮助分析或比较图。

例如,考虑我们的迷宫冒险者。在经历了多年的冒险,战胜了百余个地下迷宫和无数怪物后,他们决定退休并开始运营迷宫援助工作。他们希望选择一个迷宫,花费自己的一生在那里,帮助其他陷入困境的冒险者(收取合理费用)。他们的关键考虑因素是他们能多快跳进迷宫并帮助客户。毕竟,如果怪物在冒险者能够提供帮助之前就已经压倒了客户(或者客户还没付费),那就没有意义了。这个问题的复杂性在于,救援者和他们的客户可能会在迷宫中的任何一个节点遇到麻烦。如果冒险者同时有多个客户,他们甚至可能发现自己在房间之间奔波。

冒险者决定寻找一个直径在 5 到 10 个房间之间的迷宫。如果直径更大,他们将无法及时到达客户。如果直径更小,其他冒险者可能根本不需要帮助。满意于自己的推理后,他们计算了周围所有地下城的直径,并选择了一个在正确范围内的迷宫。

我们可以直接从 Floyd-Warshall 算法中的cost矩阵中提取图的直径,通过遍历条目并找到最大值。或者,我们可以通过从last矩阵中反向遍历每条路径并求和路径成本来重新构建直径。以下是第二种方法的代码,旨在演示如何使用last矩阵:

def GraphDiameter(g: Graph) -> float:
❶ last: list = FloydWarshall(g)
max_cost: float = -math.inf

for i in range(g.num_nodes):
    for j in range(g.num_nodes):
        cost: float = 0.0
        current: int = j

      ❷ while current != i:
            prev: int = last[i][current]
          ❸ if prev == -1:
                return math.inf

            edge: Union[Edge, None] = g.get_edge(prev, current)
            cost = cost + edge.weight
            current = prev

      ❹ if cost > max_cost:
            max_cost = cost

return max_cost 

这段代码使用 Floyd-Warshall 算法计算路径的最后矩阵 ❶。然后,它通过一对嵌套的 for 循环遍历所有的起点和终点配对 (i, j)。对于每一对配对,代码从终点开始,向回走过最后的矩阵,直到到达起点 ❷。在此过程中,它提取每条边并将其权重加到当前的总和中。如果路径到达死胡同(即,当 last[i][current] == -1 且 current != i 时),函数将立即返回一个无限大的距离,以表示这对节点之间没有路径 ❸。如果所有配对都有有效路径,代码将跟踪看到的最昂贵路径 ❹,并返回这个最大值作为图的直径。

### 为什么这很重要

最低成本算法直接应用于一系列现实世界的问题,从路径规划到优化。本章中的算法提供了高效找到此类路径的实用方法。Dijkstra 算法和 Bellman-Ford 算法都返回图中所有可能目的地的解。Floyd-Warshall 算法进一步扩展这一点,返回所有可能起点和终点之间的最短路径。

本章介绍的三种算法还展示了可以适应解决其他图问题的一般技术。Dijkstra 算法维护一个未访问节点的优先队列,代表了一个未探索的可能性前沿。在 第十章 中,我们将看到另一种算法如何使用相同的方法解决不同的优化问题。Bellman-Ford 算法提供了一些关于在边集上操作的算法的视角。Floyd-Warshall 算法展示了一种更复杂的动态规划方法,它通过一个较小的子集从更简单的最佳路径问题中构建最优路径。

下一章介绍了可以结合附加启发式信息来限制在寻找从给定起点到给定终点的最低成本路径时必须访问的节点数的算法。尽管这些算法所产生的路径不比本章中的路径更短,但它们通过将搜索集中在最有前景的节点上,运行得更快。




## 第八章:8 启发式引导搜索



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

本章介绍了*启发式引导搜索*算法,有时也称为*最佳优先搜索*。这些算法结合了节点与目标之间估算的距离信息,以确定探索节点的优先顺序。通过关注最有前景的路径,它们能够实现显著的计算节省。

正如我们在上一章中看到的,从指定的起始节点到指定目标节点找到最短(或最低成本)路径的过程,类似于我们日常导航的任务。无论是规划前往工作地点还是商店的路线,我们可能会通过所需时间、行驶距离或由于交叉路口拥堵所带来的烦恼来衡量成本。

在解释启发式方法的构成之后,本章介绍了两种典型的启发式搜索算法。*贪心最佳优先搜索*仅根据估算的到达目标的成本来优先考虑节点,而*A*搜索*(读作“A 星”)则将到达中介节点的成本与从该节点到目标的估算成本相结合。这种组合使得 A*搜索成为一种更强大的工具,能够高效地找到良好的路径。

### 选择合适的启发式方法

本章中的算法依赖于估算成本来指导搜索。为了将这种启发式信息加入到算法中,我们必须根据我们对每个节点的了解,选择一种估算成本的方法。尽管定义一个好的启发式方法在不同问题中的难度差异较大,但许多实际场景中的方法既简单又直观。在介绍欧几里得距离作为路径规划中常用的启发式方法后,我们将讨论选择启发式方法时涉及的约束和挑战。

#### 欧几里得距离

*欧几里得距离*是一个常见的、强大且直观的启发式方法,广泛应用于许多现实世界的路径规划问题中。它通过直线距离来估算到达某个节点的成本。例如,假设我们正在进行一次横跨美国的公路旅行,从波士顿到西雅图。如果出发城市位于(*x*[1],*y*[1])而目的地位于(*x*[2],*y*[2]),则两者之间的欧几里得距离如下所示:

<mrow><mi>d</mi><mi>i</mi><mi>s</mi><mi>t</mi><mo>=</mo> <msqrt><mrow><mfenced><mrow><msup><mrow><mfenced><mrow><msub><mi>x</mi> <mn>1</mn></msub> <mo>−</mo> <msub><mi>x</mi> <mn>2</mn></msub></mrow></mfenced></mrow> <mn>2</mn></msup> <mo>+</mo> <msup><mrow><mfenced><mrow><msub><mi>y</mi> <mn>1</mn></msub> <mo>−</mo> <msub><mi>y</mi> <mn>2</mn></msub></mrow></mfenced></mrow> <mn>2</mn></msup></mrow></mfenced></mrow></msqrt></mrow> ![dist, 等于;开括号;开括号,x 子 1 减去 x 下标 2,右括号平方;加上,左括号,y 下标 1 减去 y 下标 2,右括号平方;右括号结束根](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/pg112.jpg)

我们可以将这个方程式编码如下:

def euclidean_dist(x1: float, y1: float, x2: float, y2: float) -> float:
return math.sqrt((x1 - x2)(x1 - x2) + (y1 - y2)(y1 - y2))


除非你像鸟一样直接飞往目的地,否则欧几里得距离充其量只能提供真实成本的下界。地图上的城市之间并非每对都有道路连接。即使是直接从一个点到另一个点的高速公路,也不太可能沿直线行驶,因为它们必须绕过地理特征,如山脉和湖泊,从而延长你的旅程。

尽管欧几里得距离具有乐观的特性,但它仍然能提供重要的启发式信息。例如,这些估算可以帮助我们在规划公路旅行时选择合适的途经点。在从波士顿到西雅图的旅途中,克利夫兰显然比迈阿密更适合作为休息站,因为迈阿密离西雅图和克利夫兰的距离都比我们的起点更远。

图 8-1(a)展示了一个二维平面上的示例图。图 8-1(b)展示了对应的边权重,这些权重大致对应于节点之间的直线距离。然而,两节点之间的成本也可能*大于*欧几里得距离。节点 2 和节点 4 之间的边的权重是 3.5,这可能表示额外的成本,例如需要穿越陡峭的山丘或使用土路。节点之间的穿越成本(即边的权重)必须大于或等于估算距离。

![在(A)中,展示了一个七节点图,位于 5×5 网格上。节点 0 位于 x=0,y=0,节点 1 位于 x=0,y=2。在(B)中,每条边都标有两个节点之间的成本。边(0,1)的权重是 2.0。在(C)中,每个节点标有到目标的估算距离。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08001.jpg)

图 8-1:二维平面上节点之间的权重和欧几里得距离

基于这种安排,我们可以通过计算从每个节点到目标的欧几里得距离,来提供从每个节点到目标的潜在成本的下界估算,如图 8-1(c)所示,对于目标节点 6 而言。从节点 0 到节点 6 的估算成本为 5.0,反映了它们的欧几里得距离。如前所述,这些估算并不总是能够捕捉到全部成本。例如,从节点 0 到节点 6 的估算距离过于乐观,因为该节点没有直接到达节点 6 的路径。

#### 可接受的启发式方法

我们定义启发式方法为*可接受*,如果从起始节点到目标节点的估算成本始终小于或等于真实成本,或者换句话说,如果启发式方法不会高估真实成本。例如,欧几里得距离就是一个常见的、有效的且可接受的启发式方法,适用于现实世界的路径规划,因为到目标的直线距离提供了一个乐观的估算,表示到达目标的成本。可接受性要求对于某些搜索(如 A*搜索)的正确运行至关重要,并且在选择用于特定目的的启发式方法时将是我们主要的约束之一。

#### 启发式设计挑战

虽然定义估算距离的启发式方法相对简单,但让我们考虑一个更复杂的情况。假设你希望通过你的职业网络请求介绍认识一个新联系人。每个人(节点)只能通过联系他们现在或曾经的同事来传递这个请求。为了促进你的介绍,你必须找到一条加权边的序列,这些边间接地将你与最终想要见到的人连接起来,每条边的权重代表传递请求的成本。与一个每天都和你交谈的朋友联系,成本会很低,而与一个让你讨厌的前同事联系,且你再也不想和他交谈的成本则会非常高。

不幸的是,使用单一的可接受启发式方法很难捕捉所有这些因素。你或许能从每个人的工作历史中获得一些对目标的距离估算。与目标节点来自不同产业的人的沟通成本通常会更高;例如,职业棒球运动员和计算机科学家之间的职业互动比两个棒球运动员之间的职业互动要少得多。同样,如果这两个人在同一家公司工作,估算的成本会更低。然而,将这些正面和负面指标拼凑在一起很难形成一个好的定量估算,因为这些指标过于嘈杂。事实上,两个曾经一起工作过的人,如果他们从未见过面或者是死敌,那么这个信息对你并没有帮助。

更糟糕的是,确保这些问题的启发式方法是可接受的要困难得多。如果你对不同行业之间的成本设置了很高的权重,你偶尔会高估在它们之间传递信息的成本——也许你认识一位程序员,他仍然与曾是童年最好朋友的电影明星保持定期联系。同样,这个度量也无法捕捉到那些在不同公司工作但经常沟通的家庭成员。

选择一个好的启发式方法需要在保持可接受性和最小化计算成本的同时最大化信息量。通过为每个节点分配负无穷大的代价来设计一个可接受的启发式方法是微不足道的,但显然这种策略对于引导搜索没有任何帮助。同样,我们可以通过使用上一章中的算法设计一个完美的信息丰富且可接受的启发式方法:我们只需解决所有节点对之间的最短路径问题,并根据每个节点与目标之间的真实最低成本路径来计算启发式方法。然而,这也没有任何帮助,因为搜索的计算成本过高。启发式方法的关键在于减少搜索本身的计算成本。在考虑新问题和启发式方法时,始终需要权衡信息量、计算成本和可接受性之间的关系。

以下章节介绍了两种经典的启发式搜索算法,从最简单的方法开始:贪心最佳优先搜索。

### 贪心最佳优先搜索

*贪心最佳优先搜索*总是在搜索中的某一点选择看起来最好的选项,基于最优启发式值探索估算成本最低的下一个未访问节点。该算法维持一个最小优先队列,用于测试节点。当它向目标推进时,每一步都从优先队列中选择最低成本的节点,并接着探索该节点。每当算法看到新的邻居节点时,它会将其添加到队列中,并赋予与其启发式值相等的优先级。该算法逐节点推进,直到找到通往目标的路径。

我们可以将贪心最佳优先搜索看作是广度优先搜索的一个变种。广度优先搜索优先考虑按照访问顺序排列的节点,使用队列访问最早看到的节点,而最佳优先搜索则通过启发式方法对节点进行排序。

贪心最佳优先搜索采用了我们可能会期望的方式,就像一只聪明但急切的松鼠在迷宫中穿行,正如图 8-2 所示。松鼠(S)能闻到他目标目的地(G)那堆美味的橡果。通过嗅觉,松鼠可以推测出他如果没有墙壁的阻挡,可以直接走向橡果的直线路径(图 8-2(a))。

![(A) 显示了一个 5×5 网格的迷宫,顶部中间的单元格标记为 S,右下角的单元格标记为 G。一个灰色箭头直接从 S 指向 G。 (B) 显示了同一个迷宫,其中 S 左侧的单元格标记为减号,S 下方和右侧的单元格标记为加号。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08002.jpg)

图 8-2:带有启发式目标方向估算的迷宫

在任何给定的位置(节点),松鼠还可以确定它可以移动到的相邻位置,并估计哪一个能将它带得离目标更近。松鼠使用基于气味的启发式方法——气味更强的橡果节点更接近目标。图 8-2(b)显示,两个相邻的方格将使松鼠更接近橡果(+),而一个则使它远离橡果(–)。松鼠始终选择气味最强的路径,沿着气味朝食物前进,有时会回溯到气味比当前地点更强的点。在此过程中,它会在脑海中记下其他路径,并将它们加入到待尝试的选项列表中。

虽然贪婪的最佳优先搜索如果使用了好的启发式方法,可能会快速找到通向目标的路径,但最终路径并不保证是最优的。我们可能会走一条看起来不错的路径,因为在早期节点时的乐观估计,而跳过了一条更好的路径,尽管它的估计更为现实。松鼠可能会走一条较长的路径,暂时偏离食物的方向,仅仅因为那个方向的气味更强。我们将在本节后面看到这个场景的示例。

#### 代码

要实现贪婪搜索算法,我们需要提供比之前搜索中包含的更多的信息:节点的启发式值。有多种方法可以提供这些信息。为了清晰地说明,我们将从传递一个预先计算好的列表h开始,该列表将节点的索引与它们的启发式值对应起来,稍后在本章中我们将介绍一种替代方法。

贪婪的最佳优先搜索的代码类似于广度优先搜索的代码。我们不是使用队列按照节点被访问的顺序来存储节点,而是使用一个自定义的*基于最小堆的优先级队列*(在附录 B 中描述)来按估计成本递减的顺序获取节点:

def greedy_search(g: Graph, h: list, start: int, goal: int) -> list:
visited: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
pq: PriorityQueue = PriorityQueue(min_heap=True) ❶ pq.enqueue(start, h[start])
❷ while not pq.is_empty() and not visited[goal]:
ind: int = pq.dequeue()
current: Node = g.nodes[ind]
visited[ind] = True

    for edge in current.get_edge_list():
        neighbor: int = edge.to_node
      ❸ if not visited[neighbor] and not pq.in_queue(neighbor):
            pq.enqueue(neighbor, h[neighbor])
            last[neighbor] = ind

return last 

代码首先设置内部数据结构,包括一个列表,用于指示我们是否已经访问过每个节点(visited),一个列表,将每个节点映射到它在搜索路径中前面的节点(last),以及一个最小优先级队列(pq)。然后,它将起始节点插入到优先级队列中,并以它的启发式成本作为优先级 ❶。在松鼠迷宫的类比中,这标志着松鼠搜索开始之前的那个点。它站在迷宫外,闻到橡果的气味,并且在它的心理优先级队列中有一个可用的选项:起始节点。

贪心最佳优先搜索的探索过程是在一个while循环中进行的,直到代码耗尽优先队列或访问到目标节点❷为止。在每次迭代中,代码会检索优先队列中的下一个节点——具有最佳启发式值的节点——并访问该节点。你可以将其想象成松鼠奔向下一个最佳选择的位置。

代码使用一个for循环遍历当前节点的每个邻居。如果某个邻居没有被访问过且不在优先队列中,那么它之前没有被看到❸。因此,代码会将其加入优先队列(以估算的距离作为优先级),并将当前节点标记为该邻居的前一步。

当while循环完成时,贪心搜索要么找到了通往目标节点的路径,要么发现没有这样的路径。在前一种情况下,与我们之前看到的其他算法不同,优先队列中可能仍然有未探索的节点。在后一种情况下,优先队列将为空;没有更多的节点可供探索。目标节点在last中的条目将为-1,表示没有任何路径返回到起始节点。代码最终通过返回last列表来结束。

#### 一个例子

图 8-3 展示了一个在图 8-1 中的图上进行的贪心最佳优先搜索示例。在每个子图中,当前正在探索的节点被虚线圆圈包围,而已访问的节点则被阴影标记。每条边的权重显示在每条边旁边。

![每个子图展示了图 8-1 中的七节点图,并附带排序后的优先队列和最后数组。(B)显示了节点 0 被标记和圈出。最后的数组为[–1, 0, 0, 0, –1, –1, –1],优先队列中包含节点 2、1 和 3,它们的优先级分别为 2.24、3.60 和 4.0。](../images/f08003.jpg)

图 8-3:贪心最佳优先搜索的步骤

为避免图示过于拥挤,图 8-3 中没有直接显示每个节点的启发式值。然而,这些启发式值与图 8-1(c)中显示的欧几里得距离相同。我们将它们作为列表h提供给算法:

h: list = [5.0, 3.6, 2.24, 4.0, 2.24, 3.16, 0.0]


每个子图还展示了当前的优先队列(为清晰起见以排序顺序显示)和last列表。虽然代码保持优先队列的堆排序,但我们以排序顺序展示优先队列,以便更清楚地看到相对顺序。

搜索从将起始节点放入优先队列并赋予其对应的成本估算值 5.0 开始,如图 8-3(a)所示。在< s amp class="SANS_TheSansMonoCd_W5Regular_11">while 循环的第一次迭代中,它从优先队列中移除节点 0,访问该节点,并将每个之前未见过的邻居添加到优先队列,如图 8-3(b)所示。节点的优先级等于其启发式成本(即到目标节点的欧几里得距离),如图 8-1(c)所示:节点 1 = 3.6,节点 2 = 2.24,节点 3 = 4.0。搜索为这些邻居设置了< s amp class="SANS_TheSansMonoCd_W5Regular_11">last 值为 0,以指示前往它们的路径来自节点 0。

在搜索的每一步,算法都会选择看起来最有前途的节点,朝着目标前进。检查优先队列后,它继续处理节点 2。如图 8-3(c)所示,它随后将节点 2 的未访问邻居添加到优先队列中。节点 4 现在以优先级 2.24 排在队列的最前面。搜索通过节点 4 进展,最终到达目标节点,如图 8-3(d)和图 8-3(e)所示。

从这个例子中可以看出,贪婪最佳优先搜索并未产生通向目标的最优路径。搜索被节点 2 的目标邻近性吸引,但最终被迫绕过节点 4,经过一个代价为 3.5 的高权重边。贪婪最佳优先搜索无法判断通过节点 1 会是更好的选择,因为它没有考虑到通向节点的路径成本。它仅仅看待从某个节点到目标的估算成本,并将其作为优先级的依据。等到搜索访问完节点 2 时,它已经看到节点 4 的启发式值比节点 1 更优。

我们可以通过一个令人沮丧的骑行旅行来形象化这种次优性。假设在一个上午的骑行中,你们没有目的地,也没有注意到路径,最终你和你的朋友都感到筋疲力尽,想要找到回家的路。你们停在一个岔路口,开始考虑各自的选择。你知道左侧的路尽头是一个与你家相邻的交叉口,但为了到达那里,必须翻越一座小山。右侧的路是平坦的,但终点是离你家几条街的交叉口。两条路都能让你们更接近家,但代价(边的权重)却有天壤之别。不幸的是,贪婪算法并未考虑这一点。在你开口之前,你那过于急切的朋友欢呼一声,便骑上了左侧的路。当你试图抗议时,他们只是喊道:“一点小山坡有什么关系?这条路让我们更接近家。”  ### A* 搜索

A* 搜索将贪心最佳优先搜索的启发式估算与对已观察到的边缘成本的更全面的考虑相结合,提供了一种高效的机制,用于找到两个节点之间的最短路径。而贪心最佳优先搜索完全忽略边缘成本,A* 则平衡了启发式估算的潜力与我们对每个节点的最佳路径所了解的冷硬事实。这种结合产生了一个准确且计算高效的算法。

A* 算法背后的关键直觉是,我们希望按估算的总成本对潜在的路径节点进行排序。仅仅关注从当前节点到目标的成本是不够的;我们还必须问,首先到达该节点的成本有多高。为了回答这个问题,A* 跟踪一个额外的信息:到每个节点的最佳路径所需的成本。如图 8-4 所示,用于未访问节点的优先级是到该节点的最佳路径成本加上从该节点到目标的估算成本之和。

![三个节点,S、C 和 G。S 到 C 之间的路径标注为“Best cost S to C”,C 到 G 之间的路径标注为“Estimate C to G。”](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08004.jpg)

图 8-4:节点的真实成本与目标的估算成本的结合

与急躁的松鼠展示的贪心最佳优先搜索相比,我们可以将 A* 搜索可视化为一个更为细致的探险者,他拥有高级制图学学位,正在寻找通往提议的考古挖掘现场的路径。除了标准的指南针、水壶和必备的探险帽,我们的主角还携带着一个文件夹来追踪有关该地区的信息。他们将每个节点表示为一行,包含三列信息:到该节点的最佳成本(标题为 Best Cost)、到该节点的最佳路径(Best Path)以及通过该节点到达目标的估算总成本(Heuristic)。在整个旅程中,探险者不断更新这三列信息。

探险者从一个村庄到另一个村庄(从一个节点到另一个节点)旅行。GPS 坐标提供了最短可能距离的估算。路标、路径标记和面对面的访谈揭示了到邻近节点的实际距离。每当探险者发现一个新节点时,他们会计算到目标的估算成本,并将其记录在启发式(Heuristic)列中。

随着旅程的继续,探险者总是朝着估算总成本最低的下一个位置(节点)移动。每次他们考虑一个邻近位置,无论是新位置还是以前见过的,他们都会问自己是否发现了比之前更好的路径到达该节点。如果是,他们会在最佳成本和最佳路径栏目中记录这一发现。也许在旅程的早期,他们发现了一条穿越密林、蜘蛛横行的 10 英里小道通向考古遗址。他们的笔记详细记录了这条路径和它的巨大成本。然而,他们后来发现了一条新的、三英里长的铺设公路,通过东边的一个小村庄通往同一地点。他们急切地擦掉旧的记录,并更新最佳成本和最佳路径栏目,以反映这一新发现。

#### 代码

清单 8-1 中的 A*搜索代码根据通过该节点并通向目标的估算总成本对潜在节点进行排序。它再次使用了一个预先计算的列表 h,该列表包含每个节点的启发式值。

def astar_search(g: Graph, h: list, start: int, goal: int) -> list:
visited: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
cost: list = [math.inf] * g.num_nodes
pq: PriorityQueue = PriorityQueue(min_heap=True)

❶ pq.enqueue(start, h[start])
cost[start] = 0.0
❷ while not pq.is_empty() and not visited[goal]:
ind: int = pq.dequeue()
current: Node = g.nodes[ind]
visited[ind] = True

    for edge in current.get_edge_list():
        neighbor: int = edge.to_node
      ❸ if cost[neighbor] > cost[ind] + edge.weight:
            cost[neighbor] = cost[ind] + edge.weight
            last[neighbor] = ind

          ❹ est_value: float = cost[neighbor] + h[neighbor]
            if pq.in_queue(neighbor):
                pq.update_priority(neighbor, est_value)
            else:
                pq.enqueue(neighbor, est_value)
return last 

清单 8-1: A* 搜索的代码

这段代码首先设置内部数据结构,包括一个列表,表示是否访问过每个节点(visited),一个列表,将每个节点映射到其在搜索路径上前面的节点(last),一个列表,存储从起始节点到每个后续节点的最佳路径成本(cost),以及一个最小优先队列(pq)。它将起始节点插入到优先队列中,以其估算成本作为优先级,并将起始节点的成本设置为 0,以反映搜索已到达该节点 ❶。

搜索现在准备开始了。一个 while 循环将继续探索节点,直到代码耗尽优先队列或访问到目标节点 ❷。在每次迭代中,代码从优先队列中取出下一个节点——到达目标的估算总成本最低的节点——并访问它。

该算法使用一个 for 循环遍历当前节点的每个邻居。它检查当前节点是否提供了到邻居的更优路径,通过将到当前节点的最佳路径与到邻居的边权重相加来计算完整的成本 ❸。如果代码找到到某个节点的更优路径,它会更新 cost 和 last 列表。然后,它使用新的到 neighbor 的成本以及从 neighbor 到目标的估计成本,更新邻居节点的估计总成本 ❹。如果邻居已经在优先队列中,代码会使用 update_priority() 函数更新它的优先级,以考虑新的估计总成本。否则,它会将节点添加到优先队列中。

与贪心最佳优先搜索一样,当 A* 搜索完成时,要么找到了到目标节点的路径,要么得出结论没有这样的路径——也就是说,如果搜索在访问目标节点之前已经耗尽了优先队列。代码通过返回 last 列表来完成。

#### 一个例子

图 8-5 显示了一个 A* 搜索示例。与我们在贪心最佳优先搜索示例中一样,我们显示了边权重,并再次使用来自 图 8-1(c) 的启发式方法:

h: list = [5.0, 3.6, 2.24, 4.0, 2.24, 3.16, 0.0]


每个子图还显示了 last 数组、cost 数组和优先队列。当前正在探索的节点用虚线圆圈标出,已访问的节点被阴影标记。为了清晰起见,优先队列按排序顺序显示。

图 8-5(a) 显示了搜索的初始状态,在访问第一个节点之前。优先队列最初只包含起始节点。由于从起始节点到其自身的距离为 0.0,因此起始节点的估计总成本只是到目标的估计距离。cost 数组反映了到达每个节点的已知成本:起始节点为 0.0,其它节点为无限大,因为搜索尚未观察到通往这些节点的路径。

图 8-5(a)代表了我们假设的探险者在开始探险前的状态。他们被雇佣来寻找从一个城市(节点 0)到一个提议的考古挖掘地点(节点 6)的最短路径。在到达起始城市之前,他们仅根据考古挖掘地点的地理坐标,对距离目标地的距离有一个粗略(且乐观的)估计。探险者检查他们的列表,戴上头盔,说:“我知道挖掘地点距离城市至少五英里,是时候开始了。”

![每个子图展示了图 8-1 中的七节点图,以及排序后的优先队列、最后的数组和成本数组。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08005.jpg)

图 8-5:A*算法在一个示例图上的步骤

在图 8-5(b)中,搜索从优先队列中出队最上面的节点并探索它。这相当于我们勇敢的探险者到达起始城市并四处观察。搜索发现三个邻近的节点,并计算它们的预期总成本,作为到当前节点的距离、到邻居的边权和从邻居到目标的估计成本的总和,得到以下总估算成本:

节点 1:0.0 + 2.0 + 3.6 = 5.6

节点 2:0.0 + 2.83 + 2.24 = 5.07

节点 3:0.0 + 3.0 + 4.0 = 7.0

这相当于探险者在找到三条通往起始城市的道路后更新他们的列表。从一个有用的路标上,他们知道村庄的距离和位置。每条道路提供了一个潜在的路径,并且每条路径的预估成本不同。

由于节点 2 的估计总成本最优,搜索接下来会探索它,如图 8-5(c)所示。从这里,搜索会考虑两个邻居节点 3 和 4。节点 3 从起始节点的成本已经较低(3.0 对比 2.83 + 2.24 = 5.07),所以搜索不会更新它的路径或优先级。搜索从未见过节点 4,因此提供了初始成本值 2.83 + 3.5 = 6.33,以及总成本估计 2.83 + 3.5 + 2.24 = 8.57。这个成本反映了从节点 2 到节点 4 路径的极高成本。

从探险者的视角来看,这些决策看起来很相似。他们看到一个路标,指示着两个新村庄。村庄 3 距离约 2.24 英里。与从城市 0 到村庄 3 的直线路径相比,绕过村庄 2 到村庄 3 的绕行路线要长得多。他们立刻意识到没有必要增加一个不必要的停靠点,因此决定不更新村庄 3 的行。相比之下,虽然从村庄 2 到村庄 4 的路径异常困难,但它有可能让他们更接近目标,因此他们更新了村庄 4 的行。

搜索继续进行,通过选择优先值最低的未访问节点来进行。与贪心搜索不同,A* 搜索不会直接跳到估计离目标最近的节点,这里是节点 4。虽然这个节点的估计成本(2.24)是最接近目标的,但使用当前路径到达它的成本较高(通过节点 2 为 6.3)。相反,搜索探索了节点 1,如图 8-5(d)所示,并找到了通往节点 4 的更好路径,更新了估计的总成本为 2.0 + 1.41 + 2.24 = 5.65。同时,更新了 last 数组,表明通往节点 4 的路径是通过节点 1 而不是节点 2。

这一步反映了探险者思考路线总成本的过程。雇佣探险者的考古学家希望找到一条低成本的路线,反复到达现场。知道这一点后,探险者首先尝试从村庄 2 到村庄 4 穿越山脉之前先探索村庄 1。

搜索继续在图 8-5(e)中前进到节点 4,然后在图 8-5(f)中到达节点 6。在每个停顿点,搜索会考虑未访问的邻居,并检查是否发现了更好的路径。它在到达节点 6 后停止,因为它知道自己已经找到了通向目标的最佳路径,即使没有访问节点 3 和 5。#### 为何 A* 能找到最优路径

怀疑的读者可能会问,我们如何确保 A* 搜索找到了最佳路径,因为它只探索了图的一部分,而没有访问每个节点。然而,只要其启发式是可接受的,A* 就一定能找到最优路径。为了理解原因,让我们分析一下 A* 搜索通过某条路径到达目标节点后的状态,并考虑通过一个未访问的节点 *v* 到达目标节点的替代路径。由于我们使用的是可接受的启发式和优先队列排序,任何通过节点 *v* 的路径必定比我们已经找到的路径更长。

由于搜索在到达目标节点之前没有访问节点 *v*,因此节点 *v* 的优先值(估计总成本)一定大于目标节点的优先值。当搜索访问到目标节点时,目标节点的优先值等于已找到路径的实际成本。目标的估计成本总是为 0,因为它本身就是目标节点,因此目标的优先值等于前一个节点的成本加上相应的边权重。我们不再依赖启发式方法,而是得到了实际的路径成本。

相比之下,未访问节点 *v* 的优先值是使用可接受启发式方法后的真实距离的下界。它永远不可能小于真实距离。我们的启发式方法是乐观的。任何通过节点 *v* 到达目标的路径,其成本至少与节点 *v* 的优先值相等,而这个优先值大于目标节点的优先值。因此,任何通过节点 *v* 到达目标的路径,其成本必定比已经找到的路径要高。

### 应用 A* 于难题

只要我们能生成一个有用且可接受的启发式,我们就可以应用基于启发式的搜索,来高效地为第六章中的难题图(例如囚徒与看守难题)找到解决方案。作为提醒,图 8-6 展示了该难题的状态图(最初在图 6-8 中介绍)。

![一个 16 节点的图,每个节点标有一个数字和一个描述难题状态的三元素列表。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08006.jpg)

图 8-6:囚徒与看守过河难题的状态图

我们可以利用关于船只物理特性的两个基本事实来推导一个可接受的度量,表示到达目标状态的距离:

+   这艘船最多可以载两个人。如果左岸有*k*人,我们至少需要 *ceil*(*k* / 2) 次旅行才能将他们全部带到右岸。

+   如果船在右岸,它必须先返回左岸才能接载更多的人。

使用这些条件,我们可以定义一个函数,从存储在每个节点中的 PGState 生成启发式值:

def pg_generate_heuristic(g: Graph) -> list:
heuristic = [0.0] * g.num_nodes
for node in g.nodes:
state: PGState = node.label
❶ num_left: int = state.guards_left + state.prisoners_left
❷ min_trips_l_to_r: int = math.ceil(num_left / 2.0)
❸ min_trips_r_to_l: int = max(0, min_trips_l_to_r - 1)
if not state.boat_side == "L" and min_trips_l_to_r > 0:
min_trips_r_to_l += 1
heuristic[node.index] = min_trips_l_to_r + min_trips_r_to_l

return heuristic 

代码使用 for 循环遍历图的每个节点,检查每个节点的难题状态以确定左岸的人数❶。然后,它通过考虑还需要移动的人数,并注意每次最多可以带两个人,计算船从左岸到右岸所需的最少次数❷。它还通过注意到虽然右岸有更多的乘客需要运输,但船必须返回左岸接他们,计算船从右岸到左岸所需的最少次数❸。启发式值是这两组旅行次数的总和。

表 8-1 将该启发式函数在每个状态下的值与通过计算从每个状态到目标的步骤得到的真实距离进行比较,如图 8-6 所示。正如你所看到的,启发式是可接受的,并且从不高估真实距离。

表 8-1: 来自 pg_generate_heuristic() 函数的值与目标节点的真实距离

| 状态 | 33L | 32R | 31R | 22R | 32L | 30R | 31L | 11R | 22L | 02R | 03L | 01R | 11L | 02L | 00R | 01L |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 启发式距离 | 5 | 6 | 4 | 4 | 5 | 4 | 3 | 2 | 3 | 2 | 3 | 2 | 1 | 1 | 0 | 1 |
| 真实距离 | 11 | 12 | 10 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 1 | 0 | 1 |

给定这个启发式函数,我们可以在过河难题上运行 A*搜索:

g: Graph = create_prisoners_and_guards()
h: list = pg_generate_heuristic(g)
last: list = astar_search(g, h, 0, 14)


囚犯与守卫问题提供了一个示范性例子,展示了如何将 A*搜索应用于难题,因为我们可以枚举状态并将启发式值与真实的最佳路径进行比较。然而,图的结构是一个没有分支的单一长状态序列,这意味着在这个难题上,A*搜索并没有显著优于广度优先搜索。相比之下,A*搜索在状态空间更大的难题上可能会带来显著的优势,因为 A*搜索的优势在于它能够集中精力探索朝向目标的有前景路径。

### 搜索未知图

本章迄今为止介绍的算法将图和启发式值都视为已知项传递给搜索,但这些方法同样适用于算法需要动态构建一个未知图的问题。请参考第六章中的拼图构建示例。在那里,我们使用广度优先搜索来字面地探索状态空间,在遇到新节点和边时构建图。我们可以在启发式引导搜索中做同样的事情。

我们可以不直接传递一个启发式值的列表给每个节点,而是传递一个函数,这个函数根据节点中的信息动态评估启发式。例如,如果一个节点有辅助数据 *x* 和 *y*,表示其空间位置,我们可以定义启发式函数为该节点到已知目标位置的欧几里得距离。对于一个现实世界的探险者来说,这可能类似于他们使用 GPS 来估算穿越丛林时距离目标的距离。

我们可以通过使用视频游戏中的“遮罩”机制来可视化这个动态构建和评估的过程。例如,图 8-7 展示了一个 5×5 迷宫的网格。像图底部的长死胡同这样的已探索区域显示为方块,而未探索区域则显示为灰色。灰色区域中可能有任何东西:通向目标的直接路径、无数的死胡同,或是一个巨大的怪物。我们在探索之前无法得知。

![该图展示了一个 5×5 的迷宫网格。网格中展示了十一格,剩余的部分为灰色。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08007.jpg)

图 8-7:一个迷宫,未见区域被灰色显示

在这一节中,我们修改了清单 8-1 中的代码,通过在发现新节点时动态构建图来进行探索。辅助数据结构如last和 distance 也必须动态扩展,以便考虑到新状态。

#### 代码

对于我们的示例代码,我们通过使用World类来对算法进行泛化。这个类提供了关于谜题的基本信息,包括以下内容:

+   起始状态的索引

+   给定状态的邻居

+   任意两个邻近状态之间的转换成本

+   一个状态的启发式值

+   当前状态是否为目标

通过这个接口,我们无需事先了解任何状态空间的信息。就像现实中的探险者咨询 GPS 和路标一样,我们在整个算法中使用World接口来检查世界的局部状态。

请注意,World类不需要枚举状态空间。它也不需要构建和存储完整的图。我们可以使用像第六章中的函数,动态地根据状态信息确定一个状态的邻居。动态评估使我们能够在没有巨大内存开销的情况下探索庞大的状态空间。

我们可以为本章的基于距离的示例搜索定义一个简单的World类,代码如下:

class World:
def init(self, g: Graph, start_ind: int, goal_ind: int):
self.g = g
self.start_ind = start_ind
self.goal_ind = goal_ind

def get_num_states(self) -> int:
    return self.g.num_nodes def is_goal(self, state: int) -> bool:
    return state == self.goal_ind

def get_start_index(self) -> int:
    return self.start_ind

def get_neighbors(self, state: int) -> set:
    return self.g.nodes[state].get_neighbors()

def get_cost(self, from_state: int, to_state: int) -> float:
    if not self.g.is_edge(from_state, to_state):
        return math.inf
    return self.g.get_edge(from_state, to_state).weight

def get_heuristic(self, state: int):
  ❶ pos1 = self.g.nodes[state].label
    pos2 = self.g.nodes[self.goal_ind].label
  ❷ return math.sqrt((pos1[0]-pos2[0])**2 + (pos1[1]-pos2[1])**2) 

World 类存储底层图(g)、起始索引(start_ind)和目标索引(goal_ind)。在这个例子中,它们都是由用户提供的。然后,类通过基本的获取函数提供所需的信息。例如,get_start_index() 返回起始状态的索引,而 get_neighbors() 列出邻近的状态。get_cost() 函数使用两个节点之间的实际边权重,如果没有这样的边,则返回无穷大。

get_heuristic() 函数假设节点的坐标存储在其标签中,以 (*x*, *y*) 元组或列表的形式 ❶,并使用到目标的欧几里得距离作为启发式 ❷(这要求代码包含 import math)。在创建图时,我们还需要将节点的标签设置为包含这些坐标。

使用 World 类,我们创建了一个修改版的 A* 搜索,该版本动态地分配和填充数据结构。为了简化,我们使用字典将每个状态的索引(或字符串)直接映射到相应的信息,如 清单 8-2 所示。

def astar_dynamic(w: World):
visited: dict = {}
last: dict = {}
cost: dict = {}
pq: PriorityQueue = PriorityQueue(min_heap=True)
visited_goal: bool = False

❶ start: int = w.get_start_index()
visited[start] = False
last[start] = -1
pq.enqueue(start, w.get_heuristic(start))
cost[start] = 0.0

while not pq.is_empty() and not visited_goal:
    index: int = pq.dequeue() visited[index] = True
    visited_goal = w.is_goal(index)

  ❷ for other in w.get_neighbors(index):
        c: float = w.get_cost(index, other)
        h: float = w.get_heuristic(other)

      ❸ if other not in visited:
            visited[other] = False
            cost[other] = cost[index] + c
            last[other] = index
            pq.enqueue(other, cost[other] + h)
      ❹ elif cost[other] > cost[index] + c:
            cost[other] = cost[index] + c
            last[other] = index
            pq.update_priority(other, cost[other] + h)

return last 

清单 8-2:一个针对未知图的 A* 搜索

清单 8-2 中的代码定义了一个修改版的 astar_search() 函数(见 清单 8-1),即 astar_dynamic()。这个函数创建空的辅助数据结构并将起始状态插入到每个数据结构中 ❶。字典的使用意味着我们不需要了解总状态的数量或它们的底层索引。在这一点上,数据结构仅包含那个单一状态的信息,因为代码尚未探索其他任何状态。它通过 get_start_index() 函数获取起始状态的索引,并通过 get_heuristic() 函数获取其估计成本(优先级)。

列表 8-2 算法使用一个while循环来探索优先队列中的状态,直到没有更多状态可以探索或找到了目标。在每次迭代中,算法会出队最有前景的状态(index),将其标记为已访问,并使用is_goal()函数检查它是否为目标。在现实世界中,这可能类似于进入一个新城市并寻找熟悉的地标。

对于每个探索的状态,代码使用get_neighbors()函数检查所有邻居,以返回该状态的局部邻域❷。然后,代码计算从当前节点到该邻居的代价(c),使用get_cost()函数返回边的权重。类似地,它通过get_heuristic()函数动态计算该邻居的启发式值(h)。

一旦获取到邻居的距离和该邻居的启发式值,代码会检查是否之前遇到过该邻居状态。它通过测试是否在visited字典中有任何值的条目来检查该状态是否出现过❸。如果邻居状态不在字典中,说明它之前从未出现过,将其添加到每个数据结构中。邻居状态的代价是到达当前状态的代价(cost[index])加上下一状态转换的代价(c)。邻居状态的优先级是到达该状态的最佳代价(cost[other])加上该状态的启发式估算值(h)。

如果邻居状态之前已经出现过❹,代码会通过比较邻居的代价(cost[other])与新路径的代价(cost[index] + c)来检查新路径是否更优。如果代码发现了更好的路径,它会更新到达该状态的路径(last)、到达该状态的代价(cost)以及该状态的优先级。再次强调,代码会直接更新队列中的优先级。

#### 一个例子

让我们将astar_dynamic()函数应用到图 8-1 中的图形。自然,算法并不知道图形的具体信息,也不知道图中有多少个节点。所有代码能够看到的,仅仅是由World类提供的内容。

我们可以通过在标签中加入节点的空间位置来扩展我们在图 8-3 和图 8-5 中的贪心算法和 A*示例,如以下代码所示:

g.nodes[1].label = [0, 2]


虽然这种手动分配方法在设置这个示范例子的过程中是有效的,但对于大量数据点而言,它会导致一个冗长且繁琐的过程。在附录 A 中,我们讨论了程序化方法来读取图形数据。

图 8-8 展示了搜索过程。在图 8-8(a)中,算法知道初始状态的索引为 0,并且知道目标状态的存在。然而,它对图形的其他部分一无所知,包括节点 0 的边。目标节点甚至还没有编号,因为搜索还没有遇到它。

当搜索访问节点 0 时,如图 8-8(b)所示,它发现了三条通向三个邻居的边。每条边的权重由World类的get_cost()函数提供,启发式值则由该类的get_heuristic()函数提供。虽然这些信息并不多,但足以让我们构建出关于起始状态周围环境的图像。搜索算法通过在其辅助数据结构(visited、last、cost和pq)中新增对应节点的条目来处理这些信息。它并没有显式地创建图形或存储边。

该搜索遵循与图 8-5 中的 A*搜索示例相同的步骤。不同之处在于,图 8-8 中搜索算法在每一步时对图形的了解不同。它仅能在访问节点时保证已经看到该节点的所有边。例如,尽管算法在几次迭代中已经知道节点 1 和节点 4,但直到图 8-8(d)中,访问节点 1 后,才知道它们之间的边。

![每个子图展示了部分图形,同时显示了排序的优先队列、最后的数组和成本数组。(B)展示了五个节点,其中节点 0 被阴影标记并圈出,且通过边连接到节点 1、2 和 3,而节点 G 未连接任何边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f08008.jpg)

图 8-8:在未知图形上进行 A*搜索算法的步骤

与之前的 A* 搜索示例一样,代码继续执行,直到访问到目标节点。此时,World 类的 is_goal() 函数返回 True,我们知道已经找到了最短路径。然而,如 图 8-8(f) 所示,这并不意味着搜索已经遍历了整个图。它不仅跳过了访问节点 3 和 5,而且也没有了解它们之间的边缘。那些节点之外可能还有一个分支的整个世界。

### 为什么这很重要

贪心最佳优先搜索和 A* 搜索提供了将启发式估计纳入搜索算法的机制,帮助我们找到两个节点之间的最佳路径。贪心最佳优先搜索简单,所需跟踪的信息很少,但可能产生非最优路径。通过结合一个可接受的(乐观的)启发式方法和对目前成本的良好记录,A* 搜索能够高效地选择要探索的节点,同时保证找到最低成本路径。

这些算法的主要优点,特别是 A* 算法, lies in the use of heuristic information to focus the search. 就像 GPS 坐标可以帮助我们确定两条可能的道路中哪一条更快到达目的地一样,启发式方法允许我们优先考虑接下来探索的节点。因此,A* 搜索是一种实用的算法,已成为人工智能和视频游戏路径规划的核心算法之一。

在本书的下一部分,我们将不再讨论搜索算法,而是研究与图的连通性相关的任务。我们回顾如何在有向图中对节点进行排序,考虑如何测试图的连通性,并研究图上的随机行为。许多这些算法将搜索作为核心组件。




# 第三部分 连通性与排序






## 第九章:9 拓扑排序



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

除了它们的物理对应物(如单行道)外,我们还可以在图中使用有向边来指定节点之间的*依赖关系*或*排序顺序*。指向节点的入边表示必须在到达当前节点之前完成的动作。而出边指向的是通过完成当前节点后能够启用的动作。

考虑一个制作巧克力曲奇饼干的食谱示例。每个节点代表食谱中的一个步骤,包括诸如“添加面粉”和“搅拌混合物”等指令。某些步骤有明确且不可更改的顺序。我们不希望在往碗里加入任何东西之前就搅拌混合物,也不希望在没有测量之前就加入配料。

本章介绍了*拓扑排序*算法,它按照有向边指定的顺序对图的节点进行排序。在讨论了拓扑排序背后的概念,并展示了几个实际应用案例和排序算法后,我们将探讨无环图对拓扑排序的重要性,并考虑为什么说明书不是以图的形式呈现的。

### 拓扑排序算法是如何工作的

一个拓扑排序算法会找到一个节点的排序,使得如果从节点*u*到节点*v*有一条有向边,则节点*u*必须排在节点*v*之前。换句话说,每条有向边代表一个*依赖关系*。有些图有多个有效的排序。例如,图 9-1 展示了一个具有有效拓扑排序[0, 1, 2, 4, 3]和[0, 2, 1, 4, 3]的图。

![一个包含五个节点和有向边(0, 1)、(0, 2)、(1, 3)、(1, 4)、(2, 4)和(4, 3)的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09001.jpg)

图 9-1:具有两个有效拓扑排序的有向图

拓扑排序的一个关键约束是图必须是*无环*的,意味着图中不能包含任何*循环*。如果一个有向图中存在一条从某节点出发并最终回到该节点的路径,那么该图就包含了一个循环,如图 9-2 所示(这是图 9-1 的稍微修改版)。从节点 1 出发,我们可以通过节点 4 和 3 回到节点 1,路径为[1, 4, 3, 1]。

![一个包含五个节点和有向边(0, 1)、(0, 2)、(1, 4)、(2, 4)、(3, 1)和(4, 3)的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09002.jpg)

图 9-2:带有循环的有向图

含有环的图将无法进行有效的拓扑排序,因为它包含至少一个节点,其路径指向自身。这意味着该节点必须排在排序列表中的前面,但这是显然不可能的。不论我们如何在图 9-2 中排序节点 1、3 和 4,始终会有一条边从后来的节点指向较早的节点。我们称没有环的有向图为*有向无环图*,简称*DAG*。

我们可以通过检查每对节点之间的相对顺序来测试拓扑排序是否有效:

def is_topo_ordered(g: Graph, ordering: list) -> bool:
❶ if len(ordering) != g.num_nodes:
return False

❷ index_to_pos: list = [-1] * g.num_nodes
for pos in range(g.num_nodes):
current: int = ordering[pos]
if index_to_pos[current] != -1:
return False
index_to_pos[current] = pos

❸ for n in g.nodes:
for edge in n.get_edge_list():
if index_to_pos[edge.to_node] <= index_to_pos[n.index]:
return False
return True


代码首先检查排序中是否包含与图相同数量的节点❶。如果不相等,排序无效,函数返回False。接着,一个for循环构建一个反向索引,将每个节点映射到它在排序数组中的位置❷。这使得它能够轻松查找任意两个节点的相对顺序。在此循环中,代码还通过测试index_to_pos是否已被设置,来检查排序中是否存在重复节点。如果发现某个节点出现两次,函数将返回False,表示排序无效。

代码通过一对嵌套的for循环❸遍历每个节点及其所有出边。对于这些有向边中的每一条,代码通过比较节点在ordering中的位置(使用index_to_pos)来检查节点是否按正确顺序排列。一旦发现有任何一对节点顺序错误,函数立即返回False。最后,如果代码顺利通过所有检查,它将返回True。

### 使用案例

拓扑排序在现实世界中有多种应用场景。本节描述了如何将其中几个应用场景表示为图,并对其应用拓扑排序。

#### 代码依赖

程序员通常将大型程序拆分成一系列模块或库,以促进可理解性、可维护性和可扩展性。与其编写一个包含百万行代码的单一文件,程序员可能会创建三个模块:一个用于表示和处理图,另一个用于处理用户界面,第三个用于读取和写入文件。理想情况下,他们会设计这些模块相互依赖,以便能够在整个代码中重用核心库函数。例如,图形库和用户界面代码可能依赖文件模块来读取和写入配置文件。

这样的代码依赖关系意味着代码需要按照特定的顺序处理。我们可以将这些依赖关系表示为一个有向图,其中每个模块是图中的一个节点,每个 import 或 include 语句表示指向该节点的输入边。该图的拓扑排序告诉计算机处理文件的顺序。

#### 任务列表

我们可以将按照特定顺序遵循食谱的重要性扩展到广泛的任务中,从写作到清洁到组装家具。在这些情况下,节点代表任务列表中的步骤,边表示它们之间的依赖关系。例如,我们需要先拿出拖把和水桶,然后才能洗地板。另一个例子是,图 9-3 显示了一个图表,表示制作花生酱果冻三明治的步骤。

![七个框表示制作三明治的步骤。左边是“取两片面包”、“取花生酱瓶”和“取果冻或果酱瓶”的框。“取两片面包”的框指向“将面包放在盘子上”的框。“将花生酱涂抹到一片面包上”的框从“将面包放在盘子上”和“取花生酱瓶”这两个框中各有一条箭头。“将果冻涂抹到另一片面包上”的框从“将花生酱涂抹到一片面包上”和“取果冻或果酱瓶”这两个框中各有一条箭头。“将涂有花生酱的面包放在涂有果冻的面包上,做成三明治”的框从“将果冻涂抹到另一片面包上”的框指向。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09003.jpg)

图 9-3:制作花生酱果冻三明治的任务图

尽管大多数说明手册是按线性顺序编写的,但顺序上通常是有一定灵活性的,这意味着图形不一定是节点的直线。例如,考虑组装一把预制椅子的过程。你先安装左臂还是右臂可能并不重要。然而,完成椅子的主体再安装座垫可能是至关重要的。

将指令表示为图形显然为并行性提供了机会。理论上,两个人一起做一个食谱可以比一个人更快完成;一个烘焙师可以量取面粉,而另一个量取糖,等等。不幸的是,这种并行表示可能会引发更多问题而不是更少。我个人在使用线性指令时常常忘记自己在哪一步,花费几分钟回想我是否已经把盐加入了碗中。通过任务的分支图跟踪已完成的步骤几乎可以保证我会漏掉某些步骤。

幸运的是,虽然人类很难追踪这种状态,但计算机在这方面表现得非常出色,它们使用图结构来寻找并行性机会。事实上,整个系统就是围绕图的概念构建工作流程的。分布式工作流系统通常是基于任务图的概念构建的,其中多个任务按其依赖关系的顺序执行。此类工作流的设计与优化是一个活跃的研究领域,且这一话题足以单独成书。

#### 教学与学习

许多学科由大量相互关联的概念组成,其中一些必须在继续学习下一个概念之前先理解。例如,数学老师很难在学生还没有学会乘法之前解释指数的概念。然而,其他一些概念可以并行学习。学习外语时,学生可能能够学习与咖啡店相关的词汇,而不必先掌握计算机科学术语。

我们可以将教学或学习概念的推荐顺序指定为一个图。节点表示概念,例如法语中的“咖啡”一词或递归的概念,而边则表示这些概念之间的依赖关系。在计算机科学的情况下,我们可能会将“函数”这一概念与“递归”概念之间加一条边,以表明学生应在学习递归之前了解函数。

基于这种图形表示法,我们可以使用拓扑排序来确定在大学课程中需要先修的课程。请参见图 9-4,该图展示了一个假设的课程先修关系图。

![七个框代表不同的计算机科学课程。CS300:高级算法的框列出了先修课程 CS200 和 CS201,并且这两个框都有指向该框的箭头。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09004.jpg)

图 9-4:表示计算机科学课程先修关系的图

为了修完 CS450:高级人工智能,学生需要完成这门课程的先修链,直到 CS100:编程入门。

### 卡恩算法

计算机科学家 Arthur B. Kahn 提出了一种方法,现在称为*Kahn 算法*,用于在有向无环图上执行拓扑排序。该算法通过找到没有入边的节点,将其从待处理节点列表中移除,添加到已排序列表中,然后删除该节点的出边。算法会重复此过程,直到将所有节点都添加到已排序列表中。

直观上,Kahn 算法的排序方式类似于我们在现实世界中执行复杂任务的方式。我们首先执行一个可以不需要完成其他任何步骤的子任务,然后继续进行另一个子任务。任何需要我们先完成某个任务的行动,必须等到我们完成所有依赖任务后才能进行。

#### 代码

在实现 Kahn 算法时,我们不需要通过删除节点或边来修改正在处理的图。相反,我们可以使用额外的数组count来跟踪每个节点的入边数量,并在我们“删除”一个节点时减少这些计数。由于我们不需要修改图的数据结构,因此可以避免进行初始复制、重新添加删除的边或使结构发生变化。

Kahn 算法的代码使用了栈和多个循环,如在列表 9-1 所示。

def Kahns(g: Graph) -> list:
count: list = [0] * g.num_nodes
s: list = []
result: list = []

❶ for current in g.nodes:
for edge in current.get_edge_list():
count[edge.to_node] = count[edge.to_node] + 1
for current in g.nodes:
if count[current.index] == 0:
s.append(current.index)

❷ while len(s) > 0:
current_index: int = s.pop()
result.append(current_index)
❸ for edge in g.nodes[current_index].get_edge_list():
count[edge.to_node] = count[edge.to_node] - 1
if count[edge.to_node] == 0:
s.append(edge.to_node)
return result


列表 9-1:Kahn 算法的拓扑排序

代码首先创建了算法所需的辅助数据结构。数组count存储每个边的*入节点*计数,并将用于检查没有任何入边的节点。栈s(实现为列表)存储没有入边的未处理节点的索引。这些节点就是代码可以从图中删除的节点。最后,列表result将保存拓扑排序后的节点索引列表。

该代码使用了一对嵌套的for循环来计算每个节点的入边数量❶。在第一个循环中,由于算法在处理有向图,代码必须遍历所有节点(外层循环)及其出边(内层循环),并增加入边目标节点的计数(to_node)。接下来的for循环遍历count数组,查找没有入边的节点(count[current.index] == 0),并将它们的索引插入到栈s中。此时,函数已经设置了所有需要的初始信息,可以执行拓扑排序了。

代码的主体是一个while循环,处理堆栈中的每个项目 ❷。在每次迭代中,代码从s中弹出一个节点索引。它检索相应的节点,并将该索引添加到result数组的末尾。然后,函数通过遍历每条出边并减少指向目标的边的计数,虚拟地移除该节点及其出边 ❸。在此过程中,它检查是否有任何节点的传入边数降至零,如果是,则将该节点的索引添加到堆栈中。代码通过使用return result返回排序后的节点索引数组来完成。

每次迭代都集中于单个节点及其出边,这意味着算法的运行时间与节点数加上出边数成线性关系。

#### 示例

图 9-5 展示了如何在示例图上运行 Kahn 算法,其中每个子图表示算法进展的一个步骤。图中显示了每个节点上方传入边的计数(count中的值),并且移除的节点和边被灰显。

算法基于输入图初始化传入边的计数,并将没有任何传入边的两个节点(节点 0 和 1)存入堆栈,如图 9-5(a)所示。在图 9-5(b)中的排序的第一步,算法从堆栈中取出顶部元素(节点 1),并将其及其出边“移除”出图,从而减少节点 3 和 4 的计数。由于没有目标节点的计数减少到零,代码因此不会将它们添加到堆栈中。

在图 9-5(c)中,排序继续,通过从堆栈中弹出节点 0 并移除其到节点 2 和 3 的边。这样,节点 2 和 3 的传入边计数都降至零,允许将它们添加到堆栈中。该过程逐个节点地继续,直到堆栈中的每个节点都被处理完毕。

![Kahn 算法的七个步骤。(A) 显示了一个有向图,其中包含边 (0, 2),(0, 3),(1, 3),(1, 4),(2, 4),(3, 4) 和 (4, 5)。在 (B) 中,节点 1 及其到节点 3 和 4 的边被灰显。到节点 3 的计数为 1。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09005.jpg)

图 9-5:在示例图上运行 Kahn 算法

我们可以将卡恩算法想象成一个小心翼翼的烘焙师按照复杂食谱的过程。开始之前,烘焙师把所有任务及其必要的前提任务写下来。任务 5a,“添加两杯面粉”必须在任务 4,“在大碗中混合湿性原料”和任务 1a,“量取两杯面粉”之后完成。然而,烘焙师可以在任务 5a 之前或之后完成任务 5b,“加入一汤匙小苏打”,因为面粉和小苏打的添加顺序无关紧要。烘焙师会计算每个任务的前提任务数量,并将这些数字写在各自任务旁边。

烘焙师首先选择一个没有未完成前提任务的任务,执行该任务并在列表中勾选。然后,他们会遍历任务列表,并更新所有依赖于刚完成的任务的未来任务的未满足前提任务数量。例如,在量取了两杯面粉之后,他们可以将任务 5a,“添加两杯面粉”的依赖计数从 2 更新为 1——他们已经量取了面粉,但仍需要混合湿性原料才能继续。完成任务 4,“在大碗中混合湿性原料”后,他们可以将任务 5a 添加到下一步的任务列表中。

### 深度优先搜索

除了卡恩算法,另一种对有向无环图(DAG)中的节点进行排序的方法是使用深度优先搜索这一多功能工具。深度优先搜索从给定的节点*u*开始,然后按拓扑顺序探索*u*之后的节点。我们可以修改深度优先搜索,跟踪每个节点处理完成的顺序。通过保存搜索完成每个节点探索的顺序,深度优先搜索可以重建节点的*逆*排序。拓扑排序中的最后节点将在深度优先搜索中最先完成,因此会出现在列表的开头。

#### 代码

基于深度优先搜索的拓扑排序代码在很大程度上与我们在本书中使用的其他深度优先搜索实现相似,如 Listing 9-2 所示。然而,我们维护了一项额外的信息:一个列表s来跟踪搜索完成访问每个节点的顺序。

def topological_dfs(g: Graph) -> list:
seen: list = [False] * g.num_nodes
s: list = []
❶ for ind in range(g.num_nodes):
if not seen[ind]:
topological_dfs_recursive(g, ind, seen, s)
❷ s.reverse()
return s

def topological_dfs_recursive(g: Graph, index: int, seen: list, s: list):
seen[index] = True
current: Node = g.nodes[index]
for edge in current.get_edge_list():
neighbor: int = edge.to_node
if not seen[neighbor]:
topological_dfs_recursive(g, neighbor, seen, s)
❸ s.append(index)


Listing 9-2:用于拓扑排序的深度优先搜索算法

列表 9-2 中的代码由两个函数组成。外部的 topological _dfs() 函数设置数据结构,调用深度优先搜索来从不同的起始节点开始,并处理结果。它首先创建一个空列表 s 和一个包含所有元素初始值为 False 的列表 seen。然后,函数遍历每个节点。如果发现一个尚未访问的节点,函数从该节点开始进行深度优先搜索 ❶。最后,函数将节点索引列表按相反的顺序输出到结果列表中 ❷。

接下来的内部 topological_dfs_recursive() 函数是一个递归实现的深度优先搜索,唯一的修改是:它将每个已完成的节点附加到一个列表中。此函数首先将当前节点标记为已访问,然后通过边列表迭代邻居,并递归地探索任何未访问的邻居。最后,它将当前节点的索引插入到列表 s 的末尾,以跟踪其完成访问节点的顺序 ❸。

#### 示例

图 9-6 展示了一个通过深度优先搜索进行拓扑排序的示例,其中当前正在访问的节点由虚线圈出,已访问的节点被着色。

搜索从 图 9-6(a) 中的节点 0 开始,并在 图 9-6(b) 中探索节点 2,图 9-6(c) 中的节点 4,以及 图 9-6(d) 中的节点 5。在每个节点上,算法将节点标记为已访问,并递归地探索其未访问的邻居。直到搜索完成一个节点的处理并回溯到其前驱节点时,才将该节点插入到排序后的列表中,正如在 图 9-6(d) 所示,搜索在节点 5 处遇到死胡同,并被迫回溯。将一个节点插入反向排序的列表意味着该节点必须位于所有先前被搜索的节点之后。

![带有有向边(0, 2),(0, 3),(1, 3),(1, 4),(2, 4),(3, 4)和(4, 5)的图的深度优先搜索算法的十二个步骤。在(D)中,节点 0、2、4 和 5 被灰色标出,节点 5 被圈出。在(E)中,节点 4 被圈出,列表 s 现在包含了 5。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09006.jpg)

图 9-6:在示例图上运行深度优先搜索以进行拓扑排序

当搜索回溯到图 9-6(e)中的节点 4 和图 9-6(f)中的节点 2 时,它会检查是否有其他的出边。若没有,它将当前节点添加到列表中并回溯。当它返回到图 9-6(g)中的节点 0 时,深度优先搜索可以继续进行,然后再次回溯。它递归地探索图 9-6(h)中的节点 3。

如图 9-6(j)所示,在初始深度优先搜索完成后,我们可能还没有结束。节点 1 不在从节点 0 出去的任何路径上,因此尚未被探索。搜索继续进行,通过检查所有节点是否都已被访问,如果没有,则从那个未被访问的节点开始深度优先搜索。在算法结束时,搜索已访问所有节点,并按照逆拓扑顺序列出了节点,如图 9-6(l)所示。

我们可以将这个搜索过程想象成一个大学生规划要修的系列课程。他们将要修的课程列为图中的节点,并使用有向边表示课程的先修关系。深度优先搜索从一个节点开始,询问:“这门课程会让我修哪些课程?”当搜索到死胡同时,学生就知道他们找到了一个没有任何课程作为先修的课程。

返回到图 9-4 中的课程列表,考虑一下当学生从 CS200 开始并沿着路径通过 CS350 到 CS450 时会发生什么。CS450 已经完全探索过,因此他们转向未探索的课程列表。学生返回到 CS350,它不是任何其他课程的先修课程,并将其添加到列表中。最终,他们建立了[CS450, CS350, CS300, CS201, CS200]的列表。然后他们继续访问下一个未访问的课程(可能是 CS100),并继续完善他们的学习计划。

#### 起始节点的顺序

深度优先搜索方法在拓扑排序中的一个反直觉之处在于,topological_dfs()基函数在清单 9-2 中根据图中每个节点的索引开始递归搜索。它不会根据节点的入边数或其他任何位置特征对节点进行排序。

这就导致了搜索可能从一个有入边的节点开始的情况,就像图 9-7 中的图一样。毕竟,我们自然地认为深度优先搜索是从节点链的起点开始的。

![一个包含四个节点和三条边的图:(1,0),(0,2)和(3,2)](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09007.jpg)

图 9-7:一个图,其中节点 0 有一个入边连接

幸运的是,深度优先搜索在节点 0 不是拓扑排序起点的情况下仍然能够完美工作。在图 9-7 中,深度优先搜索算法将从节点 0 开始,并在初始递归深度优先搜索中找到节点 0 和节点 2。在第一次递归深度优先搜索结束时,包含反向排序的列表为[2, 0]。虽然看起来我们在跳过节点 1 时犯了一个错误,但我们将在下一次搜索中将其添加到正确的位置。

接下来,算法从节点 1 开始搜索,并将其添加到列表的末尾。由于列表是*反向*拓扑排序的,节点 1 在节点 0 之后的位置是正确的。在从节点 1 开始搜索后,列表变为[2, 0, 1]。当从节点 3 开始搜索时,列表变为[2, 0, 1, 3]。当函数结束时,它使用 Python 的reverse()函数反转列表,并返回正确的排序[3, 1, 0, 2]。

### 检测环路

如前所述,拓扑排序的一个关键限制是图必须是无环的。考虑图 9-8 所示的假设课程顺序。所有学生必须首先选修 CS100:编程导论,该课程没有先决条件。然而,下一学期情况变得更加复杂。为了覆盖更多的内容,CS200:算法导论的讲师希望学生掌握编程和图的基础知识。因此,他们要求 CS100 和 CS202:图论导论作为先修课程。同时,CS202 的讲师要求学生掌握数据结构,因此要求 CS201:数据结构导论作为先修课程。不幸的是,CS201 的讲师希望学生已经掌握基本算法,因此他们要求 CS200 作为先修课程。

![五个框代表不同的计算机科学课程。“CS200:算法导论”的框列出了先修课程 CS100 和 CS202,并从这两个框有指向的箭头。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09008.jpg)

图 9-8:带有环路的课程先修关系

当学生完成 CS100 后,他们查看自己现在可以修的课程,并遇到一个问题。每一门 200 级课程都要求另一门不同的 200 级课程作为先修课程。学生没有可以修的 200 级课程,而不需要先修其他课程。

我们可以调整本章中介绍的算法来检测图中是否存在循环,这为审核学校要求、使用手册、食谱或任何其他表示事件序列的图形提供了有用的工具。一种简单的方法是观察当我们在有循环的图上运行列表 9-1 的 Kahn 算法时会发生什么。该算法依赖于当它移除所有前置节点后,输入连接数降至零。为了移除一个属于循环的节点,该算法首先需要移除它的前驱节点,包括该节点本身。因此,循环中的节点的计数永远不会降至零,该节点也永远不会被加入到排序列表中。因此,我们知道,如果算法返回的列表不包含图中的所有节点,至少有一个节点必须是循环的一部分,因此无法移除。

我们将 Kahn 算法封装在一个名为check_cycle_kahns()的函数中:

def check_cycle_kahns(g: Graph) -> bool:
result: list = Kahns(g)
if len(result) == g.num_nodes:
return False
return True


此检查的代码需要一个额外的if语句来测试返回列表的长度。如果列表的大小与图相同,则代码返回False表示没有循环。否则,返回True。

### 重新排序列表

让我们考虑一个使用*拓扑排序*的任务:重新排序一个具有前向依赖关系的项目列表。作为示例,我们将使用拓扑排序来完成一个实际任务:对选择你自己冒险书的页面进行排序,以便你永远不需要翻回去。

如图 9-9 所示,我们可以将一个包含*N*页的选择你自己冒险书籍可视化为一个具有*N*节点的图。由于读者必须大部分连续翻页才能跟随各个故事情节,因此大多数页面的对应节点都有一个来自上一页的输入边和一个指向下一页的输出边。然而,使这些书籍充满趣味的是决策点。图 9-9 展示了围绕页面*k*的过渡。读者有两个选择:他们可以翻到页面*i*去探索鬼屋,或者翻到页面*j*去攀爬被雨水浸湿的悬崖。

![一个线性图。节点 k 有来自节点 K-1 的输入边和指向节点 i 和 j 的输出边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09009.jpg)

图 9-9:表示选择你自己冒险书籍的页面顺序的图表

拓扑排序使我们能够将页面重新排列为故事顺序,如图 9-10 所示。故事从第 1 页开始。叙事路径从左到右延伸。在决策点处,它们会分支开来,其中一些决定导致了不幸的早期结局(由阴影节点表示)。

![一个所有边都指向右侧节点的图,决策点分支到更高或更低的节点行。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09010.jpg)

图 9-10:将书籍重新组织为故事顺序

我们可以使用本章中的两种算法来为我们排序。作为输入,我们获取一个列表的列表,该列表将每一页映射到其对应的选项。列表 [[1], [3, 4], [-1], [-1], [2]] 表示一个五页的故事,列表中的索引对应当前页面。页面 0 确定性地指向页面 1。页面 1 有一个选项可以继续到页面 3 或 4。页面 2 和 3 都表示故事的结束(由 -1 表示)。最后,页面 4 确定性地返回页面 2。

Listing 9-3 显示了通过将列表转换为图形来排序故事的代码,并且为了本示例的目的,调用了 Kahn 算法。

def sort_forward_pointers(options: list) -> list:
num_nodes: int = len(options)
g: Graph = Graph(num_nodes)
for current in range(num_nodes):
for next_index in options[current]:
if next_index != -1:
g.insert_edge(current, next_index, 1.0)
❶ return Kahns(g)


Listing 9-3:排序前向指针

代码为每一页创建一个节点。然后,使用一个 for 循环迭代每一页,第二个 for 循环迭代该页的所有外部选项。它检查该页是否表示终结状态(next_index == -1);如果不是,代码会向故事顺序中的下一页添加一条边。最后,代码调用 Kahn 算法来执行拓扑排序并返回结果 ❶。(或者,代码可以使用 Listing 9-2 中的 topological_dfs()。)

作为该代码的示例实现,我们将其应用于 图 9-11 中显示的 10 页冒险故事。

![一个包含 10 个节点和边 (0,1),(1, 2),(2, 4),(2, 6),(6, 7),(4, 5),(5, 3),(5, 8),(8, 9) 的图。节点 3、7 和 9 被阴影标记。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f09011.jpg)

图 9-11:一个包含 10 页的故事图

我们将选项表示为列表的列表,选项 -1 表示叙事线的结束,无论它是积极还是消极的结局:

[[1], [2], [4, 6], [-1], [5], [3, 8], [7], [-1], [9], [-1]]

当我们通过 Listing 9-2 中的 sort_forward_pointers() 函数运行输入时,代码返回以下排序:

[0, 1, 2, 6, 7, 4, 5, 8, 9, 3]

将这个结果与图 9-11 进行比较,我们可以看到,如果我们将页面顺序重新排列,从第 0 页开始,接着翻到第 1、2、6 页,以此类推,那么在跟随叙事线的过程中,我们就不需要向后翻页。

虽然排序选择自己冒险故事书可能不是你在日常工作流程中需要处理的典型问题,但很容易从这个例子中推断,并将相同的技巧应用于其他使用场景。你可以简单地从正向指针(例如选择自己冒险故事书或食谱)或反向指针(如课程先修条件或代码依赖关系)构建依赖图。

### 为什么这很重要

拓扑排序展示了如何使用图中的有向边来强制执行更抽象的约束条件,比如物品的顺序。我们可以通过将物品建模为节点,将它们之间的依赖关系建模为有向边,将一系列依赖和排序问题转化为图。

正如本章所示,拓扑排序在现实世界中有一系列的应用场景。我们在日常生活中常常不自觉地执行拓扑排序。在我们煮咖啡之前,我们会先往水壶里加水。我们知道这一系列操作的正确顺序,当然也无需将其表示为图。然而,将拓扑排序转化为图问题,可以大大扩展我们能够使用该算法解决的问题类型。例如,编译器可以使用拓扑排序来确定在一个项目中编译数千个源文件的顺序。基于云的工作流系统也可以使用拓扑排序来确定下一步执行的计算。一旦你开始注意它,拓扑排序就在计算领域和日常生活中随处可见。知道如何建模这些问题并排序任务,是实施高效解决方案的第一步。

下一章将讨论图中的连通性,以及如何选择一组边使得图完全连通。具体来说,我们将研究如何找到一组最小成本的边,使得所有节点都能连接。




## 第十章:10 最小生成树



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

加权无向图的*最小生成树*是连接所有节点的边集,并且具有最小的总权重。我们可以用这一概念来建模和优化多种现实世界中的问题,从设计电网到推测松鼠应该如何构建它们的洞穴。

本章介绍了构建最小生成树的两种经典算法。普里姆算法是一种逐步聚合的节点算法,它通过构建越来越大的连接节点集合来实现。克鲁斯卡尔算法通过从排序后的边列表中逐一添加边来构建最小生成树。

在讨论最小生成树如何应用于多个现实世界问题之后,我们考虑与最小生成树紧密相关的另外两个算法:基于网格的迷宫生成和单连接聚类。我们展示了这些任务如何映射为图问题,并通过本章的算法变种进行求解。

### 最小生成树的结构

一个图的*生成树*是连接图中所有节点的边集,并且不形成任何环。我们可以将生成树想象为现实世界基础设施网络的骨干——连接每个节点的最少连接,使得任何节点都可以从其他节点到达。这些可能是电力线、道路、计算机网络中的链接,或者是松鼠洞穴中的通道。*最小生成树*是连接所有节点的边集,同时最小化边权的总和。

我们可以通过一个特别有组织的松鼠洞穴来形象化这些要求,如图 10-1 所示。松鼠将它们的领域构建为一系列通过隧道(边)连接的洞穴(节点)。就像在图中一样,每条隧道直接连接着两个洞穴,并且是一条直线。松鼠设定了两个额外的要求。首先,每个洞穴到地表需要通过隧道从任何其他洞穴到达。毕竟,如果多个入口不能让你从一个洞口消失然后从另一个洞口出来,那还有什么用呢?其次,隧道的总距离必须最小化。松鼠很懒,宁愿随机地在不同地点从地面冒出来,而不愿挖新的隧道。

![一个包含五个节点和四条边的图,排列在一个二维平面上。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10001.jpg)

图 10-1: 五个松鼠洞穴连接成最小生成树

正式定义,在一个加权无向图中寻找最小生成树的问题如下:

给定一个由节点 *V* 和边 *E* 组成的图,找到一个边集 *E*′ ⊆ *E*,它连接图中每个节点,同时最小化边权重的总和 *∑*e [∈] E[′] *weight*(*e*)*。

按照定义,最小生成树将拥有|*V*| – 1 条边,这是连接|*V*|个节点所需的最少边数。任何更多的边都会产生环路和不必要的重量。

### 使用案例

本节介绍了一些使用最小生成树概念设计高效物理网络或优化社交网络通信的实际案例。

#### 物理网络

最小生成树在确定我们需要构建的、以最低成本完全连接物理网络的链接集时非常有用。想象一下,算法咖啡公司计划为其各个地点之间传送咖啡豆构建一套最先进的气动管道系统。在承诺提供超过 10,000 种咖啡后,公司很快意识到一些地点缺乏足够的存储空间来存放如此多种类的咖啡豆。因此,公司决定建立一个中央仓库,根据需求将少量咖啡豆运送到每个商店。每个商店现在将拥有无与伦比的咖啡选择。

规划人员很快意识到,从每个商店到仓库建立气动管道的费用过高。位于 Javaville 的两家商店距离配送中心超过 10 英里,但彼此相隔仅两个街区。通过从配送中心到 Main Street 位置再到 Coffee Boulevard 位置建立一条管道要便宜得多。对 Coffee Boulevard 位置的请求可以通过先将咖啡豆发送到 Main Street 位置,然后再转发到 Coffee Boulevard 来满足。

这种多步骤路由将气动传递系统的设计转化为一个最小生成树问题,如图 10-2 所示。算法咖啡公司每个建筑物都是一个节点,任何一对商店之间的潜在管道都是一条边。

![一张城市地图,上面有三条垂直街道和两条水平街道。两家咖啡店的位置分别位于第一条垂直街道(Coffee Boulevard)和底部水平街道的右侧部分(Main Street)。两家咖啡店之间有一条边,箭头指向图外,标注为“前往配送中心”。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10002.jpg)

图 10-2:最小生成树交付网络中的两家咖啡店

在图 10-2 中,边的权重是建造两栋建筑之间气动管道的成本。虽然通常与距离有关,但成本也可能因环境因素而增加。例如,在城市中心修建一条管道比在农田下修建相同长度的管道要昂贵得多。规划者需要找到一组边(需要建造的管道),以便连接所有建筑物并最小化成本。

除了气动咖啡管外,最典型的最小成本生成树在物理网络中的应用包括以下几种:

**构建高速公路** 节点是城市,边是高速公路,边的权重是建造两点之间高速公路的成本。

**电力网** 节点是城市,边是传输线路,边的权重是建造两点之间传输线路的成本。

**跨岛桥梁** 节点是群岛中的岛屿,边是两岛之间的实际桥梁,边的权重是建造两岛之间桥梁的成本。

**航空网络设计** 节点是机场,边是航班,边的权重是两机场之间飞行的成本。

#### 社交网络

最小生成树也适用于非物理网络。例如,想象一个不相信群发邮件的“数据结构专家个人通信协会”。这种公告方式过于冷漠。相反,组织者坚持要求每个消息必须通过成员之间的个人电话传递。然而,像任何由专家组成的组织一样,成员之间有着各种各样的旧友谊和恩怨。去年,艾丽斯·哈希表与鲍勃·二叉搜索树发生了争执,之后他们不再交谈。

每年,组织都会开发一个复杂的电话树,允许该组织在最小化成员不适感的同时传播即将举行的会议的消息。每个成员都表示为一个节点,并与其他成员通过边相连。边的成本是两个成员之间交谈时的不适感。在最佳情况下,即朋友之间的聊天,权重最小,仅表示电话通话的时间成本。然而,在最坏情况下,两名有恩怨的成员之间的成本会导致数天的生产力损失和愤怒的抱怨。组织需要找到一组配对通信,使每个成员都能收到会议的相关信息,同时最小化整体的不安。这要求所有节点通过最少数量和成本的边进行连接。

### 普里姆算法

构建最小生成树需要一种算法,从完整的图中选择一个最小代价的边集,使得生成的图是完全连接的。找到图的最小生成树的一种方法是*普里姆算法*,该算法由包括计算机科学家 R.C. 普里姆和数学家 Vojteˇch Jarník 在内的多位学者独立提出。该算法与第七章中的 Dijkstra 算法非常相似,都是通过遍历一个未访问的节点集,并逐个节点地构建最小生成树。

普里姆算法从一个未访问的所有节点集合开始,任意选择一个节点进行访问。这个已访问的节点构成最小生成树的起点。在每次迭代中,算法会找到与它已经访问过的节点中某一个节点的边权最小的未访问节点,并问:“哪个节点离我们已连接集合的边界最近,且可以以最小代价添加进来?”算法将这个新节点从未访问集合中移除,并将相应的边添加到最小代价生成树中。它不断地在每次迭代中添加节点和边,直到访问了所有的节点。

普里姆算法最多访问每个节点一次,并且最多考虑每条边两次(每个端点各一次)。此外,对于每个节点,我们可能会看到与|*V*|的对数成比例的成本,来插入或更新在标准堆中实现的优先队列中的节点。因此,算法的总成本按(|*V*| + |*E*|) × log (|*V*|)的复杂度增长。

我们可以将普里姆算法想象成一个建设公司,受雇于一个群岛之间的桥梁升级项目。该公司计划将连接群岛的腐朽木桥替换为现代化的桥梁。由于旧的木桥无法承受施工设备的重量,因此从公司角度来看,只有通过新桥连接的岛屿才算是“真正连接”。他们的合同要求,最终任何一对岛屿都必须通过新建的现代化桥梁相互可达。

建设者从一个岛屿开始,向外扩展,逐渐用新的桥梁连接更多岛屿。每一步,他们选择升级连接当前已连接岛屿与外部岛屿之间的最短木桥。通过始终从已连接岛屿开始新桥的建设,建设者可以用现代桥梁将设备运送到新边的起点。通过始终在未连接岛屿上结束桥梁,建设者在每一步都增加了已连接岛屿集的覆盖范围。

#### 代码

在 Prim 算法的每一步中,我们跟踪未连接的节点以及连接它们的最佳边权重。我们通过使用自定义的PriorityQueue实现来维护这些数据,该实现提供了一种高效的机制来查找队列中的值并修改优先级。为了理解这段代码,你只需要掌握向优先队列中插入项、从优先队列中移除项以及修改优先级的基本知识。然而,如果你感兴趣,可以在附录 B 中查看详细信息。

代码本身会遍历优先队列中的节点,直到队列为空。每次从优先队列中移除一个新节点(即未访问的节点),它会检查该节点的未访问邻居,并判断当前节点是否为任何未连接的邻居提供了更好的(即更低成本的)边。如果是,它就会用新的边和权重更新邻居的信息:

def prims(g: Graph) -> Union[list, None]:
pq: PriorityQueue = PriorityQueue(min_heap=True)
last: list = [-1] * g.num_nodes
mst_edges: list = []

❶ pq.enqueue(0, 0.0)
for i in range(1, g.num_nodes):
pq.enqueue(i, float('inf'))

❷ while not pq.is_empty():
index: int = pq.dequeue()
current: Node = g.nodes[index]

  ❸ if last[index] != -1:
        mst_edges.append(current.get_edge(last[index]))
    elif index != 0:
        return None

  ❹ for edge in current.get_edge_list():
        neighbor: int = edge.to_node
        if pq.in_queue(neighbor):

            if edge.weight < pq.get_priority(neighbor):
                pq.update_priority(neighbor, edge.weight)
                last[neighbor] = index

return mst_edges 

这段代码首先创建了三个辅助数据结构,包括一个基于最小堆的未连接节点优先队列(pq)、一个表示在给定节点之前最后访问节点的数组(last),以及最小生成树的最终边集(mst_edges)。这段代码需要导入在附录 B 中定义的自定义PriorityQueue类,以及从 Python 的typing库中导入Union。

所有节点在算法开始时都会被插入到优先队列中❶。起始节点(0)被赋予优先级 0.0,其他节点则赋予无限优先级。接着,代码按照类似于 Dijkstra 算法的方式,逐一处理未访问的节点。一个while循环会一直运行,直到未访问节点的优先队列为空❷。在每次迭代中,会选择与任何已访问节点距离最小的节点,并从优先队列中出队。如我们将看到的,这有效地将该节点从未访问的节点集中移除。

接下来,代码检查是否存在一条返回到连通集合中某个节点的边 ❸。有两种情况可能导致节点的last条目为-1。第一种情况是节点 0,由于首先被探索,因此没有前驱节点。第二种情况是断开分量,其中index无法从节点 0 到达。在后一种情况下,由于所有节点无法连接,图就没有最小生成树,函数返回None。

在将新节点添加到已访问集合(通过出队操作)后,for循环遍历每个节点的邻居 ❹,检查邻居是否未被访问(仍在优先队列中)。如果是,代码将通过比较先前最佳边的权重与新边的权重,来检查是否找到了到该节点的更好边。代码最后通过返回组成最小生成树的边集来结束。

请注意,如果图是断开的,每个连通分量都有自己的最小生成树。与这里呈现的代码的替代方法是返回为每个连通分量创建最小生成树的边的列表。我们可以通过删除elif检查 ❸及其对应的返回语句来实现这一点。然后,代码将通过从优先队列中选择一个节点并继续选择边来处理下一个分量。

#### 一个例子

图 10-3 展示了 Prim 算法在一个包含八个节点的图上的示例。每个子图右侧的表格显示了每个节点跟踪的信息,包括节点的 ID、从已连接节点集合到该节点的距离(由节点的优先级存储),以及当前连接子集中的最近节点,该信息存储在last列表中。除了第一个节点外,所有节点的初始距离为无穷大,last节点指针为-1,表示我们尚未找到通向该节点的路径。移除一个节点后,我们将其行标灰色显示,以表示该节点不再被考虑。

搜索从图 10-3(a)中的节点 0 开始。这对应于我们的岛屿桥梁建设公司在其母岛的总部开始运营。搜索从优先队列中移除节点 0,检查其每个邻居,并相应更新信息。节点 1 被分配了 1.0 的距离,节点 3 被分配了 0.6 的距离。两个邻居的last值现在都指向节点 0,作为已连接子集中最近的节点。

在图 10-3(b)中,搜索进展到距离最近且不在已连接子集中的节点。这对应于建立岛屿之间的第一座桥梁。算法从优先队列中取出距离(优先级)为 0.6 的节点 3,将其加入已连接子集,并检查其邻居节点 4 和 6。这两个节点通过节点 3 的边成为新可达节点。搜索更新了这两个节点的优先级和last值。

搜索接下来探索图 10-3(c)中的节点 1。在检查节点 1 的邻居时,发现了一个更短的边连接到节点 4。这相当于工人们注意到旧木桥(1,4)比另一个当前计划升级的木桥(3,4)更短,因此升级成本更低。因此,搜索更新了节点 4 的距离为 0.5,并将其last指针更新为节点 1,以反映连接边的起点。搜索现在计划使用边(1,4)将节点 4 加入到我们的已连接集合中,而不是使用之前的边(3,4)。

![每个子图展示了 Prim 算法在一个包含 8 个节点和 12 条加权边的图上的八个步骤。(A)显示了节点 0 被圈出并着色。(B)显示了节点 3 被圈出,节点 0 和 3 都被着色。节点 0 和节点 3 之间的边加粗显示。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10003.jpg)

图 10-3:Prim 算法的示意图

在接下来的五个子图中,搜索依次进行到节点 5、节点 2、节点 4、节点 6 和节点 7,检查每个节点未访问的邻居,并更新发现更短边的节点。每一步,连接的子图大小增加一,直到所有节点都被连接。

### 克鲁斯卡尔算法

普里姆算法的逐节点方法的替代方案是采用基于边的方式来构建最小生成树。克鲁斯卡尔算法由跨学科的学者约瑟夫·B·克鲁斯卡尔发明,其原理是循环遍历一个排序后的边权重列表,逐步添加边以构建最小生成树。直观地,我们希望添加图中较小的边,因为它们是节点之间最便宜的连接。如果我们维护一个按权重排序的边列表,就可以逐步添加下一个有助于构建最小生成树的边。这一排序列表循环遍历的过程就是克鲁斯卡尔算法的核心。

克鲁斯卡尔算法的成本与|*E*| log (|*E*|)成正比。算法首先提取并排序每条边,所需时间与|*E*| log (|*E*|)成正比。使用高效实现的并查集算法,我们可以在|*E*| log (|*V* |)时间内合并集合。只要|*E*| ≥ |*V* |,算法的规模将是|*E*| log (|*E*|)。

我们可以通过将克鲁斯卡尔算法与一位宠物主人为其心爱的仓鼠搭建复杂生活空间的情境进行可视化。仓鼠已经有了几个大栖息地,主人决定用透明管道将它们连接起来,让宠物自由穿梭于各个笼子之间。栖息地在房间内的布局是固定的。为了最小化所需的管道总量,主人测量每一对栖息地之间的距离,排序列表,然后决定接下来添加哪条管道。与岛屿建设的例子不同,宠物主人不需要担心将施工设备从一个节点运送到另一个节点。他们可以轻松地在任意一对节点之间移动来建立连接。

#### Union-Find

除了找到下一个最低成本的边,我们在考虑每条新边时还需要回答一个额外的问题:这条边是否将当前断开的集群连接起来?如果没有,那么这条边就是多余的。记住,关键字是*最小*。如果我们已经有了边(A, B)和(B, C),那么边(A, C)就没有帮助,因为节点 C 已经通过节点 B 从节点 A 到达。

为了高效实现克鲁斯卡尔算法,我们使用一种新的辅助数据结构UnionFind。这种数据结构允许我们表示一组不同的集合,我们将使用它来跟踪图的连通组件。该数据结构支持一些高效的集合操作,包括以下内容:

are_disjoint(i, j)确定两个元素i和j是否在不同的集合中。我们使用此方法来测试两个节点是否属于同一连通集合。

union_sets(i, j)将包含元素 i 的集合和包含元素 j 的集合合并为一个集合。我们用它来连接两个节点集合,当我们添加一条边时。

数据结构还会跟踪不相交集合的计数,并在每次操作时更新(num_disjoint_sets)。

对于本书中的算法,实际上不需要深入了解 UnionFind 的细节。将其视为一个方便进行操作的模块就足够了。有兴趣的读者可以在附录 C 中找到一个基本的描述和足够实现本书算法的代码。

#### 代码

给定辅助数据结构,Kruskal 算法的代码分为两个主要步骤。首先,我们创建一个包含所有图的边的列表并进行排序。然后,我们通过遍历该列表,检查当前边是否连接了不相连的组件,如果是的话,就将其添加到我们的最小生成树中:

def kruskals(g: Graph) -> Union[list, None]:
djs: UnionFind = UnionFind(g.num_nodes)
all_edges: list = []
mst_edges: list = []

❶ for idx in range(g.num_nodes):
for edge in g.nodes[idx].get_edge_list():
❷ if edge.to_node > edge.from_node:
all_edges.append(edge)
❸ all_edges.sort(key=lambda edge: edge.weight)

for edge in all_edges:
  ❹ if djs.are_disjoint(edge.to_node, edge.from_node):
        mst_edges.append(edge)
        djs.union_sets(edge.to_node, edge.from_node)

❺ if djs.num_disjoint_sets == 1:
return mst_edges
else:
return None


代码首先创建一系列辅助数据结构,包括一个表示当前不相交集合的 UnionFind 数据结构(djs),用于确定哪些点已经属于同一个簇,一个列表(all_edges)用于存储*排序后的*边列表,以及一个空列表(mst_edges)用于存放最小生成树的结果边。然后,代码遍历图中的每个节点来填充这些辅助数据结构❶。对于每个节点,它将该节点的每条边插入到所有边的列表中。

由于我们对无向图的表示在节点 A 和节点 B 的邻接表中都包含边 (A, B),代码使用简单的检查来避免重复添加相同的边❷。(请注意,这个检查仅在使用这种无向图表示法时用于提高效率。如果没有该检查,代码仍然能够正常工作,只不过在 all_edges 中会包含两倍数量的边。)

在所有边列表组装完成后,代码会按边的权重升序对边进行排序❸。代码通过单一的for循环遍历排序后的每条边,然后使用UnionFind数据结构检查该边是否连接两个当前未连接的组件❹。如果是,这条边就是有用的。代码会将它添加到最小生成树的边集(mst_edges)中,并合并该边节点的两个不连通子集。

最后,代码检查是否能够将所有节点连接成一个单一的连通组件❺。如果是,它会返回最小生成树的边列表。如果不是,它则返回None。如果去掉这个最终的检查,代码将返回那些不属于单一连通组件的图的每个最小生成树的边。

#### 一个示例

图 10-4 展示了 Kruskal 算法在一个有 8 个节点和 12 条边的图上的运行示例。

搜索从一个空的边集开始,因此是一个不连通的节点集。在图 10-4(a)中,搜索从图中选择了权重最小的边。这个边对应的是边(1, 5),权重为 0.2。图中的边用粗体标记,表示它是最小成本生成树的一部分。节点 1 和节点 5 现在属于同一个连通子集,搜索将不连通的子集数量从八个减少到了七个。

在图 10-4(b)中,搜索继续通过选择权重次小的边来进行。这次它通过权重为 0.3 的边连接了节点 6 和节点 7,将不连通的子集数量减少到了六个。

![每个子图展示了 Kruskal 算法的七个步骤中的一个,其中一条边被添加。在(A)中,只有边(1, 5)的权重 0.2 被加粗。在(B)中,边(1, 5)和(6, 7)都被加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10004.jpg)

图 10-4:Kruskal 算法的示意图

在接下来的两个子图中,搜索将节点 2 和节点 4 添加到第一个连通子集{1, 5}中,结果形成了包含{1, 2, 4, 5}的连通集。在图 10-4(e)中,算法通过边权重为 0.6 的边将节点 0 和节点 3 连接在一起,合并了另外两个孤立的节点。接着,算法通过在接下来的两个子图中添加边(0, 1)和(3, 6),将剩余的三个不连通子集合并到一起。此时,所有节点已经连接成一个单一的子集,意味着最小成本生成树的边连接了图中的所有节点。### 迷宫生成

尽管前几章中介绍的图形搜索方法可以帮助我们算法地解决迷宫问题,但它们无法帮助我们首先生成迷宫。在本节中,我们偏离了最小生成树算法的更经典用途(例如构建交通网络),展示了如何扩展 Kruskal 算法来创建随机但始终可解的迷宫。为了让迷宫足够有趣,我们确保每个迷宫只有一个有效的解。

假设我们被要求为当地一家家庭餐厅的儿童餐垫设计一个迷宫。我们的设计可以很简单,但必须是可解的,并且迷宫中只有一条路径。餐厅老板聪明地不想用不可能解开的迷宫来挑战年轻的顾客,以免造成尖叫声和食物被扔出去的情况。

#### 表示基于网格的迷宫

为了简化本节代码,我们使用类似图纸上的常规方格来表示迷宫。在经过数小时的深思熟虑后,我们决定通过给每条边着色来表示迷宫的墙壁。玩家可以在没有墙壁的两个相邻方格之间移动。随着每条线的绘制,我们消除了离开该方格的一个潜在选项,也许还会因我们正在创造的艰难任务而轻轻一笑。

图 10-5(a)展示了一个基于网格的迷宫示例。我们可以通过图形等效地表示该网格结构,如图 10-5(b)所示。

![两个子图展示了基于网格的迷宫表示。(A)展示了一个 4×4 的方格,左上角标记为 S,右下角标记为 E。墙壁用实线表示。(B)展示了相同的迷宫,以 4×4 的节点系列表示。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10005.jpg)

图 10-5:一个基于网格的迷宫及其图形表示

在图 10-5(b)中,迷宫中的每个方格都对应一个单独的图节点。我们在没有墙壁的两个相邻节点之间添加无向边,以便边表示能够从一个节点到达另一个节点。

#### 生成迷宫

我们通过从一个基于网格的图开始,并基于 Kruskal 算法构建一个随机化的生成树算法,来连接所有节点。基于网格的初始结构使我们能够基于邻接关系建立连接。每个节点最多有四个连接,分别连接其上、下、左、右的节点。生成生成树可以确保每个节点都可以从任何其他节点到达,并且我们可以从起始节点到达结束节点。

我们使用一个连接的基于网格的图来定义有效边,如图 10-6 所示。就像我们在第五章中生成的网格一样,这个图表示我们需要连接的所有节点,以及我们可以用来连接它们的潜在边的集合。如果我们的网格宽度为*w*,高度为*h*,那么它包含*h* × *w*个节点和连接相邻节点的无向边(权重为 1)。

![一个 5×5 的节点网格。每个节点最多有四条边连接到四个方向的相邻节点。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10006.jpg)

图 10-6:基于网格的图

如果我们使用图 10-6 中的图来构建我们的最终迷宫,那么任意两点之间就会有大量潜在路径。换句话说,这个图并不会构成一个特别有趣或具有挑战性的迷宫。从起始节点开始,我们可以通过在水平方向和垂直方向上各走一步,直接到达终点节点。现实世界中的类似例子可能是一个用空旷草坪做的树篱迷宫,或者是我们餐垫上的一个空白迷宫。为了构建一个有趣的迷宫,我们需要从这些边中选择一个最小的子集。

就像 Kruskal 算法一样,我们从一个空的生成树开始,其中没有任何节点是连接的。在我们基于网格的餐垫的例子中,我们从一个方格网格开始。我们逐个向生成树添加边并擦除相邻方格之间的线。我们也可以将连接两个组件的过程想象成一个卡通人物用一个巨大的大锤子拆除两个相邻房间之间的实体墙,或者直接穿透墙壁。当我们的卡通人物愉快地打开通道(或者我们小心地擦去网格线)时,原本分离的组件连接在一起,迷宫中的路径也形成了。

生成随机迷宫的关键直观上是随机选择下一条边。Kruskal 算法和 Prim 算法都依赖于某种方法来打破权重相同边之间的平局。然而,在这个案例中,所有的边权重都是相同的(1.0),所以我们可以随机选择一条边。如果所选的边连接了两个不相交的组件,我们就保留它。这条边在之前无法到达的两个组件之间打开了一条路径。否则,如果所选的边连接了两个已经连接的组件,我们就舍弃它,因为在组件之间添加多个路径会导致循环,破坏迷宫必须只有一条路径的规则。

#### 代码

以下代码允许我们从基于网格的图中随机创建一组迷宫边:

def randomized_kruskals(g: Graph) -> list:
❶ djs: UnionFind = UnionFind(g.num_nodes)
all_edges: list = []
maze_edges: list = []

❷ for idx in range(g.num_nodes):
for edge in g.nodes[idx].get_edge_list():
if edge.to_node > edge.from_node:
all_edges.append(edge)

❸ while djs.num_disjoint_sets > 1:
num_edges: int = len(all_edges)
❹ edge_ind: int = random.randint(0, num_edges - 1)
new_edge: Edge = all_edges.pop(edge_ind)

  ❺ if djs.are_disjoint(new_edge.to_node, new_edge.from_node):
        maze_edges.append(new_edge)
        djs.union_sets(new_edge.to_node, new_edge.from_node)

return maze_edges 

该函数使用完整的基于网格的图(g)来定义边列表。代码首先设置辅助数据结构,包括表示不相交集合的<ssamp class="SANS_TheSansMonoCd_W5Regular_11">UnionFind数据结构(<ssamp class="SANS_TheSansMonoCd_W5Regular_11">djs),所有边的列表(<ssamp class="SANS_TheSansMonoCd_W5Regular_11">all_edges),以及迷宫或生成树边的列表(<ssamp class="SANS_TheSansMonoCd_W5Regular_11">maze_edges)❶。像克鲁斯卡尔算法一样,代码从图中提取出完整的边列表❷。

该算法通过单个<ssamp class="SANS_TheSansMonoCd_W5Regular_11">while循环进行迭代,直到所有节点属于同一集合(因此可达)❸。在每次循环迭代中,算法随机选择一条边❹,使用 Python 的<ssamp class="SANS_TheSansMonoCd_W5Regular_11">random库中的<ssamp class="SANS_TheSansMonoCd_W5Regular_11">randint()函数(这要求我们在文件开头包含<ssamp class="SANS_TheSansMonoCd_W5Regular_11">import random)。然后,它从所有边列表中移除选中的边,并检查它是否连接了两个之前不相交的集合❺。如果是,则将该边添加到迷宫边列表中,并合并对应的集合。否则,忽略该边。算法在所有节点合并为一个集合后完成,返回定义迷宫的边列表:最小生成树。

#### 一个示例

图 10-7 展示了该算法前几个步骤的示例。每个子图的左侧图示表示当前迷宫,定义为已移除的墙壁,而右侧图示则表示通过添加边缘到图中的迷宫。在每一步(每次<ssamp class="SANS_TheSansMonoCd_W5Regular_11">while循环迭代中),最多添加一条边。

![六个子图。(A)展示了一个 4×4 的网格和相应的 4×4 的未连接节点排列。(B)展示了相同的网格,缺少了一堵墙,并且相同的节点通过一条边连接。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10007.jpg)

图 10-7:迷宫构造算法的六个步骤

提前构建完整的基于网格的图(g)并不是绝对必要的。相反,我们可以像在第五章中构建网格时那样,根据计算出的邻接关系编程填充<ssamp class="SANS_TheSansMonoCd_W5Regular_11">all_edges列表。然而,在本章的目的下,从完整的基于网格的图开始,使代码与克鲁斯卡尔算法的关联更加明显,并且保持代码简单。

随机化的 Kruskal 算法是一种生成迷宫的简单方法,它不能保证终点位于一条深且转弯多的路径的尽头。它可能会生成非常无聊的迷宫,比如在图 10-8(a)、10-8(b)和 10-8(c)中所示的那些。我们只能确保该算法*不会*生成一个终点无法到达的迷宫,例如在图 10-8(d)中所示的那个。

![每个子图显示一个 5×5 的网格和不同的迷宫。左上角标记为 S,右下角标记为 E。前三个迷宫有简单的路径。第四个迷宫没有有效的路径。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10008.jpg)

图 10-8:三个过于简单的迷宫和一个无法解决的迷宫

除了设计儿童餐垫所涉及的激动人心的商业机会外,本节中的迷宫生成算法展示了我们如何扩展最小生成树和 Kruskal 算法的基本组件。此外,它还演示了如何在算法中使用随机化来生成不同的生成树。

### 单链接层次聚类

我们还可以调整 Kruskal 算法来处理看似不同的空间点聚类问题。*聚类*是常见的无监督数据挖掘和机器学习方法,它将数据点分配到簇中,使得每个簇中的点是相似的(根据某种给定的相似性定义)。例如,我们可以根据地理接近度将咖啡馆进行聚类,使得安克雷奇的所有咖啡店都聚集在一个簇中,而檀香山的咖啡馆则聚集在另一个簇中。生成的簇为数据点提供了一个分区,这可以帮助我们发现数据中的结构或对相似的数据点进行分类。

存在多种聚类技术,它们在定义相似点和如何将点分配到簇中的方式上有所不同。顾名思义,层次聚类是一种通过在每个层次上合并两个“相邻”的簇来创建簇层次结构的方法。每个数据点最初定义自己的簇;这些簇会迭代地合并,直到所有点都属于同一个簇。即使在层次聚类中,也有不同的方法来确定合并哪些簇,包括以下几种:

+   计算每个簇的点的均值位置,并合并中心最接近的簇

+   从两个簇中找到最远的点对,并合并最大距离最小的簇

+   从两个簇中找到最接近的点对,并合并最小距离最小的簇

本节重点介绍最后一种方法,称为*单链接聚类*,它通过连接两个簇中最接近的一对单独点来合并簇。我们将介绍一个几乎与图的 Kruskal 算法相同的实现算法。

图 10-9 展示了单链接聚类的一个例子。左侧图形显示了五个二维点(0, 0)、(1, 0)、(1.2, 1)、(1.8, 1)和(0.5, 1.5)。右侧图形展示了层次聚类。

![左侧子图显示了带有五个点的 X 和 Y 图,右侧子图显示了元素的层次合并。第一级合并了点 2 和点 3,下一层将 {2, 3} 和点 4 合并。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10009.jpg)

图 10-9:一组二维点(左)及其对应的单链接聚类(右)

我们从每个点单独作为一个簇开始,并通过合并距离最小的两个单独点(点 2 和点 3)来创建一个合并簇。接下来,我们将簇 {2, 3} 和 {4} 合并,因为点 2 和点 4 之间的距离是不同簇中所有点对中最小的。这个过程继续进行,如图 10-9 右侧所示。

层次聚类的优点在于它提供了一个易于可视化和解释的结构。我们可以利用这个结构通过沿着层次结构向上走,直到达到给定的距离阈值,动态改变聚类的数量(分区级别)。在达到阈值之前已经合并的点将被归为一类,而未合并的簇则保持独立。

#### 代码

为了简化聚类代码的逻辑,我们定义了两个小的辅助类,用来存储关于点和结果聚类连接的信息。首先,为了表示我们正在聚类的二维点,我们定义了一个Point类来存储坐标并计算点对之间的距离:

class Point:
def init(self, x: float, y: float):
self.x: float = x
self.y: float = y

def distance(self, b) -> float:
    diff_x: float = (self.x - b.x)
    diff_y: float = (self.y - b.y)
    dist: float = math.sqrt(diff_x*diff_x + diff_y*diff_y)
    return dist 

distance() 函数计算二维空间中的欧几里得距离,并要求我们在文件开头包含 import math,以便使用 math 库的平方根函数。(附录 A 进一步讨论了如何从空间点创建图形,包括使用替代距离函数。)

其次,由于我们没有使用显式图形,我们还定义了一个Link数据结构,用于存储同一簇中各点之间的连接:

class Link:
def init(self, dist: float, id1: int, id2: int):
self.dist: float = dist
self.id1: int = id1
self.id2: int = id2


这个数据结构实际上与无向图的边完全相同。它存储了一对标识符,表示两个点以及它们之间的距离(权重)。我们在这里将其定义为一个独立的数据结构,以突出我们不需要显式地构建图来进行单链接聚类这一事实。

使用这两个辅助数据结构,我们可以基于克鲁斯卡尔算法的思路实现单链接层次聚类算法:

def single_linkage_clustering(points: list) -> list:
num_pts: int = len(points)
djs: UnionFind = UnionFind(num_pts)
all_links: list = []
cluster_links: list = []

❶ for id1 in range(num_pts):
for id2 in range(id1 + 1, num_pts):
dist = points[id1].distance(points[id2])
all_links.append(Link(dist, id1, id2))

❷ all_links.sort(key=lambda link: link.dist)

for x in all_links:
  ❸ if djs.are_disjoint(x.id1, x.id2):
        cluster_links.append(x)
        djs.union_sets(x.id1, x.id2)

return cluster_links 

代码接受一个 Point 对象列表 (points) 来进行聚类。该函数首先创建一系列辅助数据结构,包括一个表示离散集的 UnionFind 数据结构 (djs),用来确定哪些点已经属于同一个聚类,一个空列表 (all_links) 用来存储所有的点对距离,以及一个空列表 (cluster_links) 用来存储表示每次合并的 Link 对象。然后,代码使用一对嵌套的 for 循环遍历所有点对 ❶。对于每一对,代码使用点的 distance 函数计算距离,并创建一个 Link 数据结构来保存这个距离信息。所有点对距离计算完成后,代码将按递增距离排序链接 ❷。

接下来,另一个 for 循环遍历排序列表中的每一条边,使用 UnionFind 数据结构检查下一个点对是否已经在同一个聚类中 ❸。如果不是,程序将把这个链接添加到 cluster _links 中,将包含这两个点的两个聚类合并,并合并这些点的离散集。

最后,代码返回表示聚类的 Link 对象列表。每个 Link 表示两个之前不相交的聚类之间的连接。cluster_links 中的链接将按距离递增顺序排列,因此第一个元素表示合并的前两个点。

#### 一个例子

图 10-10 展示了我们在图 10-9 中的数据点上运行聚类算法的步骤。图的左列显示了当前的聚类,作为二维点的连接图组件。图的右列显示了相同的聚类,在层次结构中作为合并的点,每个聚类以圆形表示。

![单链接聚类算法的四个步骤。在子图 A 中,有五个点,只有两个点通过边相连接。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f10010.jpg)

图 10-10:单链接聚类

在图 10-10(a)中,算法将最接近的两个点——(1.2, 1)和(1.8, 1)——合并为一个聚类。使用图 10-9 中的点标签,我们分别将这两个点称为 2 和 3。

在下一步中,图 10-10(b)中,算法将最接近的点对所在的两个聚类合并。在这一阶段,最接近的点是(1.2, 1)和(0.5, 1.5),它们之间的距离约为 0.86。由于(1.2, 1)已经是某个聚类的一部分,算法将整个聚类与包含单个点(0.5, 1.5)的聚类合并。最终得到的聚类包含三个点{2, 3, 4}。

算法在图 10-10(c)中继续进行,通过将剩余的两个独立点(0, 0)和(1, 0)合并为一个新的聚类。此时,算法已经创建了两个分别包含三点和两点的独立聚类。在最后一步中,图 10-10(d)中,这两个聚类通过在每个聚类中最接近的点对(1.2, 1)和(1, 0)之间添加一条链接来合并。

由于单链接聚类通过连接越来越远的点对来扩展聚类,我们可以将这种距离作为算法的停止阈值。例如,如果我们将最大距离设置为 0.95,那么我们将得到图 10-10(b)中显示的三个不同的聚类。

### 为什么这很重要

最小生成树问题可以帮助我们解决一系列现实世界中的优化问题,从建设道路到设计通信网络。在计算机科学领域,我们可以利用最小生成树来解决网络、聚类和生物数据分析等一系列问题。例如,我们可以将通信网络表示为一个图,并找到最小生成树,以决定哪些连接需要升级,确保所有节点都可以通过新技术互通。

我们也可以将相同的基本方法应用于那些我们通常不认为是基于图的问题。通过使用克鲁斯卡尔算法的变体,我们可以通过构建相似数据点的簇来搜索实数值数据集中的结构,或者通过在算法中引入随机化来设计可解的迷宫,从而创造出新颖的解决方案。在单链接聚类中,我们使用距离来确定哪些点是相似的。

下一章将扩展这一讨论,介绍帮助我们识别对保持连通性至关重要的节点和边的算法。




## 第十一章:11 桥和关节点



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

在本章中,我们考虑连通性的另一个方面:在无向图中,维持连通分量完整性所必需的节点和边。这些分别被称为*关节点*和*桥*。理解哪些节点或边对维持连通性至关重要,对于一系列现实世界的问题非常重要。每当我们必须确保网络中没有单点故障时,我们就需要找出其桥和关节点。

在正式定义桥和关节点之后,本章提供了一些实际的示范性应用案例,这些概念可以应用于其中,比如为一组岛屿开发强健的交通网络,以及为邪恶的巫师构建最佳的秘密迷宫。接着,我们介绍了两种高效搜索这些元素的算法,基于第四章中介绍的深度优先搜索算法。

### 桥和关节点的定义

对于无向图中的每一对节点,要使它们能够相互到达,它们必须属于同一个连通分量。在第三章中,我们学到了无向图中的连通分量是节点的一个子集 *V*′ ⊆ *V*,使得对于所有 *u* ∈ *V*′ 和 *v* ∈ *V*′,*u* 可以从 *v* 到达。以一系列由渡轮连接的岛屿为例。节点代表岛屿,边代表它们之间的渡轮路线。为了提供全面的旅行选项,交通规划者需要使得结果图包含一个单一的连通分量。也就是说,一个人必须能够在渡轮网络中通过直接连接或一系列旅行,在任何两个岛屿之间往返。

图 11-1 显示了一个包含两个独立连通分量{0, 1, 2, 4, 5}和{3, 6, 7}的示例图。

![在包含节点 0、1、2、4 和 5 的集合中的节点与包含节点 3、6 和 7 的集合中的节点之间没有边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11001.jpg)

图 11-1:一个包含两个独立连通分量的图

*桥*是一个边,它的去除会将一个连通分量分割成两个不相交的分量。图 11-2(a)显示了一个包含两条桥(1, 2)和(4, 5)的示例图。移除其中任意一条边都会将单一的连通分量分割成两个。移除两条边都会将图分割成三个独立的连通分量,如图 11-2(b)所示。

![(A) 显示了一个包含八个节点的图。边(1, 2)和(4, 5)加粗显示。 (B) 显示了去除这些边后的相同图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11002.jpg)

图 11-2:一个有两个桥接点的图(a),以及移除桥接点后的三个独立组件(b)

类似地,*关节点*(或*割顶*)是一个节点,移除它会将一个连通组件拆分为两个或多个不相交的组件。例如,图图 11-3 中有三个关节点:阴影节点 1、2 和 4。

![一个有八个节点的图。节点 1、2 和 4 被阴影标出。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11003.jpg)

图 11-3:一个有三个关节点的图

图 11-4 展示了分别移除图 11-3 中的每个关节点所产生的影响。

![(A) 展示了移除节点 1 后的图。 (B) 展示了移除节点 2 后的图。 (C) 展示了移除节点 4 后的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11004.jpg)

图 11-4:移除不同关节点的结果

在图 11-4(a)中,移除节点 1 会产生两个组件 {0, 4, 5} 和 {2, 3, 6, 7}。在图 11-4(b)中,移除节点 2 会产生组件 {0, 1, 4, 5} 和 {3, 6, 7},而移除节点 4 会产生组件 {0, 1, 2, 3, 6, 7} 和 {5},如图 11-4(c)所示。

### 用例

在图中识别桥接点和关节点对于理解网络中的单点故障至关重要。本节提供了一些现实世界中寻找桥接点和关节点的应用实例。我们首先展示如何将这些概念应用于创建一个具有韧性的渡轮网络,然后探讨如何将相同的技术扩展到防止疾病传播或构建最佳魔法迷宫。

#### 设计韧性网络

一个*韧性网络*需要能够优雅地处理单个边或节点的丢失,而不会失去连通性。为了扩展上一节中的岛屿示例,假设有两个假想的渡轮网络,连接八个夏威夷岛屿,如图 11-5 和图 11-6 所示。

![一个包含八个夏威夷岛屿和七条边,形成一个单一连通组件的地图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11005.jpg)

图 11-5:假设的夏威夷岛屿之间渡轮航线的地图

图 11-5 展示了连接八个岛屿所需的最小渡轮网络。如果所有渡轮都正常运行,任何两个岛屿之间都可以互通。虽然可能需要经过多个跳跃才能到达目的地,但总会有一条路径。

然而,这个网络很脆弱。如果在欧胡岛(节点 2)和莫洛凯岛(节点 3)之间的渡轮出现故障,网络就会被分割成两部分。人们将无法从毛伊岛(节点 5)和尼伊豪岛(节点 0)之间旅行。图中的每条渡轮路线都是一个桥梁,任何一条线路的丧失都会使至少一个岛屿断开连接。同样,图 11-5 中的许多节点也是关节点。如果欧胡岛的渡轮码头(节点 2)因天气原因关闭,它将把考艾岛(节点 1)和毛伊岛(节点 5)隔断。

通过理解网络的桥梁和关节点,规划者可以设计出一个更强大的网络,没有桥梁,如图 11-6 所示。例如,即使渡轮出现故障(移除一条边),也不会切断任何两个岛屿之间的旅行。如果欧胡岛(节点 2)和莫洛凯岛(节点 3)之间的渡轮出现故障,旅行者仍然可以通过其他路线从毛伊岛(节点 5)前往尼伊豪岛(节点 0)。该网络也没有关节点。例如,如果毛伊岛的渡轮码头关闭,它只会断开该岛屿的连接。

![一张显示八个夏威夷岛屿及其间有 14 条边连接的地图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11006.jpg)

图 11-6:夏威夷岛屿之间假设渡轮路线的第二张地图

我们可以将这些概念扩展到交通系统之外,应用到计算机网络、电网、通信网络或废水系统中。虽然通常最好设计没有桥梁或关节点的图,但这并不总是可行的。然而,了解一个网络的弱点仍然对规划有所帮助。

#### 防止疾病传播

想一想常见感冒是如何通过社交网络传播的。为了简化,我们假设你需要靠近一个生病的人才能得感冒。你不能从一个你永远不会见到的人那里感染感冒。如果这些边代表现实世界中人们之间的互动,那么病毒只能在邻近的节点之间传播。

我们可以使用桥梁和关节点的概念来模拟或阻止疾病的传播。与以前的同事进行的咖啡聚会就像一个桥梁,它允许感冒在两组原本不相交的人群之间传播:你以前的同事和你现在的同事。一个自我隔离并切断病毒在不同人群之间传播的人是一个关节点。通过几周不参加任何活动,你可以帮助防止感冒在不同的社交圈之间传播。你的跑步朋友、数据结构阅读小组的成员以及你的同事们,将只能各自限于他们自己的人群内传播感冒,而不会通过你互相传播。

#### 设计魔幻迷宫

与前两种情况不同,我们希望最小化桥梁和关节点,试想一个邪恶的巫师决定在他的迷宫中布置最有效的陷阱。一个作为迷宫两个部分唯一连接通道的隧道就是桥梁。如果一个部分包含迷宫入口,另一个部分包含目标,巫师知道所有的冒险者都必须经过这条隧道,这使得它成为布置最强大陷阱的理想地点。同样,必须穿过的房间以便在迷宫的两个部分之间移动就是关节点——部署高级怪物的理想场所。

在更常见的场景中,我们可以使用这些相同的技术,在关键的高速公路(桥梁)上设置收费站,或者在机场航站楼交汇处设置信息亭(关节点)。在这些情况下,我们利用这样一个事实:从图的一个部分到另一个部分的旅行者必须经过这个单一的节点或边。理解图的连通性可以帮助我们优化潜在稀缺或昂贵的资源。

### 桥梁查找算法

计算机科学家罗伯特·塔尔扬(Robert Tarjan)提出了一系列有用的算法,用于通过图的深度优先搜索树的性质来理解图的结构。本节介绍了一种*桥梁查找算法*,它在无向图上使用了这一方法。该算法从一个任意节点开始深度优先搜索,并跟踪所用的边以及节点首次访问的顺序(*顺序索引*或*前序索引*,记作*order*(*u*))。我们可以利用这些信息通过询问一个边是否是到达其子树中节点的唯一路径来寻找桥梁。那些不出现在深度优先搜索树*T*中的边可以立即排除为桥梁,因为我们已经能在不使用它们的情况下到达节点。这意味着我们只需考虑*T*中的边。

图 11-7 显示了一个图的示例以及其对应的深度优先搜索树的两种表示,树的根节点为 0。图 11-7(a)显示了初始图。图 11-7(b)显示了从节点 0 开始的深度优先搜索树;每个节点外的数字表示顺序索引。图 11-7(c)显示了相同的树,并将*未遍历的边*标为虚线。这些未遍历的边称为*回边*,它们回到在深度优先搜索过程中已经访问过的节点。

![(A) 显示了一个包含七个节点和九条边的无向图。(B) 显示了从该图构建的树,根节点为 0。节点 0 标记为 order = 0,并且有两个子节点:节点 1,order = 1,和节点 3,order = 5。(C) 显示了与 B 子图相同的树,并附加了三条虚线边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11007.jpg)

图 11-7:无向图(a),深度优先搜索树(b),以及包含未遍历边的深度优先搜索树(c)

我们可以通过查找指向 *T* 子树的边来识别桥,其中该子树的节点只与同一子树中的邻居相连。换句话说,如果边(*v*,*u*)是桥,则没有其他方式能够进出节点 *u* 的子树,除了通过边(*v*,*u*)进入或离开。在图 11-7(a)中,边(1,6)就是一个这样的例子,提供了进入或离开以节点 6 为根的子树的唯一路径。相反,边(0,3)不是桥,因为节点 5 有一条通往节点 0 的边。

这个算法的关键是我们可以查看 *u* 及其后代节点的邻域中的最小和最大顺序索引。根据深度优先搜索的性质,*u* 的子树中的所有节点的顺序索引都必须位于 [*order*(*u*),*order*(*u*) + *K* − 1] 范围内,其中 *K* 是子树中节点的数量(包括 *u*)。这是因为搜索在访问 *u* 后,先访问这些节点,然后才访问其他子树中的节点。如果 *u* 的子树中的节点有任何邻居,其顺序索引超出了这个范围,则通向该邻居的边将提供进入 *u* 子树的替代路径。

我们可以通过观察到任何未访问的节点,只要它可以从子树到达,就会被深度优先搜索所探索,从而出现在子树中,来简化问题。因此,我们只需要检查指向邻居且具有较低顺序索引的回边即可。我们可以通过检查边(*v*,*u*),其中 *v* 是 *u* 的父节点,是否为桥来进行测试,即检查 *u* 的子树中的任何节点是否有一个邻居 *w*,使得 *order*(*w*) < *order*(*u*),并排除连接(*v*,*u*)本身。如果存在这样的邻居,我们就找到了一个绕过(*v*,*u*)的回边,并且可以确定(*v*,*u*)不是桥。相反,如果在排除连接(*v*,*u*)的情况下,所有子树邻居 *w* 的 *order*(*w*) ≥ *order*(*u*),则(*v*,*u*)是一个桥。

图 11-7 中的节点 2 提供了一个此类情况的例子。搜索通过边(1,2)到达节点 2,并将其分配了顺序索引 2,如图 11-7(b)所示。为了使边(1,2)成为一座桥,必须没有从该子树出去的替代路径。然而,节点 2 本身有一条通向节点 0(顺序 = 0)的边,从而提供了这样的替代路径。

相反的情况显示在图 11-8 中,边 (0, 1)。图 11-8(a)中的图与图 11-7(a)中的图稍有不同,排除了边 (0, 2),因此边 (0, 1) 现在是一个桥接边。图 11-8(b) 显示了以节点 0 为根的对应深度优先搜索子树,其中未遍历的边以灰色显示。两幅图中的虚线椭圆表示节点 1 的子树。如图 11-8(b)所示,从节点 1 的子树到顺序小于 1 的节点的唯一连接是边 (0, 1) 本身。

![(A) 显示一个有七个节点和八条边的无向图。 (B) 显示一个从该图构建的树,根节点是 0,且有额外的灰色边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11008.jpg)

图 11-8:一个无向图 (a) 和一个深度优先搜索树 (b)

桥接查找算法通过记录邻接任何子树节点的最低序号,检查深度优先搜索树中的每个子树。唯一排除的邻接边是子树根节点 *u* 与其父节点之间的链接,因为这就是我们正在测试的边。

#### 代码

我们可以通过一次深度优先搜索来实现桥接查找算法。为了简化代码,我们将使用辅助数据结构 DFSTreeStats 来跟踪深度优先搜索到达各个节点的顺序信息,包括:

parent **(**list**) **将每个节点的索引映射到其在深度优先搜索树中的父节点索引

next_order_index **(**int**) **存储下一个要分配的顺序索引

order **(**list**) **将每个节点的索引映射到其顺序索引

lowest **(**list**) **将每个节点映射到其深度优先搜索子树或其直接邻居(不包括节点的父节点)中的 *最低* 顺序索引

数据结构 DFSTreeStats 提供了这个信息的封装,避免了我们需要将许多参数传递给搜索函数。我们还可以使用该对象执行基本的赋值和更新操作。我们在以下代码中定义了 DFSTreeStats:

class DFSTreeStats:
def init(self, num_nodes: int):
❶ self.parent: list = [-1] * num_nodes
self.next_order_index: int = 0
self.order: list = [-1] * num_nodes
self.lowest: list = [-1] * num_nodes

def set_order_index(self, node_index: int):
    self.order[node_index] = self.next_order_index
    self.next_order_index += 1
  ❷ self.lowest[node_index] = self.order[node_index] 

构造函数将所有信息设置为初始值❶。代码将parent、order和lowest列表的所有条目初始化为-1,以表示这些值尚未为每个节点设置。它将next_order_index设置为0,以准备处理第一个节点。

辅助方法set_order_index()记录当前节点的顺序索引,并增加下一个要分配的顺序索引。它还初始化该节点的最低顺序索引,初始值为该节点自身的顺序索引❷。

我们使用来自第四章的深度优先搜索,来填写DFSTreeStats的条目并查找桥。

def bridge_finding_dfs(g: Graph, index: int, stats: DFSTreeStats, results: list):
❶ stats.set_order_index(index)

for edge in g.nodes[index].get_sorted_edge_list():
    neighbor: int = edge.to_node
  ❷ if stats.order[neighbor] == -1:
        stats.parent[neighbor] = index
        bridge_finding_dfs(g, neighbor, stats, results)
      ❸ stats.lowest[index] = min(stats.lowest[index],
                                  stats.lowest[neighbor])
      ❹ if stats.lowest[neighbor] >= stats.order[neighbor]:
            results.append(edge)
    elif neighbor != stats.parent[index]:
      ❺ stats.lowest[index] = min(stats.lowest[index],
                                  stats.order[neighbor])

def find_bridges(g: Graph) -> list:
results: list = []
stats: DFSTreeStats = DFSTreeStats(g.num_nodes)
for index in range(g.num_nodes):
if stats.order[index] == -1:
bridge_finding_dfs(g, index, stats, results)
return results


递归辅助函数bridge_finding_dfs()通过使用set_order_index()辅助方法❶,首先设置当前节点的顺序索引和子树中可达的最低顺序索引的初始值。

代码接着使用for循环检查每个节点的邻居。为了与其他示例中的顺序保持一致,我们使用函数get_sorted_edge_list()按顺序遍历邻居,尽管按排序顺序遍历对于算法的正确性并非必要。如果某个邻居尚未访问(其order值未设置)❷,代码将其父节点设置并递归地探索它。递归调用返回后,代码通过比较子节点的lowest条目与它自身的lowest条目❸,检查是否找到了邻近子树的较小顺序索引。

此时,搜索已完成对以neighbor为根的深度优先搜索子树的探索。它可以通过比较子树中任何节点或其直接邻居的最低顺序索引与子树根节点的顺序索引,来检查edge是否为桥❹。代码将新发现的桥添加到result中。

如果邻居已被访问(其 order 值已设置),代码首先检查邻居是否是父节点。如果是,那么正在考虑的边刚刚被遍历过,搜索将忽略它。否则,代码会检查该邻居是否代表子树外的节点,通过检查该邻居的顺序索引 ❺。

find_bridges() 函数提供了一个封装器,用于设置统计信息和 results 数据结构,然后开始搜索。代码通过执行一次深度优先搜索来找到每个连通分量中的所有桥,使用的方式是从 Listing 4-2 中改编而来。由于每个节点仅访问一次,每条边最多检查两次(每个方向一次),因此整个算法的成本是 |*V* | + |*E*|。

#### 示例

图 11-9 显示了在一个具有八个节点的图上运行桥查找算法的示例。每个子图显示了搜索完成访问圈中节点后的状态。DFSTreeStats 的列表 order 和 low 被展示。箭头表示到目前为止遍历的边,而虚线边表示已经被搜索到但未遍历的边,粗体灰色箭头表示桥。

图 11-9(a) 显示了搜索完成节点 6 后算法的状态。此时,算法已初步访问并为节点 0、1、2、3、7 和 6 设置了先序索引。节点 4 和 5 未被访问,因此没有先序索引。同样,低值与算法在访问已访问节点的子节点后获得的状态相对应。节点 6 的最终状态是 最低,因为搜索已经完成了对它的处理。相比之下,节点 3 的 最低 值尚未最终确定,因为算法尚未完成对其子树的搜索。

在 图 11-9(b) 中,搜索回溯到节点 7 并完成了该节点。在此过程中,算法检查边(7, 6)是否可能是桥。由于 lowest[6] 小于 order[6],我们知道从子树中存在一条备用路径(通过节点 2),因此该边不是桥。

![每个子图显示一个具有八个节点的图以及两个数组中的值。在(A)中,节点 6 被圈出,order 数组包含[0, 1, 2, 3, –1, –1, 5, 4],low 数组包含[0,1, 2, 3, –1, –1, 2, 4]。](../images/f11009.jpg)

图 11-9:桥接算法的各个阶段

根据图 11-9(e),搜索已经找到了第一个桥接边。虽然它还没有完成处理节点 1,但已经完全搜索了以节点 2 为根的子树。在从节点 2 返回后,算法发现lowest[2]等于order[2],表明边(1, 2)是通向或离开以节点 2 为根的子树的唯一路径。它将(1, 2)添加到桥接边列表中,然后继续处理节点 1 的其他子节点。在图 11-9(f)中,完成了以节点 5 为根的子树后,搜索发现边(4, 5)必定是另一个桥接边,因为删除该边会使节点 5 与其他节点断开连接。

为了形象化这一过程,想象我们的邪恶巫师正在检查他们新创建的魔法迷宫。他们首先开始走迷宫,建立一个深度优先树,并在每个房间的墙上用粉笔标记该房间的前序索引。每当他们进入一个新房间时,他们会递归地探索任何未访问的邻居房间,并探头查看以前访问过的邻居房间,检查墙上的标记。当他们访问到“松动天花板砖房间”时,他们可能会发现一个新邻居,“丑陋地毯房间”,并且还会找到一条通向先前访问过的“总是令人不舒服的温暖房间”的连接。在整个过程中,他们记录下进入每个房间以来看到的最小编号。

在回溯通过每个走廊后,巫师检查他们的笔记,以确定他们刚刚访问过的任何房间是否有邻近房间,其前序索引小于走廊尽头的房间(即他们回溯时刚离开的房间)。在回溯通过他们个人最喜欢的“吊灯过多走廊”后,巫师实际上是在问:“是否还有其他通道可以让冒险者到达前面的某个房间?还是他们必须穿过‘吊灯过多走廊’?”如果没有其他通道,他们可以将“吊灯过多走廊”标记为一个桥接边,并且高兴地知道冒险者总会看到这个华丽装饰的通道,同时还计划设置一个很好的陷阱。

### 一种寻找关节点的算法

我们可以通过考虑每个子树的根节点,而不是直接与它们相连的边,使用非常相似的逻辑,来将桥接查找算法适应为识别关节点。我们通过寻找一个节点*u*来识别关节点,该节点的深度优先搜索树中的后代没有任何邻居位于该树中的*u*之上。一个来自树外节点的边,连接到*u*的后代之一,将为*u*提供一个关键的替代路径。

要理解如何使用节点的子树来识别关节点,考虑图 11-10 中展示的两种情况。我们将深度优先搜索的子树映射到原始无向图上,并用箭头标示每个节点的顺序索引。当前正在考虑的节点被阴影标出,节点的后代由虚线边界标示。

![在 (A) 中,节点 {2, 3, 6, 7} 被圈出。在 (B) 中,节点 {6, 7} 被圈出。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11010.jpg)

图 11-10:图中深度优先搜索树中的两个节点及其子树

在图 11-10(a)中,算法正在考虑节点 1 及其后代 {2, 3, 6, 7}。移除节点 1 会将其后代与图的其余部分断开。相比之下,节点 3 不是一个关节点,如图 11-10(b)所示。该节点的后代包括节点 6,而节点 6 有一条通向节点 2 的链接(在节点 3 的子树外)。边 (2, 6),尽管不包含在深度优先搜索树中,但在节点 3 被移除时,提供了通向节点 6 和 7 的备用路径。

这个逻辑适用于除根节点外的每个节点。由于根节点没有祖先,我们不能使用相同的方法来检查子树是否有回边。相反,我们必须寻找根节点有多个子树的情况。如图 11-11 中的示例图所示,只有当图的组件在根节点被移除后会断开时,根节点才会有多个子树。如果子树之间有一条连接边,深度优先搜索会在返回根节点之前先遍历该边。

![一个包含七个节点的图。六条边是箭头,包括 (0, 1)、(1, 2) 和 (1, 6)。三条边是虚线,包括 (0, 2)、(0, 5) 和 (1, 4)。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f11011.jpg)

图 11-11:根节点为关节点的深度优先子树

我们可以将专业的根节点测试与桥接检测算法中的下界追踪相结合,以识别图中的关节点,如以下代码所示。

#### 代码

与桥接查找算法类似,我们通过一次深度优先搜索实现*关节点查找算法*,该算法同时完成搜索和识别。我们重复使用DFSTreeStats数据结构来跟踪并更新每个节点的父节点、顺序索引和最低可达顺序索引的信息。

为了简化代码,我们将搜索分为两个函数。第一个函数处理非根节点并执行递归探索:

def articulation_point_dfs(g: Graph, index: int, stats: DFSTreeStats,
results: set):
❶ stats.set_order_index(index)
for edge in g.nodes[index].get_edge_list():
neighbor: int = edge.to_node
if stats.order[neighbor] == -1:
stats.parent[neighbor] = index
articulation_point_dfs(g, neighbor, stats, results)
❷ stats.lowest[index] = min(stats.lowest[index],
stats.lowest[neighbor])

      ❸ if stats.lowest[neighbor] >= stats.order[index]:
            results.add(index)

    elif neighbor != stats.parent[index]:
      ❹ stats.lowest[index] = min(stats.lowest[index],
                                  stats.order[neighbor]) 

递归函数 articulation_point_dfs() 执行了此算法的大部分工作。它首先设置当前节点的顺序索引和暂定下界 ❶,然后通过遍历每个邻居来执行深度优先搜索,检查是否已访问,如果没有,则递归地进行探索。

代码跟踪任何节点在子树中邻居的下界。对于深度优先搜索树中的子树(先前未探索的节点),代码会根据该子树的最低邻居来更新下界 ❷。标识关节点的逻辑发生在每个子节点的递归探索之后。代码通过检查该子树中的任何节点是否包含一个在深度优先搜索树中高于当前节点的邻居 ❸ 来确定当前节点的移除是否会切断它刚刚访问过的子树。

对于那些不属于深度优先搜索子树(先前已探索的节点)且不是当前节点的父节点的邻居,代码会将节点的下界与邻居的顺序索引进行比较 ❹。

对于根节点,我们添加了一些额外的逻辑来跟踪子树的数量:

def articulation_point_root(g: Graph, root: int,
stats: DFSTreeStats, results: set):
stats.set_order_index(root)
num_subtrees: int = 0 for edge in g.nodes[root].get_edge_list():
neighbor: int = edge.to_node
❶ if stats.order[neighbor] == -1:
stats.parent[neighbor] = root
articulation_point_dfs(g, neighbor, stats, results)
num_subtrees += 1

❷ if num_subtrees >= 2:
results.add(root)


articulation_point_root() 函数首先设置根节点的顺序索引,并初始化 num_subtrees 计数器。然后,它通过遍历每个邻居,检查是否已访问过 ❶,如果没有,则使用 articulation_point_dfs() 函数递归地进行探索。代码不是通过使用下界逻辑来判断根节点是否为关节点,而是直接检查根节点是否有两个或更多的子树 ❷。如果是,它将根节点添加到结果中。

查找所有关节点的函数是通过使用 articulation_point_root() 函数在图中的每个连通分量上运行此搜索:

def find_articulation_points(g: Graph) -> set:
stats: DFSTreeStats = DFSTreeStats(g.num_nodes)
results: set = set()
for index in range(g.num_nodes):
❶ if stats.order[index] == -1:
articulation_point_root(g, index, stats, results)
return results


find_articulation_points() 函数首先创建并初始化算法所需的数据结构。由于数据结构是通过节点进行索引的,并且不同的连通分量是离散的,因此代码可以使用单一的 stats 和 results 对象来处理所有连通分量。然后,代码遍历每个节点,检查是否已经通过搜索访问过 ❶,如果没有,则从该节点开始新的深度优先搜索。最后,它返回所有关节点的列表。

#### 一个示例

图 11-12 展示了寻找关节点算法的示意图。每个子图显示了算法在*完成*访问圈中节点后的状态。被测试的边通过箭头表示,如果它们是深度优先搜索树的一部分,或者通过虚线表示,如果它们不是。未探索的边是实心灰色线,已发现的关节点则被阴影标出。

![每个子图展示了一个包含八个节点的图,并且有两个数组的值。在(A)中,节点 6 被圈出,order 数组包含[0, 1, 2, 3, –1, –1, 5, 4],low 数组包含[0, 1, 2, 3, –1, –1, 2, 4]。](../images/f11012.jpg)

图 11-12:关节点查找算法的各个阶段

图 11-12 中展示的大部分算法行为与图 11-9 中的行为相同。节点被探索的顺序以及每一步中DFSTreeStats的值是相同的。行为上的区别出现在图 11-12(d)中检测到关节点的地方。任何以节点 3 为根的子树的邻居的最低顺序索引是 2,即当前节点的顺序索引。我们知道节点 2 至少有一个没有与任何祖先节点连接的子树,这意味着移除节点 2 会断开该子树。

图 11-12(e)很有趣,因为尽管它显示了完成节点 5 后的状态,但算法已经将(未完成的)节点 1 标记为关节点,这是因为在检查每个子树后,执行了关节点测试。无论在探索节点 1 的其他后代时发生什么,我们都知道移除该节点会断开以节点 2 为根的子树。

图 11-12(h)展示了算法的最后一步。在这一点上,搜索已经从对articulation_point_dfs()函数的调用返回,并正在测试根节点。它没有使用低边界,而是检查根节点有多少个子树,揭示出节点 0 只有一个深度优先搜索子树。图中的所有节点在搜索返回节点 0 之前,都通过节点 1 被访问。因此,节点 0 不是关节点。

### 为什么这很重要

桥和关节点对于理解图的结构至关重要,包括它们的故障点和瓶颈。正如我们在示例用例中所见,这些特性适用于各种现实世界问题,从将冗余路线纳入航空网络,到设计终极魔法迷宫。

本章介绍的算法提供了实用的方法,用于识别这些结构元素,并利用深度优先搜索树和顺序索引来确定哪些节点可以通过替代路径到达。这再次突显了简单的深度优先搜索的强大和多功能性,并展示了如何通过增加顺序索引等信息,深入理解图的整体结构。

下一章将进一步扩展我们关于连通性的讨论,这次将考虑有向图以及相关的强连通分量概念。我们介绍了一种算法,该算法基于本章中介绍的利用深度优先搜索收集统计信息来理解图结构的思想。




## 第十二章:12 强连通分量



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

前几章使用无向图上的连通分量来回答诸如“我们能从这里到达某个位置吗?”或“移除此边会破坏图的连通性吗?”这类问题。随着我们开始思考*方向性*,这些问题和解答它们的算法变得更加复杂。当我们考察有向图上的*可达性*时,单纯地说“我们能从 A 到 B”已经不再足够。我们需要理解是否能返回到 A,如果不能,这将如何影响图的遍历。

本章探讨了*强连通分量*的概念,这是有向图中一组节点,使得该组中的任何节点都可以从该组中的任何其他节点到达。这些分量帮助我们理解图的结构以及如何遍历图。我们首先正式介绍强连通分量的概念,并提供检查一组节点是否强连通的示例代码。我们还描述了一些强连通分量的实际应用,包括建模计算机程序如何在某些状态下卡住,以及信息如何通过社交网络流动,然后讨论一个识别图的强连通分量的示例算法。

### 定义强连通分量

有向图中强连通分量的正式定义是一个最大节点集*V'* ⊆ *V*,使得对于任何两个节点*u* ∈ *V'*和*v* ∈ *V'*,存在一条从*u*到*v*的有向边路径。换句话说,您可以从同一分量中的任何其他节点出发,到达强连通分量中的任何节点。如果有向图中的每个节点都属于同一个强连通分量,则我们称该图为*强连通*。

我们可以通过交通网络来直观地理解强连通分量的重要性。让我们回到一个邪恶巫师设计的神奇迷宫,如第十一章所介绍的那样。为了阻止冒险者迷路,巫师使用单向门连接迷宫中的每个房间,如图 12-1 所示。这样,他们就给每条路径设定了预定义的流向。例如,冒险者可以通过房间 A 和 B 之间的门从 A 到 B,但无法反向通过。

![该图有六个节点和有向边 (A, B)、(B, C)、(B, E)、(C, F)、(D, A)、(E, D) 和 (E, F)。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12001.jpg)

图 12-1:一个模型,表示六个房间之间的单向门

这种方法超出了巫师的预期。他们原本只希望防止冒险者回溯并从后方袭击他们的爪牙。然而,他们发现某些房间变得无法从其他房间到达。迷宫包含多个强连通组件:{A, B, D, E},{C} 和 {F}。冒险者可以从房间 A 穿越到 B,再到 E,再到 D,并返回 A,而不会触及任何门。然而,一旦他们需要离开这个组件,灾难就发生了。进入房间 C 的冒险者很快发现,他们无法找到回到房间 A、B、D 或 E 的路径。随着时间的推移,冒险者实际上被引导进入房间 F,这使得巫师能够将他们困在这个房间,并让他们与一只 BOSS 级怪物对抗。

#### 确定哪些节点是相互可达的

理解并构建强连通组件的关键在于确定哪些节点是*相互可达*的。让我们首先回顾一下节点 *v* 从节点 *u* 可达的含义。如在第三章的“可达性”中所述,节点 *v* 只有在存在一系列(有向)边,形成从节点 *u* 到节点 *v* 的连续路径时,才算从节点 *u* 可达。根据这个定义,每个节点都可以从自身可达,方法是使用空边集;我们可以通过不移动到达自己所在的位置。

我们可以定义一个辅助函数 get_reachable(),通过广度优先搜索来获取从给定起始索引(index)开始,在有向图 g 中所有可达节点的集合。我们使用 set 数据结构来追踪当前搜索过程中可达的节点以及已经访问过的节点,如列表 12-1 所示。

def get_reachable(g: Graph, index: int) -> set:
seen: set = set()
pending: queue.Queue = queue.Queue()

❶ seen.add(index)
pending.put(index)

❷ while not pending.empty():
current_index: int = pending.get()
current: Node = g.nodes[current_index]
for edge in current.get_edge_list():
neighbor: int = edge.to_node
❸ if neighbor not in seen:
pending.put(neighbor)
seen.add(neighbor)

return seen 

列表 12-1:获取从给定节点可达的节点索引集合

代码首先设置数据结构:一个已经访问过的、因此可达的节点集合(seen)和一个待探索的未来节点索引队列(pending)。注意,使用队列数据结构要求我们在文件中包含 import queue。代码将初始节点(index)同时添加到 seen 集合和待探索的节点索引队列 ❶ 中。

该代码使用广度优先搜索来发现图中所有其他可达的节点。当仍有节点可供探索(即pending不为空)❷时,代码将出队下一个索引,检索该节点,并使用for循环检查其每个邻居。如果代码之前未遇到过某个邻居,则该邻居的索引将被添加到队列和seen集合中❸。当代码没有更多节点可供探索时,它返回已见索引的集合。该集合包括所有可以从index到达的节点。

示例 12-1 中的算法就像一个有条不紊的冒险者在规划穿越魔法迷宫的旅程,且他们手中有一张地图。冒险者维护一个待评估房间的列表(队列),列表初始时只有入口一个房间。在每一步,他们从列表中取出顶部的房间,划去它,仔细在地图上找到它的位置,并检查相邻的房间。他们将任何未探索的邻居添加到列表的底部,并在地图上为该房间画上整齐的勾号。然后,他们继续评估房间,从列表中取出顶部(或最旧)的项,直到列表为空。

#### 确定节点是否强连接

我们可以使用示例 12-1 中的可达性函数来定义一种暴力检查,来判断一组节点(由索引列表inds给出)是否强连接,如下代码所示:

def check_strongly_connected(g: Graph, inds: list) -> bool:
for i in inds:
reachable = get_reachable(g, i)
for other in inds:
if other not in reachable:
return False
return True


该代码使用一对嵌套的for循环来检查组件中每个节点是否可以从其他每个节点到达。它从inds中的每个节点索引开始,并使用来自示例 12-1 的get_reachable()辅助函数生成可达节点的集合。然后,它检查inds中的每个索引是否出现在此可达集合中。如果任何节点无法从其他节点到达,则该函数返回False。否则,它返回True。

尽管 check_strongly_connected() 函数很简单,仅由两个循环和一个辅助搜索函数组成,但它不是一种高效的方法。我们在这里介绍它,是因为它提供了一个易于理解和直观的概述,说明了一个节点集合需要满足什么条件才能是强连通的。它相当于一个不幸的冒险者,每次从可能的每个房间开始探索迷宫,并记录他们到达的目的地。对于一组 |*V*| 节点,该函数需要运行 |*V*| 次搜索,然后检查其他 |*V*| – 1 个节点与结果可达集合的关系。

更糟糕的是,check_strongly_connected() 函数并没有告诉我们是否有节点缺失在强连通分量中。记住,强连通分量是*最大*的互相可达节点集合。该函数仅告诉我们列表中每一对节点是否互相可达,但没有告诉我们其他哪些节点可能属于该集合。

### 用例

在图中识别强连通分量对于理解图中的可能移动至关重要。本节提供了一些识别强连通分量的实际应用:分析程序中的操作流、网络中的八卦传播流以及交通网络的遍历能力。

#### 计算机程序状态建模

我们可以将计算机程序的状态建模为有向图。启动状态可能是一个节点,连接到加载初始数据、初始化变量和检查网络连接的状态。例如,图 12-2 显示了视频游戏中的状态图。程序的各个状态代表处理用户输入和渲染屏幕。虚线表示核心游戏循环,它形成一个强连通分量,其中每个状态都可以从其他状态到达。然而,诸如加载初始数据文件或退出游戏的状态不属于该分量;一旦加载了初始数据文件,程序就不会再返回到该状态。

![一个包含九个节点的图,表示程序状态。图的中间有一个由“处理输入”、“移动角色”、“检查条件”和“渲染屏幕”节点组成的循环。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12002.jpg)

图 12-2:作为程序状态集建模的一个玩具视频游戏

计算机程序可能有多个强连通分量,封装了不同的操作或逻辑。例如,一个数据分析程序可能有一个强连通分量用于批量处理文件中的数据,另一个用于允许用户交互。

#### 理解一个八卦网络

寻找图的强连通分量的能力可以帮助我们确定信息在有向通信网络中的传播范围。对于这样的网络,一个强连通分量就是一组人,如果其中一个人知道某些信息,那么整组人都会知道。也就是说,信息会从该组中的任何一个节点传递到该组中所有其他节点。

考虑图示 图 12-3 中的社交网络,其中每个节点代表一个个体。从节点 *u* 到节点 *v* 的边表示人 *u* 会告诉人 *v* 一个关于新图算法书籍发布的激动人心的谣言。没有边表示没有这种沟通。如果某个节点分享了一个谣言,那么这些信息将传播到从起始节点可达的所有节点。

![一个包含六个节点和有向边的图(0, 1)、(1, 4)、(2, 5)、(3, 4)、(4, 0)、(4, 3)、(5, 2)和(5, 4)](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12003.jpg)

图 12-3:一个模拟通信网络的图

强连通分量提供了对完全共享信息的群体的洞察。在 图 12-3 中,节点 0、1、3 和 4 形成了一个强连通分量。任何节点 4 分享的信息最终都会被该分量中的其他节点了解。

请记住,信息仍然可以在不同强连通分量之间的两个节点之间流动。在 图 12-3 中,节点 5 分享的任何信息最终都会通过节点 4 传递到节点 0。然而,反过来则不成立。由于节点 4 不与节点 5 分享信息,因此节点 5 和 2 被切断了来自图左侧的秘密。直到该图算法书籍正式发布前,他们不会知道这本书的消息。

#### 规划一个旅行网络

在规划一个真实世界的旅行网络时,理解其中涉及的强连通分量是至关重要的。在旅行的上下文中,一个强连通分量对于旅行者进行圆形旅行是必需的。如果两个地点不在同一个强连通分量中,比如带有单向门的巫师地牢,那么任何穿越该网络的人可能会陷入某些地点的子集中。

例如,在一个航空网络中,从城市 *u* 可到达的任何城市 *v* 必须属于同一个强连通分量。否则,第一组的飞机和乘客将被困在第二组中。如果一家航空公司提供从多伦多到珀斯的一系列航班,它们还需要提供另一组航班,将飞机和乘客送回多伦多。

请注意,航空网络不必严格地形成一个单一的连通分量,因为航空公司可以用两个不同的强连通分量服务两个不相交的市场。也许它运营着一个服务佛罗里达城市的通勤网络,并且有一个独立的网络覆盖新英格兰。然而,每个子网络必须是强连通的。

设计任何交通网络时,都会考虑到相同的因素。如果城市某个区域只有单向的进站路,那将是一个灾难。通勤者只能开车进入该区域,却无法离开。这个区域很快就会被汽车塞满,绝望的司机试图寻找任何逃离的办法,持续的鸣笛声让局面更加混乱。

### Kosaraju-Sharir 算法

*Kosaraju-Sharir 算法*(或简称*Kosaraju 算法*)是一种实用、易懂且可视化的算法,用于查找强连通分量。在他们的书《数据结构与算法》(Addison-Wesley,1983 年)中,Aho、Hopcroft 和 Ullman 描述了这一方法是由计算机科学家 S. Rao Kosaraju 和 M. Sharir 独立发明的。该算法通过使用一对深度优先搜索和一个反转图来识别强连通分量。

Kosaraju-Sharir 算法首先通过执行图的深度优先搜索来进行,该搜索有效地提出了“我可以从这个起始节点到达哪些节点?”在整个搜索过程中,它记录了每个节点的*完成时间*。完成时间,也叫做*后序索引*,是一个计数器,记录了搜索完成处理一个节点的顺序。第一个完成的节点时间为 0,第二个为 1,依此类推。在我们的迷宫示例中,这相当于巫师使用深度优先搜索走过迷宫的房间——只通过正确方向的门——并记录下他们离开每个房间的最终时间。

让我们回到巫师检查他们升级版迷宫的场景,如图 12-1 所示。在初步检查时,他们禁用了单向门的魔法,以便可以自由地漫游自己的迷宫。然后,巫师从房间 A 开始检查。他们走到 B,然后到 C,再到 F,最后遇到第一个死胡同。由于没有地方可去,F 的结束顺序是 0\。巫师回溯到 C(因为他们控制着迷宫,所以保持了那扇门是开的),意识到没有新的地方可以去,并给 C 房间分配了结束顺序 1\。直到他们回溯到 B,才发现了一条新的前进路径。这次,他们去到房间 E,再到房间 D,因为在标记 B 房间完成之前,需要探索这条路径。此搜索通过如下方式分配结束时间:F = 0,C = 1,D = 2,E = 3,B = 4,A = 5\。从第一次搜索得到的顺序不仅依赖于图的结构,还依赖于选择的起始节点。如果巫师从房间 D 开始检查,他们将最后完成该房间,并得到结束顺序:F = 0,C = 1,E = 2,B = 3,A = 4,D = 5。

算法的第二阶段反转图的边的方向,并运行另一组深度优先搜索。通过这样做,它有效地将问题从“从这个起始节点可以到达哪些节点?”转变为“如果我想到达这个节点,我从哪里开始?”这相当于巫师检查迷宫时,逆向查看单向门的方向。在每个房间,他们使用深度优先搜索,沿着单向门的相反方向行进,从而能够看到哪些房间通向当前房间。

为了看清楚这一点,请考虑图 12-4(a)中的简单图和其反向图图 12-4(b)。从节点 0 开始的深度优先搜索将找到从节点 0 可达的节点(在这个例子中是节点 1)。然而,在图 12-4(b)中对反向图进行相同的搜索,将找到能够到达原始图中节点 0 的节点(即节点 2)。

![两个图,每个图有三个节点。在 (A) 中,图有有向边 (2, 0) 和 (0, 1)。在 (B) 中,图有有向边 (1, 0) 和 (0, 2)。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12004.jpg)

图 12-4:一个简单的图(a)及其反向图(b)

通过在反向图上运行第二次搜索,并按照*递减*的结束顺序选择我们的起始节点,Kosaraju-Sharir 算法结合了两个问题:“从这个起始节点可以到达哪些节点?”和“如果我想到达这个节点,我从哪里开始?”在第二阶段,算法从每个之前未访问的节点开始新的搜索(在反向图上),并记录哪些节点是新访问的。在每次搜索期间访问的节点集合构成了图中的强连通分量。

尽管 Kosaraju-Sharir 算法执行了多次深度优先搜索,但每个节点最多只会被访问两次:一次是在原图中,一次是在反转图中。每次访问时,算法会检查当前节点的出边,要求的时间是|*V*| + |*E*|,每次搜索所需的时间都是成比例的。反转图需要再次遍历所有节点及其出边,时间复杂度同样是|*V*| + |*E*|。因此,算法的总体运行时间为|*V*| + |*E*|。

这个算法为何有效的推理有点复杂,完整的证明超出了本书的范围。有兴趣的读者可以在算法学类的书籍中找到相关讨论,比如《数据结构与算法》和 Sedgewick 与 Wayne 的《算法》第 4 版(Addison-Wesley, 2011)。目前,让我们来看看这个算法是如何基于第四章和第十一章中的算法来解决一个新问题的。

#### 转置图

Kosaraju-Sharir 算法的核心步骤是对图的边反向版本进行深度优先搜索,这个版本称为*转置图*。这个术语来源于*矩阵转置操作*,它反转每条边的方向;我们稍后将详细讨论这一点。

对于图的邻接表表示法,我们定义了一个<`samp` class="SANS_TheSansMonoCd_W5Regular_11">make_transpose_graph()函数,它通过遍历图中的每条边并将反向边添加到一个新图中来创建转置图:

def make_transpose_graph(g: Graph) -> Graph:
❶ g2: Graph = Graph(g.num_nodes, undirected=g.undirected)
for node in g.nodes:
for edge in node.get_edge_list():
g2.insert_edge(edge.to_node, edge.from_node, edge.weight)
return g2


make_transpose_graph()代码首先创建一个空图(g2),并设置正确的节点数量,并复制原图的<`samp` class="SANS_TheSansMonoCd_W5Regular_11">无向设置❶。然后,它遍历原图中的每个节点及其每条边。对于每条边,代码会在新图中添加一条相反方向的边。最后,代码返回新图。

我们可以通过考虑底层环境的变化来将这个函数与前面提到的迷宫示例相结合。一位学徒邪恶巫师,为了在自己的声誉上有所成就,决定将迷宫中*每个*门的方向反转。他们通过从一张新的地图开始,考虑旧地图中的每个门,并将其添加到新地图中,方向相反来执行这一大胆的计划。每个门处理完后,他们将其提交给导师,等待不可避免的赞扬。

请注意,尽管<sup>make_transpose_graph()</sup>函数在技术上通过复制原始图的<sup>undirected</sup>设置支持无向图,但它仅对有向图有用。无向图的转置将等于原始图。

#### 代码

Kosaraju-Sharir 算法的代码使用了一个辅助函数,该函数实现了每个算法阶段所需的单独搜索,执行针对未访问节点的修改版深度优先搜索,并按递增的完成时间将每个新访问的节点添加到给定的列表中:

def add_reachable(g: Graph, index: int, seen: list, reachable: list):
seen[index] = True
current = g.nodes[index]

for edge in current.get_edge_list():
    if not seen[edge.to_node]:
        add_reachable(g, edge.to_node, seen, reachable)
reachable.append(index) 

除了图(g)和当前索引(index)外,该函数还接受一个布尔列表(seen),指示哪些节点已经被访问过,以及一个表示完成顺序的节点索引列表(reachable)。代码首先将当前节点索引标记为已访问,并检索节点数据结构。一个简单的for循环遍历未访问的邻居节点并递归探索它们。当搜索完成处理一个节点时,它会将该节点添加到<sup>reachable</sup>列表的末尾。由于节点索引是在函数的末尾添加的,因此一旦递归探索完成,最终列表将按完成时间递增的顺序排序。

Kosaraju-Sharir 算法的完整代码包括两次遍历节点列表,并在当前未访问的节点上调用辅助函数:

def kosaraju_sharir(g: Graph) -> list:
seen1: list = [False] * g.num_nodes
finish_ordered: list = []
❶ for ind in range(g.num_nodes):
if not seen1[ind]:
add_reachable(g, ind, seen1, finish_ordered) ❷ gT: Graph = make_transpose_graph(g)

seen2: list = [False] * g.num_nodes
components: list = []

❸ while finish_ordered:
start: int = finish_ordered.pop()
if not seen2[start]:
new_component: list = []
❹ add_reachable(gT, start, seen2, new_component)
components.append(new_component)

return components 

代码通过设置数据结构来开始第一次搜索:一个布尔列表表示每个节点是否已被访问(seen1),以及一个按*递增*完成时间排序的节点索引列表(finish_ordered)。finish_ordered列表最初为空,因为没有节点被访问过。然后,for循环执行算法的第一阶段,通过遍历每个节点索引并从任何未访问的节点开始深度优先搜索 ❶。由于代码在每次调用时使用相同的finish_ordered列表,因此所有节点都包含在一个单独的列表中。

第二阶段通过使用 make_transpose_graph() 函数反转图的边❷开始。代码创建一个新的布尔列表来指示在第二轮搜索过程中它已经访问过的节点(seen2),然后创建一个空的组件列表(components)。components 列表将是一个包含多个列表的列表,每个条目包含一个强连通分量中所有节点的索引。一个 while 循环按 *递减* 完成顺序❸遍历节点,通过将 finish_ordered 列表当作栈来实现这一排序,每次迭代时弹出栈中的最后一个元素。栈的大小减小一个,代码检查一个新的节点索引(start),看看是否需要从该节点开始新的搜索。

使用 add_reachable() 函数,代码从每个未访问的节点开始新的搜索❹。每次,它传入一个新的空结果列表(new_component)来表示当前的强连通分量。该函数将该列表填充为新的强连通分量的节点索引。然后,代码将 new_component 列表附加到 components 列表中。

#### 一个示例

让我们来看看 Kosaraju-Sharir 算法在示例图 12-5 中的表现。

![一个包含六个节点和有向边 (0, 2), (2, 3), (3, 0), (3, 5), (4, 5), (5, 4) 的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12005.jpg)

图 12-5:一个包含六个节点的有向图

算法的第一阶段如图 12-6 所示,按照节点索引递增的顺序考虑节点。算法从节点 0 开始进行深度优先搜索,并计算每个节点完成的顺序。节点 4 位于一条长长的死胡同的尽头,因此最先完成。相比之下,节点 0 直到它的深度优先搜索发现四个其他节点后才完成。在第一次深度优先搜索完成后,节点 1 仍未访问,因此算法从该节点开始新的搜索。最终的排序为 4, 5, 3, 2, 0, 1,如每个节点外部指示的完成顺序所示。

![图中的节点标记为节点 0 = 4,节点 1 = 5,节点 2 = 3,节点 3 = 2,节点 4 = 0,节点 5 = 1。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12006.jpg)

图 12-6:Kosaraju-Sharir 算法的第一次迭代产生了节点的排序。

在算法的这一阶段,我们从每个未访问的节点开始新的深度优先搜索,如节点 1 的情况。不能从节点 0 到达的节点因此会在后续的深度优先搜索中被包含,并添加到排序的末尾。

在第二阶段,Kosaraju-Sharir 算法对图进行转置,使用一个新的未见标记数组,并重复执行最多 |*V* | 次深度优先搜索序列。算法不会按照任意顺序(例如使用递增的节点索引)来搜索节点,而是使用第一步中完成顺序的反向来选择起始节点:1、0、2、3、5、4。最后完成的节点成为第一次搜索的起点。每当算法在外部循环中遇到一个未见的节点时,它就从该节点开始新的深度优先搜索。它将所有在此次深度优先搜索中遇到的未见节点添加到当前组件中,并标记它们为已见。

图 12-7 显示了我们在示例图中第二阶段执行的三次搜索,从图 12-7(a)中的节点 1、图 12-7(b)中的节点 0,以及最终在图 12-7(c)中的节点 5 开始。用虚线圈起的节点表示在每次深度优先搜索中访问的节点,而灰色节点表示由当前搜索或先前的搜索将其seen值设置为True的节点。

![三个子图显示了不同的搜索。在 (A) 中,节点 1 被圈起。在 (B) 中,节点 0、2 和 3 被圈起。在 (C) 中,节点 4 和 5 被圈起。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f12007.jpg)

图 12-7:Kosaraju-Sharir 算法的第二阶段

如图 12-6 所示,我们只在未见的节点上启动搜索,单个搜索不会访问先前搜索中已见的节点。

第一次深度优先搜索,如图 12-7(a)所示,从节点 1 开始。在图 12-7(b)中,反向图的深度优先搜索从节点 0 开始,依次访问节点 3,然后是节点 2。此时,它遇到死胡同并回溯。当深度优先搜索返回到节点 0 时,我们知道我们已经访问了所有可以到达节点 0 并且在原始图中从节点 0 可达的节点。第三次也是最后一次深度优先搜索,如图 12-7(c)所示,探索了节点 4 和 5。

### 为什么这很重要

强连通分量揭示了相互可达的节点子集。本文中的概念不仅为解决现实世界问题(如交通网络或谣言网络)提供了一个实用工具,而且为思考图的基本结构提供了基础。例如,识别强连通分量提供了一种将大图分割成有意义子图的机制。

本章介绍的算法基于第四章中的基本深度优先搜索,用于分析图中的可达性并构建强连通分量。Kosaraju-Sharir 算法提供了一种既实用又易于可视化的查找连通分量的方法,展示了我们如何继续将搜索算法应用于更复杂的问题。

除了本章介绍的算法外,还有多种其他方法被开发出来用于查找强连通分量。例如,罗伯特·塔尔贾恩(Robert Tarjan)提出了一种算法,仅依靠一次深度优先搜索,并使用与他在第十一章中讨论的算法相同的原理。和本书中的所有主题一样,存在多种具有不同权衡的解决方案。本章的目标是为理解和比较这些不同方法提供基础。

下一章讨论了图中的随机游走,基于强连通分量的概念,研究了游走如何陷入吸收状态或永远漫游下去。




## 第十三章:13 随机行走



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

到目前为止,本书介绍了多种旨在实现特定目标的算法。本章考虑了那些寻求做些不同事情的算法:在图上引入*随机行为*。分析图上的随机移动使我们能够模拟和研究具有非确定性行为的系统,例如随机化网络路由或现实世界中的社交互动。

图上的随机行走有着丰富的数学历史,远超本书的范围。本章概述了随机行走,介绍了如何用马尔科夫链分析它们,并提供了在图上实现随机行走的代码。我们探讨了通过随机行走可以调查的问题类型以及能够建模的系统,如赌博和基于运气的棋盘游戏。最后,我们考虑如何通过样本观察重建潜在的图。

### 引入随机行走

图上的*随机行走*是一个节点序列,其中序列中的下一个节点是基于某种概率分布随机选择的。我们用 *u* 到 *v* 的*转移概率*表示:

*p*(*u* → *v*) 其中 0 ≤ *p*(*u* → *v*) ≤ 1

这意味着当我们处于节点 *u* 时,我们根据给定的概率分布从 *u* 的邻居中选择下一个节点。

我们可以将随机行走想象成一个完全拒绝提前计划的游客。相信偶然性能带来最好的假期,他们在没有地图或目的地线索的情况下出发,开始探索这座城市。当他们到达一个交叉口时,会考虑可能的路线并随机选择一条。游客每次做出决定时,都会独立考虑,而不考虑过去或未来的转变。

我们使用图的结构以几种方式限制概率。首先,我们限制只能在通过边连接当前节点的节点之间移动。在有向图的情况下,这必须是朝正确方向的边:

*p*(*u* → *v*) = 0 如果 (*u*, *v*) ∉ *E*

换句话说,我们的游客如果没有道路连接点 *u* 和点 *v*,就无法从 *u* 到 *v*。为了清晰起见,本章还要求如果边存在,则概率必须大于零:

*p*(*u* → *v*) > 0 如果 (*u*, *v*) ∈ *E*

这意味着我们的游客理论上可以遍历城市中的所有道路。

其次,所有出发邻接边的转移概率之和必须为 1.0:

∑v *p*(*u* → *v*) = 1 对于每个 *u* ∈ *V*

这限制了概率的分布,使其形成有效的分布。每个节点必须至少包含一条出口边。在有向图中,我们可以使用自环*p*(*u* → *u*) > 0 来模拟漫步未前往新节点的情况。这个约束意味着游客始终至少有一条路径可以继续前进,即使这条路径最终回到当前的位置。

#### 随机漫步中的概率

最简单的随机漫步是我们以相等的概率从出口边选择。在这种情况下,我们假设的游客完全随机地从当前交叉口的街道中选择。如果交叉口有两条出口边,游客选择其中一条的概率为 50%。如果交叉口有四条出口边,每条边的概率为 25%。图 13-1 展示了一个无向且无权重的图(a)以及每个节点的相应转移概率(b)。

![(A)展示了一个包含 4 个节点和 5 条边的无向图。节点 0 相邻的两条边分别是(0, 1)和(0, 2)。(B)展示了一个包含 4 个节点和 10 条边的有向图。每条边都标有一个分数。节点 0 有两条出口边(0, 1)和(0, 2),它们的标签都是 1/2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13001.jpg)

图 13-1:无向图(a)及其随机漫步概率(b)

尽管我们讨论了有向和无向图上的随机漫步,但我们始终将这些系统建模为有向图,因为在许多情况下,两个节点之间的转移概率不会是对称的。用正式的术语来说:

*p*(*u* → *v*) ≠ *p*(*v* → *u*)

在图 13-1(b)中,例如,从节点 0 到节点 1 的概率是 1/2,而从节点 1 到节点 0 的概率只有 1/3。对于我们的游荡游客而言,旅行在两个交叉口之间的概率取决于当前交叉口有多少条路分出。

我们可以使用加权图来模拟更现实的场景,通过为每条边分配不同的概率,并将这些概率存储在边的权重中。我们对这些概率(边的权重)进行约束,使得所有出口边的概率和等于 1.0。图 13-2 展示了一个作为有向加权图的示例。在节点 3 的随机漫步中,有三个可能的下一状态:它可以以 0.2 的概率移动到节点 1,或以 0.6 的概率移动到节点 2,或者以 0.2 的概率停留在节点 3(通过自环)。

![一个包含 4 个节点和 10 条边的有向图。每条边标有一个介于 0 和 1 之间的数字。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13002.jpg)

图 13-2:带有转移概率的有向图

这些更一般的图对应于一个受概率性因素影响的游客,这些因素超出了道路数量。他们倾向于朝着具有更有趣建筑的区域走去,或者跟随咖啡的香气。当他们到达一个特定的四岔路口时,他们有惊人的 90%的概率向左转,朝着一条咖啡馆街道走去。

#### 作为马尔科夫链的随机游走

图的边上的随机游走是 *马尔科夫链* 或 *马尔科夫模型* 的一种示例,这种系统的下一个状态的概率仅依赖于当前状态。每一步的选择不考虑之前的路径,这种特性称为 *时间不变性*。我们将其与马尔科夫链相联系,以便利用大量相关的分析。对不同类型的马尔科夫链的全面研究远远超出了本书的范围。在本章中,我们将简要介绍一些有助于分析随机游走的概念和术语,并展示其建模能力。

时间不变性特性对应于我们徘徊游客的习惯,即只考虑他们面前开放的路径。他们不考虑烦人的细节,例如他们已经去过哪里,走了多少步,甚至是一天中的哪个时段。虽然对于像吃饭和睡觉这样常规的活动来说,这并不是最理想的,但这位游客坚定地遵循着他们随机的度假原则。

在概率和统计的参考文献中,这些转移概率通常写作 *p*(*X*t | *X*t [– 1]),表示在时刻 *t* 系统处于状态 *X*t 的概率,前提是系统在时刻 *t* – 1 时处于状态 *X*t [– 1]。将这种简写与基于图的符号结合,我们得到以下方程:

*p*(*u* → *v*) = *p*(*X*t = *v* | *X*t [– 1] = *u*)

鉴于每个转移的独立性,我们可以通过将每个转移的概率相乘来计算从固定起始节点 *v*[0] 到整个路径 [*v*[0], *v*[1], *v*[2], . . . , *v*k] 的概率:

*p*([*v*[0], *v*[1], *v*[2], . . . , *v*k]) = ∏i [= 1 to] k *p*(*X*t = *v*i | *X*t [– 1] = *v*i [– 1])

马尔科夫链对于许多任务非常有用,尤其是那些转移与之前路径无关的任务。人工智能使用各种(更强大的)马尔科夫模型来模拟或推理各种现实现象,从理解语音到使用自主代理进行决策。例如,*隐马尔科夫模型* 是机器学习中的一个基础,它使用随机的转移状态,这些状态的输出是噪声。已经有高效的算法可以从输出中估计潜在的状态,甚至通过样本数据学习转移概率和输出分布。

相比之下,本章中我们考虑的随机游走代表了一个特别简单的马尔可夫模型。每个状态(节点)在每个时刻都是可见的,决策是完全随机的。然而,正如我们将看到的,即使这些看似简单的模型,也能提供丰富的模拟和分析能力。

#### 转移概率

在使用边的权重对图进行随机游走建模时,我们要求每个节点的出边权重形成一个有效的概率分布。我们可以通过遍历每个节点并检查其出边的权重总和是否为 1.0,来测试我们的Graph数据结构的边权重是否形成了一个有效的概率分布,正如在 Listing 13-1 中所示。

def is_valid_probability_graph(g: Graph) -> bool:
for node in g.nodes:
❶ edge_list: list = node.get_edge_list()
if len(edge_list) == 0:
return False

    total: float = 0.0
    for edge in edge_list:
      ❷ if edge.weight < 0.0 or edge.weight > 1.0:
            return False
        total += edge.weight
  ❸ if abs(total - 1.0) > 1e-10:
        return False

return True 

Listing 13-1: 检查存储在边权重中的概率的有效性

代码使用for循环遍历每个节点,并检查其出边的权重是否形成一个有效的概率分布。首先,它提取该节点的边列表,并检查该列表是否为空❶。如果为空,说明从该节点没有出路,代码返回False。节点的出边权重总和为 1 的约束要求每个节点必须至少有一条权重不为零的出边,即使它是自环。

代码使用第二个for循环遍历每个出边。它检查每个边的概率是否在 0.0 到 1.0 之间,如果不是,立即返回False❷,然后将当前边的权重加到总和中。在检查完所有的边后,代码会检查总权重是否为 1.0,以允许少量的浮动点误差❸。只有当所有节点和边满足这些条件时,才会返回True。

#### 矩阵表示

图的矩阵表示在分析图上随机游走的性质时非常有用,并且在统计学和机器学习的文献中常用于描述随机游走。在矩阵表示中,转移概率通常通过*转移矩阵*(*M*)来指定,其中矩阵的第*i*行第*j*列的值对应于从节点*i*到节点*j*的概率:

M[*i*][*j*] = *p*(*i* → *j*)

我们甚至可以重用 第一章 中的 GraphMatrix 数据结构来存储这些值。由于我们将条目限制为概率,我们对 GraphMatrix 的 connection 列表施加了额外的限制:

+   对于所有 i 和 j,有 0 ≤ connections[i][j] ≤ 1。

+   对于所有 i,有 ∑j connections[i][j] = 1。

这些约束与先前对边权重施加的限制相对应。

我们可以使用矩阵运算来模拟随机步伐的效果。我们令 *V*t 为概率向量,使得 *V*t [*u*] 表示我们在时间步 *t* 时随机漫步位于节点 *u* 的概率(因此对于每个 *u*,0 ≤ *V*t [*u*] ≤ 1 且 ∑u *V*t [*u*] = 1)。例如,我们可以使用 *V*t = [0.5, 0.4, 0.0, 0.1]* 来表示在 Figure 13-2 中我们的漫步位于四个节点的概率。该向量表示在节点 0 处有 50% 的概率,在节点 1 处有 40% 的概率,在节点 2 处有 0% 的概率,在节点 4 处有 10% 的概率。

向量 *V*[0] 给出了从每个节点开始漫步的概率。例如,*V*[0] = [1.0, 0.0, 0.0, 0.0] 表示从节点 0 确定性地开始漫步,而 *V*[0] = [0.5, 0.5, 0.0, 0.0] 表示从节点 0 或节点 1 开始漫步的机会相等。接下来,向量 *V*[1] 给出了根据 *V*[0] 随机开始漫步并执行一步随机漫步后,位于每个节点的概率。*V*[2] 表示随机漫步进行两步后的概率,依此类推。

我们可以使用矩阵代数与转移矩阵 *M* 来计算后续的概率分布:

*V*t [+ 1] = *V*t *M*

每个条目 *V*t [+ 1] [*u*] 给出了我们在下一时间步 *t* + 1 时随机漫步位于节点 *u* 的概率。我们甚至可以在 GraphMatrix 类中添加一个方法来执行此计算,如 Listing 13-2 所示。

def simulate_random_step(self, Vt: list) -> list:
if len(Vt) != self.num_nodes:
raise ValueError("Incorrect length of probability dist")

Vnext: list = [0.0] * self.num_nodes
for i in range(self.num_nodes):
    for j in range(self.num_nodes):
        Vnext[j] += Vt[i] * self.connections[i][j]
return Vnext 

Listing 13-2: 在图上模拟单步随机漫步

代码首先检查传入向量 Vt 的长度是否正确,如果不正确,则抛出错误。然后,它创建一个结果向量 Vnext,并使用一对嵌套的 for 循环执行计算。最后,它返回新的概率向量。

> 注意

*正如在第一章中提到的,本书中的代码使用列表的列表来表示矩阵,以便于说明。为了提高效率,生产环境中的代码应该使用支持高效矩阵操作的库,例如* numpy*。*

模拟随机步长的计算也适用于一个确定性状态:*V*[*u*] = 1 对于恰好一个节点 *u*。然后通过乘以 *V*t [+ 1] = *V*t *M*,我们得到随机游走在从 *u* 出发后恰好一步的概率分布。我们可以通过再次乘以 *M* 来重复这一过程,如下所示:

*V*t [+ 2] = *V*t *M M*

这给出了恰好经过两步后到达的节点的概率分布。

另外,我们可以扩展我们的矩阵表示,使得 *M*t 成为一个随机游走的转移矩阵,表示恰好 *t* 步的随机游走,因此 *M*t [*u*][*v*] 是从节点 *u* 到节点 *v* 在恰好 *t* 步内的转移概率。我们可以直接通过矩阵乘法来计算这个矩阵:

*M*t = ∏I [= 1 到] t *M*

虽然矩阵表示法对于描述和分析随机游走的性质很有用,但本章其余部分的代码使用了我们 Graph 类的邻接表表示法,以保持与其他章节的一致性。所有函数都可以适配为与 GraphMatrix 类一起使用。

### 使用案例

随机游走用于建模和分析涉及非确定性行为的问题。随机行为在许多现实世界的系统中都有出现,从人类互动到一些计算机算法中的显式随机性。在本节中,我们将看三个示例用例:社交网络、随机化探索和机会游戏。

#### 社交网络中的信息链

我们可以使用随机游走来模拟谣言如何在社交网络中传播,其中的互动包含随机成分。每个人都决心不传播过多的闲言碎语,但一旦听到谣言,他们就迫不及待地想与某个人分享。因此,他们将新闻传给遇到的第一个人。这是一个概率性的选择,因为他们不知道首先会遇到哪个朋友。分享完新闻后,他们暂时满足,会等到别人再次传递这个谣言给他们时,才会继续讨论。

我们可以将社交网络建模为一个图,其中边表示每个邻居是传递谣言的对象的概率。谣言本身随机在图中传播,逐个传递给不同的人。

#### 探索

之前的章节讨论了许多用于确定性地探索图的算法,如深度优先搜索或 A*搜索。然而,许多现实世界中的探索涉及随机元素,比如基于天气的路径封闭。随机漫步允许我们模拟具有这些约束的系统。

考虑第八章中提到的探险者,正在寻找通往考古遗址的最佳路径。当前的条件可能会为他们的探索增添随机因素。当面临路径的分岔时,北边的路线可能有 50%的几率被洪水淹没,而南边的路线则有 10%的几率被愤怒的黄蜂群堵住。考虑到这些概率,我们可以将他们穿越丛林的艰难旅程建模为一次随机漫步。

我们可以使用类似的方法来分析动态环境中的机器人路径规划。一个探索损坏建筑物的搜救机器人可能会在不同的时间遇到不同的障碍物,比如火灾或被淹没的通道,并需要重新规划路径。

#### 机会游戏

我们还可以使用随机漫步来模拟机会游戏的结果。图节点表示不同的游戏状态,边表示这些状态的可能(概率)变化。例如,我们可以使用图 13-3 所示的马尔可夫链来表示赌徒玩一美元老丨虎丨机的情况。

![一条线性节点链。每个节点都有一个指向左边的边,概率为 0.99,还有一条指向右边 9 个节点的边,概率为 0.01。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13003.jpg)

图 13-3:赌徒图中的一个子集

每个状态表示赌徒手中的美元数量。每次拉动老丨虎丨机时,都会决定下一个状态,而不考虑之前的拉动或赌徒当前的财务状况。也许机器有 1/100 的几率支付 10 美元,将赌徒从状态*k*移动到状态*k* + 9,而 99/100 的几率什么也不支付,将赌徒从状态*k*移动到*k* - 1。

随着我们对更复杂的机会游戏进行建模,我们需要更复杂的图来表示这些情况。在本章稍后,我们将展示如何使用图来建模基于运气的桌面游戏。我们讨论了同时表示多个玩家状态的节点及其之间的转换。

### 模拟随机漫步

理解随机游走及其底层图形的一个强大方法是*反复模拟*图上的随机游走并分析所走过的路径。我们通过反复选择基于当前状态邻居的概率分布来模拟图上的随机游走。作为先决条件,我们需要一个从有限选项集中按预定概率采样的函数。

我们提供了一个简单的算法进行说明,它在 0, 1)范围内均匀抽取一个随机数,并通过遍历每个出边,累积之前节点的累积概率,检查该随机数对应哪个邻居。我们实际上是将[0, 1)范围划分为每个选项的区域,其中每个区域的大小对应于该选项的概率。[图 13-4 展示了一个示例,其中 50%的范围指向节点 0,20%指向节点 1,30%指向节点 3。

通过在遍历选项时跟踪目前为止看到的累积概率,我们在跟踪每个区域的起始和结束,并将其与选定值进行比较。我们想要找到一个区域,该区域的范围包含我们随机选择的值。只要跨过累积值超过随机选择值的区域边界,我们就知道走得太远了。

![从 0.0 到 1.0 的条形图分为三个部分。第一部分从 0.0 到 0.5,标签为节点 0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13004.jpg)

图 13-4:到三个节点的转移概率及其对应的累积概率

图上的随机游走代码包括生成随机数,随后是一个单独的 for 循环,遍历出边,如列表 13-3 所示。

def choose_next_node(current: Node) -> int:
❶ prob: float = random.random()
cumulative: float = 0.0
edge_list: list = current.get_edge_list()

for edge in edge_list:
    cumulative += edge.weight
  ❷ if cumulative >= prob:
        return edge.to_node

❸ return edge_list[-1].to_node


列表 13-3:在随机游走中选择下一个节点

代码首先使用 Python 的 random 库从 0, 1)范围内生成一个随机数 ❶。然后,它通过一个 for 循环遍历每个出边来计算目前为止看到的总(累积)概率。选中的边是第一个其权重导致累积概率超过选定随机数的边 ❷。如果代码到达列表末尾(由于浮点数存储的精度问题),代码将简单地返回最后一条边 ❸。

[列表 13-3 中的 choose_next_node() 函数没有对每个节点离开的概率分布进行任何有效性检查。这是故意的,因为每次调用该函数时都不想支付进行检查的成本。相反,我建议使用列表 13-1 中的 is_valid_probability_graph() 函数,先检查所有节点的分布。

给定这个辅助函数,从一个给定起始节点(start)执行随机漫步的代码相对简短:

def random_walk(g: Graph, start: int, steps: int) -> list:
❶ if not is_valid_probability_graph(g):
raise ValueError("Graph weights are not probabilities.")

walk: list = [-1] * steps
current: int = start
walk[0] = current
for i in range(1, steps):
  ❷ current = choose_next_node(g.nodes[current])
    walk[i] = current

return walk 

该函数首先确认图的权重表示一个有效的概率分布,如果没有则抛出错误 ❶。接着,它分配一个列表 walk 来存储结果,将当前节点设置为起始节点,并将漫步的第一步设置为起始节点。代码使用一个 for 循环迭代每一步,使用列表 13-3 中的 choose_next_node() 函数不断从当前节点选择下一个节点 ❷。代码将新节点添加到 walk 列表中,并在走完所有步骤后返回该列表。

### 统计度量

随机漫步是理解随机化系统和计算各种实用统计度量的有力工具。例如,我们可能想找到到达特定节点的概率,或确定需要多少步才能到达那里。这些度量在回答以下问题时非常有用:“一个赌徒输掉所有钱的概率是多少?”或者“一个谣言平均需要多长时间才能传到我这里?”或者“如果一个游客在城市中随机游荡多年,他们在每个地方会停留多久?”

在本节中,我们简要考虑如何将这些问题应用于我们徘徊的游客案例,并概述如何计算答案。在考虑到到达特定节点的概率和所需平均步数后,我们分析了随机漫步的长期行为。

#### 到达与吸收时间

*到达时间*指的是随机漫步必须采取的平均步数,直到它从一个给定的起始节点首次到达集合 *A* ⊂ *V* 中的一个节点。例如,如果 *A* 是我们游客正在探索的小镇上所有有咖啡馆的交叉路口的集合,他们可能想要计算这个集合的到达时间,以便了解他们可能何时能喝到下一杯咖啡。

如果一个随机游走无法离开子集*A*中的节点,我们称这些到达时间为*吸收时间*,因为游走已经被*A*吸收。例如,在图 13-5 中,节点*k*形成了一个吸收集。一旦游走到达节点*k*,它将永远停留在那里。

![一个节点具有多个进入边和一个标记为 1.0 的单一出边,形成自环。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13005.jpg)

图 13-5:一个由单个节点组成的吸收集

吸收节点还可以用来表示随机游走的终止。例如,游客可以决定在到达酒店时停止随机游走。

在分析图上的随机游走时,我们通常会考虑与到达时间相关的两个统计量:游走到达子集的概率和达到该子集所需的期望时间。

##### 到达概率和吸收概率

*到达概率*对于一个节点子集*A* ⊆ *V*来说,是指从节点*u*开始的随机游走将会在该子集内到达节点*v* ∈ *A*的概率。我们可以使用这个度量来回答像“我们的游客遇到咖啡馆的概率是多少?”这样的问题。类似地,*吸收概率*对于一个节点子集*A*来说,是指从节点*u*开始的随机游走会被该子集吸收的概率。这使我们能够问类似“游客回到酒店并停止随机游走的概率是多少?”这样的问题。

吸收子集的节点可以影响非吸收节点的到达概率。例如,考虑图 13-6 中显示的加权图,其中有三个节点和一个吸收子集{0, 1}。

![一个包含三个节点的图。节点 0 有一个自环,权重为 0.25,并且有一条权重为 0.75 指向节点 1 的边。节点 1 有一条权重为 1.0 的边指向节点 0。节点 2 有一个自环,权重为 0.5,并且有一条权重为 0.5 指向节点 1 的边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13006.jpg)

图 13-6:一个包含三个节点和转移概率的图

在给定潜在的无限步数的情况下,从节点 2 开始的随机游走到达节点 0 的概率是 1.0。相反,一旦游走到达节点*A* = {0, 1},它将永远无法到达节点 2,因为它将被困在一个无限的步骤集合中,该集合连接到节点 0 和 1。

##### 期望到达时间

*期望到达时间*是指一个随机游走(平均而言)首次到达子集*A*中的一个节点所需要的步骤数。这使我们能够确定游客平均需要多久才能喝到下一杯咖啡。*吸收时间*类似,量化了随机游走到达吸收集所需的期望时间。对于我们的游客来说,这将是他们的游走将持续多久(直到他们到达酒店并停下来)。

例如,图 13-6 中从节点 0 到节点 1 的预期到达时间(记作 *h*[01])如下所示:

*h*[01] = 1 × *p*(在 1 步后首次到达节点 1)

+ 2 × *p*(在 2 步后首次到达节点 1)

+ 3 × *p*(在 3 步后首次到达节点 1) + . . .

*h*[01] = 1 × ¾ + 2 × ¼ × ¾ + 3 × (¼)² × ¾ + 4 × (¼)³ × ¾ + . . .

*h*[01] = 4/3

这是因为从节点 0 开始的可能游走包括 [0, 1]、[0, 0, 1]、[0, 0, 0, 1] 等等。如果我们拥有完整的转移矩阵,就可以通过一组方程来求解预期到达时间。

预期到达时间可能是无限的,例如 图 13-6 中从节点 0 到节点 2 的 *h*[02]。无论我们考虑多长时间的游走,都无法从节点 0 到达节点 2。

#### 平稳分布

*平稳分布* 表示如果我们在一个强连通的图中永远游走,每个状态的访问分布。这个分布提供了我们在每个节点停留的可能性。例如,我们可以问:“我们的游客已经游走了好几天,当前他们有多大概率在第五街的咖啡馆?”我们还可以利用平稳分布来预测成千上万的游客的可能位置,这些游客都遵循相同的随机化规则在城市中游走。

回到本章早些时候介绍的矩阵表示法(其中 *M* 是转移矩阵,*V*t 是一个概率向量,使得 *V*t [*u*] 是在时间步 *t* 时随机游走位于节点 *u* 的概率),平稳分布是一个向量 *V*^*,其满足:

*V*^* = *V* ^**M*

换句话说,在随机游走中再增加一步也不会改变可能位置的分布。

我们可以从图的结构中推导出平稳分布。考虑 图 13-7 中的两个节点图,其中 *M* = [[0.25, 0.75], [0.5, 0.5]]。

![图中有两个节点。节点 0 有一个自环,权重为 0.25,且有一条指向节点 1 的边,权重为 0.75。节点 1 有一个自环,权重为 0.5,且有一条指向节点 0 的边,权重为 0.5。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13007.jpg)

图 13-7:一个包含两个节点和四个转移概率的图

图 13-7 中图的结构表明,从长远来看,随机游走将在节点 1 上花费比节点 0 更多的时间。由于自环的存在,停留在节点 0 的概率仅为 0.25,而停留在节点 1 的概率为 0.5。我们可以通过平稳分布量化在每个节点上花费的时间差异,对于该图,平稳分布为 *V*^* = [0.4, 0.6]。

### 基于运气的桌面游戏

我们可以结合本节中的主题,考虑随机游走最有趣的应用之一:分析基于运气的儿童棋盘游戏。这些游戏没有实际的选择,而是依赖于转盘或骰子生成的随机数字来决定行动。在花了几个小时转动指示棋子前进一格、两格或三格的拨轮之后,即使是不懂统计学的人也可能会问到目标状态的期望吸收时间,问:“我还需要多久才能玩完这个游戏?”我们可以通过将游戏建模为图上的随机游走来回答这些问题。

考虑一个简单的游戏示例,目标是成为第一个完成棋盘一圈的玩家。在每个玩家的回合中,他们使用一个标有 1、2 和 3 的小转盘,指示要走多少步。图 13-8 显示了游戏中多个状态的图示。节点的数字对应棋盘上的方格,玩家当前所在方格是*k*。根据转盘的随机结果,他们可以移动到方格*k* + 1、*k* + 2 或*k* + 3,每个方格的概率为 1/3。

![一行图节点,每个节点 k 有三条出边,分别指向节点 k + 1、k + 2 和 k + 3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13008.jpg)

图 13-8:表示基于转盘的游戏示例状态的图

为了让游戏更刺激(或可能让更多的孩子因沮丧而哭泣),游戏设计者将某些方格标记为“倒退 X 步”。这些代表应避免的陷阱。我们可以将这种行为直接纳入我们的图中。

图 13-9 显示了一个状态图,其中方格*k* + 2 包含“倒退一格”的指示。这实际上将节点*k* + 2 从图中移除(在图中通过将节点变灰来表示)。在这个状态下无法完成一个回合。相邻节点的转移概率也相应发生变化。从节点*k*到*k* + 1 的概率从 1/3 增加到 2/3,因为现在转动 1 和 2 都会停在方格*k* + 1。类似地,从节点*k* + 1 转动 1 会将玩家带回节点*k* + 1。我们将这建模为一个自环,概率为 1/3。

![一行图节点,节点有指向自身和更右侧节点的边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13009.jpg)

图 13-9:表示从状态 k 在基于转盘的游戏中的移动

虽然我们可以添加更多的边来捕捉基于方格和旋转器的转移,但要捕捉玩家之间互动等方面需要更复杂的模型。本节中介绍的图模型只捕捉了单次随机行走在棋盘游戏中的动态。如果所有玩家是独立的,这样的模型工作得很好。然而,如果游戏提供了一种在某个玩家落到另一个玩家的方格上时将其击退两个方格的能力,那么我们就需要一个同时考虑两个玩家位置的模型。

我们可以通过增加节点的数量来创建此类模型,以匹配棋盘游戏的可用状态。与其为*N*个方格使用*N*个节点,我们可以通过将游戏状态建模为包含爱丽丝位置(玩家 1 的状态)、鲍勃位置(玩家 2 的状态)以及一个布尔值表示是否是爱丽丝回合的三元组,来使用 2*N*²个节点。例如,三元组(5, 4, False)表示爱丽丝在第 5 个方格,鲍勃在第 4 个方格,并且是鲍勃的回合。如果我们将三项选择旋转器与新的“击退”规则结合起来,那么鲍勃有 1/3 的概率旋转到 1,前进一个方格,并将爱丽丝击退两个方格。更正式地说,*p*((5, 4, False) → (3, 5, True)) = 1/3。

### 转移概率

除了分析给定的图形外,我们还可以将本章中的概念扩展到*估计*这些图形本身,从观察数据中进行估算。假设我们发现了一个游客的日志本,他在过去一年里随机地在一个未知城市的街道上漫游。每个条目至少列出了一个地点和一个时间。为了更好地理解他们的旅程,我们考虑他们长时间的访问地点序列。

我们可以轻松地从地点名称重建各个节点,例如整数广场和浮动点港口。稍作推理,我们还可以识别边的存在;例如,从整数广场到“如果-那么”交叉口的过渡暗示着它们之间存在一条边。经过几个小时对游客痛苦循环路径的研究后,我们退后一步,试图重建他们的转移矩阵。

#### 最大似然估计

我们可以利用一系列由一次或多次行走所产生的观察值来估计转移矩阵,从而统计地估计转移概率。*最大似然估计*让我们找到那些最大化在给定参数下观测到样本数据的模型参数(转移概率)。我们在这里不会讨论这种方法的数学细节,但简而言之,最大似然估计利用每一步的独立性,通过计算以下两项量来计算从节点*u*到节点*v*的转移概率:

***N***u 节点 *u* 在数据中作为移动起始节点出现的次数(不是路径中的最后一个节点)

***N***u [→] v 节点 *v* 在数据中紧跟在节点 *u* 之后出现的次数

给定这些信息,我们计算转移的概率如下:

*p*(*u* → *v*) ≈ *N*u [→] v / *N*u

如果我们从节点 1 出发了 100 次,其中有 30 次直接移动到节点 3,那么我们估算 *p*(1 → 3) = 30 / 100 = 0.3。

#### 转移矩阵估算算法

从观察数据中估算一个潜在图形的算法包含三个阶段。在第一个阶段,我们使用行走中访问过的节点来计算图形中节点的数量。这相当于扫描游客的日志并确定我们需要跟踪多少个交叉点。第二,我们构建节点访问次数的计数数组(*N*u)和节点到节点转移的计数矩阵(*N*u [→] v)。我们通过遍历行走中的每一步来计算计数,实际上是在重新追溯游客的旅程。最后,我们通过为所有非零的节点到节点转移插入边来构建图形。

因为我们是从行走中的节点构建图形,所以只包括算法至少访问过一次的节点。这意味着,对于一个断开的图形,我们只会捕捉到从初始起始节点(或节点们)可达的部分。如果游客害怕水并拒绝过桥,那么我们可能会错过河对岸的所有地点。

清单 13-4 反映了这三个阶段。

def estimate_graph_from_random_walks(walks: list) -> Graph:
num_nodes: int = 0
❶ for path in walks:
for node in path:
if node >= num_nodes:
num_nodes = node + 1

counts: list = [0.0] * num_nodes
move_counts: list = [[0.0] * num_nodes for _ in range(num_nodes)]

❷ for path in walks:
for i in range(0, len(path) - 1):
counts[path[i]] += 1.0
move_counts[path[i]][path[i + 1]] += 1.0

g: Graph = Graph(num_nodes)

❸ for i in range(num_nodes):
if counts[i] > 0.0:
for j in range(num_nodes):
❹ if move_counts[i][j] > 0.0:
g.insert_edge(i, j, move_counts[i][j] / counts[i])
return g


清单 13-4:从观察数据估算转移矩阵

该函数接收一个包含多个随机行走的节点的列表的列表(walks)。代码首先使用一对嵌套的 for 循环遍历每个行走和该行走中的每个节点,并记录看到的最大索引 ❶。然后,它为两组计数(counts 和 move_counts)分配数据结构,并使用 for 循环遍历路径中的每一步 ❷ 来填充这些数据。一旦计数完成,代码创建一个图形(g)并用两层嵌套的 for 循环遍历每对节点 ❸。如果这两个节点之间的转移计数非零 ❹,则计算概率并将相应的边插入图形中。

请记住,使用看到的最高索引的缺点在于,如果底层图由多个不连通的组件组成,重建的图将包含我们永远不会访问的索引。由于这些索引的计数为零,它们将没有出边,导致生成的图未能通过is_valid_probability_graph()检查。为了简化并保持与前面章节的一致性,我们在清单 13-4 中使用了最大索引方法,但更健壮的方法是使用*节点名称*到索引的映射,如附录 A 中所述。

作为估算图的示例,考虑一个旅行日志,其中包含两条路径并且访问了编号的建筑物:

path1 = [0, 1, 0, 0, 1, 0, 0]
path2 = [0, 1, 0, 1, 0, 0, 0]


我们可以将这些路径输入到我们的估算函数中,来了解旅游者的度假行为:

g = estimate_graph_from_random_walks([path1, path2])


看这些路径时,有几个要点立刻显现。首先,旅游者总是从节点 0 出发。其次,他们只访问了两个建筑,0 号和 1 号。在这个案例中,0 号建筑是有咖啡馆的大堂的酒店,而 1 号建筑是街对面的咖啡店。

在计算最大似然估计时,我们积累了以下值:

*N*[0] = 8, *N*[1] = 4

*N*[0 → 0] = 4, *N*[0 → 1] = 4, *N*[1 → 0] = 4, *N*[1 → 1] = 0

旅游者从节点 0 出发移动了八次 (*N*[0] = 8)。其中四次停留在节点 0 (*N*[0 → 0] = 4),四次移动到节点 1 (*N*[0 → 1] = 4)。他们从节点 1 出发四次,始终回到节点 0 (*N*[1 → 0] = 4),从未回到节点 1 (*N*[1 → 1] = 0)。

我们使用这些统计数据来估算成对的转移概率:

*p*(0 → 0) = 4 / 8 = 0.5

*p*(0 → 1) = 4 / 8 = 0.5

*p*(1 → 0) = 4 / 4 = 1.0

*p*(1 → 1) = 0 / 4 = 0.0

这些概率提供了估算图的边权重,如图 13-10 所示。注意,我们没有包括边(1, 1),因为它的权重为 0。

![一个包含两个节点和三条边的图。节点 0 有一个自环,权重为 0.5,并且有一条指向节点 1 的边,权重为 0.5。节点 1 有一条回到节点 0 的边,权重为 1.0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f13010.jpg)

图 13-10:一个根据数据估算的边转移概率图

在酒店时,旅游者有 50%的机会停留在酒店,也有 50%的机会走到街对面的咖啡店。然而,在咖啡店时,他们总是直接回到酒店。

#### 处理有限数据的局限性

当然,直到我们积累了足够的观察数据之前,我们对转移概率的估计可能会非常嘈杂。如果我们的图足够大,可能永远也没有机会观察到某些节点或低概率的边。这是使用随机数据时的一个基本问题。如果我们的游客有 5%的几率进入一条狭窄的小巷,我们在看到他们 10 次时,他们可能每次都会合理地跳过这条小巷。

使用统计学方法,我们不仅可以分析最大似然值,还可以分析它们的误差范围和我们的置信水平。这些分析超出了本书的范围。现在,我们只是提醒读者避免从少量的随机数据中得出严谨的结论。

### 随机起始节点

我们可以扩展我们的模型及其估计,考虑随机游走从不同节点开始的情况。*随机起始节点*的概念可能并不直观,因为物理上的游走通常从一个特定地点开始,比如游客的酒店。然而,随机起始点可以代表许多现实世界的现象,例如游客随机选择离开某个酒店,或者谣言从社交网络中的一个随机点开始传播。

让我们将起始概率的向量标记为*S*,其中*S*[*u*]表示我们从节点*u*开始随机游走的概率。由于*S*包含概率,我们限制 0 ≤ *S*[*u*] ≤ 1,并且∑u *S*[*u*] = 1。

#### 选择一个随机起始节点

我们可以用与示例 13-3 中用于 choose_next_node() 相同的方法,直接抽取起始节点:

def choose_start(S: list) -> int:
❶ prob: float = random.random()
cumulative: float = 0.0

❷ for i in range(len(S)):
cumulative += S[i] if cumulative >= prob:
return i
return len(S) - 1


choose_start()函数使用 Python 的 random 库 ❶ 从[0, 1)之间随机抽取一个数。与 choose_next_node() 函数按节点边进行迭代不同,该函数会在*S*中的值上迭代,直到找到正确的值 ❷。

作为一个更详细的例子,想象一个邪恶的巫师,他创建的地下城使用随机化的入口,以防止前来的冒险者编写攻略(从而影响他们的退休计划)。巫师使用精心挑选的分布*S*将每个新到达者传送到一个随机地点。他们通过将宝藏室的起始概率设置为零,*S*[*treasure*] = 0,来限制冒险者永远不能从宝藏室开始他们的探险。为了减少冒险者之间分享信息的可能性,巫师还强制要求所有房间的起始概率 *S*[*room*] < 0.1,这意味着冒险者有不到 10%的概率从任何特定房间开始。

#### 估计起始节点的概率分布

如果我们运行多个随机漫步,可能会对估计起始状态的分布感兴趣。我们可以使用类似于转移概率的方法来估计起始概率。如果*N*[0][*u*]是从节点*u*开始的漫步次数,那么我们将从节点*u*开始的概率定义为该节点开始漫步的次数所占的比例:

S[*u*] = *N*[0][*u*] / ∑v *N*[0][*v*]

我们可以在以下代码中实现这一估计:

def estimate_start_from_random_walks(walks: list) -> list:
num_nodes: int = 0
❶ for path in walks:
for node in path:
if node >= num_nodes:
num_nodes = node + 1
counts: list = [0.0] * num_nodes

❷ for path in walks:
counts[path[0]] += 1.0

for i in range(num_nodes):
    counts[i] = counts[i] / len(walks)
return counts 

与估计转移概率的代码类似,估计起始概率的代码从计算最大节点索引开始,并创建一个数据结构来跟踪出现次数❶。然而,在这种情况下,代码只关注漫步中的第一个节点。它通过一个<sup class="SANS_TheSansMonoCd_W5Regular_11">for</sup>循环遍历不同的漫步,统计有多少次漫步是从每个节点开始的❷。然后它计算每个节点的经验起始概率。

我们的冒险者理论上可以使用这种方法为他们即将发布的战略指南收集信息。他们进入地下城几百次,每次都仔细记录自己落脚的位置。他们可以决定是否值得编写一本全面的地下城指南,尽管这意味着需要一次又一次地穿越相同的地下城。

### 为什么这很重要

在图上分析随机漫步对于理解图的结构以及分析其底层系统是非常有用的。然而,更重要的是,随机漫步的概念扩展了我们可以用图算法建模的现实世界问题的范围。我们可以超越确定性问题,比如如何找到两个节点之间的最短路径,转而考虑更现实的行为。例如,为了模拟有时会发生错误的路径规划,我们可以使用一种随机漫步,它大多数时候走最优路径,但在某些交叉口会随机出错。

在下一章,我们将话题转向另一个方向,从整体容量的角度考虑图,寻找通过网络的最大流,以建模从管道到交通运输的各种系统。




# 第四部分 最大流和二分图匹配






## 第十四章:14 最大流算法



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

前几章已经展示了如何利用图来建模连接性和交通问题。本章考虑了网络的*整体容量*以及流量如何在其中流动。假设我们想要模拟水流通过管道网络的情况。我们可以使用边的权重来表示任意两节点之间可以流过多少水,从而帮助我们确定整个网络的最大容量。

*最大流问题*旨在确定在给定有限容量的边时,图可以支持多少流量。这个表述是有意保持一般性的。我们可能在模拟水流通过管道、人员流动通过交通网络,或者信息流动通过社交网络。每个应用都带来了自己的术语、测量方法和单位。然而,它们都归结为一个基本问题。

本章中,我们考虑如何在有向带权图上计算最大流量,使用*Ford-Fulkerson*和*Edmonds-Karp*算法。在此过程中,我们展示如何扩展我们在前几章中使用的<code class="SANS_TheSansMonoCd_W5Regular_11">Graph</code>和<code class="SANS_TheSansMonoCd_W5Regular_11">Edge</code>数据结构,以支持边的容量的动态使用。

### 最大流问题

给定一个带权图,其中的边表示相邻节点之间流动的方向性和容量,我们如何确定网络中的最大流量?我们将流量起点的节点称为*源节点*并标记为*s*,将流量的目的地节点称为*汇节点*并标记为*t*。我们用*容量*(*u*, *v*)表示从*u*到*v*的边的容量——即该边能支持的最大流量。我们用*流量*(*u*, *v*)表示通过该边的流量。网络中的总流量是从源节点流出的流量(或者,等效地,进入汇节点的流量)。

我们可以将最大流问题在城市外的废水处理系统中进行可视化。假设废水通过一条源管道从城市流出,再通过一条汇管道进入污水处理厂。在源和汇之间,废水通过不同大小的管道流动,其流量在各个节点之间分配和重新组合。管道的容量决定了可以通过的废水量。

为了模拟现实的行为,最大流问题施加了几个约束条件。第一个约束是源节点(城市)只有流出,而汇节点(污水处理厂)只有流入。从数学角度来看:

*容量*(*u*, *s*)= 0 对于每个节点 *u*

对于每个节点 *v*,*capacity*(*t, v*) = 0。

这对应于一个非常合理的约束条件,即不能通过源管道将废水流回城市,也不能从污水处理系统流出任何东西。

第二个约束是,通过一条边(管道)的流量不能小于零,也不能超过该边的容量。用数学术语表示:

对于任意一对节点 *u* 和 *v*,*flow*(*u*, *v*) 的值必须满足 0 ≤ *flow*(*u*, *v*) ≤ *capacity*(*u*, *v*)。

上限转化为管道的物理约束。如果我们试图通过管道推送过多的水,它将爆裂。没有人希望发生这种情况。零的下限表示管道的方向性,例如单向阀门以防止水流反向流过管道。

最后的约束是,对于除源点和汇点之外的所有节点,流入该节点的流量必须等于流出该节点的流量。在数学上,这意味着对于每个节点 *u*:

∑v *flow*(*v*, u*) = ∑v *flow*(*u*, v*)。

这个约束防止了节点处出现无效情况,例如水在管道交汇处神奇地出现或消失。

在本章的最后几节之前,我们施加了一个额外的约束,这将帮助我们推理最大流算法。我们不允许 *反向边*,即在相反方向上连接相同节点的有向边。实际上,这意味着如果从节点 *u* 到节点 *v* 有一条边,我们就不允许从节点 *v* 到节点 *u* 有一条边。这个限制有助于简化后面章节中讨论的 *残余网络* 的定义。我们将在本章末尾放宽这一限制。

图 14-1 是一个关于小图的最大流问题示例。图 14-1(a) 中的边权表示容量。为了计算从节点 0 到节点 3 的源点到汇点的总流量,我们可以沿每条路径加总流量。图 14-1(b) 展示了一个最大流配置。在顶部路径上,我们可以从节点 0 向节点 1 发送 5 单位的流量。从节点 1 到节点 3 的边可以承载更多的流量,但这并不会帮助我们。我们无法将超过 5 单位的流量送到节点 1,因此我们无法让流量超过 5 单位。因此,顶部路径的最大流为 5。

![(A) 显示了一个有四个节点和四条边的图,每条边都标有其容量。边 (0, 1) 的容量为 5,边 (0, 2) 的容量为 10,边 (1, 3) 的容量为 10,边 (2, 3) 的容量为 1。 (B) 显示了相同的图,并标注了每条边的容量及其使用情况。边 (0, 2) 被标记为 1/10。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14001.jpg)

图 14-1:带有容量(a)的图和沿图边的最大流(b)

类似地,图 14-1(a)显示了图中底部路径上的一对限制。虽然从节点 0 到节点 2 的边看起来很有前景,容量为 10,但我们将无法将如此多的流量*从*节点 2 传出。边(2, 3)成为一个严重的瓶颈,容量为 1。就像一条大水管过渡到小水管一样,这些边的组合将底部路径的总容量限制为 1,网络中的总流量限制为 6。

对于更大的图,最大流问题变得更加复杂。考虑在图 14-2 中,当我们从节点 2 到节点 1 添加一条容量为 7 的新边时会发生什么。也许由于不断发生下水道倒灌,政府决定从节点 2 到节点 1 建造一条新管道。边(2, 1)为节点 2 的流量提供了另一条路径。最多可以有 7 个单位的流量分流并通过边(2, 1),同时 1 个单位继续通过(2, 3)。

![(A)显示了一个包含四个节点和五条边的图,每条边上标有其容量。(B)显示了相同的图,并标出了边的容量和已使用的流量。边(0, 1)使用了 5 个单位的容量,边(0, 2)使用了 6 个单位的容量,边(1, 3)使用了 10 个单位的容量,边(2, 1)使用了 5 个单位的容量,边(2, 3)使用了 1 个单位的容量。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14002.jpg)

图 14-2:带容量的第二个示例图(a)及其沿这些边的最大流量(b)

然而,我们需要确保通过节点 1 的新路径能够处理这额外的流量。我们已经有 5 个单位的流量从节点 0 到节点 1。由于边(1, 3)的容量是 10,而我们已经使用了 5 个,所以它仅剩下 5 个单位的容量。尽管建立了一条容量为 7 的新边,但我们只能通过网络传送额外的 5 个单位流量。

### 应用场景

最大流问题自然反映了一系列现实世界的现象,包括液体通过管道的流动、人们通过交通网络的流动,或者信息通过社交网络的流动。

#### 物理管道

最大流问题中的许多术语源于物质通过管道流动的物理现象。术语*源*、*汇*、*容量*,甚至*流量*都与它们的物理对应物相对应。我们可以轻松地将这些物理问题映射到它们的计算等效问题。

尽管本章的主要示例是废水系统中的水流,管道类比的应用远超污水或室内管道,我们可以提出更多的问题。也许我们对枫糖浆在加工厂中的流动感兴趣。给定一系列复杂的管道和节点,整个系统的容量是多少?在不导致枫糖浆加工失败的情况下,我们可以通过它输送多少液体?这些初步问题为进一步的分析和优化奠定了基础,包括回答“当前系统中的瓶颈在哪里?”或“我们应该通过增加另一根管道来扩展容量吗?”等后续问题。

#### 交通网络

交通网络也是进行最大流分析的肥沃土壤。想象一下,你最喜欢的体育队在远方城市举行冠军赛。成千上万的本地粉丝想飞到那里,参加这场无疑是本世纪最重要的比赛。航空公司可以将这种需求建模为最大流问题,以确定目前有多少粉丝能够在两座城市之间旅行。边是城市对之间的航线,每条航线有一定数量的座位,构成了其容量。流量是已经占用的座位数。本地城市是源节点,粉丝们从这里出发前往目的地,而东道城市是汇点节点。

航空公司可以通过此分析判断是否需要增加航班。如果有大量的粉丝需求远超现有航班的容量,那么就有更多的利润空间。

#### 通信网络

我们还可以使用最大流问题来建模信息通过通信或社交网络的传递。例如,假设你想通过战略性地在社交网络中传递信息来影响另一个人的决定。也许你正在试图说服你最喜欢公司的招聘经理,你将是前任首席执行官的理想继任者。为了影响他们的决定,你开始分享自己令人惊叹的成就故事,使你成为源节点,而招聘经理则是汇点节点。

不幸的是,网络中的成员在传递此类消息时有时间和兴趣的限制。这个容量在任意两个节点之间是不同的。也许两个朋友每天早上都一起喝咖啡,其中一个可以将大量的信息传递给另一个。然而,紧张的关系可能导致信息传递能力受限。将这种情况建模为最大流问题有助于你确定实际能够传递到汇点节点的信息量。

### 扩展数据结构

在介绍第一个算法之前,我们需要扩展 Edge 和 Graph 数据结构,以便完整地表示容量和流量。由于 Graph 数据结构中的边权无法同时表示容量限制和已使用的数量,而最大流问题需要能够捕捉到既有 *固定总容量* 又有 *动态流量* 的图。

在本节中,我们定义了两个新的数据结构。CapacityEdge 类基于我们的 Edge 类,并增加了支持表示已使用容量的功能。ResidualGraph 类与 Graph 类的结构类似,但增加了跟踪图中动态变化流量的功能。

#### 带容量的边

为了建模最大流问题,图的边需要能够存储两个信息:固定的总容量和动态的流量。我们定义了一个 CapacityEdge 类,用于存储以下信息:

from_node **(**int**) **存储边的起始节点索引

to_node **(**int**) **存储边的目标节点索引

capacity **(**float**) **存储边的总容量

used **(**float**) **存储边的已使用容量

我们将边中的单一权重值替换为 capacity 和 used 的组合。 图 14-3 显示了在流量上下文中这些属性的可视化,其中 capacity 是管道的宽度,used 是已占用的量。

![一条边被表示为一根管道,宽度等于总容量,已使用的部分被阴影标示。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14003.jpg)

图 14-3:一个 CapacityEdge 对象的属性

与我们在第一章中定义并在全书中使用的<sup class="SANS_TheSansMonoCd_W5Regular_11">Edge</sup>数据结构不同,CapacityEdge对象既提供存储功能,又提供操作这些存储的函数,如下所示:

class CapacityEdge:
def init(self, from_node: int, to_node: int, capacity: float):
self.from_node: int = from_node
self.to_node: int = to_node
self.capacity: float = capacity
❶ self.used: float = 0.0

def adjust_used(self, amount: float): 
  ❷ if self.used + amount < 0.0 or self.used + amount > self.capacity:
        raise Exception("Capacity Error")
    self.used += amount

def capacity_left(self) -> float: 
    return self.capacity - self.used

def flow_used(self) -> float: 
    return self.used 

构造函数初始化对象的变量,将used设置为0,表示边的初始流量为零❶。接下来,adjust_used()函数允许算法修改流经该边的流量。它接受一个调整值,并将其添加到当前使用的流量中。我们可以将该函数形象化为水龙头的旋钮。如果我们将其旋转一侧,传入正值,流量增加;如果将其旋转到另一侧,传入负值,流量减少。然而,与水龙头不同的是,当流量达到上限时,函数不会自动“停止旋转”。代码中包含额外的检查,以确保使用的容量在边的规定限制范围内❷。具体而言,流经一条边的流量不能小于 0,也不能大于该边的总容量。

进一步推展水龙头的类比,我们可能希望能够指示每个方向上可以旋转水龙头的程度。我们提供了函数capacity_left(),以指示边上剩余的未使用容量(也称为*前向残余*)。这是我们可以在边上增加的额外流量。类似地,我们提供了函数flow_used(),用于指示当前已使用的容量(也称为*反向残余*)。

#### 残余图

就像我们需要添加功能来追踪边中已使用的容量一样,我们还必须增强图的表示,以支持这些动态边的存储和计算。我们还添加了与最大流问题相关的辅助跟踪信息,即源节点和汇节点的索引。我们将这种增强的图称为*残余图*,因为它跟踪节点对之间的剩余(或剩余)容量。

我们定义了一个ResidualGraph类,它使用更简化的邻接表表示,并包含以下内容:

num_nodes **(**int**)** 存储图中节点的总数。

source_index **(**int**)** 存储源节点的索引。

sink_index **(**int**)** **存储汇点节点的索引。

edges **(**list**)** **存储一个字典,该字典包含每个节点的相邻边对象,按目标节点键控。要访问从节点j到节点k的CapacityEdge,我们使用edges[j][k]。

all_neighbors **(**list**)** **存储每个节点的所有入邻居和出邻居索引的集合。

ResidualGraph表示与<class="SANS_TheSansMonoCd_W5Regular_11">Graph类的不同之处在于,我们不再存储Node对象。相反,相同的邻接表信息,包括字典的使用,被合并到edges列表中。虽然这种方式呈现了一个更紧凑的表示方式,足以支持最大流算法,但我们失去了在节点中轻松存储辅助数据的能力,这在其他算法中曾被使用。

尽管我们在处理有向图,我们介绍的算法仍需要扫描所有邻居节点,包括不包含在传统邻接表中的入邻居。为了方便这些计算,我们存储了额外的列表all_neighbors。将任意两个节点之间的连接限制为单一的有向CapacityEdge(不允许反向平行边)使得前向和后向流的推理变得更加容易。如我们所见,这种限制并不减少图的表示能力,因为我们可以将一个带有反向平行边的图转化为一个没有这些边的图。

为了演示 edges 和 all_neighbors 列表如何捕获图的结构,考虑图 14-4 所示的例子 ResidualGraph 以及其两个列表数据结构。左侧显示的是四节点图,中央是对应的 edges 列表,右侧是 all_neighbors 列表。节点 1 有两条出边(节点 2 和节点 3),因此在其邻接字典 edges[1] 中有两个条目。字典 edges[1] 中的每个条目将邻居的索引映射到对应的 CapacityEdge,即从节点 1 出发的边。由于节点 1 还从节点 0 获得一条入边,因此集合 all_neighbors[1] 包含三个索引:0、2 和 3。

![一个四节点图,edges 数组将每个节点映射到一个目标字典,all_neighbors 数组将每个节点映射到它的邻居集合。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14004.jpg)

图 14-4:一个残余图及其内部列表数据结构

ResidualGraph 类提供了用于创建和操作这种类型图的函数:

class ResidualGraph:
def init(self, num_nodes: int, source_index: int, sink_index: int):
self.num_nodes: int = num_nodes
self.source_index: int = source_index
self.sink_index: int = sink_index
self.edges: list = [{} for _ in range(num_nodes)]
self.all_neighbors: list = [set() for _ in range(num_nodes)]

def get_edge(self, from_node: int, to_node: int) -> Union[CapacityEdge,
                                                          None]: 
    if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError
    if to_node in self.edges[from_node]:
        return self.edges[from_node][to_node]
    return None

def insert_edge(self, from_node: int, to_node: int, capacity: float): 
  ❶ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

  ❷ if from_node == self.sink_index:
        raise ValueError("Tried to insert edge FROM sink node.")
    if to_node == self.source_index:
        raise ValueError("Tried to insert edge TO source node.")
    if from_node in self.edges[to_node]:
        raise ValueError(f"Tried to insert edge {from_node}->{to_node}, "
                         f"edge {to_node}->{from_node} already exists.")
    if capacity <= 0:
        raise ValueError(f"Tried to insert capacity {capacity}")

  ❸ self.edges[from_node][to_node] = CapacityEdge(from_node, to_node,
                                                  capacity) ❹ self.all_neighbors[from_node].add(to_node)
    self.all_neighbors[to_node].add(from_node)

def compute_total_flow(self) -> float: 
    total_flow: float = 0.0
    for to_node in self.edges[self.source_index]:
        total_flow += self.edges[self.source_index][to_node].flow_used()
    return total_flow 

构造函数通过为所有节点创建空的邻接字典(edges)和邻居集合(all_neighbors)来创建一个空图。接下来,get_edge() 函数是从 Graph 类中镜像出来的,允许我们访问每条边。这个函数的大部分代码用于边界检查:如果 from_node 或 to_node 不在图中,函数会抛出 IndexError。如果边不在图中,函数返回 None。如果节点有效且边存在于图中,代码会返回对应的 CapacityEdge。这段代码依赖于从 typing 库导入 Union 来支持多种返回类型的类型提示。

由于 ResidualGraph 使用不同的结构来存储具有容量的边并添加更多的邻居信息来跟踪传入边,因此 insert_edge() 函数需要相应地跟踪这些信息。代码首先进行与我们在 Graph 类中使用的相同的索引有效性检查 ❶,并添加检查以确保我们在最大流问题中对图的结构约束 ❷。这些检查包括: (1) 确保汇节点没有出边,(2) 确保源节点没有入边,(3) 确保新插入的边不是现有边的反向,(4) 确保容量大于零。

如果所有检查都通过,代码会创建一个新的 CapacityEdge 并将其添加到 edges 列表的相应条目中 ❸。如果在同一方向上这两个节点之间已经插入了边,代码会覆盖它。最后,代码将 to_node 添加到 from_node 的所有邻居列表中,并将 from_node 添加到 to_node 的所有邻居列表中 ❹。

compute_total_flow() 函数演示了如何使用 ResidualGraph 中的值来推理其属性,通过对每条从源节点离开的边的流量求和来计算从源到汇的总流量。由于所有流量都来自单一源节点,因此这就是图中的总流量。

ResidualGraph 类中的其余函数与 Ford-Fulkerson 算法的操作密切相关;我们将在介绍算法时结合上下文展示它们。

### Ford-Fulkerson 算法

数学家 L.R. Ford Jr. 和 D.R. Fulkerson 开发了一种通用方法,通过反复找到源到汇之间的未充分利用的路径,并增加这些路径上的流量,从而找到图中的最大流。这种方法依赖于 *增广* *路径* 的概念,即从源节点到汇节点的路径,沿着该路径可以增加流量。Ford-Fulkerson 技术上是一个通用方法,涵盖了一系列特定的算法,因为原始论文没有指定用哪种搜索算法来查找增广路径。本节介绍了一个使用深度优先搜索的示例实现。

一般的 Ford-Fulkerson 方法可能会在容量使用无理数的病态情况下无法终止。这些情况可以通过限制容量的精度来避免,或者正如本章稍后所示,通过选择具有最少边的增广路径来避免。

#### 定义增广路径

增广路径的最简单形式是从源点到汇点的一系列有向边,这些边的当前流量小于其容量。在这种情况下,如图 14-5(a)所示,我们可以沿路径[0, 2, 3]添加流量,将总流量增加 2 个单位。图 14-5(b)展示了结果,总流量从源点离开并进入汇点,达到了 7 个单位。

![(A) 显示了一个包含四个节点和五条边的图。每条边标有其容量以及已使用的流量。边(0, 1)使用了 5/5,边(0, 2)使用了 0/10,边(1, 2)使用了 1/1,边(1, 3)使用了 4/10,边(2, 3)使用了 1/3。(B) 显示了相同的图,其中边(0, 2)和(2, 3)已加粗。每条边的已用容量增加了 2。边(0, 1)使用了 5/5,边(0, 2)使用了 2/10,边(1, 2)使用了 1/1,边(1, 3)使用了 4/10,边(2, 3)使用了 3/3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14005.jpg)

图 14-5:添加流量前后的容量图(a)和(b)

然而,仅仅增加前向流量只能部分解决问题。图 14-6 展示了这样一种情况:从源点到汇点没有未使用容量的路径。该图并未达到最大流量,因为从节点 1 到节点 2 的流量正在从节点 1 到节点 3 的潜在流量中抽取流量。同时,这种流量导致了从节点 2 到节点 3 的边被完全利用,从而无法接受来自边(0, 2)的更多流量。

![一个包含四个节点和五条边的图。每条边标有其容量以及已使用的流量。边(0, 1)使用了 5/5,边(0, 2)使用了 2/10,边(1, 2)使用了 1/1,边(1, 3)使用了 4/10,边(2, 3)使用了 3/3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14006.jpg)

图 14-6:没有低利用率前向路径的图

我们可以使用图 14-7 中展示的两个步骤来增加图的流量。进入节点 1 的 5 个单位流量最初被分成两个流,1 个单位流量流向节点 2,4 个单位流量流向节点 3。我们将这种分配更改为将 1 个额外的单位流量引导到节点 3,如图 14-7(a)所示。图中的总流量保持不变,但边(2, 3)上的流量现在降到容量以下。其次,我们增加了从节点 0 通过节点 2 到节点 3 的流量,如图 14-7(b)所示,将图的整体流量增加到 8 个单位。

![(A)展示了图 14-6 中的图形,边(1, 2)、(1, 3)和(2, 3)加粗显示。边(0, 1)使用了 5 个单位的 5 容量,边(0, 2)使用了 2 个单位的 10 容量,边(1, 2)使用了 0 个单位的 1 容量,边(1, 3)使用了 5 个单位的 10 容量,边(2, 3)使用了 2 个单位的 3 容量。(B)展示了加粗了边(0, 2)和(2, 3)的相同图形。边(0, 1)使用了 5 个单位的 5 容量,边(0, 2)使用了 3 个单位的 10 容量,边(1, 2)使用了 0 个单位的 1 容量,边(1, 3)使用了 5 个单位的 10 容量,边(2, 3)使用了 3 个单位的 3 容量。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14007.jpg)

图 14-7:在图 14-6 中添加流量的两步

为了重新路由流量,算法还需要能够通过将流量转移到另一条边上来减少沿某条边的流量。因此,我们将沿有向边(*u*,*v*)的*残余*定义如下。向前残余是从节点*u*到节点*v*的未使用容量,即*capacity*(*u*,*v*)−*flow*(*u*,*v*)。这与我们通常所理解的额外容量一致。向后残余是沿着与边方向相反的方向,即从节点*v*到节点*u*的已使用容量*flow*(*v*,*u*)。这对应于可以从节点*u*的输入中移除的容量,允许我们接受来自其他地方的输入。

我们可以通过低利用率的有向边传递更多流量,或者将流量推回有向边的相反方向。图 14-8 展示了结合向前和向后残余的示例情况。

![图 14-6 中的图形,边(0, 2)、(1, 2)和(1, 3)加粗显示。边(0, 1)使用了 5 个单位的 5 容量,边(0, 2)使用了 2 个单位的 10 容量,边(1, 2)使用了 1 个单位的 1 容量,边(1, 3)使用了 4 个单位的 10 容量,边(2, 3)使用了 3 个单位的 3 容量](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14008.jpg)

图 14-8:包含沿边减少流量的增广路径

加粗的边表示从源到汇的无向路径,我们可以按如下方式修改流量:

+   边(0, 2)有一个向前残余值为 8,因此我们可以通过该边向节点 2 添加更多单位的流量。

+   边(1, 2)有一个向后残余值为 1,因此我们可以将该流量减少 1 个单位,以便允许节点 2 从另一个源(在本例中是节点 0)接收更多流量。由于节点 2 的输出流量上限为 3 个单位,并且我们需要保持流入和流出相等,我们需要减少从节点 1 流入节点 2 的流量,以便增加从节点 0 流入节点 2 的流量。

+   边(1, 3)有一个向前残余值为 6,因此它可以接收节点 1 不再流向节点 2 的额外输出。同样,我们需要保持节点 1 的流入流量与流出流量平衡。

理解福特-福尔克森算法的关键在于,推动流量通过反向边实际上是减少从源节点流出的流量,使其能够流向新的目的地。正如我们将在下一节中看到的那样,我们需要在任一方向推动流量,这意味着仅仅探索每个节点(有向)邻接表中的边已经不再足够。我们需要考虑进出节点的边。

我们可以将这个算法可视化为一个污水工程师管理之前描述的污水系统。该工程师通过将流量引导到最佳管道集来最大化总的污水流量。主要约束是管道(边)和接头箱(节点)的容量。最后一位工程师为了炫耀,忽略了总容量,推动了超过实际可行的流量。超负荷的管道迅速爆裂,导致了一个污水喷泉,这个事件在报纸上讨论了好几周。

新的工程师通过不断寻找从源点到汇点的路径,来解决这个问题,直到该路径无法再承载更多污水,并且将尽可能多的污水通过该路径传输(但不超过)。有时这意味着要反向推动已有的流量,这没问题,只要这些流量能够通过另一个接头(节点)流向汇点。任何流入接头的污水也必须流出。否则,它有可能导致接头箱爆裂。工程师不断增加流量,直到所有路径都完全饱和。

#### 寻找增广路径

在我们定义搜索算法之前,我们需要形式化路径上剩余流量的计算。从前面的描述中我们可以记得,增广路径可以包含正向剩余流量和反向剩余流量的组合。我们在 ResidualGraph 类中定义了一个辅助方法,以简化计算路径上任意两节点之间剩余流量(无论是正向还是反向)的逻辑:

def get_residual(self, from_node: int, to_node: int) -> float:
❶ if to_node not in self.all_neighbors[from_node]:
return 0

❷ if to_node in self.edges[from_node]:
return self.edges[from_node][to_node].capacity_left()
else:
return self.edges[to_node][from_node].flow_used()


get_residual() 函数首先检查两个节点是否连接 ❶。如果没有连接,那么该边既不是正向边也不是反向边,剩余流量为零。如果边(from_node,to_node)在有向边的邻接表中,那么它是正向边 ❷,函数返回剩余容量(正向剩余流量)。否则,该边必定存在于相反方向,因此代码返回已使用的流量(反向剩余流量)。

在本节中,我们使用修改后的深度优先搜索来检查图中是否存在增广路径:

def find_augmenting_path_dfs(g: ResidualGraph) -> list:
seen: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
augmenting_path_dfs_recursive(g, g.source_index, seen, last)
return last

def augmenting_path_dfs_recursive(g: ResidualGraph, current: int,
seen: list, last: list):
seen[current] = True
for n in g.all_neighbors[current]:
❶ if not seen[n] and g.get_residual(current, n) > 0:
last[n] = current
❷ if last[g.sink_index] != -1:
return
augmenting_path_dfs_recursive(g, n, seen, last)


这段代码由一对函数组成。首先,find_augmenting_path_dfs() 函数设置了用于深度优先搜索的列表 seen 和 last,如第四章中所描述。接着,它调用递归的深度优先搜索函数,并返回表示增广路径的 last 列表。

augmenting_path_dfs_recursive() 函数执行递归的深度优先探索。与标准的深度优先搜索相同,它将当前节点标记为已访问,然后遍历该节点的邻居。代码使用 for 循环遍历残余图的 all_neighbors 列表,沿着有向边的两个方向进行探索。在探索当前节点的邻居时,代码会检查该节点是否未被访问(如同标准的深度优先搜索)以及残余是否非零 ❶。这个条件防止了搜索使用已经饱和的边。如果边是可行的并且该节点未被访问,搜索将更新跟踪信息,并递归地探索该节点。

代码包含一个可选的提前终止检查 ❷。它会在从源节点到汇节点找到路径时停止探索新的邻居。通过检查 last[g.sink_index] 是否已被赋值,代码可以跳过在汇节点之前和路径上较早节点的递归探索。

图 14-9 显示了在图 14-6 中的图上,find_augmenting_path_dfs() 的迭代过程。每条边都标记为 *X* / *Y*,其中 *X* 是已使用的流量,*Y* 是该边的总容量。已访问的节点为阴影标记,虚线圈中的节点是刚刚调用递归函数的节点。

![在(A)中,节点 0 被圈出,last 的值为[–1, –1, –1, –1]。在(B)中,节点 2 被圈出,last 的值为[–1, –1, 0, –1]。在(C)中,节点 1 被圈出,last 的值为[–1, 2, 0, –1]。在(D)中,没有节点被圈出,last 的值为[–1, 2, 0, 1]。](../images/f14009.jpg)

图 14-9:查找增广路径的搜索步骤

图 14-9(a) 显示了源节点未被访问前算法的状态。图 14-9(b) 显示了搜索的第二步:在访问节点 0 后,算法找到了两个邻居节点 1 和 2。只有边(0, 2)的容量未使用,因此搜索继续沿该分支进行。

由于该算法考虑了出边和入边,它在节点 2 找到两个选项。边 (1, 2) 和 (2, 3) 在各自的方向上已满载。然而,边 (1, 2) 是从节点 2 进入的,因此它有一个反向剩余值为 1。这条边为我们提供了一个减少流入节点 2 的机会。如图 14-9(c) 所示,搜索沿这条边继续,探索节点 1。

在探索节点 1 时,算法找到了一条通向汇点节点的未使用容量的路径。代码不会访问汇点节点,而是一旦找到任何路径便返回。在此情况下,如图 14-9(d) 所示,算法返回一个 last 数组 [-1, 2, 0, 1],表示增广路径。

#### 更新路径的容量

在找到增广路径后,Ford-Fulkerson 算法必须确定可以通过路径推送多少额外的流量,然后更新路径的容量以表示增加的流量。为此,我们向 ResidualGraph 类添加了两个函数。min_residual_on_path() 函数通过最后指针遍历路径,并计算路径上任何边的最小剩余值:

def min_residual_on_path(self, last: list) -> float:
min_val: float = math.inf

current: int = self.sink_index

❶ while current != self.source_index:
prev: int = last[current]
if prev == -1:
raise ValueError
min_val = min(min_val, self.get_residual(prev, current))
❷ current = prev
return min_val


代码首先将 min_val 设置为无穷大(这要求文件包含 import math),作为尚未找到最小值的标志。然后,它使用 while 循环从汇点反向遍历指针链,直到到达源节点 ❶。在每一步,它考虑当前节点前面的节点,并检查它是否不是 -1,如果是 -1,则表示路径断开。代码通过在当前边上使用 get_residual() 更新最小值,并继续前进到前一个节点 ❷。在检查完路径上的所有边后,代码返回它遇到的最小剩余值。

如果我们对图 14-6 中显示的结果应用 min_residual_on_path(),并且 last 数组为 [-1, 2, 0, 1],我们就可以遍历图 14-8 中显示的路径。该路径上的最小剩余值是沿边 (1, 2) 的 1。

一旦我们确定了可以通过路径推送多少额外的流量,我们就使用 update_along_path() 函数来更新路径:

def update_along_path(self, last: list, amount: float):
current: int = self.sink_index
❶ while current != self.source_index:
prev: int = last[current]
if prev == -1:
raise ValueError

  ❷ if current in self.edges[prev]:
        self.edges[prev][current].adjust_used(amount) else:
        self.edges[current][prev].adjust_used(-amount)
    current = prev 

与min_residual_on_path()函数类似,update_along_path()函数的代码通过使用while循环从汇点向源点回溯最后的指针 ❶。同样,它会检查前一个节点是否表示一个有效路径。如果有效,它会检查路径上边的方向,然后更新已用量 ❷。前向边在邻接表中出现,代码将新流量添加到已用容量上。反向边则是算法将流量回推的地方。边的方向是相反的,因此该边本身不在邻接表中。代码从已经使用的量中减去新流量。

#### 将所有部分组合在一起

使用深度优先搜索的福特-富尔克森算法由我们在整个章节中介绍的各个部分组成。如清单 14-1 所示,算法反复搜索增广路径。当找到增广路径时,它计算路径上的最小剩余,并相应地增加流量。

def ford_fulkerson(g: Graph, source: int, sink: int) -> ResidualGraph:
❶ residual: ResidualGraph = ResidualGraph(g.num_nodes, source, sink)
for node in g.nodes:
for edge in node.edges.values():
residual.insert_edge(edge.from_node, edge.to_node, edge.weight)

❷ done = False
while not done:
❸ last: list = find_augmenting_path_dfs(residual)
❹ if last[sink] > -1:
min_value: float = residual.min_residual_on_path(last)
residual.update_along_path(last, min_value)
else:
done = True

return residual 

清单 14-1:使用深度优先搜索的福特-富尔克森算法

代码通过创建一个ResidualGraph开始,其中容量等于原始图的权重 ❶。这有效地复制了图形,同时也转变了表示方式。

算法的主循环相对较小,开始时使用一个布尔值done来跟踪上一次迭代是否找到了增广路径 ❷。如果找到了,done将被设置为False,代码将继续搜索新的增广路径 ❸。代码检查返回的路径是否有效 ❹,如果有效,使用min_residual_on_path()函数计算路径上的最小剩余,并使用update_along_path()函数更新路径上的流量。如果代码在汇点处发现last值为-1,说明从源点到汇点没有路径,可以将done设置为True。该函数最后返回剩余图。

#### 一个示例

图 14-10 展示了 Ford-Fulkerson 算法在一个示例图上运行的情况,其中每个子图表示算法进行一次迭代后的<sup class="SANS_TheSansMonoCd_W5Regular_11">ResidualGraph</sup>状态。加粗的箭头表示在每次迭代中找到的增广路径,并且已更新容量的使用部分,完全使用该增广路径,如每条边上的*X*和*Y*符号所示。

![每个子图展示相同的图,包含 7 个节点和 10 条边。图 (A) 中没有使用容量的边。图 (B) 显示相同的图,其中边(0, 1)、(1, 3)和(3, 6)被加粗,所有边的容量都使用了 2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14010.jpg)

图 14-10:带有深度优先搜索的 Ford-Fulkerson 算法步骤,应用于包含七个节点的图

图 14-10 展示了使用深度优先搜索如何影响 Ford-Fulkerson 算法获取增广路径的顺序。例如,尽管在示例图底部通过边(0, 2)、(2, 5)和(5, 6)存在一条容量为 3 的路径,但算法首先填充了一些较小的流量,例如图 14-10(c)中的容量为 1 的路径。

图 14-10(e)展示了一条使用正向和反向剩余流的增广路径。为了增加通过边(4, 6)到达汇点的流量,算法将流量从节点 1 的边(1, 3)重定向到边(1, 4)。这给节点 4 提供了 2 个单位的输入,算法可以将其传递到汇点。然而,这使得节点 3 少了 1 个单位的流量。搜索通过从源点通过边(0, 3)的额外流量来弥补节点 3 的输入损失。

一旦算法计算出<sup class="SANS_TheSansMonoCd_W5Regular_11">ResidualGraph</sup>,我们可以使用该数据结构来回答其他问题。例如,我们可以使用<sup class="SANS_TheSansMonoCd_W5Regular_11">compute _total_flow()</sup>函数来计算图的最大流量。

### 埃德蒙兹-卡普算法

计算机科学家耶菲姆·迪尼茨(以 E.A. Dinic 为名)和计算机科学家杰克·埃德蒙兹与理查德·M·卡普分别发表了关于 Ford-Fulkerson 算法的分析,这些分析选择了边数最少的增广路径。这个方法现在被称为*迪尼茨算法*或*埃德蒙兹-卡普算法*,它通过这种路径选择避免了在使用非理性边容量时的种种问题,从而在所有情况下限制了算法的迭代次数。本节将展示如何利用广度优先搜索来找到这样的增广路径。

#### 代码

大多数 Edmonds-Karp 算法使用之前介绍的 Ford-Fulkerson 算法中的函数,如update_along_path()和min_residual_on_path()。我们需要更改的只是寻找增广路径的函数和调用它的外部函数。

我们使用改进版的广度优先搜索来寻找增广路径:

def find_augmenting_path_bfs(g: ResidualGraph) -> list:
seen: list = [False] * g.num_nodes
last: list = [-1] * g.num_nodes
pending: queue.Queue = queue.Queue() ❶ seen[g.source_index] = True
pending.put(g.source_index)
❷ while not pending.empty() and not seen[g.sink_index]:
current: int = pending.get()
for n in g.all_neighbors[current]:
❸ if not seen[n] and g.get_residual(current, n) > 0:
❹ pending.put(n)
seen[n] = True
last[n] = current

return last 

find_augmenting_path_bfs()函数首先设置标准的广度优先搜索数据结构,包括每个节点是否已被访问的列表(seen),路径上前一个节点的列表(last),以及待探索节点的队列(pending)。使用Queue数据结构需要在文件顶部额外添加import queue。然后,函数将源节点插入为起点❶。主while循环会继续,直到待处理队列为空或汇点节点已被访问❷。与深度优先搜索代码一样,这第二个检查使得搜索一旦找到*任何*从源点到汇点的路径就终止。

在探索当前节点的邻居时,代码检查该节点是否未被访问(如同标准的广度优先搜索)以及残量是否非零❸。如果该边是可行的且节点未被访问,搜索会更新追踪信息并将其添加到队列中❹。

图 14-11 展示了find_augmenting_path_bfs()在一个部分容量已被使用的图上的迭代过程。阴影部分的节点表示已被访问,虚线框中的节点是搜索刚刚*处理完成*的节点。

图 14-11(a)展示了while循环开始前算法的状态,而图 14-11(b)展示了搜索的第一步。访问节点 0 后,我们发现了两条未访问邻居节点(节点 1 和 3)之间具有未使用容量的边。两邻居被加入到pending队列中。

该算法在图 14-11(c)中与标准的广度优先搜索有所不同。尽管节点 2 是节点 1 的邻居,但边(1, 2)已经满了。我们无法通过该边再发送流量,因此算法排除了使用该边的路径,并将节点 2 保持为未访问状态。直到图 14-11(d),它才找到一条通向节点 2 的可行路径(从节点 3 出发)。

![每个子图显示一个包含六个节点和八条边的图。在 (A) 中,节点 0 被阴影标记,且没有节点被圈出。在 (B) 中,节点 0 被圈出,节点 0、1 和 3 被阴影标记。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14011.jpg)

图 14-11:搜索增广路径的步骤

搜索在 图 14-11(e) 中完成,找到通向汇节点的路径。此时,它已经找到了一个可行的路径 [0, 1, 4, 5] 从源节点到汇节点,无需考虑其他节点。

顶级 Edmonds-Karp 算法的代码与 清单 14-1 中的 Ford-Fulkerson 深度优先搜索版本几乎相同:

def edmonds_karp(g: Graph, source: int, sink: int) -> ResidualGraph:
residual: ResidualGraph = ResidualGraph(g.num_nodes, source, sink)
for node in g.nodes:
for edge in node.edges.values():
residual.insert_edge(edge.from_node, edge.to_node, edge.weight)

done = False
while not done:
  ❶ last: list = find_augmenting_path_bfs(residual)
    if last[sink] > -1:
        min_value: float = residual.min_residual_on_path(last)
        residual.update_along_path(last, min_value)
    else:
        done = True
return residual 

与 清单 14-1 中的代码唯一显著的不同是使用了函数 find_augmenting_path_bfs() 来进行增广路径的搜索 ❶。

#### 一个示例

图 14-12 显示了在一个包含 8 个节点和 11 条边的图上运行 Edmonds-Karp 算法的示例,其中节点 0 为源节点,节点 7 为汇节点。 图 14-12(a) 表示第一次迭代前的 ResidualGraph 状态。此时,所有边的容量都没有被使用。算法的每个后续步骤都显示在*每次*增广路径更新后;加粗的边表示所使用的增广路径。例如,在 图 14-12(b) 中,边 (0, 1)、(1, 2) 和 (2, 7) 形成增广路径。最小残余为 3,子图显示了在该路径上增加 3 个单位流量后的已使用容量。

![每个子图显示相同的包含 8 个节点和 11 条边的图。图 (A) 没有边使用容量。(B) 显示相同的图,边 (0, 1)、(1, 2) 和 (2, 7) 被加粗。所有边使用了 3 单位的容量。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14012.jpg)

图 14-12:在一个包含八个节点的图上执行 Edmonds-Karp 算法的步骤

图 14-12(f)展示了算法使用反向剩余流的步骤。在经过四轮仅沿前向边缘的搜索后,搜索遇到了瓶颈,并*减少*了从节点 5 到节点 4 的边的流量,以释放更多的容量。为了理解这种操作的帮助,考虑从节点 0 到节点 5 的流量。在最后一步之前,流量已经达到了最大值。该边无法处理超过 10 个单位的流量。然而,这个流量并未得到最优利用。通过减少从节点 5 到节点 4 的流量,我们可以将更多的流量通过节点 6 送往汇点。这使得节点 4 的流入流量低于流出流量。为了解决这种不平衡,我们需要通过替代路径推动更多的流量。在这种情况下,额外的单位流量通过路径[0, 1, 2, 3, 4]到达节点 4。### 建模日益复杂的现实世界情况

我们的最大流算法对图的结构设置了限制,以简化对算法的推理。这些约束包括将图限制为单一源节点和单一汇点,并禁止反向平行边。本节将探讨如何放宽其中一些限制,以建模日益复杂的现实世界情况。

#### 多个源

许多现实世界的流网络包含多个源节点。例如,考虑我们在本章中一直使用的废水问题的更现实的视角。与单一的进水管道相比,网络更有可能包括来自每个与系统连接的建筑物的管道。即使我们在城市层面建模,我们也可以预期新的源节点将来自周边郊区加入到网络中。图 14-13(a)显示了一个包含三个源节点的网络。

![(B)显示了子图 A 的图,其中左侧插入了一个新节点(s'),并与子图 A 图中的三个 s 节点建立了边连接。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14013.jpg)

图 14-13:具有多个源的流图(a)及其对应的模型,单一聚合源(b)

幸运的是,我们可以通过添加一个新的人工源节点*s*′来轻松扩展流网络模型,该节点有效地为所有先前的源节点提供流量。新源节点通过有向边连接到每个先前的源节点。反过来,这些先前的源节点现在成为我们扩展模型中的内部节点,如图 14-13(b)所示。加粗的箭头表示从新节点到先前源节点添加的新边。

当然,我们的人工源节点在现实中并不存在。城市的暴雨排水系统并不是由一个秘密的超级排水管道提供的。相反,聚合源作为一种方便的数学抽象,允许我们将所有流量的源归结为一个单一(虚拟)节点。

添加新节点和边引发了一个问题,即我们如何选择这些新边的容量。如果我们将其容量设置得太低,这些边将成为瓶颈,导致无法准确建模问题。然而,如果我们将边的权重设置得过高也没关系,因为瓶颈将成为原始网络中已经存在的瓶颈。因此,我们可以在这些新边上使用无限容量,以便为之前的源提供它们可以处理的所有流量。在废水系统的背景下,这些边将是巨大的管道,远远超过任何工程师实际能够建造的流量。

我们创建了一个辅助函数,用于将一个具有多个源的任意图增广,添加聚合源,如 Listing 14-2 所示。

def augment_multisource_graph(g: Graph, sources: list) -> int:
❶ new_source: Node = g.insert_node()

❷ for old_source in sources:
g.insert_edge(new_source.index, old_source, math.inf)
return new_source.index


Listing 14-2: 将多源图转换为单源图

代码将一个新节点插入到图中 ❶。对于每个之前的源,代码会从新源到该源创建一个具有无限容量的新边 ❷。最后,代码返回新源的索引,以供我们在调用 Ford-Fulkerson 算法时使用。

#### 多个汇点

就像许多现实世界的问题有多个源一样,我们经常遇到具有多个汇点的网络。以洲际公路系统为例,汽车和卡车沿着道路流向多个不同的目的地。Figure 14-14(a) 显示了一个具有两个汇点节点的网络。

我们可以将用于多源问题的方法适配到处理多个汇点的问题。我们创建一个新的聚合汇点 *t*′,并从每个之前的汇点到新聚合汇点创建有向边,如图 Figure 14-14(b) 所示。新节点和边被加粗显示。我们为这些边分配足够的容量,以确保它们不会产生新的瓶颈。

![(B) 显示了图 (A) 中右侧插入的新节点 (t'),以及从 (A) 中的两个 t 节点到新节点的边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14014.jpg)

Figure 14-14: 一个具有多个汇点的流量图(a),以及相应的具有单一聚合汇点的模型(b)

同样,我们提供一个辅助函数,用于将给定图增广,添加多个汇点:

def augment_multisink_graph(g: Graph, sinks: list) -> int:
new_sink: Node = g.insert_node()

for old_sink in sinks:
    g.insert_edge(old_sink, new_sink.index, math.inf)
return new_sink.index 

代码遵循 augment_multisource_graph() 函数的形式,如 Listing 14-2 所示。它将一个新的汇点节点插入图中,创建与每个旧汇点之间具有足够容量的边,并返回这个新节点的索引。

#### 反向边

在实际应用中,禁止反向平行边也是不现实的。继续以州际高速公路系统为例,几乎每条高速公路都是双向道路:你可以从克利夫兰到布法罗,也可以从布法罗到克利夫兰。

我们可以使用另一个数学技巧来支持这种实际案例,同时保持图中不允许有反向平行边的限制。当处理一个包含边(*u*, *v*),容量为*w*[1]和边(*v*, *u*),容量为*w*[2]的回路时,如图 14-15(a)所示,我们可以添加一个新节点*x*,并将边(*u*, *v*)替换为边对(*u*, *x*)和(*x*, *v*),如图 14-15(b)所示。如果我们将先前边(*u*, *v*)的容量*w*[1]应用于边(*u*, *x*)和(*x*, *v*),则扩展路径允许的总流量为相同的值(*w*[1])。

![(B)显示了(A)图中的图,其中插入了一个新节点 x,并且从(u,v)到(u,x)和(x,v)的有向边替换了原边。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f14015.jpg)

图 14-15:包含两个边的回路图(a)及其扩展版本(b)

我们可以定义一个单一的预处理步骤,遍历图中的所有边,并在需要时插入额外的节点和边。如果原始图包含一条边(起点,终点)及其反向边(终点,起点),那么我们就有反向平行边,需要插入一个新节点。

### 为什么这很重要

我们可以利用最大流问题来解答各种实际的分析和优化问题。除了单纯地从源点到汇点找到最大流量外,解决最大流问题的技巧还能为网络本身提供重要的见解:我们可以利用残差图来找到瓶颈,或者发现哪些链路存在过剩的容量。例如,假设我们对一个拟议的废水处理系统进行分析,结果显示,由于网络中的其他限制,一条容量为每分钟 50 加仑的管道只会使用每分钟 10 加仑。我们现在知道,这条管道是一个明显的节省成本的机会。

本章中的算法方法还提供了新的思路来思考和处理图。CapacityEdge 数据结构是标准边的扩展,它可以跟踪动态量,而在<sup class="SANS_TheSansMonoCd_W5Regular_11">ResidualGraph</sup>中的路径会随着流量的应用而变化。这是我们第一次看到需要考虑与边相关的动态量的算法。

正如我们将在下一章看到的,最大流算法有扩展到更一般的匹配问题,包括优化节点对之间的连接。我们还将看到这些技术如何应用于更抽象的最大基数二分图匹配问题。




## 第十五章:15 二分图匹配



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

许多商业和物流问题都涉及到从两个不同集合中匹配项目。我们可能需要将人匹配到工作、会议匹配到地点、车辆匹配到路线,或将被收养的宠物匹配到家庭。在每一种情况下,我们都必须询问哪个集合中的项目与第二个集合中的哪个项目兼容。本章将详细探讨这个问题,称为*二分图匹配*。

*二分图*是一个由两个不相交的节点集合组成的图,其中每条边的两端总是分别位于这两个集合中。这个图自然地适用于匹配问题:每个节点集合代表我们想要匹配的项目集,而边则表示项目之间的兼容性。

本章首先讨论图上匹配的更广泛概念,然后正式介绍无向二分图及其二分匹配算法,并展示图匹配涵盖了从为小组工作分配伙伴到数据中心作业调度等一系列问题,以及相关的技术挑战。

### 匹配

在无向图上,*匹配*是指一组边,其中每条边的两个节点不共享任何节点。换句话说,匹配中的每条边连接两个不同的节点,每个节点最多只能与一条边相邻。我们可以通过使用学生之间的友谊关系来可视化匹配。在这种情况下,每条边代表两位朋友(边的两个节点),他们将合作完成项目。作为这种配对分配的自然结果,我们只会配对那些已经有社交关系的学生,且不能保证所有学生都能找到配对。

匹配的基本概念为我们提供了解决一系列问题的可能性。作为本章的具体示例,下面我们将讨论两个特别有用的匹配问题。首先,寻找一个*最大基数匹配*(有时缩写为*最大匹配*)的任务是找到一个具有最多边的匹配。这相当于为学生分配配对,创建最多的组。其次,*极大匹配*是指任何一个匹配,在这个匹配中没有额外的边可以加入而不破坏匹配属性。虽然最大基数匹配总是一个极大匹配,但反之则不成立。

图 15-1 展示了这两种匹配类型的示例。我们不能再向图 15-1(a)中的极大匹配中添加任何边(由粗体边表示),否则就会重用一个节点。同时,图 15-1(b)既是极大匹配,也是最大基数匹配:我们无法为该图创建一个超过三条边的匹配。

![一个包含无向边的图(0,1)、(0,3)、(1, 2)、(1, 4)、(1, 5)、(2, 5)和(4, 5)。(A) 中,边(0, 3)和(1, 5)加粗。(B) 中,边(0, 3)、(1, 2)和(4, 5)加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15001.jpg)

图 15-1:最大匹配(a)和最大基数匹配(b)

寻找*最大权重匹配*的问题是指在加权图中找到最大化边权总和的匹配。这对应于在分配小组时优先考虑学生之间的友谊强度。尽管这种方法在最大化奖励函数(如学生幸福感)的背景下是有用的,但它不一定会导致最大基数匹配。例如,图 15-2 展示了一个最大权重匹配,但它不是最大基数匹配。两个节点对{0, 1}和{2, 5}被匹配,而节点 3 和 4 被遗漏。

![一个包含无向加权边的图。边(0,1)的权重为 10,边(0,3)的权重为 1,边(1,2)的权重为 3,边(1,4)的权重为 5,边(1,5)的权重为 1,边(2,5)的权重为 19,边(4,5)的权重为 4。边(0,1)和(2,5)加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15002.jpg)

图 15-2:图上的最大权重匹配

可匹配问题的列表远不止这些初始示例。我们可以问一个图是否存在*完美匹配*,其中每个节点恰好被包含一次;找到一个最大基数匹配,最小化边权的总和;或者找到一个匹配,在保持边数不超过给定数量的情况下,最大化权重。在本章的其余部分,我们将主要集中于最大基数匹配,这是最简单且应用最广泛的形式之一,适用于特定图类型的背景。

### 二分图

如前所述,*二分图*可以被划分为两个不相交的节点集,以至于没有边连接同一集中的两个节点。二分图通常被可视化为两行并行的节点,如图 15-3 所示。左列和右列定义了两个节点集。图中的每一条边都横跨这两列。

![一个包含七个节点和无向边的图(0, 3)、(0, 5)、(2, 1)、(2, 5)、(4, 3)、(4, 5)和(6, 1)。偶数节点在左侧的一列,奇数节点在右侧的一列。所有边都连接两个列之间的节点。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15003.jpg)

图 15-3:一个包含七个节点的二分图

二分图为配对匹配问题提供了一个自然模型。在一个典型的例子中,左侧的项代表人,而右侧的项代表他们有资格胜任的工作,这在一个简单的图中总结了复杂的约束条件。

虽然匹配是本章的重点,但二分图的实用性远不止于此。二分图可以模拟许多现象,从跨越河流的物理桥梁到在聚会中互相监视的间谍。

### 二分图标记

给定一个无向图,我们可以询问它是否是二分图,如果是的话,哪个节点属于哪个集合。我们可以利用二分图的特性来进行这项检查,并标记哪些节点属于哪个集合。我们知道,任何通过无向二分图的路径都必须在两个集合之间交替,而一个节点不能有来自其所在集合的邻居。我们使用简单的搜索方法,无论是广度优先还是深度优先,遍历无向图并给节点分配标签。关键是,我们先任意为第一个节点分配一个标签,然后每当穿越一条边时,交替分配标签。如果我们发现两个邻居有相同的标签,我们就知道这个图不是二分图。

我们可以将这个算法想象成在一个鸡尾酒会上,多个间谍机构在竞争。各个间谍机构由不相交的间谍集合组成,表示为节点。每条边代表两个人互相监视的关系。这些间谍训练有素,每个间谍可以同时监视多个人——间谍 A 可能在监视间谍 B、间谍 C 和间谍 D!

一位无聊的服务员,无法识别舞厅里每个人的真实身份,利用这个机会来确定哪些间谍是合作的。他们首先随机挑选一名间谍并将其分配到绿色队伍。然后,他们确定这个间谍监视的所有人,并将这些人分配到黄色队伍。接下来,对于每个新发现的黄色成员,服务员会确定他们正在监视谁,并将被监视的人分配到绿色队伍。这个过程来回跳动,随着服务员递送各种开胃菜,揭示出每个人的归属。

当然,如果服务员发现有间谍监视自己队伍中的成员,他们就知道这个图不是二分图。也许间谍机构派来了内务人员,或者有双重间谍。不管怎样,情况不再是简单的黄色与绿色对立,服务员可能不想卷入其中。

#### 代码

Listing 15-1 中的二分图标记代码使用广度优先搜索(BFS)迭代地遍历图,并将节点标记为属于右侧或左侧。

def bipartite_labeling(g: Graph) -> Union[list, None]:
label: list = [None] * g.num_nodes
pending: queue.Queue = queue.Queue()

❶ for start in range(g.num_nodes):
❷ if label[start] is not None:
continue

  ❸ pending.put(start)
    label[start] = True
    while not pending.empty():
        current: int = pending.get()
      ❹ next_label = not label[current]

        for edge in g.nodes[current].get_edge_list():
            neighbor: int = edge.to_node
          ❺ if label[neighbor] is None:
                pending.put(neighbor)
                label[neighbor] = next_label
          ❻ elif label[neighbor] != next_label:
                return None
return label 

Listing 15-1: 根据节点在二分图中的位置对其进行标记

bipartite_labeling() 函数维护一个列表,将每个节点索引映射到三种状态之一(未标记 = None,右侧 = False,或左侧 = True),通过布尔值和 None 值的组合。代码通过设置标签列表(label)和队列(pending)开始,这要求我们导入 Python 的 queue 库。每个标签被初始化为 None,表示算法尚未看到该节点并为其分配标签。此列表将既用于在广度优先搜索中跟踪已访问的节点,也用于存储标签。

代码的主体是一个重复的广度优先搜索,其中外部循环从任何未访问的节点开始新的搜索❶。一个 for 循环检查每个潜在的起始节点是否已被之前的搜索访问过。如果是(节点的标签不是 None),则代码跳过它❷。如果节点尚未被访问,代码将其添加到 pending 队列中进行广度优先搜索,标记其属于左侧(标签为 True),并从该节点开始新的广度优先搜索❸。

在每一步广度优先搜索中,代码获取当前正在探索的节点,并使用其标签来确定其邻居的标签❹。也就是说,当前节点的邻居必须具有相反的标签;否则,图不是二分图。代码遍历节点的边,并检查每个邻居。如果邻居尚未被访问(标签为 None)❺,代码会设置标签(标记为已访问),并将其添加到 pending 队列中。如果该节点已被访问,代码将利用此机会检查其标签的有效性❻。如果标签与预期不符,则图中存在两个连接的节点在同一侧,因此不是二分图。它会立即返回 None 来指示问题。

如果代码成功完成了探索每个节点所需的一系列广度优先搜索,它将返回一个节点标签列表,包含 True 或 False 值。否则,它会返回 None 来表示图不是二分图。和书中的其他示例一样,我们需要从 Python 的 typing 库中导入 Union 来支持这些多个返回值的类型提示。

#### 一个示例

图 15-4 显示了二分图标签算法在一个七节点图上的应用步骤。在 图 15-4(a) 中,选择了一个任意节点(0),赋予第一个标签(True),并将其加入队列进行探索。这相当于服务员选择了第一个间谍并将其分配到绿色队。

在探索节点 0 后,算法发现了两个邻居,如 图 15-4(b) 所示。它给节点 3 和 5 赋予 False 标签,表示它们与节点 0 在不同的集合中。两个节点也被加入到队列中。

图 15-4(c) 显示了在探索节点 3 并发现一个新邻居节点 4 后,搜索的状态。由于节点 3 的标签为 False,搜索为节点 4 赋予了 True 标签。算法还检查了所有先前看到的节点,在本例中是节点 0,以确认它们的标签是否与预期值匹配。对于无聊的服务员来说,这一步相当于观察到第一个被认定为黄色队成员的人。服务员躲到一盆植物后面,注意到那个人 3 正在看着人 0,这符合任何好间谍的预期。片刻之后,服务员注意到人 3 也在看着人 4。服务员发现了绿色队的另一个成员,并在鸡尾酒纸巾上做了记录。

搜索继续在图中逐个节点进行。在每一步,算法检查完整的邻居集合,为新的邻居赋标签并将其加入队列,同时检查已知邻居的标签是否一致。搜索在 图 15-4(h) 中结束,所有节点都已检查完毕。

![每个子图显示了来自图 15-3 的图形,包含七个节点和无向边(0, 3)、(0, 5)、(2, 1)、(2, 5)、(4, 3)、(4, 5)和(6, 1)。在子图 B 中,节点 0 被圈出,标签列表显示为 [T, /, /, F, /, F, /]](../images/f15004.jpg)

图 15-4:成功的二分图检查步骤

我们还可以使用相同的算法来识别非二分图。图 15-5 展示了在一个非二分图上应用相同的算法,该图通过在图 15-4 中的图上添加一条额外的边来形成。在前几个步骤中,搜索的过程与图 15-4 中的类似。在图 15-5(a)中选择了一个任意的初始节点,并在图 15-5(b)中进行探索。问题的第一个迹象出现在图 15-5(c)中,节点 1 被标记为出现在左侧,因为它是节点 3 的邻居。我们可以通过视觉表示轻松地看出这是一个错误,但算法尚未获得这类信息。从它的角度来看,节点 1 完全可能位于左侧。直到算法进一步执行,它才会发现问题。

![与图 15-4 类似的一组图,添加了一条边(1,3)。在子图 E 中,节点 1 被圈出,标签数组中节点 2 的条目被标记为 X。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15005.jpg)

图 15-5:一次失败的二分图检查步骤

算法最终在图 15-5(e)中发现了问题,当它探索节点 1 时。由于节点 1 自身的标签是True,它期望其邻居是False。当它检查节点 2 时就失败了。节点 2 在探索节点 5 时曾被标记为True,但节点 2 不能同时是True和False。因此,这个图不是二分图。

### 使用案例

*二分匹配问题*涉及在二分图上解决匹配问题,并可以用来解决许多现实世界中的优化和分配问题。由于二分图的结构,每条被选中的边将把左侧的一个节点与右侧的一个节点连接起来。根据任务的不同,问题可能是在尝试最大化不同的标准,比如匹配的数量或使用的边权重之和。这个模型涵盖了许多现实世界的问题,这些问题通常我们不会在图的上下文中考虑,从作业调度到规划办公室大楼的组织,再到在魔法地下城中匹配英雄与怪物。

#### 作业调度

假设一个物理实验室希望最大化其机器上同时运行的仿真数量。这些机器的能力各不相同,但每台机器一次只能运行一个程序。科学家们将每个程序提交给人类调度员,并催促调度员优先处理他们自己的工作。然而,每个程序都有自己的要求,如高内存或 GPU。调度员必须遵守这些约束,从而限制有效分配的数量。

调度员敏锐地察觉到这是运用二分匹配算法的最佳时机,于是将分配建模为一个无向图。他们将科学家的工作列在左侧,将计算机列在右侧,然后如果某个程序可以在某台计算机上运行,就从程序到计算机画一条边。需要高内存的工作与高内存计算机之间有边,要求 GPU 的工作与带 GPU 的计算机之间有边,以此类推。在确认他们的表示方式后,他们开始寻找能同时安排的最大数量的工作。

#### 分配办公室空间

快乐数据结构公司正在寻找新办公室。虽然大多数团队都在期待搬迁日的到来,憧憬着新的工作空间,规划人员却在担心如何将每个团队分配到新大楼的工作区。每个工作区都有基于区域属性的约束,包括自然光、资源访问和面积。

在收集了几天的长长需求清单后,规划人员决定将这个问题建模为二分图匹配问题。一组节点代表团队,另一组代表工作空间。与某个团队需求兼容的空间通过边连接。每个团队只能分配到一个空间,每个空间只能容纳一个团队。规划人员使用团队与空间之间的二分图匹配来找到一个满足所有约束条件的团队与空间的分配方案。

#### 规划任务战斗

一支冒险小队正在探索一个神秘的地下城时,偶然闯入了一个满是怪物的房间。每个冒险者同意与一只怪物单独作战,但小队需要(快速地)进行分配。一些分配是无效的:巫师不能与抗魔的蜥蜴作战,剑术大师不能挑战蒸汽云。

在经典的作业分配问题上,团队将该问题建模为一个二分图分配。他们使用一组节点表示冒险者,另一组节点表示怪物。兼容的敌人通过边连接。现在,他们只需要高效地将每个冒险者与一个敌人匹配。

### 穷举算法

找到各种匹配方式(包括最大基数匹配和最大权重匹配)的一个简单方法是尝试每一种边的组合。我们可以枚举所有 2^|*^E*^|个可能的边集,丢弃那些使用任何节点超过一次的集合,然后根据我们的评分标准对其余的集合进行评分。在本节中,我们简要地考虑了一种基于深度优先搜索的算法,执行这种*详尽搜索*。该算法提供了一个基准,用于与更高效的计算方法进行比较。

#### 匹配数据

为了简化和通用化本节的代码,我们围绕匹配分配创建了一个包装数据结构,跟踪匹配和当前评分。Matching对象保存关于当前匹配的三条信息:

num_nodes **(**int**) **存储图中的节点总数

assignments **(**list**) **存储每个节点到其配对节点的映射,如果节点没有配对,则为-1

score **(**float**) **存储匹配的得分

assignments列表是双向的,存储二分图两侧的配对。例如,包含边(0, 4)时,将导致assignments[0]=4和assignments[4]=0。

为了实现这个包装数据结构,我们定义了一个构造函数来创建一个空的匹配,并提供了添加和移除匹配边的函数:

class Matching:
def init(self, num_nodes: int):
self.num_nodes: int = num_nodes self.assignments: list = [-1] * num_nodes
self.score: float = 0.0

def add_edge(self, ind1: int, ind2: int, score: float): 
    self.assignments[ind1] = ind2
    self.assignments[ind2] = ind1
    self.score += score

def remove_edge(self, ind1: int, ind2: int, score: float): 
    self.assignments[ind1] = -1
    self.assignments[ind2] = -1
    self.score -= score 

为了简化示例,add_edge()和remove_edge()函数都没有检查节点的有效性或节点是否已分配。在实际生产软件中,你通常需要添加检查,确保没有节点被重复使用,并且所添加的边存在于图中,可以使用类似第一章中的检查方法。

#### 详尽评分

我们使用基于递归深度优先搜索的方法来列举使用的边。我们不是在节点上进行深度优先搜索,而是在节点分配上进行探索。

例如,考虑图 15-6 中的二分图。节点 0 有三种匹配选择:无匹配、节点 1 或节点 3,同样适用于节点 2。

![一个包含四个节点和边(0, 1)、(0, 3)、(2, 1)和(2, 3)的图。偶数节点在左侧的单列中,奇数节点在右侧的单列中。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15006.jpg)

图 15-6:包含四个节点的二分图

我们可以将图中的潜在匹配空间在图 15-6 中可视化为一棵树,每一层表示左侧节点的某个匹配分配。此树如图 15-7 所示。树的第一层根据节点 0 的三种不同选择分成三种情况。左分支代表如果我们不配对节点 0 的选项。中间分支代表将节点 0 与节点 1 配对的选项。右分支代表将节点 0 与节点 3 配对的选项。我们通过只考虑与图中的边对应的潜在匹配来限制搜索空间。

![一棵展示潜在匹配的树。每个节点是每个节点的匹配向量。根节点是[–1, –1, –1, 1],而最右下角的节点是[3, 2, 1, 0]。](../images/f15007.jpg)

图 15-7:潜在节点匹配分配的搜索树

图 15-7 中的第二层树展示了类似的节点 2 分配的拆分。由于节点 0 在三个分支中的两个分支中已被分配,因此这些分支只能再分成两个子选项。

由于这种方法执行的是穷举搜索,它可以解决各种二分匹配问题,包括最大基数匹配和最大权重匹配。正如我们所看到的,唯一变化的因素是我们如何计算匹配的得分。

#### 代码

我们使用递归算法实现对这棵树的最大权重匹配搜索:

def bipartite_matching_exh(g: Graph) -> Union[list, None]:
❶ labels: Union[list, None] = bipartite_labeling(g)
if labels is None:
return None

current: Matching = Matching(g.num_nodes)

❷ best_matching: Matching = matching_recursive(g, labels, current, 0)
return best_matching.assignments

def matching_recursive(g: Graph, labels: list, current: Matching,
index: int) -> Matching:
❸ if index >= g.num_nodes:
return copy.deepcopy(current)
❹ if not labels[index]:
return matching_recursive(g, labels, current, index + 1)

❺ best: Matching = matching_recursive(g, labels, current, index + 1)
for edge in g.nodes[index].get_edge_list():
❻ if current.assignments[edge.to_node] == -1:
current.add_edge(index, edge.to_node, edge.weight)
new_m: Matching = matching_recursive(g, labels, current, index + 1)
if new_m.score > best.score:
best = new_m
current.remove_edge(index, edge.to_node, edge.weight)
return best


外部包装函数 bipartite_matching_exh() 标记图的两侧 ❶ 并设置 Matching 数据结构。如果图不是二分图,它返回 None(这再次需要包含 import Union 来支持多个返回值的类型提示)。然后,它调用递归函数进行匹配 ❷。

递归函数 matching_recursive() 首先检查是否已到达搜索的底部 ❸,即左侧的所有内容都已被分配(即使是分配了 -1)。如果没有更多的节点可以分配,它会返回 Matching 的副本,作为在这一分支下找到的最佳分配,使用 Python 的 deepcopy() 函数,该函数来自 copy 库。代码执行副本操作是为了有效地捕捉这一匹配,并将其与 current 对象分开,因为后者将在搜索的剩余过程中继续被修改。使用 deepcopy() 需要在文件顶部包含 import copy。

然后,代码检查此节点是否位于图的左侧 ❹。由于代码只从左侧节点进行分配,我们通过在下一个索引处使用当前匹配调用递归函数,跳过右侧的节点。尽管代码可以修改为同时测试左右两侧,但为右侧节点分配是不必要的。每条边只能使用一次,并且保证与左侧节点相邻。

然后,代码检查每个可用的匹配选项与当前节点的匹配情况,并保存最佳匹配,从选择将当前节点(index)保持未分配开始,通过递归函数以当前匹配在下一个索引 ❺ 处进行调用。代码将此分支下的最佳匹配保存为 best,以供稍后比较。接着,它使用 for 循环遍历当前节点的每一个邻居,检查当前的匹配分配,从而跳过已经分配给其他节点的节点 ❻。代码将每一个可行的邻居加入匹配中,使用递归函数获取该路径下的最佳匹配,必要时更新 best 匹配,并从匹配中移除该边。最后,它返回整个分支下的最佳匹配。

我们可以通过更改传递给匹配的分数,将代码中的最大权重匹配更改为最大基数匹配。也就是说,我们在添加或移除边时,用 edge.weight 替换 1.0。通过此更改,搜索将选择分配更多边的匹配,而不是选择总权重较大的匹配。

#### 示例

我们可以通过观察每次算法到达递归底部时当前匹配的状态来可视化此函数。图 15-8 显示了算法在一个示例图上达到递归结束的前九次情况。

由于该算法首先测试无分配(-1)分支,因此递归首先遇到完全分配 [-1,-1,-1,-1],如图 15-8(a)所示。在评估这个空匹配之后,搜索回溯并测试节点 4 的其他分配,同时保持节点 0 和 2 的分配不变。这产生了图 15-8(b)、15-8(c) 和 15-8(d) 中的匹配。直到图 15-8(f),算法才评估了一个使用了两条边的匹配。

![每个子图显示一个六节点二分图,其中一个或多个边被加粗。得分是加粗边的权重总和。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15008.jpg)

图 15-8:穷举搜索算法的前九个步骤

尽管穷举算法是完整的并且具有可推广性,但它效率低下,特别是在大图上。即使是在图 15-8 中的六节点图,搜索也需要探索 34 种不同的分配。下一节介绍了几种专门的算法,它们可以高效地解决单一匹配问题。

### 解决最大基数二分图问题

本节展示了如何使用上一章中的最大流算法高效地解决最大基数二分图问题。我们可以通过将二分图转换为带有有向边和单位权重的流网络,直接将最大基数二分图问题转化为最大流问题。图 15-9(a) 显示了一个二分图,图 15-9(b) 显示了该转换的结果。我们添加了一个单一的源节点 *s*,它向左列的所有节点提供流量。我们添加了一个单一的汇节点 *t*,它接受右列节点的流量。图中的每条边都从左向右有向,并且容量为 1。(为了减少杂乱,插图中未显示边的容量。)

![(A) 显示一个二分图,左边有五个节点,右边有四个节点。图中有边 (0, 1),(0, 5),(0, 7),(2, 1),(4, 3),(4, 7),(6, 1),和 (8, 3)。(B) 显示一个扩展后的图,添加了一个源节点,该源节点向左侧节点提供流量,同时一个汇节点接受右侧节点提供的流量。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15009.jpg)

图 15-9:一个二分图 (a) 和其流网络版本 (b)

使用此设置,源节点可以为每个左侧节点提供最多一个单位的流量。类似地,右侧列中的每个节点最多可以向汇节点提供 1 个单位的流量。考虑到流入一个节点的流量必须等于流出该节点的流量,左侧的每个节点最多可以向右侧的一个节点发送 1 个单位的流量。右侧的每个节点最多可以接受左侧节点发送的 1 个单位的流量。最大流量等于我们可以分配的最大配对数。

#### 代码

执行最大基数二分匹配的代码使用了 第十四章 中的 Edmonds-Karp 实现来完成所有繁重的工作。包装函数本身的主要任务是通过添加源节点和汇节点来转换图形,之后再修剪掉不需要的边:

def bipartite_matching_max_flow(g: Graph) -> Union[list, None]:
num_nodes: int = g.num_nodes

labeling: Union[list, None] = bipartite_labeling(g)
if labeling is None:
    return None

❶ extended: Graph = Graph(g.num_nodes + 2, undirected=False)
for node in g.nodes:
for edge in node.edges.values():
if labeling[edge.from_node]:
extended.insert_edge(edge.from_node, edge.to_node, 1.0)

❷ source_ind: int = num_nodes
sink_ind: int = num_nodes + 1
for i in range(num_nodes):
if labeling[i]:
extended.insert_edge(source_ind, i, 1.0)
else:
extended.insert_edge(i, sink_ind, 1.0)

❸ residual: ResidualGraph = edmonds_karp(extended, source_ind, sink_ind)

❹ result: list = [-1] * g.num_nodes
for from_node in range(residual.num_nodes):
if from_node != source_ind:
edge_list: dict = residual.edges[from_node]
for to_node in edge_list.keys():
if to_node != sink_ind and edge_list[to_node].used > 0.0:
result[from_node] = to_node
result[to_node] = from_node
return result


bipartite_matching_max_flow() 函数返回一个分配列表(格式与 Matching 类的 assignments 列表相同),或者如果图不是二分图,则返回 None。

为了构建增强图,代码首先必须知道哪些节点在二分图的哪一侧。它重用了来自 清单 15-1 的 bipartite_labeling() 函数,并借此机会检查是否为非二分图。

代码接着构建一个增强图。首先,它创建一个新的(有向)图,并为源节点和汇节点添加两个额外的节点❶。其次,它添加有向边,每条边的容量为 1,并从左列指向右列,使用 labeling 列表来确定每个节点所在的列。最后,代码连接源节点和汇节点❷。它从源节点到原图的每个左侧节点添加边,从原图的每个右侧节点到汇节点添加边。

代码在图上运行 Edmonds-Karp 算法,以找到最大流量 ❸。结果图(residual)中每个已使用流量的 CapacityEdge 都是连接的。然而,整个图仍然包含源节点、汇节点及其对应的所有边。代码遍历这些边,并为每个 CapacityEdge 填充分配信息,前提是其 used 大于零,且起始节点不是源节点,目标节点不是汇节点❹。

#### 示例

图 15-10 显示了最大流算法在 图 15-9 中的示例二分图上识别匹配的步骤。每次算法执行后,容量已满的边(used 等于 1)会被加粗。

我们可以将这个算法与在“用例”中介绍的作业调度算法相结合,参见 第 263 页。工作 0(节点 0)是最灵活的作业,能够在四台机器中的三台上运行。相比之下,工作 2、6 和 8 仅限于在特定机器上运行。

图 15-10(a) 显示了算法添加了源节点、汇节点和所有相应边的初始状态。此时没有边携带任何流量。这对应于开始时没有作业在任何机器上调度的空状态。

图 15-10(b) 和 15-10(c) 展示了代码找到前两个增广路径后分配的状态。在第一轮中,它发现未分配的工作 0 可以在未使用的机器 1 上运行,并做出了相应的分配。在第二轮中,它对工作 4 和机器 3 执行了相同的操作。

事情在此时变得有趣,如 图 15-10(d) 所示。通过将工作 0 分配给机器 1,算法阻塞了工作 2 和工作 6,因为这两个作业只能在机器 1 上运行。幸运的是,算法可以通过找到一个新的增广路径来解决此问题:(*s*,2)、(2,1)、(1,0)、(0,5)、(5,*t*)。这样做时,它沿着边(0,1)反向推送流量,从机器 1 上解分配工作 0。最终结果是,调度了一个新的作业。

图 15-10(e) 显示了一个类似的多步增广路径。这个新路径由边(*s*,8)、(8,3)、(3,4)、(4,7)和(7,*t*)组成。代码首先将工作 4 从机器 3 上解分配,然后将工作 4 分配给机器 7,并将工作 8 分配给机器 3。

![每个子图展示了相同的图形,但不同的边被加粗以指示使用的路径。(B)有一条路径(s,0),(0,1),(1,t)被加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15010.jpg)

图 15-10:最大流算法在增广二分图上的操作

在最大流算法完成后,生成了 图 15-10(e) 中的图形。为了生成匹配列表,算法接着遍历残余图的边。它跳过与源节点 *s* 或汇节点 *t* 相连的所有边,因为这些边不属于原始的二分图。它保存所有具有非零流量的连接。匹配的边(0, 5)、(2, 1)、(4, 7)和(8, 3)结果显示在 图 15-11 中。原始二分图中的未使用连接以细灰线表示以供参考。

![一个二分图,左侧有五个节点,右侧有四个节点。图中有边 (0, 1),(0, 5),(0, 7),(2, 1),(4, 3),(4, 7),(6, 1),(8, 3)。其中,(0, 5),(2, 1),(4, 7),(8, 3) 这些边是加粗的。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f15011.jpg)

图 15-11:一个二分图,最大匹配中的边加粗显示

最大流算法只会找到一个单一的最大匹配,该匹配不一定是唯一的。例如,在图 15-11 中,可能存在其他的匹配方式。我们可以选择包括边 (6, 1) 而不是边 (2, 1)。

### 为什么这很重要

二分图使我们能够将一系列分配问题转化为等价的图算法,从而利用大量强大的图算法。通过这种方式,我们可以解决一些最初可能不会认为是图形问题的问题。最大匹配问题就是这种灵活性的一个明确示例,它将从两个不相交集合中匹配项的问题转化为一个图问题,我们可以用最大流算法来解决。

在本书的下一节,我们切换话题,考虑一些在图形上计算上具有挑战性的问题。第十六章介绍了为图的节点分配颜色的问题,要求相邻的节点不能共享颜色。第十七章考虑了其他一些有用的节点分配问题。最后,第十八章将挑战性问题的讨论扩展到在图中寻找特定类型路径的问题。




# 第五部分 难解的图论问题






## 第十六章:16 图着色



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

*图着色* 是一个概念上简单但计算上复杂的问题,具有广泛的现实世界应用。它的核心是为无向图中的每个节点分配一个颜色,确保任何一对共享边的节点具有不同的颜色。该问题的变体包括最小化使用的颜色数量或仅使用固定数量的颜色进行分配。

我们可以通过欧洲地图轻松地形象化图着色问题的重要性。我们需要为每个国家分配一个颜色,使得没有两个相邻的国家有相同的颜色。如果我们把法国和比利时都涂成绿色,观众可能无法看到它们之间的边界。除了地图之外,图着色问题所施加的约束条件适用于许多现实世界的优化任务。

本章首先正式定义图着色问题,并更深入地讨论现实世界中的应用案例。然后,我们将探讨几种解决该任务的方法,并讨论为什么使用固定数量颜色的问题(或最小化使用的颜色数量)令人惊讶地困难。

### 图着色问题

*图着色问题* 包括为无向图中的每个 |*V* | 节点分配颜色,使得没有两个共享边的节点有相同的颜色。正式地,我们可以将该问题定义如下:

给定一个由节点集合 *V* 和边集合 *E* 定义的图,以及一个颜色集合 *C*,找到一个节点与颜色的分配,使得对于任何两个节点 *u* ∈*V* 和 *v* ∈*V*,如果它们通过边连接(*u*, *v*) ∈ *E*,则 *color*(*u*) ≠ *color*(*v*)。

我们可以将 *最小图着色问题* 定义为找到最小数量的颜色,使得图具有有效的图着色。

在本章中,我们使用图节点的 label 字段来存储该节点的颜色。颜色将通过整数表示,从 1 开始,None 的值表示未分配的节点(node.label == None)。

列表 16-1 定义了一个简单的 is_graph_coloring_valid() 函数,用于检查图是否具有有效的着色。这个检查器既提供了图着色问题的机械过程的良好概述,也为测试提供了一个有用的实用函数。

def is_graph_coloring_valid(g: Graph) -> bool:
for node in g.nodes:
❶ if node.label is None:
return False
for edge in node.get_edge_list():
neighbor: Node = g.nodes[edge.to_node]
❷ if neighbor.label == node.label:
return False
return True


列表 16-1:检查图的着色是否有效

代码使用一个 for 循环遍历图中的每个节点,首先检查节点是否分配了颜色 ❶。如果没有,图的着色是不完整的,因此无效。如果节点有颜色,代码使用第二个 for 循环遍历该节点的每个邻居,并检查两个节点是否共享相同的颜色 ❷。如果两个邻居共享相同的颜色,着色无效,代码返回 False。如果代码遍历了每个节点的每个邻居且没有找到匹配,则返回 True。

我们在本章插图中使用不同方向排列的哈希值表示不同的节点颜色,如图 16-1 所示。

![一个包含五个节点和边(0, 1)、(0, 3)、(0, 4)、(1, 2)、(1, 4)、(2, 4)和(3, 4)的图。节点 0 有垂直哈希标记并标注颜色 1,而它的邻居节点 1 有水平哈希标记并标注颜色 2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16001.jpg)

图 16-1:一个有效颜色分配的图

对于本章中的图示,我们将标记每个节点外部的颜色编号,并从 1 开始编号颜色。

### 应用案例

我们可以使用图着色模型来描述一系列现实世界的问题,包括地图着色、会议座位安排、分配停车位,以及保护迷宫中的宝贵财宝。

#### 地图着色

图着色的经典应用案例来自于世界各地制图师和地图出版商的日常需求。为了区分地图上的不同区域,必须使用不同的颜色对各个区域进行着色。以图 16-2 所示的新英格兰地图为例,我们可以选择将康涅狄格州涂成绿色,将罗德岛涂成橙色。

![显示新英格兰六个州的地图,图上覆盖了一个图。节点 MA 的邻居有 CT、RI、VT 和 NH。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16002.jpg)

图 16-2:新英格兰地图及其叠加的图表示

我们可以通过为每个区域创建一个节点,并在共享边界的任何两个区域之间添加边,将地图着色问题转化为图着色问题。在图 16-2 中,罗德岛节点与康涅狄格州和马萨诸塞州的节点之间有边连接。制图师的目标就是找到一个有效的图着色。

#### 组织座位安排

每年举办的“咖啡饮用数据结构爱好者大会”是一个充满欢乐但出乎意料地充满政治斗争的活动。社区内对咖啡和数据结构的偏好存在深刻分歧,包括轻烘焙和深烘焙阵营之间的对立,超过 30 个不同的热衷数据结构小组,以及不可避免地在任何计算机科学讨论中出现的编程语言之争。并非所有的偏好差异都会引发争执,但那些引发争执的会导致数小时的迂腐争吵。每年,组织者都面临着一个艰巨的任务——在开幕宴会上分配桌子,确保没有激烈的争论。

组织者长期以来一直利用这次聚会作为测试新图着色算法的机会。将每个与会者建模为一个节点,并将强烈的意见分歧建模为一条边,宴会座位安排的会议主席试图找到一个与会者分配到桌子的方案,确保同一桌子上的两个人没有强烈的意见分歧。节点的颜色代表桌子的分配。

在最简单的情况下,组织者可以将桌子分成最小的连贯群体。他们可能会为仅仅是喝浓缩咖啡、写 Fortran 程序的哈希表爱好者分配一张桌子。这相当于给该节点分配一个完全独特的颜色。然而,这种方法是多余且浪费的,几乎会为每个与会者分配一张桌子。上述的哈希表爱好者与浓缩咖啡和深烘焙咖啡饮用者、Fortran 或 Cobol 用户以及大多数数据结构的爱好者相处得相当融洽。会议主席的目标是最小化桌子的数量(图的颜色),同时确保不会引发激烈的争吵。

#### 分配停车位

数据结构与咖啡书店全天都有稳定的顾客流量。为了满足需求,店主决定雇佣更多的员工。经过广泛的面试,店主雇佣了六名员工。他们制定了一个时间表,安排这些新员工在一天内按固定班次工作,如图 16-3(a)所示。然而,仍然有一个问题:他们应为员工预留多少个停车位?

![(A) 显示了一个具有跨越不同水平范围的大小条形图。 (b) 显示了一个包含六个节点的图,节点之间通过边连接,边连接的是条形图重叠的节点。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16003.jpg)

图 16-3:员工工作时间(a)与相应的停车冲突图(b)

利用他们的数据结构专业知识,业主们迅速将问题简化为图着色问题。如图 16-3(b)所示,每个员工都成为图中的一个节点,任何两个日程有重叠的员工之间都会有一条边,这样他们就需要在相同时间占用停车位。例如,员工 1 的日程与员工 2、3 和 5 的日程有重叠。但与员工 0 或 4 的日程没有重叠,这意味着员工 1 可以与后两位员工共享一个停车位。如果业主能够找到一个最多包含*C*种颜色的图着色方案,那么他们就可以安全地预留最多*C*个停车位。

#### 规划魔法迷宫

一位邪恶的巫师决定建造一个魔法迷宫来保护他们最珍贵的宝物,一只能演奏他们最喜欢歌曲的号角。为了保护“美妙音乐之角”免受无情冒险者的侵扰,巫师在迷宫中布满了各种陷阱和怪物。

然而,巫师很快就遇到了一个问题。由于大量的通道(边),许多房间(节点)相互连接。它们不希望因为两个相邻房间有相同的怪物而被认为是懒惰的。迷宫设计的技巧是声誉的必要条件;即使是最初级的冒险者,也会对那些一个接一个地在房间里放置相同挑战的建筑师失去尊重。然而,巫师希望通过批量订购怪物来降低成本。他们需要确定能够使用的最少数量的怪物(颜色),以确保没有两个相邻的房间包含相同的怪物。

### 图着色算法

计算机科学家和数学家们开发了多种算法来解决图着色问题。然而,每种解决方案都有其权衡。有些算法使用启发式方法来找到解,但可能需要过多的颜色;另一些则在大规模图形中计算代价较高。

图着色是一个*NP 难问题*,这意味着非正式地说,目前没有已知的算法能够在最坏情况下其运行时间随着数据规模的增加而呈多项式增长。实际上,需要考虑的状态数是指数级的:对于一个有|*V*|个节点和*C*种颜色的问题,需要考虑|*V*|*^C*种状态。然而,这并非全是坏消息。尽管该问题在最坏情况下受限于其行为,但许多算法在实践中表现良好,并且可以应用于许多日常问题。

本节中的算法用于寻找有效的图着色。如果找到了一个有效的着色,它会返回 True 来表示成功,并将颜色分配设置在节点的 label 字段中,而不是返回一个单独的数据结构。如果无法找到有效的分配集,算法将返回 False。

#### 穷举搜索

*穷举搜索* 是一种全面的方法,能够确保在存在有效图着色的情况下找到一个解:

def graph_color_brute_force(g: Graph, num_colors: int) -> bool:
options: list = [i for i in range(1, num_colors + 1)]

❶ for counter in itertools.product(options, repeat=g.num_nodes):
❷ for n in range(g.num_nodes):
g.nodes[n].label = counter[n]
❸ if is_graph_coloring_valid(g):
return True

❹ for n in range(g.num_nodes):
g.nodes[n].label = None
return False


该代码使用 Python 的 itertools 包中的 product 函数来枚举所有可能的颜色分配组合❶。最初,counter 的每个值都被分配给第一个颜色(1)。在每次迭代中,计数器会发生变化。

在每次 for 循环的迭代中,代码将分配值复制到节点标签中❷,并检查这些分配是否有效❸。如果有效,立即返回 True 表示已经找到了解决方案。否则,循环继续执行下一种组合。如果代码没有找到成功的组合,则返回 False。在此之前,它会将所有节点的标签分配重置为 None,因为没有有效的着色❹。

当然,随着图的大小增大,穷举搜索的代价可能会变得非常昂贵。如果图有 |*V* | 个节点并使用 *C* 种颜色,那么我们可能需要测试 |*V* |*^C* 个分配组合,才能找到一个有效的解或确定没有任何 *C* 颜色分配能工作。图 16-4 显示了对五节点图进行穷举搜索的前六次迭代,使用了三种颜色,图来自 图 16-1。搜索从所有节点为同一颜色开始,如 图 16-4(a) 所示,接着进行不同的分配。

![一个包含五个节点的图及其边(0, 1)、(0, 3)、(0, 4)、(1, 2)、(1, 4)、(2, 4)、(3, 4)。(A) 显示了五节点图,其中所有节点都使用垂直哈希并标记为颜色 = 1,对应于 [1, 1, 1, 1, 1] 的分配。(B) 显示相同的图,其中节点 0 现在使用水平哈希,并标记为颜色 = 2,对应于 [2, 1, 1, 1, 1] 的分配。(C) 显示 [1, 2, 1, 1, 1] 的分配。(D) 显示 [2, 2, 1, 1, 1] 的分配。(E) 显示 [1, 1, 2, 1, 1] 的分配。(F) 显示 [2, 1, 2, 1, 1] 的分配。](../images/f16004.jpg)

图 16-4:穷举搜索的前六次迭代

如图 16-4 所示,穷举搜索可能会浪费大量时间检查一个接一个明显错误的状态。它提供了简单性和完整性,但代价是效率。如果我们的图没有边,那么显然所有节点都无法共享相同的颜色。想象一下,使用这种方法的人类会感到多么沮丧,因为他们被迫测试一个又一个他们知道不会成功的组合,因为图的某个地方明显存在冲突。

#### 回溯搜索

我们还可以使用*递归回溯搜索*来实现前一节中的基于迭代器的穷举搜索。与前几章中使用的深度优先搜索,尤其是第四章中的深度优先搜索不同,这种回溯搜索并不会通过相邻节点探索单独的节点。相反,搜索状态就是颜色分配的集合。我们递归地探索所有可能的颜色分配,遇到不可行的解时进行回溯。我们搜索中的每个状态都对应于节点的部分颜色分配,如图 16-5 所示。

![一棵选项树。根级别显示五个空白位置。下一层包含节点,填充第一个条目为 1、2、3。第二层显示前三个节点,第一个条目的值为 1,第二个条目的值分别为 1、2 和 3。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16005.jpg)

图 16-5:回溯搜索在不同分配上分支

我们可以首先将相同的搜索空间建模为基于迭代器的穷举搜索,使用深度优先搜索而不进行任何剪枝(稍后我们会改进这种方法)。这种搜索通过给下一个未分配的节点分配颜色来进入相邻状态,如清单 16-2 所示。

def graph_color_dfs(g: Graph, num_colors: int, index: int=0) -> bool:
if index == g.num_nodes:
return is_graph_coloring_valid(g)

for color in range(1, num_colors + 1):
    g.nodes[index].label = color
  ❶ if graph_color_dfs(g, num_colors, index + 1):
        return True

❷ g.nodes[index].label = None
return False


清单 16-2:颜色分配的递归穷举搜索

graph_color_dfs() 代码使用递归搜索为每个节点分配颜色。它从基本情况开始,检查是否所有节点都已分配颜色,如果是,检查分配是否有效。如果还有节点需要分配,代码将迭代当前节点的所有可能颜色。对于每种颜色,代码将继续在下一个节点(按索引顺序)上进行递归搜索。如果分配导致有效解,它返回 True ❶。如果搜索未找到有效的分配,它将当前节点的颜色重置为 None ❷ 并通过返回 False 来回溯。

清单 16-2 中的回溯搜索实现仅仅是基于迭代器的穷举搜索的另一种实现方式。它并没有提高效率。 图 16-6 显示了搜索如何迭代通过与穷举搜索在 图 16-4 中相同的第一个死胡同。经过所有步骤后,到达了 图 16-6(e) 中的死胡同,算法回溯并尝试为节点 4 分配新的值,如 图 16-6(f) 所示。

![一个包含五个节点和边(0, 1)、(0, 3)、(0, 4)、(1, 2)、(1, 4)、(2, 4)和(3, 4)的图。 (A) 显示了只有节点 0 被标记为 color = 1 的五节点图。 (B) 显示了相同的图,其中节点 0 被标记为 color = 1,节点 1 被标记为 color = 1。 (C) 显示了分配 [1, 1, 1, None, None]。 (D) 显示了分配 [1, 1, 1, 1, None]。 (E) 显示了分配 [1, 1, 1, 1, 1]。 (F) 显示了分配 [1, 1, 1, 1, 2]。](../images/f16006.jpg)

图 16-6:图着色的回溯搜索的前六个步骤

然而,我们可以通过 *剪枝* 来大大提高效率,仅探索有效路径。在给节点分配颜色之前,我们可以检查该分配是否会导致冲突。如果是,我们不仅跳过该分配,还跳过由此产生的所有后续递归,如 图 16-7 所示,我们跳过了从给节点 0 和节点 1 都分配颜色 = 1 开始的整个子树。而是,一旦我们给节点 0 分配了颜色 = 1,我们只考虑颜色 2 和 3 来分配给相邻的节点 1。

![一棵树,其中每个节点显示了带有部分颜色填充的五节点图。根节点没有任何节点被着色。它的子节点包括分别带有颜色 1、2 和 3 的图。树节点中,节点 0 和 1 都分配了颜色 1 的情况被灰色显示,并且该分支被划掉。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16007.jpg)

图 16-7:带有剪枝的回溯搜索在图着色中的前几个步骤

*回溯搜索与剪枝*的代码需要做一个小的修改。在给节点分配颜色之前,我们需要检查是否有任何邻居已经有了该分配。这个简单的检查可以防止我们深入到死胡同:

def graph_color_dfs_pruning(g: Graph, num_colors: int, index: int=0) -> bool:
if index == g.num_nodes:
return True

for color in range(1, num_colors + 1):
  ❶ is_usable: bool = True
    for edge in g.nodes[index].get_edge_list():
        if g.nodes[edge.to_node].label == color:
            is_usable = False

    if is_usable:
      ❷ g.nodes[index].label = color
      ❸ if graph_color_dfs_pruning(g, num_colors, index + 1):
            return True
        g.nodes[index].label = None

return False 

代码再次从基本情况开始,检查是否所有节点都已分配,如果是,返回 True。代码不需要检查当前分配的有效性,因为它会在分配每个颜色之前进行检查。

如果还有更多节点需要探索(分配),则代码会遍历当前节点的所有可能颜色。它首先检查是否有任何邻接节点使用此颜色,如果有,则将该颜色标记为不可用于当前节点❶。如果颜色可用,代码会继续进行递归探索,将该颜色分配给节点❷,并递归地继续到下一个节点❸。与清单 16-2 中的方法一样,如果代码找到有效的分配,它会返回 True,如果必须回溯,则返回 False。

图 16-8 展示了一个带有剪枝的回溯搜索示例,图中有五个节点,*C* = 3。

在其最初的几步中,搜索过程通过为图 16-8(a)中的节点 0、图 16-8(b)中的节点 1、图 16-8(c)中的节点 2,以及图 16-8(d)中的节点 3 分配有效颜色来进行。当它到达节点 4 时,它意识到自己已经到了死胡同:这三个潜在的颜色都无法分配给该节点。搜索回溯到它为节点 3 分配颜色的地方,但这并没有帮助,因为在那个时刻,节点 3 只有一个有效的分配。搜索再次回溯,并尝试为图 16-8(e)中的节点 2 进行不同的分配。当搜索遇到图 16-8(f)中的下一个死胡同时,它会回溯到为节点 1 分配颜色的地方,并尝试在那里使用一种新颜色,如图 16-8(g)所示。通过新的节点 1 分配,搜索可以顺利为剩余的节点找到分配。

![该图有 10 个子图,每个子图展示了图的不同着色或部分着色。图中有边 (0, 3),(1, 2),(1, 4),(2, 3),(2, 4),和 (3, 4)。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16008.jpg)

图 16-8:带剪枝的回溯搜索图着色算法的十个步骤

带剪枝的回溯搜索就像一个有着好橡皮擦的有条理的会议策划人。他们从一个一个地做初步的桌位分配开始。对于每个分配,他们都会检查桌上是否存在已知的冲突。如果有,他们跳过该分配,避免浪费时间——如果他们已经知道会导致围绕 B 树与红黑树优缺点的激烈争论,那么继续寻找剩下的解决方案就没有意义了。然而,这种有效性检查也仅能帮助到一定程度。策划人仍然会遇到死胡同,当前的与会者没有有效的座位。如果每张桌子上已经坐满了至少一位 Python 爱好者,那热衷的 LISP 程序员就无路可走了。会议策划人拿出他们 trusty 的橡皮擦,深深地叹了口气,开始回溯到一个更早的时刻,在那个地方他们本可以做出不同的分配。

#### 贪心搜索

除了这些准确但计算开销大的解决方案外,我们还可以考虑启发式方法。*贪心法*图着色一次考虑一个节点,选择第一个不会与已分配邻居违反任何约束的颜色。与本节描述的穷举算法不同,我们定义的这个贪心搜索并不考虑最大颜色数。虽然它总能找到某个解决方案,但由于其贪心性质,它不一定总是使用最小的颜色数。

贪心搜索方法的代码从一个辅助函数开始,该函数通过确定邻居使用的颜色来找到节点的第一个有效颜色,然后选择该集合中没有的第一个颜色,如第 16-3 节所示。

def first_unused_color(g: Graph, node_index: int) -> int:
used_colors: set = set()
for edge in g.nodes[node_index].get_edge_list():
neighbor: Node = g.nodes[edge.to_node]
❶ if neighbor.label is not None:
used_colors.add(neighbor.label)

❷ color: int = 1
while color in used_colors:
color = color + 1
return color


第 16-3 节:为节点找到有效的颜色分配

first_unused_color() 函数将相邻节点中已使用的颜色收集到 set 数据结构 used_colors 中,这使得它能够轻松插入新颜色并检查某个颜色是否已被使用。然后,代码会遍历所有该节点的邻居。对于每个邻居,它将该邻居的颜色添加到 used_colors 集合中。它会跳过未分配颜色的节点(neighbor.label == None),因为这些节点不会造成冲突 ❶。代码最后通过 while 循环来查找第一个不在 used_colors 集合中的颜色 ❷。虽然这种方式效率不是特别高,但该循环总能找到*某种*可以使用的颜色。

给定辅助函数后,这种贪心搜索可以在一个循环中实现:

def graph_color_greedy(g: Graph) -> bool:
for idx in range(g.num_nodes):
g.nodes[idx].label = first_unused_color(g, idx)
return True


graph_color_greedy() 函数使用索引变量 idx 遍历所有节点。对于每个节点,它调用第 16-3 节中的辅助函数,找到第一个不与已分配邻居冲突的颜色。为了与本章中的其他算法保持一致,函数返回 True,表示已找到有效的着色。

我们可以通过数据结构大会上宴会座位安排的会议主席的视角来形象化贪心算法。组织者逐一遍历与会者名单,在将每位与会者安排到一张桌子后,再继续安排下一位。对于每位与会者,组织者查看可用的桌子列表,并检查是否与已安排在某桌的与会者发生冲突。这相当于检查当前与会者(节点)是否与该桌的其他占用者(已安排的节点)发生冲突(共享边)。如果发生冲突,组织者将转到下一张桌子。如果桌子安排完毕,组织者会叹气,嘟囔着一些不友好的话,关于编程语言争斗的荒谬,并在场地上添加一张新桌子。

图 16-9 说明了这个贪心搜索过程。在图 16-9(a)的第一次迭代中,代码为节点 0 分配颜色。在图 16-9(b)中,接着考虑节点 1。由于该节点与节点 0 共享一条边,因此搜索不能重用颜色=1,而是为节点 1 分配颜色=2。当在图 16-9(c)中考虑节点 2 时,唯一已分配的邻居颜色为 2,因此搜索可以为节点 2 分配颜色=1。这个过程持续进行,直到为所有节点分配颜色,如图 16-9(e)所示。

![一个包含五个节点和边(0, 1),(0, 3),(0, 4),(1, 2),(1, 4),(2, 4)和(3, 4)的图。(A)展示了只有节点 0 被标记为颜色=1 的五节点图。(B)展示了同样的图,节点 0 标记为颜色=1,节点 1 标记为颜色=2。(C)展示了分配为[1, 2, 1, None, None]的分配。(D)展示了分配为[1, 2, 1, 2, None]的分配。(E)展示了分配为[1, 2, 1, 2, 3]的分配。](../images/f16009.jpg)

图 16-9:贪心着色算法的五个步骤

在拥有足够颜色的情况下,贪心算法将为图找到有效的着色。然而,这种分配并不保证使用最少的颜色。相反,节点分配的顺序对贪心算法需要多少颜色有显著影响。请参见图 16-10,它展示了同一图的两种有效着色方法。

![两个子图都展示了包含五个节点和边(0, 3),(1, 2),(1, 4),(2, 3),(2, 4)和(3, 4)的图。(A)展示了分配为[1, 1, 2, 3, 4]的图。(B)展示了分配为[1, 3, 2, 3, 1]的图。](../images/f16010.jpg)

图 16-10:一个图,其中贪心着色方法使用四种颜色(a)找到了解决方案,而实际上只需三种颜色(b)即可得到解决方案

图 16-10(a)展示了贪心算法生成的图着色。由于搜索为节点 0 和 1 分配了相同的颜色,它必须在节点 4 上使用第四种颜色。相比之下,采用深度优先搜索并进行剪枝的算法会找到一个仅需三种颜色的最优着色,如 图 16-10(b) 所示。两者的权衡是速度与最优性的选择。虽然贪心搜索有时会使用超过最少颜色数的颜色,但它没有回溯过程,因此执行速度更快。

#### 节点移除

另一个值得讨论的启发式算法是 IBM 团队提出的*移除算法*,用于在编译器中为 CPU 寄存器分配变量,以避免冲突。该算法通过迭代地简化问题来工作(如果可能的话)。就像为停车位分配空间的例子一样,论文的作者将寄存器分配定义为一个图着色问题,节点代表变量,边表示哪些变量在同一时刻被使用,而颜色则代表每个 CPU 的寄存器。颜色的数量由芯片架构固定为*C*。该算法的目标是确定是否能找到一种着色方式,使得使用的颜色数不超过*C*。

正如乔治·蔡廷(George Chaitin)等人在其论文《通过着色进行寄存器分配》中所讨论的那样,IBM 团队提出了一种多步骤的寄存器分配方法,其中包括一个用于生成颜色分配的节点移除算法。该算法基于一个洞察力:如果一个节点的边数少于*C*,在为其邻居分配颜色后,我们可以轻松地为它分配一个颜色。我们只需要查看其邻居的颜色,并使用任何在这些邻居中没有出现过的颜色,重新利用我们的 first_unused_color() 函数,参考 Listing 16-3。因此,我们可以最初忽略那些边数少于*C*的节点,专注于处理那些更复杂的情况:拥有*C*个或更多邻居的节点。实际上,我们可以更进一步,暂时从图中移除所有边数少于*C*的节点及其边,处理完其他节点后再重新加入这些节点,并为它们分配颜色。

基于这一洞察力,该算法会迭代检查当前图中的节点,并移除所有边数少于*C*的节点以及它们的边。它会将这些节点加入栈中,待处理完更复杂的情况后再重新访问。随着节点和边的移除,新的节点会低于*C*邻居的阈值,也可以被移除。它知道,在为邻居分配颜色后,当回到这些节点时,可以轻松地使用 first_unused_color() 为这些节点分配颜色。

如果算法能够从图中移除每个节点,那么我们就知道它具有有效的*C*色彩。如果搜索跟踪它在栈中移除的内容,它可以从栈中弹出项目,逆转操作并重新组装图,通过这种方式有效地重新将节点添加到图中,并在此过程中分配颜色。

移除算法的代码使用了这种两阶段的方法:

def graph_color_removal(g: Graph, num_colors: int) -> bool:
removed: list = [False] * g.num_nodes
node_stack: list = []
❶ g2 = g.make_copy() removed_one: bool = True
while removed_one:
removed_one = False
for node in g2.nodes:
❷ if not removed[node.index] and node.num_edges() < num_colors:
node_stack.append(node.index)

          ❸ all_edges: list = node.get_sorted_edge_list()
            for edge in all_edges:
                g2.remove_edge(edge.from_node, edge.to_node)

            removed[node.index] = True
            removed_one = True

❹ if len(node_stack) < g.num_nodes:
return False

❺ while len(node_stack) > 0:
current: int = node_stack.pop()
g.nodes[current].label = first_unused_color(g, current)

return True 

代码首先创建了多个辅助数据结构。removed 数组为每个节点存储一个布尔值,允许代码快速检查节点是否仍然在图中。node_stack 列表存储了被移除的节点的信息以及它们被移除的顺序。代码还创建了图的副本(g2),允许它移除边而不修改原始图❶。

然后,代码进入一个 while 循环,只要函数在前一次迭代中移除了至少一个节点(通过布尔值 removed_one 跟踪)。在 while 循环中,代码遍历图中的每个节点,检查该节点是否已经被移除以及它有多少个邻居❷。如果节点没有被移除且邻居数量少于 *C*(num_colors)个,代码会将节点添加到 node_stack 中,移除它的所有边❸,并将节点标记为已移除。从技术上讲,代码仅移除图中的边;节点的移除通过 removed 数组捕获。这使我们能够在 for 循环中稳定地遍历图中的节点,并且不会影响算法的准确性。

如果代码未能将图中的所有节点移除并将其添加到堆栈中,则说明它在寻找有效颜色分配时失败了 ❹。在这种情况下,代码返回 False。如果存在有效的分配,代码会一次为每个节点分配颜色 ❺。每次从堆栈中弹出一个节点时,它使用 first_unused_color() 来选择一个有效的颜色,来自清单 16-3。由于当节点被加入堆栈时,其邻居少于 num_colors,因此它现在必须有少于 num_colors 个已分配颜色的邻居。因此,first_unused_color() 将在[1, num_colors]范围内选择一个有效的颜色。

我们可以通过使用关键短语 “我稍后再处理这个与会者” 来在会议计划者的背景下想象移除算法。每当会议计划者看到一个冲突少于 *C* 的与会者时,他们会不屑地说:“这个人不会成为问题的。我可以为他们找一个桌子。我会在处理完困难的与会者之后再处理他们。” 外部人可能会认为这是拖延,但图着色爱好者会把它看作是一个关键的算法性洞察。

图 16-11 显示了此代码操作的第一阶段,其中 *C* = 3。在这个阶段,节点是一次一个地被移除。在 while 循环的第一次迭代中,移除了三个节点。节点 2 的邻居少于 *C*,因此它被加入到堆栈中,如图 16-11(b)所示。接下来,在图 16-11(c)中移除了节点 3。此时,节点 4 的邻居少于 *C*,也可以被移除,如图 16-11(d)所示。

![(A) 显示了包含边 (0, 1)、(0, 3)、(0, 4)、(1, 2)、(1, 4)、(2, 4) 和 (3, 4) 的五节点图。每个后续的小图显示了图中移除了一个额外的节点,并且相应的节点被添加到堆栈中。(B) 移除了节点 2。(C) 移除了节点 3。(D) 移除了节点 4。(E) 移除了节点 0。(F) 不再有节点剩余。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16011.jpg)

图 16-11:移除图着色算法的第一阶段

该算法现在已经遍历了图中的每个节点一次。由于它在这次迭代中移除了至少一个节点,因此它从节点 0 重新开始,并再次检查。在图 16-11(e)中,它移除了节点 0,该节点只有一个剩余的邻居。最后,在图 16-11(f)中,它移除了节点 1。

算法的第二阶段,如图 16-12 所示,是标记并“重新添加”节点。算法首先从栈中弹出节点 1,并将其标记为颜色 1,如图 16-12(a)所示。在图 16-12(b)中,算法从栈中弹出节点 0,并将节点 0 分配为不与邻居冲突的第一个颜色。这个过程继续在图 16-12(c)、16-12(d)和 16-12(e)中依次进行,分别为节点 4、3 和 2 着色。

![每个子图展示了图中一个附加的节点重新加入并着色。(A)显示节点 1 使用颜色 1。 (B)加入节点 0 使用颜色 2。 (C)加入节点 4 使用颜色 3。 (D)加入节点 3 使用颜色 1。 (E)加入节点 2 使用颜色 2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16012.jpg)

图 16-12:移除图着色算法的第二阶段

不幸的是,这种启发式方法并不足以解决所有图。即使在有一个至少有 *C* 条边的互联节点集群的情况下,图着色有时也可以使用少于 *C* 种颜色。例如,在图 16-13 中,尽管仅用两种颜色就可以完成有效的着色,但当 *C* = 3 时,移除算法会失败。因为每个节点都有三个邻居,移除算法无法删除任何节点。它陷入了死胡同。

![一个包含六个节点和九条边的二分图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f16013.jpg)

图 16-13:一个移除算法失败的示例图

然而,很明显,我们可以为图 16-13 创建一个有效的着色方案,只需使用两种颜色即可。我们可以将所有左侧节点分配为颜色 1,所有右侧节点分配为颜色 2。由于边仅连接左侧节点和右侧节点,便不会发生冲突。实际上,我们可以使用第十五章中的二分图标记算法来解决这个特殊的案例。

### 为什么这很重要

图节点着色问题在现实世界中有多种应用场景,从规划魔法迷宫到分配停车位。这个问题之所以有趣,是因为没有已知的算法能够高效地解决所有情况。相反,我们必须依赖于穷举搜索或启发式方法。这促使我们开发了各种方法,以在不同的现实世界条件下提供良好的性能。

在下一章,我们将探讨类似的任务分配问题,这些问题目前没有已知的高效解法。我们将研究基于本章回溯深度优先搜索的多种不同分支搜索方法,并考虑各种启发式方法和使用随机算法来寻找解决方案。




## 第十七章:17 团、独立集和顶点覆盖



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

在上一章中,我们看到看似简单的颜色分配问题会迅速膨胀成代价高昂的搜索。在这里,我们考虑类似的挑战性问题,即组装满足各种条件的节点集:最大团、最大独立集和最小顶点覆盖。

对于这些问题中的每一个,我们都希望找到满足某些基于邻接节点或边缘的条件的最大或最小节点集。虽然检查单一的提议解决方案是否满足各种约束条件很容易,但找到最佳解决方案可能需要大量计算。与图着色问题类似,这些问题被归类为 NP-困难问题。同样,我们可以使用启发式方法或穷举法来解决这些问题。

本章开始时回到了上一章介绍的带剪枝的穷举回溯搜索,并将其适应于对本章所涉及的三个问题进行穷举搜索。此外,我们还考虑了多种贪心或启发式方法。我们还将讨论每个问题的实际应用,从选择办公室位置的团到避免怨恨的独立集,再到利用顶点覆盖建立警卫塔。

### 回溯搜索节点集

对于本章中的每个问题,我们都希望找到满足给定约束的节点集。我们使用在第十六章中介绍的带剪枝的回溯搜索的修改版本,通过探索是否将每个节点包含在集合中的不同分配来寻找潜在的解决方案。与图着色问题中的应用一样,这些回溯搜索会列举所有有效的解决方案。虽然它们会检查每一个可能的有效分配,但它们的效率很少高。

这种搜索的基本概念是通过一次考虑一个节点并在每个决策点将搜索分成两个路径,来探索每一个可能的节点集。在第一条路径中,搜索探索不包含当前节点的可能集合。在第二条路径中,搜索探索包含当前节点的可能集合。

图 17-1 展示了这种方法,每个节点在集合中的包含情况标记为 True(包含)或 False(不包含)。列表中的空条目表示我们尚未决定是否包含该节点。在每个层次,搜索会考虑下一个未分配的节点,并在每个决策点分支出两个可能的分支。

![一棵展示集合赋值分支的树。在顶层,一个五元素的数组没有填充任何项。该数组分为两条路径。左侧路径的第一个元素为 F,右侧路径的第一个元素为 T。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17001.jpg)

图 17-1:回溯搜索,穷举尝试所有集合赋值

因为在图 17-1 中我们将每个分支分成两个子分支,所以每一层的可能选项数量都会翻倍。对于一个有*N*个决策的树,我们探索 2*^N*个完整的赋值。在图节点的子集的情况下,我们将每个节点视为一个单独的决策,因此*N* = |*V*|,我们有 2^|*V*|种选项可以探索。虽然剪枝无效路径有助于去除一些明显不可行的结果,但它无法避免此搜索可能带来的复杂性全面爆炸。

我们可以将这种搜索视为解决魔法地下城谜题的方法。当我们进入寒冷的石质房间时,发现墙上有五个巨大的开关。根据我们之前对魔法地下城的研究,我们知道只有一种正确的开关配置能打开通向藏有宝藏的门。不幸的是,地下城的设计师并不仅仅是在试图创造一个有趣的谜题;他们想要保护他们的宝藏,因此完全没有提供任何线索。虽然检查任何单一猜测并不需要很长时间,但我们可能需要尝试每一种组合才能找到正确的配置。

为了得到宝藏,我们从最左侧的开关(关闭)开始猜测,然后是第二个左侧的开关(关闭),依此类推,直到所有开关都处于关闭位置。当宝库门不可避免地没有打开时,我们回溯到上一个决策点(我们将最右侧的开关设置为关闭)并尝试开启选项。当这样做无效时,我们继续回溯(回到第二个最右侧的开关),将其改为开启,再次尝试探索最后一个开关的每种可能设置。我们应该感到幸运,因为地下城设计师的预算只有五个开关,这意味着我们只需要测试 2⁵ = 32 种设置。但每次回溯时,我们发现很难保持如此积极的心态。

本章中的所有算法,我们都描述了相同的两种算法方法来寻找解决方案。我们首先描述了一种近似的贪心搜索,以建立问题的基础和影响解决方案的因素。接着我们展示了如何将回溯搜索应用于该问题,并添加剪枝技术。

### 团

*团*是无向图中完全连接的节点子集。形式上,我们说一个团是一个节点集*V*′ ⊆ *V*,满足以下条件:

(*u*, *v*) ∈ *E* 对于所有 *u* ∈ *V*′ 和 *v* ∈ *V*′

在社交网络中,一个团是指一群彼此都是朋友的人。

图 17-2 展示了一个图形,其中有两个阴影子集节点。在图 17-2(a)中,阴影节点{1, 2, 5}形成了一个团体,因为图中包含了子集中每一对节点之间的边。相比之下,在图 17-2(b)中,阴影节点{0, 1, 4}并未形成一个团体,因为节点 0 和 4 之间没有边,节点 1 和 4 之间也没有边。

![一个包含六个节点和无向边(0, 1)、(0, 3)、(1, 2)、(1, 5)、(2, 5)、(3, 4)和(4, 5)的图。在左侧,阴影节点 1、2 和 5 之间有边相连;在右侧,阴影节点 0、1、4 之间没有。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17002.jpg)

图 17-2:一个包含节点子集形成团体(a)和非团体节点子集(b)的图

我们可以通过检查每一对节点并确认它们之间是否存在边来判断一组节点是否形成了一个团体,正如在清单 17-1 中所示。

def is_clique(g: Graph, nodes: list) -> bool:
num_nodes: int = len(nodes)
for i in range(num_nodes):
for j in range(i + 1, num_nodes):
if not g.is_edge(nodes[i], nodes[j]):
return False
return True


清单 17-1:检查一组节点是否形成有效的团体

代码使用一对for循环来遍历列表中的每一对节点,并检查是否存在相应的边。如果边缺失,代码会立即返回False。如果代码成功检查完列表中的所有节点对而没有发现缺失的边,则返回True。

我们可以将此检查可视化为社交网络中的一个好奇外人。在听到某高中“伟大朋友圈”的传言后,怀疑的外人宣称,“他们不可能真的都互相喜欢”,并着手揭示这个团体的隐藏分裂。外人以初学侦探的方式,逼问每个人关于他们与其他团体成员的关系:“你真的和 Jonny 是朋友吗?那 Suzy 呢?”直到他们确认每一对关系都是真实的,才放弃了他们的怀疑。

虽然判断一组给定的节点是否形成一个团体比较简单,但构建图中最大的可能团体则要困难得多。寻找*最大团体*的问题是指在图中找到最大的节点子集*V*′ ⊆ *V*,使其形成一个有效的团体。这个问题比在图中寻找任意团体要困难得多,因为一个节点是否有效的成员取决于团体中其他节点的关系。如果逐个添加节点,早期的选择可能会导致我们走向次优的方向并排除后续的节点。

#### 使用案例

我们可以通过考虑需要由交通路线(边)直接连接的地点(节点)来形象化团体的重要性。王国的冒险者、探险家和制图师公会正在寻找在有魔法地牢的地方建立区域总部。在经过数小时关于各种标准的重要性讨论之后,包括地牢难度和新鲜农产品的可获取性,他们最终得出结论,首要优先事项是各办公室之间便捷的交通。毕竟,公会的各个办公室共享一份为会员开放的任务列表。如果老墨尔本市的一个冒险者得知“决策悬崖”处有一个有前景的任务,他们会希望直接前往那里。公会领导们召集资深制图师寻找最大的城市集,其中每个城市都通过一条公路直接相连。制图师们熟悉最大团体问题,开始枚举所有可能的组合。

在一个不那么幻想的世界里,我们可能希望使用最大团体检测来选择具有直接交通连接的商业地点或具有直接链接的计算节点。这些问题都涉及到在图中找到完全连接的子集。

#### 贪心搜索

我们可以通过从任意一个节点开始,将其作为我们的团体,并不断添加兼容的节点,来定义一个构建团体的贪心算法。我们总是选择添加那些能保持我们的团体*有效*的新节点,所谓有效节点,是指与团体中的每个节点都有边相连的节点。

清单 17-2 展示了如何通过检查每个节点,列出是否可以将其添加到集合中并保持团体有效的选项。

def clique_expansion_options(g: Graph, clique: list) -> list:
options: list = []
for i in range(g.num_nodes):
❶ if i not in clique:
valid: bool = True
for j in clique:
❷ valid = valid and g.is_edge(i, j)
if valid:
options.append(i)
return options


清单 17-2:检查可以添加到团体中的节点

代码遍历图中的每个节点,测试该节点是否可以被添加到团体中,首先检查该节点是否已经是团体的一部分 ❶。如果不是,代码通过检查该节点是否与团体中的每个节点都有边相连来验证其有效性 ❷。如果每个团体节点的这些测试都通过,代码就会将当前节点添加到扩展选项列表中。

这个函数可以帮助“大朋友小组”识别潜在的成员。学校里的每个学生都是潜在候选人。对于每个还不在小组中的学生,小组的代表会询问每个成员:“你们是朋友吗?”如果潜在成员已经和所有现有成员是朋友(即新节点与小组中的每个节点都有边相连),那么现有成员会迅速欢迎他们的共同朋友加入。

在清单 17-3 中,我们构建了一个贪心算法,逐步构建一个团体,每次添加一个节点。

def clique_greedy(g: Graph) -> list:
clique: list = []
to_add: list = clique_expansion_options(g, clique)
while len(to_add) > 0:
❶ clique.append(to_add[0])
to_add = clique_expansion_options(g, clique)
return clique


列表 17-3:寻找团的贪婪算法

代码从一个空列表开始,表示正在构建的团。它使用一个while循环,持续调用列表 17-2 中的函数<sup class="SANS_TheSansMonoCd_W5Regular_11">clique_expansion_options()</sup>来找到潜在选项列表,并将返回列表中的第一个选项添加到团中❶。当没有更多节点可以添加到当前团时(len(to_add) == 0),它停止并返回该列表。

当逐个添加节点时,我们立刻会遇到一个问题:“我们接下来应该添加哪个节点?”在列表 17-3 中的代码中,我们只添加了第一个选项,但这可能是一个*糟糕*的选择。考虑一下,如果我们将这个贪婪算法应用于图 17-3 中的图,结果会怎样。按原样,贪婪算法会首先选择节点 0,最终返回{0, 1},而不是更大的团{1, 2, 4, 5}。

![一个包含六个节点和无向边(0, 1)、(0, 3)、(1, 2)、(1, 4)、(1, 5)、(2, 4)、(2, 5)、(3, 4)和(4, 5)图](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17003.jpg)

图 17-3:一个贪婪搜索最大团失败的图

贪婪搜索无法保证找到最大团,因为贪婪搜索的每次迭代中的决策并不独立。每次算法将节点 *u* 添加到团中时,这会阻止它添加与 *u* 没有边的任何未来节点。我们很容易因为一开始就选择了错误的节点而陷入局部最大值。我们可以改进选择启发式方法,例如选择边最多的节点,但这也只能起到有限的作用。要构建一个最大团,我们需要进行更全面(且代价更高)的搜索。

#### 回溯搜索

*回溯搜索* 最大团递归地尝试将图中的一个节点设置为团的成员或非成员,如列表 17-4 所示。在每一层递归中,搜索函数使用当前已构建的团(clique)和下一个要测试的节点(index),并递归地测试所有未分配节点的组合,返回该分支搜索中找到的最大团。这个分支过程有效地测试了所有 2^|*^V* ^|个可能的节点子集,同时利用剪枝方法切除无效的选项。

def maximum_clique_recursive(g: Graph, clique: list, index: int) -> list:
❶ if index >= g.num_nodes:
return copy.copy(clique)

❷ best: list = maximum_clique_recursive(g, clique, index + 1)

❸ can_add: bool = True
for n in clique:
can_add = can_add and g.is_edge(n, index)

if can_add:
    clique.append(index)
    candidate: list = maximum_clique_recursive(g, clique, index + 1)
  ❹ clique.pop()

  ❺ if len(candidate) > len(best):
        best = candidate

return best 

列表 17-4:递归探索可能的团

回溯搜索的代码首先检查是否达到了终止条件(已迭代过图中的最后一个节点) ❶。如果是,那么就没有剩余需要检查的内容,clique 就是沿该分支搜索得到的最大子集。代码会返回当前团体的副本,以有效地快照当前状态,并将其与在搜索其余部分中会继续修改的 clique 对象分开。

如果搜索尚未达到递归的终点,代码会尝试分别使用和不使用 index 来构建团体。它通过调用 maximum_clique_recursive(),以当前的 clique 和下一个节点的索引 ❷ 来测试不带 index 的子集,然后保存沿该分支获得的最佳结果以供比较。

在测试包含当前节点的子集之前,代码通过检查该节点是否与当前的 clique ❸ 兼容,避免探索无效的子树。与清单 17-2 中的 clique_expansion_options() 函数类似,maximum_clique_recursive() 函数会检查拟加入的节点 index 是否与当前团体中的所有节点都有边相连。如果缺少任何一条边,添加 index 将导致无效的团体。代码会跳过对这种无效集的递归探索。

如果当前节点与当前团体兼容,代码会尝试将其添加到团体中,并递归测试剩余的选项。然后,它通过移除 index 来清理 clique 数据,以便该 clique 列表可以在其他分支中继续使用 ❹。代码会比较两个分支的结果,并保留较大的有效节点子集 ❺。

我们在清单 17-4 中调用该函数,初始值为 clique=[] 和 index=0,或者使用包装函数:

def maximum_clique_backtracking(g: Graph) -> list:
return maximum_clique_recursive(g, [], 0)


图 17-4 显示了搜索的可视化。每一层都显示了算法在是否包含某个节点上的分支。第一层考虑是否包含节点 0。第二层考虑是否包含节点 1。被分配到团体的节点用阴影表示,排除的节点为白色,未分配的节点为虚线圆圈。

![一棵树,其中每个节点对应一个包含无向边(0, 1)、(0, 3)、(1, 2)和(1, 3)四个节点的图。在根节点处,所有图节点都是虚线的。在树的每一层,另一个节点变为实心白色或实心灰色。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17004.jpg)

图 17-4:最大团体的回溯搜索步骤

图 17-4 中的子图展示了每次函数调用开始时 团体 列表的状态。正如你所看到的,函数只跟踪包含有效团体的分支。例如,在评估 clique=[0] 和 index=2 时,搜索无法跟随右侧分支,因为 {0, 2} 不是一个有效的团体。因此,搜索只测试了 16 种可能的完整组合中的 10 种,如最后一行所示。

### 独立集

一个 *独立集* 实际上是一个团体的对立面。我们在一个无向图中定义独立集为一个节点的子集,保证该子集中的任何两个节点之间都没有邻接边。正式地说,独立集是一个节点集 *V*′ ⊆ *V*,满足以下条件:

(*u*, *v*) ∉ *E* 对于所有 *u* ∈ *V*′ 和 *v* ∈ *V*′

我们可以将选择独立集想象成策划世界上最尴尬的聚会:我们邀请一群来自学校或办公室的人,确保聚会中的任何人都不喜欢其他人。

图 17-5 展示了一个包含两组阴影节点的图。在 图 17-5(a) 中,阴影节点 {0, 2, 4} 构成一个独立集,因为图中没有连接这些节点的边。相比之下,在 图 17-5(b) 中,阴影节点 {0, 1, 4} 并不构成独立集,因为节点 0 和 1 之间有一条边。

![一个包含六个节点和无向边(0, 1)、(0, 3)、(1, 2)、(1, 5)、(2, 5)、(3, 4)和(4, 5)的图。在左侧,阴影节点为 0、2 和 4。在右侧,阴影节点为 0、1 和 4。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17005.jpg)

图 17-5:具有独立子集(a)和非独立子集(b)节点的图

确定一组节点是否构成独立集需要检查每对节点,并确认它们之间没有边:

def is_independent_set(g: Graph, nodes: list) -> bool:
num_nodes: int = len(nodes)
for i in range(num_nodes):
for j in range(i + 1, num_nodes):
if g.is_edge(nodes[i], nodes[j]):
return False
return True


这段代码与 清单 17-1 中的团体检查算法几乎相同。它遍历列表中的每对节点,并检查它们是否违反了独立集的标准。

在我们尴尬聚会的背景下,is_independent_set()函数扮演着另一个持怀疑态度的外来者的角色。无法忍受寂静,他们坚持说:“这里一定有些人是朋友。”他们询问聚会中的每个成员与其他与会者的关系,问:“你确定你们不是朋友吗?”“那他们呢?”只有当每个问题的回答都是没有一对是朋友时,他们才承认,尴尬的气氛是可以理解的,主人可能有点傲慢。

和团体一样,生成大的独立集可能很困难,因为向我们的独立集添加一个节点可能会影响其他节点的有效性。寻找*最大独立集*的问题就是在图中找到一个最大的节点子集*V*′ ⊆ *V*,该子集构成一个有效的独立集。

#### 使用案例

我们可以通过考虑一个问题来形象化独立集的重要性:我们希望选择一组没有负面联系(边)的人(节点)。想象一下,在一个高度功能失调的组织中,你被分配任务建立一个功能正常的项目团队。每个员工对同事都怀有怨恨,因为“错放”的午餐或被遗忘的生日。实际上,人力资源部门甚至已经建立了一个图,表示成对的怨恨关系。每个节点代表一名员工,无向边表示相互之间的恶意。寻找一个没有互相讨厌的员工团队的问题,就是在这个图中找到一组节点,使得这组节点之间没有共享边。

或者,我们可以将独立集问题想象成设计一个神奇地下城的背景。为了给冒险者提供一个适当但不至于不可能完成的挑战,一位邪恶的巫师决定不在任何两个相邻的房间中放置 BOSS 级怪物。他们将地下城层级建模为一个图,节点表示房间,房间之间的隧道表示边。接下来,他们开始寻找包含 BOSS 级怪物的最大独立集房间。其余的房间可以安排低级史莱姆,以便给冒险者们休息一下。

#### 贪心搜索

和团体算法一样,我们可以定义一个贪心算法,通过逐个添加兼容节点来构建独立集。我们通过检查每个节点是否有效,列出独立扩展的选项,如列表 17-5 所示。

def independent_set_expansion_options(g: Graph, current: list) -> list:
options: list = []
for i in range(g.num_nodes):
if i not in current:
valid: bool = True
for j in current:
❶ valid = valid and not g.is_edge(i, j) if valid:
options.append(i)
return options


列表 17-5:找到可以添加到独立集中的节点

代码遍历图中的每个节点,并测试该节点是否可以添加到独立集合中。对于这个功能,代码检查考虑中的节点是否与当前集合中的任何节点共享边 ❶。只有当这些检查对于集合中的每个节点都通过(并且 valid 仍然为 True)时,代码才会将当前节点添加到候选节点列表中。

我们可以通过选择下一个符合启发式的节点来扩展贪婪搜索,而不是仅仅选择任何可行的节点。这个启发式方法不会保证 100% 的正确结果,但它可以帮助引导集合构建朝着更好的方向发展。对于独立集合问题,一个合理的启发式方法是选择边数最少的节点,这些节点可能与其他节点的冲突较少,因此更符合我们的需求。在我们功能失调的组织中,这相当于选择那些怀恨最少的团队成员。

清单 17-6 展示了我们如何通过修改 清单 17-5 来编码这个启发式方法,从而返回具有最少边数的可行节点。

def independent_set_lowest_expansion(g: Graph, current: list) -> int:
❶ best_option: int = -1
best_num_edges: int = g.num_nodes + 1

for i in range(g.num_nodes):
  ❷ if i not in current and g.nodes[i].num_edges() < best_num_edges:
        valid: bool = True
      ❸ for j in current:
            valid = valid and not g.is_edge(i, j)
        if valid:
            best_num_edges = g.nodes[i].num_edges()
            best_option = i
return best_option 

清单 17-6:寻找与独立集合兼容的具有最少边数的节点

代码在很大程度上与 清单 17-5 中的代码相似,但会跟踪看到的最佳节点(best_option)及其边数(best_num_edges)。它首先将看到的最佳节点设置为无效选项 -1,并将最佳边数设置为大于单个节点可能相邻的数量 ❶。然后,代码使用 for 循环检查每个节点的可行性。然而,在进行可行性测试之前,代码会先检查考虑中的节点是否比当前已找到的最佳节点具有更少的边 ❷。如果没有,它是否可行就不重要,因为代码反正不会返回它。因此,它可以跳过可行性测试,继续检查下一个节点。

实际的可行性测试与 清单 17-5 中的完全相同❸。代码使用一个 for 循环遍历现有的独立集合,测试每个节点是否与当前候选节点 i 兼容。只有当这些检查对于集合中的每个节点都通过时,代码才会将当前节点保存为新的 best_option。在遍历完所有可能的节点后,代码返回找到的最佳节点。

我们可以通过不断将最佳候选节点添加到独立集合中来构建贪婪搜索:

def independent_set_greedy(g: Graph) -> list:
i_set: list = []
to_add: int = independent_set_lowest_expansion(g, i_set)
while to_add != -1:
i_set.append(to_add)
to_add = independent_set_lowest_expansion(g, i_set)
return i_set


代码从一个空列表 i_set 开始,并使用来自清单 17-6 的 independent_set _lowest_expansion() 函数来找到最佳节点进行添加。它使用一个 while 循环,持续地找到并添加节点,直到无法再添加其他节点。

在我们功能失调的组织示例中,寻找独立集的贪心搜索算法通过每次选择与之前选择的每个员工兼容且怨恨最少的员工,逐步建立一个团队。我们从选择没有冲突的员工(节点)开始(没有边)。这些员工可以总是添加到独立集中。接下来,我们考虑只有单一冲突的员工,依此类推,始终跳过与当前团队中任何人不兼容的员工。

贪心搜索并不总是能找到最大独立集。尽管使用了有信息的启发式方法,但这个贪心搜索仍然可能做出次优选择,将解决方案局限于局部最小值。考虑如果我们将这个贪心算法应用于图 17-6 中的图,结果会怎样。

![一个包含六个节点和无向边的图,边为 (0, 2), (0, 4), (1, 3), (1, 5), (2, 3), (2, 4), (2, 5) 和 (4, 5)。(A) 图中节点 0 和 1 被标记为阴影。(B) 图中节点 0、3 和 5 被标记为阴影。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17006.jpg)

图 17-6:贪心搜索最大独立集的结果(a)和真实的最大独立集(b)

如图 17-6(a)所示,贪心算法将选择节点 0,然后选择节点 1,最终锁定在局部最小值。如果搜索选择节点 3 作为第二选择,如图 17-6(b)所示,它将找到 {0, 3, 5}。#### 回溯搜索

构建最大独立集的回溯搜索再次尝试为每个节点标记为集合的成员或非成员。这个分支使得函数可以测试所有节点组合,并返回通过每个分支搜索找到的最大独立集。在每一层递归中,清单 17-7 中的函数将传入目前已构建的独立集(current)和下一个要测试的节点(index)。

def maximum_independent_set_rec(g: Graph, current: list, index: int) -> list:
❶ if index >= g.num_nodes:
return copy.copy(current)

❷ best: list = maximum_independent_set_rec(g, current, index + 1)

❸ can_add: bool = True
for n in current:
can_add = can_add and not g.is_edge(n, index)

if can_add:
    current.append(index)
  ❹ candidate: list = maximum_independent_set_rec(g, current, index + 1)
  ❺ current.pop()

    if len(candidate) > len(best):
        best = candidate

return best 

清单 17-7:递归探索可能的独立集

按照列表 17-4 中 maximum_clique_recursive() 函数的相同模式,maximum_independent_set_rec() 函数首先测试是否已到达递归的末尾,并且没有剩余的节点需要检查 ❶。如果是,它会返回当前独立集的副本,作为沿此分支找到的最佳结果。

如果搜索还没有到达递归的末尾,它会尝试构建两个独立集合,一个包含 index,另一个不包含 index。它通过用当前独立集和下一个节点的索引 ❷ 来调用函数,从而测试不包含 index 的子集。这实际上跳过了当前的索引,继续考虑后续节点。代码将沿此分支找到的最佳结果保存下来,作为其他分支的基准。

然后,代码检查当前节点(index)是否与正在构建的独立集兼容 ❸。如果当前节点与 current 中的任何节点共享边,那么添加该节点将导致无效的独立集。代码仅探索能够形成有效独立集的路径(can_add == True)。

如果当前节点与当前集合兼容,代码会尝试将该节点添加到 current 中,并递归测试剩余选项 ❹。然后,它通过删除 index 来清理 current 列表,以便在其他分支中继续使用该列表 ❺。代码比较两个分支的结果,并保留较大的有效节点子集。

我们在列表 17-7 中调用函数,初始值为 current=[] 和 index=0,或者使用一个包装函数:

def maximum_independent_set_backtracking(g: Graph) -> list:
return maximum_independent_set_rec(g, [], 0)


图 17-7 显示了搜索的可视化,每一层表示算法在是否包含某个节点上的分支。分配给独立集的节点用阴影表示,排除的节点为白色,未分配的节点为虚线圆圈。

![一棵树,其中每个节点对应一个四节点图,具有无向边 (0, 1),(0, 3),(1, 2) 和 (1, 3)。在根节点,所有图节点都为虚线。在树的每一层,另一个节点变成实心白色或实心灰色。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17007.jpg)

图 17-7:最大独立集回溯搜索的探索

图 17-7 中的子图展示了每次函数调用开始时的当前状态。第一级考虑是否包含节点 0;第二级考虑是否包含节点 1。由于该函数仅探索包含有效独立集的分支,因此它仅达到了 16 种可能完整赋值中的 7 种。

### 顶点覆盖

虽然寻找团体(cliques)和独立集(independent sets)的问题都聚焦于是否一对节点是邻居,但*顶点覆盖(vertex cover)*的问题则考虑每个节点所连接的边。我们在无向图中定义顶点覆盖为一个节点子集,使得每一条边至少有一个端点在这个集合中。换句话说,每条边都被至少一个顶点(节点)覆盖。形式化地,顶点覆盖是一个节点集合*V*′ ⊆ *V*,满足以下条件:

对于每条边(*u*, *v*)∈ *E*,我们有 *u* ∈ *V*′,*v* ∈ *V*′,或者两者都在。

我们可以在一个由群岛(节点)和桥梁(边)组成的王国背景下来设想顶点覆盖。为了维护安全,王国在每个岛屿上建造了高大的瞭望塔来监视每座桥梁。每座岛屿上的瞭望塔能够看到触及该岛屿的每座桥梁,从而使王国能够策略性地选择瞭望塔的位置。然而,每座桥梁(边)必须至少以一个岛屿为终点,并且该岛屿上有一座瞭望塔(选定的节点)。

图 17-8 展示了一个包含两个阴影节点子集的图。图中阴影节点{1, 3, 5}在图 17-8(a)中形成了一个顶点覆盖,因为每条边至少触及一个阴影节点。相反,图 17-8(b)中的阴影节点{0, 1, 4}并没有形成顶点覆盖,因为边(2, 5)没有被集合中的任何节点覆盖。

![一张包含六个节点和无向边(0, 1),(0, 3),(1, 2),(1, 5),(2, 5),(3, 4),(4, 5)的图。在(A)中,阴影节点是 1、3 和 5。在(B)中,阴影节点是 0、1 和 4。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17008.jpg)

图 17-8:形成顶点覆盖的节点子集(a)和不是顶点覆盖的节点子集(b)

判断一组节点是否形成顶点覆盖需要我们检查图中的每一条边是否被集合中的至少一个节点所覆盖:

def is_vertex_cover(g: Graph, nodes: list) -> bool:
❶ node_set: set = set(nodes) for edge in g.make_edge_list():
❷ if edge.from_node not in node_set and edge.to_node not in node_set:
return False
return True


这段代码首先创建一个包含节点的集合(node_set),通过使用 set 数据结构来实现快速查找,而不是通过 list 进行搜索 ❶。然后,它循环遍历图中的每一条边,检查源节点和目标节点是否都不在集合中 ❷。如果这两个节点都不在集合中,那么该边就没有被覆盖,函数立即返回 False。如果代码能遍历所有的边,最后返回 True。

寻找*最小顶点覆盖*的问题就是找到图中最小的节点子集 *V*′ ⊆ *V*,该子集能形成一个顶点覆盖。这个问题与成本节省有直接的类比。在瞭望塔的例子中,王国希望建造最少数量的瞭望塔,以确保每座桥梁的安全。

#### 使用案例

顶点覆盖问题在维护的背景下自然出现。假设一个邪恶但整洁的巫师建造了一个魔法地下城。知道自己不能让通道不清扫或冒着火把熄灭的风险,他们需要在每个通道附近安置一队紧急修理小兵。在冒险者跌跌撞撞地走过隧道,与怪物搏斗时打松了石块后,小兵们会冲过去修复损坏。为了效率,巫师需要在每个通道尽头的两个房间中至少安排一个队伍。为了尽量降低成本,巫师精心计算出他们可以雇佣的最少数量的队伍。

在非魔法环境下,我们可能会考虑雇佣维修队或收费员来管理交通网络。为了保持成本低廉,我们计划设置一个收费站,通过该收费站,所有进出岛屿的交通流量都会经过。

#### 贪心搜索

我们可以基于为独立集提出的贪心算法,创建一个用于寻找顶点覆盖的贪心方法,方法是使用节点的子集,如清单 17-8 所示。这一次,我们使用启发式方法,选择能够覆盖最多未覆盖边的节点。

def vertex_cover_greedy_choice(g: Graph, nodes: list) -> int:
❶ edges_covered: set = set([])
for index in nodes:
for edge in g.nodes[index].get_edge_list():
edges_covered.add((edge.from_node, edge.to_node))
edges_covered.add((edge.to_node, edge.from_node))

best_option: int = -1
best_num_edges: int = 0 for i in range(g.num_nodes):
    new_covered: int = 0
    for edge in g.nodes[i].get_edge_list():
      ❷ if (edge.from_node, edge.to_node) not in edges_covered:
            new_covered = new_covered + 1

    if new_covered > best_num_edges:
        best_num_edges = new_covered
        best_option = i

return best_option 

清单 17-8:启发式选择一个节点来添加到顶点覆盖中

相较于清单 17-6 中的代码,这段代码进行了一些额外的记录工作,用于跟踪在集合edges_covered中已经覆盖的边。它首先创建一个空的已覆盖边集合❶。由于我们的Graph类实现了无向边,代码会将每条无向边以两个方向添加到edges_covered中。

主要的for循环类似于独立集的启发式方法,代码遍历图中的每个节点并检查它的启发式值。在这种情况下,代码计算当前节点的边中有多少会被新覆盖❷,并保持目前为止看到的最佳选项,返回它。如果没有任何节点能够增加已覆盖的边的数量(即nodes已经形成了一个有效的顶点覆盖),代码返回-1。

我们通过在清单 17-8 中的选择逻辑外面加一个循环,创建了一个完整的贪心算法:

def vertex_cover_greedy(g: Graph) -> list:
nodes: list = []
to_add: int = vertex_cover_greedy_choice(g, nodes)
while to_add != -1:
nodes.append(to_add)
to_add = vertex_cover_greedy_choice(g, nodes)
return nodes


代码从一个空的节点列表(nodes)开始,表示当前选择,并使用来自清单 17-8 的vertex_cover_greedy_choice()函数逐个添加节点,直到构建出一个有效的顶点覆盖,并且没有任何添加能够增加覆盖率。

请注意,我们可以通过在外部循环中维护< samp class="SANS_TheSansMonoCd_W5Regular_11">edges_covered集合并将其传递给vertex_cover_greedy_choice()函数来提高这个贪心算法的效率。这样可以避免在每次迭代时重新计算它。在本描述的上下文中,我们故意重新计算edges_covered,以保持选择函数独立。

正如本章中所有其他贪心算法一样,最小顶点覆盖的贪心算法并不能保证是最优的。一个看起来不错的初始选择可能在整个图的上下文中证明是次优的。

想象一下我们在瞭望塔示例中的规划员正在处理图 17-9 中展示的岛屿。为了保持低成本,规划员选择了拥有最多桥梁的岛屿(节点 0)作为第一个瞭望塔。这通常是一个不错的策略,因为该节点覆盖了最多的边。然而,在这种情况下,它导致了图 17-9(a)中展示的次优解。通过首先选择节点 0,规划员需要选择另外三个岛屿来覆盖最右侧的边。更糟糕的是,他们会一次又一次重复这个错误。只要贪心算法使用确定性的选择,它总会产生相同的结果。

![一个包含七个节点和边(0, 1)、(0, 2)、(0, 3)、(1, 4)、(2, 5)和(3, 6)的图。在(A)中,节点 0、1、2 和 3 被阴影标出。在(B)中,节点 1、2 和 3 被阴影标出。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17009.jpg)

图 17-9:在最小顶点覆盖问题上,非最优贪心搜索(a)与最优解(b)的结果比较

相比之下,图 17-9(b)展示了一个使用更少节点的顶点覆盖。一旦我们包含了节点 1、2 和 3,就不再需要包含节点 0。

#### 回溯搜索

虽然最小顶点覆盖的回溯搜索采用了与最大团和独立集构建类似的逐节点方法,但通过添加节点构建顶点覆盖并未提供相同的剪枝机会。我们的通用剪枝方法要求我们从一个有效的解决方案开始,并跳过那些使候选集合无效的选择。然而,一个有效顶点覆盖的子集可能无法覆盖每一条边,因此可能本身不是一个有效的顶点覆盖。因此,我们不能从一个空集合开始并从那里构建。

如果我们从一个完整的节点集合开始并逐个删除节点,而不是向集合中添加节点,我们就能重新获得剪枝的机会。由于所有节点的集合本身就是一个有效的顶点覆盖,我们重新获得了仅沿着仍然有效的顶点覆盖分支前进的约束。

在每一层递归中,回溯搜索函数会取当前的顶点覆盖(current)和下一个待测试移除的节点(index),并探索如果移除该节点和不移除该节点的两种可能性,如清单 17-9 所示。

def minimum_vertex_cover_rec(g: Graph, current: set, index: int) -> set:
❶ if index >= g.num_nodes:
return copy.copy(current) best: set = minimum_vertex_cover_rec(g, current, index + 1)

can_remove: bool = True
for edge in g.nodes[index].get_edge_list():
  ❷ can_remove = can_remove and edge.to_node in current

if can_remove:
    current.remove(index)
    candidate: set = minimum_vertex_cover_rec(g, current, index + 1)
  ❸ current.add(index)

    if len(candidate) < len(best):
        best = candidate

return best 

清单 17-9:递归探索可能的顶点覆盖

代码首先测试是否已经到达递归的末尾,并且没有剩余的节点可供检查 ❶。如果是,它会返回当前顶点覆盖的副本作为沿着该分支找到的最佳结果。

如果函数尚未到达递归的结束,代码会尝试带有和不带有index的顶点覆盖。然而,与清单 17-4 和 17-7 中的搜索不同,这里考虑的是是否*移除*index。默认选项是将index保留在集合中,通过递归调用函数,使用当前集合和下一个节点的索引。代码将此分支的结果保存为基准最佳结果。

在移除节点之前,代码检查此移除是否会破坏顶点覆盖。为了确保在没有index的情况下集合仍然有效,当前由该节点覆盖的所有边必须由current中的另一个节点覆盖 ❷。代码通过遍历当前节点的每条边并检查相应的邻居(edge.to_node)是否在current中来进行检查。

如果可以移除当前节点,代码尝试从current中移除index并递归测试剩余的选项。然后,它通过重新添加index来清理current数据,这样集合就可以在其他分支中使用 ❸。代码比较两个分支的结果,并保留较小的有效节点子集。

我们调用清单 17-9 中的函数,初始时将current设置为所有节点索引的集合,并将index=0,通过使用包装函数:

def minimum_vertex_cover_backtracking(g: Graph) -> list:
current: set = set([i for i in range(g.num_nodes)])
best: set = minimum_vertex_cover_rec(g, current, 0)
return list(best)


图 17-10 提供了这种搜索的可视化,每一层展示算法在移除(或不移除)单个节点时的分支情况。分配给顶点覆盖的节点被阴影显示,排除的节点为白色,未分配的节点最初是虚线圆圈,包含在顶点覆盖中。

![一棵树,每个节点对应一个四节点图,图中有无向边(0, 1)、(0, 3)、(1, 2)和(1, 3)。在根节点,所有图节点都为虚线和灰色。在树的每一层,另一个节点变为实心白色或实心灰色。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f17010.jpg)

图 17-10:用于寻找顶点覆盖的回溯搜索

图 17-10 中的子图显示了每次函数调用开始时current的状态。由于该函数只探索包含有效顶点覆盖的分支,它仅遍历了 16 个可能完整分配中的 7 个。

### 随机化算法

另一种解决本章讨论的分配问题的方法是使用*随机化算法*来评估解决方案。这些算法使用随机数生成器来选择下一个要添加到集合中的节点或从集合中移除的节点。起初,这看起来似乎不太可能奏效。以确定性思维为主的用户可能会惊呼:“为什么要添加一个随机节点,而我们可以通过贪心搜索添加最好的节点?难道我们不会浪费大量时间做出糟糕的选择吗?”虽然随机化算法确实会探索次优选择,但它们有几个重要的优势值得考虑。

首先,随机化算法避免了贪心算法可能陷入的局部最小值。正如我们在图 17-9(a)中看到的,贪心搜索通过单独做每个选择,可能会导致次优解。相比之下,随机化算法有时会猜测一个好的解决方案,就像图 17-9(b)中的情况一样。

其次,随机化算法非常适合并行化:我们可以并行运行多个随机化搜索(无需大量协调),然后比较每个搜索中找到的最佳结果。这相当于让多个瞭望塔规划者各自进行随机化搜索并比较结果,或许作为一次王国范围的竞争,参赛者争夺制作最佳瞭望塔计划的称号。每个小组可以独立工作,而无需进行王国范围的协调。

在最简单的形式中,没有任何机制阻止随机化搜索多次尝试相同的解决方案。虽然我们可以使用额外的跟踪来避免或至少减少评估重复选项,但这会增加复杂性,并且在并行搜索的情况下,还需要进行协调。在本节中,我们关注随机化如何工作的基础知识,因此保持实现简单。

#### 基本随机化搜索

最简单的随机化搜索完全随机地选择可行的选项。让我们考虑在寻找最大独立集的背景下,这种方法如何工作。我们可以使用来自清单 17-5 的 independent_set_expansion_options() 函数,提供可行选项的列表:

def independent_set_random(g: Graph) -> list:
i_set: list = []
options: list = independent_set_expansion_options(g, i_set)
while len(options) > 0:
❶ index: int = random.randint(0, len(options)-1)
i_set.append(options[index])
❷ options = independent_set_expansion_options(g, i_set)
return i_set


代码从一个空的独立集(i_set)和一个包含所有节点的潜在选项列表(options)开始。代码通过不断随机选择一个可行的节点 ❶,将其添加到独立集中,并重新构建可行扩展选项集 ❷ 来运行。代码使用 Python 的 random 库中的 randint() 函数来选择节点,因此文件顶部需要包含 import random。循环继续,直到没有更多可添加的选项,此时代码返回当前的独立集。

尽管是随机的,这个函数保证生成有效的独立集。在每次迭代中,算法只考虑来自可行选项列表的扩展,这意味着每次添加后独立集依然有效。我们可以使用一个循环不断搜索更好的解,直到达到最大迭代次数为止:

def build_independent_set_random(g: Graph, iterations: int) -> list:
best_iset: list = []
for i in range(iterations):
current_iset: list = independent_set_random(g)
if len(current_iset) > len(best_iset):
best_iset = current_iset
return best_iset


代码从一个空的独立集(best_iset)开始,作为迄今为止看到的最佳结果。然后它使用一个 for 循环来生成和测试更多选项。在每次迭代中,代码使用 independent_set_random() 生成一个随机独立集,并将其大小与迄今为止看到的最佳集进行比较。它跟踪看到的最大独立集,并将其作为 best_iset 返回,在测试了 iterations 次选项后返回结果。

我们可以将这种搜索过程与之前提到的在功能失调的组织中建立团队的例子联系起来。规划者决心组建最大团队,但由于时间紧迫,他们无法进行全面的搜索。由于面临严格的最后期限,他们决定建立 100 个随机但有效的团队,并向老板呈现最佳团队。在他们的 100 次尝试中,他们使用随机选择的方法,确保至少有机会尝试一些之前未曾考虑过的选项。在 100 次尝试后,他们写下最佳团队,并赶紧去老板办公室以完成最后期限。

与贪心搜索类似,随机搜索并不能保证找到最佳解。然而,与贪心搜索不同,随机搜索可以避免一遍又一遍地犯同样的错误。

#### 加权随机搜索

完全随机化搜索的一个潜在缺点是,我们有相等的概率选择一个有前景的节点或一个糟糕的节点。虽然必须有一定的概率选择每个节点,以完全探索解空间,但我们并不一定要以相等的概率选择节点。我们没有理由让办公室外交官(没有人际冲突的人)和办公室麻烦制造者(与公司一半的人有持续的纷争)有相等的机会进入团队。

一个*加权随机化算法*利用关于问题结构的信息来定义一个自定义的概率分布,以选择节点。举一个简单的例子,考虑在最大独立集问题中选择下一个节点。给定一个表示当前独立集的节点子集*V*′ ⊆ *V*,我们可以定义一个可行候选集*C*,该集由那些不在*V*′中且与*V*′中某个节点没有边相连的节点组成。形式上,我们可以这样表示:

对于每个*u* ∈ *C* 和 *v* ∈ *V*′:*u* ≠ *v* 且 (*u*, *v*) ∉ *E*。

给定这个候选集*C*,我们可以定义一个概率分布*p*(*v*),用于选择节点*v* ∈ *V*,其中:

*p*(*v*) = 0 如果 *v* ∉ *C*

和

∑v *p*(*v*) = 1

例如,我们可以为每个节点分配一个权重,该权重与邻接边的数量成反比,从而增加我们选择邻居较少的节点的概率。

### 为什么这很重要

对于本章和上一章中涉及的所有问题,评估一个提议的解决方案很容易,但找到最优解却很困难。我们已经研究了多种解决 NP 难题图分配问题的方法,包括贪婪搜索、随机化搜索、穷举搜索和定制(启发式)算法。然而,没有一种已知的方法对于所有情况都是高效的。

这些问题仅仅是 NP 难图问题的一个子集。虽然它们没有已知的通用高效算法,但它们通常对应着现实世界中至关重要的问题。因此,理解这些问题的结构以及解决它们的实际技术是很重要的。

我们为本章中的每个问题提出了两种方法——一种是近似贪婪解法,另一种是使用回溯搜索的穷举解法——以说明这些问题以及使它们在计算上困难的因素。这些方法仅仅触及了所研究的技术范围的表面。例如,有兴趣的读者可以在 Cormen、Leiserson、Rivest 和 Stein 的《*算法导论*》(第四版,MIT 出版社,2022 年)中找到一个关于顶点覆盖的有界近似算法。Russell 和 Norvig 的《*人工智能:一种现代方法*》(第四版,Pearson,2020 年)为强大的约束满足算法世界提供了很好的介绍,并将这些算法应用于图着色等问题。

在下一章,我们将讨论选择图中的哪些边作为遍历图的部分,而不是为集合选择节点。




## 第十八章:18 图中的旅游路线



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

设计一个最佳观光旅游的难题非常适合用图形来表示。根据我们的旅行偏好,我们可能希望精确地访问一组主要景点一次,最小化总行程,或者访问目的地城市中的每一条街道。每一种情况都对应着一个经典的图问题:规划图中的路径。

本章中,我们将考虑这个核心问题的几种变体。*哈密尔顿路径*是指每个节点仅访问一次,可以帮助我们规划那些我们希望访问离散景点的旅行。解决*旅行商问题*则可以帮助我们访问每个景点,同时最小化总行程距离。最后,*欧拉路径*仅遍历每条边一次,可以帮助我们规划那些我们希望在不重复的情况下遍历每条街道的旅行。

这些路径规划问题不仅限于度假规划,它们在现实世界中有广泛的应用,如解决航运行业中的物流问题。不幸的是,许多问题也伴随着显著的计算挑战。虽然计算欧拉路径问题有高效的计算解法,但哈密尔顿路径和旅行商问题都是 NP 难题。我们将基于前几章的技术,创建穷举算法来解决这些问题。

### 哈密尔顿路径与循环

*哈密尔顿路径*是以数学家威廉·哈密尔顿命名的,它是图中的一条路径,访问每个节点一次且仅一次。我们可以将这个问题看作是为一个既彻底又容易感到无聊的游客规划行程的问题。游客面临着两个相互竞争的目标。首先,他们需要确保访问城市中的每个景点,不希望错过任何一个。其次,他们希望避免看到同一个景点两次。毕竟,如果你已经看过了一个巨大的钟楼,你真的需要再看一遍吗?

图 18-1 展示了一个包含六个节点的图中的哈密尔顿路径[0, 1, 2, 5, 4, 3]。该路径从节点 0 开始,经过节点 1、2、5 和 4,最后到达节点 3。每个节点仅被访问一次。

![一个包含六个节点的图。箭头标记了从节点 0 到节点 1、2、5、4、3 的路径。该路径以节点 3 为终点。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18001.jpg)

图 18-1:哈密尔顿路径

对于游客而言,更有用的是*哈密尔顿循环*(或回路),它从同一个节点出发并回到该节点,同时访问每个节点一次且仅一次。虽然游客希望尽量避免重复的目的地,但他们希望能在酒店开始和结束旅行——这是一个可接受的折中,因为这样就不必在城市中搬运行李。

虽然旅游示例使用了预定义的节点(酒店)作为起点和终点,但哈密顿循环可以从图中的任何节点开始或结束。图 18-2 中的示例哈密顿循环可能从节点 0 或其他任意五个节点开始。

![一个包含六个节点的图,箭头标记了一条从节点 0 通过节点 1、2、5、4 和 3 到节点 0 的路径。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18002.jpg)

图 18-2:哈密顿循环

由于图 18-2 中的路径形成了一个循环,路径上的所有节点必须能够从自身到达。如我们所见,这种灵活性并不适用于一般的哈密顿路径。

#### 验证哈密顿路径

判断路径是否是哈密顿路径需要我们检查每个节点是否被访问一次。我们定义了一个检查函数is_hamiltonian_path(),它接受一个由已访问节点组成的路径列表:

def is_hamiltonian_path(g: Graph, path: list) -> bool:
num_nodes: int = len(path)
❶ if num_nodes != g.num_nodes:
return False

visited: list = [False] * g.num_nodes

❷ prev_node: int = path[0]
visited[prev_node] = True

for step in range(1, num_nodes):
    next_node: int = path[step]

  ❸ if not g.is_edge(prev_node, next_node):
        return False
  ❹ if visited[next_node]:
        return False

    visited[next_node] = True
    prev_node = next_node

return True 

代码首先通过确认路径长度等于图中节点的数量 ❶ 来检查潜在的有效性。如果路径中的步骤较少,那么就无法访问所有节点。如果步骤过多,那么某个节点一定被访问了多次。这也处理了空路径和空图的边界情况。

如果路径非空,代码会设置数据结构,使用布尔数组跟踪每个节点是否被访问过(visited),以及沿路径看到的前一个节点(prev_node)。算法通过将prev_node设置为路径中的第一个节点并将其标记为已访问 ❷来开始检查。

算法的大部分内容是通过路径进行的for循环。在每一步,它检查前一个节点和当前节点之间是否存在边 ❸,然后检查当前节点是否尚未被访问 ❹。如果任何一个检查失败,则路径不是有效的哈密顿路径,代码返回False。如果两个检查都成功,算法将当前节点标记为已访问,并将prev_node设置为当前节点。如果代码成功遍历整个路径,则表示它已经访问了图中的相同数量的节点,并且没有访问任何节点两次。它返回True表示成功。

#### 使用深度优先搜索寻找哈密顿路径

虽然寻找哈密顿路径的问题是 NP 难题,但我们可以定义一个穷举搜索,尽管代价很高,它仍然能够找到这样的路径。我们使用深度优先搜索的变体,该变体不是每次只访问一个节点,而是探索通过一个节点的所有路径。

考虑 图 18-3(a) 中的图。这个图有一个有效的哈密顿路径 [0, 1, 3, 2, 4],如 图 18-3(b) 中所示。然而,第四章 中的深度优先搜索不会按此顺序访问节点,而是在节点 3 之前探索节点 2。

![(A) 显示了一个包含五个节点的图,带有有向边(0, 1)、(1, 0)、(1, 2)、(1, 3)、(2, 0)、(2, 3)、(2, 4)和(3, 2)。(B) 显示了相同的图,边(0, 1)、(1, 3)、(3, 2)和(2, 4)被加粗。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18003.jpg)

图 18-3:一个哈密顿路径,它与深度优先搜索的访问顺序不匹配

为了找到有效的哈密顿路径,我们必须扩展这个深度优先搜索,使其能够回溯并尝试不同的路径。搜索必须在当前节点之后重置节点的访问状态为未访问,以便它可以尝试不同的路径到达这些节点。

列表 18-1 中的哈密顿深度优先搜索代码使用了标准的深度优先搜索,并进行了少量修改。

def hamiltonian_dfs_rec(g: Graph, current: int, path: list,
seen: list) -> Union[list, None]:
path.append(current)
seen[current] = True
❶ if len(path) == g.num_nodes:
return path

for edge in g.nodes[current].get_edge_list():
    n: int = edge.to_node
    if not seen[n]:
      ❷ result: Union[list, None] = hamiltonian_dfs_rec(g, n, path, seen) if result is not None:
            return result

❸ _ = path.pop()
seen[current] = False
return None


列表 18-1:一个用于搜索哈密顿路径的递归函数

hamiltonian_dfs_rec() 函数接受图(g)、当前节点索引(current)、到目前为止的路径(作为节点列表,path),以及已经访问的节点的布尔列表(seen)。如果找到了路径,它将返回代表路径的节点列表,否则返回 None。我们必须从 Python 的 typing 库中导入 Union,以支持返回值的类型提示。

代码首先将当前节点添加到路径中,并将其标记为已访问。然后,它检查是否已访问 g.num_nodes 个独特的节点 ❶。如果是,path 就是一个有效的哈密顿路径,函数将返回它。

核心搜索逻辑发生在路径尚未完成时。代码通过 for 循环迭代出边,并递归地测试未访问的邻居 ❷。如果在这些探索中找到有效的哈密顿路径(result is not None),它会立即返回该路径。在这种情况下,函数退出时不会重置节点的 seen 值或 path 列表,因为它将不再继续测试备用路径。

如果代码在通过每条出边时都没有找到有效路径,它会回溯到上一个节点。代码会从路径中移除当前节点并标记为未访问 ❸。这样,搜索就可以通过不同的路径访问该节点。代码返回 None,表示无法在此分支上找到路径。

我们定义了一个包装器,它从每个可能的起始节点启动搜索:

def hamiltonian_dfs(g: Graph) -> Union[list, None]:
seen: list = [False] * g.num_nodes
for start in range(g.num_nodes):
path: Union[list, None] = hamiltonian_dfs_rec(g, start, [], seen)
if path is not None:
return path
return None


hamiltonian_dfs() 函数将 seen 列表初始化为所有 False,并使用 for 循环从每个起始节点开始递归搜索。一旦找到路径(即非 None 的结果),就返回该路径。如果无法使用任何起始节点找到有效的哈密顿路径,则返回 None。

图 18-4 展示了这种更新后的搜索示例。在访问 图 18-4(a) 中的节点 1 时,搜索有两个去向选择,节点 2 或 3。它首先探索节点 2,如 图 18-4(b) 所示,这导致在节点 3 处遇到死胡同,未能访问节点 4。由于无法访问节点 4,因此没有找到有效的哈密顿路径,必须回溯并尝试备用路径。

![所有四个子图显示相同的有向图,包含五个节点和有向边 (0, 1),(1, 0),(1, 2),(1, 3),(2, 0),(2, 3),(2, 4),(3, 2)。(A) 显示边 (0, 1) 加粗,节点 0 和 1 被阴影标记。(B) 显示边 (0, 1),(1, 2) 和 (2, 3) 加粗,节点 0、1、2 和 3 被阴影标记。(C) 显示边 (0, 1),(1, 2) 和 (2, 4) 加粗,节点 0、1、2 和 4 被阴影标记。(D) 显示边 (0, 1),(1, 3),(3, 2) 和 (2, 4) 加粗,节点 0、1、2、3 和 4 被阴影标记。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18004.jpg)

图 18-4:基于深度优先的哈密顿路径搜索步骤

搜索回溯到节点 2。它将节点 3 标记为未访问,因为它不再将该节点包含在当前路径中。搜索考虑从节点 2 出发的其他路径。它已经拒绝了边 (2, 0),因为它之前已经访问过节点 0。剩下的边是 (2, 4),如 图 18-4(c) 所示。不幸的是,走这条边会导致死胡同,无法访问节点 3,搜索再次被阻塞。

搜索必须回溯到节点 1,并尝试通往节点 3 的路径,如 图 18-4(d) 所示。它将节点 2 和节点 4 都重置为未访问,并返回到 图 18-4(a) 中显示的状态。这样它就可以从节点 3 到达节点 2,并继续前往节点 4。

不幸的是,执行一次深度优先搜索可能不足以解决问题。与哈密顿回路不同,搜索的起始节点会影响是否能够找到哈密顿路径。 图 18-5 显示了一个图,其中从节点 1 开始可以找到哈密顿路径 [1, 0, 2],从节点 2 开始可以找到哈密顿路径 [2, 0, 1],但从节点 0 开始无法找到哈密顿路径。

![该图显示了一个包含三个节点和边 (1, 0) 以及 (0, 2) 的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18005.jpg)

图 18-5:从节点 0 开始没有哈密顿路径的图

为了解决这个问题,我们可以使用每个可能的起始节点进行单独的深度优先搜索。我们会继续搜索,直到用尽所有起始节点或者找到一个有效的路径。  ### 旅行商问题

*旅行商问题* 是寻找哈密顿回路的扩展问题,它考虑了边的权重。这个问题的模型借鉴了旅行商规划多城市行程的问题,其目标是找到一条路径,满足以下条件:(1) 从同一节点出发并返回;(2) 每个节点恰好访问一次;(3) 最小化边权重的总和。

图 18-6 显示了一个示例图 (a) 和其最低成本的旅行商路线 (b)。通过手动尝试不同的路径,几分钟内就能快速发现问题的难度:即使是像这样的简单图,可能的路径数量也会急剧增加。

![(A) 显示了一个带有 6 个节点和 11 条带权无向边的加权图,边的权重分别为: (0, 1) = 5, (0, 2) = 6, (0, 3) = 2, (1, 2) = 3, (1, 3) = 1, (1, 4) = 1, (1, 5) = 2, (2, 5) = 6, (3, 4) = 7, (3, 5) = 5 和 (4, 5) = 1。 (B) 显示了相同的图,其中边 (0, 3)、(3, 1)、(1, 4)、(4, 5)、(5, 2) 和 (2, 0) 为粗体。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18006.jpg)

图 18-6:一个包含 6 个节点和 11 条边的示例图 (a) 以及一个最优的旅行商路径 (b)

这个任务在现实世界中的许多具体应用,如运输和物流,使其在多个领域至关重要。正因为如此,计算机科学家和数学家们投入了大量时间和精力研究旅行商问题,并开发了许多方法,包括启发式搜索和近似算法。在本节中,我们将基于前几节的方法,构建一个基于深度优先搜索的穷举法。

#### 深度优先搜索

我们可以直接调整哈密顿路径的深度优先搜索算法来考虑路径成本。在此过程中,我们需要做三项更改。首先,因为我们要寻找的是循环而不是路径,所以我们更新算法以返回起始节点。其次,我们不再在找到第一个有效结果时立即停止,而是继续搜索,直到评估所有有效的哈密顿循环,从而找到最低成本的循环。第三,我们跟踪迄今为止看到的最佳路径及其成本。

我们定义了该算法,在路径上执行深度优先搜索,每当从节点回溯时,重置每个节点的seen标签。与哈密顿路径搜索类似,这允许我们尝试从每个节点出发的替代路径。每当我们通过完成哈密顿循环到达递归的终点时,我们会返回路径的副本及其成本。调用函数比较每次递归调用中的路径和成本,并返回最佳路径。

与哈密顿路径算法不同,我们可以从任意一个节点开始此搜索,而不必从每个起始节点开始,因为旅行商问题的结果是一个循环。无论从哪个节点作为起始和结束点,整个循环的成本都是相同的。

#### 代码

我们基于 Listing 18-1 中哈密顿路径搜索的代码实现了旅行商问题的代码。再次,我们从递归函数开始,如 Listing 18-2 所示。

def tsp_dfs_rec(g: Graph, path: list, seen: list, cost: float) -> tuple:
current: int = path[-1]

❶ if len(path) == g.num_nodes:
last_edge: Union[Edge, None] = g.get_edge(current, path[0])
if last_edge is not None:
return (cost + last_edge.weight, path + [path[0]])
else:
return (math.inf, [])

best_path: list = []
best_score: float = math.inf
for edge in g.nodes[current].get_edge_list():
    n: int = edge.to_node
    if not seen[n]:
      ❷ seen[n] = True
        path.append(n)

      ❸ result: tuple = tsp_dfs_rec(g, path, seen, cost + edge.weight)
      ❹ seen[n] = False
        _ = path.pop()

        if result[0] < best_score:
            best_score = result[0]
            best_path = result[1]

return (best_score, best_path) 

Listing 18-2:用于旅行商问题的递归函数

tsp_dfs_rec()函数接受图(g)、到目前为止的路径(path)、访问过的节点的布尔列表(seen)和到目前为止的成本(cost)。它提取当前节点的索引作为<code>path</code>中的当前终节点。

该函数首先考虑递归的基本情况,其中所有节点都已经被访问❶。它检查是否可以通过返回起始节点(path[0])将路径转换为一个循环。如果可以,代码返回一个包含循环成本和完整循环的新副本的元组(path + [path[0]])。如果没有边返回到起始节点,代码返回无限大的成本,表示这不是一个有效的循环。

如果算法有更多节点需要探索,代码使用for循环遍历当前节点的出边,并递归地测试未访问的邻居❸。与清单 18-1 中用于哈密顿路径的代码不同,代码会增加❷并重置❹调用函数中的seen列表和path列表。这简化了之前的基本情况逻辑。在递归探索邻居之后,代码检查是否找到了更好的结果,如果有,便保存它。最后,它返回通过该分支找到的最佳成本和循环。

我们定义了一个包装器,设置数据结构并启动搜索:

def tsp_dfs(g: Graph) -> tuple:
if (g.num_nodes == 1):
return (0.0, [0])

seen: list = [False] * g.num_nodes
path: list = [0]
seen[0] = True

return tsp_dfs_rec(g, path, seen, 0.0) 

代码首先检查图中只有一个节点的边界情况,并返回相应的答案。接着,它为搜索设置初始的seen和path列表,从节点 0 开始路径并标记为已访问。最后,它运行搜索并返回结果。

在清单 18-2 中显示的递归函数是旅行商问题算法的基本实现。我们可以通过额外的剪枝来提高它的效率。例如,如果当前路径的成本超过了迄今为止的最佳成本,我们可以剪枝该路径。类似地,我们可以结合启发式方法,例如按边权递增的顺序探索邻居,以专注于潜在的低成本路径。正如前面所提到的,旅行商问题的优化和启发式方法种类繁多,远超本章的讨论范围。

#### 一个例子

图 18-7 显示了在图 18-6(a)的图上运行此搜索的结果。每个子图展示了算法找到哈密顿循环的基本情况,路径以粗体突出显示,成本列在其下方。

![十四个子图展示了图中的路径及其成本。第一个子图中,边(0, 1)、(1, 2)、(2, 5)、(5, 4)、(4, 3)和(3, 0)是加粗的,成本为 24。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18007.jpg)

图 18-7:深度优先搜索中探索的 14 个哈密尔顿回路

每条路径在图 18-7 中出现多次,因为算法会在两个方向上找到回路。例如,第一个子图对应路径[0, 1, 2, 5, 4, 3, 0],而倒数第二个子图对应路径[0, 3, 4, 5, 2, 1, 0]。

### 欧拉路径和回路

*欧拉路径*以数学家莱昂哈德·欧拉命名,是一种通过图的路径,恰好经过每一条边一次。我们可以将问题看作是一个高效的橱窗购物游客。为了调查城市中的所有商店,游客会寻找一条路径,确保每条路只走一次。他们不愿意错过通过某条路时可能找到的潜在商店,也不愿浪费时间再走已经看过的街道。*欧拉回路*是一个从同一个节点出发并返回的欧拉路径,它提供了一个理想的规划工具,如果游客希望从酒店出发并返回,但又想确保每条路都走一次。

> 注意

*请记住,正如第三章中所述,我们使用的是计算机科学文本中常见的路径定义,这允许节点重复。这与图论中对路径的正式定义不同,后者不允许重复节点。在图论中,这个问题可能被称为寻找一个*欧拉轨迹*。

图 18-8 展示了一个包含六个节点的图上的欧拉回路。路径从节点 0 开始,包含[0, 1, 2, 5, 1, 3, 4, 5, 3, 0]。尽管路径回访了节点,但它只经过了每条边一次。游客可能会多次通过相同的交叉口,但每条街道的店铺橱窗仅会经过一次。

![包含六个节点的图。箭头标记了从节点 0 到节点 1、2、5、1、3、4、5、3、0 的路径。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18008.jpg)

图 18-8:欧拉回路

并非所有的图都包含欧拉路径。图 18-9 展示了一个无向图,其中不可能存在欧拉路径。从节点 1 移动到任何其他节点后,搜索需要使用相同的边返回到节点 1。由于节点 0、2 和 3 只与节点 1 相连,因此任何经过所有边的路径都需要返回到节点 1。

![包含四个节点和边(0, 1)、(1, 2)和(1, 3)的图](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18009.jpg)

图 18-9:没有欧拉路径的图

莱昂哈德·欧拉设计了一种简单而有效的方法来测试一个连通的无向图是否包含欧拉回路:

一个连通的无向图当且仅当所有节点的度数都是偶数时,才包含欧拉回路。

使用这个测试,我们可以定义一个辅助函数,用于测试图是否既完全连通又具有欧拉回路,如示例 18-3 所示。

def has_eulerian_cycle(g: Graph) -> bool:
❶ components: list = dfs_connected_components(g)
for i in range(g.num_nodes):
❷ if components[i] != 0:
return False

  ❸ degree: int = g.nodes[i].num_edges()
    if i in g.nodes[i].edges:
        degree += 1
    if degree % 2 == 1:
        return False
return True 

示例 18-3:检查图是否完全连通并具有欧拉回路

该代码首先使用来自第四章的 dfs_connected_components() 函数为每个节点标记其组件 ❶。然后,它使用 for 循环检查每个节点,确认它属于同一组件 ❷ 且具有偶数度数。

为了完整性,has_eulerian_cycle() 中的度数计算处理了无向自环的情况 ❸。正如第二章中所述,形成自环的边在无向图中被计算两次度数,因为它们分别与每个端点的节点接触。

如果代码发现一个不连通的组件或一个度数为奇数的节点,它将立即返回 False。如果它检查所有节点都没有问题,则返回 True。

#### 验证欧拉路径

为了确定路径是否是有效的欧拉回路,我们需要检查每条边是否只被使用一次。我们定义一个检查函数,将路径作为节点列表传入:

def is_eulerian_cycle(g: Graph, path: list) -> bool:
num_nodes: int = len(path)
❶ if num_nodes == 0:
return g.num_nodes == 0

❷ used: dict = {}
for node in g.nodes:
for edge in node.get_edge_list():
used[(edge.from_node, edge.to_node)] = False

prev_node: int = path[0]
for step in range(1, num_nodes):
    next_node: int = path[step]
  ❸ if not g.is_edge(prev_node, next_node):
        return False
  ❹ if used[(prev_node, next_node)]:
        return False ❺ used[(prev_node, next_node)] = True
    if g.undirected:
        used[(next_node, prev_node)] = True

    prev_node = next_node

❻ for value in used.values():
if not value:
return False
❼ return path[0] == path[-1]


is_eulerian_cycle() 代码首先检查空路径的边界情况,只有当图没有节点时,空路径才被认为是有效的 ❶。如果路径确实有边,代码构建一个字典 used,该字典将图中的每条边映射到一个布尔值,用于指示该边是否已被访问 ❷。

代码的主体部分是一个 for 循环,它遍历路径,结合前一个节点 (prev_node) 和当前节点 (next_node) 来识别当前的边。如果路径使用了不存在的边 ❸ 或已被遍历的边 ❹,该函数将立即返回 False。否则,代码将标记这些边为已访问 ❺,并确保在无向图的情况下,双向边都被标记。

代码完成后会检查是否访问了每条边,并在发现未访问的边时返回False ❻。(或者,我们可以结构化代码来计算边的总数和已访问的边数,正确处理无向情况,并仅进行计数比较。)该函数使用最后检查确保起始节点和结束节点相同,意味着路径是一个回路 ❼。

#### 使用海尔霍尔策算法查找欧拉回路

与本章前两个问题不同,寻找欧拉回路的问题不是 NP 难题,并且存在一种高效的方法来在图中找到欧拉回路。数学家卡尔·海尔霍尔策(Carl Hierholzer)开发了一种用于提取具有欧拉回路的图中欧拉回路的算法。*海尔霍尔策算法*通过反复寻找未使用的边上的循环,并从图中移除这些循环来运行。由于该算法要求图中必须存在欧拉回路,因此我们使用欧拉度数测试(以及清单 18-3 中的代码)来对图进行预检查。

这种方法背后的主要思路是,如果一个图具有欧拉回路,我们可以通过一系列潜在的较小循环构建这个完整的回路。我们将这些较小的循环称为*子循环*,以便与完整的欧拉回路区分开来。算法首先通过查找图中的任何一个循环并移除其边来开始,这可能会在图中留下部分边。由于该图具有一个使用所有边的完整欧拉回路,算法可以通过将这些剩余的边插入到完整路径中,插入额外的子循环,每个子循环都从当前路径中的相同节点开始和结束。

图 18-10 展示了该算法的一个示例。在图 18-10(b)中,搜索找到一个初始循环[0, 1, 2, 5, 3, 0],该循环使用五条边并访问了五个阴影节点。然后它移除这些边,如图 18-10(c)所示。

![四个子图展示了 Hierholzer 算法的步骤。在(a)中,显示了一个包含六个节点和九条无向边的初始图,边为(0, 1)、(0, 3)、(1, 2)、(1, 3)、(1, 5)、(2, 5)、(3, 4)、(3, 5)和(4, 5)。(B)显示了一个包含箭头的循环[0, 1, 2, 5, 3, 0],箭头指示遍历过的边。(C)展示了去除遍历边后的图。(D)展示了突出显示的循环[1, 5, 4, 3, 1]。](../images/f18010.jpg)

图 18-10:Hierholzer 算法在步骤前(a)、第一步期间(b)、第一步后(c)和第二步期间(d)的图

接下来,算法寻找一个从之前访问过的节点出发并结束于该节点的循环,但该循环通过未使用的边缘。图 18-10(d) 显示了循环 [1, 5, 4, 3, 1]。我们可以通过将其插入到节点 1 的出现位置,将这个新环路拼接到完整路径中,最终路径为 [0, 1, 5, 4, 3, 1, 2, 5, 3, 0]。

根据算法如何选择下一个访问的节点,不同的实现最终可能会探索不同的子循环,并为相同的图生成不同的欧拉回路。例如,本节中的代码将按照图 18-10(a)中的节点顺序进行探索,从而生成图 18-8 中的最终欧拉回路:[0, 1, 2, 5, 1, 3, 4, 5, 3, 0]。

要从图中提取欧拉回路,我们必须通过图中的子循环进行遍历:

def hierholzers(g: Graph) -> Union[list, None]:
❶ if not has_eulerian_cycle(g):
return None

g_r: Graph = g.make_copy()
options: set = set([0])
full_cycle: list = [0]

while len(options) > 0:
  ❷ start: int = options.pop()
    current: int = start
    subcycle: list = [start]

  ❸ while current != start or len(subcycle) == 1:
      ❹ neighbor: int = list(g_r.nodes[current].edges.keys())[0] subcycle.append(neighbor)
        g_r.remove_edge(current, neighbor)

      ❺ new_num_edges: int = g_r.nodes[current].num_edges()
        if new_num_edges > 0:
            options.add(current)
        elif new_num_edges == 0 and current in options:
            options.remove(current)

        current = neighbor

  ❻ if g_r.nodes[start].num_edges() == 0 and start in options:
        options.remove(start)

    loc: int = full_cycle.index(start)
  ❼ full_cycle = full_cycle[0:loc] + subcycle + full_cycle[loc+1:]

return full_cycle 

代码首先通过使用 Listing 18-3 ❶ 中的 has_eulerian_cycle() 函数来确认图是否存在欧拉回路。如果检查失败,代码将返回 None,表示没有欧拉回路。代码依赖于从 typing 库导入 Union 来支持多个返回类型的类型提示。如果检查通过,代码会设置初始数据结构,包括一个可以修改的图的完整副本(g_r),一个已访问节点的集合,用于作为子循环的起始点(options),以及一个跟踪迄今为止构建的欧拉回路的列表(full_cycle)。代码将通过遍历子循环并将其插入到 full_cycle 中,逐步构建 full_cycle。

算法的主体是一个 while 循环,只要存在带有未使用边缘的已访问节点(options 不为空),它就会继续寻找新的循环。options 集合提供了一个节点列表,代码可以从这些节点开始一个新的子循环。代码从 options ❷ 中弹出一个任意节点,并开始遍历一个循环。

代码通过使用内部while循环遍历新的循环,直到它完成一个回路并返回到循环的起始节点 ❸。循环条件还会检查新的循环是否至少走了一步后才会终止。如果len(subcycle) == 1,循环会继续,因为路径还没有走到任何地方。在每一步的循环遍历过程中,代码选择当前节点edges字典中的第一个键作为下一个要访问的目标(neighbor) ❹。它将neighbor添加到当前跟踪的循环中,并从图的副本中删除该边。

代码通过考虑当前节点剩余的边数来更新options ❺。如果至少还有一条剩余的边,它将该节点添加到options中,以表示还有其他路径可走。相比之下,如果代码刚刚删除了当前节点的最后一条相邻边,代码将该节点从options中移除。在内部while循环完成后,代码同样会丢弃没有剩余边的起始节点 ❻。

在完成内部循环后,代码还将subcycle插入到full_cycle ❼中。为了简化处理,我们使用线性时间查找(index()函数)并构建一个新的full_cycle副本。如果进行额外的记录工作,我们可以使用更高效的方法来最小化这一步的成本。

图 18-11 展示了 Hierholzer 算法在一个包含八个节点的图上的操作。图 18-11(a)展示了图的状态、options集合以及算法开始前的full_cycle列表。其余的子图展示了每次外部while循环迭代后的算法状态。在该迭代中遍历并移除的边以粗体显示。

我们可以通过将该算法可视化为一位城市旅游局官员规划综合旅游的过程来理解。其目标是设计一条遍历每条街道一次的路径,让游客全方位体验这座城市,而不产生不必要的重复。他们选择城市的豪华酒店作为起点(节点 0),并开始出发。旅行过程中,他们记录下每条已走过的街道,并访问未走过的道路交叉口。

图 18-11(b) 展示了旅游规划者第一天的结果。通过走未探索的道路,他们完成了一个小循环 [0, 1, 2, 0],回到了酒店。此时,他们在当前节点没有未走过的道路。尽管还有许多街道没有被探索,他们仍然毫不气馁,标记了道路(0, 1)、(1, 2)和(2, 0)为已走过。他们还注意到,在交叉口(即节点)1 和 2,他们本可以选择不同的道路。

![五个子图展示了 Hierholzer 算法在不同阶段的状态。在(A)中,选项集包含元素 0。在(B)中,边(0, 1)、(1, 2)和(2, 0)被加粗显示。选项集包含元素 1 和 2。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f18011.jpg)

图 18-11:Hierholzer 算法在示例图上的步骤

第二天,规划者前往一个未走过边的节点,从那里继续探索。如图 18-11(c)所示,他们选择从节点 1 出发,因为它在之前的循环中是可达的,并且有未探索的选项。他们完成了另一个小循环 [1, 3, 4, 1],然后返回到节点 1,发现他们已经遍历了所有相邻的街道。他们更新了地图,删除了道路(1, 3)、(3, 4)和(4, 1),并注意到有未探索的街道从节点 2、3 和 4 的交叉口分叉出来。他们将今天的路径拼接到昨天的路径中,形成了合并路径 [0, 1, 3, 4, 1, 2, 0]。

第三天的情况类似,规划者从节点 2 出发,如图 18-11(d)所示。他们完成了循环 [2, 4, 7, 2],删除了已走的街道,并将已合并的路径扩展为 [0, 1, 3, 4, 1, 2, 4, 7, 2, 0]。在这一天的旅行过程中,他们注意到已经走过了所有与节点 2 和 4 相邻的道路。他们从起始选项中移除了这两个节点,只剩下节点 3。

最后一天从节点 3 开始,如图 18-12(e) 所示。规划者走了 [3, 5, 6, 3] 并将其拼接到已合并路径中,形成欧拉回路 [0, 1, 3, 5, 6, 3, 4, 1, 2, 4, 7, 2, 0]。

### 为什么这很重要

本章讨论的三个问题——寻找哈密顿路径和欧拉路径以及解决旅行推销员问题——在各种现实世界的规划和优化应用中具有明确的应用场景。与前两章中从给定起点到给定终点寻找路径的问题不同,本章所涉及的问题旨在寻找访问图中每个节点或边的路径。

这些问题为构建更复杂的任务提供了基础。我们可以通过添加成对排序约束来扩展欧拉路径问题。例如,游客可能需要先参观城市的欢迎中心并购买票据,然后才能乘坐缆车。一家公司可能会将其城市分配给五名销售人员,要求他们为每位员工分配城市和路径。本章中的这三个问题仅仅是我们可以提出的有趣且复杂问题的冰山一角。

本章中的问题还表明,解决看似相似的问题的难度实际上可能差异很大。尽管寻找欧拉路径和哈密顿路径的任务有类似的现实世界类比,但它们的最坏情况计算成本差异显著。在解决新问题时,认识到并理解这些差异非常重要,这有助于我们决定采用何种方法。




# 第十九章:结论



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

在本书中,我们探讨了如何用图数据结构建模一系列现实世界的问题和系统。我们还研究了对应的算法来解决这些问题。我们基于基本的搜索方法,如深度优先搜索和广度优先搜索,构建了更复杂的算法来执行诸如拓扑排序和标记二分图等任务。我们还介绍了各种更专业的算法,如卡恩的拓扑排序算法或图着色的移除算法。

然而,本书只是略微触及了图的迷人世界。关于图的理论属性(*图论*这一数学领域)和实际图算法,已有大量研究。例如,最近的计算机科学研究持续发展了处理大规模图的新方法。全面覆盖图算法超出了单本书的范围。

本书旨在介绍图的基本概念,并解释各种操作方法。它提供了一个基础和基本工具箱。你应该能够将这些算法背后的思想应用到本书以外的技术中,并轻松深入其他与图相关的主题。

正如本书中所讨论的,我们通常可以优化算法和实现,以满足特定用例的需求。作为学习图的下一步,我鼓励你尝试修改到目前为止学到的方法,利用特定问题的性质或避免不必要的开销。例如,在图中存储显式的Node数据结构,在某些情况下可能不如使用邻居列表更有效。在其他情况下,向节点的表示中添加辅助数据可能有助于避免重复计算。

也许计算机科学中最令人兴奋的方面就是能够不断探索并构建新的解决方案。




# 第二十章:A 构建图



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

本书已经涵盖了多种操作图的算法。为了将这些算法应用于现实问题,我们需要一个机制,能够以编程方式创建新图或从文件中加载它们。

虽然第一章中的图形创建函数,如 insert_node() 和 insert_edge() 提供了定义图的完整机制,但为每个图形手动编写代码是不现实的。除了列出数百或数千个 insert_edge() 语句的繁琐外,这种方法还容易出错。

在本附录中,我们介绍了几种简单的机制,用于创建图并从文件中加载它们。与前几章一样,我们优化的是易理解性,而非存储大小或计算效率。这意味着我们使用的某些输入格式,如逗号分隔值,将是简单的。然而,这些格式非常适合展示我们需要考虑的信息以及如何以编程方式操作这些信息来构建图。读者可以基于这些方法构建适合自己应用的优化格式。

本附录使用 Python 的 csv 库来简化文件的加载和解析。我们鼓励读者探索更多专门的库,它们可能进一步简化代码。

## 从边构建图

我们从第一章开始使用的 Graph 数据结构依赖于一个构造函数,它创建一个没有边的初始图。用户可以使用 insert_edge() 函数向图中添加边。在本节中,我们将自动化这一流程,基于边的列表构建图。

在从给定的边列表创建一个新图后,我们将扩展该功能以从边文件中读取并写入数据。在此过程中,我们解决了节点是通过通用文本标签而不是节点索引来指定的问题。

### 从列表插入边

我们可以通过一个 Edge 数据结构的列表来创建一个图:

def make_graph_from_edges(num_nodes: int,
undirected: bool,
edge_list: list) -> Graph:
g: Graph = Graph(num_nodes, undirected)
for edge in edge_list:
g.insert_edge(edge.from_node, edge.to_node, edge.weight)
return g


除了边列表,代码还输入节点数量(num_nodes)以及图是否为无向图(undirected)。它创建一个初始的图结构,包含正确数量的节点和无向设置,然后循环遍历每个边并将其插入到图中。如果边是无向的,insert_edge()函数在<sam class="SANS_TheSansMonoCd_W5Regular_11">Graph类中会在两个方向插入有向边。

在许多情况下,我们可以通过跟踪看到的最大节点索引来直接计算节点数量。然而,这种方法不允许存在完全断开的节点,因为它们的索引永远不会出现在边列表中,结果图可能会缺少后面的节点。因此,代码将<sam class="SANS_TheSansMonoCd_W5Regular_11">num_nodes作为输入参数。

图 A-1 展示了一个图的构造示例,其中插入了以下有向边:

[Edge(0,1,1.0), Edge(1,3,10.0), Edge(2,4,5.0), Edge(3,1,2.0), Edge(1,2,3.0)]

图 A-1(a)展示了第一个节点插入后的图。每个子图展示了代码的for循环迭代后的图状态。

![六个子图展示了一个五节点图,边的数量逐渐增加。在(A)中,没有边。在(B)中,边(0, 1)已被添加。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0a001.jpg)

图 A-1:向一个五节点图中添加边的步骤

我们可以将make_graph_from_edges()函数与返回边列表的算法结合使用,例如第十章中的randomized_kruskals()算法。

### 从文件加载边列表

构建图的一种自然方法是直接从文件加载图。虽然我们可以使用许多不同的格式来存储图的表示,但我们将从最简单的方法之一开始:将图存储为逗号分隔值(CSV)文件。每一行表示一个边,列出起始节点作为字符串标签,目标节点作为字符串标签,以及一个可选的浮动权重。未连接的节点单独列出。

使用这种 CSV 格式,我们可以使用以下数据对图 A-2 中显示的无向图进行编码:

a,b

b,c,10.0

d,e,5.0

这些数据定义了一个包含五个节点 a、b、c、d 和 e,以及三条边的图。两条边具有显式权重,第三条使用默认值 1.0。

![一个包含五个节点和无向边(a, b)、(b, c)和(d, e)的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0a002.jpg)

图 A-2:一个包含五个节点和三条加权边的图

我们可以扩展此格式以考虑完全断开的节点,这些节点不会出现在任何边中,通过允许只有一个条目的行来实现。这一单一条目仅表示节点存在,从而程序将其包含在图中。

我们可以使用这种通用格式来编码大量的现实世界现象。例如,我们可以使用一个文件来表示教室里的纸条传递行为。每个节点代表一个学生。每一行提供有关课堂(图)的新信息。一行可以包含一个单独的名字,表示学生(节点)的存在。或者,一行可以表示学生之间的有向纸条传递行为,并包含<sup class="SANS_TheSansMonoCd_W5Regular_11">person1</sup>*,* <sup class="SANS_TheSansMonoCd_W5Regular_11">person2</sup>*,* <sup class="SANS_TheSansMonoCd_W5Regular_11">weight</sup>,表示学生 1 向学生 2 传递纸条的次数(由 weight 表示)。

读取此类 CSV 文件的代码利用 Python 的<sup class="SANS_TheSansMonoCd_W5Regular_11">open()</sup>函数来读取文件,并使用 csv 包(需要导入<sup class="SANS_TheSansMonoCd_W5Regular_11">csv</sup>)来解析每一行,如列表 A-1 所示。

def make_graph_from_weighted_csv(filename: str, undirected: bool) -> Graph:
g: Graph = Graph(0, undirected)
node_indices: dict = {}

❶ with open(filename) as f:
graph_reader = csv.reader(f, delimiter=',')
❷ for row in graph_reader:
name1: str = row[0]
if name1 not in node_indices:
new_node: Node = g.insert_node(label=name1)
node_indices[name1] = new_node.index
index1: int = node_indices[name1]

      ❸ if len(row) > 1:
            name2: str = row[1]
            if name2 not in node_indices:
                new_node = g.insert_node(label=name2)
                node_indices[name2] = new_node.index
            index2: int = node_indices[name2] ❹ if len(row) > 2:
                weight: float = float(row[2])
            else:
                weight = 1.0

            g.insert_edge(index1, index2, weight)
return g 

列表 A-1:从 CSV 文件加载图

代码首先创建必要的数据结构——一个空的图(g)和一个空的字典(node_indices),该字典将字符串名称映射到相应的节点索引。

然后,代码打开文件并使用<sup class="SANS_TheSansMonoCd_W5Regular_11">csv.reader</sup> ❶进行解析。它遍历文件中的每一行,最多读取三个条目 ❷。对于每一行,它从第一个条目中提取节点的名称。由于代码读取的是名称,它需要将这些名称映射到相应的索引。如果节点已经在图中,代码可以从<sup class="SANS_TheSansMonoCd_W5Regular_11">node_indices</sup>字典中查找它。否则,它发现了一个尚未在图中的节点,并必须将该节点插入到图中,并将名称插入字典。如果该行仅包含一个条目,函数会跳过剩余的逻辑并继续到下一行。

然而,如果这一行有第二个条目,那么该行指定了一个边 ❸。代码提取第二个节点的名称。然后,它检查该节点是否在图中,如果不在,就插入该节点。代码会检查行中是否有第三个条目,表示权重 ❹。如果没有提供权重,代码会使用默认值 1.0。最后,代码使用这两个节点的索引和权重的组合,将一条新边插入到图中。

代码会逐行读取 CSV 文件,直到读取完整个文件。此时,它会返回最终的图。文件会在代码退出 with 语句时自动关闭。

这段代码中的主要复杂性之一是将节点的文本字符串映射到它们的索引。理论上,我们可以限制加载函数要求文件提供每个节点的整数索引。虽然这样可以去掉映射的需求,但会大大降低可用性。每次我们想加载一个新的图时,必须首先构建从名称到索引的映射并转换文件。在后续章节中,我们将讨论如何将这一常见操作集成到 Graph 数据结构本身。

### 将边列表保存到文件

我们可以使用相同的逗号分隔值格式来保存我们最喜爱的图。由于我们考虑的大多数图未使用命名节点,因此我们将在 CSV 文件中使用每个节点的整数索引作为其名称。然而,我们仍然面临清单 A-1 中的代码将节点索引分配到遇到的位置的问题。理想情况下,我们希望节点 5 在重新加载时仍然是节点 5,无论代码在边列表中遇到它的顺序如何。

解决这个问题有几种潜在的方案。我们可以完全放弃使用名称,只在 CSV 中存储整数节点索引,并修改清单 A-1 以直接将节点名称读取为整数索引。或者,我们可以在 CSV 文件开始时按顺序输出每行一个节点名称,以指示它们的存在。这样可以确保第一个节点映射到索引 0,第二个节点映射到索引 1,以此类推。

对于以下代码,我们采用第二种方法,以保持与清单 A-1 一致。这段代码同样适用于基于整数的读取器:

def save_graph_to_csv(g: Graph, filename: str):
❶ with open(filename, 'w', newline="\n") as f:
graph_writer = csv.writer(f, delimiter=',')
❷ for node in g.nodes:
graph_writer.writerow([node.index])

  ❸ for node in g.nodes:
        for edge in node.get_edge_list():
            graph_writer.writerow([edge.from_node, edge.to_node,
                                   edge.weight]) 

代码使用csv.writer打开文件并写入数据❶。它使用for循环遍历每个节点,每行写入一个节点索引❷。这确保了即使某些节点不属于任何边,也会被包含在 CSV 中。最后,一对for循环遍历图中的每条边,并写入三项值(起始节点、目标节点和权重),表示这条边❸。当代码退出with语句时,文件会自动关闭。

## 按名称插入节点

列表 A-1 中的一大部分代码涉及到将节点名称映射到索引的处理。我们在node_indices字典中查找每个名称字符串,以获取索引。如果名称不在字典中,我们需要插入一个新节点,并创建一个相应的字典条目,将名称映射到新索引。根据你在程序中如何引用节点,这可能是一个常见问题。

为了简化与命名节点的工作,我们可以将这一逻辑整合到Graph类本身中。我们需要做两个修改。首先,在初始化函数中添加一个空字典的创建:

self.node_indices: dict = {}


每当我们需要将名称映射到索引时,我们都会使用这个字典。

第二,我们向Graph类中添加一个函数,该函数执行查找和(如有必要)插入操作,如列表 A-2 所示。

def get_index_by_name(self, name: str) -> int:
if name not in self.node_indices:
new_node: Node = self.insert_node() self.node_indices[name] = new_node.index
return self.node_indices[name]


列表 A-2:向图中添加命名节点

代码首先通过检查节点名称是否在映射中,来判断该节点是否已经存在于图中。如果没有,图将使用原始的insert_node()函数将节点插入图中。它还将名称插入索引映射中。该函数最终返回节点的索引。

我们可以通过懒散教师创建座位表的情景来形象化这个函数。在第一天,老师允许学生选择自己的座位,但不打算记录谁坐在哪儿。座位表最初是空的。随着课程的进行,学生们举手提问。每次发生这种情况,老师都会看一眼座位表。如果学生的名字在上面,老师就会使用它。否则,老师会盯着学生看,完全没有技巧地问:“你是谁?”当学生翻完白眼并回答之后,老师会把学生添加到座位表中。随着学生们的出现,座位表逐渐填满。

我们可以使用这些辅助函数,更简洁地重写列表 A-1 中的 CSV 读取器代码:

def make_graph_from_weighted_csv2(filename: str, undirected: bool) -> Graph:
g: Graph = Graph(0, undirected)

with open(filename) as f:
    graph_reader = csv.reader(f, delimiter=',')
    for row in graph_reader:
      ❶ index1: int = g.get_index_by_name(row[0])

        if len(row) > 1:
          ❷ index2: int = g.get_index_by_name(row[1])

            if len(row) > 2:
                weight: float = float(row[2])
            else:
                weight = 1.0
            g.insert_edge(index1, index2, weight)
return g 

该代码遵循与列表 A-1 相同的流程,但使用get_index_by _name()来进行节点映射和查找每行中的第一个❶和第二个节点❷。

## 共现

*共现图*表示哪些实体对曾经共同出现过。在生物学中,它们可以用来研究相互作用,比如哪些基因经常一起出现,或者哪些微生物之间存在互动。在社会学中,它们可以建模群体聚会或学术出版物的共同作者关系。而在流行文化中,它们可以帮助回答一些关键问题,例如哪些电影明星曾经在同一部电影中共同出演,正如在第二章中讨论的那样。

吸收共现数据的难点在于,它通常由一组集合而不是成对的交互组成。我们最喜爱的在线电影数据库可能不会列出去年夏天热播电影中所有电影明星的每一对搭档。相反,它提供一个标记为*演员表*的单一列表,列出电影中所有出现的演员。

例如,在回顾当地剧团近期的所有演出时,我们可能会发现以下演员表:

(A, B, C)

(D, E)

(A, D)

(C, F, G, H)

(C, E)

使用单一的演员表列表,我们可以很容易地看到A与C曾在同一舞台上演出。然而,如果我们对更复杂的问题感兴趣,比如A和H之间有多少度的分隔,我们就需要构建一个更全面的图像。图 A-3 展示了基于这些演出的共现图。

![一个包含 8 个节点和 12 条边的图。节点 A 与节点 B、C 和 D 之间有边相连。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0a003.jpg)

图 A-3:一个无向图,包含八个节点,表示演员在剧中的共现情况

为了使模型更强大,我们可以使用边权来追踪两个节点的共同出现次数。例如,Alice 和 Bob 在 10 个不同的戏剧中共同出演后,可能会有更强的演出关联,而 Diane 与 Alice 的较弱连接则来源于仅仅一次的同台演出。如果你想了解世界著名的 Alice,最好通过 Bob 使用更强的关联。

我们可以扩展从 CSV 加载图的方法,以从任意列表构建成对图形。我们不再期望每行最多只有三个条目,而是允许将所有共同出现的条目列在同一行。然后,代码会根据每个列表推断出成对的边:

def make_graph_from_multi_csv(filename: str) -> Graph:
g: Graph = Graph(0, undirected=True)
with open(filename) as f:
graph_reader = csv.reader(f, delimiter=',')
for row in graph_reader:
num_items: int = len(row)

        for i in range(num_items):
            index1: int = g.get_index_by_name(row[i])

            for j in range(i + 1, num_items):
                index2: int = g.get_index_by_name(row[j])
                edge: Union[Edge, None] = g.get_edge(index1, index2)
              ❶ if edge is not None:
                    weight = edge.weight + 1.0
                else:
                    weight = 1.0
              ❷ g.insert_edge(index1, index2, weight)
return g 

与之前的 CSV 读取器一样,代码首先创建一个空图,打开文件并使用 csv.reader 进行解析。这次,代码将图限制为无向图,因为这些共现边没有暗示的方向性。代码还不维护一个名称字典,而是使用列表 A-2 中的辅助函数来插入新节点并检索每个节点的索引。

代码使用 for 循环逐行迭代。对于每一行,它使用另一对嵌套的 for 循环来遍历每行中的所有唯一条目对。它通过 get_index_by_name() 函数查找节点的索引,并在需要时插入新节点。在创建边之前,代码会检查该边是否已存在 ❶。我们使用 Python 的 typing 库中的 Union 使得 None 可以由 get_edge() 函数返回。如果边已存在,代码会检索其当前权重并将其增加 1,然后插入具有更新权重的新边 ❷。由于 insert_edge() 在插入重复项时会覆盖现有的边,代码实际上是更新了现有边的权重。

与之前在列表 A-1 中的 CSV 读取器一样,代码会逐行读取文件,直到读取完整个文件为止。此时,它会返回最终的图。

## 空间点

在使用图形来模拟路径规划或其他物理问题时,我们经常需要从一系列空间数据点构建图形。我们可以通过创建一个图,其中每个空间点对应一个节点,且每对点之间有一条边来实现这一点。

图 A-4 展示了这种表示方式的一个例子。图 A-4(a) 展示了五个二维点:(0, 0)、(1, 0)、(1.2, 1)、(1.8, 1) 和 (0.5, 1.5)。图 A-4(b) 展示了图的表示,其中的边权重捕捉了点与点之间的距离。

![(A) 显示了五个点在笛卡尔 X 和 Y 轴上的坐标。(B) 显示了这五个点与 10 条成对边的关系,每条边标注了对应的距离。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0a004.jpg)

图 A-4:一组二维空间点(a)及其对应的图表示(b)

我们首先定义一个辅助类来存储空间点并计算它们之间的距离:

class Point:
def init(self, x: float, y: float):
self.x: float = x
self.y: float = y

def distance(self, b) -> float:
    diff_x: float = (self.x - b.x)
    diff_y: float = (self.y - b.y)
  ❶ dist: float = math.sqrt(diff_x*diff_x + diff_y*diff_y)
    return dist 

虽然不要求使用 Point 类,但它使我们可以轻松地更换为更高维度的点或替代的距离函数。distance() 函数计算二维空间中的欧几里得距离。请注意,我们需要导入 math 才能使用平方根函数。

我们可以通过修改 distance() 函数来替换为替代的距离函数。例如,我们可以通过将 ❶ 处的代码修改为以下内容来使用曼哈顿距离:

dist: float = abs(diff_x) + abs(diff_y)


我们可以通过使用一对嵌套循环来编写构建图的代码,该图是由空间点构成的:

def build_graph_from_points(points: list) -> Graph:
num_pts: int = len(points)
g: Graph = Graph(num_pts, undirected=True)

for i in range(num_pts):
    for j in range(i + 1, num_pts): dist: float = points[i].distance(points[j])
        g.insert_edge(i, j, dist)
return g 

该代码为数据集中的每个点分配一个节点,然后使用一对 for 循环遍历每一对点。对于每一对,代码使用 Point 类中的 distance() 函数计算两点之间的距离,并在图中插入带有相应权重的无向边。所有边添加完后,函数返回已完成的图。

## 前提条件

在第九章中,我们讨论了拓扑排序的问题——根据图的有向边对节点进行排序。图 A-5 展示了这种排序的一个例子,其中每个节点沿水平方向进行排序。

![一个有六个节点和有向边(0, 1)、(0, 2)、(1, 2)、(2, 5)、(1, 4)、(3, 4)和(4, 5)的图。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0a005.jpg)

图 A-5:根据节点之间的相对依赖关系排序的六个节点

拓扑排序使我们能够列出操作手册的步骤、规划具有先决条件的课程,或者计算编译程序各部分的顺序。这些问题都要求我们明确节点之间的依赖关系。

我们定义了一个简单的函数,从一个前置条件的字典构建图。字典中的每个条目将一个节点映射到它的依赖列表。例如,图 A-5 中的图将表示为:

{0: [], 1: [0], 2: [0, 1], 3: [], 4: [1, 3], 5: [2, 4]}

没有依赖的节点,例如节点 0 和 3,用一个空列表表示。

从依赖关系构建图的代码由遍历每个节点的循环组成,同时还遍历每个节点的依赖项:

def make_graph_from_dependencies(dependencies: dict) -> Graph:
g: Graph = Graph(0, undirected=False)
for node in dependencies:
n_index: int = g.get_index_by_name(node) for prior in dependencies[node]:
p_index: int = g.get_index_by_name(prior)
g.insert_edge(p_index, n_index, 1.0)
return g


代码首先分配一个空的有向图。它通过遍历 `dependencies` 列表中的每个键,使用 `for` 循环来填充图。对于每个条目,即使是那些没有前置依赖的条目,代码也会使用 `get_index_by_name()` 来查找并可能插入一个新节点。此代码足以填充图的节点。

为了填充边,代码使用第二个 `for` 循环遍历每个节点的依赖列表。通过 `get_index_by_name()` 获取依赖项的索引(并可能插入一个新节点)。然后,代码插入从依赖节点 *到* 当前节点的边。由于我们只关心排序,代码对所有边使用默认的 `1.0` 权重。代码最后返回构建的图。




# 第二十一章:B 可修改优先队列



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

本书中的几个算法,如 Dijkstra 算法和 A* 搜索,使用了增强型优先队列,允许程序修改现有元素的优先级。为完整起见,本附录描述并提供了该数据结构的代码。

虽然许多标准堆实现支持添加和移除元素,但它们通常不支持高效地更改元素的优先级。我们将简要概述堆,然后定义一个对标准优先队列的小扩展,该扩展使用字典来映射每个元素在堆中的位置。此映射允许我们高效地查找给定的元素并更改其优先级。最后,我们将展示用于实现可修改优先队列的代码。

## 堆

我们优先队列数据结构的核心是堆数据结构。本节提供了一个简短的堆介绍,内容来自我之前的书《*数据结构的有趣方式*》。我们将讨论足够的堆内容来解释代码,但不会深入探讨;你可以在上述书籍中学习更多关于堆及其性质的详细信息。

*堆* 是一种二叉树的变种,它在节点与其子节点之间维持一种特殊的有序关系。*最大堆* 按照最大堆属性对元素进行排序,该属性规定树中任意节点的值都大于或等于其子节点的值。*最小堆* 按照最小堆属性对元素进行排序,该属性规定树中任意节点的值都小于或等于其子节点的值。对于优先队列,我们使用最大堆根据优先级对元素进行排序。

### 堆元素

我们使用两个变量来定义优先队列中的每个元素。元素的 *值* 是我们存储的关于该对象的信息。这可以是整数(节点的索引)、字符串(节点的名称)或甚至是对象。元素的 *优先级* 是一个浮动小数,用于确定优先队列中下一个被提取的元素。

我们使用一个包装数据结构 HeapItem 来存储项的值和优先级的组合:

class HeapItem:
def init(self, value, priority: float):
self.value = value
self.priority = priority

❶ def lt(self, other):
return self.priority < other.priority

❷ def gt(self, other):
return self.priority > other.priority


该数据结构包含一个构造函数来初始化对象,并重载了小于 ❶ 和大于 ❷ 比较运算符以比较优先级。这个数据结构并非严格必要,增加了一些开销;我们可以选择使用元组。但是,在本章中,我们将依赖于 HeapItem 来使代码更具可读性。

### 基于数组的存储

虽然堆是通过树来定义的,但我们使用基于数组的标准实现方式,这种方式特别高效。数组中的每个元素对应树中的一个节点,根节点位于索引 1(我们跳过索引 0,这是堆的常规做法)。子节点的索引是相对于其父节点的索引定义的;例如,索引为*i*的节点,其子节点的索引分别为 2*i*和 2*i* + 1。我们同样可以计算出索引*i*节点的父节点的索引,公式为Floor(*i*/2)。这种索引方案,如图 B-1 所示,使得算法可以轻松地基于父节点的索引计算子节点的索引,反之亦然。

![一个堆以数组和树的形式表示,箭头指示每个节点在数组中的位置。根节点 95 对应数组中的第一个元素,索引为 1。节点的两个子节点 71 和 63 分别是数组中的第二和第三个元素。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0b001.jpg)

图 B-1:堆以树形(左)和数组(右)表示

尽管数组中的项目并未按照排序顺序排列,但第一个项目总是优先级最高的。在最大堆中,数组中的第一个项目具有最大优先级;而在最小堆中,第一个项目具有最小优先级。这意味着我们可以通过简单的查找来访问堆中的“下一个”项目。

### 元素交换

插入和移除项目都会破坏堆的性质,并通过交换元素对来恢复它。每当我们发现一个项目位置不正确时,可以通过将其与父节点向上交换或与子节点向下交换来修正排序。我们重复这个过程,直到该项处于堆中的正确位置。

在最大堆的情况下,如果一个元素的优先级比它的父节点大,我们就将该元素向上交换。例如,在图 B-2 中,元素 56 的位置不正确。当我们将其与父节点图 B-2(a)进行比较时,我们看到 56 大于 41,这违反了最大堆的性质。我们可以通过交换这两个元素来修正它,将 56 放置到相对于父节点的正确位置,正如图 B-2(b)所示。

![在(A)中,节点 56 的位置不正确,父节点为 41。虚线圆圈表示我们正在比较这两个节点。在(B)中,节点已被交换,虚线圆圈表示我们正在将节点 56 与其新的父节点 71 进行比较。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0b002.jpg)

图 B-2:一个不合适的堆元素(a)和结果向上比较与交换(b)

类似地,如果一个元素的优先级小于其子节点中的任何一个,我们就将其在最大堆中向下交换。在这种情况下,我们还必须选择哪个子节点进行交换,选择两个子节点中较大的一个,以保持最大堆的性质。例如,在图 B-3 中,元素 29 的位置不对。当我们将其优先级与其子节点在图 B-3(a)中的优先级进行比较时,我们发现 29 小于 71,并且 29 小于 41,这违反了最大堆的性质。我们通过将元素 29 与其两个子节点中较大的那个交换来修复这一点,如图 B-3(b)所示。

![在(A)中,节点 29 相对于其子节点 71 和 41 的位置不对。在(B)中,节点 29 已经与 71 交换。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0b003.jpg)

图 B-3:一个位置不对的堆元素(a)以及由此产生的向下比较和交换(b)

在修复最小堆时,我们反转比较的方向。如果一个元素的优先级小于其父节点的优先级,我们就将其向上交换;如果其优先级大于两个子节点中的任何一个,则将其向下交换。在向下交换时,我们选择优先级较小的子节点,以保持最小堆的性质。

## 可修改优先队列

可修改的优先队列由一个封装标准基于堆的优先队列的类和一个将项目映射到堆数组位置的字典组成。该结构如图 B-4 所示,字典(按项目的值索引)位于左侧,基于数组的最大堆位于右侧。如图所示,字典中的每个条目都指向堆数组中该项目的索引。

![左侧的表格表示一个字典,将键(项目的值)映射到索引。右侧的列表表示堆,其中每个条目包含一个值和优先级对。左侧的“节点 1”映射到堆中的第 4 行。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0b004.jpg)

图 B-4:优先队列类中的两个数据结构

给定这些属性,我们定义了一个简单的接口,使我们能够将(value, priority)对插入到优先队列中,从优先队列的前端移除值,并更改现有值的优先级。我们还包括了几个方便的函数,用于获取大小或检查某个值是否在优先队列中。

本附录提供了构成优先队列接口的函数代码:

dequeue() 从优先队列中移除顶部项并返回其值。

enqueue(value, priority) 将一个新的(value, priority)对插入到优先队列中。

get_priority(value) 返回该值的浮动优先级。

in_queue(value)返回一个布尔值,指示给定值是否在优先队列中

is_empty()返回一个布尔值,指示优先队列是否为空

peek_top()返回优先队列中的顶部项目

peek_top_priority()返回队列中顶部项目的优先级

peek_top_value()返回队列中顶部项目的值

size()返回优先队列中的项目数

update_priority(value, priority)更新具有给定值的项目的优先级

## 数据结构

PriorityQueue类为堆和索引字典提供了一个封装。它包含以下属性:

array_size **(**int**) **存储堆数组的总长度

heap_array **(**list**) **存储HeapItems,作为优先队列的堆排序内部存储

last_index **(**int**) **存储堆中最后一个元素的索引

is_min_heap **(**bool**) **指示一个PriorityQueue对象是最小堆(True)还是最大堆(False)

indices **(**dict**) **存储一个映射,将HeapItem的值映射到其在heap_array中的索引,从而实现通过值高效查找项目

我们定义了一个构造函数,它将属性初始化为空优先队列的值,并提供一些基本功能:

class PriorityQueue:
def init(self, size: int = 100, min_heap: bool = False):
self.array_size: int = size
❶ self.heap_array: list = [None] * size
self.last_index: int = 0
self.is_min_heap: bool = min_heap
self.indices: dict = {}

def size(self) -> int: 
    return self.last_index

def is_empty(self) -> bool: 
    return self.last_index == 0

def in_queue(self, value) -> bool: 
    return value in self.indices

def get_priority(self, value) -> Union[float, None]: 
    if not value in self.indices:
        return None
    ind: int = self.indices[value]
    return self.heap_array[ind].priority 

PriorityQueue构造函数根据估算的大小预分配heap_array ❶。正如我们稍后所见,如果需要的元素超过array_size,堆会使用数组扩展来增加大小。

size() 和 is_empty() 函数都使用 last_index 的值来确定优先队列中的项数。请注意,因为在这种情况下我们使用的是 1 索引,所以 PriorityQueue 中的元素数量始终等于 last_index,当 last_index == 0 时,堆是空的。

接下来的两个函数使用优先队列的 indices 字典,该字典将每个项的值映射到其在队列中的索引,以便高效查找项。in_queue() 函数通过检查值是否存在于 indices 中来检查值是否在队列中。get_priority() 函数首先检查项是否在优先队列中,如果不在,则返回 None。否则,它使用 indices 查找正确的 HeapItem 并返回其优先级。与其他可以返回多种类型的函数一样,我们使用了 Python 的 typing 库中的 Union。

### 定义辅助函数

我们还定义了几个内部辅助函数以支持堆操作。由于我们允许堆可配置为最小堆或最大堆,这些函数封装了这两种设置所需的不同逻辑。

#### 检查反转

第一个辅助函数检查节点及其父节点是否在堆的错误顺序中:

def _elements_inverted(self, parent: int, child: int) -> bool:
❶ if parent < 1 or parent > self.last_index:
return False
if child < 1 or child > self.last_index:
return False

❷ if self.is_min_heap:
return self.heap_array[parent] > self.heap_array[child]
else:
return self.heap_array[parent] < self.heap_array[child]


_elements_inverted() 函数的代码首先对节点父节点和子节点的索引进行边界检查 ❶。如果任一索引无效,代码将返回 False。由于我们将在本节稍后使用此函数来确定是否需要在堆中交换节点,这样可以防止交换超出数组的边界。该检查考虑到了堆使用从索引 1 开始的数组,因此不允许索引 0。

然后,代码根据是处理最小堆还是最大堆来分支 ❷。在最小堆的情况下,代码通过检查父节点的优先级是否大于子节点的优先级来判断元素是否被反转。在最大堆的情况下,代码通过检查父节点的优先级是否小于子节点的优先级来检查是否存在反转。由于我们在HeapItem中重载了这两种比较,代码始终检查项目的相对优先级。

#### 交换元素

第二个辅助函数交换堆数组中的两个元素。这个操作需要一些额外的逻辑,因为我们不仅需要交换对象,还需要更新它们在indices字典中的相应条目:

def _swap_elements(self, index1: int, index2: int):
❶ if index1 < 1 or index1 > self.last_index:
return
if index2 < 1 or index2 > self.last_index:
return

item1: HeapItem = self.heap_array[index1]
item2: HeapItem = self.heap_array[index2]
self.heap_array[index1] = item2
self.heap_array[index2] = item1

❷ self.indices[item1.value] = index2
self.indices[item2.value] = index1


同样,_swap_elements()的代码首先进行边界检查 ❶,如果任一索引越界则提前返回。然后,代码提取两个堆项目,交换它们在数组中的位置,并更新它们在字典中的索引 ❷。

#### 向上传播元素

第三个辅助函数实现了附录中描述的向上传播:

def _propagate_up(self, index: int):
parent: int = int(index / 2)
❶ while self._elements_inverted(parent, index):
self._swap_elements(parent, index)
index = parent
parent = int(index / 2)


_propagate_up()代码首先计算父节点的索引。然后,它使用while循环不断地将元素向上交换,直到它相对于父节点的顺序正确 ❶。由于_elements_inverted()在任一索引越界时返回False,当元素到达数组的前面(索引 1 和父节点索引 0)时,循环也会终止。

每次循环发现元素仍然不在正确位置时,它会将该元素与其父节点交换。代码然后更新元素的索引,并计算新父节点的索引。

#### 向下传播元素

最终的辅助函数实现了之前描述的向下传播:

def _propagate_down(self, index: int):
while index <= self.last_index:
swap: int = index
if self._elements_inverted(swap, 2index):
swap = 2
index
if self._elements_inverted(swap, 2index+1):
swap = 2
index + 1

  ❶ if index != swap:
        self._swap_elements(index, swap)
        index = swap
    else:
      ❷ break 

_propagate_down()代码使用while循环不断将元素向下交换,直到它成为数组中的最后一个元素或相对于子节点的顺序正确。代码使用_elements_inverted()函数检查左右子节点,该函数还处理数组的边界检查。如果找到具有反转优先级的子节点 ❶,代码会执行交换。否则,它会跳出循环 ❷。

### 添加项目

我们通过先将新元素附加到数组的末尾来添加 (*enqueue*) 新元素,这对应于树底层第一个空位。由于这个位置没有考虑到项目的优先级,因此我们很可能破坏了堆的性质。我们通过交换该元素向上移动,直到它处于正确的位置来修正这一点。

入队的代码执行堆插入以及额外的书籍管理:

def enqueue(self, value, priority: float):
❶ if value in self.indices:
self.update_priority(value, priority)
return

❷ if self.last_index == self.array_size - 1:
old_array: list = self.heap_array
self.heap_array = [None] * self.array_size * 2
for i in range(self.last_index + 1):
self.heap_array[i] = old_array[i]
self.array_size = self.array_size * 2

self.last_index = self.last_index + 1
self.heap_array[self.last_index] = HeapItem(value, priority)
self.indices[value] = self.last_index
self._propagate_up(self.last_index) 

enqueue() 函数的代码首先检查对象是否已经存在于优先队列中,方法是判断它的值是否在 indices 中 ❶。如果是,它更新项目的优先级并返回。代码不会插入具有重复值的项目。

代码接下来检查列表是否有足够的空间容纳新元素 ❷。如果没有,它必须在插入项目之前分配更多空间,并使用数组扩展来增加大小。

最后,代码将元素插入到 heap_array 的末尾。它在 indices 字典中标记该位置,以便后续查找,然后使用 _propagate_up() 修正插入造成的任何排序问题。重要的是,_propagate_up() 函数使用 _swap_elements() 函数,这会更新 indices。因此,尽管代码最初将 indices[value] 设置为 last_index,它会在整个过程中正确更新此索引映射。

### 移除项目

我们通过用数组中的最后一个值替换顶部节点来移除 (*dequeue*) 顶部节点。这将最后一个节点跳到树的根部,这很可能破坏堆的性质。我们通过将该元素向下传播,直到它不再与其子节点顺序错误来修正相对顺序。

出队的代码执行堆移除以及额外的书籍管理:

def dequeue(self):
if self.last_index == 0:
return None

❶ result: HeapItem = self.heap_array[1]
new_top: HeapItem = self.heap_array[self.last_index]
self.heap_array[1] = new_top
self.indices[new_top.value] = 1

self.heap_array[self.last_index] = None
self.indices.pop(result.value)
self.last_index = self.last_index - 1

self._propagate_down(1)
return result.value 

dequeue() 函数的代码首先检查队列是否为空,如果为空,则返回 None。(根据代码的上下文,我们可能需要引发错误。)

如果队列不为空,代码更新堆和索引映射。首先,它将 heap_array 中的最后一个元素交换到第一个位置,并将旧根保存为 result ❶。第二步,它从数组和索引映射中删除原来的顶部元素(result)。第三步,使用 _propagate_down() 函数修复堆属性的任何破坏。最后,返回结果的值。

### 修改优先级

可修改优先级队列支持的最终操作是更改元素的优先级。这涉及到查找元素在 heap_array 中的位置,修改优先级,并使用 _propagate_up() 或 _propagate_down() 函数修复由于优先级变化导致的堆属性破坏。大部分代码都是确定应该使用哪个传播函数:

def update_priority(self, value, priority: float):
if not value in self.indices:
return

index: int = self.indices[value]
old_priority: float = self.heap_array[index].priority
self.heap_array[index].priority = priority

if self.is_min_heap:
    if old_priority > priority:
        self._propagate_up(index)
    else:
        self._propagate_down(index)
else:
    if old_priority > priority:
        self._propagate_down(index)
    else:
        self._propagate_up(index) 

代码首先通过检查 value 是否在 indices 字典中来确定值是否在优先级队列中。如果没有,则没有需要更新的内容,可以立即返回。

如果值在优先级队列中,代码查找并保存该项的当前索引(index)和优先级(old_priority),然后设置新的优先级。此时,代码必须根据对象是否为最小堆以及新优先级是否大于旧优先级来确定使用哪个传播函数。如果对象是最小堆且旧优先级较大,或对象是最大堆且旧优先级较小,则代码使用 _propagate_up();如果对象是最小堆且旧优先级较小,或对象是最大堆且旧优先级较大,则使用 _propagate_down()。

## 查看函数

除了本附录中介绍的标准优先级入队/出队函数外,我们还提供了一些额外的便捷函数,执行允许我们查看顶部值的操作——即在不出队的情况下查看它。我们可以返回顶部项的整个 HeapItem(如果队列为空,则返回 None),项的值,或项的优先级:

def peak_top(self) -> Union[HeapItem, None]:
if self.is_empty():
return None
return self.heap_array[1]

def peek_top_priority(self) -> Union[float, None]:
obj: Union[HeapItem, None] = self.peak_top()
if not obj:
return None
return obj.priority

def peek_top_value(self):
obj: Union[HeapItem, None] = self.peak_top()
if not obj:
return None
return obj.value


这三个函数中的每一个都提供了一种有用的机制,用于在不修改队列的情况下检查优先队列中的“最佳”项,并且在调试时非常有用。




# 第二十二章:C UNION-FIND



![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/opener.jpg)

Kruskal 算法、随机迷宫生成和单链聚类都使用一种名为UnionFind的数据结构来表示图中不同连通分量对应的节点不相交的集合。这个数据结构使得算法能够高效地(1) 判断两个节点是否已经在同一个连通分量中,以及(2) 合并两个不同的分量。为完整性起见,本附录描述并提供了该数据结构的代码。

我们首先简要概述并查集数据结构,然后提供足够的代码以实现本书中的算法。我们鼓励感兴趣的读者进一步探索相关资源。Daniel Zingaro 的《Algorithmic Thinking》第 2 版(No Starch Press,2023)中的“并查集”一章,提供了对这些迷人数据结构的易懂介绍,并介绍了额外的优化。

## 并查集数据结构

*并查集*数据结构(也叫做*不相交集合*数据结构)通常被视为一组树的列表(也叫做*树的森林*)。每个项表示为树节点,每个集合表示为一棵树。若且仅若两个项处在同一棵树中,则它们被视为在同一个集合中。

图 C-1 显示了一个示例的并查集数据结构,包含 11 个项,分为三组:{0, 1, 6, 7, 10}、{3, 5, 9}、{2, 4, 8}。如图所示,树并不限于二叉树(每个节点最多有两个子节点),也没有强制对元素进行排序。

![最左边的树的根节点是 0,包含节点 0、1、6、7 和 10。中间的树的根节点是 5,包含节点 5、3 和 9。最右边的树的根节点是 2,包含节点 2、4 和 8。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0c001.jpg)

图 C-1:三个不相交的集合表示为树

该数据结构中的每个集合都通过根节点的索引号唯一标识。在图 C-1 中,树从左到右分别标记为 0、5 和 2。我们可以通过从某个节点遍历到该树的根节点来轻松检索该项的集合标签。例如,我们可以通过从节点 9 经过节点 3 到节点 5,然后返回 5 来识别项 9 的集合标签。

我们通过合并树来创建集合的并集。将一棵树附加到另一棵树上有多种方式。在本附录中,我们将使用常见的优化方法,将节点较少的树的根节点附加到较大树的根节点上。图 C-2 展示了将以 0 和 2 为根的集合合并为一个集合的示例。由于以节点 2 为根的树包含的节点较少,我们将节点 2 的父指针指向节点 0,实际上将子树作为子节点添加。

![合并后的树的根节点是 0。节点 2 的父指针指向节点 0。](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/gph-algo-fun-way/img/f0c002.jpg)

图 C-2:合并两棵树

为了便于说明,本节介绍了一种简化的 UnionFind 数据结构,它使用显式的森林树实现,使树形操作更为清晰。还可以进行更高效的优化,例如基于数组的实现和使用路径压缩来减少树的高度。

## UnionFind

UnionFind 数据结构将元素划分为不同的(且不重叠的)集合,使得每个元素仅属于一个集合。对于 Kruskal 算法,这些集合表示图中的不同连通组件。同一组件中的节点属于同一个集合。

如第十章所述,这种数据结构非常强大,因为它能够非常快速地执行两个操作。第一个操作是判断两个点是否在同一个集合中,这对于判断两个节点是否已连接是必要的。第二个操作是合并两个集合,这对于连接组件是必要的。

本附录提供了以下函数的代码,这些函数构成了并查集接口:

are_disjoint(i, j)判断两个元素 i 和 j 是否属于不同的集合

union_sets(i, j)将包含元素 i 的集合与包含元素 j 的集合合并为一个集合

find_set(i)返回包含元素 i 的集合的唯一标签

### UnionFindNode

由于我们只需要向上遍历树(而不是向下),每个节点只需要存储两项信息:它自己的索引号和指向其父节点的指针。节点不需要存储指向子节点的指针。我们可以按如下方式定义一个最简的 UnionFindNode:

class UnionFindNode:
def init(self, label: int):
self.label = label
self.parent = None


我们最初将一个节点的父节点设置为 None,表示它是一个根节点。

### UnionFind 类

我们的最简 UnionFind 对象跟踪三项信息:

nodes **(**list**) **一个按标签索引的 UnionFindNode 对象的列表

set_sizes **(**list**) **一个将集合标签映射到其大小的列表

num_disjoint_sets **(**int**) **不相交集合的数量

我们使用列表来存储节点,因为在本书中的算法只需要支持连续的整数标签。然而,通过使用字典将每个标签映射到其对应的 UnionFindNode,我们也可以支持更通用的标签类型,如字符串。num_disjoint_sets 属性可以通过其他属性计算得到,但为了简便,它被显式存储。

使用这些属性,我们定义了一个构造函数,用于设置并查集数据结构的初始状态:

class UnionFind:
def init(self, num_sets: int):
self.nodes: list = [UnionFindNode(i) for i in range(num_sets)]
self.set_sizes: list = [1 for i in range(num_sets)]
self.num_disjoint_sets: int = num_sets


构造函数接受项的数量(num_sets),并构建完整的节点列表(nodes)和每个集合的大小列表(set_sizes)。由于所有项最初都在不相交的集合中,构造函数将每个项的 num_sets 大小初始化为 1。最后,它设置不相交集合的计数(num_disjoint_sets)。

查找集合的标签对应于向上遍历树并返回根节点的标签:

def find_set(self, label: int) -> int:
if label < 0 or label >= len(self.nodes):
raise IndexError

❶ current: UnionFindNode = self.nodes[label]
while current.parent is not None:
current = current.parent
return current.label


find_set() 函数首先检查标签的边界,如果标签超出范围,则引发 IndexError。然后,它从当前节点 ❶ 开始,使用 while 循环向上遍历树直到父节点。它返回父节点的标签作为集合的标识符。

are_disjoint() 函数使用两次调用 find_set() 来提取每个项的集合标签,并测试它们是否相等:

def are_disjoint(self, label1: int, label2: int) -> bool:
return self.find_set(label1) != self.find_set(label2)


如果集合标签相同,则项必须共享一个根节点,从而属于同一集合。

取两个集合的并集包含将树连接在一起:

def union_sets(self, label1: int, label2: int):
❶ set1_label: int = self.find_set(label1)
set2_label: int = self.find_set(label2)
if set1_label == set2_label:
return ❷ if self.set_sizes[set1_label] < self.set_sizes[set2_label]:
small = set1_label
large = set2_label
else:
small = set2_label
large = set1_label
❸ self.nodes[small].parent = self.nodes[large]
self.set_sizes[large] += self.set_sizes[small]
self.set_sizes[small] = 0
self.num_disjoint_sets -= 1


union_sets() 函数首先通过查找每个集合的标签并检查它们是否已经相等 ❶。如果相等,则不需要进行任何操作,函数返回。如果不相等,函数使用 set_sizes 列表来确定哪个树的节点较少 ❷,并将较小树的根节点附加到较大树的根节点下 ❸。最后,函数通过计算较大树的新大小来更新剩余数据,将较小树的大小条目设置为 0(因为它不再是一个不相交的集合),并更新不相交集合的数量。




# 第二十三章:INDEX



## A

+   吸收节点,217

+   吸收概率,217

+   吸收时间,217

+   无环图,136

+   邻接表表示法,7–15

+   邻接矩阵表示法,15–17

+   可接受启发式,113。*另见* 启发式

+   *算法思维*(Zingaro),369

+   *算法*(Sedgewick 和 Wayne),200

+   所有对的最短路径问题,102–103。*另见* 最短路径问题

+   反向边,231,253–254

+   弧。*另见* 边

+   切点查找算法,186–191

+   articulation_point_dfs() 函数,188

+   articulation_point_root() 函数,188

+   切点,177

+   *人工智能*(Russell 和 Norvig),323

+   A* 搜索,119–132

+   可接受启发式,124

+   astar_dynamic() 函数,128

+   astar_search() 函数,120

+   图的探索,126–132

+   拼图,124–126

+   增广路径,238–243。*另见* 最大流问题

+   平均聚类系数,25

## B

+   反向残量,235,239,241

+   贝尔曼-福特算法,97–99

+   BellmanFord() 函数,99

+   成本,99

+   最佳优先搜索,111,114。*另见* 启发式引导搜索

+   二分图,255,257–273

+   bipartite_labeling() 函数,259

+   标记,258–263

+   二分匹配问题,263–273

+   bipartite_matching_exh() 函数,266

+   bipartite_matching_max_flow() 函数,270

+   穷举算法,264–269

+   matching_recursive() 函数, 266

+   使用最大流算法, 269–273

+   广度优先搜索, 63–74

+   breadth_first_search() 函数, 65

+   最大流问题, 246–251

+   最短路径, 68–70

+   桥查找算法, 180–186

+   bridge_finding_dfs() 函数, 184

+   成本, 184

+   find_bridges() 函数, 184

+   桥, 176

## C

+   容量, 230–232. *另见* 最大流问题

+   CapacityEdge 类, 233–235

+   adjusted_used() 方法, 234

+   capacity_left() 方法, 234

+   构造函数, 234

+   flow_used() 方法, 234

+   Chaitin, George, 293

+   团, 301–308

+   回溯搜索, 305–308

+   clique_expansion_options() 函数, 303

+   clique_greedy() 函数, 304

+   贪心搜索, 303–305

+   is_clique() 函数, 302

+   最大团, 302

+   maximum_clique_backtracking() 函数, 306

+   maximum_clique_recursive() 函数, 305

+   闭邻域子图, 27

+   聚类系数, 24–26

+   ave_clustering_coefficient() 函数, 25

+   clustering_coefficient() 函数, 24

+   逗号分隔值(CSV), 347

+   连接分量, 42, 176, 193. *另见* 强连接分量

+   dfs_recursive_cc() 函数, 55

+   和欧拉回路, 336

+   构建图

+   build_graph_from_points() 函数, 354

+   从边列表, 346–347

+   加载共现图, 351–353

+   从文件加载边, 347–349

+   make_graph_from_dependencies() 函数, 355

+   make_graph_from_edges() 函数, 346

+   make_graph_from_multi_csv() 函数, 353

+   make_graph_from_weighted_csv() 函数, 348

+   make_graph_from_weighted_csv2() 函数, 351

+   从前置条件, 355–356

+   从空间数据, 353–355

+   共现图, 351–353

+   割点, 177

+   循环, 136

+   检测, 147–148

+   负数, 96

## D

+   DAG(有向无环图), 136

+   *数据结构与算法*(Aho, Hopcroft 和 Ullman), 199, 200

+   *数据结构的趣味之道*(Kubica), xxi, 358

+   度数, 22–24, 335

+   计算, 336

+   入度, 23

+   出度, 23

+   和自环, 23, 336

+   深度优先搜索, 45–61

+   depth_first_search_basic_all() 函数, 49

+   depth_first_search_path() 函数, 50

+   depth_first_search_stack() 函数, 52

+   dfs_recursive_basic() 函数, 48

+   dfs_recursive_cc() 函数, 55

+   dfs_recursive_path() 函数, 49

+   递归算法, 48–52

+   基于栈的算法, 52–55

+   使用

+   检查可达性, 48

+   查找关节点, 186–191

+   查找桥, 180–186

+   查找连通分量, 55–57

+   查找哈密顿路径, 328–330

+   Kosaraju-Sharir 算法,199–204

+   最大流问题,241–243

+   求解旅行商问题,331–334

+   拓扑排序,143–147

+   深度优先搜索森林,58

+   深度优先搜索树,57–59。*另见* DFSTreeStats 类

+   反向边,180

+   后序索引,199

+   先序索引,180

+   未遍历边,180–181

+   使用

+   查找关节点,186

+   查找桥梁,180

+   DFSTreeStats 类,182

+   构造函数,183

+   使用

+   查找关节点,188

+   查找桥梁,183

+   直径,108–110

+   GraphDiameter() 函数,109

+   Dijkstra,Edsger W.,91

+   Dijkstra 算法,91–96

+   成本,93

+   Dijkstras() 函数,92

+   不连通图,95–96

+   非负边,91

+   Dinic,E. A.,246

+   Dinitz,Yefim,246

+   Dinitz 算法,246–251

+   有向无环图(DAG),136

+   有向边,5–7。*另见* 边

+   有向图,6

+   不相交集合,161,370

+   节点之间的距离,90

## E

+   |*E*|,4

+   Edge 类,9–10

+   构造函数,9

+   表示无向边,10

+   表示无权边,9

+   边,4

+   目的地,6

+   有向,5–7

+   图形表示,4–6

+   指示顺序,136

+   负权重,96–97

+   起点,6

+   松弛,97

+   无向,6

+   带权,5

+   Edmonds,Jack,246

+   Edmonds-Karp 算法,246–251。*另见* 最大流问题

+   增广路径,238

+   在二分图匹配中,270

+   edmonds_karp() 函数, 248

+   find_augmenting_path_bfs() 函数, 246

+   欧几里得距离, 112–113, 354

+   euclidean_dist() 函数, 112

+   欧拉,莱昂哈德, 334, 335

+   欧拉回路, 334–341

+   使用 Hierholzer 算法进行寻找, 337

+   has_eulerian_cycle() 函数, 336

+   is_eulerian_cycle() 函数, 336

+   欧拉路径, 334

+   欧拉路径, 335

+   期望吸收时间, 218

+   期望击中时间, 218

## F

+   完成时间, 199

+   流, 230–231. *另见* 最大流问题

+   Floyd-Warshall 算法, 103–108

+   成本, 105

+   动态规划, 104

+   FloydWarshall() 函数, 105

+   中间路径, 103

+   福特,L.R.,Jr., 237

+   福特-福尔克森算法, 237–246. *另见* 最大流问题

+   augmenting_path_dfs_recursive() 函数, 241

+   find_augmenting_path_dfs() 函数, 241

+   ford-fulkerson() 函数, 244

+   正向残量, 235, 239, 241

+   福尔克森,D. R., 237

## G

+   机会游戏, 214–215, 219–221

+   Graph 类, 12–15. *另见* 构建图

+   构造函数, 12, 350

+   复制, 15

+   get_edge() 方法, 12

+   get_index_by_name() 方法, 350

+   get_in_neighbors() 方法, 21

+   insert_edge() 方法,13

+   按名称插入节点,350–351

+   insert_node() 方法,14

+   is_edge() 方法,13

+   make_copy() 方法,15

+   make_edge_list() 函数,13,100

+   make_undirected_neighborhood_subgraph() 方法,27

+   数字节点索引,9,12

+   remove_edge() 方法,13

+   表示无向图,12,14

+   save_graph_to_csv() 函数,350

+   保存到文件,349–350

+   图着色,277–297

+   回溯搜索,284–290

+   graph_color_dfs() 函数,285

+   graph_color_dfs_pruning() 函数,287

+   剪枝,286–290

+   穷举搜索,283–284

+   成本,283

+   graph_color_brute_force() 函数,283

+   first_unused_color() 函数,290

+   贪婪搜索,290–293

+   graph_color_greedy() 函数,291

+   is_graph_coloring_valid() 函数,278

+   最小图着色问题,278

+   节点移除,293–297

+   graph_color_removal() 函数,293

+   状态数量,282

+   问题定义,278

+   GraphMatrix 类,16–17,212

+   构造函数,16

+   get_edge() 方法,16

+   在随机游走中,212

+   表示无向图,16

+   set_edge() 方法,17

+   simulate_random_step() 方法,212

+   图。*另见* 构造图;边;节点

+   邻接表表示,7–15

+   邻接矩阵表示,15–17

+   二分图,255,257–273

+   直径,108–110

+   有向图,6

+   转置,200–201

+   无向图,6

+   无权图,5

+   有权图,5

+   图搜索,45

+   贪心最佳优先搜索,114–118

+   greedy_search() 函数,115

+   基于网格的图,70–72

+   高度,70

+   索引,71

+   make_grid_graph() 函数,71

+   make_grid_with_obstacles() 函数,72

+   和迷宫,72

+   障碍,71–72

+   宽度,70

## H

+   哈密尔顿,威廉,326

+   哈密尔顿回路,326

+   哈密尔顿路径,326–330

+   使用深度优先搜索查找,328–330

+   hamiltonian_dfs() 函数,329

+   hamiltonian_dfs_rec() 函数,328

+   is_hamiltonian_path() 函数,327

+   堆,358–361

+   作为数组,59

+   出队,366

+   入队,365

+   HeapItem 类,358

+   最大堆,358

+   最小堆,358

+   交换元素,359–361

+   启发式引导搜索,111–132。*另见* A* 搜索;贪心最佳优先搜索

+   启发式,112–114

+   可接受的,113

+   欧几里得距离,112

+   隐马尔可夫模型,210

+   层次聚类,169

+   赫尔霍泽,卡尔,337

+   赫尔霍泽算法,337,341

+   hierholzers() 函数,338

+   子循环,337

+   命中概率,217

+   命中时间,217

## I

+   入度,23

+   独立集,308–315,321–322

+   build_independent_set_random() 函数,322

+   启发式,310

+   independent_set_expansion _options() 函数,309

+   independent_set_greedy() 函数,311

+   independent_set_lowest _expansion() 函数,310

+   independent_set_random() 函数,321

+   is_independent_set() 函数,308

+   最大值,309

+   maximum_independent_set _backtracking() 函数,313

+   maximum_independent_set_rec() 函数,312

+   使用回溯搜索,311–312

+   使用贪婪搜索,309–311

+   入邻居,21

+   *算法导论*(Cormen, Leiserson, Rivest, 和 Stein),323

+   迭代加深,59–61

## J

+   贾尼克,Vojteˇch,156

## K

+   卡恩,Arthur B.,139

+   卡恩算法,139,143

+   check_cycle_kahns() 函数,148

+   成本,141

+   Kahns() 函数,140

+   卡普,Richard M.,246

+   Kosaraju,S. Rao,199

+   Kosaraju-Sharir 算法,199–204

+   add_reachable() 函数,201

+   成本,200

+   kosaraju_sharir() 函数,201

+   make_transpose_graph() 函数,200

+   转置图,200

+   克鲁斯卡尔,Joseph B.,161

+   克鲁斯卡尔算法,161–164

+   成本,161

+   kruskals() 函数,162

## L

+   链接。*见* edges

+   局部聚类系数,24

+   最低成本路径。*见* 最短路径

+   最低成本路径问题。*见* 最短路径问题

## M

+   曼哈顿距离,354

+   马尔可夫链,210。*另见* 随机游走

+   马尔可夫模型,210

+   匹配,255–257,263–273。*另见* 二分匹配问题

+   极大,256

+   最大基数,256

+   最大权重,256

+   完美,257

+   Matching 类,264–265

+   add_edge() 方法,265

+   构造函数,264

+   remove_edge() 方法,265

+   矩阵转置,200

+   最大流问题,229–254

+   反向边,231,253–254

+   增广路径,238–243

+   augment_multisink_graph() 函数,253

+   augment_multisource_graph() 函数,252

+   对于二分匹配,269–273

+   容量,230–232

+   流量,230–231

+   残量,239–241

+   向后,235,239,241

+   向前,235,239,241

+   汇节点,230

+   多汇点,252–253

+   源节点,230

+   多源,251–252

+   最大似然估计,221

+   迷宫

+   生成,165–169

+   表示,165

+   作为图,46

+   最小生成树,153–173

+   克鲁斯卡尔算法,161–164

+   迷宫生成,165–169

+   边的数量,154

+   普里姆算法,156–161

+   单链接聚类,169–173

+   可修改优先队列,357–368。*另见* PriorityQueue 类

## N

+   负循环,96

+   负权边。*另见* edges

+   邻域子图,26–30

+   make_undirected_neighborhood _subgraph() 方法,27

+   邻居,19–22

+   在有向图中,21–22

+   get_in_neighbors() 方法,21

+   get_neighbors() 方法,20

+   get_out_neighbors() 方法,21

+   内部邻居,21

+   外部邻居,21

+   在无向图中,20–21

+   Node 类,10–11

+   add_edge() 方法,11

+   构造函数,10

+   get_edge_list() 方法,11

+   get_edge() 方法,10

+   get_neighbors() 方法,20

+   get_out_neighbors() 方法,21

+   get_sorted_edge_list() 方法,11

+   num_edges() 方法,10

+   remove_edge() 方法,11

+   节点,4。*另见* degree

+   图形表示,4

+   索引,9,12

+   邻居,19–22

+   NP-hard 问题,282,299,323

## O

+   开放邻域子图,27

+   顺序索引,180

+   外度,23

+   外部邻居,21

## P

+   路径规划,70–73

+   路径,31–42,325–341

+   check_edge_path_valid() 函数,35

+   check_last_path_valid() 函数,39

+   check_node_path_valid() 函数,33

+   compute_path_cost_from_edges() 函数,40

+   成本,39–41,90

+   循环,91

+   负边权,91

+   PathCost() 表达式,90

+   目标,32

+   作为边的列表,34–36

+   作为节点的列表,33

+   作为前驱节点的列表,36–39

+   make_node_path_from_last() 函数,37

+   原点,32

+   和可达性,41–42

+   含有重复节点,32

+   PGState 类,81

+   Point 类,170,354

+   后序索引,199

+   先序索引,180

+   Prim, R. C.,156

+   Prim 算法,156,161

+   成本,157

+   prims() 函数,158

+   PriorityQueue 类,361–368

+   构造函数,362

+   dequeue() 方法,361,366

+   _elements_inverted() 方法,363

+   enqueue() 方法,361,365

+   get_priority() 方法,361–362

+   in_queue() 方法,361–362

+   is_empty() 方法,361–362

+   peek_top() 方法,362,368

+   peek_top_priority() 方法,362,368

+   peek_top_value() 方法,362,368

+   _propagate_down() 方法,365

+   _propagate_up() 方法,364

+   size() 方法,362

+   _swap_elements() 方法,364

+   update_priority() 方法,362,367

+   使用

+   在 A* 搜索中,120

+   在 Dijkstra 算法中,93

+   在贪心最佳优先搜索中,115

+   在 Prim 算法中,158

+   谜题,75–88

+   河流过河,78–79,81–88

+   滑块,79–80

+   使用搜索解决,85–88

+   状态,76,81

+   状态空间图,78

+   构造,77,80–85

+   汉诺塔,76–77

## Q

+   队列,65

## R

+   随机化算法,321–323

+   随机游走,207–226

+   吸收节点,217

+   吸收概率,217

+   吸收时间,217

+   choose_next_node() 函数,215

+   choose_start() 函数,224

+   estimate_graph_from_random_walks() 函数,222

+   estimate_start_from_random_walks() 函数,225

+   期望吸收时间,218

+   期望到达时间,218

+   和机会游戏,214–215,219–221

+   到达概率,217

+   到达时间,217

+   is_valid_probability_graph() 函数,211

+   学习,221–226

+   随机起始节点,224

+   random_walk() 函数,216

+   自环,208

+   模拟,215–216,224–225

+   平稳分布,218–219

+   时间不变性,210

+   转移矩阵,211

+   转移概率,208,210–211

+   可达性,41–48

+   检查,48,195

+   在有向图中,193

+   get_reachable() 函数,195

+   相互可达,195

+   松弛,97

+   残余,239–241

+   ResidualGraph 类,235–237,240,243

+   compute_total_flow() 方法,237

+   构造函数,236

+   get_edge() 方法,236

+   get_residual() 方法,241

+   insert_edge() 方法,236

+   min_residual_on_path() 方法,243

+   update_along_path() 方法,243

+   河流过桥谜题,78–79,81–88

+   A* 搜索,124–126

+   create_prisoners_and_guards() 函数,84

+   pg_generate_heuristic() 函数,125

+   pg_neighbors() 函数,83

+   pg_result_of_move() 函数,82

+   PGState 类,81

+   pg_state_to_index_map() 函数,85

+   solve_pg_bfs() 函数,86

+   状态,78

## S

+   自环,22

+   在邻接表表示法中,22

+   在邻接矩阵表示法中,22

+   以及度数,23

+   在随机游走中,208

+   以及无向图,22

+   Sharir, M.,199

+   最短路径,90–91

+   在无权图中,68

+   在加权图中,69

+   最短路径问题,89–110

+   全对,102–103

+   Bellman-Ford 算法,97–99

+   Dijkstra 算法,91–96

+   Floyd-Warshall 算法,103–108

+   负边权,96–97

+   单链聚类,169–173

+   Link 类,171

+   Point 类,170–171

+   single_linkage_clustering() 函数,171

+   汇聚节点,230

+   滑块拼图,79–80

+   源节点,230

+   极小生成树,154

+   空间数据,353–355

+   build_graph_from_points() 函数,354

+   欧几里得距离,112,354

+   曼哈顿距离,354

+   Point 类,354

+   状态空间图,78

+   构建,77,80–85

+   平稳分布,218–219

+   强连通分量,193–205

+   check_strongly_connected() 函数,196

+   get_reachable() 函数,195

+   Kosaraju-Sharir 算法,199–204

+   强连通图,194

## T

+   Tarjan, Robert,180,204

+   任务图,138

+   时间不变性,210

+   拓扑排序,135–151

+   深度优先搜索,143–147

+   启动节点的顺序,146

+   topological_dfs() 函数,143

+   检测环,147–148

+   check_cycle_kahns() 函数,148

+   is_topo_ordered() 函数,137

+   Kahn 算法,139

+   sort_forward_pointers() 函数,149

+   用于排序列表,148–150

+   汉诺塔问题,76–77

+   转移矩阵,211

+   转移概率,208,210–211

+   学习,221–226

+   转置图,200–201

+   make_transpose_graph() 函数,200

+   无向图,201

+   旅行商问题,331–334

+   启发式方法,333

+   tsp_dfs() 函数,333

+   tsp_dfs_rec() 函数,332

## U

+   无向边,6,10。*参见* edges

+   无向图,6,12,14

+   并查集,161,369–373

+   UnionFind 类

+   are_disjoint() 方法,372

+   构造函数,372

+   find_set() 方法,372

+   UnionFindNode 类,371

+   union_sets() 方法,372

+   使用

+   在克鲁斯卡尔算法中,161,163

+   在迷宫生成中,167

+   在单链接聚类中,171

+   无权图,5,9

## V

+   |*V*|,4

+   顶点覆盖,315–321

+   启发式方法,316

+   is_vertex_cover() 函数,315

+   最小值,316

+   minimum_vertex_cover _backtracking() 函数,319

+   minimum_vertex_cover_rec() 函数,318

+   使用回溯搜索,318–321

+   使用贪心搜索,316–318

+   vertex_cover_greedy_choice() 函数,316

+   vertex_cover_greedy() 函数,317

+   顶点。*参见* nodes

## W

+   步态,32

+   加权边,5。*参见* edges

+   加权图,5

+   World 类,127–130

## Z

+   Zingaro, Daniel,369
posted @ 2025-11-30 19:35  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报