add a brief conclusion on Graph.
This commit is contained in:
@@ -1,6 +1,35 @@
|
||||
Conclusion on Chapter Six: Graph
|
||||
================================
|
||||
|
||||
## 知识脉络
|
||||
|
||||
本章讨论图结构以及与之相关的算法。相对于前面的序列以及树,图是一种更加一般的数据结构,借助图理论上可以表示世界上所有事物以及它们之间的联系。一般地,单向链表是入度和出度均为1的图,二叉树是入度为1而出度不超过2的图。仿照前面对树的研究思路,对图的研究主要是将图转化为一棵与之对应的树,从而利用对数的分析技巧来解决问题。
|
||||
|
||||
可以从遍历的角度将图转化为树,这里对应了两种遍历策略,即广度优先搜索和深度优先搜索。广度优先搜索总是优先访问更早访问的节点的邻居,而深度优先搜索却是优先访问最后访问节点的邻居。为了尽可能保存图结构中蕴含的信息,在遍历过程中要进行大量的标注工作,包括各个节点的发现时间`dtime`与访问时间`ftime`,各条边的类型(树边,跨边,前向边,后向边)以及父亲节点等。对于两种遍历何时应该标注何种情况,需要有清晰的理解才行,例如对于深度优先搜索,关键在于对`括号引理`的理解。
|
||||
|
||||
基于两种搜索策略可以导出大量和图相关的算法,比如基于`dfs`策略的`拓扑排序`问题和`双连通域分解`问题。对于`拓扑排序`问题,可以证明,所有的有向无环图`DAG`都是存在拓扑排序序列的。一般地,构造拓扑排序序列的算法有两种思路,即`零入度`算法与`零出度`算法,`零入度`算法是基于`拓扑排序`的定义,而`零出度`算法则是基于`dfs`访问次序与`拓扑排序`次序的关系。两种算法都可以达到`O(n + e)`的时间复杂度。
|
||||
|
||||
`双连通域分解`的关键在于`关节点`以及`双连通域`的定义,即对于任意节点`C`,如果将它移除后,将导致`C`的一棵真子树与它的真祖先无法连通,则`C`必是关节点。基于此,可以构造出`双连通域分解`的算法,即对图进行一次深度优先搜索,在对每一个节点访问过程中,动态地记录它的最高可达祖先(hca, Highest Connected Ancestor),如果某一节点的孩子节点的`hca`是当前节点的真祖先,则当前节点必不是关节点,反之当前节点必是关节点。
|
||||
|
||||
`最小支撑树`问题也是图论中的经典问题,构造`最小支撑树`有两种经典算法,即`Prim`算法与`Kruscal`算法。对于`Prim`算法,关键在于理解以下事实,即`最小支撑树`总是采用两割之间的最短路径。在所有路径权值都不相等的图中,基于该事实已经可以构建出一种`最小支撑树`的生成算法,并且它的正确性是容易证明的。但是在权值可以相等的图中,最小支撑树往往并不唯一,此时可以注意到以下事实,即
|
||||
|
||||
+ 任意一棵极小支撑树中的每一边,都是某一割的极短跨越边。
|
||||
+ 每一割的极短跨越边,都会被某一棵极小支撑树采用。
|
||||
+ 对于某棵支撑树,如果它的每一边都是某割的极短跨越边,该树也未必就是一棵极小支撑树。
|
||||
|
||||
此时,`Prim`算法的正确性就显得有点似是而非。好在,可以证明,`Prim`算法每一步生成的树`T_k`,都是某一棵极小支撑树的子树,从而证明得到`Prim`算法在允许多变等权时仍然是正确的。
|
||||
|
||||
`Kruscal`算法也是基于贪心迭代策略的`最小生成树`的生成算法,在它的执行过程中主要涉及两个基本操作,即判断一条边连接的两个节点是否属于同一棵树,以及若不属于,则将这两棵树合并,因此利用一个并查集(`Union and Find Set`)可以方便地实现这些基本操作。`Kruscal`算法正确的证明也是类似于`Prim`正确性的证明。
|
||||
|
||||
为了规避`最小支撑树`的歧义性,可以采用一些其他策略,比如对整数权重的网络,可以人为引入一些微小的扰动,使得各边的权重不再相同。此外,还可以采用`合成数`策略来消除图算法的歧义性。
|
||||
|
||||
此外,在实际应用中往往还会遇到最短路径的问题。对于单源最短路径利用`Dijkstra`算法
|
||||
可以方便地解决,`Dijkstra`算法也是基于贪心的策略,总是将当前到源点路径最短的节点归入到最短路径树当中,随后再基于该节点对其他节点的路径距离进行更新,容易证明该算法的正确性。然而,`Dijkstra`算法却不能适用于存在负权重边的图,此时需要对`Dijkstra`算法做出一些修改,或者不妨使用`Floyd`算法。
|
||||
|
||||
`Floyd`算法可以求到所有节点之间的最短路径,它是基于这样的思想,即任意两个节点之间最短路径,只有可能是直接连接的路径,或者经过若干个中间节点的一条路径。因此,在`Floyd`算法中,依次将每个节点作为中间节点,来更新所有其他节点之间的路径。`Floyd`算法在负权重边的情况下仍然可以正确工作,但是对负权重环路却无能为力,实际上,存在负权重回路时,图中不存在最短路径。
|
||||
|
||||
上面提到的各种算法,无论是`bfs`,`dfs`,`Prim`算法还是`Dijkstra`算法,都具有一些共同点,即总是根据一定的优先级,对下一个要访问的节点进行选择。因此可以将这几种算法,统一归入到优先级搜索(`PFS`, Priority First Search)的框架当中。这样可以发现一些它们内在的联系,比如在优先级搜索的框架下,`bfs`实际上是`Dijkstra`算法在各边等权重的情况下的退化情况。
|
||||
|
||||
## 图的一些基本概念与术语
|
||||
|
||||
> 什么是图?为什么要有图这种数据结构?
|
||||
@@ -53,7 +82,7 @@ Conclusion on Chapter Six: Graph
|
||||
这里主要讨论一下遍历中可能会出现的几种情况。对于当前的顶点x,如果访问到它的邻居w时,w还是未被发现的,这意味着遍历树可以拓展一条边,因此将这两个顶点之间的边标记为'Tree';但如果w已经被发现(访问)了,这种情况对应了
|
||||
|
||||
+ w是x的父结点。例如一开始就从w进入x,然后x在遍历它的邻居时又发现了w。这种情况下x与w之间的边是一条回边(Backward),表示指向祖先顶点。要注意的是,这里的w只可能是x的父结点,而不可能是x更高的祖先结点。这是因为如果w是x比父结点更高的顶点的话,x早在访问w时就应该被发现了,从而w只能是x的父结点。
|
||||
+ w是x祖先结点的邻居,经由x的祖先结点就已经访问到了w。这种情形对应了跨边(Cross),表示x与w没有直接的血缘关系,来自相互独立的两个分支。
|
||||
+ w与x没有直接血缘关系,w可以是x上一层次,同层次以及下一层次的节点。无论是那种情形都对应了跨边(Cross),表示x与w没有直接的血缘关系,来自相互独立的两个分支。
|
||||
|
||||
对于上面两种情况,由于指向父结点的回边感觉不是很有意义,我们在bfs中不加以区分,全部统一标记为跨边。因此bfs的算法可以表述如下:
|
||||
|
||||
@@ -109,7 +138,7 @@ void Graph<Tv, Te>::bfs(int start){
|
||||
|
||||
由上面所述,bfs显然可以用来进行连通性的检测。凡是连通的顶点都可以在一次BFS中被发现,因此对一个图进行bfs,调用BFS的次数,即是图的连通分量的个数。
|
||||
|
||||
此外,由bfs访问顶点的次序可以看出,bfs总是优先访问离当前顶点最近的顶点,这使得bfs可以用以发现图中的最短路径。但是这里的最短路径只能是拓扑结构的最短路径,或者说无权图,或者等权图的最短路径。
|
||||
此外,由bfs访问顶点的次序可以看出,bfs总是优先访问离当前顶点最近的顶点,这使得bfs可以用以发现图中的最短路径。但是这里的最短路径只能是拓扑结构的最短路径,或者说无权图,或者等权图的最短路径。实际上,bfs是`Dijkstra`算法在所有边的权重都是1时的退化情况。
|
||||
|
||||
然后,邓公说还可以用来做连通分量的分解,无向图的环路检测,以后再探索一下。
|
||||
|
||||
@@ -124,7 +153,7 @@ dfs其实等价于树的先序遍历。简单说来,就是尽可能深地去
|
||||
+ 前向边:与回边相对应,表示指向后继结点的边。当访问到的邻居状态为`VISITED`,并且访问时间晚于当前顶点时,就会出现前向边。
|
||||
+ 跨边:类似于bfs中出现过的跨边,表示当前顶点与被访问到的邻居没有直接血缘关系。当访问到的邻居状态为`VISITED`,并且访问时间早于当前顶点时,就会出现跨边。
|
||||
|
||||
需要注意的是,这里的边的信息仅限于有向图。这是因为,在无向图中,区分回边和前向边没有意义,因为有一条回边就会有一条后向边,两者完全等效。此外,无向图中不存在跨边(我说不清楚,自己思考一下吧)。
|
||||
需要注意的是,这里的边的信息仅限于有向图。这是因为,在无向图中,区分回边和前向边没有意义,因为有一条回边就会有一条后向边,两者完全等效。实际上,在无向图中根本不会出现`VISITED`这种情形。
|
||||
|
||||
除了边的信息以外,根据上面的讨论,还需要记录各个顶点被发现以及被访问的时间,分别记为`dtime`和`ftime`。至此,DFS的代码可以表述如下:
|
||||
|
||||
@@ -180,7 +209,7 @@ dfs是图遍历算法中最重要的一个。大量与图相关的算法都是
|
||||
|
||||
事物之间往往会有一个依赖关系,因此形成一个了先后次序关系。比如我得先学好`Vector`和`List`,才能来学习依赖于两者的树,从而才能学习依赖于树的图结构。它们之间的这种次序就构成了一个拓扑顺序。
|
||||
|
||||
对于一般的有向图而言,就是要找到一个线性序列,使得对于任意个顶点x,排在它后面的顶点一定不会是当前顶点的前驱顶点。这个序列表示在访问x之前,x的所有前驱顶点一定要首先被访问。这个序列就是原图的一个拓扑排序(Topological Sort)。
|
||||
对于一般的有向图而言,就是要找到一个线性序列,使得对于任意顶点x,排在它后面的顶点一定不会是当前顶点的前驱顶点。这个序列表示在访问x之前,x的所有前驱顶点一定要首先被访问。这个序列就是原图的一个拓扑排序(Topological Sort)。
|
||||
|
||||
要注意拓扑排序通常都是研究有向图,因为它反映了事物的一个先后次序。无向图不具有这样一个先后的次序,研究它的拓扑排序是没有意义的。
|
||||
|
||||
@@ -198,7 +227,7 @@ dfs是图遍历算法中最重要的一个。大量与图相关的算法都是
|
||||
|
||||
> 基于dfs的拓扑排序算法
|
||||
|
||||
其实,我们对比拓扑排序的定义以及dfs遍历的次序,可以发现它们之间具有某种相似性。对于拓扑排序而言,每次是选择入度为零的顶点加入拓扑排序序列;而对于dfs而言,只有当一个顶点的所有邻居都访问完毕后,这个顶点才会被标记`VISITED`,这样dfs中第一个被访问的顶点,必然是出度为零的顶点,而倘若没访问结束一个顶点,就将该顶点删除,那么dfs每一步都是访问出度为零的顶点。可以看出,dfs的访问次序,恰好是拓扑排序的逆序。
|
||||
其实,我们对比拓扑排序的定义以及dfs遍历的次序,可以发现它们之间具有某种相似性。对于拓扑排序而言,每次是选择入度为零的顶点加入拓扑排序序列;而对于dfs而言,只有当一个顶点的所有邻居都访问完毕后,这个顶点才会被标记`VISITED`,这样dfs中第一个被访问的顶点,必然是出度为零的顶点,而倘若每访问结束一个顶点,就将该顶点删除,那么dfs每一步都是访问出度为零的顶点。可以看出,dfs的访问次序,恰好是拓扑排序的逆序。
|
||||
|
||||
其实,上面的结论并不是偶然,而是由dfs的特性决定的--在dfs中,对于当前顶点x,总是需要优先访问完所有依赖于它的顶点,才能进行对x的访问。这恰好是拓扑排序的对称情况。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user