通过图的路径概念是本书中将反复使用的基础构建块之一。例如,我们可能关心的是确定两个节点之间的最低成本路径(最短路径算法),或者判断是否存在任何路径能够从一个节点到达另一个节点。后续多个章节将专门介绍计算具有不同性质的路径的方法。
路径的总体概念与现实世界中的路径类似。就像你最喜欢的公园中的小路能让你从一个地方走到另一个地方,图中的路径也提供了同样的机制。它们是一系列节点(或边)的序列,允许我们在图中移动。当你在迷宫中逃脱或进行公路旅行时,我们就是通过图中的边从一个节点移动到另一个节点。
本章将正式定义图中路径的含义,并探讨我们可以用来表示路径的不同方式。无论是寻找路径还是将路径作为算法的一部分,我们都需要能够高效且明确地表示路径。如果你曾经问过别人路,却得到模糊的手势和类似“往那边走,到第三个或者第四个路口右转,你到时候大概能认出来”的回答,那你就体验过不完整路径带来的困扰。
本章介绍了三种明确无歧义的路径表示方式。我们还将考虑路径的性质以及可以用路径完成的几项任务。我们将探索如何检查路径是否有效,以及如何计算加权图中路径的代价。最后,我们讨论路径与图中可达性问题的关系。
路径
图中的路径是一系列通过边连接的节点。这些节点是我们从一个节点移动到另一个节点时经过的“途经点”。正如我们将单条边的起点称为“起点”,终点称为“终点”,路径的端点同样使用这两个术语:路径中的第一个节点是起点,最后一个节点是终点。图 3-1 展示了一条由节点 [0, 1, 3, 2, 4, 7] 组成的路径,列表中每一对相邻节点都对应图中的一条边。
为了本书的目的,我们采用计算机科学和算法教材中常用的路径定义(即节点序列)。这与图论中的严格路径定义不同——图论中路径不允许重复节点,允许重复节点的路径被称为“游走”(walk)。使用更广义的路径定义不仅能保持与其他算法教材的一致性,也更贴合现实生活中的路径。
路径具有方向性,即使是在无向图中也是如此。节点列表体现了我们沿路径行进的顺序。例如,路径 [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
- ❶ 首先检查路径是否为空,若是则返回 True(根据我们定义,空路径有效)。
- ❷ 检查路径起点是否合法(节点索引是否超出范围)。
- 接着使用循环遍历剩余节点(从第二个节点开始),利用
is_edge()检查前一个节点与当前节点之间是否存在边 ❸。如果不存在则返回 False。 - ❹ 如果存在对应边,则将当前节点赋值给
prev_node继续检查下一对节点。 - 如果遍历完成未发现无效节点或边,函数返回 True。
在公路旅行的例子中,这段代码对应检查城市列表中的每个城市是否合法且与前一个城市之间有直达道路。例如路径 [Boston, New York, Philadelphia, Pittsburgh] 会返回 True,而路径 [Boston, New York, Madrid, Philadelphia, Pittsburgh] 会因为“Madrid”无效而返回 False。
边列表
另一种自然的路径表示方法是用边列表。路径的起点是第一条边的起点,终点是最后一条边的终点。列表中的每条边代表节点间的跳转。
这种表示方法要求额外的约束:除了第一条边外,所有边的起点必须和前一条边的终点相同。换句话说,边列表 [e0, e1, ..., ek] 是有效路径的条件是:
该定义保证路径的连续性——每一段的起点必须紧接着上一段的终点,不能出现路径中断或跳跃。如果你朋友给你的公路旅行路线说明,第一天开车从波士顿到匹兹堡,第二天从辛辛那提开车到圣路易斯,你会发现这个路线有问题,因为它跳过了俄亥俄的大部分地区。图 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。
前驱节点列表
在本书中的许多算法里,我们用一种更专门的路径表示方法——用列表表示每个节点在路径中的前驱节点。该方法与书中许多算法按节点顺序处理数据的方式非常契合。这种表示既有明显优势,也有缺点。
考虑图 3-3 中的路径 [0, 1, 3, 2, 4, 7]。对于路径上的每个节点,我们可以标明它的前驱节点,如列表 last 所示。值 last[4] = 2 表示到达节点 4 是从节点 2 来的。我们用特殊值 -1 表示该节点没有前驱节点,这可能是因为该节点是路径的起点,或者它根本不在路径中。
这种表示方法的主要缺点是它限制了我们能表示的路径类型。每个节点最多只能有一个前驱节点。对于那些会重复访问节点的路径,比如 [0, 1, 3, 0, 5, 3, 4, 7],我们就无法用这种方式表示。如果试图用前驱节点数组来表示这条路径,就会遇到节点 3 的前驱信息冲突问题:节点 3 既先被节点 1 前驱过,后来又被节点 5 前驱过。虽然可以定义更复杂的“列表的列表”来处理这类情况,但本书采用的单一索引列表更适合书中算法的需求。
这种表示方法的最大优点是,在算法内部更新路径时非常简单,因为只需修改单个索引值。我们将在后续章节中反复利用这一特性来简化代码。另一个优点是,这种表示可以捕获多条分支路径。与前面两种表示方式(都使用单一定点的起点和终点)不同,前驱节点表示法只要求有一个固定的起点。通过这个列表,我们可以从任意终点节点回溯出一条路径至起点。
用一次公路旅行来比喻,前驱节点列表就相当于列出你可能去过的每个城市,以及你从哪个城市出发去到那个城市。如果你改变计划,决定去匹兹堡,你可以沿着前驱节点链路通过费城回到波士顿。这个前驱访问列表提供了从起点出发可达所有停靠点的信息。
另一个形象的比喻是探险者在迷宫中行进时留下的粉笔标记,就像图 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 设为空列表,起点是目标节点 dest,即当前节点 current ❶。然后用 while 循环遍历路径,直到遇到 -1,表示没有前驱节点 ❷。循环中,每步将当前节点加入 reverse_path,并更新当前节点为其前驱节点 ❸。最后,代码将 reverse_path 反转,得到路径的正确顺序,并返回该列表 ❹。
我们来看把这段代码应用到图 3-5(a) 和表示从节点 0 开始路径的 last 数组时的效果:
[-1, 0, 1, 2, 2, 0, 5, 0, 5, 8]
图 3-5(b) 展示了图中的向后指针(箭头指向起点),图 3-5(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 到列表中未包含的其他节点的路径,比如节点 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 是当前检查的索引(目标节点),from_node 是对应的前驱节点。代码判断要么 from_node 是 -1,表示该节点没有前驱节点,要么 from_node 到 to_node 这条边在图中有效 ❷。如果两个条件都不满足,则立即返回 False。所有条目都有效时,返回 True。
在公路旅行的比喻里,这个函数遍历列表中的每个城市,问:“我们能否直接从列表中标注的前一个城市到达这里?”比如,若匹兹堡的前驱节点是费城或伊利,我们会认可这条路径。但若波士顿的前驱节点是圣达菲,我们就会否定这条路径。
计算路径代价
在许多场景中,我们不仅关心用哪些边组成路径,还关心整条路径的整体收益或代价。正如第一章所述,我们可以使用边权重来表示从一个节点到另一个节点的代价,比如用它来模拟公路旅行中城市间的距离。在规划从波士顿到西雅图的飞行行程时,我们可能会跳过波士顿到迈阿密这条边,因为它的代价接近 1500 英里。
我们定义加权图中路径的代价为路径上所有边权重的总和。形式化地,对于路径 [e_0, e_1, ..., e_k],路径代价为:
图 3-6 中路径 [0, 3, 4, 2] 的代价是 1.0 + 3.0 + 5.0 = 9.0。
对于无权图,我们通常将每条边的权重设为 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,用来验证路径的有效性。
函数 compute_path_cost_from_edges() 遍历路径中的每条边,检查当前边的起点是否与上一个节点一致 ❷。如果不一致,则路径无效,比如边列表 [(0, 1), (2, 3), (3, 4)] 是无效的,因为从节点 1 跳到节点 2 之间没有边。如果路径有效,就将边的权重加入总代价;如果无效,则将总代价设置为无穷大。根据具体实现,程序员也可以选择抛出异常、退出程序或者用其他方式标识错误。每次循环结束时更新 prev_node,以跟踪当前位置。
代码继续检查列表中的每条边并累加权重,最后返回总代价。
与本章其他函数不同的是,这里没有传入图对象,目的是演示如何只对纯边列表进行操作。这样做的缺点是函数无法验证路径是否符合图结构。我们可以扩展函数,通过传入图对象,检查节点索引和边的存在性。相关代码可参考本章其他函数的实现方式。
可达性(Reachability)
我们可以利用路径的形式定义图中的另一个重要问题:“节点 v 是否可从节点 u 到达?”这个问题在许多实际问题中至关重要。在交通网络中,意味着“能否从城市 u 到达城市 v?”在社交网络中,意味着“谣言是否能从人 u 传播到人 v?”在地下城迷宫中,则是“我能否从这里走到出口?”
当存在一条从节点 u 出发并终止于节点 v 的路径时,我们称节点 v 可从节点 u 到达。给定一条路径,我们可以用本章前面介绍的任意一种有效性检查方法来验证路径是否合法。
想象你被困在图 3-7 表示的城堡地牢中。边表示相邻房间之间有可通行的门。为了逃离邪恶的巫师,你需要找到通向上层楼梯的房间。此时“可达性”问题尤为关键。如果你从房间 0 出发,想要到达节点 15,就没有希望了,因为节点 15 对节点 0 是不可达的。
在无向图中,我们可以将节点划分为若干不相交的集合,称为连通分量,使得同一个连通分量内的任意两个节点都是可达的。给定节点子集 ,我们称连通分量是满足以下条件的极大节点集合:
对于所有 和 ,有
以图 3-7 中的地下城示例为例,地图由两个连通分量组成,分别是 和 。只要通往地牢出口的楼梯和你当前所在的房间属于同一个连通分量,就可以安全逃脱。否则,你就被困住了。
为什么这很重要
我们将在全书中广泛使用路径的概念,解决从路径规划到优化受容量限制网络的流量等各种问题。路径将成为许多算法中的基本数据单元,也是函数计算的最常见结果之一。可达性和路径代价等概念,是从第 11 章寻找强连通分量,到第 15 章二分图匹配等多个算法的基础。
除了计算上的实用性,路径还能帮助我们在现实场景中直观地理解算法的运作。相比抽象的“边序列”,将路径想象成现实世界的对应路线更有助于理解。本章反复使用公路旅行的类比,将图中的边映射为全国各地的公路。我们同样可以想象自己沿着路径亲身“行走”本书中讨论的许多算法。
下一章将在路径的基础上,介绍多种图搜索算法,并返回它们所走过的路径。这些路径不仅展示了搜索的功能,也体现了在图中导航的能力。