赞
踩
此前已经介绍过图的基本概念以及它在计算机中的表示和实现方法,这节转入图相关算法。实际上我们首先所介绍的与其说是图的算法不如说是构造一些列图算法的若干种典型的算法策略或框架。
这是一种什么样的框架?思路上依然是化繁为简
此前介绍如何处理半线性结构,也就是以树为典型代表的结构时,所采取的策略也是化繁为简——也就是说,希望基于此前相对而言更加研究有素的基本数据结构(比如序列),当时化繁为简最主要的手段就是遍历,通过遍历可以将任何一个半线性结构,顺利地转化为线性结构。具体来说,任何一棵二叉树都可以转换为相应的遍历序列。前者所对应的很多问题都可以对应的转化为后者。
~
那么在处理图这种非线性结构时,依然需要在宏观上借鉴这种思路——依然需要构建几种典型的遍历模式,完成从非线性结构到半线性结构的转换,也就是将任何一幅图转换为对应的树,或者严格讲支撑树。相应地,只要我们的搜索策略设计并且应用得当,与图对应的很多算法问题都可以相应地转化为树的算法问题。由此可见,从非线性结构到半线性结构,进而到线性结构,所采取地总体策略是一以贯之的。
因此这里最主要的问题,首先就是如何来设计并且构建这样相应的遍历算法。
当然在图中所进行的这样一种遍历过程,更多地体现为针对某种目标的查找过程,所以更加倾向称为搜索。
所谓的广度优先遍历过程,可以大致描述为如下一段自然语言。
具体步骤:
- 如果指定的起点是顶点S,那么这种搜索将首先访问S,图中通过将S染黑,表示它已经接受了访问。
- 接下来需要访问S所有尚未访问的邻接顶点,图中S具有若干个邻居,需要逐一枚举并且访问这些邻居,这里由S通往它的那些刚被访问的邻居的边都被加粗,这暗示着这些边都已经被算法所采纳和保留。这些边都是非常重要的,它们携带了整个遍历过程中所发现的一些信息。很快就会看到,这些边将构成原图的一个极大无环子图,通常情况下是一棵树或者是一个森林。反过来在原图中还会有一些边并不被采纳,比如所有刚被访问的节点之间有可能也有连边,但是经过广度优先遍历之后,它们将不再保留,而是被舍弃掉。
- 接下来关注点是刚被访问的这些新发现节点,将继续去枚举出它们各自的所有邻接顶点并且检查它们的状态,如果这些顶点中仍有尚未访问者,就轮到对它们进行访问了,也就是说,将通过刚刚被访问过的那些顶点通往它们的边找到它们,同样地,这些边中也有一部分被保留下来,而另外一些按照同样的原理不予保留。
- 再一次地在新发现的这些顶点之间也有可能连有某些边,如此反复,直至没有尚未被访问的邻接顶点。
上述过程可以看到,所谓广度优先搜索的确是一种遍历。
它会按照刚才所介绍的策略确定不同顶点接受访问的次序并且按照这种次序对各顶点逐个地访问。而整个搜索过程地最终产物或成果不过是选自原图地一系列边(加粗边),而原图中其余边(淡色细线条边)都将被忽略。
~
通过图可以看出取舍原则,也就是说,这里按照与起点S的距离将所有顶点划分为若干个等价类,在同一等价类内部,各顶点的边都不会被采纳。而只有连接于相邻等价类之间的某些边才会被采纳。
~
注意,同样是连接,相邻等价类顶点的边未必都会被采纳。
反过来注意到,所有被保留下来并且采纳的边,将足以把所有的顶点连接起来,构成一个连通图。同时它们之间也因为刚才的规则而不至于造成环路,也就是说他是一个极大无环图——这就是一棵树,树种涵盖了原图中所有顶点,称为支撑树。
另外,既然这样一个遍历过程可以将所有顶点划分为一个一个又一个等价类,而且这些等价类按照到起点S的距离是逐次单调变化的,因此它与我们此前所介绍的树的层次遍历有异曲同工之妙。
对于图的特例,也就是树而言,这样一个遍历过程,其实就是不折不扣的层次遍历,所以反过来也可以认为,所谓图的广度优先遍历,实际上就等同于树的层次遍历。后者(树)可以认为是前者(图)的一个特例,反过来前者(图)也是后者(树)的推广。
这样用自然语言描述的过程,如何严格地描述为算法,并且实现为具体代码呢?
描述图的广度优先遍历实现方式
既然图的广度优先遍历可以视作为树的层次遍历的一种推广,所以与后者相仿,这里依然借助一个队列结构也就不足为奇了。
- 每次都通过dequeue()取出队首的顶点并且重新命名为v;
请注意在每一个顶点刚刚出队并随即接受访问的同时,还需要给它打上一个时间标签dTime,在算法的入口处,还有一个名为clock的引用型参数,顾名思义,它就像是一块钟表,在整个算法运行过程中,它都会忠实地给出时间的进度,任何时候如果希望加注当前地时间标签,只需要将这块表取出并且读取上面的时刻。当然bfs只是图算法的一个基本框架,在不同的具体问题中,可能在这个时刻int v = Q.dequeue() 需要对当前的顶点做相应的处理,我们可以笼统地认为,这些操作就是我们所谓的对这个顶点的真正访问。
- 那么接下来按照算法的策略,需要枚举出当前节点v的所有邻居;
这可以通过for循环语句来实现,这里的firstNbr以及nextNbr接口——在此前介绍过它们的原理以及实现,可以看到通过这两个接口组合,可以简捷地从v的第一个邻居开始,不断地转向它的下一个邻居,直至所有邻居都被枚举完毕。
而每枚举出一个新的邻居u,都会根据u当前的状态,采取不同的处理方法,具体的处理方式下一步再做介绍。那么一旦v的所有邻居都被遍历并且处理完毕。v自己也将从刚才的discovered状态顺利地转化为visited状态。
由此可见,经过整个地遍历搜索过程,每一个顶点地状态都会由最初的undiscovered状态转化为discovered,并最终转化为visited。这样的三个状态也就构成了每一个顶点在它的生命期内的三部曲。那么总有一天这个队列会变空,整个广度优先搜索过程也就顺利结束。
请注意这里采取的原则是每当一个顶点入队,都会标记为三部曲中的第二个状态——discovered状态,所以处于undiscovered状态的顶点可以用白色来表示。使用黑色表示处于discovered状态的顶点。
总体效果意味着,从当前顶点V成功地发现并且访问了它的邻居U,因此从V通过U的那条边将被算法采纳并且保留下来,应该记得初始化时每一条边都会被设置为undetermined状态,而按照算法规则一旦发现某一条边应该被采纳,就将它设置为tree,也就是从undetermined转为tree状态。顾名思义,所谓的树边tree edge将会构成最终所需要构造的那棵遍历支撑树。
至此既然我们已经完全了解了邻居顶点的不同处理方法,整个算法介绍完毕。
请注意关于同一顶点的所有邻居并没有定义一个优先次序,实际上这并不是什么实质的问题,完全可以根据自己的偏好采用某种策略,甚至是随机的策略。
~
这里以它们的字母编号为序。
在所有的tree edge状态生成之后,S也就完成了它的历史使命,它会被标记为visited状态,也就是最终状态,图中顶点标记为双边框形式以示区别。
由算法原理及过程不难看出,与起始顶点s相连通的每一个顶点都迟早会被bfs搜索发现并访问,也就是说s顶点所属的连通域确实可以被系数遍历。然而问题是并不是每幅图都只包含一个连通域。
当包含多个连通域时,从任何一个起点s出发,未必能够抵达其他的连通域,这种情况如何处理呢?如何使得bfs搜索足以覆盖整幅图,而不是其中某一个连通域呢?不妨采用如下方法。
可以看到这里只不过是对刚才介绍的bfs算法做了一个while循环的封装。具体来说逐一检查图中的每一个顶点v,一旦发现新的顶点v仍然处于最初的undiscovered状态,就会随即启动依次源自顶点v的广度优先搜索。为以示区别,封装后的算法以小写bfs表示。
这样无论图中包含多少个连通域,总是能够在其中找到一个起始顶点并且启动对这个连通域的遍历。这样就顺利实现了对多个连通域的统一遍历。
当然尽管这个封装后的算法在功能上是毋庸置疑,但是难免会对其效率产生质疑。我们的质疑是有理由的,因为这里毕竟引入了一层新的循环。至少从表面看来这个循环将多达线性次。
然而这种担心是不必的,因为这里并非对每个顶点都启动一次bfs搜索,而是只有当前顶点能够进入这个if判断之后才启动bfs搜索。这种处理方式可以保证对于每一个连通域只有一个顶点可能作为起点引起它所属的那个连通域被完全的遍历掉。每个连通域启动而且只启动一次广度优先搜索。因此所有花费在搜索上的时间累计也不过对全图的一次遍历而不是多次。
回到BFS算法,它的复杂度是多少呢?这取决于你的不同实现方法,尤其是图结构自身的实现算法。以上述实现版本为例
由while和for构成的两层循环,第一个问题是while循环累计会执行多少步呢?
为此需要考察出队操作dequeue。不难验证,在整个算法的过程中,dequeue只出现一处,因此dequeue执行多少步整个while循环就会迭代多少步。在进入while循环之前,队列中只有一个顶点——起始点s,然而此后的入队操作确实不定的,在有些迭代步中可能会连续地执行多步enqueue操作,而在另一些迭代步中却有可能一步也没有执行。所幸的是我们可以发现每一个顶点都会入队一次而且仅一次。因此enqueue操作将累计操作线性次,所以相应的dequeue操作也将执行O(n)次,由此可知整个while循环累计执行恰好O(n)次。
然而内存for循环却非一目了然,为此我们不妨将它拆解为两部分,首先是for循行这条语句本身,其次再是进入这个循行后所执行的操作。
关于for循环本身需要回顾它的实现机制,起始就是对顶点v对应的那个行向量进行线性地扫描,具体来说,自后向前累计扫过n个单元,因此与外层地while循环组合起来,for循环累计需要执行的时间为n* n。所幸的是并不需要对每一个潜在的边都实质地进入一次这个内循环。实际上当前顶点v有多少个邻居,也就会实质地进入几次内循环,因此内循环地实质操作累计而言不过边的总数,也就是e,两项合计
n * n + e,边可以忽略掉。
由此可以得出结论:这样一个算法从渐进意义而言,需要执行n平方时间 O ( n 2 ) O(n^2) O(n2),这只具有理论上地意义,在实际中却远远不是这样。
背后的原因在于内存循环for语句本身所对应的那个渐进O(n)实际上是非常非常小的,至少在常系数意义上而言是这样,这可以从两方面加以验证。
首先这个for语句对行向量的操作都非常简单,无非就是逐一地取出其中的每一个元素并且判断是否为空,相对其他更为复杂的基本操作而言,这种基本操作更加的名副其实。
~
而更重要的第二个方面在于,组成每一个行向量的所有元素,不仅在逻辑上是连续的,而且在物理上也是连续的,构成一个紧凑的整体,这样一种物理上的组织和存储方式可以有效地激活系统的缓冲机制,换而言之,在对整个行向量的访问过程中,所有的元素都有极高的概率处于高速缓存中。
~
此后介绍B树时,我们将会指出,任何一级存储,相对于它的高速缓存而言,在访问速度上的差异将高达5-6个数量级,在实际效果上的这样一种极大差异完全足以抵消理论上的分析结论。
因此完全可以将O(n)忽略掉,并代之以常数,因此这样实现的BFS算法实际运行性能更贴近于O(n + e)。 当然如果将邻接矩阵改为邻接表则可以直接达到这样一个效率。
可以说这样一种结果应该已经是不能指望更好的了。
原因在于,既然是要遍历,至少需要对n个顶点分别访问一次,也至少需要对每一条边访问一次,当然这样一种结论也是至关重要的,因为正如之前所介绍的,无论是BFS以及后面的DFS和PFS,所有遍历算法实际上都是后面更为具体也更为复杂的算法的一种基本实现框架,作为所有算法的基本框架,它能够达到如此低廉的成本,对于算法设计者而言,已经是不能再好的消息了。
总结如下图
最后讨论下BFS非常有趣也最为本质特性,所谓的最短距离性。
回顾此前所介绍的树结构,相对于树根节点,任何一个节点v都对应于唯一通路,这条路径长度称为路径深度,然而可以自上而下按照它们的深度,进行等价类划分,在每一个等价类的有所顶点,所具有的深度指标都是彼此相等的。而树的层次遍历也可以按照这一指标非降的次序,将所有顶点逐一枚举出来。
那么这样一个遍历过程是否也可以转化为图结构的遍历过程呢?
表面看来似乎不太容易,因为此时与树结构极不相同的就是从起始顶点S出发可能有多条路径都最后通往同一个顶点而且可能出现分叉,然而这样一个问题不难解决。
~
实际上只需考查顶点之间的最短通路并且将这两个顶点之间的距离取作这条最短通路的长度dist(v,s)。而在起始顶点相对固定的情况下,甚至可以将S在这个记号中省略,直接简称顶点v所对应的距离。巧合的是图的BFS搜索与树的层次遍历一样都具有这样一种单调性,也就是说BFS所给出的顶点序列,按照这样到起点的距离,也是按照非降次单调排列,所有顶点被发现并访问的过程,从起点S出发,所有的顶点按照它们到起点S的距离,成批地被发现并进而接受访问,直到最终所有地顶点都被访问完毕。
在最终所生成地BFS树中,每个顶点与S之间地那条通路,恰好就是在原图中这两个顶点之间的那条最短通路。
介绍下与广度优先搜索完全对称的另一种搜索深度优先搜索,深度优先搜索的算法策略更为简明,然而有趣的是深度优先搜索的过程更为复杂,其功能也相对而言更为强大。因此也成为有效解决很多实际问题的基本算法框架。
起始于某一顶点s的深度优先搜索过程可以简明描述为:
上图算法过程如下:
- 在任何一幅图中,只需确定一个搜索起点,然后按照刚才描述的策略,只需要找到它的一个邻居。
请注意,当前顶点可能还有其他邻居,但是这个算法并不需要去考虑它们,而只是任选其一并且将控制权交给这个新的顶点。
- 接下来,新的顶点一旦接过控制权,它也会仿效这种策略,在它的邻居中任选其一并且将控制权交给这个尚未访问的邻居。
- 再一次地,最新的这个顶点依然会效仿这种策略,在它的尚未访问的邻居中任选其一,并将控制权再交给这个邻居。
- 同样地,这个新的顶点也会去扫描它的所有邻居,并试图找到一个尚未访问的,当然如果有访问过的,对应这条边将不会被采用,而是以某种适当的形式加以标注。
稍后会对标注方式做详细的说明。这也是这个算法至关重要的方面。
- 以下假设这个顶点已经没有任何邻居尚未访问,那么按照算法策略,将在这个位置返回,也就是回溯,顺着此前的通路回到它的前驱顶点,同样,如果依然没有尚未访问的邻居,也需要在这个顶点处继续返回。
- 假设在这个顶点处至少还有一个尚未被访问的邻居,就将控制权转交给它,以下过程类似。
最终整幅图遍历完毕,可以看到遍历效果与此前的BFS类似,我们依然会得到一棵DFS树,也就是这些粗边所构成原图的一棵支撑树。而且同样地未被这棵树所采纳的那些边也同样会被分类,而且这种分类会更为细致。
那么这样一个遍历和递归过程任何明确地兑现为具体算法和代码呢?
给出DFS一种可能的实现方式,可以看到,起自顶点v的DFS算法,从形式和接口看与BFS算法完全一样。
~~~~~~~~ 需要指出的是与BFS不同这里可能含有递归。
~~~~~~~~ 另外不同的在于,还需要将此时的时钟也记录下来,请注意,这是每个顶点的另一个时间标签fTime——这个节点被访问完毕的时刻。
那么针对邻接顶点的不同状态,究竟可以分为几种情况分别处理?每一种处理方式又是如何?以及为什么要这样处理?还有整体的处理效率如何?
以下给出对于不同邻接节点的处理方法,可以看到无非三种情况:
整个算法至此已经封闭。
在此首先需要准备一张表,其中每一行分别对应图中某一顶点,可以看到每一行有三列,分别是这个顶点标识,也就是对应的字母,以及它的dTime和fTime两个时间标签。
相对于无向图有向图的深度优先搜索要更为复杂,所涉及的情况也更多。
图中将每一个顶点都绘制成长方形,顶点的标识居中,在它的左右空白处将分别记录下它在遍历过程中所获得的dTime和fTime两个时间标签。
立足于顶点G,也同样要去环顾四周,逐一去考察它的邻居们,可以看到第一个邻居是顶点a,请注意,此时应该属于算法的第二种情况,这个邻居当前处于第二种状态,也就是discovered。根据算法规则,应该将它们之间的有向边归类为back word——后向边或者回边。等效于遍历树中试图从一个后代去回连到它的祖先,所以称为back word是在形象不过了。
同样地,一旦出现backword边,也就同时意味着发现了至少一条回路。
纵观整个过程,总共用了10s,遍历完了由abcfg这五个顶点所构成的子图。更确切的讲,它们构成了在这个图中从顶点a出发可以达到的区域,可称作可达区域。
记得对广度优先算法BFS处理手法,就不难发现,完全可以效仿那种做法,在这样的dfs算法之外,在包装一层循环,来枚举图中的所有顶点,这样就可以无一遗漏地遍历图中所有顶点。而且只要我们处理得当,对所有可达域的遍历都不会彼此有所重叠,从而在时间效率上,也依然可以得到保证。
从效果上看,经过上述封装以后,或许应该从顶点D出发,也就是在第11s发现顶点d。
盘点下遍历获得的成果:
然而相对原图,它们毕竟只不过是一个子集,这样一个子集所携带的难道是原图的所有信息吗?从某种意义上讲的确是这样的,而其中至关重要的点在于,通过遍历不仅获得这样一棵树,而且为每个顶点都标记了dTime和fTime两类时间标签,而这两类时间标签的作用是非常巨大的,作用之大远远超过直观想象。
通过dfs搜索,为所有顶点所标注的两个时间标签实际上蕴含了大量有用信息,这一点可以由括号引理或嵌套引理来加以印证。
为此首先引入活动期概念,也就是由它的dTime和fTime两个时间标签所界定的那样一段时间范围,也就是这个顶点在整个dfs搜索过程中处于活跃状态的时间范围。
所谓嵌套引理 告诉我们任何有向图经过了dfs搜索后,在对应的dfs森林或者dfs树中,任何一对顶点之间,存在直系血缘关系,当且仅当它们的活跃期存在包含与被包含关系,具体来说,祖先的活跃期必然包含后代的活跃期。
~
而反过来,如果两个顶点之间没有任何直系血缘关系,那么它们的活跃期也是彼此互不相交的。
为了获得对这个引理更为直观认识,不妨以横向作为时间轴,依然以上述有向图为例,可以将每个顶点以水平方向适当展开,使得它们恰好覆盖各自所对应的活跃期。于是就不难看出,作为祖先,它的活跃期的确可以覆盖它的后代,而后代也将继续覆盖它的后代,以致后代的后代。而反过来,没有直接血缘关系的节点,如F和B、B和G、D和F、B和E,它们的活跃期的确彼此不搭,没有任何公共部分。
这样一个特性是非常强大的工具,比如在算法中,经常要做的判断就是任意的一对顶点v和u之间在遍历树中是否存在一个直系血缘关系,如果没有这样一种渐变的机制,我们将不得不从u出发顺着parent引用不断地溯流而上,直到有一天遇到顶点v,才能够确定,它们的确存在祖先和后代的直系关系。或者不得不追溯到遍历的起点,从而断定u和v之间并没有直系血缘关系。
~
而现在呢,借助时间标签,可以快速准确地在O(1)的时间内就得出相应的结论。这种机制所带来的更多遍历之处,在后续算法中不断地深入体会。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。