在计算机程序设计中,图是最常用的结构之一。一般来说,用图来帮助解决的问题类型与本书中已经讨论过的问题类型有很大差别。如果处理一般的数据存储问题,可能用不到图,但对某些问题,图是必不可少的。
图是一种与树有些相像的数据结构。实际上,从数学意义上说,树是图的一种。然而,在计算机程序设计中,图的应用方式与树不同。
图通常有一个固定的形状,这是由物理或抽象的问题所决定的。例如,图中节点表示城市,而边可能表示城市间的班级航线。另一个更抽象的例子是一个代表了几个单独任务的图,这些任务是完成一个项目所必须的。在图中,节点可能代表任务,有向边(在一端带一个箭头)指示某个任务必须在另一个任务前完成。在这种情形下,图的形状取决于真实世界的具体情况。
当讨论图时,节点通常叫做顶点。
图只是显示了顶点和边的关系——即,哪些边连接着哪些顶点。它本身并不涉及物理的远近和方向。
邻接
如果两个顶点被同一条边连接,就称这两个顶点是邻接的。和某个指定顶点邻接的顶点有时叫做它的邻居。
路径
路径是边的序列。
连通图
如果至少有一条路径可以连接起所有的顶点,那么这个图通常被称作连通的,反之就是非连通的。
非连通图包含几个连通子图。
有向图和带权图
图中的边没有方向,可以从任意一边到另一边,称作无向图。只能沿着边朝一个方向移动的图称为有向图,允许移动的方向在图中通常用边一端的箭头表示。
在某些图中,边被赋予一个权值,权值是一个数字,它能代表两个顶点间的物理距离,或者从一个顶点到另一个顶点的时间,或者是两点间的花费。这样的图叫做带权图。
为了向图中添加顶点,必须用new保留字生成一个新的顶点对象,然后插入到顶点数组vertexList中。在模拟真实世界的程序中,顶点可能包含许多数据项,但是为了简便起见,这里假定顶点只包含单一的字符。因此顶点的创建用下面的代码:
vertexList[nVerts++]=new Vertex(‘F’);
这就插入了顶点F,nVerts变量是图中当前顶点数。
怎样添加边取决于用邻接矩阵还是用邻接表表示图。假定使用邻接矩阵并考虑在顶点1和顶点3之间加一条边。这些数字对应vertexList数组的下标,顶点存储在数组的对应位置。首次创建邻接矩阵adjMat时,初值为0。添加边的代码如下:
adjMat[1][3]=1;
adjMat[3][1]=1;
如果使用邻接表,就把1加到3的链表中,然后把3加到1的链表中。
在Graph类中,vertexList数组的下标唯一地表示一个顶点。
在图中实现的最基本的操作之一,就是搜索从一个指定顶点可以到达哪些顶点。还有另一种情形可能需要找到所有当前顶点可到达的顶点。
假设已经创建了这么一个图。现在需要一种算法来提供系统的方法,从某个特定顶点开始,沿着边移动到其他顶点。移动完毕后,要保证访问了和起始点相连的每一个顶点。访问意味着在顶点上的某种操作,例如显示操作。
有两种常用的方法可以用来搜索图:即深度优先搜索(DFS)和广度优先搜索(BFS)。它们最终都会到达所有连通的顶点。深度优先搜索通过栈来实现,而广度优先搜索通过队列实现。不同的机制导致了搜索的不同方式。
在搜索到尽头的时候,深度优先搜索用栈记住下一步的走向。
图中的数字显示了顶点被访问的顺序。
为了实现深度优先搜索,找一个起始点——本例为顶点A。需要做三件事:首先访问该顶点,然后把该顶点放入栈中,一遍记住它,最后标记该点,这样就不会再访问他。
接着开始访问与A相连的顶点,假设顶点按字母访问,所以下一步访问顶点B。然后标记它,放入栈中。
现在已经访问了顶点B,相同的事情:找下一个未访问的顶点,也就是F。这个过程称作规则1:
规则1
如果可能,访问一个邻接的问访问顶点,标记它,并把它放入栈中。
再次应用规则1,访问顶点H。因为没有和H邻接的未访问顶点,下面是规则2:
规则2
当不能执行规则1时,如果栈不空,就从栈中弹出一个顶点。
根据这条规则,从栈中弹出H,这样就又回到了顶点F。F也没有与之邻接且未访问的顶点了。那么再弹出F,依此类推。这时只有顶点A在栈中。
然而A还有未访问的邻接点,所以访问下一个顶点C,因为C是这条线的终点,所以从栈中弹出它,再次回到A点。接着访问D、G、I。当到达I时,弹出返回A顶点,接着访问E顶点,回到A顶点。
最终A也没有未访问的邻接点,所以把它也弹出栈。现在栈中已无顶点。下面是规则3:
规则3
如果不能执行规则1和规则2,就完成了整个搜索过程。
栈的内容就是刚才从起始顶点到各个顶点访问的整个过程。从起始顶点出发访问下一个顶点时,就把这个顶点入栈。回到起始顶点时,出栈。访问顶点的顺序是ABFHCDGIE。
深度优先搜索算法要得到距离起始点最远的顶点,然后在不能继续前进的时候返回。使用深度这个术语表示与起始点的距离,便可以理解“深度优先搜索”的意义。
正如深度优先搜索中看到的,算法表现得好像要尽快地远离起始点似的。相反,在广度优先搜索中,算法好像要尽可能地靠近起始点。它首先访问起始顶点地所有邻接点,然后再访问较远地区域。这种搜索不能用栈,而要用队列来实现。
使用最少的边连接所有顶点,这组成了最小生成树(MST)。
对于给定的一组顶点,可能有很多种最小生成树。最小生成树边E的数量总比顶点V的数量小1:
E=V-1
attention:不必关心边的长度。并不需要找到一条最短的路径,而是要找最少数量的边。
创建最小生成树的算法与搜索的算法几乎是相同的。它同样可以基于广度优先搜索或深度优先搜索。
在执行深度优先搜索过程中,记录走过的边,就可以创建一棵最小生成树。
最小生成树比较容易从深度优先搜索得到,这是因为DFS访问所有顶点,但只访问一次。它绝不会两次访问同一个顶点。当它看到某条边将到达一个以访问的顶点,就不会走这条边。它不遍历那些不可能的边。因此,DFS算法走过整个图的路径必定是最小生成树。
拓扑排序是可以用图模拟的另一种操作。它可用于表示一种情况,即某些项目或事件必须按特定的顺序排列或发生。
当边有方向时,图叫做有向图。在有向图中,只能沿着边指定的方向移动。
有向图和无向图的区别是有向图的边在邻接矩阵中只有一项。
对于前面讨论的无向图,邻接矩阵的上下三角是对称的,所以一半的信息是冗余的。而有向图的邻接矩阵中所有行列值都包含必要的信息。它的上下三角是不对称的。
A —— >> B
如果用邻接表表示,那么A在它的链表中有B,但不同于无向图的是,B的链表中不包含A。
将图按照先后关系进行排列,叫做为图进行拓扑排序。当用算法生成一个拓扑序列时,使用的方法和代码的细节决定了产生哪种拓扑序列。
工作进度是一个重要例子。用图对工作进度建模叫做关键路径分析。
拓扑排序算法的思想虽然不寻常但是很简单。有两个步骤是必需的:
步骤1:
找到一个没有后继的顶点。
顶点的后继也是一些顶点,它们是该节点的直接“下游”——即,该节点与它们由一条边相连,并且边的方向指向它们。如果有一条边从A指向B,那么B是A的后继。
步骤2
从图中删除这个顶点,在列表的前面插入顶点的标记。
重复步骤1和步骤2,直到所有顶点都从图中删除。这时,列表显示的顶点顺序就是拓扑排序的结果。
删除顶点似乎是一个极端步骤了,但是它是算法的核心。如果第一个顶点不处理,算法就不能计算出要处理的第二个顶点。如果需要,也可以在其他地方存储图的数据(顶点列表或邻接矩阵),然后在排序完成后恢复它们。
算法能够执行是因为,如果一个顶点没有后继,那么它肯定是拓扑序列中的最后一个。一旦删除它,剩下的顶点中必然有一个没有后继,所以它成为下一个拓扑序列中的最后一个,依此类推。
拓扑排序算法既可以用于连通图,也可以用于非连通图。这可以模拟另外一种有两个互不相关的目标的情况。
有一种图是拓扑排序算法不能处理的,那就是有环图。什么是环?它是一条路径,路径的起点和终点都是同一个顶点。
不包含环的图叫做树。二叉树和多叉树就是这个意义上的树。然而在图中提出的树比二叉树和多叉树更具有一般意义,因为二叉树和多叉树定死了子节点的最大个数。在图中,树的顶点可以连接任意数量的顶点,只要不存在环即可。
要计算出无向图是否存在环也很简单。如果有N个顶点的图有超过N-1条边,那么它必定存在环。
拓扑排序必须在无环的有向图中进行。这样的图叫做有向无环图,缩写为DAG。
关于连通性的问题是:如果从一个指定的顶点出发,能够到达哪些顶点?
有向图的连通性表:依次从每个顶点开始搜索,后面的是能够到达的顶点(直接或者通过其他顶点)。
在有些应用中,需要快速地找出是否一个顶点可以从其他顶点到达。
可以检索连通性表,但是那要查看指定行地所有表项,需要O(N)地时间(N是指定顶点可到达的顶点数目平均值)。
可以构造一个表,这个表将立即(即,O(1)的复杂度)告知一个顶点对另一个点是否是可达的。这样的表可以通过系统地修改图的邻接矩阵得到。这种修正过的邻接矩阵表示的图,叫做原图的传递闭包。
attention:在原来的邻接矩阵中,行号代表从哪里开始,列号代表边到哪里结束。(在连通表中排列是类似的。)行C列D的交叉点为1,表示从顶点C到顶点D有一条边。可以一步从一个顶点到另一个顶点。
可以用Warshall算法把邻接矩阵变成图的传递闭包。算法用有限的几行做了很多工作。它基于一个简单的思想:
如果能从顶点L到M,并且能从顶点M到N,那么可以从L到N。
这里已经从两个一步路径,到一个两步路径。邻接矩阵显示了所有的一步路径,所以它可以很好的应用这个规则。
实现Warshall算法的一个方法是用三层嵌套的循环。外层循环考察每一行:称它为y变量。它里面的一层循环考察行中的每个单元,它使用变量x。如果在单元(x,y)发现1,那么表明有一条边从y到x,这时执行最里层循环,它使用变量z。
第三个循环检查列y的每个单元,看是否有边以y为终点。(attention:y在第一个循环中作为行,在这个循环中作为列。)如果行z列y的值为1,说明有一条边从z到y。一条边从z到y,另一条从y到x,就可以说有一条路径从z到x,所以把(z,x)置为1。
带权图中,边带有一个数字,它叫做权,它可能代表距离、耗费、时间或其他意义。
计算机算法(除非它可能是神经网络)不能一次“知道”给定问题的所有数据;它不能处理全面的大图,只能一点一点地得到数据,随着处理过程地深入,不断修改问题地结果。对于图来说,算法从某个顶点开始工作,首先得到这个点附近的数据,然后找到更远顶点的数据。
重复这些步骤,知道所有顶点都在树的集合中。这时,工作完成。
在步骤1中,“最新的”意味着最近放入树中的。此步骤的边可以在邻接矩阵中找到。步骤1完成后,表中包含了所有的边,这些边都是从树中顶点到它们的不在树中的邻接点(边缘点)的连接。
可能在带权图中最常遇到的问题就是,寻找两点之间的最短路径问题。
这里所说的最短并不总是指距离上的最短;它也可以代表最便宜,最快或最好的路径,或其他衡量标准。
为解决最短路径问题而提出的方法叫做Dijkstra算法,这个算法的实现基于图的邻接矩阵表示法。它不仅能够找到任意两点间的最短路径,还可以找到某个指定点到其他所有顶点的最短路径。
最短路径算法的关键数据结构是一个数组,它保持了从源点到其他顶点(终点)的最短路径。在算法执行过程中这个距离是变化的,直到最后,它存储了从源点开始的真正最短距离。
不仅应该记录从源点到每个终点的最短路径,还应该记录走过的路径,但不必要明确记录整个路径,只需要记录终点的父节点(即到达终点前到达的顶点)。
在带权图中,用一张表给出任意两个顶点间的最低耗费,这对顶点可能通过几条边相连。这种问题叫做每一对顶点之间的最短路径问题。
Warshall算法可以很快的创建这样的表,来显示从某个顶点通过一步或若干步到达其他顶点。带权图中类似的方法叫做Floyd算法。Floyd算法的实现与Warshall算法类似。然而,在Warshall算法中当找到一个两段路径时,只是简单的在表中插入1,在Floyd算法中,需要把两端的权值相加,并插入它们的和。
图的表示方法有2种,邻接矩阵和邻接表。
如果使用邻接矩阵,前面讨论的算法大多需要O(V2)的时间级,这里V是顶点的数量。因为它们几乎都检查了一遍所有的顶点,具体方法是在邻接矩阵中扫描每一行,依次查看每一条边。换句话讲,邻接矩阵的每个单元,一共有V2个单元,都被扫描过。
对于规模大的矩阵,O(V2)的时间级不是非常好的性能。如果图是密集的,那就没什么提高性能的余地。
然而许多图是稀疏的,与稠密相反。其实并没有一个确定数量的定义说明多少条边的图是稠密或稀疏的,但是如果在一个大图中每个顶点只有很少几条边相连,那么这个图通常被认为是稀疏的。
在稀疏图中,使用邻接表的表示方法代替邻接矩阵,可以改善运行时间。(因为不必浪费时间来检索邻接矩阵中没有边的单元。)
对于无权图,邻接表的深度优先搜索需要O(V+E)的时间级,这里V是顶点的数量,E是边的数量。对于带权图,最小生成树算法和最短路径算法都需要O((E+V)logV)的时间级,在大型的稀疏图中,与邻接矩阵方法的时间级相比,这样的时间级可使性能大幅提升。当然,算法会更加复杂一些。
Warshall算法和Floyd算法执行需要O(V3)的时间级。这是因为在它们的实现中使用了三层嵌套的循环。
来源:《数据结构》