python中判断无向图是否有环_图03---图的遍历

图的遍历 (graph traversal)

给出一个图G和其中任意一个顶点V0, 从V0出发系统地访问G中所有的顶点, 每个顶点访问而且只访问一次

深度优先遍历

广度优先遍历

拓扑排序

图遍历的考虑

从一个顶点出发,试探性访问其余 顶点,同时必须考虑到下列情况

• 从一顶点出发,可能不能到达所有其 它的顶点 ,如 非连通图

• 也有可能会陷入死循环 , 如 存在回路的图

解决办法

  • 为每个顶点保留一个 标志位 (mark bit)
  • 算法开始时,所有顶点的标志位置零
  • 在遍历的过程中,当某个顶点被访问时, 其标志位就被标记为已访问

图的遍历算法框架

void 

深度优先遍历(depth-first search)

深搜(简称DFS) 类似于树的先根次序遍历, 尽可能先对纵深方向进行搜索

选取一个未访问的点 v0 作为源点

  • 访问顶点 v0
  • 递归地深搜遍历 v0 邻接到的其他顶点
  • 重复上述过程直至从 v0 有路径可达的顶点都已被访 问过

再选取其他未访问顶点作为源点做深搜,直到图的所有顶点都被访问过

5a89940f6a4d82a72745716d16a81037.png

深度优先搜索的顺序是:

图的深度优先遍历 (DFS) 算法

void 

邻接表的DFS

/* 邻接表存储的图 - DFS */
 

广度优先遍历

广度优先搜索 (breadth-first search,简 称 BFS)。其遍历的过程是:

从图中的某个顶点 v0 出发

  • 访问并标记了顶点 v0 之后
  • 一层层横向搜索 v0 的所有邻接点
  • 对这些邻接点一层层横向搜索,直至所有由 v0 有路径 可达的顶点都已被访问过

再选取其他未访问顶点作为源点做广搜,直到所有点都被访问过

图的广度优先遍历(BFS)算法

void 

邻接矩阵BFS

/* 邻接矩阵存储的图 - BFS */
 

图搜索的时间复杂度

DFS 和 BFS 每个顶点访问一次,对每一条边 处理一次 (无向图的每条边从两个方向处理)

• 采用邻接表表示时,有向图总代价为

, 无向图为

• 采用相邻矩阵表示时,处理所有的边需要

的时间 ,所以总代价为

图不连通怎么办?

void 

拓扑排序

对于 有向无环图 G= (V,E) ,V 里顶点的线性序列称作一个 拓扑序列,该顶点序列满足:

若在有向无环图 G 中从顶点 vi 到 vj 有一条路径, 则在序列中顶点 vi 必在顶点 vj 之前

拓扑排序 (topological sort)

将一个 有向无环图 中所有顶点在不违反 先决条件 关系 的前提下排成线性序列的过程称为 拓扑排序

拓扑排序图例

79f5087c9056c927a986f6ebcfcf039a.png

d95a21219cdba584faf4b9c9572a115d.png
学生课程的安排图

拓扑排序方法

任何 有向无环图 (DAG) ,其顶点都可 以排在一个拓扑序列里,其拓扑排序的

方法是:

  • (1) 从图中选择任意一个入度为0的顶点 且输出之
  • (2) 从图中删掉此顶点及其所有的出边, 将其入度减少1
  • (3) 回到第 (1) 步继续执行

用队列实现的图拓扑排序

void 

d95a21219cdba584faf4b9c9572a115d.png

按结点编号深度优先:

置逆,拓扑序列为:

深度优先搜索实现的拓扑排序

int 

拓扑排序递归函数

void 

拓扑排序的时间复杂度

与图的深度优先搜索方式遍历相同

图的每条边处理一次 ,图的每个顶点访问一次

采用邻接表表示时,为 Θ(n + e) ,采用相邻矩阵表示时,为

递归与非递归的拓扑排序

必须是有向图

必须是无环图

支持非连通图

不用考虑权值

回路

  • 非递归的算法,最后判断 (若 还有顶点没有输出,肯定有回 路)
  • 递归的算法要求判断有无回路

二、广度优先搜索(BFS)

2.1 、通过词梯问题,了解BFS。

词梯问题:。比如,将单词“ FOOL”转变成单词“ SAGE”。在词梯问题中,你必须以一次只改变一个字母的方式来逐步转变单词。每一步你都必须将一个单词转变成另一个单词,并且不允许转变成一个不存在的单词。

词梯问题还有很多的变形。比如你可能被要求以给定的步数完成单词转换,或者你必须使用一个特定的单词。在本节内容中,我们感兴趣的是如何算出从开始单词到目标单词所需要的最小转换次数。

为了能用图解决这个问题,我们通过两步来达到目标:

  • 1、先建立一个图,描绘出单词之间的关系。可以建立一个图的类graph。
  • 2、图已经建立好,再用广度优先搜索(BFS)的图算法找到最短路径。

2.2 图的表现形式

图的表现形式主要有两个,一个是邻接矩阵,一个是邻接表。 邻接表:一个实现稀疏图的更高效的方案是使用邻接表 adjacency list。在这个实现方法中,我们维护一个包含所有顶点的主列表(master list),主列表中的每个顶点,再关联一个与自身有边连接的所有顶点的列表。在实现顶点类的方法里,我们使用字典而不是列表,此时字典中的键(key)对应顶点标识,而值(value)则可以保存顶点连接边的权重。

实现邻接表,需要用到两个类,一个是图graph,一个是顶点vertex。

2.3 建立word ladder图

把问题细化再优化:

  • 1、就像动态规划中,我们把对63美分的零钱找零一样,我们先把问题细化成一次找一个硬币(贪心,穷举)来找到最优解,然后再优化(优化成动态规划)。
  • 2、就像打算用图解决问题一样,我们细化问题,先建立一个图,再用图算法。
  • 3、同样,这里也是。我们把“建立词梯图”问题细化成:首先明确建立什么样的图,再考虑怎么建立这个图。

2.3.1 建立什么样的图

我们第一个问题是去解决如何将大量单词组成的集合转变成图。我们想要的是在两个仅差一个字母的单词之间连一条边。如果我们可以创建一个这样的图表,那么任一从一个单词到另一个单词的路径都是某个词梯问题的一个解。图中显示了一个由一些单词构成的小图,可以解决从“ FOOL”转变成“SAGE”的词梯问题。值得注意的是,该图是无向图,并且边是没有权的。

13e3c7895fcc869b66465cb8bcbabcce.png

2.3.2 怎么建造这个图

简单粗暴的方法:

假设有一个单词列表,这个列表中所有的单词长度都是4。

  • 我们为这个列表中每个单词创建一个顶点。
  • 为了将这些单词连接起来,我们可以将列表中的每个词与所有其他单词进行比较。
  • 若是该单词,和比较的单词只有一个字母不同,就可以在图中创建一条连接他们的边。

但是这个方法的时间复杂度是

。假设有5110个单词,
比2600万还大。

优化这个方法:

上面的方法, 是每次来一个单词,都与所有的单词进行比较。比如来了pope,会和第一个字母不同的单词(rope,nope,hope),也会和第二个字母不同的单词(pipe,pape)进行比较,然后再将有联系的单词连接起来。以此类推。这样会比较冗余。

我们可以把同一个单词类型,都放到一个桶里。

比如_ope,就是只要第一个字母不同的单词,都放到这个桶里去,不用再去和别的单词进行比较了。

对应到上面的,就是,来一个pope,我们放到_ope里去,下一次来了rope,我们也放到_ope去,这样就避免了让新来的单词跟所有的单词“见面”,只需要找到属于自己的桶就行了。

这样结束以后,我们可以认为,在同一个_ope桶里的,都是第一个字母不一样的,相互连接的单词。

如下图:

40d320bd115cc919a6166000a3946fab.png

上述方法,用Python实现的话,就是把桶的表示作为字典key,把桶里的单词作为value。 构建图的时候,对一个桶的m个单词,两两之间添加联系。

for 

最后返回图就行了。

2.3.3 用广度优先搜索法(BFS)解决这个图问题

直观理解:在搜索完第k层的节点之前,是不会搜索第k+1层的节点的。 想像BFS的运行原理: BFS过程,就是建造一棵以顶点 s 为根的树的过程,一次建造树的一层,同时,BFS在增加k+1层次之前,会保证将所有的k层的子顶点都添加在了树中。

追踪这个过程:

  • 1、BFS 算法在搜索过程中,会给每一个顶点染色为白色、灰色或黑色。
  • 2、每一个顶点在被构建时都被初始化为白色,在这之后,白色代表的是尚未被发现的顶点。
  • 3、当一个顶点被第一次发现后,它被染成灰色(下次别的点搜索到这个点时,发现是灰色就说明已经被发现了,就不会再进行搜索了。)
  • 4、当广度优先搜索(BFS)完全探索完一个顶点后,它被染成黑色。 这意味着一旦一个节点染成了黑色,它就没有邻近的白色节点;而另一方面,如果一个顶点被标识为了灰色,这就意味着其附近可能还存在着未探索的顶点等待被探索。

代码实现:

广度优先搜索算法使用的是前文出现过的邻接表来实现的图。此外,它还使用了一个队列来决定下一步应该探索哪一个顶点

队列的作用:扫描到一个顶点,会把该顶点添加到队尾。由于队列的后进后出。只有当把距离为k的顶点都从队列中弹出完了以后,队首才会是距离为k+1的顶点,然后接着把距离为k+2的顶点扫到队尾。

广度优先搜索(BFS) 从起始顶点 s 开始,此时 s 的颜色被设置为灰色,代表它现在已经被发现了,另外两个参数——距离和父顶点,对于起始节点 s 初始设置为了 0 和 None。随后,起始节点会被加入到一个队列中,下一步便是系统地探索队首顶点。这个过程通过迭代(遍历)队首顶点的邻接列表来完成,每检查邻接表中的一个顶点,便会维护这个顶点的颜色参量,如果颜色是白色的,就说明这个节点尚未被探索,也就会按下述四步操作

1、 把这个新的未探索的节点 nbr,标记为灰色;

2、 nbr 的父顶点被设置为当前节点 currentVert; 比如,我是从a点向下搜索到了b点,那么a就为b的父节点

3、 nbr 的距离被设置为当前节点的距离加一; 比如从顶点s到a的距离是sa,那么从顶点s到b的距离一定是sa+1.

4、 nbr 被加入队尾,直到在当前顶点的邻接列表中的所有顶点nbr被搜索完后,才能够进行下一层次的探索操作。

假设队列的队首顶点是a,a到顶点的距离是sa,那么,会在搜索完a的下一层的所有距离为sa+1的顶点。把a的颜色标记为黑色。弹出a。此时a后面的节点变为队首。

若a2距离顶点的距离一样等于sa,那么也会搜索完a2的下一层所有距离为sa+1的顶点。 这样循环下去。

from 

如果,当 bfs 函数从节点a检查到节点 b 时,发现它的颜色已经染为了灰色,这代表它已经被发现过了,并且表明从起始节点到 b之间有一条更短的路径。

  • 1、顶点s到a的距离为sa,s到b的距离为sa+1。又因为BFS只有在搜索当前层节点以后,才会搜索下一层的节点。
  • 2、说明在搜到a之前,已经搜索过b了,而且,之前那条路径从顶点s到b的距离小于等于sa。
  • 3、所以表明,此时从顶点s到b之间有一个更短的路径,于是可以放弃当前的从s到a再到b的路径。

于是,由上面分析,可以知道,确实,代码构建的树实现了每次从节点s到b都是最短的路径。 更加形象的例子参考教材Python算法

总结

所以,BFS也就是开始说的两步:

  • 1、构建图:用相邻表构建,用到了字典的知识
  • 2、BFS搜索,主要是理解两点:一、搜索思想,搜索完第k层以后,才会搜索第k+1层。二、追踪节点,就是上面的发现节点以后的四小步:若是白节点b,变灰-->当前节点a设置为节点b的父节点-->该节点距离设置为sa+1-->把b添加到队尾。

理顺了,BFS也就理解了。

另外,BFS还能够让我们从树的任一节点出发,沿着父节点返回到根节点,从而得到从这个节点的词到根节点的词的最短词梯。

广度优先搜索的时间复杂度分析:

尽管是两个循环,while跟for,就相当于,两个for,第一个for的循环次数是1。第二个for的循环次数是该节点的相邻点的个数。

由于每个节点仅被发现一次,因此每个节点入栈和出栈各一次,时间均为O(1),故所有V个节点入栈和出栈总时间为O(V)

由于需要对每个节点的邻接表进行扫描,时间为O(Adj[u]),总时间为O(E);

V代表节点总数,E代表图的所有边的总数。一次while和for,只是遍历了一次一个节点的所有nbr。一个节点出入栈一次,时间为1,遍历这个节点nbr时间为adj[u](也就是判断这个节点具有几个nbr),所以这次的while和for的时间复杂度是1+adj[u]。

那么V各节点,找了E次,就是V+E。

注意,这里不能认为while同等于for遍历V次。

首先因为while循环的条件是栈不为空,这个栈一共会出栈进栈V个节点,所以while会循环V次。 !!!但是!!!,每次循环时,for所查找的每个节点的相邻点的个数是不一样的!!!都是不确定的,所以不能单纯的就是相乘。

比如第一次while循环时,for找到了当前点有2个相邻点,下次的vert,for可能只找到了一个。

综上所示,广度优先搜索的时间复杂度为O(V+E).即是图邻接表大小的线性函数。

三、深度优先搜索(DFS)

我们用于解决骑士周游问题的搜索算法是 Depth First Search (DFS)——深度优先搜索算法。前面讨论的 BFS(广度优先搜索算法)是一次建立搜索树的一层,而 DFS 算法则是尽可能深的搜索树的一枝。 同理于用BFS解决词梯问题的步骤,对于用DFS解决骑士周游问题也是采用两步走的方案:

  • 1、建立一个图。
  • 将棋盘上合法的走棋次序表示为一个图。
  • 2、利用图搜索算法找到答案。
  • 利用DFS算法搜索一个长度为(行 x 列 -1)的路径,此路径上恰好包含每个顶点一次。

3.1 建立骑士周游图

问题细化:

  • 1、将棋盘的每个格子用图的顶点表示。见左下
  • 2、骑士从点a可以合法移动到点b,则在图中,a和b就用边连接起来。

8d9ba4ef28ddd7e8ac6ae2bdc0cb90cf.png

根据上面两点,可以构建图:

  • 1、for每行,for每列,得到一个棋盘上的点的行列信息。
  • 2、用一个pos_to_nodeid函数将棋盘上点位置的行列信息转换成一个线性顶点数。(如上左图的表示方法)。
  • 3、用gen_legal_Moves函数来创建当前这个格子上骑士所有合法移动的列表。
  • 4、将得到列表中的每个点和当前点用addEdge方法,用边连接起来。

于是得到一个图。

3.2 用DFS算法解决问题

3.2.1 DFS算法的思想

DFS算法尽可能深的搜索每个树枝,一直搜索到最深的那一个为止。

所以DFS很适合用于发现一条包含63条边的路径。

当DFS走到一条死路(再也没有可能的合法移动的方式)时,它会沿着树返回直到该节点有路可走。然后继续往深处探索。

3.3.2 DFS的递归函数

在DFS中,我们递归调用knightTour函数,将节点传入此函数,这个函数能够返回这个节点能够走的最长的path。

knightTour函数需要四个传递参量:

  • n ,当前树的深度;
  • path ,这个节点前所有已访问的点的列表;
  • u ,我们能够探索的点;
  • limit ,搜索总深度限制。

该函数递归使用:当 knightTour 被调用时,首先检查基础状态:如果 path 包含有 64 个节点,函数 knightTour返回 True 表示已经找到一条可周游的路径;如果 path 还不够长,我们继续选择一个新节点并以此为参数调用自身。

该函数结束条件,path包含64个节点,或者当前节点不能再走下去。

DFS对节点的追踪:

DFS 算法还需要使用“颜色”来追踪图中哪些节点已经被访问过了。未访问的节点染为白色,访问过的染为灰色。

DFS使用栈:

在 BFS 里面我们用 queue(队列)来跟踪要访问的节点,而在 DFS 里由于我们使用了递归,也即默认使用了 Stack(栈)来实现我们的回溯机制。

具体的解释:

当我们从knightTour函数返回 False时( 代码第11行),我们依然在 while 循环里面并在 nbrList 中寻找下一个要搜索的节点。也就是说,对于深度为path_a的节点a来说,它的相邻点b(path_a+1的点)有若干个,若其中一个b1调用knightTour后返回了False,说明从a到b1再往后就走不通了。

此时不会跳出while循环,会继续对另一个b2调用这个函数,如果不返回false,说明从a到b2再往后还有路走,那就一直深挖下去。

如果a的所有相邻点都最终返回了false,可能此时从顶点s到a到a的相邻点的路深度分别为path_sab1=20,path_sab2=30,path_sab3=25,都小于63,那么说明经过a点也不会找到最长路径,那么此时以a为参数的递归调用也返回false。

from 

总结

也是说的两步走:

  • 1、建立图
  • 2、用栈的思想,探索一个节点a,就标记为灰色然后入栈,继续探索下面的节点b,b的深度为sa+1,b入栈,标记为灰色。若是探索完b返回false,那么就将b弹出,b变回白色。再探索a的另一个相邻点b1。
  • 3、直到探索的点的深度达到要求。

3.3.3 目前DFS的缺点--搜的太傻,不如启发式搜索

当前骑士周游算法是一个时间复杂度为O(kN)的算法,N是棋盘格的数目,k是一个小的常数。可视化:

455f04f7a99649576337795687fd6d96.png

假设搜索到的节点下面有8个合法的位置,然后就要先把下面的每个位置往深了使劲找完以后再才能再回到当前节点。然后进行新的节点的搜索。即使这个节点时错误的,算法也会忠实的搜索完。

启发式搜索:

直观地说,就是下一步选择有最少可能移动位置的顶点。

比如:

不用启发式:

顶点a下面的点有b1,b2,b3,b4.按理说,就是从a挨个向下探索,a到b1,再到下下层的c1...

用启发式:先判断b1,b2,b3,b4下面有几个相邻节点,把具有相邻节点数目最小的比如b3,排在第一位。下次a就会先从b3搜索。

假设选择有最多可能移动位置的节点作为下一个顶点的问题在于骑士将倾向于在周游的早期访问棋盘中间的方格。当这样的事情发生的时候,骑士将容易被困在棋盘的一边而不能到达棋盘另一边未被访问的方格。

另一方面,首先去访问最少可能的格子会迫使骑士早早的进入边角的格子。 进而保证骑士早早访问那些不容易到达的角落,并且在需要的时候通过中间的方格跳跃着穿过棋盘。利用这种先验的知识来改进算法性能的做法,称作为“启发式规则”。

人类每天应用启发式规则来做决定,启发式搜索经常被用在人工智能领域。这个启发式规则算法以 H. C. Warnsdorff 的名字来命名,被叫做 Warnsdorff 算法,他在1823 年发表了自己的想法。

3.3 通用深度优先搜索

上述的DFS只建立了一个深度最深的树。

在深度优先搜索中建立不止一个树,也是有可能的。(为什么要建立不止一个树,比如下面的拓扑排序题,想要知道不同的树,以及不同树需要的时间,这就是通用DFS的用处了。)

对于通用深度优先搜索算法的理解参考教材,其中开头标题的注释也讲的比较明白了。不过,此算法的应用不是很理解,比如拓扑排序用此算法以后顶点确实能成线性了,但还是不知道怎么制作香饼。。

四、BFS与DFS的不同

4.1、直观上的不同

4.1.1、BFS==> 搜完一层,才搜下一层

BFS是先搜索完一层的所有点,再搜索下一层。被搜索完第k层的节点,绝对不会再次被搜索到,因为这些节点的所有可能已经被搜索完了。

想想的话,就是,既然目标是找最短的路径,那么我就干脆一层一层的找,最短的路径肯定是层数最少的,这种方法是可以的。

4.1.2、DFS==> 每搜一个节点,就是当前节点的下一层

DFS是每次搜索一层的一个点,然后搜下一层的一个点。即,先从根节点s使劲往下探索,探索到最深处再回来探索另一条路径。 想想的话,就是既然目标是找最长的路径,那最长的路径肯定是需要一路往下找的。所以每次就一个点一个点的往下搜索。

理论上说,如果我用BFS,每次搜索完一一层,以最长的路径目标,也是可以的。

4.2、所用的数据结构不同

4.2.1、BFS ==> 队列

BFS所用的是队列,每次新搜索一个点,就添加到队尾,按照上面所说,因为是一层搜完才会搜下一层。所以搜索完这个点,就不会再对这个点进行搜索了。所以直接从队列中删掉就行。

4.2.2、DFS ==> 栈

DFS用的是栈,按照上面所说,因为是搜了k层的点a,再搜k+1层的点b,再 搜k+2层的点c。搜到c时,当前点标记为b,搜完c若返回false,那么就会回来从b再向下别的方向进行搜索。也就是说,搜索了这个点,还可能回来再搜这个点向下的别的方向。

因为会回溯,所以要用栈,将搜索的点压入栈中,若返回false就pop出这个点,然后再搜索栈顶的点的别的方向。

4.3、对节点的追踪方式不同(标注颜色方式不同)

4.3.1、BFS ==> 三种颜色,白,灰,黑

上面也已经说过,BFS搜索完一个点,这个点就不会被再次搜索了。

一个节点未被搜索前,是白色。 已经被搜索,标记为灰色。(注意,搜索的目标--是--当前节点的下一层的节点)。

这个节点的下一层节点搜索完了,那么此时,当前节点标记为黑色表示不会再被搜索,当前节点的下一层节点标记为灰色。

4.3.2、DFS ==> 两种颜色,白,灰

DFS中,已经被搜索的点,还可能会被用到(代码中的三行解释

节点被搜索前,是白色。 节点正在被搜索标记为灰色。(注意,搜索的目标--是--当前节点的不断往下层前进的节点

节点被搜索完以后,路径是false ,节点从灰色再次标记为白色。说明在新的路径中,此节点未被搜索到。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值