【数据结构(邓俊辉)学习笔记】图02——广度优先搜索和深度优先搜索

0. 概述

此前已经介绍过图的基本概念以及它在计算机中的表示和实现方法,这节转入图相关算法。实际上我们首先所介绍的与其说是图的算法不如说是构造一些列图算法的若干种典型的算法策略或框架。

这是一种什么样的框架?思路上依然是化繁为简

此前介绍如何处理半线性结构,也就是以树为典型代表的结构时,所采取的策略也是化繁为简——也就是说,希望基于此前相对而言更加研究有素的基本数据结构(比如序列),当时化繁为简最主要的手段就是遍历,通过遍历可以将任何一个半线性结构,顺利地转化为线性结构。具体来说,任何一棵二叉树都可以转换为相应的遍历序列。前者所对应的很多问题都可以对应的转化为后者。
  ~  
那么在处理图这种非线性结构时,依然需要在宏观上借鉴这种思路——依然需要构建几种典型的遍历模式,完成从非线性结构到半线性结构的转换,也就是将任何一幅图转换为对应的树,或者严格讲支撑树。相应地,只要我们的搜索策略设计并且应用得当,与图对应的很多算法问题都可以相应地转化为树的算法问题。由此可见,从非线性结构到半线性结构,进而到线性结构,所采取地总体策略是一以贯之的。

因此这里最主要的问题,首先就是如何来设计并且构建这样相应的遍历算法。

当然在图中所进行的这样一种遍历过程,更多地体现为针对某种目标的查找过程,所以更加倾向称为搜索。

1. 广度优先搜索

所谓的广度优先遍历过程,可以大致描述为如下一段自然语言。
在这里插入图片描述
具体步骤:

  1. 如果指定的起点是顶点S,那么这种搜索将首先访问S,图中通过将S染黑,表示它已经接受了访问。
  2. 接下来需要访问S所有尚未访问的邻接顶点,图中S具有若干个邻居,需要逐一枚举并且访问这些邻居,这里由S通往它的那些刚被访问的邻居的边都被加粗,这暗示着这些边都已经被算法所采纳和保留。这些边都是非常重要的,它们携带了整个遍历过程中所发现的一些信息。很快就会看到,这些边将构成原图的一个极大无环子图,通常情况下是一棵树或者是一个森林。反过来在原图中还会有一些边并不被采纳,比如所有刚被访问的节点之间有可能也有连边,但是经过广度优先遍历之后,它们将不再保留,而是被舍弃掉。
  3. 接下来关注点是刚被访问的这些新发现节点,将继续去枚举出它们各自的所有邻接顶点并且检查它们的状态,如果这些顶点中仍有尚未访问者,就轮到对它们进行访问了,也就是说,将通过刚刚被访问过的那些顶点通往它们的边找到它们,同样地,这些边中也有一部分被保留下来,而另外一些按照同样的原理不予保留。
  4. 再一次地在新发现的这些顶点之间也有可能连有某些边,如此反复,直至没有尚未被访问的邻接顶点。

1.1 策略

上述过程可以看到,所谓广度优先搜索的确是一种遍历。

它会按照刚才所介绍的策略确定不同顶点接受访问的次序并且按照这种次序对各顶点逐个地访问。而整个搜索过程地最终产物或成果不过是选自原图地一系列边(加粗边),而原图中其余边(淡色细线条边)都将被忽略。
  ~  
通过图可以看出取舍原则,也就是说,这里按照与起点S的距离将所有顶点划分为若干个等价类,在同一等价类内部,各顶点的边都不会被采纳。而只有连接于相邻等价类之间的某些边才会被采纳。
  ~  
注意,同样是连接,相邻等价类顶点的边未必都会被采纳

反过来注意到,所有被保留下来并且采纳的边,将足以把所有的顶点连接起来,构成一个连通图。同时它们之间也因为刚才的规则而不至于造成环路,也就是说他是一个极大无环图——这就是一棵树,树种涵盖了原图中所有顶点,称为支撑树

另外,既然这样一个遍历过程可以将所有顶点划分为一个一个又一个等价类,而且这些等价类按照到起点S的距离是逐次单调变化的,因此它与我们此前所介绍的树的层次遍历有异曲同工之妙。

对于图的特例,也就是树而言,这样一个遍历过程,其实就是不折不扣的层次遍历,所以反过来也可以认为,所谓图的广度优先遍历,实际上就等同于树的层次遍历。后者(树)可以认为是前者(图)的一个特例,反过来前者(图)也是后者(树)的推广。

这样用自然语言描述的过程,如何严格地描述为算法,并且实现为具体代码呢?

1.2 实现

描述图的广度优先遍历实现方式
在这里插入图片描述

  1. 可以看到,遍历的起点总是某个预先指定的顶点v。

既然图的广度优先遍历可以视作为树的层次遍历的一种推广,所以与后者相仿,这里依然借助一个队列结构也就不足为奇了。

  1. 预处理的手法也是相仿的,首先需要令当前的这个顶点v入队,如果说有所不同的话,那么这里需要在v入队之前,将它的状态由最初的undiscovered转化为discovered,也就是刚被发现。
  2. 接下来是一个while循环 :
  1. 每次都通过dequeue()取出队首的顶点并且重新命名为v;

请注意在每一个顶点刚刚出队并随即接受访问的同时,还需要给它打上一个时间标签dTime,在算法的入口处,还有一个名为clock的引用型参数,顾名思义,它就像是一块钟表,在整个算法运行过程中,它都会忠实地给出时间的进度,任何时候如果希望加注当前地时间标签,只需要将这块表取出并且读取上面的时刻。当然bfs只是图算法的一个基本框架,在不同的具体问题中,可能在这个时刻int v = Q.dequeue() 需要对当前的顶点做相应的处理,我们可以笼统地认为,这些操作就是我们所谓的对这个顶点的真正访问。

  1. 那么接下来按照算法的策略,需要枚举出当前节点v的所有邻居;

这可以通过for循环语句来实现,这里的firstNbr以及nextNbr接口——在此前介绍过它们的原理以及实现,可以看到通过这两个接口组合,可以简捷地从v的第一个邻居开始,不断地转向它的下一个邻居,直至所有邻居都被枚举完毕。

而每枚举出一个新的邻居u,都会根据u当前的状态,采取不同的处理方法,具体的处理方式下一步再做介绍。那么一旦v的所有邻居都被遍历并且处理完毕。v自己也将从刚才的discovered状态顺利地转化为visited状态。

由此可见,经过整个地遍历搜索过程,每一个顶点地状态都会由最初的undiscovered状态转化为discovered,并最终转化为visited。这样的三个状态也就构成了每一个顶点在它的生命期内的三部曲。那么总有一天这个队列会变空,整个广度优先搜索过程也就顺利结束。

  • 那么接下来惟一仍未交代的就是对于枚举出来的每一个新邻居u都有哪些处理方式呢?

1.3 可能情况

在这里插入图片描述

  1. 每次迭代都会首先取出队首的顶点V并且记下它的时间标签V,这个顶点既然能够出队,那么在此前必然曾经入过队。

请注意这里采取的原则是每当一个顶点入队,都会标记为三部曲中的第二个状态——discovered状态,所以处于undiscovered状态的顶点可以用白色来表示。使用黑色表示处于discovered状态的顶点。

  1. 通过内嵌的for循环逐一枚举出V的每一个邻居U。粗略将U的状态分为两种情况。第一种情况,U还处于最初的undiscovered状态,按照算法策略,应该对它进行访问,这就是为什么将它标记为第二种状态(discovered状态)并且随即令其入队。

总体效果意味着,从当前顶点V成功地发现并且访问了它的邻居U,因此从V通过U的那条边将被算法采纳并且保留下来,应该记得初始化时每一条边都会被设置为undetermined状态,而按照算法规则一旦发现某一条边应该被采纳,就将它设置为tree,也就是从undetermined转为tree状态。顾名思义,所谓的树边tree edge将会构成最终所需要构造的那棵遍历支撑树。

  1. 对于广度优先而言,每次所枚举的下一个邻居未必总是处于最初的undiscovered状态,实际上U还可能处于discovered状态,甚至在某些情况下处于visited状态,尽管两种状态还是有本质区别,但这里为了简化起见,不妨笼统地归入else分支,还在后面介绍的深度优先搜索将对不同情况做更为细致的分类,而那种分类方法同样可以为BFS所借鉴。在这种简化的版本中,此时都将由V通往U的那条边归入cross类型,也就是跨边。

至此既然我们已经完全了解了邻居顶点的不同处理方法,整个算法介绍完毕。

1.4 实例

在这里插入图片描述

  1. 首先引入初始为空的队列,起点取做S。
  2. 按照算法流程,首先令S入队并且同时将它标记为discovered状态——图中深色。
  3. 接下来将逐一枚举S的邻居——acd,它们都是白色——undiscovered状态,因此它们都会被取出并且归入队列中。同样的在这些顶点依次入队的同时,它们也会从最初的undiscovered的状态转化为discovered状态,这个时刻它们会进而转为深色,每个邻居状态转化的同时都会生成一条tree edge。

请注意关于同一顶点的所有邻居并没有定义一个优先次序,实际上这并不是什么实质的问题,完全可以根据自己的偏好采用某种策略,甚至是随机的策略。
  ~  
这里以它们的字母编号为序。

在所有的tree edge状态生成之后,S也就完成了它的历史使命,它会被标记为visited状态,也就是最终状态,图中顶点标记为双边框形式以示区别。

  1. 接下来应该是队首节点a出队,a出队之后也同样需要遍历它的所有邻居,其中S是它父亲,所以直接忽略掉,而C已经被发现,处于discovered状态,这种情况属于算法else分支,对C不做任何处理,而是将由a通往c的边标为cross——跨边,再接下来a的下一个邻居e在当时仍处于undiscovered状态,属于算法流程if分支,所以令e入队,并且同时处于discovered状态,图中变为深色。
  2. 接下来应该轮到新的队首节点c出队,c出队后也需要环顾四周,首先忽略掉父亲S,以及处于visited状态的a,实质需考虑的之后仍处于最初undiscovered状态的顶点b,所以令b入队并同时转为discovered的状态。至此既然C的所有邻居都已经处理完毕,它也顺利转入最终的visited状态。
  3. 接下来新的队首节点d同样会出队,站在d的角度环顾它的所有邻居,忽略它的父亲,唯一的邻居b已经入队,处于discovered状态,所以这里无需作任何实质操作,而只需将由d通往b的这条边,标记为cross,d也将顺利转入最终的visited状态。
    在这里插入图片描述
  4. 顶点e b f g处理流程同上。当节点e转为visited状态后,纵观全局会发现,此时已经没有任何顶点依然处于最初的undiscovered状态,所以接下来只会是一系列过门,可能会标记出一些corss edge但是不会有新的tree edge生成。
  5. 经过若干过门后,所有的顶点都已出队并且转为最终的visited状态。此时如果忽略掉所有的cross edge,剩下的tree edge就的确构成一棵树。整个遍历的最终产物是遍历支撑树。

1.5 多联通

由算法原理及过程不难看出,与起始顶点s相连通的每一个顶点都迟早会被bfs搜索发现并访问,也就是说s顶点所属的连通域确实可以被系数遍历。然而问题是并不是每幅图都只包含一个连通域。
在这里插入图片描述
当包含多个连通域时,从任何一个起点s出发,未必能够抵达其他的连通域,这种情况如何处理呢?如何使得bfs搜索足以覆盖整幅图,而不是其中某一个连通域呢?不妨采用如下方法。

在这里插入图片描述
可以看到这里只不过是对刚才介绍的bfs算法做了一个while循环的封装。具体来说逐一检查图中的每一个顶点v,一旦发现新的顶点v仍然处于最初的undiscovered状态,就会随即启动依次源自顶点v的广度优先搜索。为以示区别,封装后的算法以小写bfs表示。

这样无论图中包含多少个连通域,总是能够在其中找到一个起始顶点并且启动对这个连通域的遍历。这样就顺利实现了对多个连通域的统一遍历。

当然尽管这个封装后的算法在功能上是毋庸置疑,但是难免会对其效率产生质疑。我们的质疑是有理由的,因为这里毕竟引入了一层新的循环。至少从表面看来这个循环将多达线性次。

然而这种担心是不必的,因为这里并非对每个顶点都启动一次bfs搜索,而是只有当前顶点能够进入这个if判断之后才启动bfs搜索。这种处理方式可以保证对于每一个连通域只有一个顶点可能作为起点引起它所属的那个连通域被完全的遍历掉。每个连通域启动而且只启动一次广度优先搜索。因此所有花费在搜索上的时间累计也不过对全图的一次遍历而不是多次。

1.6 复杂度

回到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) On2,这只具有理论上地意义,在实际中却远远不是这样。

背后的原因在于内存循环for语句本身所对应的那个渐进O(n)实际上是非常非常小的,至少在常系数意义上而言是这样,这可以从两方面加以验证。

首先这个for语句对行向量的操作都非常简单,无非就是逐一地取出其中的每一个元素并且判断是否为空,相对其他更为复杂的基本操作而言,这种基本操作更加的名副其实。
  ~  
而更重要的第二个方面在于,组成每一个行向量的所有元素,不仅在逻辑上是连续的,而且在物理上也是连续的,构成一个紧凑的整体,这样一种物理上的组织和存储方式可以有效地激活系统的缓冲机制,换而言之,在对整个行向量的访问过程中,所有的元素都有极高的概率处于高速缓存中。
  ~  
此后介绍B树时,我们将会指出,任何一级存储,相对于它的高速缓存而言,在访问速度上的差异将高达5-6个数量级,在实际效果上的这样一种极大差异完全足以抵消理论上的分析结论。

因此完全可以将O(n)忽略掉,并代之以常数,因此这样实现的BFS算法实际运行性能更贴近于O(n + e)。 当然如果将邻接矩阵改为邻接表则可以直接达到这样一个效率。

可以说这样一种结果应该已经是不能指望更好的了。

原因在于,既然是要遍历,至少需要对n个顶点分别访问一次,也至少需要对每一条边访问一次,当然这样一种结论也是至关重要的,因为正如之前所介绍的,无论是BFS以及后面的DFS和PFS,所有遍历算法实际上都是后面更为具体也更为复杂的算法的一种基本实现框架,作为所有算法的基本框架,它能够达到如此低廉的成本,对于算法设计者而言,已经是不能再好的消息了。

总结如下图
在这里插入图片描述

1.7 最短路径

最后讨论下BFS非常有趣也最为本质特性,所谓的最短距离性。
在这里插入图片描述
回顾此前所介绍的树结构,相对于树根节点,任何一个节点v都对应于唯一通路,这条路径长度称为路径深度,然而可以自上而下按照它们的深度,进行等价类划分,在每一个等价类的有所顶点,所具有的深度指标都是彼此相等的。而树的层次遍历也可以按照这一指标非降的次序,将所有顶点逐一枚举出来。
在这里插入图片描述

那么这样一个遍历过程是否也可以转化为图结构的遍历过程呢?

表面看来似乎不太容易,因为此时与树结构极不相同的就是从起始顶点S出发可能有多条路径都最后通往同一个顶点而且可能出现分叉,然而这样一个问题不难解决。
  ~  
实际上只需考查顶点之间的最短通路并且将这两个顶点之间的距离取作这条最短通路的长度dist(v,s)。而在起始顶点相对固定的情况下,甚至可以将S在这个记号中省略,直接简称顶点v所对应的距离。巧合的是图的BFS搜索与树的层次遍历一样都具有这样一种单调性,也就是说BFS所给出的顶点序列,按照这样到起点的距离,也是按照非降次单调排列,所有顶点被发现并访问的过程,从起点S出发,所有的顶点按照它们到起点S的距离,成批地被发现并进而接受访问,直到最终所有地顶点都被访问完毕。

在最终所生成地BFS树中,每个顶点与S之间地那条通路,恰好就是在原图中这两个顶点之间的那条最短通路。

2. 深度优先搜索

介绍下与广度优先搜索完全对称的另一种搜索深度优先搜索,深度优先搜索的算法策略更为简明,然而有趣的是深度优先搜索的过程更为复杂,其功能也相对而言更为强大。因此也成为有效解决很多实际问题的基本算法框架。

2.1 算法

在这里插入图片描述
起始于某一顶点s的深度优先搜索过程可以简明描述为:

  1. 只需直接访问这个顶点
  2. 然后在它的所有尚未访问的邻居中,任选其一,并且递归地以选出的顶点为基础,继续执行DFS。
  3. 当然,一旦所有的邻居均已访问完毕,算法也就在这个位置返回。

上图算法过程如下:

  1. 在任何一幅图中,只需确定一个搜索起点,然后按照刚才描述的策略,只需要找到它的一个邻居。

请注意,当前顶点可能还有其他邻居,但是这个算法并不需要去考虑它们,而只是任选其一并且将控制权交给这个新的顶点。

  1. 接下来,新的顶点一旦接过控制权,它也会仿效这种策略,在它的邻居中任选其一并且将控制权交给这个尚未访问的邻居。
  2. 再一次地,最新的这个顶点依然会效仿这种策略,在它的尚未访问的邻居中任选其一,并将控制权再交给这个邻居。
  3. 同样地,这个新的顶点也会去扫描它的所有邻居,并试图找到一个尚未访问的,当然如果有访问过的,对应这条边将不会被采用,而是以某种适当的形式加以标注。

稍后会对标注方式做详细的说明。这也是这个算法至关重要的方面。

  1. 以下假设这个顶点已经没有任何邻居尚未访问,那么按照算法策略,将在这个位置返回,也就是回溯,顺着此前的通路回到它的前驱顶点,同样,如果依然没有尚未访问的邻居,也需要在这个顶点处继续返回。
  2. 假设在这个顶点处至少还有一个尚未被访问的邻居,就将控制权转交给它,以下过程类似。

最终整幅图遍历完毕,可以看到遍历效果与此前的BFS类似,我们依然会得到一棵DFS树,也就是这些粗边所构成原图的一棵支撑树。而且同样地未被这棵树所采纳的那些边也同样会被分类,而且这种分类会更为细致。

那么这样一个遍历和递归过程任何明确地兑现为具体算法和代码呢?

2.2 框架

在这里插入图片描述
给出DFS一种可能的实现方式,可以看到,起自顶点v的DFS算法,从形式和接口看与BFS算法完全一样。

  1. 进入算法之后,依然要将当前的顶点v从undiscovered最初状态转换为discovered。这等效于发现并访问了这个节点,因此也需要将此时的时钟作为标签记录下来。
  2. 接下来,又进入了这样一个循环, 已经非常熟悉了,它的作用无非是枚举当前节点v的所有邻居并且根据这个邻居的状态分别加以适当地处理,具体处理方法放到下面,再做详细剖析。

         ~~~~~~~~         需要指出的是与BFS不同这里可能含有递归。

  1. 但无论如何,一旦当前顶点v的所有邻居都已枚举并且访问完毕,就可以将它的状态转为最终的visited。

         ~~~~~~~~         另外不同的在于,还需要将此时的时钟也记录下来,请注意,这是每个顶点的另一个时间标签fTime——这个节点被访问完毕的时刻。

那么针对邻接顶点的不同状态,究竟可以分为几种情况分别处理?每一种处理方式又是如何?以及为什么要这样处理?还有整体的处理效率如何?

2.3 细节

在这里插入图片描述
以下给出对于不同邻接节点的处理方法,可以看到无非三种情况:

  1. 如果是处于最初的undiscovered状态那么就与BFS一样,意味着支撑树在这个位置可以进一步拓展,为此要将从v通往u的那样一条边引入到遍历中。相应地,在遍历树中顶点u就将以顶点v为父亲,相应地,此后的控制权就转移到这个新的邻居u,因此根据此前所约定的算法策略,就应该以这个新的顶点u为基准,继续递归执行深度优先搜索。
  2. 另一种可能的情况是下一个邻接顶点u是处于第二种状态也就是discovered,此时的处理方法是将v和u之间的那条连边标记为backword——回向边,或者简称回边。
  3. 最后一种可能是遇到的下一个邻接顶点u已经被访问完毕,也就是处于最终的visited状态。这种情况又可以细分为两种小的情况,而判别的依据是要看顶点v和顶点u的dTime时间标签孰大孰小,也就是看它们谁更早被发现。如果是v更早被发现,那么就将v到u的这条边标记为forward——前向边,或者叫向前边,反过来如果是u更早被发现,那么v到u的这条边就会被标记为cross——交叉边或者是跨越边。

整个算法至此已经封闭。

2.4 无向边

在此首先需要准备一张表,其中每一行分别对应图中某一顶点,可以看到每一行有三列,分别是这个顶点标识,也就是对应的字母,以及它的dTime和fTime两个时间标签。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.5 有向边

相对于无向图有向图的深度优先搜索要更为复杂,所涉及的情况也更多。

图中将每一个顶点都绘制成长方形,顶点的标识居中,在它的左右空白处将分别记录下它在遍历过程中所获得的dTime和fTime两个时间标签。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
立足于顶点G,也同样要去环顾四周,逐一去考察它的邻居们,可以看到第一个邻居是顶点a,请注意,此时应该属于算法的第二种情况,这个邻居当前处于第二种状态,也就是discovered。根据算法规则,应该将它们之间的有向边归类为back word——后向边或者回边。等效于遍历树中试图从一个后代去回连到它的祖先,所以称为back word是在形象不过了。

同样地,一旦出现backword边,也就同时意味着发现了至少一条回路。
在这里插入图片描述
纵观整个过程,总共用了10s,遍历完了由abcfg这五个顶点所构成的子图。更确切的讲,它们构成了在这个图中从顶点a出发可以达到的区域,可称作可达区域。

  • 那么其余部分的呢?

2.6 多可达域

记得对广度优先算法BFS处理手法,就不难发现,完全可以效仿那种做法,在这样的dfs算法之外,在包装一层循环,来枚举图中的所有顶点,这样就可以无一遗漏地遍历图中所有顶点。而且只要我们处理得当,对所有可达域的遍历都不会彼此有所重叠,从而在时间效率上,也依然可以得到保证。

从效果上看,经过上述封装以后,或许应该从顶点D出发,也就是在第11s发现顶点d。
在这里插入图片描述
盘点下遍历获得的成果:
在这里插入图片描述

  1. 首先是这些粗边,它们构成了两棵遍历树,整体的构成了一个遍历森林。
  2. 此外,还对所有未被采纳的边进行了分类,有的被分为跨越边,有的被归为前向边,而有的被归类为后向边,无一例外,无一遗漏。
  3. 在通过遍历所获得的所有这些信息中遍历树或者说遍历森林无疑是最为重要的。

然而相对原图,它们毕竟只不过是一个子集,这样一个子集所携带的难道是原图的所有信息吗?从某种意义上讲的确是这样的,而其中至关重要的点在于,通过遍历不仅获得这样一棵树,而且为每个顶点都标记了dTime和fTime两类时间标签,而这两类时间标签的作用是非常巨大的,作用之大远远超过直观想象。

2.7 嵌套引理

通过dfs搜索,为所有顶点所标注的两个时间标签实际上蕴含了大量有用信息,这一点可以由括号引理或嵌套引理来加以印证。
在这里插入图片描述
为此首先引入活动期概念,也就是由它的dTime和fTime两个时间标签所界定的那样一段时间范围,也就是这个顶点在整个dfs搜索过程中处于活跃状态的时间范围。

所谓嵌套引理 告诉我们任何有向图经过了dfs搜索后,在对应的dfs森林或者dfs树中,任何一对顶点之间,存在直系血缘关系,当且仅当它们的活跃期存在包含与被包含关系,具体来说,祖先的活跃期必然包含后代的活跃期。
  ~  
而反过来,如果两个顶点之间没有任何直系血缘关系,那么它们的活跃期也是彼此互不相交的。

为了获得对这个引理更为直观认识,不妨以横向作为时间轴,依然以上述有向图为例,可以将每个顶点以水平方向适当展开,使得它们恰好覆盖各自所对应的活跃期。于是就不难看出,作为祖先,它的活跃期的确可以覆盖它的后代,而后代也将继续覆盖它的后代,以致后代的后代。而反过来,没有直接血缘关系的节点,如F和B、B和G、D和F、B和E,它们的活跃期的确彼此不搭,没有任何公共部分。

这样一个特性是非常强大的工具,比如在算法中,经常要做的判断就是任意的一对顶点v和u之间在遍历树中是否存在一个直系血缘关系,如果没有这样一种渐变的机制,我们将不得不从u出发顺着parent引用不断地溯流而上,直到有一天遇到顶点v,才能够确定,它们的确存在祖先和后代的直系关系。或者不得不追溯到遍历的起点,从而断定u和v之间并没有直系血缘关系。
  ~  
而现在呢,借助时间标签,可以快速准确地在O(1)的时间内就得出相应的结论。这种机制所带来的更多遍历之处,在后续算法中不断地深入体会。

3 遍历算法的应用

在这里插入图片描述

  • 30
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值