图论算法

图的基础

一个图(graph)G = (V,E)由顶点(vertex)集V和边(edge)集E组成。每一条边就是一个点对(v,w),v,w∈V。有时也把边叫做弧(arc)。如果点对是有序的,那么图叫做有向的(directed),有向的图叫做有向图(digraph)。当且仅当(v,w)∈E时,称顶点v和w邻接(adjacent)

图中的一条路径(path)是一个顶点序列w1,w2,w3,...,wN,其中(w i,w i+1)∈E,1<=i<N。这样一条路径的长(length)是该路径上的边数,它等于N-1。从一个顶点到它自身可以看成是一条路径;如果路径不包含边,那么路径的长为0

如果图含有一条从一个顶点到它自身的边(v,v),那么路径v,v有时也叫作一个环(loop)

简单路径是指一条路径上所有的顶点都是互异的,但第一个顶点和最后一个顶点可能相同

有向图的圈(cycle)是满足w1 = wN且长至少为1的一条路径;如果该路径是简单路径,那么这个圈就是简单圈。如果一个有向图没有圈,则称其为无圈的(acyclic),一个有向无圈图简称为DAG

如果在一个无向图中从每一个顶点到其他所有顶点都存在一条路径,则称无向图联通的(connected)。具有这样性质的有向图强联通的(strongly connected)。如果一个有向图不是强联通的,那么该有向图称为是弱连通的(weakly connected)。完全图(complete graph)是其每一对顶点间都存在一条边的图



图的表示

表示图一种简单的方法是使用一个二维数组,称为邻接矩阵(adjacency matrix)表示法。对于每条边(u,v),我们置A[u][v] = 1;否则为0。如果边有权,可以置A[u][v]等于该权,并使用一个很大或很小的权表示边不存在。这种表示方法的优点是简单,但空间需求为Θ(|V|^2),如果图的边不是很多,那么表示的代价就太大了。如果图是稠密的(dense):|E| = Θ(|V|^2),则邻接矩阵是合适的表示方法

如果图是稀疏的(sparse),则更好的表示方法是邻接表(adjacency list)。对每个顶点,使用一个链表存放所有的邻接顶点,此时的空间需求为O(|E| + |V|)。邻接表是表示图的标准方法。无向图可以类似地表示,不过每条边出现在两个表中,因此空间的使用基本上是双倍的

在大部分应用中,顶点都使用名字而非数字,而这些名字在编译时是未知的。由于我们不能通过未知名字为一个数组做索引,因此我们必须提供名字到数字的映射,完成这项工作最容易的方法是使用散列表



拓扑排序

拓扑排序是对有向无圈图的顶点的一种排序,它使得如果存在一条从vi到vj的路径,那么在排序中vj出现在vi后面。显然,如果图含有圈,那么拓扑排序是不可能的

一个简单的拓扑排序算法是先找出任意一个没有入边的顶点,然后显示出该顶点,并将它和它的边一起从图中删除。然后,我们对图的其余部分应用同样的方法处理。我们把顶点v的入度(indegree)定义为边(u,v)的条数

比较高效的算法是,首先对每一个顶点计算它的入度;然后,将所有入度为0的顶点放入一个初始为空的队列中。当队列非空时,删除一个顶点v(dequeue),并将与v邻接的所有顶点的入度减1。只要一个顶点的入度降为0,就把该顶点放入队列中。此时,拓扑排序就是顶点出队的顺序

图论算法_第1张图片              

图论算法_第2张图片

如果使用邻接表,执行这个算法所用的时间为O(|E| + |V|),因为for循环对每条边最多执行一次。而队列操作对每个顶点也最多进行一次,初始化花费的时间也和图的大小成正比



最短路径算法

对一个赋权图,与每条边(vi,vj)相联系的是穿越该弧的代价(或称为值)c i,j。一条路径v1,v2,...,vN的值是∑ c i,i+1(i = 1 ~ N-1),叫做赋权路径长(weighted path length)。而无权路径长(unweighted path length)只是路径上的边数,即N - 1

单源最短路径问题:给定一个赋权图G = (V,E)和一个特定顶点s作为输入,找出从s到G中每一个其他顶点的最短赋权路径

当边存在负值时,最短路径可能小于零,这个循环叫做负值圈(negative-cost cycle)。当它出现时,最短路径问题就是不确定的


无权最短路径

我们可以找出所有距离s为1的顶点,然后再根据这些顶点寻找距离为2的顶点,以此类推则可以找出通向所有定点的路径长

这种搜索一个图的方法称为广度优先搜索(breadth-first search,BFS)。该方法按层处理顶点:距开始点最近的那些顶点被首先赋值,而最远的那些顶点最后被赋值。这很像对树的程序遍历(level-order traversal)

可以通过队列简化广度优先搜索。在迭代开始时,队列之含有CurrDist的顶点;当添加距离为CurrDist+1的邻接顶点时,由于它们从队尾入队,因此就保证它们在所有距离为CurrDist的顶点都被处理完成后才被处理。如果某些顶点从开始节点出发是不可能到达的,那么队列可能会过早地变空。在这种情况下,将对这些节点报出infinity距离。只要使用邻接表,运行时间就是O(|E|+|V|)

图论算法_第3张图片图论算法_第4张图片



Dijkstra算法

可以用来求解赋权图的最短路径。对每一个顶点保留一个临时距离d v,这个距离实际上是使用已知顶点作为中间顶点从s到v的最短路径长。Dijkstra算法像无权最短路径算法一样,按阶段进行。在每个阶段,Dijkstra算法选择一个顶点v,它在所有未知顶点中具有最小的d v,同时算法声明从s到v的最短路径是已知的。阶段的其余部分由d w值的更新工作组成

 图论算法_第5张图片

图论算法_第6张图片

每个被邻接到的顶点被设为已知;后面它再出现时,若新的路径长比原来的短,则更新d并更改p。9-31可以显示出路径,9-32列出了主要算法,它是一个使用贪婪选取法则填表的for循环

 图论算法_第7张图片


只要没有边的值为负,该算法总能顺利完成;如果任何一边出现负值,则算法可能得出错误的答案。如果通过使用扫描表来找出最小值d v,那么每一步将花费O(|V|)时间,从而整个算法过程将花费O(|V|^2)时间查找最小值。每次更新d w的时间是常数,而每条边最多有一次更新,总计为O(|E|)。因此,总的运行时间为O(|E|+|V|^2)=O(|V|^2)。如果图是稠密的,边数|E|=Θ(|V|^2),则该算法不仅简单而且基本上最优,因为运行时间与边数成线性关系。如果图是稀疏的,边数|E|=Θ(|V|),那么这种算法就太慢了。这种情况下,距离要存储在优先队列中。第2行与第5行联合形成一个DeleteMin操作;第9行有两种实现方法:

一是把更新处理成DecreaseKey操作,此时查找最小值的时间为O(log|V|),即为执行那些更新的时间(DecreaseKey操作),由此得出的运行时间为O(|E| log |V| + |V| log |V|)=O(|E| log |V|)。由于优先队列不能有效地支持Find操作,因此d i的每个值在优先队列的位置需要保留并当d i在优先队列中改变时更新。如果优先队列是用二叉堆实现的,那么这将很难办;如果使用配对堆(pairing heap),则程序不会太差

另一种方法是在每次执行第9行时把w和新值d w插入到优先队列中去。这样,在优先队列中的每个顶点就可能有多余一个的代表。当DeleteMin操作把最小的顶点从优先队列中删除时,必须检查以肯定它不是已经知道的。这样第2行变成一个循环,它执行DeleteMin直到一个未知的顶点合并为止。这种方法虽然从软件的观点看是优越的,而且变成容易得多,但是队列的大小可能达到|E|这么大。由于|E|<=|V|^2意味着log |E| <=2 log |V|,因此这并不影响渐进时间界。这样,我们仍然得到一个O(|E| log |V|)算法。不过,空间需求增加了,在某些应用中这可能是严重的。不仅如此,因为该方法需要|E|次,而不仅仅是|V|次DeleteMin,所以在实践中可能要慢


如果知道图是无圈的,那么我们可以通过改变声明顶点为已知的顺序,或者叫做顶点选取法则来改进Dijkstra算法。新法则以拓扑顺序选择顶点,由于选择和更新可以在拓扑排序执行的时候进行,因此算法能够一趟完成。因为当一个顶点v被选取以后,按照拓扑排序的法则,它没有从未知顶点发出的进入边,因此它的距离d v可以不再被降低,所以这种选择法则是可行的。使用这种选择法则不需要优先队列;由于选择常数时间,因此运行时间为O(|E|+|V|)

无圈图的一个更重要的用途是关键路径分析法(critical path analysis)。每个节点表示一个必须执行的动作以及完成动作所花费的时间。因此,该图叫做动作节点图(activity-node graph)。图中的边代表优先关系:一条边(v,w)意味着动作v必须在动作w开始前完成

为了进行运算,我们把动作节点图转换成事件节点图(event-node graph)。哑边和哑节点可能需要插入到一个动作依赖于几个其他动作的地方,为了避免引进假相关性(或相关性的短缺),这么做是必要的

图论算法_第8张图片

为了找出方案的最早完成时间,我们只要找出从第一个事件到最后一个事件的最长路径的长。由于事件节点图是无圈图,因此我们不必担心圈的问题。此时,可以采纳最短路径算法计算图中所有节点最早完成时间。如果EC i是节点i的最早完成时间,那么可用的法则为:

EC 1 = 0

EC w = max (EC v + c v,w)    (v, w)∈E

我们还可以计算每个事件能够完成而不影响最后完成时间的最晚时间LC i。进行这项工作的公式为:

LC n = EC n

LC v = min (LC w - c v,w)    (v,w)∈E

时间节点图中每条边的松弛时间(slack time)代表对应动作可以被延迟而不推迟整体的完成时间量:Slack(v, w) = LC w - EC v - c v,w

某些动作的松弛时间为0,这些动作是关键性动作,它们必须按计划结束。至少存在一条完全由0-松弛边的路径,这样的路径是关键路径(critical path)



网络流问题

给定容量为c v,w的有向图G=(V,E),有两个顶点,s称为发点(source),t称为收点(sink)。对于任意一条边(v,w),最多有“流”的c v,w个单位可以通过。在既不是发点s又不是收点t的任意顶点v,总的进入的流必须等于总的发出的流。最大流问题就是确定从s到t可以通过的最大流量。我们从图G开始并构造一个流图Gf,表示在算法的任一阶段已经到达的流。开始时Gf的所有边都没有流,我们希望当算法终止时Gf包含最大流。我们构造另一个图Gr,称为残余图(residual graph),表示对于每条边还能再添加多少流。Gr的边叫做残余边(residual edge)。在每个阶段,我们寻找图Gr中从s到t的一条路径,这条路径叫做增长通路(augmenting path)。这条路径上的最小值边就是可以添加到路径每一边上的流的量。我们通过调整Gf和重新计算Gr做到这一点。一旦注满(使饱和)一条边,则这条边就要从Gr中除去。当发现在Gr中没有从s到t的路径时算法终止。这个算法是不确定的。

但是这个算法不能保证总能找到最大流,为此我们需要让算法能够改变它的意向。为此,对于流图中具有流f v,w的每一边(v,w),我们将在Gr中添加一条容量为f v,w的边(w,v)。事实上,我们可以通过以相反的方向发回一个流而使算法改变它的意向。最终结果如下:

图论算法_第9张图片

在这个图中没有增长通路,因此,算法终止。可以证明,如果边的容量都是有理数,那么该算法总以最大流终止。如果容量都是整数且最大流为f,那么,由于每条增长通路使流的值至少增加1,所以f个阶段足够,从而总的运行时间为O(f * |E|),因为无权最短路径算法一条增长通路可以以O(|E|)时间找到。但这个运行时间可能并不好,如下图



最小生成树(minimum spanning tree)

一个无向图G的最小生成树就是由连接G所有顶点的边构成的树,并且总的权值最低。当且仅当G是联通的时最小生成树才是存在的。在最小生成树中,边的条数是|V| - 1。因为无圈,所以最小生成树是一棵树;因为包含每一个顶点,所以它是生成树;此外,它是包含图的所有顶点的最小的树。介绍两种算法,区别在于最小值的边的选取上

图论算法_第10张图片


Prim算法

通过逐步增长的方式形成一个生成树,在每一步都把一个节点当作根并往上加边。算法在每一阶段都可以通过选择边(u,v),使得(u,v)的值是所有u在树上但v不在树上的边的值中最小的,然后将v添加到树中。Prim算法和求最短路径的Dijkstra算法类似,不过它是在无向图上运行的,因此当编写代码时要把每一条边都放到两个邻接表中。不用堆时的运行时间为O(|V|^2),它对于稠密的图来说是最优的;使用二叉堆的运行时间是O(|E| log |V|),对稀疏的图它是一个好的界

图论算法_第11张图片


Kruskal算法

第二种贪婪策略是连续地按照最小的权选择,并且当所选的边不产生圈时把它作为取定的边。当添加到森林中的边足够多时算法终止。实际上,算法就是要决定边(u,v)应该添加还是放弃。我们用到的一个恒定的事实是,在算法实施的任意时刻,当且仅当两个顶点在当前的生成森林中联通时它们才属于同一个集合;每个顶点最初是在它自己的集合中。如果u和v在同一个集合中,那么连接它们的边就要放弃,因为它们已经连通了。如果这两个顶点不在同一个集合中,则将该边加入,并对包含顶点u和v的这两个集合进行一次合并。

图论算法_第12张图片

固然,将边排序可便于选取,不过,用线性时间建立一个堆是更好的选择。在某些情况下把优先队列实现成指向边的指针数组更高效,这样避免了大量记录的移动。

图论算法_第13张图片

该算法最坏运行时间为O(|E| log |E|),它受堆操作控制。注意,由于|E| = O(|V|^2),因此这个运行时间实际上是O(|E| log |V|)。在实践中,该算法要比这个时间界快得多



深度优先搜索的应用

深度优先搜索(depth-first search)是对先序遍历的推广,我们从某个顶点v开始处理v,然后递归地遍历所有与v邻接的顶点。如果图是无向的且不连通,或是有向的但非强联通,这种方法可能会访问不到某些节点。此时,我们搜索一个未被标记的节点,然后应用深度优先遍历,并继续这个过程直到不存在未标记的节点为止。因为每条边只访问一次,所以只要使用邻接表,则执行遍历的总时间就是O(|E| + |V|)

图论算法_第14张图片


无向图

假设无向图是联通的(如果不连通,也可以找出所有联通分支并将算法依次应用于每个分支)。下图是深度优先搜索模板,只是进行搜索,此外什么都不做

图论算法_第15张图片

我们用图形描述生成深度优先生成树的步骤。该树的根是A,是第一个被访问到的顶点。图中的每一条边(v,w)都出现在树上。如果当我们处理(v,w)时发现w是未被标记的,或当我们处理(w,v)时发现v是未被标记的,那么我们就用树的一条边表示它(这里(v,w)是严格的从前到后(A-Z)的顺序)。如果当我们处理(v,w)时发现w已被标记,并且在处理(w,v)时发现v也已被标记,那么我们就画一条虚线,称为背向边(back edge),表示这条“边”实际不是树的一部分(但这两个顶点可以彼此到达)

如果树不是联通的,那么所有的节点(和边)需要多次调用Dfs,每次都生成一棵树,整个集合就是深度优先生成森林(depth-first spanning forest)


双连通性

如果一个联通的无向图中的任意顶点删除后,剩下的图仍然联通,那么这样的无向图就成为是双联通的(biconnected)。如果一个图不是双联通的,那么,被删除后图将不再联通的那些顶点叫做割点(articulation point)。

图论算法_第16张图片

深度优先搜索提供一种找出连通图所有割点的线性时间算法。首先,从图中任意顶点开始,执行深度优先搜索并在顶点被访问时给它们编号。对于每一个顶点v我们称其先序编号为Num(v)(就是编号)。然后,对深度优先搜索生成树上的每一个顶点v,从它开始,通过树的零条或多条边,且可能还(仅)有一条背向边(以该序)达到的顶点中,编号最低的,称为Low(v)。我们可以通过对深度优先生成树执行一次后序遍历算出Low。根据Low的定义可知Low(v)是:

1. Num(v)  2. 所有背向边(v,w)中的最小Num(w)  3. 树的所有边(v,w)中的最低Low(w)   这三者中的最小者

第一个条件是不选取边,第二个条件是选取一条背向边,第三个条件是选择树的某些边以及可能还有一条背向边。第三种方法可用一个递归调用简明地描述。由于需要对v的所有儿子计算出Low值后才能计算Low(v),因此这是一个后序遍历。对于任一条边(v,w),我们只要检查Num(v)和Num(w)就可以知道它是树的一条边还是一条背向边(背向边(v,w)的Num(v) > Num(w))。因此,Low(v)很容易计算:我们仅需扫描v的邻接表,应用适当的法则并记住最小值。所有的计算花费O(|E| + |V|)时间

图论算法_第17张图片

然后要做的就是利用这些信息找出割点。当且仅当根有多于一个儿子时它才是割点,因为删除根会使得节点分布在不同的子树上。对于其他顶点v,当且仅当它的某个儿子w的Low(w) >= Num(v)时它才是割点。注意,这个条件根是满足的,所以需要对根进行特别的测试

该算法可以通过执行一次先序遍历计算Num,然后执行一趟后序遍历计算Low来实现。第三趟遍历可以用来检验哪些顶点满足割点的标准。然而,执行三趟遍历是一种浪费,由于第二趟和第三趟都是后序遍历,因此可以一次实现。图9-66第8行处理一个特殊的情况:如果w邻接到v,那么递归调用w将发现v邻接到w。这不是一条背向边,而是一条已经考虑过且需要忽略的边

图论算法_第18张图片

图论算法_第19张图片



欧拉回路

一笔画成一个图,附加问题是结束时回到起点。转化成图论,则是在图中找出一条路径,使得该路径对图的每条边恰好访问一次。如果要解决附加问题,就必须找到一个圈,该圈恰好经过每条边一次。这种问题叫做欧拉路径(Euler path)或欧拉环游(Euler tour)或欧拉环路(Euler circuit)问题

当且仅当每个顶点的度(即边的条数)是偶数时,终点与起点相同的欧拉回路才有可能存在。如果恰好有两个顶点的度是奇数,仍然有可能得到一个欧拉环游。这里,欧拉环游是必须访问图的每一边但最后不一定必须回到起点的路径。如果奇数度的顶点多于两个,那么欧拉环游也是不可能存在的。所有顶点的度均为偶数的任何连通图必然有欧拉回路,我们可以以线性时间找出这样一条回路,基本算法就是执行一次深度优先搜索

可能出现的问题是,可能只访问了图的一部分而提前返回起点。最容易的补救方法是找出有未访问边的路径上的第一个顶点,并执行另一次深度优先搜索,而这将得到另一个回路。把这个回路拼接到原来的回路上,继续该过程直到所有的边都被遍历

为使拼接简单,应该把路径作为一个链表保留。为避免重复扫描邻接表,对每一个邻接表我们必须保留一个指向最后扫描到的边的指针。当拼接进一个路径时,必须从拼接点开始搜索新顶点。使用适当的数据结构,算法的运行时间为O(|E| + |V|)


有向图

与无向图类似,可以通过深度优先搜索以线性时间遍历有向图。如果图不是强联通的,那么从某个节点开始的深度优先搜索可能访问不了所有的节点,这种情况下我们在某个未标记的节点处开始,反复执行深度优先搜索,直到所有的节点都被访问

       图论算法_第20张图片


深度优先生成森林中虚线箭头是一些(v,w)边,其中w在之前已经做了标记(在无向图中是背向边)。存在三种类型的边不通向新的起点:一些背向边,如(A,B)(I,H);一些前向边(forward edge)如(C,D)和(C,E),它们从树的一个节点通向一个后裔;最后是一些交叉边,如(F,C)和(G,F),它们把不直接相关的两个树节点连接起来

深度优先搜索的一个用途是检测一个有向图是否是无圈图,法则如下:当且仅当一个有向图没有背向边时,它是无圈图


查找强分支

通过执行两次深度优先搜索,我们可以检测一个有向图是否是强联通的,如果不是,我们实际可以得到顶点的一些子集,它们到其自身是强联通的

首先,在输入的图G上执行一次深度优先搜索得到深度优先生成森林,然后对其进行后序遍历将G的顶点编号,再把G的所有边反向,形成Gr。该算法通过对Gr执行一次深度优先搜索而完成,总是在编号最高的顶点开始新的深度优先搜索,得到的深度优先生成森林中的每一棵树形成一个强联通的分支

图论算法_第21张图片



NP-完全性介绍

计算机不可能解决每一个问题,这些“不可能”解出的问题叫做不可判定问题(undecidable problem)

NP类在难度上逊于不可判定问题的类。NP代表非确定型多项式时间(non-deterministic polynomial time)。检验一个问题是否属于NP的简单方法是用“是/否(yes/no)问题”的语言描述该问题。如果我们在多项式时间内能够证明一个问题的任意“是”的示例是正确的,那么该问题属于NP类

NP-完全(complete)问题是属于NP的所有问题的一个子集,它包含了NP中最难的问题。NP-完全问题有一个性质,即NP中的任意一个问题都能够多项式地约成NP-完全问题。NP-完全问题是最难的NP问题的原因在于,一个NP-完全的问题基本可以用作NP中任何问题的子程序,其花费只不过是多项式的开销量


你可能感兴趣的:(图论算法)