【程序人生】数据结构杂记(六)
说在前面
个人读书笔记
图的概念
图结构是描述和解决实际应用问题的一种基本而有力的工具。
所谓的图(graph),可定义为。其中,集合中的元素称作顶点(vertex);集合中的元素分别对应于中的某一对顶点,表示它们之间存在某种关系,故亦称作边(edge)。
一种直观显示图结构的方法是,用小圆圈或小方块代表顶点,用联接于其间的直线段或曲线弧表示对应的边。
从计算的需求出发,我们约定和均为有限集。
无向图、有向图及混合图
若边所对应顶点和的次序无所谓,则称作无向边(undirected edge),例如表示同学关系的边。
反之若和不对等,则称为有向边(directed edge),例如描述企业与银行之间的借贷关系,或者程序之间的相互调用关系的边。
如此,无向边也可记作,而有向的和则不可混淆。这里约定,有向边从指向,其中称作该边的起点(origin)或尾顶点(tail),而称作该边的终点(destination)或头顶点(head)。
若中各边均无方向,则称作无向图。若中只含有向边,则称作有向图。若同时包含无向边和有向边,则称作混合图。
相对而言,有向图的通用性更强,因为无向图和混合图都可转化为有向图:
每条无向边都可等效地替换为对称的一对有向边和。
度
对于任何边,称顶点和彼此邻接(adjacent),互为邻居;而它们都与边彼此关联(incident)。
在无向图中,与顶点关联的边数,称作的度数(degree),记作。
以上图中无向图为例,顶点{ A, B, C, D }的度数为{ 2, 3, 2, 1 }。
对于有向边,称作的出边(outgoing edge)、的入边(incoming edge)。的出边总数称作其出度(out-degree),记作;入边总数称作其入度(in-degree),记作。
在上图中的有向图中,各顶点的出度为{ 1, 3, 1, 1 },入度为{ 2, 1, 2, 1 }。
简单图
联接于同一顶点之间的边,称作自环(self-loop)。在某些特定的应用中,这类边可能的确具有意义——比如在城市交通图中,沿着某条街道,有可能不需经过任何交叉路口即可直接返回原处。
不含任何自环的图称作简单图(simple graph)。
通路和环路
所谓路径或通路(path),就是由个顶点与条边交替而成的一个序列,且对任何都有。也就是说,这些边依次地首尾相联。其中沿途边的总数,亦称作通路的长度。
尽管通路上的边必须互异,但顶点却可能重复。沿途顶点互异的通路,称作简单通路
特别地,对于长度的通路,若起止顶点相同(即),则称作环路(cycle),其长度也取作沿途边的总数。同样,尽管环路上的各边必须互异,但顶点却也可能重复。反之,若沿途除外所有顶点均互异,则称作简单环路。
特别地,经过图中各边一次且恰好一次的环路,称作欧拉环路(Eulerian tour)——其长度恰好等于图中边的总数。经过图中各顶点一次且恰好一次的环路,称作哈密尔顿环路(Hamiltonian tour)。
不含任何环路的有向图,称作有向无环图
带权网络
图不仅需要表示顶点之间是否存在某种关系,有时还需要表示这一关系的具体细节。以铁路运输为例,可以用顶点表示城市,用顶点之间的联边表示对应的城市之间是否有客运铁路联接;同时,往往还需要记录各段铁路的长度、承运能力,以及运输成本等信息。
为适应这类应用要求,需通过一个权值函数,为每一边指定一个权重(weight),比如即为边的权重。各边均带有权重的图,称作带权图(weighted graph)或带权网络(weightednetwork),有时也简称网络(network),记作。
图ADT的实现方法
邻接矩阵
邻接矩阵(adjacency matrix)是图ADT(Abstract Data Type)最基本的实现方式,使用方阵表示由个顶点构成的图,其中每个单元,各自负责描述一对顶点之间可能存在的邻接关系,故此得名。
对于无权图,存在(不存在)从顶点到的边,当且仅当。上图中和即为无向图和混合图的邻接矩阵实例。
对于带权网络,如上图中所示,矩阵各单元可从布尔型改为整型或浮点型,记录所对应边的权重。对于不存在的边,通常统一取值为无穷大或0。
邻接表
以如上图中所示的无向图为例,只需将如上图中所示的邻接矩阵,逐行地转换为如上图中所示的一组列表,即可分别记录各顶点的关联边(或等价地,邻接顶点)。这些列表,也因此称作邻接表(adjacency list)。
图遍历算法
无论采用何种策略和算法,图的遍历都可理解为,将非线性结构转化为半线性结构的过程。
经遍历而确定的边类型中,最重要的一类即所谓的树边,它们与所有顶点共同构成了原图的一棵支撑树(森林),称作遍历树(traversal tree)。
以遍历树为背景,其余各种类型的边,也能提供关于原图的重要信息,比如其中所含的环路等。
图中顶点之间可能存在多条通路,故为避免对顶点的重复访问,在遍历的过程中,通常还要动态地设置各顶点不同的状态,并随着遍历的进程不断地转换状态,直至最后的“访问完毕”。
图的遍历更加强调对处于特定状态顶点的甄别与查找,故也称作图搜索(graph search)。
广度优先搜索
各种图搜索之间的区别,体现为边分类结果的不同,以及所得遍历树(森林)的结构差异。
其决定因素在于,搜索过程中的每一步迭代,将依照何种策略来选取下一接受访问的顶点。
通常,都是选取某个已访问到的顶点的邻居。同一顶点所有邻居之间的优先级,在多数遍历中不必讲究。因此,实质的差异应体现在,当有多个顶点已被访问到,应该优先从谁的邻居中选取下一顶点。
比如,广度优先搜索(breadth-first search, BFS)采用的策略,可概括为:
越早被访问到的顶点,其邻居越优先被选用
于是,始自图中顶点的BFS搜索,将首先访问顶点;再依次访问所有尚未访问到的邻居;再按后者被访问的先后次序,逐个访问它们的邻居;…;如此不断。
在所有已访问到的顶点中,仍有邻居尚未访问者,构成所谓的波峰集(frontier)(用队列实现)。于是,BFS搜索过程也可等效地理解为:
反复从波峰集中找到最早被访问到顶点v,若其邻居均已访问到,则将其逐出波峰集;否则,随意选出一个尚未访问到的邻居,并将其加入到波峰集中。
不难发现,若将上述BFS策略应用于树结构,则效果等同于层次遍历。波峰集内顶点的深度始终相差不超过一,且波峰集总是优先在更浅的层次沿广度方向拓展。
仿照树的层次遍历,这里也借助队列,来保存已被发现,但尚未访问完毕的顶点。
因此,任何顶点在进入该队列的同时,都被随即标记为DISCOVERED(已发现)状态。
BFS()的每一步迭代,都先从中取出当前的首顶点;再逐一核对其各邻居的状态并做相应处理;最后将顶点置为VISITED(访问完毕)状态,即可进入下一步迭代。
若顶点尚处于UNDISCOVERED(未发现)状态,则令其转为DISCOVERED状态,并随即加入队列。
实际上,每次发现一个这样的顶点,都意味着遍历树可从到拓展一条边。于是,将边标记为树边(tree edge),并按照遍历树中的承袭关系,将记作的父节点。
若顶点已处于DISCOVERED状态(无向图),或者甚至处于VISITED状态(有向图),则意味着边不属于遍历树,于是将该边归类为跨边(cross edge)。
BFS()遍历结束后,所有访问过的顶点通过parent[]指针依次联接,从整体上给出了原图某一连通或可达域的一棵遍历树,称作广度优先搜索树,或简称BFS树(BFS tree)。
实例:
不难看出,BFS(s)将覆盖起始顶点所属的连通分量或可达分量,但无法抵达此外的顶点。
而上层主函数bfs()的作用,正在于处理多个连通分量或可达分量并存的情况。具体地,在逐个检查顶点的过程中,只要发现某一顶点尚未被发现,则意味着其所属的连通分量或可达分量尚未触及,故可从该顶点出发再次启动BFS(),以遍历其所属的连通分量或可达分量。
如此,各次BFS()调用所得的BFS树构成一个森林,称作BFS森林(BFS forest)。
深度优先搜索
深度优先搜索(Depth-First Search, DFS)选取下一顶点的策略,可概括为:
优先选取最后一个被访问到的顶点的邻居
于是,以顶点为基点的DFS搜索,将首先访问顶点;再从所有尚未访问到的邻居中任取其一,并以之为基点,递归地执行DFS搜索。
故各顶点被访问到的次序,类似于树的先序遍历;而各顶点被访问完毕的次序,则类似于树的后序遍历。
每一递归实例中,都先将当前节点标记为DISCOVERED(已发现)状态,再逐一核对其各邻居的状态并做相应处理。待其所有邻居均已处理完毕之后,将顶点置为VISITED(访问完毕)状态,便可回溯。
若顶点尚处于UNDISCOVERED(未发现)状态,则将边归类为树边(tree edge),并将记作的父节点。此后,便可将作为当前顶点,继续递归地遍历。
若顶点处于DISCOVERED状态,则意味着在此处发现一个有向环路。此时,在DFS遍历树中必为的祖先,故应将边归类为后向边(back edge)。
这里为每个顶点都记录了被发现的和访问完成的时刻,对应的时间区间均称作的活跃期(active duration)。实际上,任意顶点和之间是否存在祖先/后代的“血缘”关系,完全取决于二者的活跃期是否相互包含。
对于有向图,顶点还可能处于VISITED状态。此时,只要比对与的活跃期,即可判定在DFS树中是否为的祖先。若是,则边应归类为前向边(forward edge);否则,二者必然来自相互独立的两个分支,边(v, u)应归类为跨边(cross edge)。
DFS(s)返回后,所有访问过的顶点通过parent[]指针依次联接,从整体上给出了顶点所属连通或可达分量的一棵遍历树,称作深度优先搜索树或DFS树(DFS tree)。
与BFS搜索一样,此时若还有其它的连通或可达分量,则可以其中任何顶点为基点,再次启动DFS搜索。最终,经各次DFS搜索生成的一系列DFS树,构成了DFS森林(DFS forest)。
实例:
最终结果如上图中所示,为包含两棵DFS树的一个DFS森林。可以看出,选用不同的起始基点,生成的DFS树(森林)也可能各异。如本例中,若从D开始搜索,则DFS森林可能如上图中所示。
深度优先搜索的应用——拓扑排序
问题描述:
给定描述某一实际应用(上图中)的有向图(上图中),如何在与该图“相容”的前提下,将所有顶点排成一个线性序列(上图中)。
此处的“相容”,准确的含义是:
每一顶点都不会通过边,指向其在此序列中的前驱顶点。
这样的一个线性序列,称作原有向图的一个拓扑排序。
同一有向图的拓扑排序未必唯一,一个有向图也未必一定存在拓扑排序。
有向无环图一定存在拓扑排序。
思路一:
任一有向无环图必包含入度为零的顶点。否则,每个顶点都至少有一条入边,意味着要么顶点有无穷个,要么包含环路。
于是,只要将入度为0的顶点(及其关联边)从图中取出,则剩余的依然是有向无环图,故其拓扑排序也必然存在。从递归的角度看,一旦得到了的拓扑排序,只需将作为最大顶点插入,即可得到的拓扑排序。
思路二:
有向无环图的DFS搜索过程中各顶点被标记为VISITED的次序,恰好(按逆序)给出了原图的一个拓扑排序。
相对于标准的DFS搜索算法,这里增设了一个栈结构。一旦某个顶点被标记为VISITED状态,便随即令其入栈。如此,当搜索终止时,所有顶点即按照被访问完毕的次序——亦即拓扑排序的次序-——在栈中自顶而下排列。
最小支撑树
连通图的某一无环连通子图若覆盖中所有的顶点,则称作的一棵支撑树或生成树(spanning tree)。
就保留原图中边的数目而言,支撑树既是“禁止环路”前提下的极大子图,也是“保持连通”前提下的最小子图。在实际应用中,原图往往对应于由一组可能相互联接(边)的成员(顶点)构成的系统,而支撑树则对应于该系统最经济的联接方案。确切地,尽管同一幅图可能有多棵支撑树,但由于其中的顶点总数均为n,故其采用的边数也均为n - 1。
若图为一带权网络,则每一棵支撑树的成本(cost)即为其所采用各边权重的总和。在的所有支撑树中,成本最低者称作最小支撑树(minimum spanning tree, MST)。
尽管同一带权网络通常都有多棵支撑树,但总数毕竟有限,故必有最低的总体成本。然而,总体成本最低的支撑树却未必唯一。
Prim算法
假定各边的权重互异。
图中,顶点集的任一非平凡子集及其补集V\U都构成的一个割(cut),记作(U : V\U)。若边满足属于,且不属于,则称作该割的一条跨越边(crossing edge)。
因此类边联接于及其补集之间,故亦形象地称作该割的一座桥(bridge)。
Prim算法的正确性基于以下事实:
最小支撑树总是会采用联接每一割的最短跨越边。
否则,如上图所示假设是割(U : V\U)的最短跨越边,而最小支撑树并未采用该边。于是由树的连通性,如图所示在中必有至少另一跨边联接该割(有可能或,尽管二者不能同时成立)。同样由树的连通性,中必有分别联接于和、和之间的两条通路。由于树是极大的无环图,故倘若将边加至中,则如图所示,必然出现穿过和的唯一环路。接下来,只要再删除边,则该环路必然随之消失。
经过如此的一出一入,若设转换为,则’依然是连通图,且所含边数与相同均为。这就意味着,也是原图的一棵支撑树。就结构而言,与的差异仅在于边和边,故二者的成本之差就是这两条边的权重之差。不难看出,边的权重必然大于身为最短跨越边的,故的总成本低于——这与总体权重最小的前提矛盾。
由以上性质,可基于贪心策略(当前看来是最好的选择)导出一个迭代式算法。
实例:
最短路径
若以带权图来表示真实的通讯、交通、物流或社交网络,则各边的权重可能代表信道成本、交通运输费用或交往程度。此时我们经常关心的一类问题可以概括为:
给定带权网络,以及源点(source)属于,对于所有的其它顶点,到的最短通路有多长?该通路由哪些边构成?
设顶点到的最短路径为。于是对于该路径上的任一顶点,若其在上对应的前缀为,则也必是到的最短路径(之一)。否则,若从到还有另一严格更短的路径,则易见不可能是到的最短路径。
即便各边权重互异,从到的最短路径也未必唯一。
当存在非正权重的边,并导致某个环路的总权值非正时,最短路径甚至无从定义。
最短路径不含任何(有向)回路。
Dijkstra算法
该算法与Prim算法仅有一处差异:
考虑的是到的距离,而不再是其到的距离。
实例:
结语
如果您有修改意见或问题,欢迎留言或者通过邮箱和我联系。
手打很辛苦,如果我的文章对您有帮助,转载请注明出处。