python数据结构与算法分析(四)

数据结构与算法分析(一)
数据结构与算法分析(二)
数据结构与算法分析(三)

与树相比,图是更通用的结构;事实上,可以把树看作一种特殊的图。
顶点
顶点又称节点,是图的基础部分。它可以有自己的名字,我们称作“键”。顶点也可以带有附加信息,我们称作“有效载荷”。

边是图的另一个基础部分。两个顶点通过一条边相连,表示它们之间存在关系。边既可以是单向的,也可以是双向的。如果图中的所有边都是单向的,我们称之为有向图。
权重
边可以带权重,用来表示从一个顶点到另一个顶点的成本。例如在路线图中,从一个城市到另一个城市,边的权重可以表示两个城市之间的距离。
有了上述定义之后,就可以正式地定义图。图可以用G来表示,并且G = (V, E)。其中,V是一个顶点集合,E是一个边集合。每一条边是一个二元组(v, w),其中w, v∈V。可以向边的二元组中再添加一个元素,用于表示权重。子图s是一个由边e和顶点v构成的集合,其中e⊂E且v⊂V。
下图展示了一个简单的带权有向图。我们可以用6个顶点和9条边的两个集合来正式地描述这个图。
在这里插入图片描述
在这里插入图片描述
上图中的例子还体现了其他两个重要的概念。
路径
路径是由边连接的顶点组成的序列。路径的正式定义为w1, w2,···, wn,其中对于所有的1≤i≤n-1,有(wi, wi+1)∈E。无权重路径的长度是路径上的边数,有权重路径的长度是路径上的边的权重之和。以上图为例,从 V3到 V1的路径是顶点序列(V 3, V 4,V 0, V1),相应的边是{(v3, v4,7), (v4, v0,1), (v0, v1,5)}。

环是有向图中的一条起点和终点为同一个顶点的路径。例如,上图中的路径(V5, V2, V3, V5)就是一个环。没有环的图被称为无环图,没有环的有向图被称为有向无环图,简称为DAG。接下来会看到,DAG能帮助解决很多重要的问题。

图的抽象数据类型

图的抽象数据类型由下列方法定义。
Graph()新建一个空图。
addVertex(vert)向图中添加一个顶点实例。
addEdge(fromVert, toVert)向图中添加一条有向边,用于连接顶点fromVert和toVert。
addEdge(fromVert, toVert, weight)向图中添加一条带权重weight的有向边,用于连接顶点fromVert和toVert。
getVertex(vertKey)在图中找到名为vertKey的顶点。
getVertices()以列表形式返回图中所有顶点。
in通过vertex in graph这样的语句,在顶点存在时返回True,否则返回False。
根据图的正式定义,可以通过多种方式在Python中实现图的抽象数据类型。在使用不同的表达方式来实现图的抽象数据类型时,需要做很多取舍。有两种非常著名的图实现,它们分别是邻接矩阵和邻接表。

邻接矩阵
要实现图,最简单的方式就是使用二维矩阵。在矩阵实现中,每一行和每一列都表示图中的一个顶点。第v行和第w列交叉的格子中的值表示从顶点v到顶点w的边的权重。如果两个顶点被一条边连接起来,就称它们是相邻的。下图展示了上图对应的邻接矩阵。格子中的值表示从顶点v到顶点w的边的权重。
在这里插入图片描述
邻接矩阵的优点是简单。对于小图来说,邻接矩阵可以清晰地展示哪些顶点是相连的。但是,图中的绝大多数单元格是空的,我们称这种矩阵是“稀疏”的。对于存储稀疏数据来说,矩阵并不高效。事实上,要在Python中创建如图所示的矩阵结构并不容易。
邻接矩阵适用于表示有很多条边的图。但是,“很多条边”具体是什么意思呢?要填满矩阵,共需要多少条边?由于每一行和每一列对应图中的每一个顶点,因此填满矩阵共需要|V|^2条边。当每一个顶点都与其他所有顶点相连时,矩阵就被填满了。在现实世界中,很少有问题能够达到这种连接度。

邻接表
为了实现稀疏连接的图,更高效的方式是使用邻接表。在邻接表实现中,为图对象的所有顶点保存一个主列表,同时为每一个顶点对象都维护一个列表,其中记录了与它相连的顶点。在对Vertex类的实现中,使用字典(而不是列表),字典的键是顶点,值是权重。下图展示了上图所对应的邻接表。
在这里插入图片描述
邻接表的优点是能够紧凑地表示稀疏图。此外,邻接表也有助于方便地找到与某一个顶点相连的其他所有顶点。

实现
在Python中,通过字典可以轻松地实现邻接表。
要创建两个类:Graph类存储包含所有顶点的主列表,Vertex类表示图中的每一个顶点。
Vertex使用字典connectedTo来记录与其相连的顶点,以及每一条边的权重。代码展示了Vertex类的实现,其构造方法简单地初始化id(它通常是一个字符串),以及字典connectedTo。addNeighbor方法添加从一个顶点到另一个的连接。getConnections方法返回邻接表中的所有顶点,由connectedTo来表示。getWeight方法返回从当前顶点到以参数传入的顶点之间的边的权重。
在这里插入图片描述
Graph类的实现如代码所示,其中包含一个将顶点名映射到顶点对象的字典。在图7-4中,该字典对象由灰色方块表示。Graph类也提供了向图中添加顶点和连接不同顶点的方法。getVertices方法返回图中所有顶点的名字。此外,我们还实现了__iter__方法,从而使遍历图中的所有顶点对象更加方便。总之,这两个方法使我们能够根据顶点名或者顶点对象本身遍历图中的所有顶点。
在这里插入图片描述
下面的Python会话使用Graph类和Vertex类创建了如上图所示的图。首先创建6个顶点,依次编号为0~5。然后打印顶点字典。注意,对每一个键,我们都创建了一个Vertex实例。接着,添加将顶点连接起来的边。最后,用一个嵌套循环验证图中的每一条边都已被正确存储。
在这里插入图片描述

广度优先搜索

从一个例子引入:将单词FOOL转换成SAGE。在解决词梯问题时,必须每次只替换一个字母,并且每一步的结果都必须是一个单词,而不能是不存在的词。
FOOL–POOL–POLL–POLE–PALE–SALE–SAGE
这里研究的是从起始单词转换到结束单词所需的最小步数。
使用图算法来解决这个问题。以下是大致步骤:
用图表示单词之间的关系;
用一种名为宽度优先搜索的图算法找到从起始单词到结束单词的最短路径。

如果两个单词的区别仅在于有一个不同的字母,就用一条边将它们相连。如果能创建这样一个图,那么其中的任意一条连接两个单词的路径就是词梯问题的一个解。它是无向图,并且边没有权重。
在这里插入图片描述
创建这个图有多种方式。假设有一个单词列表,其中每个单词的长度都相同。首先,为每个单词创建顶点。为了连接这些顶点,可以将每个单词与列表中的其他所有单词进行比较。如果两个单词只相差一个字母,就可以在图中创建一条边,将它们连接起来。对于只有少量单词的情况,这个算法还不错。但是,假设列表中有5110个单词,将一个单词与列表中的其他所有单词进行比较,时间复杂度为O( 2n )。对于5110个单词来说,这意味着要进行2600多万次比较。

采用下述方法,可以更高效地构建这个关系图。假设有数目巨大的桶,每一个桶上都标有一个长度为4的单词,但是某一个字母被下划线代替。下图展示了一些例子,如POP_。当处理列表中的每一个单词时,将它与桶上的标签进行比较。使用下划线作为通配符,我们将POPE和POPS放入同一个桶中。一旦将所有单词都放入对应的桶中之后,我们就知道,同一个桶中的单词一定是相连的。
在这里插入图片描述
在Python中,可以通过字典来实现上述方法。字典的键就是桶上的标签,值就是对应的单词列表。一旦构建好字典,就能利用它来创建图。首先为每个单词创建顶点,然后在字典中对应同一个键的单词之间创建边。代码展示了构建图所需的代码。
在这里插入图片描述
本例中的单词列表包含5110个单词。如果使用邻接矩阵表示,就会有26112100个单元格(5110 *5110 = 26112100)。用buildGraph函数创建的图一共有53286条边。因此,只有0.2%的单元格被填充。这显然是一个非常稀疏的矩阵。

广度优先搜索(breadth firstsearch,以下简称BFS)。
给定图G和起点s, BFS通过边来访问在G中与s之间存在路径的顶点。BFS的一个重要特性是,它会在访问完所有与s相距为k的顶点之后再去访问与s相距为k+1的顶点。为了理解这种搜索行为,可以想象BFS以每次生成一层的方式构建一棵树。它会在访问任意一个孙节点之前将起点的所有子节点都添加进来。
为了记录进度,BFS会将顶点标记成白色、灰色或黑色。在构建时,所有顶点都被初始化成白色。白色代表该顶点没有被访问过。当顶点第一次被访问时,它就会被标记为灰色;当BFS完成对该顶点的访问之后,它就会被标记为黑色。这意味着一旦顶点变为黑色,就没有白色顶点与之相连。灰色顶点仍然可能与一些白色顶点相连,这意味着还有额外的顶点可以访问。
在代码中,BFS使用邻接表来表示图。它还使用Queue来决定后续要访问的顶点。
在这里插入图片描述
除此以外,BFS还使用了Vertex类的扩展版本。这个新的Vertex类新增了3个实例变量:distance、predecessor和color。每一个变量都有对应的getter方法和setter方法。扩展后的Vertex类被包含在pythonds包中。
BFS从起点 s 开始,将它标记为灰色,以表示正在访问它。另外两个变量,distance和predecessor,被分别初始化为0和None。随后,start被放入Queue中。下一步是系统化地访问位于队列头部的顶点。我们通过遍历邻接表来访问新的顶点。在访问每一个新顶点时,都会检查它的颜色。如果是白色,说明顶点没有被访问过,那么就执行以下4步。
(1) 将新的未访问顶点nbr标记成灰色。
(2) 将nbr的predecessor设置成当前顶点currentVert。
(3) 将nbr的distance设置成到currentVert的distance加1。
(4) 将nbr添加到队列的尾部。这样做为之后访问该顶点做好了准备。但是,要等到currentVert邻接表中的所有其他顶点都被访问之后才能访问该顶点。

bfs函数构建对应下图的广度优先搜索树。从顶点fool开始,将所有与之相连的顶点都添加到树中。相邻的顶点有pool、foil、foul,以及cool。它们都被添加到队列中,作为之后要访问的顶点。下图展示了正在构建中的树以及完成这一步之后的队列。
在这里插入图片描述
接下来,bfs函数从队列头部移除下一个顶点(pool)并对它的邻接顶点重复之前的过程。但是,当检查cool的时候,bfs函数发现它的颜色已经被标记为了灰色。这意味着从起点到cool有一条更短的路径,并且cool已经被添加到了队列中。下图展示了树和队列的新状态。
在这里插入图片描述
队列中的下一个顶点是foil。唯一能添加的新顶点是fail。当bfs函数继续处理队列时,后面的两个顶点都没有可供添加到队列和树中的新顶点。左图展示了树和队列在扩展了第2层之后的状态。
右图展示了访问完图中所有顶点之后的广度优先搜索树。非常神奇的一点是,我们不仅解决了一开始提出的从FOOL转换成SAGE的问题,同时也解决了许多其他问题。可以从广度优先搜索树中的任意节点开始,跟随predecessor回溯到根节点,以此来找到任意单词到fool的最短词梯。代码中的函数展示了如何通过回溯predecessor链来打印整个词梯。
在这里插入图片描述
在这里插入图片描述
分析广度优先搜索
在广度优先代码中,第8行的while循环对于|V| 中的任一顶点最多只执行一次。这是因为只有白色顶点才能被访问并添加到队列中。这使得while循环的时间复杂度是O(V)。至于嵌套在while循环中的for循环(第10行),它对每一条边都最多只会执行一次。原因是,每一个顶点最多只会出列一次,并且我们只有在顶点u出列时才会访问从u到v的边。这使得for循环的时间复杂度为O(E)。因此,两个循环总的时间复杂度就是O(V+E)。
进行广度优先搜索只是整个任务的一部分,从起点一直找到终点则是任务的另一部分。这部分的最坏情况是整个图是一条长链。在这种情况下,遍历所有顶点的时间复杂度是O(V)。正常情况下,时间复杂度等于O(V)乘以某个小数,但是仍然用O(V)来表示。

深度优先搜索

通过骑士周游问题引入深度优先搜索。
为了解决骑士周游问题,取一块国际象棋棋盘和一颗骑士棋子(马)。目标是找到一系列走法,使得骑士对棋盘上的每一格刚好都只访问一次。这样的一个移动序列被称为“周游路径”。
对于8×8的棋盘,周游数的上界是1.305×1035,但死路更多。
很多种算法来解决骑士周游问题,但是图搜索算法是其中最好理解和最易编程的一种。再一次通过两步来解决这个问题:
用图表示骑士在棋盘上的合理走法;
使用图算法找到一条长度为rows × columns-1的路径,满足图中的每一个顶点都只被访问一次。

构建骑士周游图
为了用图表示骑士周游问题,将棋盘上的每一格表示为一个顶点,同时将骑士的每一次合理走法表示为一条边。下图展示了骑士的合理走法以及在图中对应的边。
在这里插入图片描述
可以用代码中的Python函数来构建n × n棋盘对应的完整图。knightGraph函数将整个棋盘遍历了一遍。当它访问棋盘上的每一格时,都会调用辅助函数genLegalMoves来创建一个列表,用于记录从这一格开始的所有合理走法。之后,所有的合理走法都被转换成图中的边。另一个辅助函数posToNodeId将棋盘上的行列位置转换成与上图中顶点编号相似的线性顶点数。
在这里插入图片描述
在代码中,genLegalMoves函数接受骑士在棋盘上的位置,并且生成8种可能的走法。legalCoord辅助函数确认走法是合理的。
在这里插入图片描述
下图展示了在8×8的棋盘上所有合理走法所对应的完整图,其中一共有336条边。注意,与棋盘中间的顶点相比,边缘顶点的连接更少。可以看到,这个图也是非常稀疏的。如果图是完全相连的,那么会有4096条边。由于本图只有336条边,因此邻接矩阵的填充率只有8.2%。
在这里插入图片描述

实现骑士周游
用来解决骑士周游问题的搜索算法叫作深度优先搜索(depthfirst search,以下简称DFS)。与BFS每次构建一层不同,DFS通过尽可能深地探索分支来构建搜索树。将探讨DFS的2种实现:第1种通过显式地禁止顶点被多次访问来直接解决骑士周游问题;第2种更通用,它在构建搜索树时允许其中的顶点被多次访问。
DFS正是为找到由63条边构成的路径所需的算法。可以看到,当DFS遇到死路时(无法找到下一个合理走法),它会回退到树中倒数第2深的顶点,以继续移动。
在代码中,knightTour函数接受4个参数:n是搜索树的当前深度;path是到当前为止访问过的顶点列表;u是希望在图中访问的顶点;limit是路径上的顶点总数。knightTour函数是递归的。当被调用时,它首先检查基本情况。如果有一条包含64个顶点的路径,就从knightTour返回True,以表示找到了一次成功的周游。如果路径不够长,则通过选择一个新的访问顶点并对其递归调用knightTour来进行更深一层的探索。
DFS也使用颜色来记录已被访问的顶点。未访问的顶点是白色的,已被访问的则是灰色的。如果一个顶点的所有相邻顶点都已被访问过,但是路径长度仍然没有达到64,就说明遇到了死路。如果遇到死路,就必须回溯。当从knightTour返回False时,就会发生回溯。在宽度优先搜索中,我们使用了队列来记录将要访问的顶点。由于深度优先搜索是递归的,因此我们隐式地使用一个栈来回溯。当从knightTour调用返回False时,仍然在while循环中,并且会查看nbrList中的下一个顶点。
在这里插入图片描述
通过一个例子来看看knightTour的运行情况,可以参照下图来追踪搜索的变化。这个例子假设在代码中第6行对getConnections方法的调用将顶点按照字母顺序排好。首先调用knightTour(0, path, A, 6)。
在这里插入图片描述
knightTour函数从顶点A开始访问。与A相邻的顶点是B和D。按照字母顺序,B在D之前,因此DFS选择B作为下一个要访问的顶点,如图b所示。对B的访问从递归调用knightTour开始。B与C和D相邻,因此knightTour接下来会访问C。但是,C没有白色的相邻顶点(如图c所示),因此是死路。此时,将C的颜色改回白色。knightTour的调用返回False,也就是将搜索回溯到顶点B,如图d所示。接下来要访问的顶点是D,因此knightTour进行了一次递归调用来访问它。从顶点D开始,knightTour可以继续进行递归调用,直到再一次访问顶点C。但是,这一次,检验条件n < limit失败了,因此我们知道遍历完了图中所有的顶点。此时返回True,以表明对图进行了一次成功的遍历。当返回列表时,path包含[A, B, D,E, F, C]。其中的顺序就是每个顶点只访问一次所需的顺序。
下图展示了在8×8的棋盘上周游的完整路径。存在多条周游路径,其中有一些是对称的。通过一些修改之后,可以实现循环周游,即起点和终点在同一个位置。
在这里插入图片描述

分析骑士周游
knightTour对用于选择下一个访问顶点的方法非常敏感。例如,利用速度正常的计算机,可以在1.5秒之内针对5×5的棋盘生成一条周游路径。但是,如果针对8×8的棋盘,可能需要等待半个小时才能得到结果!
如此耗时的原因在于,目前实现的骑士周游问题算法是一种O(k^N)的指数阶算法,其中 N是棋盘上的格子数,k是一个较小的常量。下图有助于理解搜索过程。树的根节点代表搜索过程的起点。从起点开始,算法生成并且检测骑士能走的每一步。如前所述,合理走法的数目取决于骑士在棋盘上的位置。若骑士位于四角,只有2种合理走法;若位于与四角相邻的格子中,则有3种合理走法;若在棋盘中央,则有8种合理走法。下图展示了棋盘上的每一格所对应的合理走法数目。在树的下一层,对于骑士当前位置来说,又有2~8种不同的合理走法。待检查位置的数目对应搜索树中的节点数目。
在这里插入图片描述
在这里插入图片描述
在高度为N的二叉树中,节点数为2^(N+1)-1;至于子节点可能多达8个而非2个的树,其节点数会更多。由于每一个节点的分支数是可变的,因此可以使用平均分支因子来估计节点数。需要注意的是,这个算法是指数阶算法:k ^(N+1)-1,其中k是棋盘的平均分支因子。让我们看看它增长得有多快。对于5× 5的棋盘,搜索树有25层(若把顶层记为第0层,则N = 24),平均分支因子k = 3.8。因此,搜索树中的节点数是3.8 ^25-1或者3.12×10 ^14。对于6×6的棋盘,k =4.4,搜索树有1.5×10 ^23个节点。对于8×8的棋盘,k = 5.25,搜索树有1.3×10 ^46个节点。由于这个问题有很多个解,因此不需要访问搜索树中的每一个节点。但是,需要访问的节点的小数部分只是一个常量乘数,它并不能改变该问题的指数特性。
幸运的是,有办法针对8×8的棋盘在1秒内得到一条周游路径。代码展示了加速搜索过程的代码。orderByAvail函数用于替换代码清单7-8中第6行的u.getConnections调用。在orderByAvail函数中,第10行是最重要的一行。这一行保证接下来要访问的顶点有最少的合理走法。
在这里插入图片描述
选择合理走法最多的顶点作为下一个访问顶点的问题在于,它会使骑士在周游的前期就访问位于棋盘中间的格子。当这种情况发生时,骑士很容易被困在棋盘的一边,而无法到达另一边的那些没访问过的格子。首先访问合理走法最少的顶点,则可使骑士优先访问棋盘边缘的格子。这样做保证了骑士能够尽早访问难以到达的角落,并且在需要的时候通过中间的格子跨越到棋盘的另一边。我们称利用这类知识来加速算法为启发式技术。人类每天都在使用启发式技术做决定,启发式搜索也经常被用于人工智能领域。这里用到的启发式技术被称作Warnsdorff算法,以纪念在1823年提出该算法的数学家H. C. Warnsdorff。

通用深度优先搜索
骑士周游是深度优先搜索的一种特殊情况,它需要创建没有分支的最深深度优先搜索树。通用的深度优先搜索其实更简单,它的目标是尽可能深地搜索,尽可能多地连接图中的顶点,并且在需要的时候进行分支。
一次深度优先搜索甚至能够创建多棵深度优先搜索树,称之为深度优先森林。和广度优先搜索类似,深度优先搜索也利用前驱连接来构建树。此外,深度优先搜索还会使用Vertex类中的两个额外的实例变量:发现时间记录算法在第一次访问顶点时的步数,结束时间记录算法在顶点被标记为黑色时的步数。在学习之后会发现,顶点的发现时间和结束时间提供了一些有趣的特性,后续算法会用到这些特性。
深度优先搜索的实现如代码所示。由于dfs函数和dfsvisit辅助函数使用一个变量来记录调用dfsvisit的时间,因此我们选择将代码作为Graph类的一个子类中的方法来实现。该实现继承Graph类,并且增加了time实例变量,以及dfs和dfsvisit两个方法。注意第11行,dfs方法遍历图中所有的顶点,并对白色顶点调用dfsvisit方法。之所以遍历所有的顶点,而不是简单地从一个指定的顶点开始搜索,是因为这样做能够确保深度优先森林中的所有顶点都在考虑范围内,而不会有被遗漏的顶点。for aVertex in self这条语句可能看上去不太正确,但是此处的self是DFSGraph类的一个实例,遍历一个图实例中的所有顶点其实是一件非常自然的事情。
在这里插入图片描述
尽管这里的bfs实现只对回到起点的路径上的顶点感兴趣,但也可以创建一个表示图中所有顶点间的最短路径的宽度优先森林。
从startVertex开始,dfsvisit方法尽可能深地探索所有相邻的白色顶点。如果仔细观察dfsvisit的代码并且将其与bfs比较,应该注意到二者几乎一样,除了内部for循环的最后一行,dfsvisit通过递归地调用自己来继续进行下一层的搜索,bfs则将顶点添加到队列中,以供后续搜索。有趣的是,bfs使用队列,dfsvisit则使用栈。我们没有在代码中看到栈,但是它其实隐式地存在于dfsvisit的递归调用中。
下图展示了在小型图上应用深度优先搜索算法的过程。图中,虚线表示被检查过的边,但是其一端的顶点已经被添加到深度优先搜索树中。在代码中,这是通过检查另一端的顶点是否不为白色来完成的。
在这里插入图片描述
搜索从图中的顶点A开始。由于所有顶点一开始都是白色的,因此算法会访问A。访问顶点的第一步是将其颜色设置为灰色,以表明正在访问该顶点,并将其发现时间设为1。由于A有两个相邻顶点(B和D),因此它们都需要被访问。我们按照字母顺序来访问顶点。
接下来访问顶点B,将它的颜色设置为灰色,并把发现时间设置为2。B也与两个顶点(C和D)相邻,因此根据字母顺序访问C。
访问C时,搜索到达某个分支的终点。在将C标为灰色并且把发现时间设置为3之后,算法发现C没有相邻顶点。这意味着对C的探索完成,因此将它标为黑色,并将完成时间设置为4。图d展示了搜索至这一步时的状态。
由于C是一个分支的终点,因此需要返回到B,并且继续探索其余的相邻顶点。唯一的待探索顶点就是D,它把搜索引到E。E有两个相邻顶点,即B和F。正常情况下,应该按照字母顺序来访问这两个顶点,但是由于B已经被标记为灰色,因此算法自知不应该访问B,因为如果这么做就会陷入死循环。因此,探索过程跳过B,继续访问F。
F只有C这一个相邻顶点,但是C已经被标记为黑色,因此没有后续顶点需要探索,也即到达另一个分支的终点。从此时起,算法一路回溯到起点,同时为各个顶点设置完成时间并将它们标记为黑色,如图h~图l所示。
每个顶点的发现时间和结束时间都体现了括号特性,这意味着深度优先搜索树中的任一节点的子节点都有比该节点更晚的发现时间和更早的结束时间。下图展示了通过深度优先搜索算法构建的树。
在这里插入图片描述

分析深度优先搜索
一般来说,深度优先搜索的运行时间如下。在代码中,若不计dfsvisit的运行时间,第8行和第11行的循环为O(V),这是由于它们针对图中的每个顶点都只执行一次。在dfsvisit中,第19行的循环针对当前顶点的邻接表中的每一条边都执行一次。由于dfsvisit只有在顶点是白色时被递归调用,因此循环最多会对图中的每一条边执行一次,也就是O(E)。因此,深度优先搜索算法的时间复杂度是O(V+E)。

拓扑排序

拓扑排序根据有向无环图生成一个包含所有顶点的线性序列,使得如果图G中有一条边为(v, w),那么顶点v排在顶点w之前。在很多应用中,有向无环图被用于表明事件优先级。
拓扑排序是对深度优先搜索的一种简单而强大的改进,其算法如下。
(1) 对图g调用dfs(g)。之所以调用深度优先搜索函数,是因为要计算每一个顶点的结束时间。
(2) 基于结束时间,将顶点按照递减顺序存储在列表中。
(3) 将有序列表作为拓扑排序的结果返回。

强连通单元

在互联网上,各种网页形成一张大型的有向图,谷歌和必应等搜索引擎正是利用了这一事实。要将互联网转换成一张图,我们将网页当作顶点,将超链接当作连接顶点的边。下图展示了以路德学院计算机系的主页作为起点的网页连接图的一小部分。
在这里插入图片描述
首先,图中的很多网页来自路德学院的其他网站。其次,一些链接指向爱荷华州的其他学校。最后,一些链接指向其他文理学院。由此可以得出这样的结论:网络具有一种基础结构,使得在某种程度上相似的网页相互聚集。
通过一种叫作强连通单元的图算法,可以找出图中高度连通的顶点簇。对于图G,强连通单元C为最大的顶点子集C⊂V,其中对于每一对顶点v, w∈C,都有一条从v到w的路径和一条从w到v的路径。
下图展示了一个包含3个强连通单元的简单图。不同的强连通单元通过不同的阴影来表现。
在这里插入图片描述
定义强连通单元之后,就可以把强连通单元中的所有顶点组合成单个顶点,从而将图简化。下图是上图的简化版。
在这里插入图片描述
利用深度优先搜索,可以再次创建强大高效的算法。在学习强连通单元算法之前,还要再看一个定义。图G的转置图被定义为GT,其中所有的边都与图G的边反向。这意味着,如果在图G中有一条由A到B的边,那么在GT中就会有一条由B到A的边。下图展示了一个简单图及其转置图。
在这里插入图片描述
再次观察。注意,图a中有2个强连通单元,图b中也是如此。以下是计算强连通单元的算法。
(1) 对图G调用dfs,以计算每一个顶点的结束时间。
(2) 计算图GT。
(3) 对图GT调用dfs,但是在主循环中,按照结束时间的递减顺序访问顶点。
(4) 第3步得到的深度优先森林中的每一棵树都是一个强连通单元。输出每一棵树中的顶点的id。
图a展示了用深度优先搜索算法对原图计算得到的发现时间和结束时间,图b展示了用深度优先搜索算法在转置图上得到的发现时间和结束时间。
在这里插入图片描述
最后,下图展示了由强连通单元算法在第3步生成的森林,其中有3棵树。
在这里插入图片描述

最短路径问题

Dijkstra算法
Dijkstra算法可用于确定最短路径,它是一种循环算法,可以提供从一个顶点到其他所有顶点的最短路径。这与广度优先搜索非常像。
为了记录从起点到各个终点的总开销,要利用Vertex类中的实例变量dist。该实例变量记录从起点到当前顶点的最小权重路径的总权重。Dijkstra算法针对图中的每个顶点都循环一次,但循环顺序是由一个优先级队列控制的。用来决定顺序的正是dist。在创建顶点时,将dist设为一个非常大的值。理论上可以将dist设为无穷大,但是实际一般将其设为一个大于所有可能出现的实际距离的值。
Dijkstra算法的实现如代码所示。当程序运行结束时,dist和predecessor都会被设置成正确的值。
在这里插入图片描述
Dijkstra算法使用了优先级队列。堆实现优先级队列的简单实现和用于Dijkstra算法的实现有几个不同点。首先,PriorityQueue类存储了键-值对的二元组。这对于Dijkstra算法来说非常重要,因为优先级队列中的键必须与图中顶点的键相匹配。其次,二元组中的值被用来确定优先级,对应键在优先级队列中的位置。在Dijkstra算法的实现中,我们使用了顶点的距离作为优先级,这是因为我们总希望访问距离最小的顶点。另一个不同点是增加了decreaseKey方法(第14行)。当到一个顶点的距离减少并且该顶点已在优先级队列中时,就调用这个方法,从而将该顶点移向优先级队列的头部。
让我们对照下图来理解如何针对每一个顶点应用Dijkstra算法。从顶点u开始,与u相邻的3个顶点分别是v、w和x。由于到v、w和x的初始距离都是sys.maxint,因此从起点到它们的新开销就是直接开销。更新这3个顶点的开销,同时将它们的前驱顶点设置成 u,并将它们添加到优先级队列中。我们使用距离作为优先级队列的键。此时,算法运行的状态如图a所示。
在这里插入图片描述
下一次while循环检查与x相邻的顶点。之所以x是第2个被访问的顶点,是因为它到起点的开销最小,因此排在了优先级队列的头部。与x相邻的有u、v、w和y。对于每一个相邻顶点,检查经由x到它的距离是否比已知的距离更短。显然,对于y来说确实如此,因为它的初始距离是sys.maxint;对于u和v来说则不然,因为它们的距离分别为0和2。但是,我们发现经过x到w的距离比直接从u到w的距离要短。因此,将到达w的距离更新为更短的值,并且将w的前驱顶点从u改为x。图b展示了此时的状态。
下一步检查与v相邻的顶点。这一步没有对图做任何改动,因此我们继续检查顶点y。此时,我们发现经由y到达w和z的距离都更短,因此相应地调整它们的距离及前驱顶点。最后检查w和z,发现不需要做任何改动。由于优先级队列为空,因此退出。
非常重要的一点是,Dijkstra算法只适用于边的权重均为正的情况。如果图中有一条边的权重为负,那么Dijkstra算法永远不会退出。
除了Dijkstra算法,还有其他一些算法被用于寻找最短路径。Dijkstra算法的问题是需要有完整的图,这意味着每一个路由器都要知道整个互联网的路由器连接情况,而事实并非如此。Dijkstra算法的一些变体允许每个路由器在运行时才发现图,例如“距离向量”路由算法。

分析Dijkstra算法
来分析Dijkstra算法的时间复杂度。开始时,要将图中的每一个顶点都添加到优先级队列中,这个操作的时间复杂度是O(V)。优先级队列构建完成之后,while循环针对每一个顶点都执行一次,这是由于一开始所有顶点都被添加到优先级队列中,并且只在循环时才被移除。在循环内部,每次对delMin的调用都是O(logV)。综合起来考虑,循环和delMin调用的总时间复杂度是O(V logV)。for循环对图中的每一条边都执行一次,并且循环内部的decreaseKey调用为O(ElogV)。因此,总的时间复杂度为O((V+E)logV)。

Prim算法
考虑网络游戏设计师和互联网广播服务提供商面临的问题。他们希望高效地把信息传递给所有人。这在网络游戏中非常重要,因为所有玩家都可以据此知道其他玩家的最近位置。互联网广播也需要做到这一点,以让所有听众都接收到所需数据。下图展示了上述广播问题。
在这里插入图片描述
为了更好地理解上述问题,先来看看如何通过蛮力法求解。稍后会看到,Prim解决方案为何优于蛮力法。假设互联网广播服务提供商要向所有收听者播放一条消息,最简单的方法是保存一份包含所有收听者的列表,然后向每一个收听者单独发送消息。以上图为例,若采用上述解法,则每一条消息都需要有4份副本。假设使用开销最小的路径,让我们来看看每一个路由器需要处理多少次相同的消息。
从广播服务提供商发出的所有消息都会经过路由器A,因此A能够看到每一条消息的所有副本。路由器C只能看到一份副本,而由于路由器B和D在收听者1、2、3的最短路径上,因此它们能够看到每一条消息的3份副本。考虑到广播服务提供商每秒会发送数百条消息,这样做会导致流量剧增。
一种蛮力法是广播服务提供商针对每条消息只发送一份副本,然后由路由器来正确地发送。最简单的方法就是无控制泛滥法,策略如下:每一条消息都设有存活时间ttl,它大于或等于广播服务提供商和最远的收听者之间的距离;每一个路由器都接收到消息的一份副本,并且将消息发送给所有的相邻路由器。在消息被发送时,它的ttl递减,直到变为0。不难发现,无控制泛滥法产生的不必要消息比第一种方法更多。
解决广播问题的关键在于构建一棵权重最小的生成树。我们对最小生成树的正式定义如下:对于图G=(V, E),最小生成树T是E的无环子集,并且连接V 中的所有顶点。
下图展示了简化的广播图,并且突出显示了形成最小生成树的所有边。为了解决广播问题,广播服务提供商只需向网络中发送一条消息副本。每一个路由器向属于生成树的相邻路由器转发消息,其中不包括刚刚向它发送消息的路由器。在图7-32的例子中,A把消息转发给B, B把消息转发给C和D, D转发给E, E转发给F, F转发给G。每一个路由器都只看到任意消息的一份副本,并且所有的收听者都接收到了消息。
在这里插入图片描述
上述思路对应的算法叫作Prim算法。由于每一步都选择代价最小的下一步,因此Prim算法属于一种“贪婪算法”。在这个问题中,代价最小的下一步是选择权重最小的边。接下来实现Prim算法。
构建生成树的基本思想如下:
当T还不是生成树时,(a) 找到一条可以安全添加到树中的边;(b) 将新的边添加到T中。
难点在于,如何找到“可以安全添加到树中的边”。我们这样定义安全的边:它的一端是生成树中的顶点,另一端是还不在生成树中的顶点。这保证了构建的树不会出现循环。
Prim算法的Python实现如代码所示。与Dijkstra算法类似,Prim算法也使用了优先级队列来选择下一个添加到图中的顶点。
在这里插入图片描述
下图展示了将Prim算法应用于示例生成树的过程。以顶点A作为起点,将A到其他所有顶点的距离都初始化为无穷大。检查A的相邻顶点后,可以更新从A到B和C的距离,因为实际的距离小于无穷大。更新距离之后,B和C被移到优先级队列的头部。并且,它们的前驱顶点被设置为A。注意,我们还没有把B和C添加到生成树中。只有在从优先级队列中移除时,顶点才会被添加到生成树中。
在这里插入图片描述
由于到B的距离最短,因此接下来检查B的相邻顶点。检查后发现,可以更新D和E。接下来处理优先级队列中的下一个顶点C。与C相邻的唯一一个还在优先级队列中的顶点是F,因此更新到F的距离,并且调整F在优先级队列中的位置。
现在检查与D相邻的顶点,发现可以将到E的距离从6减少为4。修改距离的同时,把E的前驱顶点改为D,以此准备将E添加到生成树中的另一个位置。Prim算法正是通过这样的方式将每一个顶点都添加到生成树中。

猜你喜欢

转载自blog.csdn.net/yeqing1997/article/details/112910009
今日推荐