【第六章】图

【知识框架】

\[图 \begin{cases} 图的定义 \\ 图结构的存储 \begin{cases} 邻接矩阵法、邻接表法 \\ 临街多重表、十字链表 \\ \end{cases} \\ 图的遍历 \begin{cases} 深度优先遍历 \\ 广度优先遍历 \\ \end{cases} \\ 图的相关应用 \begin{cases} 最小生成树:Prim算法、Kruskal算法 \\ 最短路径:Dijkstra算法、Floyd算法 \\ 拓扑排序:AOV网 \\ 关键路径:AOE网 \end{cases} \end{cases} \]

【复习提示】
图算法的难度较大,主要掌握深度优先搜索和广度优先搜索。掌握图的基本概念及基本性质、图的存储结构(邻接矩阵、邻接表、邻接多重表和十字链表)及特性、存储结构之间的转化、基于存储结构上的各种遍历操作和各种应用(拓扑排序、最小生成树、最短路径和关键路径)等。图的相关算法较多,通常只需掌握其基本思想和实现步骤,而实现代码不是重点。

6.1 图的基本概念

6.1.1 图的定义

  • \(G\) 由顶点集 \(V\) 和边集 \(E\) 组成,记为 \(G=(V,E)\)
    • 其中 \(V(G)\) 表示图 \(G\) 中顶点的有限非空集;
    • \(E(G)\) 表示图 \(G\) 中顶点之间的关系(边)集合。
  • \(V=\{ v_{1},v_{2},\dots,v_{n} \}\)
    • 则用 \(|V|\) 表示图 \(G\) 中顶点的个数;
  • \(E=\{ (u,v)|u\in V,v\in V \}\)
    • \(|E|\) 表示图 \(G\) 中边的条数。

[!abstract] 注意
线性表可以是空表,树可以是空树,但图不可以是空图。也就是说,图中不能一个顶点也没有,图的顶点集 \(V\) 一定非空,但边集 \(E\) 可以为空,此时图中只有顶点而没有边。

换言之,图不允许为空,但允许只有一个结点且无边。

下面是图的一些节本概念及术语。

1. 有向图

  • 有向图:若 \(E\) 是有向边(也称)的有限集合,则图 \(G\) 为有向图。
  • 是顶点的有序对,记为 \(<v,w>\)
    • 顶点:其中 \(v,w\)顶点
    • 弧尾\(v\) 称为弧尾
    • \(<v,w>\) 称为从 \(v\)\(w\),也称 \(v\) 邻接到 \(w\)
      图6.1(a)所示的有向图 \(G_{1}\) 可表示为:

image.png

\[\begin{array} \\ G_{1}=(V_{1},E_{1}) \\ V_{1}=\{ 1,2,3 \} \\ E_{1}=\{ <1,2>,<2,1>,<2,3> \} \\ \end{array} \]

2. 无向图

  • 无向图:若 \(E\) 是无向边(简称边)的有限集合,则图 \(G\)无向图
  • 是顶点的无序对,记为 \((v,w)\)\((w,v)\)。可以说 \(w\)\(v\) 互为邻接点。
    • \((v,w)\) 依附于 \(w\)\(v\),或称边 \((v,w)\)\(v,w\) 相关联。

图6.1(b)所示的无向图 \(G_{2}\) 可表示为
image.png

\[\begin{array} \\ G_{2}=(V_{2},E_{2}) \\ V_{2}=\{ 1,2,3,4 \} \\ E_{2}=\{ (1,2),(1,3),(1,4),(2,3),(2,4),(3,4) \} \end{array} \]

3. 简单图、多重图

  • 简单图:一个图 \(G\) 若满足:
    1. 不存在重复边
    2. 不存在顶点到自身的边,则称图 \(G\)简单图
  • 多重图:若图 \(G\) 中某两个顶点之间的边数大于1条,又允许顶点通过一条边和自身关联,则称图 \(G\)多重图

图6.1中 \(G_{1}\)\(G_{2}\) 均为简单图。

多重图和简单图的定义是相对的。本书中仅讨论简单图

4. 顶点的度、入度和出度

  • 顶点的度:在无向图中,顶点 \(v\) 的度是指依附于顶点 \(v\) 的边的条数,记为 \(TD(v)\)
    • 在图6.1(b)中,每个顶点的度均为3。无向图的全部顶点的度之和等于边数的2倍,因为每条边和两个顶点相关联。

image.png

注意区分树的度和图的图,在树中一个结点的度表示其孩子的数量,叶结点没有度。

有向图中,顶点 \(v\) 的度分为入度出度

  • 入度入度是以顶点 \(v\)终点的有向边的数目,记为 \(ID(v)\)
  • 出度:而出度是以顶点 \(v\)起点的有向边的数目,记为 \(OD(v)\)
  • :顶点 \(v\) 的度等于其入度出度之和,即 \(TD(v)=ID(v)+OD(v)\)

有向图的全部顶点的入度之和与出度之和相等,并且等于边数,这是因为每条有向边都有一个起点和终点。

简单来说进入到 \(v\) 的结点数目叫做入度,离开(出去)\(v\) 的结点数目就是出度。两者的绝对数量相加得到结点的

在图6.1(a)中,顶点2的出度为2、入度为1。

5. 路径、路径长度和回路

  • 路径:顶点 \(v_{p}\) 到顶点 \(v_{q}\) 之间的一条路径是指顶点序列 \(v_{p,v_{i_{1}},v_{i_{2}},\dots,v_{i_{m}},v_{q}}\),当然关联的边也可理解为路径的构成要素。
  • 路径长度:路径上的边的数目称为路径长度
  • 回路:第一个顶点和最后一个顶点相同的路径称为回路
    • 若一个图有 \(n\) 个顶点,且有大于 \(n-1\) 条边,则此图一定有环。

6. 简单路径、简单回路

  • 简单路径:在路径序列中,顶点不重复出现的路径称为简单路径
  • 简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路

7. 距离

从顶点 \(u\) 出发到顶点 \(v\) 的最短路径若存在,则此路径的长度称为从 \(u\)\(v\) 的距离。若从 \(u\)\(v\) 根本不存在路径,则记该距离为无穷(\(\infty\))。

8. 子图

  • 子图:设有两个图 \(G=(V,E)\)\(G'=(V',E')\)
    • \(V'\)\(V\) 的子集,
    • \(E'\)\(E\) 的子集,则称 \(G'\)\(G\) 的子图。
  • 生成子图:若有满足 \(V(G')=V(G)\) 的子图 \(G'\),则称其为 \(G\)生成子图

这里需要注意,生成子图也是原图的一个子图。如果说子图是可以删边、可以删点,那么生成子图就是只能删边,不能删点。

如果边和结点都是另一个图的子集合,则这个图就是该图的子图。
如果两个图有完全相同的一套结点,说明一个图是另一个图的生成子图。

[!abstract] 注意
并非 \(V\)\(E\) 的任何子集都能构成 \(G\) 的子图,因为这样的子集可能不是图,即 \(E\) 的子集中的某些边关联的顶点可能不在这个 \(V\) 的子集中。

图6.1中 \(G_{3}\)\(G_{1}\) 的子图。

image.png

9. 连通、连通图和连通分量

在无向图中,若从顶点 \(v\) 到顶点 \(w\) 有路径存在,则称 \(v\)\(w\) 是连通的。

  • 连通图:若图 \(G\)任意两个顶点都是连通的,则称图 \(G\)连通图
  • 非连通图:否则称为非连通图
  • 连通分量:无向图中的极大连通子图称为连通分量
    • 在图6.2(a)中,图 \(G_{4}\) 有3个连通分量如图6.2(b)所示。image.png
    • 思考:若该图是非连通图,则最多可以有多少条边?
  • 边数性质:
    • 非连通图:最多可以有 \(C_{n-1}^2\) 条边。(表示一个结点孤零零的丢在一边,剩下的全部两两连起来
    • 连通图:至少有 \(n-1\) 条边。

需要注意的是,连通分量也就是极大连通子图的“极大”表示一整块连通的子图,比如 {A,B,C},{D,E} ,假设这里的A、B、C互相连通,D、E互相连通,则这里的连通子图就是 {A,B,C}{D,E} 这两块,连通分量可以想象为一个个的小岛,每个小岛都是一个连通分量,这里的极大不是最大的意思。但是半个小岛不能是连通分量,因为半个小岛还不是一整块。连通分量要求加上任何一个子节点(注意要保证是子图,不能自己添加额外的边),这块分量依然连通。
如果把一张图想象为一张地图,从图6.2不难看出,图允许存在相互孤立的小岛。

🏝️——🏝️——🏝️      🏝️——🏝️

10. 强连通图、强连通分量

  • 强连通:在有向图中,若有一对顶点 \(v\)\(w\),从 \(v\)\(w\) 和从 \(w\)\(v\) 之间都有路径,则称这两个顶点是强连通的。
  • 强连通图:若图中任意一对顶点都是强连通的,则称此图为强连通图
  • 强连通分量:有向图中的极大强连通子图称为有向图的强连通分量

极大强连通子图就是再加任意一个点这个图都不能再是强连通了。换言之,一个图,如果再找一个点后仍能够保持强连通,这个图就一定不是强连通子图。

\(G_{1}\) 的强连通分量如图6.3所示。

image.png

思考:假设一个有向图有 \(n\) 个顶点,若该图是强连通图,则最少需要有多少条边?

[!question]- 让顶点之间形成一个单方向的回路,答案显然
image.png

这里我们再对比下前面的边数性质:

  • 边数性质:
    • 非连通图:最多可以有 \(C_{n-1}^2\) 条边。(表示一个结点孤零零的丢在一边,剩下的全部两两连起来
    • 连通图:至少有 \(n-1\) 条边。
    • 强连通图:最少有 \(n\) 条边。

[!abstract] 注意
在无向图中讨论连通性,在有向图中讨论强连通性。(可见连通性是在无向图和有向图中完全对应的相同意义概念)

对比连通图和强连通图:

特性 连通图 (Connected) 强连通图 (Strongly Connected)
适用范围 无向图 有向图
连通要求 任意两点间有路径即可 任意两点间必须双向都有路径
路径性质 边没有方向,可来回走 必须遵循边的方向
关联概念 连通分量 (Connected Component) 强连通分量 (SCC)

11. 生成树、生成森林

  • 生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图
    • 若图中顶点数为 \(n\),则它的生成树含有 \(n-1\) 条边。
  • 极小连通子图:包含图中全部顶点的极小连通子图(👉 翻译成人话:在不丢任何顶点的前提下,用最少的边把所有顶点连起来),
    • 只有生成树满足这个极小条件(什么样的图既连通,又删一条边就断?——树),
  • 生成森林:对生成树而言,连通分量的生成树构成了非连通图的生成森林(把森林中的每个连通分量转换成生成树就是一个图的生成森林)。

\(G_{2}\) 的一个生成树如图6.4所示。
image.png
image.png

[!abstract] 注意
区分极大连通子图极小连通子图

  • 极大连通子图:要求子图必须连通,而且包含尽可能多的顶点和边;
  • 极小连通子图:是既要保持子图又要使得边数最少的子图。

12. 边的权、网和带权路径长度

  • 权值:在一个图中,每条都可以标上具有某种含义的数值,该数值称为该边的权值
  • 带权图、网:这种边上带有权值的图称为带权图,也称
    • 带权路径长度:路径上所有边的权值之和,称为该路径的带权路径长度

注意这里的权值是赋在上的,而不是结点。如果我们依然把图想象成地图,权值就是地图上两个地点之间路径的长度,这样来看就很形象了。

13. 完全图(也称简单完全图)

  • 完全图:对于无向图,\(|E|\) 的取值范围为 \(0\)\(n(n-1)/2\)(即 \(C_{n}^2\)),有 \(n(n-1) / 2\) 条边的无向图称为完全图
    • 在完全图中任意两个顶点之间都存在边。
  • 有向完全图:对于有向图,\(|E|\) 的取值范围为 \(0\)\(n(n-1)\) (即 \(2C_{n}^2\)),有 \(n(n-1)\) 条弧的有向图称为有向完全图
    • 在有向完全图中任意两个顶点之间都存在方向相反的两条弧。

图6.1中 \(G_{2}\) 为无向完全图,而 \(G_{3}\) 为有向完全图。

image.png

无论是哪一种,只要任意两个结点之间满边,就都是完全图。

14. 稠密图、稀疏图

  • 稀疏图:边数很少的图称为稀疏图
  • 稠密图:与稀疏图相反。

稀疏和稠密本身是模糊的概念,稀疏图和稠密图常常是相对而言的。一般当图 \(G\) 满足 \(|E|<|V|\log_{2}|V|\) 时,可以将 \(G\) 视为稀疏图。

15. 有向图

一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树

就是一棵树的形状。

6.2 图的存储及基本操作

图的存储必须要完整、准确地反映顶点集和边集的信息。根据不同图的结构和算法,采用不同的存储方式将对程序的效率产生相当大的影响,因此所选的存储结构应适用于待求解的问题

6.2.1 邻接矩阵法

  • 邻接存储:所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(各顶点之间的邻接关系)。
  • 邻接矩阵:存储顶点之间邻接关系的二维数组称为邻接矩阵

顶点树为 \(n\) 的图的 \(G=(V,E)\) 的邻接矩阵 \(A\)\(m\times n\) 的,将 \(G\) 的顶点编号为 \(v_{1},v_{2},\dots,v_{n}\),则:

\[A[i][j] = \begin{cases} 1, & (v_{i},v_{j})或<v_{i},v_{j}>是E(G)中的边 \\ 0, & (v_{i},v_{j})或<v_{i},v_{j}>不是E(G)中的边 \end{cases} \]

带权图而言,若顶点 \(v_{i}\)\(v_{j}\) 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值,若顶点 \(V_{i}\)\(V_{j}\) 不相连,则通常用 \(0\)\(\infty\) 来带代表这两个顶点之间不存在的边:

\[A[i][j] = \begin{cases} w_{ij}, & (v_{i},v_{j})或<v_{i},v_{j}>是E(G)中的边 \\ 0或\infty, & (v_{i},v_{j})或<v_{i},v_{j}>不是E(G)中的边 \end{cases} \]

有向图、无向图和网对应的邻接矩阵示例如图6.5所示。

image.png

图的邻接矩阵存储结构定义如下:

#define MaxVertexNum 100                       // 顶点数目的最大值
typedef char VertexType;                       // 顶点对应的数据类型
typedef char int EdgeType;                     // 边对应的数据类型
typedef struct {
    VertexType vex[MaxVertexNum];              // 顶点表
    EdgeType edge[MaxVertexNum][MaxVertexNum]; // 邻接矩阵,边表
    int vexnum, arcnum;                        // 图当前的顶点数和边数
} MGraph;

[!warning] 注意

  1. 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。
  2. 当邻接矩阵的元素仅表示相应边是否存在时,EdgeType 可用值0和1的枚举类型。
  3. 无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。
  4. 邻接矩阵表示法的空间复杂度为 \(O(n^{2})\),其中 \(n\) 为图的顶点数 \(|V|\)

图的邻接矩阵存储表示法具有以下特点:

  1. 无向图的邻接矩阵一定是一个对称矩阵(并非唯一)。因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
  2. 对于无向图,
    • 邻接矩阵的第 \(i\) 行非零元素(或非 \(\infty\) 元素)的个数正好是顶点 \(i\) 的出度 \(OD(v_{i})\)
    • \(i\) 列非零元素(或非 \(\infty\) 元素)的个数正好是顶点 \(i\) 的入度 \(ID(v_{i})\)
  3. 对于有向图,
    • 邻接矩阵的第 \(i\) 行非零元素(或非 \(\infty\) 元素)的个数正好是顶点 \(i\) 的出度 \(OD(v_{i})\)
    • \(i\) 列非零元素(或非 \(\infty\) 元素)的个数正好是顶点 \(i\) 的入度 \(ID(v_{i})\)
  4. 用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连。
    • 但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
  5. 稠密图(边数较多的图)事后采用邻接矩阵存储表示。
  6. 设图 \(G\) 的邻接矩阵为 \(A\)\(A^{n}\) 的元素 \(A^{n}[i][j]\) 等于由顶点 \(i\) 到顶点 \(j\) 的长度为 \(n\) 的路径的数目。该结论了解即可,证明方法可参考离散数学教材。

6.2.2 邻接表法

当一个图为稀疏图时,使用邻接矩阵法显然会浪费大量的存储空间,而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。

  • 邻接表:所谓邻接表,是指对图 \(G\) 中的每个顶点 \(v_{i}\) 建立一个单链表,第 \(i\) 个单链表中的结点表示依附于顶点 \(v_{i}\) 的边(对于有向图则是以顶点 \(v_{i}\) 为尾的弧),这个单链表就称为顶点 \(v_{i}\)边表(对于有向图则称为出边表)。
  • 顶点表:边表的头指针和顶点的数据信息采用顺序存储,称为顶点表

所以在邻接表中存在两种结点:

  • 顶点表结点
  • 边表结点

如图6.6所示。

image.png

顶点表结点由两个域组成:

  • 顶点域(data):存储顶点 \(v_{i}\) 的相关信息。
  • 边表头指针域(firstarc)指向第一条边的边表结点。

边表结点至少由两个域组成:

  • 邻接点域(adjvex)存储与头结点顶点 \(v_{i}\) 邻接的顶点编号
  • 指针域(nextarc)指向下一条边的边表结点。

无向图和有向图的邻接表的实例分别如图6.7和图6.8所示。

image.png

图的邻接表存储结构定义如下:

#define MaxVertexNum 100        // 图中顶点数目的最大值
typedef struct ArcNode {        // 边表结点
    int adjvex;                 // 该弧所指向的顶点的位置
    struct ArcNode *nextarc;    // 指向下一条弧的指针
    //InfoType info;            // 网的边权值
} ArcNode;

typedef struct VNode{           // 顶点表结点
    VertexType data;            // 顶点信息
    ArcNode *firstarc;          // 指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];

typedef struct {
    AdjList vertices;           // 邻接表
    int vexnum, arcnum;         // 图的顶点数和弧数
}ALGraph;                       // AlGraph 是以邻接表存储的图类型

图的邻接表存储方法具有以下特点:

  1. \(G\)
    • 无向图,则所需的存储空间为 \(O(|V|+2|E|)\)
    • 有向图,则所需的存储空间为 \(O(|V|+|E|)\)
      • 前者的倍数 \(2\) 是因为在无向图中,每条边在邻接表中出现了两次。
  2. 对于稀疏图(边数较少的图),采用邻接表表示将极大地节省存储空间。
  3. 在邻接表中,给定一个顶点,能很容易地找出它所有邻边,花费的时间为 \(O(n)\)
    • 但是,若要确定给定的两个顶点是否存在边,
      • 则在邻接矩阵中可以立刻查到,
      • 而在邻接表中则需要再相应结点对应的边表中查找另一结点,效率较低。
  4. 在无向图的邻接表中,求某个顶点的度只需计算其邻接表中的边表结点个数。
    • 在有向图的邻接表中,求某个顶点的出度只需计算其邻接表中的边表结点个数;
    • 但求某个顶点 \(x\) 的入度则需遍历全部的邻接表,统计邻接点(adjex)域为 \(x\) 的边表结点个数。
  5. 图的邻接表表示并不唯一,因为在每个顶点对应的边表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。

6.2.3 十字链表

这部分属于是用语言描述很抽象,(第一次学)但结合视频和图反而很简单。所以,不如多看几遍图,自己主动思考、理解。

十字链表是有向图的一种链式存储结构。在十字链表中,有向图的每条弧用一个结点(弧结点)来表示,每个顶点也用一个结点(顶点结点)来表示。两种结点的结构如下所示。

image.png

结点中有5个域:

  • tailvex域和headvex域分别存放弧尾弧头这两个顶点的编号;
  • 头链域hlink指向弧头相同的下一条弧;
  • 尾链域tlink指向弧尾相同的下一条弧;
  • info域存放该弧的相关信息。

这样,弧头相同的弧在同一个链表上,弧尾相同的弧也在同一个链表上。

顶点结点中有3个域:

  • data域存放该顶点的数据信息,如顶点名称
  • firstin域指向以该顶点为弧头的第一条弧;
  • firstout域指向以该顶点为弧尾的第一条弧。

image.png

把弧看作是一个箭头 \(\to\),弧头就是箭头的头部——有小尖尖的地方,弧尾就是箭头的左边,没有尖尖的平直尾部。不过,需要注意的是,弧所存储的信息不仅包括了这个箭头,还包括了两个结点,这点可以从弧的存储结构定义中看出。

可见,十字链表表示法本质上是把邻接表根据弧的方向拆成了两条链表,这样就可以在两条道路上分别找到所有的入度和出度。

图6.9为有向图的十字链表表示法。

image.png

注意,顶点结点之间是顺序存储的,弧结点忽略了 info 域。

在十字链表中,既容易找到 \(V_{i}\) 为尾的弧,也容易找到 \(V_{i}\) 为头的弧,因而容易求得顶点的出度和入度。图的十字链表表示是不唯一的,但一个十字链表表示唯一确定一个图。

6.3.4 邻接多重表

邻接多重表是无向图的一种链式存储结构。在邻接表中,容易求得顶点和边的各种信息,但求两个顶点之间是否存在边而执行删除边等操作时,需要分别在两个顶点的边表中遍历,效率较低。与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下所示。

image.png

其中,ivex域和jvex域存放该边依附的两个顶点的编号:ilink域指向依附于顶点ivex的下一条边:jlink域指向依附于顶点jfex的下一条边,info域存放该边的相关信息。

每个顶点也用一个结点表示,它由如下所示的两个域组成。

image.png

还是视频里的图看起来最直观。

image.png

删除一条边也很方便,和链表的分析过程类似。

其中,data域存放该顶点的相关信息,firstedge域指向依附于该顶点的第一条边。

在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,因为每条边依附于两个顶点,所以每个边节点同时链接在两个链表中。对无向图而言,其邻接多重表和邻接表的差别仅在于,同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。

图6.10为无向图的邻接多重表表示法。邻接多重表的各种基本操作的实现和邻接表类似。

image.png

图的四种存储方式的总结如表6.1所示。

image.png

有颜色的版本:

image.png

6.2.5 图的基本操作

图的基本操作时独立于途的存储结构的。而对于不同的存储方式,操作算法的具体实现会有着不同的性能。在设计具体算法的实现时,应考虑采用何种存储方式的算法效率会更高。

图的基本操作主要包括(仅抽象地考虑,所以忽略各变量的类型):

  • Adjacent(G,x,y):判断图 \(G\) 是否存在边 <x,y>(x,y)
  • Neighbors(G,x):列出图 \(G\) 中与结点 x 邻接的边。
  • InsertVertex(G,x):在图 \(G\) 中插入顶点 x
  • DeleteVeretx(G,x):从图 \(G\) 中删除顶点 x
  • AddEdge(G,x,y):若无向边 (x,y) 或有向边 <x,y> 不存在,则向图 \(G\) 中添加该边。
  • RemoveEdge(G,x,y):若无向边 (x,y) 或有向边 <x,y> 存在,则从图 \(G\) 中删除该边。
  • FirstNeighbor(G,x):求图 \(G\) 中顶点 x 的第一个邻接点,若有则返回顶点号。若 x 没有邻接点或图中不存在 x,则返回 -1
  • NextNeighbor(G,x,y):假设图 \(G\) 中顶点 y 时顶点 x 的一个邻接点,返回除 y 外顶点 x 的下一个邻接点的顶点号,若 yx 的最后一个邻接点,则返回 -1
  • Get_edge_value(G,x,y):获取图 \(G\) 中边 (x,y)<x,y> 对应的权值。
  • Set_edge_value(G,x,y,v):设置图 \(G\) 中边 (x,y)<x,y> 对应的权值为 v

此外,还有图的遍历算法:按照某种方式访问图中的每个顶点且仅访问一次。图的遍历算法包括深度优先遍历和广度优先遍历,具体见下一节的内容。

6.3 图的遍历

图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次,且仅访问一次。注意到树是一种特殊的图,所以树的遍历实际上也可视为一种特殊的图的遍历。图的遍历算法时求解图的连通性问题、拓扑排序和求关键路径等算法的基础。

图的遍历比树的遍历要复杂的多,因为图的任意一个顶点都可能和其余的顶点相邻,所以在访问某个顶点后,可能沿着某条路径搜索又回到该顶点。为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点,为此可以设置一个辅助数组 visited[] 来标记顶点是否被访问过。图的遍历算法主要有两种:

  • 广度优先搜索
  • 深度优先搜索。

6.3.1 广度优先搜索

广度优先搜索(Breadth-First-Search,BFS)类似于树的层序遍历

基本思想是:

  • 首先访问起始顶点 \(v\),接着由 \(v\) 出发,依次访问 \(v\) 的各个未访问过的邻接顶点 \(w_{1},w_{2},\dots,w_{i}\),然后依次访问 \(w_{1},w_{2},\dots,w_{i}\) 的所有未访问过的邻接顶点;
  • 再从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点,直至图中所有顶点都被访问过为止。
  • 若此时图中尚有顶点未被访问,则另选图中的一个未曾被访问的顶点作为始点,重复上述过程,直至图中所有顶点都被访问到为止。

Dijkstra单源最短路径算法和Prim最小生成树算法也应用了类似的思想。

换句话说,广度优先搜索遍历图的过程是以 \(v\) 为起始点,由近至远依次访问和 \(v\) 有路径相通且路径长度为 \(1,2,\dots\) 的顶点。广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。

广度优先搜索算法的伪代码如下:

bool visited[MAX_VERTEX_NUM];    // 访问标记数组
void BFSTraverse(Graph G){       // 对图 G 进行广度优先遍历
    for (i=0;i<G.vexnum;++i)
        visited[i] = FALSE;      // 访问标记数组初始化
    InitQueue(Q);                // 初始化辅助队列 Q
    for(i=0;i<G.vexnum;++i)      // 从 0 号顶点开始遍历
        if(!visited[i])          // 对每个连通分量调用一次BFS()
            BFS(G,i);            // 若Vi未访问过,从Vi开始调用BFS()
}

用邻接表实现广度优先搜索的算法如下:

void BFS(ALGraph G, int i) {
    visit(i);                    // 访问初始顶点 i
    visited[i] = TRUE;           // 对 i 做已访问标记
    EnQueue(Q, i);               // 顶点 i 入队
    while (!IsEmpty(Q)) {
        DeQueue(Q, v);           // 队首顶点 v 出队
        for (p=G.vertices[v].firstarc; p; p=p->nextarc) { // 检测 v 的所有邻接点
            w = p->adjvex;
            if (visited[w] == FALSE) {
                visit(w);                // w 为 v 的尚未访问的邻接点,访问 w
                visited[w] = TRUE;       // 对 w 做已访问标记
                EnQueue(Q, w);           // 顶点 w 入队
            }
        }
    }
}

邻接矩阵实现广度优先搜索的算法如下:

void BFS(MGraph G, int i) {
    visit(i);                    // 访问初始顶点 i
    visited[i] = TRUE;           // 对 i 做已访问标记
    EnQueue(Q, i);               // 顶点 i 入队
    while (!IsEmpty(Q)) {
        DeQueue(Q, v);           // 队首顶点 v 出队
        for (w=0; w<G.vexnum; w++)    // 检测 v 的所有邻接点
            if (visited[w] == FALSE && G.edge[v][w] == 1) {
                visit(w);             // w 为 v 的尚未访问的邻接点,访问 w
                visited[w] = TRUE;    // 对 w 做已访问标记
                EnQueue(Q, w);        // 顶点 w 入队
            }
    }
}

辅助数组 visited[] 标志顶点是否被访问过,其初始状态为FALSE。在图的遍历过程中,一旦某个顶点 \(v_{i}\) 被访问,就立即置 visited[i]TRUE,防止它被多次访问。

下面通过实例演示广度优先搜索的过程,给定图G如图6.11所示。假设从顶点a开始访问,a先入队。此时队列非空,取出队头元素a,因为b,ca邻接且未被访问过,于是依次访问b,c,并将b,c依次入队。队列非空,取出队头元素b,依次访问与b邻接且未被访问的顶点d,e,并将d,e入队(注意:ab也邻接,但a已置访问标记,所以不再重复访问)。此时队列非空,取出队头元素c,访问与c邻接且未被访问的顶点f,g,并将f,g入队。此时,取出队头元素d,但与d邻接且未被访问的顶点为空,所以不进行任何操作。继续取出队头元素e,将h入队列......最终取出队头元素h后,队列为空,从而循环自动跳出。遍历结果为abcdefgh

image.png

从上侧不难看出,图的广度优先搜索的过程与二叉树的层序遍历是完全一致的,这也说明了图的广度优先搜索遍历算法时二叉树的层次遍历算法的扩展。

1. BFS 算法的性能分析

无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列Qn哥顶点均需入队一次,在最坏的情况下,空间复杂度\(O(|V|)\)

遍历图的过程实质上是对每个顶点查找其邻接点的过程,耗费的时间取决于所采用的存储结构。采用邻接表存储时,每个顶点均需搜索(或入队)一次,时间复杂度为 \(O(|V|)\),在搜索每个顶点的邻接点时,每条边至少访问一次,时间复杂度为 \(O(|E|)\),总的时间复杂度为 \(O(|V|+|E|)\)。采用邻接矩阵存储时,查找每个顶点的邻接点所需的时间为 \(O(|V|)\),总时间复杂度为 \(O(|V|^{2})\)

2. BFS 算法求解单源最短路径问题

若图 \(G=(V,E)\) 为非带权图,定义从顶点 \(u\) 到顶点 \(v\) 的最短路径 \(d(u,v)\) 为从 \(u\)\(v\) 的任何路径中最少的边数;若从 \(u\)\(v\) 没有通路,则 \(d(u,v)=\infty\)

使用 BFS,我们可以求解一个满足上上述定义的非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中的每个顶点的性质决定的。

BFS 算法求解单源最短路径问题的算法如下:

void BFS_MIN_DISTANCE(Graph G, int u) {
    # d[i]表示从 u 到 i 结点的最短路径
    for (i=0; i<G.vexnum; ++i)
        d[i] = ∞;                    // 初始化路径长度为infty
    visited[u] = TRUE; d[u] = 0;
    EnQueue(Q, u);
    while (!isEmpty(Q)) {            // BFS 算法主过程
        DeQueue(Q, u);               // 队头元素 u 出队
        for (w=FirstNeighbor(G, u); w>=0; w=NextNeighbor(G,u,w))
            if (!visited[w]) {       // w 为 u 的尚未访问的邻接顶点
                visited[w] = TRUE;   // 设已访问标记
                d[w] = d[u] + 1;     // 路径长度加 1
                EnQueue(Q, w);       // 顶点 w 入队
            }
    }
}

3. 广度优先生成树

在广度遍历的过程中,我们可以得到一棵遍历树,称为广度优先生成树,如图 6.12 所示。需要注意的是,同一个图的邻接矩阵存储表示是唯一的,所以其广度优先生成树也是唯一的。但因为邻接表存储表示不唯一,所以其(邻接表的)广度优先生成树也是不唯一的。

image.png

6.3.2 深度优先搜索

与广度优先搜索不同,深度优先搜索(Depth-First-Search,DFS类似于树的先序遍历。如其名称中所暗含的意思一样,这种搜索算法所遵循的策略是尽可能“深”地搜索一个图。

它的基本思想如下:首先访问图中某一起始顶点 \(v\) ,然后由 \(v\) 出发,访问与 \(v\) 邻接且未被访问的任意一个顶点 \(w_{1}\),再访问与 \(w_{1}\) 邻接且未被访问的任意一个顶点 \(w_{2}\) ......重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。

一般情况下,其递归形式的算法十分简洁,算法过程如下:

bool visited(MAX_VERTEX_NUM);    // 访问标记数组
void DFSTraverse(Graph G) {      // 对图 G 进行深度优先遍历
    for (i=0; i<G.vexnum; i++)
        visited[i] = FALSE;      // 初始化已访问标记数组
    for (i=0; i<G.vexnum; i++)   // 本代码中是从v0开始遍历
        if (!visited[i])         // 对尚未访问的顶点调用 DFS()
            DFS(G, i);
}

邻接表实现深度优先搜索的算法如下:

void DFS(ALGraph G, int i) {
    visit(i);                            // 访问初始顶点 i
    visited[i] = TRUE;                   // 对 i 做已访问标记
    for (p=G.vertices[i].firstarc; p; p=p->nextarc) {    // 检测 i 的所有邻接点
        j = p->adjvex;
        if (visited[j] == FALSE)
            DFS(G, j);                    // j 为 i 的尚未访问的邻接点,递归访问 j
    }
}

邻接矩阵实现深度优先搜索的算法如下:

void DFS(MGraph G, int i) {
    visit(i);                                // 访问初始顶点 i
    visited[i] = TRUE;                       // 对 i 做已访问标记
    for (j=0; j<G.vexnum; j++) {             // 检测 i 的所有邻接点
        if (visited[j] == FALSE && G.edge[i][j] == 1)
            DFS(G, j);                       // j 为 i 的尚未访问的邻接点,递归访问 j
    }
}

以图 6.11 的无向图为例,深度优先搜索的过程:

  • 首先访问 \(a\),并置 \(a\) 访问标记;
  • 然后访问与 \(a\) 邻接且未被访问的顶点 \(b\),置 \(b\) 访问标记;
  • 然后访问与 \(b\) 邻接且未被访问的顶点 \(d\),置 \(d\) 访问标记。
  • 此时 \(d\) 已没有未被访问过的邻接点,所以返回上一个访问的顶点 \(b\),访问与其邻接且未被访问的顶点 \(e\),置 \(e\) 访问标记,以此类推,直至图中所有顶点都被访问一次。遍历结果为 \(abdehcfg\)

[!caution] 注意
图的邻接矩阵表示是唯一的,但对邻接表来说,若边的输入次序不同,则生成的邻接表也不相同。因此,对同样一个图,基于邻接矩阵的遍历得到的 DFS 序列和 BFS 序列是唯一的,基于邻接表的遍历得到的 DFS 序列和 BFS 序列是不唯一的。

1. DFS 算法的性能分析

DFS 算法是一个递归算法,需要借助一个递归工作栈,所以其空间复杂度为 \(O(|V|)\)
遍历图的过程实质上是通过边查找邻接点的过程,因此两种遍历方式的时间复杂度都相同,不同之处仅在于对顶点访问顺序的不同。采用邻接矩阵存储时,总时间复杂度为 \(O(|v|^{2})\)。采用邻接表存储时,总的时间复杂度为 \(O(|V|+|E|)\)

2. 深度优先的生成树和生成森林

与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。当然,这是有条件的,即对连通图调用 DFS 才能产生深度优先生成树,否则产生的将是深度优先生成森林,如图 6.13 所示。与 BFS 类似,基于邻接表存储的深度优先生成树是不唯一的。

image.png

6.3.3 图的遍历与图的连通性

图的遍历算法可以用来判断图的连通性。

  • 对于无向图来说,若无向图是连通的,则从任意一个结点出发,仅需一次遍历就能够访问图中的所有顶点;
  • 无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。
  • 对于有向图来说,若从初始顶点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。

因此,在 BFSTraverse()DFSTraverse() 中添加了第二个 for 循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。

  • 对于无向图,上述两个函数调用 BFS(G, i)DFS(G, i) 的次数等于该图的连通分量数;
  • 而对于有向图则不是这样,因为一个连通的有向图分为强连通图的和非强连通的,它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用 BFS(G, i)DFS(G, i) 不一定能访问到该子图的所有顶点,如图 6.14 所示。

image.png

6.4 图的应用

本节是历年考察的重点。图的应用主要包括:

  • 最小生成(代价)树
  • 最短路径
  • 拓扑排序
  • 关键路径

一般而言,这部分内容直接以算法设计题形式考察的可能性偏小,而更多的是结合图的实例来考察算法的具体操作过程,读者必须学会手工模拟给定图的各个算法的执行过程。此外,还需掌握对给定模型建立相应的图去解决问题的方法。

6.4.1 最小生成树

一个连通图的生成树包含图的所有顶点,并且只含尽可能少的边。对于生成树来说,

  • 若砍去一条边,则会使生成树变成非连通图;
  • 若增加一条边,则会在图中形成一条回路。

定义:对于一个带权连通无向图 \(G\),生成树不同,每棵树的权(树中所有边的权值之和)也可能不同。权值之和最小的那棵生成树称为 \(G\) 的最小生成树(Minimum-Spanning-Tree, MST)。

这个定义过于官方了,其实最小生成树本质只在研究一个问题:如何用最少的成本去连通所有点?

不难看出,最小生成树具有如下性质:

  1. 若图 \(G\) 中存在权值相同的边,则 \(G\) 的最小生成树可能不唯一,即最小生成树的树形不唯一
    • 当图 \(G\) 中的各边权值互不相等时\(G\) 的最小生成树是唯一的;
    • 若无向连通图 \(G\)边数比顶点数少 1,即 \(G\) 本身是一棵树时,则 \(G\) 的最小生成树就是它本身。
  2. 虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
  3. 最小生成树的边数为顶点数减 1。

[!caution] 注意
最小生成树中所有边的权值之和最小,但不能保证任意两个顶点之间的路径是最短路径。如下图所示,最小生成树中 \(A\)\(C\) 的路径长度为 \(5\),但图中 \(A\)\(C\) 的最短路径长度为 \(4\)
image.png

构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:

  • 假设 \(G=(V,E)\) 是一个带权连通无向图,\(U\) 是顶点集 \(V\) 的一个非空子集。
  • \((u,v)\) 是一条具有最小权值的边,其中 \(u\in U,v\in V-U\),则必存在一棵包含边 \((u,v)\) 的最小生成树。

基于该性质的最小生成树算法主要有 \(Prim\) 算法和 \(Kruskal\) 算法,它们都基于贪心算法的策略。对于这两种算法应主要掌握算法的本质含义和基本思想,并能手动模拟算法的实现步骤。

下面介绍一个通用的最小生成树算法:

GENERIC_MST(G) {
    T=NULL;
    while T 未形成一棵生成树;
        do 找到一条最小代价边(u, v)并且加入T后不会产生回路;
            T=T 并(∪) (u,v);
}

通用算法每次加入一条边以逐渐形成一棵生成树,下面介绍两种实现上述通用算法的途径。

1. Prim 算法

该方法其实也可以俗称为:加点法

Prim(普里姆)算法的执行非常类似于寻找图的最短路径的 Dijkstra算法(见下一节)。

Prim算法构造最小生成树的过程如图 6.15 所示。

  1. 初始时从图中任取一顶点(如顶点 1)加入树 \(T\)
    • 此时树中只含有一个顶点;
  2. 之后选择一个与当前 \(T\) 中顶点集合距离最近的顶点,并将该顶点和相应的边加入 \(T\)
    • 每次操作后 \(T\) 中的顶点树和边数都增 1。
  3. 以此类推,直至图中所有的顶点都并入 \(T\),得到的 \(T\) 就是最小生成树。
    • 此时 \(T\) 中必然有 \(n-1\) 条边。

image.png

\(Prim\) 算法的步骤如下:

  1. 假设 \(G=\{ V,E \}\) 是连通图,其最小生成树 \(T=(U,E_{T})\)\(E_{T}\) 是最小生成树中边的集合。
  2. 初始化:向空树 \(T=(U,E_{T})\) 中添加图 \(G=(V,E)\) 的任意一个顶点 \(u_{0}\),使 \(U=\{ u_{0} \}\)\(E_{T}=\emptyset\)
  3. 循环(重复下列操作直至 \(U=V\)):从图 \(G\) 中选择满足 \(\{ (u,v)|u\in U,v\in V-U \}\) 且具有最小权值的边 \((u,v)\),加入树 \(T\),置 \(U=U\cup \{ v \}\)\(E_T=E_{T}\cup \{ (u,v) \}\)

\(Prim\) 算法的简单实现如下:

void Prim(G,T) {
    T=∅;                // 初始化空树
    U={w};              // 添加任意一个顶点 w
    while ((V-U) != ∅) {
        设(u,v)是使u∈U与v∈(V-U),且权值最小的边;
        T=T ∪ { (u,v) };        // 边归入树
        U=U ∪ { v };            // 顶点归入树
    }
}

Prim算法的时间复杂度为 \(O(|V|^{2})\),不依赖于 \(|E|\),因此它适用于求解边稠密的图的最小生成树。虽然采用其他方法能改进Prim算法的时间复杂度,但增加了实现的复杂性。

2. Kruskal 算法

该方法也可以俗称为:加边法

\(Prim\) 算法从顶点开始扩展最小生成树不同,\(Kruskal\) 算法是一种按权值的递增次序选择合适的边来构造的最小生成树的方法。

\(Kruskal\) 算法构造最小生成树的过程如图 6.16 所示。

  • 初始时为只有 \(n\) 个顶点而无边的非连通图 \(T=\{ V,\{ \} \}\),每个顶点自成一个连通分量。
  • 然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在 \(T\) 中不同的连通分量上(使用并查集判断这两个顶点是否属于同一颗集合树),则将此边加入 \(T\),否则舍弃此边而选择下一条权值最小的边。
  • 以此类推,直至 \(T\) 中所有顶点都在一个连通分量上。

\(Kruskal\) 算法的步骤如下:

  • 假设 \(G=(V,E)\) 是连通图,其最小生成树 \(T=(U,E_{T})\)
  • 初始化:\(U=V,E_{T}=\emptyset\)。即每个顶点构成一棵独立的树,\(T\) 此时是一个仅含 \(|V|\) 个顶点的森林。
  • 循环(重复直至 \(T\) 是一棵树):按 \(G\) 的边的权值递增顺序依次从 \(E-E_{T}\) 中选择一条边,若这条边加入 \(T\) 后不构成回路,则将其加入 \(E_{T}\),否则舍弃,直到 \(E_{T}\) 中含有 \(n-1\) 条边。

image.png

Kruskal 算法的简单实现如下:

void Kruskal(V, T) {
    T=V;                                    // 初始化树T,仅含顶点
    numS=n;                                 // 连通分量数
    while (numS>1) {                        // 若连通分量数大于 1
        从E中取出权值最小的边(v,u)
        if(v和u属于T中不同的连通分量) {
            T=T∪{ (v,u) };                  // 将此边加入生成树中
            numS--;                         // 连通分量数减 1
        }
    }
}

根据图的相关性质,若一条边连接了两条不同树中的顶点,则对这两棵树来说,它必定是连通的,将这条边加入森林中,完成两棵树的合并,直到整个森林合并成一棵树。

Kruskal 算法中,最坏情况需要对 \(|E|\) 条边各扫描一次。

  • 通常采用堆(见第 7 章)来存放边的集合,每次选择最小权值的边需要 \(O(\log_{2}|E|)\) 的时间;
  • 每次使用并查集来快速判断两个顶点是否属于一个集合所需的时间为 \(O(\alpha(|V|))\)\(\alpha(|V|)\) 的增长极其缓慢,可视为常数。算法的总时间复杂度为 \(O(|E|\log_{2}|E|)\),不依赖于 \(|V|\),因此 Kruskal 算法适合于边稀疏而顶点较多的图。

6.4.2 最短路径

6.3 节所述的广度优先搜索查找最短路径只是对无权图而言的。当图是带权图时,把从一个顶点 \(v_{0}\) 到图中其余任意一个顶点 \(v_{i}\) 的一条路径所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径(可能不止一条)成为最短路径

求解最短路径的算法通常都依赖于一种性质,即两点之间的最短路径也包含了路径上其他顶点间的最短路径。带权有向图 \(G\) 的最短路径问题一般可分为两类

  • 一是单源最短路径,即求图中某一顶点到其他各顶点的最短路径,可通过经典的 \(Dijkstra\)(迪杰斯特拉)算法求解;
  • 二是求每对顶点间的最短路径,可通过 \(Floyd\) (弗洛伊德)算法来求解。

1. \(Dijkstra\) 算法求单源最短路径问题

[!abstract]- 快速回忆

  • 一句话每次选一个最近的点,然后更新
  • 所以是一个贪心算法。
  • 一般使用邻接表来存储。

image.png

\(Dijkstra\) 算法设置一个集合 \(S\) 记录已求得的最短路径的顶点,初始时把源点 \(v_{0}\) 放入 \(S\),集合 \(S\) 每并入一个新顶点 \(v_{i}\),都要修改源点 \(v_{0}\) 到集合 \(V-S\) 中顶点当前的最短路径长度值(这里可能不太好理解?没关系,继续往下看,相信会逐步理解)。

在构造的过程中还设置了三个辅助数组:

  • final[] :标记各顶点是否已找到最短路径,即是否归入集合 \(S\)
  • dist[]:记录从源点 \(v_{0}\) 到其他各顶点当前的最短路径长度,它的初始值为:
    • 若从 \(v_{0}\)\(v_{i}\) 有弧,则 dist[i] 为弧上的权值;
    • 否则置 dist[i]\(\infty\)
  • path[]path[i] 表示从源点到顶点 \(i\) 之间的最短路径的前驱结点。在算法结束时,可根据其值追溯得到源点 \(v_{0}\) 到顶点 \(v_{i}\) 的最短路径。

假设从顶点 0 出发,即 \(v_{0}=0\),集合 \(S\) 最初只包含顶点 0,邻接矩阵 arcs 表示带权有向图,arcs[i][j] 表示有向边 \(<i,j>\) 的权值,若不存在有向边 \(<i,j>\),则 arcs[i][j]\(\infty\)

Dijkstra 算法的步骤如下(不考虑对 path[] 的操作):

  1. 初始化:集合 \(S\) 初始为 \(\{ 0 \}\)dist[] 的初始值 dist[i]=arcs[0][i],i=1,2,...,m=1
  2. 从顶点集合 \(V-S\) 中选出 \(v_{j}\),满足 \(dist[j]=Min\{ dist[i]|v_{i}\in V-S \}\)\(v_{j}\) 就是当前求得的一条从 \(v_{0}\) 出发的最短路径的终点,令 \(S=S\cup \{ j \}\)
  3. 修改从 \(v_{0}\) 出发到集合 \(V-S\) 上任意一个顶点 \(v_{k}\) 可达的最短路径长度:若 dist[j]+arcs[j][k]<dist[k] ,则更新 dist[k]=dist[j]+arcs[j][k]
  4. 重复 2、3 操作共 \(n-1\) 次,直到所有的顶点都包含在集合 \(S\) 中。

步骤 3 也就是开头留下的疑问,每当一个顶点加入集合 \(S\) 后,可能需要修改源点 \(v_{0}\) 到集合 \(V-S\) 中可达顶点当前的最短路径长度,下面举一简单例子证明。如下图所示,源点为 \(v_{0}\),初始时 \(S=\{ v_{0} \}\)dist[1]=3dist[2]=7,当将 \(v_{1}\) 并入集合 \(S\) 后,dist[2] 需要更新为 \(4\)

image.png

思考:\(Dijkstra\) 算法与 \(Prim\) 算法有何异同之处?

例如,对图 6.17 中的图应用 Dijkstra 算法求从顶点 1 出发至其余顶点的最短路径长度的过程,如表 6.2 所示。算法执行过程的说明如下。

image.png

下表 6.2 为从 \(v_{1}\) 到各终点的 dist 值和最短路径的求解过程

顶点 第 1 轮 第 2 轮 第 3 轮 第 4 轮
2 \(10\)
\(v_{1}\to v_{2}\)
\(8\)
\(v_{1}\to v_{5}\to v_{2}\)
\(8\)
\(v_{1}\to v_{5}\to v_{2}\)
3 \(\infty\) \(14\)
\(v_{1}\to v_{5}\to v_{3}\)
\(13\)
\(v_{1}\to v_{5}\to v_{4}\to v_{3}\)
\(9\)
\(v_{1}\to v_{5}\to v_{2}\to v_{3}\)
4 \(\infty\) \(7\)
\(v_{1}\to v_{5}\to v_{4}\)
5 \(5\)
\(v_{1}\to v_{5}\)
\(\text{集合 S}\) \(\{ 1,5 \}\) \(\{ 1,5,4 \}\) \(\{ 1,5,4,2 \}\) \(\{ 1,5,4,2,3 \}\)
  • 初始化:集合 \(S\) 初始化为 \(\{ v_{1} \}\)\(v_{1}\) 可达 \(v_{2}\)\(v_{5}\)\(v_{1}\) 不可达 \(v_{3}\)\(v_{4}\),因此 dist[] 数组各元素的初始值依次设置为 \(dist[2]=10,dist[3]=\infty,dist[4]=\infty,dist[5]=5\)
  • 第 1 轮:
    • 选出最小值 \(dist[5]\),将顶点 \(v_{5}\) 并入集合 \(S\),即此时已找到 \(v_{1}\)\(v_{5}\) 的最短路径。
    • \(v_{5}\) 加入 \(S\) 后,从 \(v_{1}\) 到集合 \(V-S\) 中可达顶点的最短路径长度可能会产生新变化。
    • 因此需要更新 \(dist[]\) 数组。
    • \(v_{5}\) 可达 \(v_{2}\),因 \(v_{1}\to v_{5}\to v_{2}\) 的距离 8 比 \(dist[2]=10\) 小,更新 \(dist[2]=8\)
    • \(v_{5}\) 可达 \(v_{3}\)\(v_{1}\to v_{5}\to v_{3}\) 的距离 14,更新 \(dist[3]=14\)
    • \(v_{5}\) 可达 \(v_{4}\)\(v_{1}\to v_{5}\to v_{4}\) 的距离 7,更新 \(dist[4]=7\)
  • 第 2 轮:
    • 选出最小值 \(dist[4]\),将顶点 \(v_{4}\) 并入集合 \(S\)
    • 继续更新 \(dist[]\) 数组。
    • \(v_{4}\) 不可达 \(v_{2}\)\(dist[2]\) 不变;
    • \(v_{4}\) 可达 \(v_{3}\)\(v_{1}\to v_{5}\to v_{4}\to v_{3}\) 的距离 13 比 \(dist[3]\) 小,故更新 \(dist[3]=13\)
  • 第 3 轮:
    • 选出最小值 \(dist[2]\),将顶点 \(v_{2}\) 并入集合 \(S\)
    • 继续更新 \(dist[]\) 数组。
    • \(v_{2}\) 可达 \(v_{3}\)\(v_{1}\to v_{5}\to v_{2}\to v_{3}\) 的距离 9 比 \(dist[3]\) 小,更新 \(dist[3]=9\)
  • 第 4 轮:
    • 选出唯一最小值 \(dist[3]\),将顶点 \(v_{3}\) 并入集合 \(S\),此时全部顶点都已包含在 \(S\) 中。

显然,Dijkstra 算法也是基于贪心策略的。

使用邻接矩阵表示时,时间复杂度为 \(O(|V|^{2})\)。使用带权的邻接表表示时,虽然修改 \(dist[]\) 的时间可以减少,但因为在 \(dist[]\) 中选择最小分量的时间不变,所以时间复杂度仍为 \(O(|V|^{2})\)

人们可能只希望找到从源点到某个特定顶点的最短路径,但这个问题和求解源点到其他所有顶点的最短路径一样复杂,时间复杂度也为 \(O(|V|^{2})\)

注意,边上带有负权值时,Dijkstra 算法并不实用。若允许边上带有负权值,则在与集合 \(S\)(已求得最短路径的顶点集,并入 \(S\) 内的顶点的最短路径不再变更)内某顶点(记为 \(a\))以负边相连的顶点(记为 \(b\))确定其最短路径时,其最短路径长度加上这条负边的权值结果可能小于 \(a\) 原先确定的最短路径长度,而此时 \(a\) 在 Dijkstra 算法下是无法更新的。例如,对于图 6.18 所示的带权有向图,利用 Dijkstra 算法不一定能得到正确的结果。

image.png

2. Floyd 算法求各顶点之间最短路径问题

[!abstract]- 快速回忆

  • 一句话依次将每个点作为 中间点 去更新
  • 求所有顶点之间的最短路径。
  • 一般使用邻接矩阵存储。
  • 动态规划思想。

求所有顶点之间的最短路径问题描述如下:已知一个各边权值均大于 0 的带权有向图,对任意两个顶点 \(v_{i}\neq v_{j}\),要求求出 \(v_{i}\)\(v_{j}\) 之间的最短路径和最短路径长度。

Floyd 算法的基本思想是:递推产生一个 \(n\) 阶方阵序列 \(A^{(-1)},A^{(0)},\dots,A^{(k)},\dots,A^{(n-1)}\),其中 \(A^{(k)}[i][j]\) 表示从顶点 \(v_{i}\) 到顶点 \(v_{j}\) 的路径长度,\(k\) 表示绕行第 \(k\) 个顶点的运算步骤。

  • 初始时,对于任意两个顶点 \(v_{i}\)\(v_{j}\)
    • 若它们之间存在边,则以此边上的权值作为它们之间的最短路径长度;
    • 若它们之间不存在有向边,则以 \(\infty\) 作为它们之间的最短路径长度
  • 以后逐步尝试在原路径中加入顶点 \(k(k=0,1,\dots,n-1)\) 作为中间顶点。
  • 若增加中间顶点后,得到的路径比原来的路径长度减少了,则以此新路径代替原路径。

算法描述如下:

定义一个 \(n\) 阶方阵序列 \(A^{(-1)},A^{(0)},\dots,A^{(k)},\dots,A^{(n-1)}\),其中,

\[\begin{array} \\ A^{(-1)}[i] [j]=arcs[i][j] \\ A^{(k)}[i][j]=Min\{ A^{(k-1)}[i][j], A^{(k-1)}[i][k]+A^{(k-1)}[k][j] \},k=0,1,\dots,n-1 \\ \end{array} \]

  • 式中,\(A^{(0)}[i][j]\) 是从顶点 \(v_{i}\)\(v_{j}\) 、中间顶点是 \(v_{0}\) 的最短路径长度,\(A^{(k)}[i][j]\) 是从顶点 \(v_{i}\)\(v_{j}\)、中间顶点的序号不大于 \(k\) 的最短路径长度。
  • Floyd 算法是一个迭代的过程,每迭代一次,在从 \(v_{i}\)\(v_{j}\) 的最短路径上就多考虑了一个顶点;
  • 经过 \(n\) 次迭代后,所得到的 \(A^{(n-1)}[i][j]\) 就是 \(v_{i}\)\(v_{j}\) 的最短路径长度,即方阵 \(A^{(n-1)}\) 中就保存了任意一对顶点之间的最短路径长度。

图 6.19 所示为带权有向图 G 及其邻接矩阵。应用 Floyd 算法求所有顶点之间的最短路径长度的过程如表 6.3 所示。算法执行过程的说明如下。

image.png

  • 初始化:方阵 \(A^{(-1)}[i][j]=arcs[i][j]\)
  • 第 1 轮:将 \(v_{0}\) 作为中间顶点,对于所有顶点对 \(\{ i,j \}\),若有 \(A^{-1}[i][j]>A^{-1}[i][0]\),则将 \(A^{-1}[i][j]\) 更新为 \(A^{-1}[i][0]+A^{-1}[0][j]\)。有 \(A^{-1}[2][1]>A^{-1}[2][0]+A^{-1}[0][1]=11\) ,更新 \(A^{-1}[2][1]=11\),更新后的方阵标记为 \(A^{0}\)
  • 第 2 轮:将 \(v_{1}\) 作为中间顶点,继续检测全部顶点对 \(\{ i,j \}\)。有 \(A^{0}[0][2]>A^{0}[0][1]+A^{0}[1][2]=10\),更新 \(A^{0}[0][2]=10\),更新后的方阵标记为 \(A^{1}\)
  • 第 3 轮:将 \(v_{2}\) 作为中间顶点,继续检测全部顶点对 \(\{ i,j \}\)。有 \(A^{1}[1][0]>A^{1}[1][2]+A^{1}[2][0]=9\),更新 \(A^{1}[1][0]=9\),更新后的方阵标记为 \(A^{2}\)。此时 \(A^{2}\) 中保存的就是任意顶点对的最短路径长度。

image.png

Floyd 算法的时间复杂度为 \(O(|V|^{3})\)。不过其代码很紧凑,且并不包含其他复杂的数据结构,因此隐含的常数系数是很小的,即使对于中等规模的输入来说,他仍然是相当有效的。

Floyd 算法允许图中有带负权值的边,但不允许包含总权值为负的回路。Floyd 算法同样适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。

也可以用单源最短路径算法来解决每对顶点之间的最短路径问题。轮流将每个顶点作为源点,并且在所有边权值均非负时,运行一次 Dijkstra 算法,其时间复杂度为 \(O(|V|^{2})\cdot|V|=O(|V|^{3})\)

BFS 算法、Dijkstra 算法和 Floyd 算法求最短路径的总结如表 6.4 所示。

image.png

6.4.3 有向无环图描述表达式

[!info] 定义
若一个有向图中不存在环,则称为有向无环图,简称 DAG 图。

有向无环图是描述含有公共子式的表达式的有效工具。例如表达式

\[((a+b)*(b*(c+d))+(c+d)*e)*((c+d)*e) \]

可以用上一章描述的二叉树来表示,如图 6.20 所示。仔细观察该表达式,可发现有一些相同的子表达式 \((c+d)\)\((c+d)*e\),而在二叉树中,它们也重复出现。若利用有向无环图,则可实现对相同子式的共享,从而节省存储空间,图 6.21 所示为该表达式的有向无环图表示。

image.png

6.4.4 拓扑排序

[!info] AOV 网

  • 若用有向无环图表示一个工程,其顶点表示活动,用有向边 \(<V_{i},V_{j}>\) 表示活动 \(V_{i}\) 必须先于活动 \(V_{j}\) 进行的这样一种关系,则将这种有向图称为顶点表示活动的网络,简称AOV 网
  • 在 AOV 网中,活动 \(V_{i}\) 是活动 \(V_{j}\) 的直接前驱,\(V_{j}\)\(V_{i}\) 的直接后继,这种前驱和后继关系具有传递性,且任何活动 \(V_{i}\) 不能以它自己作为自己的前驱或后继。

[!important] 拓扑排序

  • 在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序
    1. 每个顶点出现且仅出现一次
    2. 若顶点 A 在序列中排在顶点 B 的前面,则在图中不存在从 B 到 A 的路径。
  • 或定义为:
    • 拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点 A 到顶点 B 的路径,则在排序中 B 出现在 A 的后面。
    • 每个 AOV 网都有一个或多个拓扑排序序列。

对一个 AOV 网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:

  1. 从 AOV 网中选择一个没有前驱(入度为 0)的顶点并输出。
  2. 从网中删除该顶点和所有以它为起点的有向边。
  3. 重复前两步直到当前的 AOV 网为空或当前网中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环

图 6.22 所示为拓扑排序过程的示例。每轮选择一个入度为 0 的顶点并输出,然后删除该顶点和所有以它为起点的有向边,最后得到拓扑排序的结果为 \(\{ 1,2,4,3,5 \}\)

image.png
image.png

拓扑排序算法(基于邻接表存储)的实现如下:

bool TopologicalSort(Graph G) {
    InitStack(S);                             // 初始化栈,存储入度为0的顶点
    int i;
    for (int i = 0; i < G.vexnum; i ++ )
        if (indegree[i] == 0)
            Push(S, i);                       // 将所有入度为0的顶点入栈
    int count = 0;                            // 计数,记录当前已经输出的顶点数
    while (!IsEmpty(S)) {                     // 栈不为空,则存在入度为0的顶点
    // 1. 出栈
        Pop(S, i);                            // 栈顶元素出栈
    // 2. 打印
        print[count++] = i;                   // 输出顶点
    // 3. 更新栈(新一批入度为 0 的结点再次入栈)
        for (p=G.vertices[i].firstarc; p; p=p->nextarc) {
        // ↑ 遍历邻接表的所有弧,p是指向弧的指针
        // 将所有 i 指向的顶点入度减1,并且将入度减为 0 的顶点压入栈 S
            v = p->adjvex;    // 弧所指向的顶点
            // 遍历所有边
            if (!(--indegree[v]))    // 如果顶点(v)的入度-1后变成了 0
                Push(S, v);                    // 入度为 0,则入栈
        }
    }
    if (count < G.vexnum) // 图中有回路,不能回收所有的顶点,排序失败!
        return false;                          // 排序失败,有向图中有回路
    else                  // 排序成功了!
        return true;                           // 拓扑排序成功
}

// 邻接表结构体定义,可能你已经忘了adjvex等变量的含义,没关系这里贴出来了。
#define MaxVertexNum 100        // 图中顶点数目的最大值
typedef struct ArcNode {        // 边表结点
    int adjvex;                 // 该弧所指向的顶点的位置
    struct ArcNode *nextarc;    // 指向下一条弧的指针
    //InfoType info;            // 网的边权值
} ArcNode;

typedef struct VNode{           // 顶点表结点
    VertexType data;            // 顶点信息
    ArcNode *firstarc;          // 指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];

typedef struct {
    AdjList vertices;           // 邻接表
    int vexnum, arcnum;         // 图的顶点数和弧数
} ALGraph;                       // AlGraph 是以邻接表存储的图类型

这里的变量中, vex 表示顶点,arc 表示弧。

因为输出每个顶点的同时还要删除以它为起点的边,所以

  • 采用邻接表存储时拓扑排序的时间复杂度为 \(O(|V|+|E|)\)
  • 采用邻接矩阵存储时拓扑排序的时间复杂度为 \(O(|V|^{2})\)

此外,利用上一节的深度优先遍历也可以实现拓扑排序,下面简单描述其思路,具体代码见本节后的习题(王道书)。

对于有向无环图 G 中的任意结点 \(u\)\(v\),它们之间的关系必然是下列三种之一:

  1. \(u\)\(v\) 的祖先,则在 DFS 访问 \(u\) 之前,必然已对 \(v\) 进行了 DFS 访问,即 \(v\) 的 DFS 结束时间先于 \(u\) 的 DFS 结束时间。从而可考虑在 DFS 函数中设置一个时间标记,在 DFS 调用结束时,对各顶点计时。因此,祖先的结束时间必然大于子孙的结束时间。
  2. \(u\)\(v\) 的子孙,则 \(v\)\(u\) 的祖先,按上述思路,\(v\) 的结束时间大于 \(u\) 的结束时间。
  3. \(u\)\(v\) 没有路径关系,则 \(u\)\(v\) 在拓扑序列的关系任意。

于是,按结束时间从大到小排列,就可以得到一个拓扑排序序列。

对一个 AOV 网,若采用下列步骤进行排序,则称之为逆拓扑排序

  1. 从 AOV 网中选择一个没有后继(出度为 0)的顶点并输出。
  2. 从网中删除该顶点和所有以它为终点的有向边。
  3. 重复 1、2 直到当前的 AOV 网为空。

用拓扑排序算法处理 AOV 网时,应注意以下问题:

  1. 入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续
  2. 拓扑排序的结果可能不唯一。不少人误认为 AOV 网的各顶点为线性序列是拓扑序列唯一的充要条件,而它其实只是充分非必要条件。拓扑序列是否唯一的判断条件是在每次输出顶点时,检测入度为 0 的顶点是否唯一,若每次都唯一,则说明拓扑序列唯一。
  3. AOV 网中各顶点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编号,生成 AOV 网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列;反之则不一定成立。

6.4.5 关键路径

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络Activity On Edge,简称 AOE 网

AOE 网和 AOV 网都是有向无环图,不同之处在于它们的边和顶点所代表的含义是不同的:

  • AOE 网中的边有权值
  • AOV 网中的边无权值,仅表示顶点之间的前后关系。

AOEAOV 两者对比如下:

AOE 网 AOV 网
有无权值 边有权值 边无权值
边的含义 表示活动持续时间(权值) 表示顶点之间前后关系
名词解释 Activity On Edge Activity On Vertex
名词翻译 边上的活动 顶点上的活动

可以记住英文名,这样就不会忘记了。

[!info] AOE 网的性质

  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
  2. 只有在进入某顶点的个有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。

我的事情干完了,你才能开始,你得等我做完。
前面的事情都结束了,这件事才能发生,我得等前面的活动。
也就是结点之间相互依赖,如同多米诺骨牌一般。

  • 在 AOE 网中仅有一个入度为 0 的顶点,称为开始顶点(源点),它表示整个工程的开始;
  • 也仅有一个出度为 0 的顶点,称为结束顶点(汇点),它表示整个工程的结束。

开始和结束顶点各自只能有一个。

在 AOE 网中,有些活动是可以并行进行的。从源点到汇点的有向路径可能有多条,并且这些路径长度可能不同。完成不同路径上的活动所需的时间虽然不同,但是只有所有路径上的活动都已完成,整个工程才能算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动

一个例子不会忘:关键活动是最耗费时间的活动,类似于木桶效应。你们一家人出去玩,如果妈妈化妆需要花费很久很久,纵使其他人再快准备好,也得等你妈妈才能出发,妈妈最慢,她就是“关键路径”!

完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关键活动影响了整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因此,只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。

下面给出在寻找关键活动时所用到的几个参量的定义。

1. 事件 \(v_{k}\) 的最早发生事件 \(v_{e}(k)\)

指从源点 \(v_{1}\) 到顶点 \(v_{k}\) 的最长路径长度。事件 \(v_{k}\) 的最早发生时间决定了所有从 \(v_{k}\) 开始的活动能够开工的最早时间。可用下面的递推公式来计算:

  • \(v_{e}(源点)=0\)
  • \(v_{e}(k)=Max\{ v_{e}(j)+Weight(v_{j},v_{k}) \}\)\(v_{k}\)\(v_{j}\) 的任意后继,\(Weight(v_{j},v_{k})\) 表示 \(<v_{j},v_{k}>\) 上的权值。

这里的 \(v_{e}\) 中的 \(e\) 表示的是 early (最早)的意思,下标并没有特定的作用,包括下面的 \(v_{l}\) 的下标 \(l\) 表示的是 last (最晚)的意思。\(v_{l/e}(k)\) 表示的是结点(活动)\(k\) 的时刻。
这里的最早发生时间公式可以理解为:结点的最早时间 = 所有 前驱结点的时刻+发生的时间(路径长度) 的最大值。

[!caution] 注意
计算 \(v_{e}()\) 值时,按从前往后的顺序进行,可以在拓扑排序的基础上计算:

  1. 初始时,令 \(v_{e}[1 \dots n]=0\)
  2. 输出一个入度为 0 的顶点 \(v_{j}\) 时,计算它所有直接后继顶点 \(v_{k}\) 的最早发生时间,若 \(v_{e}[j]+Weight(v_{j},v_{k})>v_{e}[k]\),则 \(v_{e}[k]=v_{e}[j]+Weight(v_{j},v_{k})\)。以此类推,直至输出全部顶点。

2. 事件 \(v_{k}\) 的最迟发生事件 \(v_{l}(k)\)

指在不推迟整个工程完成的前提下,即保证它的后继事件 \(v_{j}\) 在其最迟发生时间 \(v_{l}(j)\) 能够发生时,该事件最迟必须发生的时间。可用下面的递推公式来计算:

  • \(v_{l}(汇点)=v_{e}(汇点)\)
  • \(v_{l}(k)=Min\{ v_{l}(j)-Weight(v_{k},v_{j}) \}\)\(v_{k}\)\(v_{j}\) 的任意前驱

image.png

最迟发生时间是从后往前推导的,比如这里的 \(v_{3}\),为什么公式中是 \(Min\{ \}\)?如果这里不是 \(Min\),走了另一条边,比如让 \(v_{3}=v_{6}-a_{6}=8-3=5\),就会矛盾,后面的 \(v_{4}\) 就会被拖延,所以为了保证所有项目都不被拖延,就应该取 \(Min\{ \}\),当想不通的时候就任取一条边去检验,并看看是否与后面所有的项目矛盾,这样就能自行推导规律从而回想起来。

[!caution] 注意
计算 \(v_{l}(k)\) 时,按从后往前的顺序进行,可以在逆拓扑排序的基础上计算。增设一个栈以记录拓扑序列,拓扑排序结束后从栈顶至栈底便为逆拓扑排序有序序列。过程如下:

  1. 初始时,令 \(v_{l}[1\dots n]=v_{e}[n]\)
  2. 栈顶顶点 \(v_{j}\) 出栈,计算其所有直接前驱顶点 \(v_{k}\) 的最迟发生事件,若 \(v_{l}[j]-Weight(v_{k},v_{j})<v_{k}[k]\),则 \(v_{l}[k]=v_{l}[j]-Weight(v_{k},v_{j})\)。以此类推,直至输出全部栈中顶点。

3. 活动 \(a_{i}\) 的最早开始时间 \(e(i)\)

指该活动弧的起点所表示的事件的最早发生时间。若边 \(<v_{k},v_{j}>\) 表示活动 \(a_{i}\),则有 \(e(i)=v_{e}(k)\)

image.png

4. 活动 \(a_{i}\) 的最迟开始时间 \(l(i)\)

指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。若边 \(<v_{k},v_{j}>\) 表示活动 \(a_{i}\),则有 \(l(i)=v_{l}(j)-Weight(v_{k},v_{j})\)

image.png

活动弧最迟的开始时间=后继结点(终点)的最迟发生时间-活动弧的所需时间(权值)。比方说这里的 \(a_{4}\) 的最迟发生时间= \(终点V_{5}-弧a_{4}=4\)。又比如这里的 \(a_{6}\) 的最迟发生时间是 \(V_{6}-a_{6}=8-3=5\)

5. 一个活动 \(a_{i}\) 的最迟开始时间 \(l(i)\) 和其最早开始时间 \(e(i)\) 的差额 \(d(i)=l(i)-e(i)\)

指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动 \(a_{i}\) 可以拖延的时间。若一个活动的时间余量为零,则说明该活动必须要如期完成,否则就会拖延整个工程的进度,所以称 \(l(i)-e(i)=0\)\(l(i)=e(i)\) 的活动是 \(a_{i}\) 的关键活动。

求关键路径的算法步骤如下:

  1. 从源点出发,令 \(v_{e}(源点)=0\),按拓扑有序求其余顶点的最早发生时间 \(v_{e}()\)
  2. 从汇点出发,令 \(v_{l}(汇点)=v_{e}(汇点)\),按逆拓扑有序求其余顶点的最迟发生时间 \(v_{l}()\)
  3. 根据各顶点的 \(v_{e}()\) 值求所有弧的最早开始时间 \(e()\)
  4. 根据各顶点的 \(v_{l}()\) 值求所有弧的最迟开始时间 \(l()\)
  5. 求 AOE 网中所有活动的差额 \(d()\),找出所有 \(d()=0\) 的活动构成关键路径。

图 6.23 所示为求解关键路径的过程,简单说明如下:

  1. \(v_{e}()\):初始 \(v_{e}(1)=0\) ,在拓扑排序输出顶点的过程中,求得 \(v_{e(2)}=3,v_{e}(3)=2,v_{e}(4)=max\{ v_{e}(2)+2,v_{e}(3)+4 \}=max\{ 5,6 \}=6\)\(v_{e}(5)=6,v_{e}(6)=max\{ v_{e}(5)+1,v_{e}(4)+2,v_{e}(3)+3 \}=max\{ 7,8,5 \}=8\)
    若这是一道选择题,根据上述求 \(v_{e}()\) 的过程就已经能知道关键路径。
  2. \(v_{l}()\):初始 \(v_{l}(6)=8\),在逆拓扑排序出栈过程中,求得 \(v_{l}(5)=7,v_{l}(4)=6,v_{l}(3)=min\{ v_{l}(4)-4,v_{l}(6)-3 \}=min\{ 2,5 \}=2\), \(v_{l(2)}=min\{ v_{l}(5)-3,v_{l}(4)-2 \}=min\{ 4,4 \}=4\)\(v_{l}(1)\) 必然为 0 而无须再求。
  3. 弧的最早开始时间 \(e()\) 等于该弧的起点的顶点的 \(v_{e}()\),结果如下表。
  4. 弧的最迟开始时间 \(l(i)\) 等于该弧的终点的顶点 \(v_{l}()\) 减去该弧的持续的时间,结果如下表。
  5. 根据 \(l(i)-e(i)=0\) 的关键活动,得到的关键路径为 \((v_{1},v_{3},v_{4},v_{6})\)

image.png

对于关键路径,需要注意以下几点:

  1. 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能变成非关键活动
  2. 网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

各种图算法在采用邻接矩阵或邻接表存储的时间复杂度如表 6.5 所示。

image.png

posted @ 2026-05-16 00:52  syn_tax  阅读(14)  评论(0)    收藏  举报