数据结构——图总结

目录

一、图的起源

二、图的定义与基本术语

三、图的存储结构

1.邻接矩阵

2.邻接表

3.十字链表

4.邻接多重表

四、 图的遍历

1、深度优先搜索(DFS)

2、广度优先搜索(BFS)

3、总结

五、图的连通性问题

1、无向图的连通分量

2、最小生成树

六、图的应用

1、拓扑排序

2、关键路径

3、最短路径

迪杰斯特拉算法(Dijkstra算法)

弗洛伊德算法(Floyd算法)


一、图的起源

众所周知,图论起源于一个非常经典的问题——柯尼斯堡(Konigsberg)问题。

十八世纪,有一座古老而美丽的城市叫做哥尼斯堡(今俄罗斯加里宁格勒)。布勒格尔河的两条支流在这里汇合,然后横贯全城,流入大海。河心有一个小岛。河水把城市分成了4块,于是,人们建造了7座各具特色的桥,把哥尼斯堡连成一体。 当时那里的居民都热衷于一种游戏:看谁能从某点出发一次走遍这七座桥,每座桥只走一次,最后回到原出发点。在众多尝试者中竟无一人成功。

 1738年,瑞士数学家欧拉( Leornhard Euler)解决了柯尼斯堡问题。由此图论诞生。欧拉也成为图论的创始人。

欧拉回路的判定规则:
1.如果通奇数桥的地方多于两个,则不存在欧拉回路;
2.如果只有两个地方通奇数桥,可以从这两个地方之一出发,找到欧拉回路;
3.如果没有一个地方是通奇数桥的,则无论从哪里出发,都能找到欧拉回路。

1859年,英国数学家汉密尔顿发明了一种游戏:用一个规则的实心十二面体,它的20个顶点标出世界著名的20个城市,要求游戏者找一条沿着各边通过每个顶点刚好一次的闭回路,即“绕行世界”。用图论的语言来说,游戏的目的是在十二面体的图中找出一个生成圈。这个生成圈后来被称为汉密尔顿回路。这个问题后来就叫做汉密尔顿问题。由于运筹学、计算机科学和编码理论中的很多问题都可以化为汉密尔顿问题,从而引起广泛的注意和研究。

图的起源大概看一下即可。

二、图的定义与基本术语

图是由顶点集合(Vertex)及顶点间的关系集合组成的一种数据结构:Graph=( V, E )

V = {x | x ∈某个数据对象 } 是顶点的有穷非空集合;

E ={ (x, y) | x, y ∈V } 是顶点之间关系的有穷集合,也叫做边(Edge)集合。

(注意:线性表中无元素,则为空表,树中无节点,则为空树,但在图中,顶点数不能为0,边数可以为0。)

相关术语:

无向边:若顶点 x 和 y 之间的边没有方向,则称该边为无向边(x, y),(x, y) 与 (y,x) 意义相同,表示 x 和 y 之间有连接。

无向图:若图中任意两个顶点之间的边均是无向边,则称该图为无向图。

有向边:若顶点 x 和 y 之间的边有方向,则称该边为有向边<x, y>,<x, y> 与 <y, x> 意义不同,表示从 x 连接到 y,x 称为尾,y 称为头。

有向图:若图中任意两个顶点之间的边均是有向边,则称该图为有向图。

在图中的两个重要关系是邻接和关联。

设用 表示图中顶点的个数,用表示图中边或弧的数目,并且不考虑图中每个顶点到其自身的边或弧。

无向完全图:n(n-1)/2条边(图中每个顶点和其余n-1个顶点都有边相连)的无向图为无向完全图。

有向完全图:n(n-1)条边(图中每个顶点和其余n-1个顶点都有弧相连)的有向图为有向完全图。

稀疏图:对于有很少条边的图(e < n log n)称为稀疏图,反之称为稠密图。 

邻接:是两个顶点之间的一种关系。如果图包含(u,v),则称顶点v与顶点u邻接。在无向图中,这也暗示了顶点u也与顶点v邻接。换句话说,在无向图中邻接关系是对称的。

关联:是指顶点和边之间的关系。在有向图中,边(u,v)从顶点u开始关联到v,或者相反,从顶点v开始关联到u。在无向图中,边(u,v)与顶点u和v相关联。

完全图:每个顶点都与其他顶点相邻接的图。

度(Degree)的定义:顶点 v 的度是和 v 相关联的边的数目,记为TD(v)。

入度:以 v 为头的边的数目,记为ID(v)

出度:以 v 为尾的边的数目,记为OD(v)

很显然:

TD(v) = ID(v) + OD(v)

E = [TD(v1) + TD(v2) + … + TD(vn)] / 2

E = ID(v1) + ID(v2) + … + ID(vn)

E = OD(v1) + OD(v2) + … + OD(vn)

权(Weight)的定义:与图的边相关的数字叫做权,权常用来表示图中顶点间的距离或者耗费。带权的图通常称为网。

路径:依次遍历顶点序列之间的边所形成的轨迹。没有重复顶点的路径称为简单路径。路径的长度是路径上的边或弧的数目。

环:指路径包含相同的顶点两次或两次以上。也就是说,在有向图的一条路径中,如果从某顶点出发,最后能够返回该顶点,则该路径是环。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路称为简单环或简单回路。

连通性(图中另一个重要的概念):

对于无向图而言:

(1)如果它的每个顶点都能通过某条路径到达其他顶点,那么我们称它为联通的。

(2)如果该条件在有向图中同样成立,则称该图是强连通。

(3)尽管无向图可能不是连通的,但它扔然可能包含连通的部分,这部分分支为连通分支。

(4)如果有向图中只有部分是强连通的,则该部分称为强连通分支。

某些特定的顶点对于保护图或连通分支的连通性有特殊的重要意义。如果移除某个顶点将使得图或某分支失去连通性,则称该顶点为关结点

简单图:简单图就是图中没有环,没有重边(两个顶点存在两条及以上的的边)。数据结构中讨论的是简单图。

邻接、依附:若两个顶点之间有边,则称为这两个点邻接。这个边也依附于这两个点。
(线性表中数据元素仅有线性关系,树中节点有层次关系,图中任意两个点都有可能有关系。)

完全图:任意两个顶点之间都存在边。(有向完全图则为任意两个顶点之间存在互相指向的边。)含n个顶点的完全图有 1/2 ×n(n-1)条边,有向完全图则为它的2倍。

稀疏、稠密图:根据边的多少判断。

顶点的度:度有入度和出度,该顶点的度就是依附于该点的边数。入度和出度是有向图里的,指向该点的边数为入度,从该点出发引出的边数则为出度。

权,网图:权是给边赋予有意义的值,带权的图称为网图。

路径、路径长度:从一个顶点到另一个顶点经过的顶点序列,v1v2v3…vi;
路径长度=路径经过的边数(无权),在网图中则为经过边数权值之和。

连通图、连通分量:图中任意两个顶点都存在路径,都可达,则称该图为连通图。非连通图的极大连通子图为连通分量。

生成树,生成森林:由图中所有的顶点构成的无回路的连通图。每个连通分量都可生成树,组成森林。

连通:从顶点V到顶点W有一条路径,则说V和W是连通的

连通图(Connected Graph):图中任意两个顶点都是连通的叫连通图

强连通图:有向图中,如果对每一对Vi,Vj∈V, Vi≠Vj,从Vi到Vj 和从Vj到 Vi都存在路径,则称G是强连通图

连通分量(Connected Component):非连通图每一个连通部分叫连通分量

注:上面部分术语可能被重复说明了。

三、图的存储结构

1.邻接矩阵

1.邻接矩阵

数组表示法:用两个数组分别存储数据元素(顶点)的信息和数据元素之间的关系。图的邻接矩阵存储方式是用两个数组来表示图,一个一维数组存储图中顶点的信息,一个二维数组(称为邻接矩阵)存储图的边或弧的信息。

无向图的边数组是一个对称矩阵。如下图表示:

 从这个矩阵我们能看出图中的一些信息(重中之重):

(1)我们要判断两个顶点之间有无边就非常容易了,为1则有边。
(2)我们要知道某个顶点的度,就是这个顶点在其这一列的元素之和。
(3)求顶点的邻接点就是讲矩阵中这一行元素扫描一遍,arc[i][j]为1就是邻接点。

那么我们现在来看一下下图的有向图:

对于有向网和无向网,需要在邻接矩阵中加上关系的权值,如果两个顶点之间有邻接关系,就用权值代替原来的1,否则就用无穷代替0。如下图:

2.邻接表

2.邻接表

邻接矩阵是不错的一种图存储结构, 但是我们也发现,对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的。比如说,如果我们要处理下图这样的稀疏有向图,邻接矩阵中除了arc[1][0]有权值外, 没有其他弧,其实这些存储空间都浪费掉了。

邻接表表示方法:数组和链表相结合的表示方法,通过头结点数组保存顶点信息,用单链表保存顶点之间的关系。这样我们可以轻易的知道每个节点的度。

对于有向图,我们要注意到有向图是有方向的,我们是以顶点为弧尾来存储边表的,这样很容易就可以得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点Vi都建立一个链接为v为弧头的表。

对于带权值的网图,可以在边表节点定义中在增加一个weight的数据域,存储权值信息即可。

3.十字链表

3.十字链表

那么对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢? 答案是肯定的,就是把它们整合在一起。这就是我们现在要讲的有向图的一种存储方法: 十字链表(Orthogonal List)。
我们重新定义顶点表节点结构:

其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指向该顶点的出边表中的第一个结点。重新定义边表节点结构:

其中tailvex是指弧起点在顶点表的下标,headvex是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink 是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一一个 weight域来存储权值。

我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。

对于vo来说,它有两个顶点V1和v2的入边。因此Vo的firstin 指向顶点V1的边表结点headvex为0的结点,如图中的①。接着由入边结点的headlink指向下一个入边顶点V2,如图中的②。

对于顶点V1,它有一个入边顶点V2,所以它的firstin指向顶点V2的边表结点headvex为1的结点,如图中的③。顶点V2和V3也是同样有一个入边顶点,如图中④和⑤。十字链表的好处就是因为把邻接表和逆邻接表整合在了一起, 这样既容易找到以vi为尾的弧,也容易找到以V1为头的弧,因而容易求得顶点的出度和入度。

而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。

4.邻接多重表

4.邻接多重表

重新定义边表节点结构:

其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。

ilink 指向依附顶点ivex的下一条边,jlink 指向依附顶点jvex的下一-条边。这就是邻接多重表结构。

我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如图所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。由于是无向图,所以ivex是0、jvex是1还是反过来都是无所谓的,不过为了绘图方便,都将ivex值设置得与一旁的顶点下标相同。

我们开始连线,如图。首先连线的①②③④就是将顶点的firstedge 指向一条边,顶点下标要与ivex的值相同,这很好理解。

接着,由于顶点vo的(vo,V1) 边的邻边有(Vo,V3) 和(Vo,V2)。 因此⑤⑥的连线就是满足指向下一条依附于顶点vo的边的目标,注意ilink指向的结点的jvex - 定要和它本身的ivex 的值相同。

同样的道理,连线⑦就是指(V1,Vo) 这条边,它是相当于顶点V1指向(V1,v2) 边后的下一条。V2有三条边依附,所以在③之后就有了⑧⑨。连线四的就是顶点V3在连线④之后的下一条边。 左图一共有5条边,所以右图有10条连线,完全符合预期。

四、 图的遍历

参考了博客:图的两种遍历方式

遍历是指从某个节点出发,按照一定的的搜索路线,依次访问对数据结构中的全部节点,且每个节点仅访问一次。

在二叉树基础中,介绍了对于树的遍历。树的遍历是指从根节点出发,按照一定的访问规则,依次访问树的每个节点信息。树的遍历过程,根据访问规则的不同主要分为四种遍历方式:

(1)先序遍历

(2)中序遍历

(3)后序遍历

(4)层次遍历

类似的,图的遍历是指,从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历。遍历过程中得到的顶点序列称为图遍历序列。

图的遍历过程中,根据搜索方法的不同,又可以划分为两种搜索策略:

(1)深度优先搜索(DFS,Depth First Search)

(2)广度优先搜索(BFS,Breadth First Search)
 

1、深度优先搜索(DFS)

(1)算法思想

深度优先搜索思想:假设初始状态是图中所有顶点均未被访问,则从某个顶点v出发,首先访问该顶点,然后依次从它的各个未被访问的邻接点出发做深度优先搜索遍历图,直至图中所有和v有路径相通的顶点都被访问到。若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
 

(2)算法特点
深度优先搜索是一个递归的过程。首先,选定一个出发点后进行遍历,如果有邻接的未被访问过的节点则继续前进。若不能继续前进,则回退一步再前进,若回退一步仍然不能前进,则连续回退至可以前进的位置为止。重复此过程,直到所有与选定点相通的所有顶点都被遍历。

深度优先搜索是递归过程,带有回退操作,因此需要使用栈存储访问的路径信息。当访问到的当前顶点没有可以前进的邻接顶点时,需要进行出栈操作,将当前位置回退至出栈元素位置。

图解过程:

无向图深度优先搜索:

以下图为例逐步讲解。

 (1)首先选取顶点A为起始点,输出A顶点信息,且将A入栈(stack),并标记A为已访问顶点。

(2)A的邻接顶点有C、D、F,从中任意选取一个顶点前进。这里我们选取C顶点为前进位置顶点。输出C顶点信息,将C入栈,并标记C为已访问顶点。当前位置指向顶点C。

(3)顶点C的邻接顶点有A、D和B,此时A已经标记为已访问顶点,因此不能继续访问。从B或者D中选取一个顶点前进,这里我们选取B顶点为前进位置顶点。输出B顶点信息,将B入栈,标记B顶点为已访问顶点。当前位置指向顶点B。

(4)顶点B的邻接顶点只有C、E,C已被标记,不能继续访问,因此选取E为前进位置顶点,输出E顶点信息,将E入栈,标记E顶点,当前位置指向E。

(5)顶点E的邻接顶点均已被标记,此时无法继续前进,则需要进行回退。将当前位置回退至顶点B,回退的同时将E出栈。

(6)顶点B的邻接顶点也均被标记,需要继续回退,当前位置回退至C,回退同时将B出栈。

(7)顶点C可以前进的顶点位置为D,则输出D顶点信息,将D入栈,并标记D顶点。当前位置指向顶点D。

(8)顶点D没有前进的顶点位置,因此需要回退操作。将当前位置回退至顶点C,回退同时将D出栈。

(9)顶点C没有前进的顶点位置,继续回退,将当前位置回退至顶点A,回退同时将C出栈。

(10)顶点A前进的顶点位置为F,输出F顶点信息,将F入栈,并标记F。将当前位置指向顶点F。

(11)顶点F的前进顶点位置为G,输出G顶点信息,将G入栈,并标记G。将当前位置指向顶点G。

(12)顶点G没有前进顶点位置,回退至F。当前位置指向F,回退同时将G出栈。

(13)顶点F没有前进顶点位置,回退至A,当前位置指向A,回退同时将F出栈。

(14)顶点A没有前进顶点位置,继续回退,栈为空,则以A为起始的遍历结束。若图中仍有未被访问的顶点(说明不是所有的顶点都是关联的),则选取未访问的顶点为起始点,继续执行此过程。直至所有顶点均被访问。

(15)采用深度优先搜索遍历顺序为A->C->B->E->D->F->G。
 

有向图深度优先搜索:

(1)以顶点A为起始点,输出A,将A入栈,并标记A。当前位置指向A。

(2)以A为尾的边只有1条,且边的头为顶点B,则前进位置为顶点B,输出B,将B入栈,标记B。当前位置指向B。

(3)顶点B可以前进的位置有C与F,选取F为前进位置,输出F,将F入栈,并标记F。当前位置指向F。

(4)顶点F的前进位置为G,输出G,将G入栈,并标记G。当前位置指向G。

(5)顶点G没有可以前进的位置,则回退至F,将G出栈。当前位置指向F。

(6)顶点F没有可以前进的位置,继续回退至B,将F出栈。当前位置指向B。

(7)顶点B可以前进位置为C和E,选取E,输出E,将E入栈,并标记E。当前位置指向E。

(8)顶点E的前进位置为D,输出D,将D入栈,并标记D。当前位置指向D。

(9)顶点D的前进位置为C,输出C,将C入栈,并标记C。当前位置指向C。

(10)顶点C没有前进位置,进行回退至D,回退同时将C出栈。

(11)继续执行此过程,直至栈为空,以A为起始点的遍历过程结束。若图中仍有未被访问的顶点,则选取未访问的顶点为起始点,继续执行此过程。直至所有顶点均被访问。
 

算法分析

当图采用邻接矩阵存储时,由于矩阵元素个数为n^2,因此时间复杂度就是O(n^2)

当图采用邻接表存储时,邻接表中只是存储了边结点(e条边,无向图也只是2e个结点),加上表头结点为n(也就是顶点个数),因此时间复杂度为O(n+e)

2、广度优先搜索(BFS)

(1)算法思想

广度优先搜索思想:从图中某顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使得“先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问,直至图中所有已被访问的顶点的邻接点都被访问到。如果此时图中尚有顶点未被访问,则需要另选一个未曾被访问过的顶点作为新的起始点,重复上述过程,直至图中所有顶点都被访问到为止。

(2)算法特点
广度优先搜索类似于树的层次遍历,是按照一种由近及远的方式访问图的顶点。在进行广度优先搜索时需要使用队列存储顶点信息。

图解过程:

无向图的广度优先搜索:

以下图为例逐步讲解。

 (1)选取A为起始点,输出A,A入队列,标记A,当前位置指向A。

(2)队列头为A,A出队列。A的邻接顶点有B、E,输出B和E,将B和E入队,并标记B、E。当前位置指向A。

 (3)队列头为B,B出队列。B的邻接顶点有C、D,输出C、D,将C、D入队列,并标记C、D。当前位置指向B。

 (4)队列头为E,E出队列。E的邻接顶点有D、F,但是D已经被标记,因此输出F,将F入队列,并标记F。当前位置指向E。

 (5)队列头为C,C出队列。C的邻接顶点有B、D,但B、D均被标记。无元素入队列。当前位置指向C。

(6)队列头为D,D出队列。D的邻接顶点有B、C、E,但是B、C、E均被标记,无元素入队列。当前位置指向D。

 (7)队列头为F,F出队列。F的邻接顶点有G、H,输出G、H,将G、H入队列,并标记G、H。当前位置指向F。

 (8)队列头为G,G出队列。G的邻接顶点有F,但F已被标记,无元素入队列。当前位置指向G。

(9)队列头为H,H出队列。H的邻接顶点有F,但F已被标记,无元素入队列。当前位置指向H。

(10)队列空,则以A为起始点的遍历结束。若图中仍有未被访问的顶点,则选取未访问的顶点为起始点,继续执行此过程。直至所有顶点均被访问。

有向图的广度优先搜索:

(1)选取A为起始点,输出A,将A入队列,标记A。

(2)队列头为A,A出队列。以A为尾的边有两条,对应的头分别为B、C,则A的邻接顶点有B、C。输出B、C,将B、C入队列,并标记B、C。

(3)队列头为B,B出队列。B的邻接顶点为C,C已经被标记,因此无新元素入队列。

(4)队列头为C,C出队列。C的邻接顶点有E、F。输出E、F,将E、F入队列,并标记E、F。

(5)队列头为E,E出队列。E的邻接顶点有G、H。输出G、H,将G、H入队列,并标记G、H。

 (6)队列头为F,F出队列。F无邻接顶点。

 (7)队列头为G,G出队列。G无邻接顶点。

 (8)队列头为H,H出队列。H邻接顶点为E,但是E已被标记,无新元素入队列。

(9)队列为空,以A为起始点的遍历过程结束,此时图中仍有D未被访问,则以D为起始点继续遍历。选取D为起始点,输出D,将D入队列,标记D。

(10)队列头为D,D出队列,D的邻接顶点为B,B已被标记,无新元素入队列。

(11)队列为空,且所有元素均被访问,广度优先搜索遍历过程结束。广度优先搜索的输出序列为:A->B->C->E->F->G->H->D。

算法分析

假设图有V个顶点,E条边,广度优先搜索算法需要搜索V个节点,时间消耗是O(V),在搜索过程中,又需要根据边来增加队列的长度,于是这里需要消耗O(E),总得来说,效率大约是O(V+E)

3、总结

图的遍历主要就是这两种遍历思想,深度优先搜索使用递归方式,需要栈结构辅助实现。广度优先搜索需要使用队列结构辅助实现。在遍历过程中可以看出,对于连通图,从图的任意一个顶点开始深度或广度优先遍历一定可以访问图中的所有顶点,但对于非连通图,从图的任意一个顶点开始深度或广度优先遍历并不能访问图中的所有顶点

五、图的连通性问题

1、无向图的连通分量

无向图的连通分量:

在对图遍历时,对于连通图,无论是广度优先搜索还是深度优先搜索,仅需要调用一次搜索过程,即从任一个顶点出发,便可以遍历图中的各个顶点。对于非连通图,则需要多次调用搜索过程,而每次调用得到的顶点访问序列恰为各连通分量中的顶点集。调用搜索过程的次数就是该图连通分量的个数。

2、最小生成树

最小生成树

1、生成树定义:所有顶点均由边连接在一起,但不存在回路的图

2、最小生成树的基本概念

(1)生成树:是一个极小连通子图,它含有图中全部顶点,但只有n-1条边。

(2)最小生成树:如果无向连通图是一个带权图,那么它的所有生成树中必有一棵边的权值总和为最小的生成树,称这棵生成树为最小代价生成树,简称最小生成树。

3、生成森林:非连通图每个连通分量的生成树组成非连通图的生成森林;

4、说明
(1)一个图可以有许多棵不同的生成树

(2)所有生成树具有以下共同特点:

        (a)生成树的顶点个数与图的顶点个数相同

        (b)生成树是图的极小连通子图

        (c)一个有n个顶点的连通图的生成树有n-1条边

        (d)生成树中任意两个顶点间的路径是唯一的

        (e)在生成树中再加一条边必然形成回路

(3)含n个顶点n-1条边的图不一定是生成树

最小生成树问题

1、问题提出

要在n个城市间建立通信联络网,

顶点——表示城市

权——城市间建立通信线路所需花费代价

希望找到一棵生成树,它的每条边上的权值之和(即建立该通信网所需花费的总代价)最小———最小代价生成树

2、问题分析

n个城市间,最多可设置n(n-1)/2条线路

n个城市间建立通信网,只需n-1条线路

问题转化为:如何在可能的线路中选择n-1条,能把所有城市(顶点)均连起来,且总耗费(各边权值之和)最小

3、构造最小生成树——普里姆Prim算法

(1)最小生成树以无向图为研究对象

(2)Prim算法:假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,其中U是T的顶点集,TE是T的边集,U和TE的初值均为空。

(3)算法开始时,首先从V中任取一个顶点(假设取v1)并入U中,此时U={v1}。然后只要U是V的真子集,就从那些一个端点已在T中,另一个端点仍在T外的所有边中,找一条权值最小的边,假定为(vi,vj),其中vi∈U,vj∈V-U,并把该边(vi,vj)和顶点vj并入T的边集TE和顶点集U中,如此进行下去,每次往生成树中并入一个顶点和一条边,直到把所有n个顶点都并入生成树T的顶点集中,此时U=V,TE中包含n-1条边,T就是最后得到的最小生成树。

4、构造最小生成树——普里姆Kruskal算法

(1)假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,U的初值等于V,即包含G中的全部顶点。T的初始状态是只含有n个顶点的而无边的森林T=(V,Φ)。

(2)该算法的基本思想是:按权值从小到大依次选取图G中的边,若选取的边使生成树T不形成回路,则把它并入TE中,保留作为T的一条边;若选取的边使生成树T形成回路,则将其舍弃,如此进行下去直到TE中包含n-1条边位置,此时的T即为最小生成树。

六、图的应用

图的应用非常广泛,例如:

用图可以表示一座城市的交通联系的情况;

用有值图可以表示两座城市之间的距离、车费、或班次数目;

表示城市之间建立的通讯网络;

可以描述化学结构式;

图中两点之间的最短距离问题等等;

1、拓扑排序

拓扑排序:

1、基本概念

(1)AOV网——用顶点表示活动,用弧表示活动间优先关系的有向图称为顶点表示活动的网(Activity On Vertex ),若<vi,vj>是图中有向边,则vi是vj的直接前驱;vj是vi的直接后继,AOV网中不允许有回路,这意味着某项活动以自己为先决条件。

(2)拓扑排序——把AOV网络中各顶点按照它们相互之间的优先关系排列成一个线性序列的过程叫拓扑排序。

(3)检测AOV网中是否存在环方法:对有向图构造其顶点的拓扑有序序列,若网中所有顶点都在它的拓扑有序序列中,则该AOV网必定不存在环.

(4)拓扑排序的方法
        (a)在有向图中选一个没有前驱的顶点(入度为0)且输出之
        (b)从图中删除该顶点和所有以它为尾的弧
        (c)重复上述两步,直至全部顶点均已输出或者当图中不存在无前驱的顶点为止        

2、具体实例理解

通常,在实现一项较大的工程时,经常会将该工程划分为若干个子工程,我们把这些子工程称为活动。在整个工程中,有些子工程没有先决条件,可以安排在任何时间开始;而有些子工程必须在其他相关子工程完成之后才能开始。

为了形象地反映出整个工程中各个子工程之间的先后关系,可用一个有向图来表示,图中的顶点代表活动(子工程),图中的有向边代表活动的先后关系,我们把这种有向无环图称为顶点活动网,简称AOV网。注意,AOV网中不应该有有向环(即回路),因为这意味着某项活动以自己为先决条件,这样的设计是有问题的!

我们看这样一个实际应用,计算机专业的同学需要修完一系列课程才能毕业,但是这些课程是有先后顺序的,比如“高等数学”是基础课,不需要先修其他课程;而“数据结构”则要先修完“离散数学”和“算法语言”才能学习,等等。这些课程的先后关系我们用下面的表格来表示:

  我们构建一个AOV网如下图:

以C1作为第一个输出的拓扑序列为:C1,C4,C2,C7,C9,C3,C6,C5,C8

以C2作为第一个输出的拓扑序列为:C2,C1,C9,C7,C4,C3,C6,C8,C5

 拓扑排序过程如下图所示:

2、关键路径

关键路径:

问题:

假设以有向网表示一个施工流图,弧上的权值表示完成该项子工程所需时间。
问:哪些子工程项是“关键工程”?
即:哪些子工程项将影响整个工程的完成期限的。

有关术语:

(1)AOE网:以顶点表示事件,弧代表活动,权表示活动需要的时间。顶点所代表的事件实际上就是表示以它为弧头的所有弧表示的活动已完成,所有以它为弧尾的弧代表的活动可以开始这一种状态。

(2)源点和汇点:在正常情况(无环)下,网中只有一个入度为零的点称为源点,一个出度为零的点称为汇点。 

(3)关键路径:完成整个工程的最短时间是从源点到汇点的最长路径的长度(路径长度等于路径上各边的权之和,而不是路径上弧的数目)。这条具有最大长度的路径称为关键路径。

(4)AOE网(Activity On Edge)——也叫边表示活动的网。AOE网是一个带权的有向无环图,其中顶点表示事件,弧表示活动,权表示活动持续时间。

(5)路径长度——路径上各活动持续时间之和

(6)关键路径——路径长度最长的路径叫关键路径

(7)Ve(j)——表示事件Vj的最早发生时间

(8)Vl(j)——表示事件Vj的最迟发生时间

(9)e(i)——表示活动ai的最早开始时间

(10)l(i)——表示活动ai的最迟开始时间

(11)l(i)-e(i)——表示完成活动ai的时间余量

(12)关键活动——关键路径上的活动叫关键活动,即 l(i) = e(i) 的活动

要找出关键路径,必须找出关键活动, 即不按期完成就会影响整个工程完成的活动。

首先计算以下与关键活动有关的量:

⑴ 事件的最早发生时间 ve[k] 
⑵ 事件的最迟发生时间 vl[k] 
⑶ 活动的最早开始时间 e[i] 
⑷ 活动的最晚开始时间 l[i]

最后计算各个活动的时间余量 l[k] - e[k],时间余量为0者即为关键活动。

换言之是求求解步骤:

(1)先正向拓扑排序求出点的最早开始时间:ve(取最大ve(j)=Max{ve(i)+dut(<i,j>)})
(2)再逆向拓扑排序求出点的最迟开始时间:vl(取最小vl(i)=Min{vl(j)-dut(<i,j>)})
(3)利用ve,vl求出边的最早开始时间e,最迟开始时间l
(4)e=l表示此边为关键路径中的边

计算公式:假设ak=<i,j>
e=ve(i)
vl=vl(j)-dut(<i,j>)

先解决几个疑惑点:

为什么是最长的路径长度决定最早的发生时间呢?

难道不应该是越想要早发生就越应该走短的路径吗?

答:

(1)开始和发生:

“发生”是针对于事件的,也就是图中的顶点。只有在指向该顶点的所有有向边对应的活动结束,该顶点所代表的事件才发生。举个例子,一个事件C,它仅被两条边a, b指向,仅当a,b两活动都完成时,事件C发生。“开始”是针对于活动的,也就是图中的边。只有在一个顶点所代表的事件发生后,从该顶点出发的所有边对应的活动才能开始。

(2)最早发生时间:

最短路径和关键路径根本不是一回事,求最短路径是找尽可能短的路来保证路径长度最小,你只需要找出一条最短的路就行。但是在关键路径里,一个顶点是有多个前提的,只有前提的路径都走完,才能发生该顶点的事件,那么只有最长的路径走完,保证其余短的路都早已经走完,该事件才发生。

换言之,我最远的路都走完了,而你短的路必定比我早走完,所以我达到终点的时候所有人通过各自的路径都到达了终点C,故说事件C的最早发生时间是走最长路径的总权值。

最迟发生时间是什么意思呢?

答:

最迟发生时间的定义是这样的:在不推迟整个工程完成的前提下,保证后继事件j能在其最迟发生时间vl(j)能够发生时,该事件最迟必须发生的时间。

首先,我们要知道,汇点的最迟发生时间就是它的最早发生时间(可以理解为汇点的最早发生时间就是工期),求VL的过程我们是从汇点倒着推回去的

其次,为什么会有最迟发生时间呢?这是因为一个工程中,通常会存在某条路径耗时比其他路径久的多,所以耗时短的事件可以不必太早发生,也就是存在着缓冲时间。

我们来看一下下面这个例子,看看最迟发生时间是多少?

看看这个图,我在顶点上方写了它的最早发生时间,D是汇点,110是它的最早发生时间(也是最迟发生时间)。

此时我们可以这样理解,如果在110天时工程必须完成,那么A事件最迟什么时候要发生?

我们可以用110减去a活动用时,得到105,也就是说A事件最迟可以在第105天发生(一旦发生就要立刻开始a活动,否则将推迟整个工期),指向A的活动可以在第100天才开始。

而A事件最早可以在第5天就发生(这里说最早才有意义了),最早第5天完成,但是可以推迟到第105天完成,中间差了100天了。

同理可得B事件的最迟发生时间是第10天,它一刻也不能拖。

这时候再回去看看最迟发生时间的定义,应该就可以理解了吧。

时间余量又是什么呢?

答:

d(i) = l(i) - e(i),即活动最迟开始时间与最早开始时间的差额,这代表着活动可以拖延的时间。如果一个活动的时间余量为0,就意味着该活动不能拖延时间,称为关键活动,必须立即完成,否则就将拖延整个工期。

时间余量为0的所有边连起来,就是关键路径了。

要求时间余量d, 就要先求活动的最早开始时间e和最迟开始时间l,要求e和l就要先求事件最早发生时间ve和最迟发生时间vl。所以做题步骤就出来啦!
 

下面开始图解演示:

 1、正向拓扑排序

 

 2、逆序拓扑排序

3、边处理

最后注意的几点:

1、关键路径上的所有活动都是关键活动, 它是决定整个工程的关键因素,因此可以通过加快关键活动来缩短整个工期。但是也不能任意缩短,因为一旦缩短到一定的程度,该关键活动就可能变成非关键活动了

2、网中的关键路径不唯一,对于有好几条关键路径的网,只加快其中某一条关键路径上的活动并不能缩短工期。只有加快存在于所有关键路径上的关键活动才能达到缩短工期的目的。
 

3、最短路径

 最短路径:

1、定义

在一个带权图中,顶点V0到图中任意一个顶点Vi的一条路径所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径最短的那条路径称为最短路径。

 2、简单思考一下

如果单纯考虑一条路径上边的条数,那么从v0到v6的最短路径应该是:v0-v3-v6。但是如果考虑边的权重,从v0到v6的“最便宜”路径应该是:v0-v1-v4-v6,其总权重为3(路径中所有边的权重之和),而如果走v0-v3-v6的路径,总权重将是11。

边有权重的图我们称之为赋权图,反之称为无权图,赋权图显然可以比无权图应用于更多场合,比如用赋权图来表示城市间公路,权重越大路况越差,或者权重越大,过路费用越高等等。

一件有意思的事情需要说明一下,那就是:找X到Y的最短路,比找X到所有顶点的最短路径更慢(有权无权都是如此)

出现这个情况的原因我们可以简单的分析一下:找X到Y的最短路径,最直接的做法就是令程序从X出发沿着可行的边不断的走,直到走到Y处为止,但是当走到Y处时,没人能保证刚刚走的那条路就是最短的,除非你走遍了整个图的顶点,换句话说,你要确定走到Y处且走的路径是最短的,你就得走遍所有顶点,而且在这个过程中你必须不断记录各个路径的长度,不然当你发现到一个顶点有多条路径时怎么比较它们呢?所以,你要找X到Y的最短路径,你就得找出X到所有顶点的最短路径。

当然,也存在专门寻找点对点最短路径的思路,但是目前来说,单独找X到Y的最短路径不会比找X到所有顶点的最短路径更快,所以我们接下来探讨的问题其实都是:单源最短路径问题。即给定一个起点(源),求出其与所有顶点的最短路径。有了到所有顶点的最短路径,我们自然也就有了到给定顶点Y的最短路径。

迪杰斯特拉算法(Dijkstra算法)

(1)算法特点:

迪科斯彻算法使用了广度优先搜索解决赋权有向图或者无向图的单源最短路径问题,算法最终得到一个最短路径树。该算法常用于路由算法或者作为其他图算法的一个子模块。

(2)算法的思路

Dijkstra算法采用的是一种贪心的策略,声明一个数组dis来保存源点到各个顶点的最短距离和一个保存已经找到了最短路径的顶点的集合:T

初始时,原点 s 的路径权重被赋为 0(dis[s] = 0)。若对于顶点 s 存在能直接到达的边(s,m),则把dis[m]设为w(s, m),同时把所有其他(s不能直接到达的)顶点的路径长度设为无穷大。初始时,集合T只有顶点s。

然后,从dis数组选择最小值,则该值就是源点s到该值对应的顶点的最短路径,并且把该点加入到T中,OK,此时完成一个顶点,然后,我们需要看看新加入的顶点是否可以到达其他顶点并且看看通过该顶点到达其他点的路径长度是否比源点直接到达短,如果是,那么就替换这些顶点在dis中的值。然后,又从dis中找出最小值,重复上述动作,直到T中包含了图的所有顶点。

(3)Dijkstra算法示例演示

下面用下图,求从顶点v1到其他各个顶点的最短路径

 首先第一步,我们先声明一个dis数组,该数组初始化的值为:

我们的顶点集T的初始化为:T={v1}

既然是求 v1顶点到其余各个顶点的最短路程,那就先找一个离 1 号顶点最近的顶点。通过数组 dis 可知当前离v1顶点最近是 v3顶点。当选择了 2 号顶点后,dis[2](下标从0开始)的值就已经从“估计值”变为了“确定值”,即 v1顶点到 v3顶点的最短路程就是当前 dis[2]值。将V3加入到T中。

为什么呢?因为目前离 v1 顶点最近的是 v3 顶点,并且这个图所有的边都是正数,那么肯定不可能通过第三个顶点中转,使得 v1顶点到 v3顶点的路程进一步缩短了。因为 v1顶点到其它顶点的路程肯定没有 v1到 v3顶点短。

OK,既然确定了一个顶点的最短路径,下面我们就要根据这个新入的顶点V3会有出度,发现以v3 为弧尾的有: < v3,v4 >,那么我们看看路径:v1–v3–v4的长度是否比v1–v4短,其实这个已经是很明显的了,因为dis[3]代表的就是v1–v4的长度为无穷大,而v1–v3–v4的长度为:10+50=60,所以更新dis[3]的值,得到如下结果:

因此 dis[3]要更新为 60。这个过程有个专业术语叫做“松弛”。即 v1顶点到 v4顶点的路程即 dis[3],通过 < v3,v4> 这条边松弛成功。

这便是 Dijkstra 算法的主要思想:通过“边”来松弛v1顶点到其余各个顶点的路程。然后,我们又从除dis[2]和dis[0]外的其他值中寻找最小值,发现dis[4]的值最小,通过之前是解释的原理,可以知道v1到v5的最短距离就是dis[4]的值,然后,我们把v5加入到集合T中,然后,考虑v5的出度是否会影响我们的数组dis的值,v5有两条出度:< v5,v4>和 < v5,v6>,然后我们发现:v1–v5–v4的长度为:50,而dis[3]的值为60,所以我们要更新dis[3]的值.另外,v1-v5-v6的长度为:90,而dis[5]为100,所以我们需要更新dis[5]的值。更新后的dis数组如下图:

然后,继续从dis中选择未确定的顶点的值中选择一个最小的值,发现dis[3]的值是最小的,所以把v4加入到集合T中,此时集合T={v1,v3,v5,v4},然后,考虑v4的出度是否会影响我们的数组dis的值,v4有一条出度:< v4,v6>,然后我们发现:v1–v5–v4–v6的长度为:60,而dis[5]的值为90,所以我们要更新dis[5]的值,更新后的dis数组如下图:

然后,我们使用同样原理,分别确定了v6和v2的最短路径,最后dis的数组的值如下: 

因此,从图中,我们可以发现v1-v2的值为:∞,代表没有路径从v1到达v2。所以我们得到的最后的结果为:

起点 终点 最短路径 长度
v1 v2
v3 {v1,v3} 10
v4 {v1,v5,v4} 50
v5 {v1,v5} 30
v6 {v1,v5,v4,v6}

60

                        

弗洛伊德算法(Floyd算法)

(1)算法的特点:
弗洛伊德算法是解决任意两点间的最短路径的一种算法,可以正确处理有向图或有向图或负权(但不可存在负权回路)的最短路径问题,同时也被用于计算有向图的传递闭包。

(2)算法的思路
通过Floyd计算图G=(V,E)中各个顶点的最短路径时,需要引入两个矩阵,矩阵S中的元素a[i][j]表示顶点i(第i个顶点)到顶点j(第j个顶点)的距离。矩阵P中的元素b[i][j],表示顶点i到顶点j经过了b[i][j]记录的值所表示的顶点序号(这句话是递归的意思,比如v1到v[7],b[0][6]=5,意思就是v1到v7的最短路径是v1到v6的最短路径加上v6到v7,然后递归下来,假如b[0][5] =4,说明v1到v6的最短路径是v1到v5然后加上v5到v6,以此类推)。

注意:P矩阵是储存两点经过哪个点连在一起的,用来记录任意两点的所有路径。比如作者第二个步骤的v3点到v7点,路径是v3->v2->v1->v7,所以在P里,v3和v7存的是v2的数组下标1,然后拿着下标1找到v2点,看到v2到v7存的是数组下标0,下标0代表v1点。再看v1到v7是0,说明还是经过v1,那就是直达了,这样通过P矩阵,就能理出v3->v7的路径,是v3->v2->v1->v7。

假设图G中顶点个数为N,则需要对矩阵D和矩阵P进行N次更新。初始时,矩阵D中顶点a[i][j]的距离为顶点i到顶点j的权值;如果i和j不相邻,则a[i][j]=∞,矩阵P的值为顶点b[i][j]的j的值。 接下来开始,对矩阵D进行N次更新。第1次更新时,如果”a[i][j]的距离” > “a[i][0]+a[0][j]”(a[i][0]+a[0][j]表示”i与j之间经过第1个顶点的距离”),则更新a[i][j]为”a[i][0]+a[0][j]”,更新b[i][j]=b[i][0]。 同理,第k次更新时,如果”a[i][j]的距离” > “a[i][k-1]+a[k-1][j]”,则更新a[i][j]为”a[i][k-1]+a[k-1][j]”,b[i][j]=b[i][k-1]。更新N次之后,操作完成!

(3)算法步骤
思想:逐个顶点试探法

初始时设置一个n阶方阵,令其对角线元素为0,若存在弧<Vi,Vj>,则对应元素为权值;否则为∞;

逐步试着在原直接路径中增加中间顶点,若加入中间点后路径变短,则修改之;

否则,维持原值所有顶点试探完毕,算法结束。
 

(4)Floyd算法示例演示

用下图举例:

第一步,我们先初始化两个矩阵,得到下图两个矩阵:

第二步,以v1为中阶,更新两个矩阵:
发现,a[1][0]+a[0][6] < a[1][6] 和a[6][0]+a[0][1] < a[6][1],所以我们只需要矩阵D和矩阵P,结果如下:

通过矩阵P,我发现v2–v7的最短路径是:v2–v1–v7

第三步:以v2作为中介,来更新我们的两个矩阵,使用同样的原理,扫描整个矩阵,得到如下图的结果:

OK,到这里我们也就应该明白Floyd算法是如何工作的了,他每次都会选择一个中介点,然后,遍历整个矩阵,查找需要更新的值,下面还剩下五步,就不继续演示下去了。

再举一例:

图的代码总结很快来袭~

我是花花,祝自己也祝您变强了~~

猜你喜欢

转载自blog.csdn.net/m0_52711790/article/details/121359621
今日推荐