数据结构(C++)笔记:06.图

文章目录

  • 6.1 图的逻辑结构
    • 6.1.1 图的定义和基本术语
    • 6.1.2 图的抽象数据类型定义
    • 6.1.3 图的遍历操作
  • 6.2 图的存储结构及实现
    • 6.2.1 邻接矩阵
      • 邻接矩阵的实现
        • 1. 构造函数
        • 2.析构函数
        • 3.深度优先遍历
        • 4.广度优先遍历
    • 6.2.2 邻接表
        • 1. 构造函数
        • 2.深度优先遍历
        • 3.广度优先遍历
    • 6.2.3 十字链表
    • 6.2.4 邻接多重表
    • 6.2.5 边集数组
    • 6.2.6 图的存储结构的比较
  • 6.3 图的连通性
    • 6.3.1 无向图的连通性
    • 6.3.2 有向图的连通性
    • 6.3.3 生成树和生成森林
  • 6.4 应用举例
    • 6.4.1 最小生成树
      • Prim算法
      • 克鲁斯卡尔(Kruskal)算法
    • 6.4.2 最短路径
    • 6.4.3 AOV网与拓扑排序
    • 6.4.4 AOE网与关键路径

6.1 图的逻辑结构

6.1.1 图的定义和基本术语

在图中,常常将数据元素称为顶点,将顶点之间的关系用边来表示。

  1. 图的定义
    图是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G=(V,E)。
    说明:
    1.G表示一个图,V是图G中顶点的集合,E是图G中顶点之间边的集合。
    2.若顶点 v i v_i vi v j v_j vj之间的边没有方向,则称这条边为无向边,用无序偶对 ( v i , v j ) (v_i,v_j) (vivj)来表示;
    3.若从顶点 v i v_i vi v j v_j vj的边有方向,则称这条边为有向边(也称为弧),用有序偶对 < v i , v j > <vi,vj>来表示, v i v_i vi称为弧尾, v j v_j vj称为弧头。
    4.如果图的任意两个顶点之间的边都是无向边,则称该图为无向图,否则称该图为有向图。
  2. 图的基本术语
    简单图
    图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。
    邻接、依附
    在无向图中,对于任意两个顶点 v i v_i vi v j v_j vj,若存在边( v i v_i vi v j v_j vj),则称顶点 v i v_i vi v j v_j vj互为邻接点,同时称边( v i v_i vi v j v_j vj)依附于顶点 v i v_i vi v j v_j vj
    在有向图中,对于任意两个顶点 v i v_i vi v j v_j vj,若存在弧< v i v_i vi v j v_j vj>,则称顶点 v i v_i vi邻接到 v j v_j vj,顶点 v j v_j vj邻接自 v i v_i vi,同时称弧< v i v_i vi v j v_j vj>依附于顶点vi和 v j v_j vj 。在不致混淆的情况下,通常称 v j v_j vj v i v_i vi的邻接点。
    无向完全图、有向完全图
    在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图,n个顶点的无向完全图有 n × ( n − 1 ) / 2 n×(n-1)/2 n×(n1)/2条边。
    在有向图中,如果任意两顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图,n个顶点的有向完全图有 n × ( n − 1 ) n×(n-1) n×(n1)条边。
    稠密图、稀疏图
    称边数很少的图为稀疏图,反之,称为稠密图。
    顶点的度、入度、出度
    在无向图中,顶点v的度是指依附于该顶点的边的个数,记为 T D ( v ) TD(v) TD(v)
    在具有n个顶点e条边的无向图中,有下式成立: ∑ i = 1 n T D ( v i ) = 2 e \sum_{i=1}^nTD(v_i)=2e i=1nTD(vi)=2e
    在有向图中,顶点v的入度是指以该顶点为弧头的弧的个数,记为 I D ( v ) ID(v) ID(v)
    顶点v的出度是指以该顶点为弧尾的弧的个数,记为 O D ( v ) OD(v) OD(v)
    在具有n个顶点e条边的有向图中,有下式成立:
    权、网
    图中,权通常是指对边赋予的有意义的数值量。
    边上带权的图称为网或网图。
    路径、路径长度、回路
    无向图G=(V,E),顶点 v p v_p vp v q v_q vq之间的路径是一个顶点序列 v p = v i 0 , v i 1 , … , v i m = v q v_p=v_{i0}, v_{i1}, …, v_{im}=v_q vp=vi0,vi1,,vim=vq,其中, ( v i j − 1 , v i j ) ∈ E ( 1 ≤ j ≤ m ) (v_{ij-1}, v_{ij})∈E(1≤j≤m) (vij1,vij)E(1jm);如果G 是有向图,则 < v i j − 1 , v i j > ∈ E ( 1 ≤ j ≤ m ) ∈E(1≤j≤m) <vij1,vij>E(1jm)
    路径上边的数目称为路径长度。
    第一个顶点和最后一个顶点相同的路径称为回路或环。
    简单路径、简单回路
    在路径序列中,顶点不重复出现的路径称为简单路径。
    除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路称为简单回路。
    子图
    对于图G=(V,E),G’=(V’,E’ ),如果 V ′ ⊆ V V'\subseteq V VV E ′ ⊆ E E' \subseteq E EE,则称图G’是G的子图。
    连通图、连通分量
    在无向图中,若任意顶点 v i v_i vi v j ( i ≠ j ) v_j(i≠j) vj(i=j)之间有路径,则称该图是连通图。
    非连通图的极大连通子图称为连通分量。
    强连通图、强连通分量
    对有向图中任意顶点 v i v_i vi v j ( i ≠ j ) v_j(i≠j) vj(i=j),若从顶点 v i v_i vi v j v_j vj和从顶点 v j v_j vj v i v_i vi均有路径,该有向图是强连通图。
    非强连通图的极大强连通子图称为强连通分量。
    生成树、生成森林
    具有n个顶点的连通图G的生成树是包含G中全部顶点的一个极小连通子图。
    在生成树中添加任意一条属于原图中的边必定会产生回路;在生成树中减少任意一条边,则必然成为非连通。所以一棵具有n个顶点的生成树有且仅有n-1条边。
    在非连通图中,连通分量的生成树构成了非连通图的生成森林。

6.1.2 图的抽象数据类型定义

ADT  Graph
Data
    顶点的有穷非空集合和边的集合
Operation
  InitGraph
     前置条件:图不存在
     输入:无 
     功能:图的初始化
     输出:无
     后置条件:构造一个空的图
  DestroyGraph 
     前置条件:图已存在
     输入:无 
     功能:销毁图
     输出:无
     后置条件:释放图所占用的存储空间
  InsertVex
     前置条件:图已存在
     输入:顶点v 
     功能:在图中插入一个顶点v 
     输出:如果插入不成功,抛出异常
     后置条件:如果插入成功,图中增加了一个顶点
  DeleteVex
     前置条件:图已存在
     输入:顶点v 
     功能:在图中删除顶点v 
     输出:如果删除不成功,抛出异常
     后置条件:如果删除成功,图中减少了一个顶点
  InsertArc
     前置条件:图已存在
     输入:顶点u,顶点v,顶点u和v之间边的信息 
     功能:在图中插入一条边 
     输出:如果插入不成功,抛出异常
     后置条件:如果插入成功,图中增加了一条边
  DeleteArc
     前置条件:图已存在
     输入:顶点u,顶点v 
     功能:在图中删除顶点u和v之间的边 
     输出:如果删除不成功,抛出异常
     后置条件:如果删除成功,图中减少了一条边
  DFSTraverse
     前置条件:图已存在
     输入:遍历的起始顶点v
     功能:从顶点v出发深度优先遍历图
     输出:图中顶点的一个线性排列
     后置条件:图保持不变
  BFSTraverse
     前置条件:图已存在
     输入:遍历的起始顶点v
     功能:从顶点v出发广度优先遍历图
     输出:图中顶点的一个线性排列
     后置条件:图保持不变
endADT

6.1.3 图的遍历操作

图的遍历是指从图中某一顶点出发,对图中所有顶点访问一次且仅访问一次。
图的遍历中要解决的关键问题是:
⑴ 在图中,没有一个确定的开始顶点,任意一个顶点都可作为遍历的起始顶点,那么,如何选取遍历的起始顶点?
解决方法:既然图中没有确定的开始顶点,那么可从图中任一顶点出发,不妨将顶点进行编号,先从编号小的顶点开始。在图中,由于任何两个顶点之间都可能存在边,顶点没有确定的先后次序,所以,顶点的编号不唯一,是图的存储实现上,一般采用一维数组存储图的顶点信息,因此,可以用顶点的存储位置(即下标)表示该顶点的编号。为了和C++语言中的数组保持一致,图的编号从0开始。

⑵ 从某个顶点出发可能到达不了所有其它顶点,例如非连通图,从一个顶点出发,只能访问它所在的连通分量上的所有顶点,那么,如何才能遍历图的所有顶点?
解决方法:要遍历图中所有顶点,只需多次重复从某一顶点出发进行图的遍历。以下仅讨论从某一顶点出发遍历图的问题。

⑶ 由于图中可能存在回路,某些顶点可能会被重复访问,那么,如何避免遍历不会因回路而陷入死循环?
解决方法:为了在遍历过程中区分顶点是否已被访问,设置一个访问标志数组visited[n](n为图中顶点的个数),其初值为未被访问标志0,如果某顶点i已被访问,则将该顶点的访问标志visited[i]置为1。

⑷ 在图中,一个顶点可以和其它多个顶点相邻接,当这样的顶点访问过后,如何选取下一个要访问的顶点?
解决方法:这就是遍历次序的问题。图的遍历通常有深度优先遍历和广度优先遍历两种方式,这两种遍历次序对无向图和有向图都适用。

  1. 深度优先遍历
    从图中某顶点v出发进行深度优先遍历的基本思想是:
    ⑴ 访问顶点v;
    ⑵ 从v的未被访问的邻接点中选取一个顶点w,从w出发进行深度优先遍历;
    ⑶ 重复上述两步,直至图中所有和v有路径相通的顶点都被访问到。
  2. 广度优先遍历
    从图中某顶点v出发进行广度优先遍历的基本思想是:
    ⑴ 访问顶点v;
    ⑵ 依次访问v的各个未被访问的邻接点v1,v2,……,vk;
    ⑶ 分别从v1,v2,…,vk出发依次访问它们未被访问的邻接点,并使“先被访问顶点的邻接点”先于“后被访问顶点的邻接点”被访问。直至图中所有与顶点v有路径相通的顶点都被访问到。
    设置队列存储已被访问的顶点,为什么?
    答:为了使“先被访问顶点的邻接点”先于“后被访问访问顶点的邻接点”被访问。

对比图的深度优先遍历与广度优先遍历算法,你会发现,它们在时间复杂度上是一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣可言。,

6.2 图的存储结构及实现

数据结构(C++)笔记:06.图_第1张图片
也正由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,即以一个数据域和多个指针域组成的结点表示图中的一个顶点,尽管可以实现图结构,但其实在树中,我们也已经讨论过,这是有问题的。如果各个顶点的度数相差很大,按度数最大的顶点设计结点结构会造成很多存储单元的浪费,而若按每个顶点自己的度数设计不同的顶点结构,又带来操作的不便。因此,对于图来说,如何对它实现物理存储是个难题,不过我们的前辈们已经解决了,现在我们来看前辈们提供的五种不同的存储结构。

6.2.1 邻接矩阵

考虑到图是由顶点和边或弧两部分组成。合在一起比较困难,那就很自然地考虑到分两个结构来分别存储。顶点不分大小、主次,所以用一个一维数组来存储是很不错的选择。而边或弧由于是顶点与顶点之间的关系,一维搞不定,那就考虑用一个二维数组来存储。于是我们的邻接矩阵的方案就诞生了。
存储方法:
假设图G=(V,E)有n个顶点,则邻接矩阵是一个n×n的方阵,定义为:
a r c [ i ] [ j ] = { 1 0 arc[i][j]=\left\{\begin{matrix} 1\\ 0\end{matrix}\right. arc[i][j]={10
我们来看一个实例,下图的左边就是一个无向图。
数据结构(C++)笔记:06.图_第2张图片
我们可以设置两个数组,顶点数组为 v e r t e x [ 4 ] = v 0 , v 1 , v 2 , v 3 vertex[4]={v_0,v_1,v_2,v_3} vertex[4]=v0v1v2v3,边数组 a r c [ 4 ] [ 4 ] arc[4][4] arc[4][4]为上图右边这样的一个矩阵。简单解释一下,对于矩阵的主对角线的值,即arc[0][0]、arc[1][1]、arc[2][2]、arc[3][3],全为0是因为不存在顶点到自身的边,比如 v 0 v_0 v0 v 0 v_0 v0。arc[0][1]=1是因为 v 0 v_0 v0 v 1 v_1 v1的边存在,而arc[1][3]=0是因为 v 1 v_1 v1 v 3 v_3 v3的边不存在。并且由于是无向图, v 1 v_1 v1 v 3 v_3 v3的边不存在,意味着 v 3 v_3 v3 v 1 v_1 v1的边也不存在。所以无向图的边数组是一个对称矩阵。
有了这个矩阵,我们就可以很容易地知道图中的信息。
1.我们要判定任意两顶点是否有边无边就非常容易了。
2.我们要知道某个顶点的度,其实就是这个顶点 v i v_i vi在邻接矩阵中第i行(或第i列)的元素之和。比如顶点 v 1 v_1 v1的度就是1+0+1+0=2。
3.求顶点 v i v_i vi的所有邻接点就是将矩阵中第i行元素扫描一遍,arc[i][j]为1就是邻接点。

下面来看一个有向图的例子:
数据结构(C++)笔记:06.图_第3张图片
顶点数组为 v e r t e x [ 4 ] = v 0 , v 1 , v 2 , v 3 vertex[4]={v_0,v_1,v_2,v_3} vertex[4]=v0v1v2v3,弧数组arc[4][4]为上右边这样的一个矩阵。主对角线上数值依然为0。但因为是有向图,所以此矩阵并不对称,比如由 v 1 v_1 v1 v 0 v_0 v0有弧,得到arc[1][0]=1,而 v 0 v_0 v0 v 1 v_1 v1没有弧,因此arc[0][1]=0。
有向图讲究入度与出度,顶点 v 1 v_1 v1的入度为1,正好是第 v 1 v_1 v1列各数之和。顶点 v 1 v_1 v1的出度为2,即第 v 1 v_1 v1行的各数之和。
与无向图同样的办法,判断顶点 v i v_i vi v j v_j vj是否存在弧,只需要查找矩阵中arc[i][j]是否为1即可。要求 v i v_i vi的所有邻接点就是将矩阵第i行元素扫描一遍,查找arc[i][j]为1的顶点。

在图的术语中,我们提到了网的概念,也就是每条边上带有权的图叫做网。那么这些权值就需要存下来,如何处理这个矩阵来适应这个需求呢?我们有办法。
设图G是网图,有n个顶点,则邻接矩阵是一个n×n的方阵,定义为:
a r c [ i ] [ j ] = { w i j 0 ∞ arc[i][j]=\left\{\begin{matrix} w_{ij}\\ 0\\ \infty \end{matrix}\right. arc[i][j]=wij0
其中, w i j w_{ij} wij表示边 ( v i , v j ) (v_i,v_j) (vivj)或弧 < v i , v j > <vivj>上的权值;∞表示一个计算机允许的、大于所有边上权值的数,也就是一个不可能的极限值。有同学会问,为什么不是0呢?原因在于权值W大多数情况下是正值,但个别时候可能就是0,甚至有可能是负值。因此必须要用一个不可能的值来代表不存在。
下面是网图的例子:
数据结构(C++)笔记:06.图_第4张图片
在图的邻接矩阵存储中容易解决下列问题:
①对于无向图,顶点i的度等于邻接矩阵中第i行(或第i列)非零元素的个数。
对于有向图,顶点i的出度等于邻接矩阵中第i行非零元素的个数;
顶点i的入度等于邻接矩阵中第i列非零元素的个数。
②判断顶点i和j之间是否存在边:只需测试邻接矩阵中相应位置的元素arc[i][j],若其值为1,则有边;否则,顶点i和j之间不存在边。
③查找顶点i的所有邻接点:扫描邻接矩阵的第i行,若arc[i][j]的值为1,则j是顶点i的邻接点。

邻接矩阵的实现

const int MaxSize=10;   //图中最多顶点个数
template <class T>
class MGraph
{
public:
   MGraph(T a[ ], int n, int e );   //构造函数,初始化具有n个顶点e条边的图
   ~MGraph( ) { }    //析构函数  
   void DFSTraverse(int v);     //深度优先遍历图
   void BFSTraverse(int v);      //广度优先遍历图
private:
    T vertex[MaxSize];         //存放图中顶点的数组
    int arc[MaxSize][MaxSize];  //存放图中边的数组
    int vertexNum, arcNum;     //图的顶点数和边数
 };

1. 构造函数

建立一个无向图的邻接矩阵存储(构造函数)的算法用伪代码描述为:
1. 确定图的顶点个数和边的个数;
2. 输入顶点信息存储在一维数组vertex中;
3. 初始化邻接矩阵;
4. 依次输入每条边存储在邻接矩阵arc中;
4.1 输入边依附的两个顶点的序号i, j;
4.2 将邻接矩阵的第i行第j列的元素值置为1;
4.3 将邻接矩阵的第j行第i列的元素值置为1;

2.析构函数

邻接矩阵的实现采用静态存储分配,无须销毁,析构函数为空。

3.深度优先遍历

将6.1.3中的深度优先遍历的伪代码算法在邻接矩阵存储结构下实现,具体算法为:
(略)

4.广度优先遍历

将6.1.3中的广度优先遍历的伪代码算法在邻接矩阵存储结构下实现,具体算法为:
(略)

6.2.2 邻接表

邻接矩阵是不错的一种图存储结构,但是我们也发现,对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的。比如说,如果我们要处理下图这样的稀疏有向图,邻接矩阵中除了arc[1][0]有权值外,没有其他弧,其实这些存储空间都浪费掉了。
数据结构(C++)笔记:06.图_第5张图片
因此我们考虑另外一种存储结构方式。回忆我们在线性表时谈到,顺序存储结构就存在预先分配内存可能造成存储空间浪费的问题,于是引出了链式存储的结构。同样的,我们也可以考虑对边或弧使用链式存储的方式来避免空间浪费的问题。
再回忆我们在树中谈存储结构时,讲到了一种孩子表示法,将结点存入数组,并对结点的孩子进行链式存储,不管有多少孩子,也不会存在空间浪费问题。这个思路同样适用于图的存储。我们把这种数组与链表相结合的存储方法称为邻接表(Adjacency List)。
邻接表的处理办法是这样。
1.图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
2.图中每个顶点 v i v_i vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点 v i v_i vi的边表,有向图则称为顶点 v i v_i vi作为弧尾的出边表。
例如下图所示的就是一个无向图的邻接表结构。
数据结构(C++)笔记:06.图_第6张图片
从图中我们知道,顶点表的各个结点由data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此顶点的第一个邻接点。边表结点由adjvex和next 两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。比如 v 1 v_1 v1顶点与 v 0 v_0 v0 v 2 v_2 v2互为邻接点,则在 v 1 v_1 v1的边表中,adjvex分别为 v 0 v_0 v0的0和 v 2 v_2 v2的2。
若是有向图,邻接表结构是类似的,比如下图中左边的图中的邻接表就是右边的图。但要注意的是有向图由于有方向,我们是以顶点为弧尾来存储边表的,这样很容易就可以得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点 v i v_i vi都建立一个链接为 v i v_i vi为弧头的表。如下下图所示。
数据结构(C++)笔记:06.图_第7张图片
数据结构(C++)笔记:06.图_第8张图片
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可,如下图所示。
数据结构(C++)笔记:06.图_第9张图片
·结点结构:
在这里插入图片描述
其中,vertex:数据域,存放顶点信息。
firstedge:指针域,存放指向边表的第一个结点。
adjvex:邻接点域,存放该顶点的邻接点在顶点表中的下标。
next:指针域,存放指向边表的下一个结点。

在图的邻接表存储结构中,容易解决下列问题:
①对于无向图,顶点i的度等于顶点i的边表中的结点个数。
对于有向图,顶点i的出度等于顶点i的出边表中的结点个数;
顶点的入度等于所有出边表中以顶点i为邻接点的结点个数。
②判断从顶点i到顶点j是否存在边:只需测试顶点i的边表中是否存在邻接点域为j的结点。
③查找顶点i的所有邻接点:只需遍历顶点i的边表,该边表中的所有结点都是顶点i的邻接点。

const int MaxSize=10;    //图的最大顶点数
template <class T>
class ALGraph
{
public:
   ALGraph(T a[ ], int n, int e);   //构造函数,初始化一个有n个顶点e条边的图
   ~ALGraph;    //析构函数,释放邻接表中各边表结点的存储空间
   void DFSTraverse(int v);      //深度优先遍历图
   void BFSTraverse(int v);      //广度优先遍历图
private:
   VertexNode adjlist[MaxSize];   //存放顶点表的数组
   int vertexNum, arcNum;       //图的顶点数和边数
};

1. 构造函数

1、 确定图的顶点个数和边的个数;
2、 输入顶点信息存储在顶点表中,并初始化该顶点的边表;
3、 依次输入边的信息并将边所对应的邻接点信息存储在边表中;
3.1 输入边所依附的两个顶点的序号i和j;
3.2 生成新的邻接点序号为j的边表结点s;
3.3 将结点s插入到第i个边表的头部;

2.深度优先遍历

将6.1.3中的深度优先遍历的伪代码算法在邻接矩阵存储结构下实现,具体算法为:
(略)

3.广度优先遍历

将6.1.3中的广度优先遍历的伪代码算法在邻接矩阵存储结构下实现,具体算法为:
(略)

6.2.3 十字链表

那么对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢?答案是肯定的,就是把它们整合在一起。这就是我们现在要讲的有向图的一种存储方法:十字链表(Orthogonal List)。
我们重新定义顶点表结点结构如下表所示。
在这里插入图片描述
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout表示出边表头指针,指向该顶点的出边表中的第一个结点。
重新定义的边表结点结构如下表所示。
在这里插入图片描述
其中tailvex是指弧起点在顶点表的下标,headvex是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,aillink是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个weight域来存储权值。
比如下图,顶点依然是存入一个一维数组 v 0 , v 1 , v 2 , v 3 {v_0,v_1,v_2,v_3} v0v1v2v3,实线箭头指针的图示完全与上节中的邻接表相同。就以顶点 v 0 v_0 v0来说,firstout指向的是出边表中的第一个结点 v 3 v_3 v3。所以 v 0 v_0 v0边表结点的headvex=3,而tailvex其实就是当前顶点 v 0 v_0 v0的下标0,由于 v 0 v_0 v0只有一个出边顶点,所以headlink和taillink都是空。
数据结构(C++)笔记:06.图_第10张图片
我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。对于 v 0 v_0 v0来说,它有两个顶点 v 1 v_1 v1 v 2 v_2 v2的入边。因此 v 0 v_0 v0的firstin指向顶点 v 1 v_1 v1的边表结点中headvex为0的结点,如上图的右图中的①。接着由入边结点的headlink 指向下一个入边顶点 v 2 v_2 v2,如图中的②。对于顶点 v 1 v_1 v1,它有一个入边顶点 v 2 v_2 v2,所以它的firstin指向顶点 v 2 v_2 v2的边表结点中headvex为1的结点,如图中的③。顶点 v 2 v_2 v2 v 3 v_3 v3也是同样有一个入边顶点,如图中④和⑤。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以 v i v_i vi为尾的弧,也容易找到以 v i v_i vi为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。
·十字链表结点结构为:
在这里插入图片描述
其中,vertex:数据域,存放顶点的数据信息。
firstin:入边表头指针,指向以该顶点为终点的弧构成的链表中的第一个结点。
firstout:出边表头指针,指向以该顶点为起点的弧构成的链表中的第一个结点。
tailvex:弧的起点(弧尾)在顶点表中的下标。
headvex:弧的终点(弧头)在顶点表中的下标。
headlink:入边表指针域,指向终点相同的下一条边。
taillink:出边表指针域,指向起点相同的下一条边。

6.2.4 邻接多重表

讲了有向图的优化存储结构,对于无向图的邻接表,有没有问题呢?如果我们在无向图的应用中,关注的重点是顶点,那么邻接表是不错的选择,但如果我们更关注边的操作,比如对已访问过的边做标记,删除某一条边等操作,那就意味着,需要找到这条边的两个边表结点进行操作,这其实还是比较麻烦的。比如下图,若要删除左图的 ( v 0 , v 2 ) (v_0,v_2) (v0v2)这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。
数据结构(C++)笔记:06.图_第11张图片
因此,我们也仿照十字链表的方式,对边表结点的结构进行一些改造,也许就可以避免刚才提到的问题。
重新定义的边表结点结构如下表所示。
在这里插入图片描述
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink指向依附顶点ivex的下一条边,jlink 指向依附顶点jvex的下一条边。这就是邻接多重表结构。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。由于是无向图,所以ivex是0、jvex是1还是反过来都是无所谓的,不过为了绘图方便,都将ivex值设置得与一旁的顶点下标相同。
数据结构(C++)笔记:06.图_第12张图片
我们开始连线,如下图。首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex的值相同,这很好理解。接着,由于顶点 v 0 v_0 v0 ( v 0 , v 1 ) (v_0,v_1) (v0,v1)边的邻边有 ( v 0 , v 3 ) (v_0,v_3) (v0,v3) ( v 0 , v 2 ) (v_0,v_2) (v0,v2)。因此⑤⑥的连线就是满足指向下一条依附于顶点 v 0 v_0 v0的边的目标,注意ilink 指向的结点的jvex一定要和它本身的ivex的值相同。同样的道理,连线⑦就是指 ( v 1 , v 0 ) (v_1,v_0) (v1,v0)这条边,它是相当于顶点 v 1 v_1 v1指向 ( v 1 , v 2 ) (v_1,v_2) (v1,v2)边后的下一条。 v 2 v_2 v2有三条边依附,所以在③之后就有了③④。连线①的就是顶点 v 3 v_3 v3在连线④之后的下一条边。左图一共有5条边,所以右图有10条连线,完全符合预期。
数据结构(C++)笔记:06.图_第13张图片
到这里,大家应该可以明白,邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了,若要删除左图的 ( v 0 , v 2 ) (v_0,v_2) (v0,v2)这条边,只需要将右图的⑥③的链接指向改为 ∧ \wedge 即可。由于各种基本操作的实现也和邻接表是相似的,这里我们就不讲解代码了。
邻接多重表结点结构:
在这里插入图片描述
其中,vertex:数据域,存储顶点的数据信息。
firstedge:边表头指针,指向依附于该顶点的第一条边的边表结点。
ivex、jvex:某条边依附的两个顶点在顶点表中的下标。
ilink:指针域,指向依附于顶点ivex的下一条边。
jlink:指针域,指向依附于顶点jvex的下一条边。

6.2.5 边集数组

边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成,如下图所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。关于边集数组的应用在参考书6.4.2节的克鲁斯卡尔(Kruskal)算法中有介绍,这里就不再详述了。
数据结构(C++)笔记:06.图_第14张图片

6.2.6 图的存储结构的比较

设图G含有n个顶点e条边,比较邻接矩阵和邻接表存储结构。
邻接矩阵和邻接表均可用于存储有向图和无向图,也都可用于存储网图。

  1. 空间性能比较(略)

  2. 时间性能比较(略)

  3. 唯一性比较(略)

6.3 图的连通性

6.3.1 无向图的连通性

判定一个无向图是否为连通图,或有几个连通分量,可以设置一个计数器count,初始时取值为0,每调用一次遍历算法,就给count增1。这样,当整个遍历算法结束时,依据count的值,就可确定图的连通性了。算法用伪代码描述如下:

  1. count=0;
  2. for (图中每个顶点v)
    2.1 if (v尚未被访问过)
    2.1.1 count++;
    2.1.2 从v出发遍历该图;
  3. if (count==1) cout<<“图是连通的”;
    else cout<<“图中有”<

6.3.2 有向图的连通性

⑴ 从某个顶点出发进行深度优先遍历,按出栈的顺序将顶点排列起来;
⑵ 从最后完成访问的顶点出发,作逆向的深度优先遍历;
⑶ 每一次逆向深度优先遍历所访问到的顶点集便是该有向图的一个强连通分量的顶点集。

6.3.3 生成树和生成森林

生成树可以在图的遍历过程中得到。
从连通图G=(V,E)中任一顶点出发进行遍历,必定将边集E分成两个集合T和B,其中T是遍历过程中经历的边的集合,B是剩余的边的集合。显然,T和图G中所有顶点一起构成连通图G的一棵生成树。

6.4 应用举例

6.4.1 最小生成树

设G=(V,E)是一个无向连通网,生成树上各边的权值之和称为该生成树的代价,在G的所有生成树中,代价最小的生成树称为最小生成树。
应用举例:
假设你是电信的实施工程师,需要为一个镇的九个村庄架设通信网络做设计,村庄位置大致如下图,其中 v 0   v 8 v_0~v_8 v0 v8是村庄,之间连线的数字表示村与村间的可通达的直线距离,比如 v 0 v_0 v0 v 1 v_1 v1就是10公里(个别如 v 0 v_0 v0 v 6 v_6 v6 v 6 v_6 v6 v 8 v_8 v8 v 5 v_5 v5 v 7 v_7 v7未测算距离是因为有高山或湖泊,不予考虑)。你们领导要求你必须用最小的成本完成这次任务。你说怎么办?
数据结构(C++)笔记:06.图_第15张图片
显然这是一个带权值的图,即网结构。所谓的最小成本,就是n个顶点,用n-1条边把一个连通图连接起来,并且使得权值的和最小。在这个例子里,每多一公里就多一份成本,所以只要让线路连线的公里数最少,就是最少成本了。
我们在讲图的定义和术语时,曾经提到过,一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n-1条边。显然下图的三个方案都是上图的网图的生成树。那么我们把构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。找连通网的最小生成树,经典的有两种算法,普里姆算法和克鲁斯卡尔算法。我们就分别来介绍一下。
数据结构(C++)笔记:06.图_第16张图片

Prim算法

为了能讲明白这个算法,我们先构造下图左边的邻接矩阵,如下图右边所示:
数据结构(C++)笔记:06.图_第17张图片
也就是说,现在我们已经有了一个存储结构为MGragh的G(见6.2.1节邻接矩阵)。G有9个顶点,它的arc二维数组如上图的右图所示。数组中的我们用65535来代表∞。
数据结构(C++)笔记:06.图_第18张图片
数据结构(C++)笔记:06.图_第19张图片
数据结构(C++)笔记:06.图_第20张图片
由算法代码中的循环嵌套可得知此算法的时间复杂度为 o ( n 2 ) o(n^2) o(n2)
Prim算法如何存储图?邻接矩阵

·如何表示候选最短边?
辅助数组lowcost
辅助数组adjvex

伪代码:

  1. 初始化两个辅助数组lowcost和adjvex;
  2. 输出顶点v0,将顶点v0加入集合U中;
  3. 重复执行下列操作n-1次
    3.1 在lowcost中选取最短边,取adjvex中对应的顶点序号k;
    3.2 输出顶点k和对应的权值;
    3.3 将顶点k加入集合U中;
    3.4 调整数组lowcost和adjvex;

参考代码:

/******************************* 
   对应教材6.4.1节,Prim算法 
********************************/
#include 
using namespace std;

const int MaxSize = 10;                   //图中最多顶点个数
int visited[MaxSize] = {0};              //全局数组变量visited初始化
template <class DataType>
class MGraph
{
public:
   	MGraph(DataType a[ ], int n, int e);     //构造函数,建立具有n个顶点e条边的图
   	~MGraph( ){ };                        //析构函数
 	void Prim(int v);
private:
    DataType vertex[MaxSize];           //存放图中顶点的数组
    int edge[MaxSize][MaxSize];           //存放图中边的数组
    int vertexNum, edgeNum;              //图的顶点数和边数

 	int MinEdge(int r[ ], int n);
};
  
template <class DataType>
MGraph<DataType> :: MGraph(DataType a[ ], int n, int e) 
{
   	int i, j, k, w;
   	vertexNum = n; edgeNum = e;
   	for (i = 0; i < vertexNum; i++)          //存储顶点
   		vertex[i] = a[i];
   	for (i = 0; i < vertexNum; i++)          //初始化邻接矩阵
 		for (j = 0; j < vertexNum; j++)
   			if (i == j)
			   edge[i][j] = 0;
			else
			   edge[i][j] = 100;               //假设边上权的最大值是100             
   	for (k = 0; k < edgeNum; k++)           //依次输入每一条边
   	{
   		cout << "请输入边依附的两个顶点的编号,以及边上的权值:";
		cin >> i >> j >> w;                       //输入边依附的两个顶点的编号
		edge[i][j] = w; edge[j][i] = w;           //置有边标志
   	}
}

template <class DataType>
void MGraph<DataType> :: Prim(int v)                           //从顶点v出发
{   
	int i, j, k;
	int adjvex[MaxSize], lowcost[MaxSize];
    for (i = 0; i < vertexNum; i++)             //初始化辅助数组
	{
		lowcost[i] = edge[v][i]; adjvex[i] = v;
	}
	lowcost[v] = 0;                         //将顶点v加入集合U
	for (k = 1; k < vertexNum; k++)            //迭代n-1次
	{
		j = MinEdge(lowcost, vertexNum);       //寻找最短边的邻接点j
      	cout << "(" << vertex[j] << "," << vertex[adjvex[j]] << ")" << lowcost[j] << endl; 
		lowcost[j] = 0;                       //顶点j加入集合U
		for (i = 0; i < vertexNum; i++)          //调整辅助数组
	        if (edge[i][j] < lowcost[i]) {
				lowcost[i] = edge[i][j]; 
				adjvex[i] = j;
        	}
    }
}

template <class DataType>
int MGraph<DataType> :: MinEdge(int r[ ], int n)
{
	int index = 0, min = 100;           //此处如果仅记载最小值下标会有bug 
	for (int i = 1; i < n; i++)
		if (r[i] != 0 && r[i] < min)
		{
			min = r[i]; index = i;		
		}
	return index;
}

int main( )
{
	/*测试数据使用教材 图6-16 所示带权无向图, 输入边依次为 
	(0 1 34)(0 2 46)(0 5 19)(1 4 12)(2 3 17)(2 5 25)(3 4 38)(3 5 25)(4 5 26) */ 
	char ch[ ]={'0','1','2','3','4','5'};       
	MGraph<char> MG(ch, 6, 9); 
	MG.Prim(0);
	return 0;
}

克鲁斯卡尔(Kruskal)算法

现在我们来换一种思考方式,普里姆(Prim)算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。这就像是我们如果去参观某个展会,例如世博会,你从一个入口进去,然后找你所在位置周边的场馆中你最感兴趣的场馆观光,看完后再用同样的办法看下一个。可我们为什么不事先计划好,进园后直接到你最想去的场馆观看呢?事实上,去世博园的观众,绝大多数都是这样做的。
同样的思路,我们也可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码:
数据结构(C++)笔记:06.图_第21张图片
我们来把克鲁斯卡尔(Kruskal)算法的实现定义归纳一下结束这一节的讲解。
假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。
此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
数据结构(C++)笔记:06.图_第22张图片
数据结构(C++)笔记:06.图_第23张图片
数据结构(C++)笔记:06.图_第24张图片
数据结构(C++)笔记:06.图_第25张图片
数据结构(C++)笔记:06.图_第26张图片
参考代码:

/********************************** 
   对应教材6.4.2节,Kruskal算法 
***********************************/
#include 
using namespace std;

struct EdgeType                              //定义边集数组的元素类型
{
  int from, to, weight;                  //假设权值为整数
};

const int MaxVertex = 10;                //图中最多顶点数
const int MaxEdge = 100;                //图中最多边数
template <typename DataType>           //定义模板类
class EdgeGraph                              
{
public:
  	EdgeGraph(DataType a[ ], int n, int e);   //构造函数,生成n个顶点e条边的连通图
	~EdgeGraph( );                      //析构函数
	void Kruskal( );                      //Kruskal算法求最小生成树
private:
	int FindRoot(int parent[ ], int v);         //求顶点v所在集合的根
	
	DataType vertex[MaxVertex];          //存储顶点的一维数组
  	EdgeType edge[MaxEdge];            //存储边的边集数组
	int vertexNum, edgeNum;        
};

template <typename DataType>
EdgeGraph<DataType> :: EdgeGraph(DataType a[ ], int n, int e)
{
	int i, j, k, w;
	vertexNum = n; edgeNum = e;
	for (i = 0; i < vertexNum; i++)
		vertex[i] = a[i];
	for (k = 0; k < edgeNum; k++)
	{
		cout << "请输入边依附的两个顶点的编号,以及边上的权值:";
		cin >> i >> j >> w;                       //输入边依附的两个顶点的编号
		edge[k].from = i; edge[k].to = j; edge[k].weight = w;
	}
}

template <typename DataType>
EdgeGraph<DataType> :: ~EdgeGraph( )
{
	
}

template <typename DataType>
void EdgeGraph<DataType> :: Kruskal( )
{
	int num = 0, i, vex1, vex2;
	int parent[vertexNum];                        //双亲表示法存储并查集
	for (i = 0; i < vertexNum; i++)
  		parent[i] = -1;                             //初始化n个连通分量
	for (num = 0, i = 0; num < vertexNum - 1; i++)    //依次考察最短边
	{
  		vex1 = FindRoot(parent, edge[i].from);
  		vex2 = FindRoot(parent, edge[i].to);
  		if (vex1 != vex2) {                         //位于不同的集合
    		cout << "(" << edge[i].from << "," << edge[i].to << ")" << edge[i].weight << endl;
			parent[vex2] = vex1;                      //合并集合
    		num++;
		}
	}
}

template <typename DataType>
int EdgeGraph<DataType> :: FindRoot(int parent[ ], int v)           //求顶点v所在集合的根
{
	int t = v;
	while (parent[t] > -1)                 //求顶点t的双亲一直到根
		t = parent[t];          
	return t;
}

int main( )
{
	/* 测试数据使用教材 图6-18 所示带权无向图,输入边依次为 
	(1 4 12)(2 3 17)(0 5 19) (2 5 25)(3 5 25)(4 5 26)(0 1 34)(3 4 38)(0 2 46)  */ 
	char ch[ ]={'0','1','2','3','4','5'};       
	EdgeGraph<char> EG(ch, 6, 9);             
	EG.Kruskal( );
	return 0;
}

6.4.2 最短路径

在非网图中,最短路径是指两顶点之间经历的边数最少的路径。
在网图中,最短路径是指两顶点之间经历的边上权值之和最少的路径。
在网图和非网图中,最短路径的含义是不同的。由于非网图它没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。显然,我们研究网图更有实际意义,就地图来说,距离就是两顶点间的权值之和。而非网图完全可以理解为所有的边的权值都为1的网。

  1. 单源点最短路径问题
    单源点最短路径问题描述为:
    给定有向网图G=(V,E)和源点v∈V,求从v到G中其余各顶点的最短路径。
    迪杰斯特拉算法的基本思想是:
    将顶点集合V分成两个集合,一类是生长点的集合S,包括源点和已经确定最短路径的顶点;另一类是非生长点的集合V-S,包括所有尚未确定最短路径的顶点,并使用一个待定路径表,存储当前从源点v到每个非生长点的最短路径。初始时,S只包含源点v,对 v i ∈ V − S v_i\in V-S viVS,待定路径表为从源点v到 v i v_i vi的有向边。然后在待定路径表中找到当前最短路径 v … v k v…v_k vvk,将 v k v_k vk加入集合S中,对 v i ∈ V − S v_i\in V-S viVS,将路径 v … v k v i v…v_kv_i vvkvi与待定路径表中从源点v到 v i v_i vi,的最短路径相比较,取路径长度较小者为当前最短路径。重复上述过程,直到集合V中全部顶点加入到集合S中。
    也就是说,我们通过迪杰斯特拉(Dijkstra)算法解决了从某个源点到其余各顶点的最短路径问题。从循环嵌套可以很容易得到此算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),尽管有同学觉得,可不可以只找到从源点到某一个特定终点的最短路径,其实这个问题和求源点到其他所有顶点的最短路径一样复杂,时间复杂度依然是 O ( n 2 ) O(n^2) O(n2)
    这就好比,你吃了七个包子终于算是吃饱了,就感觉很不划算,前六个包子白吃了,应该直接吃第七个包子,于是你就去寻找可以吃一个就能饱肚子的包子,能够满足你的要求最终结果只能有一个,那就是用七个包子的面粉和馅做的一个大包子。这种只关注结果而忽略过程的思想是非常不可取的。
    伪代码:
1. 初始化数组dist、path和s;
2. while (s中的元素个数<n)
 	2.1 在dist[n]中求最小值,其下标为k(则vk为正在生成的终点);
    2.2 输出dist[j]和path[j];
    2.3 修改数组dist和path;
    2.4 将顶点vk添加到数组s中;
/********************************** 
   对应教材6.5.1节,Dijkstra算法 cfree 5.0编译通过
***********************************/
#include 
#include 
using namespace std;

const int MaxSize = 10;                   //图中最多顶点个数
template <class DataType>
class MGraph
{
public:
   	MGraph(DataType a[ ], int n, int e);     //构造函数,建立具有n个顶点e条边的图
   	~MGraph( ){ };                        //析构函数
	void Dijkstra(int v);
private:
    int Min(int r[ ], int n);
	DataType vertex[MaxSize];           //存放图中顶点的数组
    int edge[MaxSize][MaxSize];           //存放图中边的数组
    int vertexNum, edgeNum;              //图的顶点数和边数
 };
  
template <class DataType>
MGraph<DataType> :: MGraph(DataType a[ ], int n, int e) 
{
   	int i, j, k, w;
   	vertexNum = n; edgeNum = e;
   	for (i = 0; i < vertexNum; i++)          //存储顶点
   		vertex[i] = a[i];
   	for (i = 0; i < vertexNum; i++)          //初始化邻接矩阵
 		for (j = 0; j < vertexNum; j++)
   			if (i == j)
			   	edge[i][j] = 0;             
			else
			 	edge[i][j] = 1000;            //假设边上权值最大是1000 
   	for (k = 0; k < edgeNum; k++)           //依次输入每一条边
   	{
   		cout << "请输入边依附的两个顶点的编号,以及边上的权值:";
		cin >> i >> j >> w;                       //输入边依附的两个顶点的编号
		edge[i][j] = w;                           //置有边标志
   	}
}

template <class DataType>
void MGraph<DataType> :: Dijkstra(int v)                      //从源点v出发
{
  	int i, k, num, dist[MaxSize];
  	string path[MaxSize];
	for (i = 0; i < vertexNum; i++)            //初始化数组dist和path
	{
   		dist[i] = edge[v][i];
   		if (dist[i] != 1000)                    //假设1000为边上权的最大值
			path[i] = vertex[v] + vertex[i];       //+为字符串连接操作
   		else path[i] = "";
   	}
	for (num = 1; num < vertexNum; num++)
	{
		k = Min(dist, vertexNum);      //在dist数组中找最小值并返回其下标
  		cout << path[k] <<":" << dist[k] << endl;
  		for (i = 0; i < vertexNum; i++)             //修改数组dist和path
    		if (dist[i] > dist[k] + edge[k][i]) {
       			dist[i] = dist[k] + edge[k][i];
       			path[i] = path[k] + vertex[i];         //+为字符串连接操作
    		}
		dist[k] = 0;                            //将顶点k加到集合S中
	}
}

template <class DataType>
int MGraph<DataType> ::Min(int r[ ], int n)
{
	int index = 0, min = 1000;
	for (int i = 0; i < n; i++)
		if (r[i] != 0 && r[i] < min)
		{
			min = r[i]; index = i;		
		}
	return index;
}

int main( )
{
	int i;
	string ch[ ]={"A","B","C","D","E"};       
	/* 测试数据使用 图6-20(b),输入边七条边是:
	(0 1 10)(0 4 100)(0 3 30)(1 2 50)(2 4 10)(3 2 20)(3 4 60)  */  
	MGraph<string> MG(ch, 5, 7);              //建立具有5个顶点7条边的无向图
	MG.Dijkstra(0);
	return 0;
}


数据结构(C++)笔记:06.图_第27张图片
2. 每一对顶点之间的最短路径问题
如果我们还需要知道如 v 3 v_3 v3 v 5 v_5 v5 v 1 v_1 v1 v 7 v_7 v7这样的任一顶点到其余所有顶点的最短路径怎么办呢?此时简单的办法就是对每个顶点当作源点运行一次迪杰斯特拉(Dijkstra)算法,等于在原有算法的基础上,再来一次循环,此时整个算法的时间复杂度就成了 O ( n 3 ) O(n^3) O(n3)
对此,我们现在再来介绍另一个求最短路径的算法一—弗洛伊德(Floyd),它求所有顶点到所有顶点的时间复杂度也是 O ( n 3 ) O(n^3) O(n3),但其算法非常简洁优雅,能让人感觉到智慧的无限魅力。好了,让我们就一同来欣赏和学习它吧。
每一对顶点之间的最短路径问题描述为:
给定带权有向网图G=(V,E),对任意顶点 v i v_i vi v j ( i < > j ) v_j(i<>j) vj(i<>j),求顶点 v i v_i vi到顶点 v j v_j vj的最短路径。
解决方案之一:每次以一个顶点为源点,调用Dijkstra算法n次。这样,便可求得每一对顶点之间的最短路径,时间复杂度为 O ( n 3 ) O(n^3) O(n3)
解决方案之二:弗洛伊德提出的Floyd算法,其时间复杂度是 O ( n 3 ) O(n^3) O(n3),但形式上要简单些。
Floyd算法的基本思想是:
小哼准备去一些城市旅游。有些城市之间有公路,有些城市之间则没有,如下图。为了节省经费以及方便计划旅程,小哼希望在出发之前知道任意两个城市之前的最短路程。
数据结构(C++)笔记:06.图_第28张图片
上图中有4个城市8条公路,公路上的数字表示这条公路的长短。请注意这些公路是单向的。我们现在需要求任意两个城市之间的最短路程,也就是求任意两个点之间的最短路径。这个问题这也被称为“多源最短路径”问题。
现在需要一个数据结构来存储图的信息,我们仍然可以用一个4*4的矩阵(二维数组e)来存储。比如1号城市到2号城市的路程为2,则设e[1][2]的值为2。2号城市无法到达4号城市,则设置e[2][4]的值为∞。另外此处约定一个城市自己是到自己的也是0,例如e[1][1]为0,具体如下:
数据结构(C++)笔记:06.图_第29张图片
现在回到问题:如何求任意两点之间最短路径呢?通过之前的学习我们知道通过深度或广度优先搜索可以求出两点之间的最短路径。所以进行n^2遍深度或广度优先搜索或者DIJKSTRA算法,即对每两个点都进行一次深度或广度优先搜索,便可以求得任意两点之间的最短路径。可是还有没有别的方法呢?
我们来想一想,根据我们以往的经验,如果要让任意两点(例如从顶点a点到顶点b)之间的路程变短,只能引入第三个点(顶点k),并通过这个顶点k中转即a->k->b,才可能缩短原来从顶点a点到顶点b的路程。那么这个中转的顶点k是1~n中的哪个点呢?甚至有时候不只通过一个点,而是经过两个点或者更多点中转会更短,即a->k1->k2->b或者a->k1->k2…->ki…->b。比如上图中从4号城市到3号城市(4->3)的路程e[4][3]原本是12。如果只通过1号城市中转(4->1->3),路程将缩短为11(e[4][1]+e[1][3]=5+6=11)。其实1号城市到3号城市也可以通过2号城市中转,使得1号到3号城市的路程缩短为5(e[1][2]+e[2][3]=2+3=5)。所以如果同时经过1号和2号两个城市中转的话,从4号城市到3号城市的路程会进一步缩短为10。通过这个的例子,我们发现每个顶点都有可能使得另外两个顶点之间的路程变短。好,下面我们将这个问题一般化。
当任意两点之间不允许经过第三个点时,这些城市之间最短路程就是初始路程,如下:
假如现在只允许经过1号顶点,求任意两点之间的最短路程,应该如何求呢?只需判断e[i][1]+e[1][j]是否比e[i][j]要小即可。e[i][j]表示的是从i号顶点到j号顶点之间的路程。e[i][1]+e[1][j]表示的是从i号顶点先到1号顶点,再从1号顶点到j号顶点的路程之和。其中i是1~ n循环,j也是1~n循环,代码实现如下:

for (i = 1; i <= n; i++)
{
       for (j = 1; j <= n; j++)
       {
             if (e[i][j] > e[i][1] + e[1][j])
                   e[i][j] = e[i][1] + e[1][j];
       }
}

在只允许经过1号顶点的情况下,任意两点之间的最短路程更新为:
数据结构(C++)笔记:06.图_第30张图片
通过上图我们发现:在只通过1号顶点中转的情况下,3号顶点到2号顶点(e[3][2])、4号顶点到2号顶点(e[4][2])以及4号顶点到3号顶点(e[4][3])的路程都变短了。
接下来继续求在只允许经过1和2号两个顶点的情况下任意两点之间的最短路程。如何做呢?我们需要在只允许经过1号顶点时任意两点的最短路程的结果下,再判断如果经过2号顶点是否可以使得i号顶点到j号顶点之间的路程变得更短。即判断e[i][2]+e[2][j]是否比e[i][j]要小,代码实现为如下:

//经过1号顶点
for(i=1;i<=n;i++)  
	for(j=1;j<=n;j++)  
		if (e[i][j] > e[i][1]+e[1][j])  e[i][j]=e[i][1]+e[1][j];  
//经过2号顶点
for(i=1;i<=n;i++)  
	for(j=1;j<=n;j++)  
		if (e[i][j] > e[i][2]+e[2][j])  e[i][j]=e[i][2]+e[2][j];

通过上图得知,在相比只允许通过1号顶点进行中转的情况下,这里允许通过1和2号顶点进行中转,使得e[1][3]和e[4][3]的路程变得更短了。
同理,继续在只允许经过1、2和3号顶点进行中转的情况下,求任意两点之间的最短路程。任意两点之间的最短路程更新为:
数据结构(C++)笔记:06.图_第31张图片
最后允许通过所有顶点作为中转,任意两点之间最终的最短路程为:
数据结构(C++)笔记:06.图_第32张图片
整个算法过程虽然说起来很麻烦,但是代码实现却非常简单,核心代码只有五行:

for(k=1;k<=n;k++)  
    for(i=1;i<=n;i++)  
    	for(j=1;j<=n;j++)  
    		if(e[i][j]>e[i][k]+e[k][j])  
                 e[i][j]=e[i][k]+e[k][j];

这段代码的基本思想就是:最开始只允许经过1号顶点进行中转,接下来只允许经过1和2号顶点进行中转……允许经过1~n号所有顶点进行中转,求任意两点之间的最短路程。用一句话概括就是:从i号顶点到j号顶点只经过前k号点的最短路程。

另外需要注意的是:Floyd-Warshall算法不能解决带有“负权回路”(或者叫“负权环”)的图,因为带有“负权回路”的图没有最短路。例如下面这个图就不存在1号顶点到3号顶点的最短路径。因为1->2->3->1->2->3->…->1->2->3这样路径中,每绕一次1->-2>3这样的环,最短路就会减少1,永远找不到最短路。其实如果一个图中带有“负权回路”那么这个图则没有最短路。
数据结构(C++)笔记:06.图_第33张图片
Floyd算法基于的存储结构。
⑴ 图的存储结构:与Dijkstra算法类似,采用邻接矩阵作为图的存储结构。
⑵ 辅助数组dist[n][n];根据如下递推公式进行迭代:
在这里插入图片描述
其中,dist k[i][j]是从顶点 v i v_i vi v j v_j vj的中间顶点的序号不大于k的最短路径的长度;
⑶ 辅助数组path[n][n]:在迭代中存放从 v i v_i vi v j v_j vj的最短路径,初始为path[i][j]= v i v_i vi v j v_j vj

实例代码:

/******************************* 
   对应教材6.5.2节,Floyd算法 
********************************/
#include 
#include 
using namespace std;

const int MaxSize = 10;                   //图中最多顶点个数
template <class DataType>
class MGraph
{
public:
   	MGraph(DataType a[ ], int n, int e);     //构造函数,建立具有n个顶点e条边的图
   	~MGraph( ){ };                        //析构函数
	void Floyd( );
private:
	DataType vertex[MaxSize];           //存放图中顶点的数组
    int edge[MaxSize][MaxSize];           //存放图中边的数组
    int vertexNum, edgeNum;              //图的顶点数和边数
 };
  
template <class DataType>
MGraph<DataType> :: MGraph(DataType a[ ], int n, int e) 
{
   	int i, j, k, w;
   	vertexNum = n; edgeNum = e;
   	for (i = 0; i < vertexNum; i++)          //存储顶点
   		vertex[i] = a[i];
   	for (i = 0; i < vertexNum; i++)          //初始化邻接矩阵
 		for (j = 0; j < vertexNum; j++)
   			if (i == j)
			   	edge[i][j] = 0;             
			else
			 	edge[i][j] = 1000;            //假设边上权值最大是1000 
   	for (k = 0; k < edgeNum; k++)           //依次输入每一条边
   	{
   		cout << "请输入边依附的两个顶点的编号,以及边上的权值:";
		cin >> i >> j >> w;                       //输入边依附的两个顶点的编号
		edge[i][j] = w;                           //置有边标志
   	}
}

template <class DataType>
void MGraph<DataType> :: Floyd( )
{
	int i, j, k, dist[MaxSize][MaxSize];
	string path[MaxSize][MaxSize];
	for (i = 0; i < vertexNum; i++)              //初始化矩阵dist和path
	  	for (j = 0; j < vertexNum; j++)
	  	{
	    	dist[i][j] = edge[i][j];
	     	if (dist[i][j] != 1000)                 //假设100为边上权的最大值
	       		path[i][j] = vertex[i] + vertex[j];     //+为字符串连接操作
	     	else path[i][j] = "";
	    }
	for (k = 0; k < vertexNum; k++)                //进行n次迭代
		for (i = 0; i < vertexNum; i++)       
	    	for (j = 0; j < vertexNum; j++)
	        	if (dist[i][k] + dist[k][j] < dist[i][j]) {
	          		dist[i][j] = dist[i][k] + dist[k][j];
	          		path[i][j] = path[i][k] + path[k][j];    //+为字符串连接操作
       			}
    for (i = 0; i < vertexNum; i++)
	{       
	    for (j = 0; j < vertexNum; j++)
	        cout << path[i][j] << ":" << dist[i][j] << "\t";
	    cout << endl;
	}
}

int main( )
{
	int i;
	string ch[ ]={"A","B","C"};       
	/* 测试数据使用 图6-24,输入边五条边是:
	(0 1 4)(0 2 1)(1 0 6)(1 2 2)(2 0 3)  */  
	cout <<"测试数据:(0 1 4)(0 2 1)(1 0 6)(1 2 2)(2 0 3)"<< "\n";
	MGraph<string> MG(ch, 3, 5);              //建立具有3个顶点5条边的无向图
	MG.Floyd( );
	return 0;
}


数据结构(C++)笔记:06.图_第34张图片
数据结构(C++)笔记:06.图_第35张图片
注意,代码中输出没有处理重复节点,例如:BCCA应该要BCA

6.4.3 AOV网与拓扑排序

AOV网:在现代化管理中,人们常用有向图来描述和分析一项工程的计划和实施过程,一个工程常被分为多个小的子工程,这些子工程被称为活动(Activity),在有向图中若以顶点表示活动,有向边表示活动之间的先后关系,这样的图简称为AOV网。
在AOV网中不能出现回路,因此判断AOV网所代表的工程能否顺利进行,即判断它是否存在回路。而测试AOV网是否存在回路的方法,就是对AOV网进行拓扑排序。
拓扑序列的定义:
设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列 v 1 , v 2 , … , v n v_1,v_2,…,v_n v1,v2,,vn,满足若从顶点 v i v_i vi v j v_j vj,有一条路径,则在顶点序列中顶点 v i v_i vi必在顶点 v j v_j vj之前。则我们称这样的顶点序列为一个拓扑序列。
对AOV网进行拓扑排序的基本思路是:
从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
拓扑排序算法的存储结构。
⑴ 图的存储结构:图采用邻接表存储。在顶点表中增加一个入度域,结点结构为:
在这里插入图片描述
其中,vertex、firstedge的含义如邻接表;in为记录顶点入度的数据域。
⑵ 对没有前驱顶点的查找,为了避免每次查找时都去遍历顶点表,可设置一个栈,凡是AOV网中入度为0 的顶点都将其压栈。不妨采用顺序栈。
拓扑排序的算法用伪代码描述为:

1. 栈S初始化;累加器count初始化;
2. 扫描顶点表,将没有前驱(即入度为0)的顶点压栈;
3. 当栈S非空时循环
    3.1 vj=退出栈顶元素;输出vj;累加器加1;
	3.2 将顶点vj的各个邻接点的入度减1;
	3.3 将新的入度为0的顶点入栈;
4. if (count<vertexNum) 输出有回路信息;

6.4.4 AOE网与关键路径

AOE网是一个带权的有向无环图。其中用顶点表示事件,弧表示活动,权值表示两个活动持续的时间。AOE网是以边表示活动的网。在用AOE网表示一个工程计划时,用顶点表示各个事件,弧表示子工程的活动,权值表示子工程的活动需要的时间。在顶点表示事件发生之后,从该顶点出发的有向弧所表示的活动才能开始。在进入某个顶点的有向弧所表示的活动完成之后,该顶点表示的事件才能发生。
AOE网具有以下两个性质:
⑴ 只有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始;
⑵ 只有在进入某顶点的各活动都已经结束,该顶点所代表的事件才能发生。
数据结构(C++)笔记:06.图_第36张图片
1.事件的最早发生时间etv(earliest time of vertex):即顶点 v k v_k vk的最早发生时间。
2.事件的最晚发生时间ltv(latest time of vertex):即顶点 v k v_k vk的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
3.活动的最早开工时间ete(earliest time of edge):即弧 a k a_k ak的最早发生时间。
4.活动的最晚开工时间lte(latest time of edge):即弧 a k a_k ak的最晚发生时间,也就是不推迟工期的最晚开工时间。
求关键路径的算法用伪代码描述为:
实例

1. 从始点v1出发,令ve[1]=0,按拓扑序列求其余各顶点的最早发生时间ve[i];
2. 如果得到的拓扑序列中顶点个数小于AOE网中顶点数,则说明网中存在环,不能求关键路径,算法终止;否则执行步骤3;
3. 从终点vn出发,令vl[n]=ve[n],按逆拓扑有序求其余各顶点的最迟发生时间vl[i];
4. 根据各顶点的ve和vl值,求每条有向边的最早开始时间e [i]和最迟开始时间l [i];
5. 若某条有向边ai满足条件e[i]=l[i],则ai为关键活动。

实践证明,通过这样的算法对于工程的前期工期估算和中期的计划调整都有很大的帮助。不过注意,书上的例子是唯一一条关键路径,这并不等于不存在多条关键路径的有向无环图。如果是多条关键路径,则单是提高一条关键路径上的关键活动的速度并不能导致整个工程缩短工期,而必须提高同时在几条关键路径上的活动的速度。这就像仅仅是有事业的成功,而没有健康的身体以及快乐的生活,是根本谈不上幸福的人生一样,三者缺一不可。

你可能感兴趣的:(数据结构和算法)