目录
[1. 有向图](#1. 有向图)
[2. 无向图](#2. 无向图)
[3. 简单图](#3. 简单图)
[4. 多重图](#4. 多重图)
[5. 完全图(也称简单完全图)](#5. 完全图(也称简单完全图))
[6. 子图](#6. 子图)
[7. 连通、连通图和连通分量](#7. 连通、连通图和连通分量)
[8. 强连通图、强连通分量](#8. 强连通图、强连通分量)
[9. 生成树、生成森林](#9. 生成树、生成森林)
[10. 顶点的度、入度和出度](#10. 顶点的度、入度和出度)
[11. 边的权和网](#11. 边的权和网)
[12. 稠密图、稀疏图](#12. 稠密图、稀疏图)
[13. 路径、路径长度和回路](#13. 路径、路径长度和回路)
[14. 简单路径、简单回路](#14. 简单路径、简单回路)
[15. 距离](#15. 距离)
[16. 有向树](#16. 有向树)
[1. 邻接矩阵](#1. 邻接矩阵)
[2. 邻接表](#2. 邻接表)
[3. 十字链表](#3. 十字链表)
[4. 邻接多重表](#4. 邻接多重表)
[5. 边集数组](#5. 边集数组)
[1. 深度优先遍历](#1. 深度优先遍历)
[1.1. DFS算法](#1.1. DFS算法)
[1.2. DFS算法的性能分析](#1.2. DFS算法的性能分析)
[1.3. 深度优先的生成树和生成森林](#1.3. 深度优先的生成树和生成森林)
[2. 广度优先遍历](#2. 广度优先遍历)
[2.1. BFS算法](#2.1. BFS算法)
[2.2. BFS算法性能分析](#2.2. BFS算法性能分析)
[3. 图的遍历与图的连通性](#3. 图的遍历与图的连通性)
[3.1. 普里姆(Prim)算法](#3.1. 普里姆(Prim)算法)
[3.2. 克鲁斯卡尔(Kruskal)算法](#3.2. 克鲁斯卡尔(Kruskal)算法)
[4. 总结](#4. 总结)
[1. 迪杰斯特拉( Dijkstra )算法](#1. 迪杰斯特拉( Dijkstra )算法)
[2. 弗洛伊德( Floyd )算法](#2. 弗洛伊德( Floyd )算法)
[1. 定义](#1. 定义)
[2. 算法](#2. 算法)
[1. 定义](#1. 定义)
[2. 算法](#2. 算法)
一、引言
图(Graph)描述的是一些个体之间的关系。
与线性表和二叉树不同的是:这些个体之间既不是前驱后继的顺序关系,也不是祖先后代的层次关
系,而是错综复杂的网状关系。图也是数据结构中经常使用的一种结构,让我们来学习一下使用图
的算法吧。
【知识框架】
![](https://i-blog.csdnimg.cn/img_convert/bd7aabf93605e2fe24453e493f3e1367.png)
二、基本概念
在线性表中, 数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和一
个直接后继。
在树形结构中, 数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多
个元素相关,但只能和上一层中一个元素相关。图是一种较线性表和树更加复杂的数据结构。在图
形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
三、图的定义
图(Graph)是由顶点的有穷非空集合V ( G ) 和顶点之间边的集合E ( G )组成,通常表示为: G = ( V ,
E ) ,其中,G表示个图,V 是图G中顶点的集合,E是图G中边的集合。若V = { v 1 , v 2 , . . . , v n
} ,则用∣ V ∣表示图G中顶点的个数,也称图G的阶,E = { ( u , v ) ∣ u ∈ V , v ∈ V } ,用∣ E ∣表
示图G中边的条数。
注意:线性表可以是空表,树可以是空树,但图不可以是空图。就是说,图中不能一个顶点也没
有,图的顶点集V一定非空,但边集E可以为空,此时图中只有顶点而没有边。
四、图的基本概念和术语
1. 有向图
若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v, w>,其中v,w
是顶点,v称为弧尾,w称为弧头,<v,w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自
v。
![](https://i-blog.csdnimg.cn/img_convert/ed48da68fbd550e95b45b405c15a8ef0.png)
图(a)所示的有向图G1 可表示为
![](https://i-blog.csdnimg.cn/img_convert/28ced71a61399d97e54ed93cc5410588.png)
2. 无向图
若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w,v),因为
(v,w)=(w,v), 其中v,w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说
边(v, w)和顶点v, w相关联。
![](https://i-blog.csdnimg.cn/img_convert/73e99c38ec53cb5e3796adcb702a64de.png)
图(b)所示的无向图G2可表示为
![](https://i-blog.csdnimg.cn/img_convert/ef9b7281b539849e098d38e108e16d83.png)
3. 简单图
一个图G若满足:
① 不存在重复边;
② 不存在顶点到自身的边
则称图G为简单图。
上图中G 1 和G 2均为简单图。数据结构中仅讨论简单图。
4. 多重图
若图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则G为多重图。
多重图的定义和简单图是相对的。
5. 完全图(也称简单完全图)
对于无向图,∣E∣的取值范围是0到n ( n − 1 ) / 2,有n ( n − 1 ) / 2 条边的无向图称为完全图,在完
全图中任意两个顶点之间都存在边。对于有向图,∣E∣的取值范围是0到n ( n − 1 ) ,有n ( n − 1 )
条弧的有向图称为有向完全图,在有向完全图中任意两个顶点之间都存在方向相反的两条弧。上图
中G2 为无向完全图,而G3为有向完全图。
![](https://i-blog.csdnimg.cn/img_convert/7a8f62792067bcb429353b7f9d3d392c.png)
6. 子图
设有两个图G = ( V , E ) 和G ′ = ( V ′ , E ′ ) ′, 若V ′ 是V的子集,且E ′是E的子集,则称G ′ 是G的
子图。若有满足V ( G ′ ) = V ( G ) 的子图G′,则称其为G的生成子图。上图中G3 为G1 的子图。
注意:并非V和E的任何子集都能构成G的子图,因为这样的子集可能不是图,即E的子集中的某些
边关联的顶点可能不在这个V的子集中。
7. 连通、连通图和连通分量
在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连
通的,则称图G为连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量。若一个图
有n个顶点,并且边数小于n −1,则此图必是非连通图。如下图(a)所示, 图G4有3个连通分量,如
图(b)所示。
![](https://i-blog.csdnimg.cn/img_convert/a5eaff0442db827b7dc61d18ff67e324.png)
注意:弄清连通、连通图、连通分量的概念非常重要。首先要区分极大连通子图和极小连通子图,
极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要
保持图连通又要使得边数最少的子图。
8. 强连通图、强连通分量
在有向图中,若从顶点v到顶点w和从顶点w到项点v之间都有路径,则称这两个顶点是强连通的。若
图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的
强连通分量,图G 1 的强连通分量如下图所示。
![](https://i-blog.csdnimg.cn/img_convert/e5a925b8081ed5ea2e8b06491cfa10d9.png)
注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考
虑强连通性。
9. 生成树、生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n
− 1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回
路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。图G2的一个生成树如下图所
示。
![](https://i-blog.csdnimg.cn/img_convert/949c5b6fd589ed232478617919f13d71.png)
注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条
边,图将不再连通。
10. 顶点的度、入度和出度
图中每个顶点的度定义为以该项点为一个端点的边的数目。
对于无向图,顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
在具有n个顶点、e条边的无向图中
![](https://i-blog.csdnimg.cn/img_convert/7218c645bbf52bec6ec6fea4429f6746.png)
即无向图的全部顶点的度的和等于边数的2倍,因为每条边和两个顶点相关联。
对于有向图,顶点v的度分为入度和出度,入度是以顶点v为终点的有向边的数目,记为ID(v) ; 而出度
是以顶点v为起点的有向边的数目,记为OD(v)。顶点v的度等于其入度和出度之和,即TD(v) = ID
(v) + OD (v) 。
在具有n个顶点、e条边的有向图中
![](https://i-blog.csdnimg.cn/img_convert/0e354ac9d0c1f8305cb924b3b63cf844.png)
即有向图的全部顶点的入度之和与出度之和相等,并且等于边数。这是因为每条有向边都有一个起
点和终点。
11. 边的权和网
在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带有权值
的图称为带权图,也称网。
12. 稠密图、稀疏图
边数很少的图称为稀疏图,反之称为稠密图。稀疏和稠密本身是模糊的概念,稀疏图和稠密图常常
是相对而言的。一般当图G满足∣E∣ < ∣V∣ log∣V∣ 时,可以将G视为稀疏图。
13. 路径、路径长度和回路
顶点vp 到顶点vq 之间的一条路径是指顶点序列vp , vi1 , vi2 , . . . , vim , vq ,当然关联的边也可以
理解为路径的构成要素。路径上边的数目称为路径长度。第一个顶点和最后一个顶点相同的路径称
为回路或环。若一个图有n个顶点,并且有大于n − 1条边,则此图一定有环。
14. 简单路径、简单回路
在路径序列中,顶点不重复出现的路径称为简单路径。除第一个顶点和最后一个顶点外,其余顶点
不重复出现的回路称为简单回路。
15. 距离
从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存
在路径,则记该距离为无穷( ∞ ) (∞)(∞)。
16. 有向树
一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
五、图的存储结构
由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理
位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方
式,要么会造成很多存储单元的浪费,要么又带来操作的不便。因此,对于图来说,如何对它实现
物理存储是个难题,接下来我们介绍五种不同的存储结构。
1. 邻接矩阵
图的邻接矩阵(Adjacency Matrix) 存储方式是用两个数组来表示图。一个一维数组存储图中顶点信
息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
设图G GG有n nn个顶点,则邻接矩阵A AA是一个n ∗ n n*n n∗n的方阵,定义为:
![](https://i-blog.csdnimg.cn/img_convert/0a1caf52574f4000d4fef834e511a1f1.png)
下图是一个无向图和它的邻接矩阵:
![](https://i-blog.csdnimg.cn/img_convert/50079575af545633425d1357d902af06.png)
可以看出:
- 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是第i个顶点的度TD(vi) 。比如顶点v1 的度就是1 + 0 + 1 + 0 = 2。
求顶点vi的所有邻接点就是将矩阵中第i行元素扫描一遍, A[i] [j] 为 1就是邻接点。
下图是有向图和它的邻接矩阵:
![](https://i-blog.csdnimg.cn/img_convert/4358dd3f190f1b276acb0bfa7dfc996a.png)
可以
- 主对角线上数值依然为0。但因为是有向图,所以此矩阵并不对称。
- 有向图讲究入度与出度,顶点v1的入度为1,正好是第v1列各数之顶点v1 的出度为2,即第v 1v行的各数
- 与无向图同样的办法,判断顶点vi 到vj 是否存在弧,只需要查找矩阵中A [i] [j] 是否为1
对于带权图而言,若顶点vi和vj 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值
![](https://i-blog.csdnimg.cn/img_convert/78f5f49f76875651c4f8c6f5a8d7878b.png)
下图是有向网图和它的邻接矩阵:
![](https://i-blog.csdnimg.cn/img_convert/55a777643dfb98f937d26d17a68fca4f.png)
通过以上对无向图、有向图和网的描述,可定义出邻接矩阵的存储结构:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
public class AdjacencyMatrix {
private ArrayList<String> vexs; // 顶点表
private int[][] edges; // 边表
int numVertexes;
int numEdges;
boolean[] visited;
public AdjacencyMatrix(int numVertexes, int numEdges) {
this.numVertexes = numVertexes;
this.numEdges = numEdges;
this.vexs = new ArrayList<String>(numVertexes);
this.edges = new int[numVertexes][numVertexes];
this.visited = new boolean[numVertexes];
}
private void insertVex(String v) {
vexs.add(v);
}
private void insertEdge(int v1, int v2, int weight) {
edges[v1][v2] = weight;
edges[v2][v1] = weight;
}
private void show() {
for (int[] link : edges) {
System.out.println(Arrays.toString(link));
}
}
private void DFS(int i) {
visited[i] = true;
System.out.print(vexs.get(i) + " ");
for (int j = 0; j < numVertexes; j++) {
if (edges[i][j] > 0 && !visited[j]) {
DFS(j);
}
}
}
private void DFSTraverse() {
int i;
for (i = 0; i < numVertexes; i++) {
visited[i] = false;
}
for (i = 0; i < numVertexes; i++) {
if (!visited[i]) {
DFS(i);
}
}
}
private void BFSTraverse() {
int i, j;
LinkedList queue = new LinkedList();
for (i = 0; i < numVertexes; i++) {
visited[i] = false;
}
for (i = 0; i < numVertexes; i++) {
if (!visited[i]) {
visited[i] = true;
System.out.print(vexs.get(i) + " ");
queue.addLast(i);
while (!queue.isEmpty()) {
i = (Integer) queue.removeFirst();
for (j = 0; j < numVertexes; j++) {
if (edges[i][j] > 0 && !visited[j]) {
visited[j] = true;
System.out.print(vexs.get(j) + " ");
queue.addLast(j);
}
}
}
}
}
}
public static void main(String[] args) {
int numVertexes = 9;
int numEdges = 15;
AdjacencyMatrix graph = new AdjacencyMatrix(numVertexes, numEdges);
graph.insertVex("A");
graph.insertVex("B");
graph.insertVex("C");
graph.insertVex("D");
graph.insertVex("E");
graph.insertVex("F");
graph.insertVex("G");
graph.insertVex("H");
graph.insertVex("I");
graph.insertEdge(0, 1, 1);
graph.insertEdge(0, 5, 1);
graph.insertEdge(1, 2, 1);
graph.insertEdge(1, 6, 1);
graph.insertEdge(1, 8, 1);
graph.insertEdge(2, 3, 1);
graph.insertEdge(2, 8, 1);
graph.insertEdge(3, 4, 1);
graph.insertEdge(3, 6, 1);
graph.insertEdge(3, 7, 1);
graph.insertEdge(3, 8, 1);
graph.insertEdge(4, 7, 1);
graph.insertEdge(4, 5, 1);
graph.insertEdge(5, 6, 1);
graph.insertEdge(6, 7, 1);
System.out.println("邻接矩阵");
graph.show();
System.out.print("深度优先遍历:");
graph.DFSTraverse();
System.out.println();
System.out.print("广度优先遍历:");
graph.BFSTraverse();
}
}
注意:
① 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。
② 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType可定义为值为0和1的枚举类型。
③ 无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。
④ 邻接矩阵表示法的空间复杂度为O ( n 2 ),其中n为图的顶点数∣V |。
⑤ 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有
多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
⑥ 稠密图适合使用邻接矩阵的存储表示。
2. 邻接表
当一个图为稀疏图时(边数相对顶点较少),使用邻接矩阵法显然要浪费大量的存储空间,如下图所示:
![](https://i-blog.csdnimg.cn/img_convert/5879f354081101d9d780407e1e9fb374.png)
而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
所谓邻接表,是指对图G中的每个顶点v i建立一个单链表,第i个单链表中的结点表示依附于顶点vi
的边(对于有向图则是以顶点v i为尾的弧),这个单链表就称为顶点v i的边表(对于有向图则称为出边
表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:
顶点表结点和边表结点,如下图所示。
![](https://i-blog.csdnimg.cn/img_convert/4ed96de84c6a772a9006756421577229.png)
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc) 构成,边表(邻接表)结点由邻接点
域(adjvex)和指向下一条邻接边的指针域(nextarc) 构成。
无向图的邻接表的实例如下图所示。
![](https://i-blog.csdnimg.cn/img_convert/47384631768b4cf76d2b018b81dd3393.png)
有向图的邻接表的实例如下图所示。
![](https://i-blog.csdnimg.cn/img_convert/ae4aef0b87dc6b9ba20ed06b5ccc1c6e.png)
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。
图的邻接表存储结构定义如下:
import java.util.ArrayList;
import java.util.LinkedList;
class EdgeNode {
int vex;
int adjvex; // 邻接点域,存储该顶点对应的下标
int weight;
EdgeNode next;
public EdgeNode(int vex, int adjvex, int weight) {
this.vex = vex;
this.adjvex = adjvex;
this.weight = weight;
this.next = null;
}
}
class VertexNode {
String data; // 顶点域,存储顶点信息
EdgeNode firstedge; // 边表头
public VertexNode(String data) {
this.data = data;
this.firstedge = null;
}
}
public class AdjacencyList {
private ArrayList<VertexNode> vexs; // 顶点表
int numVertexes;
int numEdges;
boolean[] visited;
public AdjacencyList(int numVertexes, int numEdges) {
this.numVertexes = numVertexes;
this.numEdges = numEdges;
this.vexs = new ArrayList<VertexNode>(numVertexes);
this.visited = new boolean[numVertexes];
}
private void insertVex(VertexNode v) {
vexs.add(v);
}
private void insertEdge(EdgeNode e) {
int i = e.vex; // 顶点表中对应结点的下标
int j = e.adjvex; // 边表结点对应的下标
VertexNode vexi = vexs.get(i);
VertexNode vexj = vexs.get(j);
e.next = vexi.firstedge;
vexi.firstedge = e;
EdgeNode e2 = new EdgeNode(j, i, 1);
e2.next = vexj.firstedge;
vexj.firstedge = e2;
}
private void show() {
for (int i = 0; i < numVertexes; i++) {
VertexNode vex = vexs.get(i);
System.out.print("【" + vex.data + "】--->");
EdgeNode node = vex.firstedge;
while (node != null) {
System.out.print(vexs.get(node.adjvex).data + "(" + node.adjvex + ")" + "->");
node = node.next;
}
System.out.print("null");
System.out.println();
}
}
private void DFS(int i) {
EdgeNode p;
visited[i] = true;
System.out.print(vexs.get(i).data + " ");
p = vexs.get(i).firstedge;
while (p != null) {
if (!visited[p.adjvex]) {
DFS(p.adjvex);
}
p = p.next;
}
}
private void DFSTraverse() {
int i;
for (i = 0; i < numVertexes; i++) {
visited[i] = false;
}
for (i = 0; i < numVertexes; i++) {
if (!visited[i]) {
DFS(i);
}
}
}
private void BFSTraverse() {
EdgeNode p;
int i;
LinkedList queue = new LinkedList();
for (i = 0; i < numVertexes; i++) {
visited[i] = false;
}
for (i = 0; i < numVertexes; i++) {
if (!visited[i]) {
visited[i] = true;
System.out.print(vexs.get(i).data + " ");
queue.addLast(i);
while (!queue.isEmpty()) {
i = (Integer) queue.removeFirst();
p = vexs.get(i).firstedge;
while (p != null) {
if (!visited[p.adjvex]) {
visited[p.adjvex] = true;
System.out.print(vexs.get(p.adjvex).data + " ");
queue.addLast(p.adjvex);
}
p = p.next;
}
}
}
}
}
public static void main(String[] args) {
int numVertexes = 9;
int numEdges = 15;
AdjacencyList graph = new AdjacencyList(numVertexes, numEdges);
graph.insertVex(new VertexNode("A"));
graph.insertVex(new VertexNode("B"));
graph.insertVex(new VertexNode("C"));
graph.insertVex(new VertexNode("D"));
graph.insertVex(new VertexNode("E"));
graph.insertVex(new VertexNode("F"));
graph.insertVex(new VertexNode("G"));
graph.insertVex(new VertexNode("H"));
graph.insertVex(new VertexNode("I"));
graph.insertEdge(new EdgeNode(0, 1, 1));
graph.insertEdge(new EdgeNode(0, 5, 1));
graph.insertEdge(new EdgeNode(1, 2, 1));
graph.insertEdge(new EdgeNode(1, 6, 1));
graph.insertEdge(new EdgeNode(1, 8, 1));
graph.insertEdge(new EdgeNode(2, 3, 1));
graph.insertEdge(new EdgeNode(2, 8, 1));
graph.insertEdge(new EdgeNode(3, 4, 1));
graph.insertEdge(new EdgeNode(3, 6, 1));
graph.insertEdge(new EdgeNode(3, 7, 1));
graph.insertEdge(new EdgeNode(3, 8, 1));
graph.insertEdge(new EdgeNode(4, 7, 1));
graph.insertEdge(new EdgeNode(4, 5, 1));
graph.insertEdge(new EdgeNode(5, 6, 1));
graph.insertEdge(new EdgeNode(6, 7, 1));
System.out.println("邻接表");
graph.show();
System.out.print("深度优先遍历:");
graph.DFSTraverse();
System.out.println();
System.out.print("广度优先遍历:");
graph.BFSTraverse();
}
}
图的邻接表存储方法具有以下特点
- 若G为无向图,则所需的存储空间为O ( ∣V∣ + 2 ∣E∣ ) ;若G GG为有向图,则所需的存储空间为O (∣V∣ + ∣E∣ ) 。前者的倍数2是由于无向图中,每条边在邻接表中出现了两次。
- 对于稀疏图,采用邻接表表示将极大地节省存储空间。
- 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为O ( n ) 。但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
- 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表。因此,也有人采用逆邻接表的存储方式来加速求解给定顶点的入度。当然,这实际上与邻接表存储方式是类似的。
- 图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。
3. 十字链表
十字链表是有向图的一种链式存储结构。
对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知
道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来
呢?答案是肯定的,就是把它们整合在一起。这就是我们现在要介绍的有向图的一种存储方法:十
字链表(Orthogonal List)。
我们重新定义顶点表结点结构如下表所示。
![](https://i-blog.csdnimg.cn/img_convert/a0a15ea2e35616c5a8d08a6a314a1091.png)
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指
向该顶点的出边表中的第一个结点。重新定义的边表结点结构如下表所示。
![](https://i-blog.csdnimg.cn/img_convert/4b830791a591d2db26f87b4e0fdf6104.png)
其中tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink是指入
边表指针域,指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。如果
是网,还可以再增加一个weight域来存储权值。
接下来通过一个例子详细介绍十字链表的结构。
如下图所示,顶点依然是存入一个一维数组{ V0, V1 , V2, V3} ,实线箭头指针的图示完全与的邻接
表的结构相同。就以顶点V0 来说,firstout 指向的是出边表中的第一个结点V3 。所以V0 边表结点
的headvex = 3 ,而tailvex就是当前顶点V0 的下标0,由于V0只有一个出边顶点,所以headlink和
taillink都是空。
![](https://i-blog.csdnimg.cn/img_convert/d209bebe411df772559ab4689a91190f.png)
我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。
对于V0 来说,它有两个顶点V1 和V2的入边。
因此V0 的firstin指向顶点V1 的边表结点中headvex为0的结点,如上图右图中的①。
接着由入边结点的
headlink指向下一个入边顶点V2 ,如图中的②。
对于顶点V1 ,它有一个入边顶点V2 ,所以它的firstin指向顶点
V2 的边表结点中headvex为1的结点,如图中的③。
顶点V2 和V3 也是同样有一个入边顶点,如图中④和⑤。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起, 这样既容易找到以V1 为尾的弧,也
容易找到以V!
为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间
复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。
import java.util.ArrayList;
/**
* 十字链表
* @author zhengge
* @version 1.0
* @create 2024/5/25 16:43
*/
public class CrossLinkedListGraph<E> {
class Vertex{
E data;
Edge firstIn;
Edge firstOut;
public Vertex(E data, Edge firstIn, Edge firstOut) {
this.data = data;
this.firstIn = firstIn;
this.firstOut = firstOut;
}
}
class Edge{
int fromVertex;
int toVertex;
Edge fromEdge;
Edge toEdge;
int weight;
public Edge(int fromVertex, int toVertex, Edge fromEdge, Edge toEdge, int weight) {
this.fromVertex = fromVertex;
this.toVertex = toVertex;
this.fromEdge = fromEdge;
this.toEdge = toEdge;
this.weight = weight;
}
}
private int numOfVertices;
private int maxOfVertices;
private ArrayList<Vertex> vertices;
public CrossLinkedListGraph(int maxOfVertices) {
this.maxOfVertices = maxOfVertices;
this.vertices = new ArrayList<>(maxOfVertices);
numOfVertices = 0;
}
public boolean putVertex(E data){
if (numOfVertices < maxOfVertices){
vertices.add(new Vertex(data,null,null));
numOfVertices ++;
return true;
}
return false;
}
public E getVertexData(int vertexIndex){
if (vertexIndex<maxOfVertices){
return vertices.get(vertexIndex).data;
}
return null;
}
/**
* 插入边,横纵列表都要插入
*/
public boolean putEdge(int fromVertexIndex,int toVertexIndex,int weight){
if (fromVertexIndex < maxOfVertices && toVertexIndex < maxOfVertices){
Vertex fromVertex = vertices.get(fromVertexIndex);
Edge newEdge = new Edge(fromVertexIndex,toVertexIndex,null,null,weight);
//插入横向链表
if (fromVertex.firstOut == null) {
fromVertex.firstOut = newEdge;
//插入竖向链表
return insertFromEdgeLinkedList(fromVertex.firstOut);
}
//遍历元素然后将元素放到尾部
Edge edge = fromVertex.firstOut;
while (edge.toEdge != null) {
edge = edge.toEdge;
}
edge.toEdge = newEdge;
//插入竖向链表
return insertFromEdgeLinkedList(edge.toEdge);
}
return false;
}
/**
* 将插入竖向链表提升为一个方法
*/
private boolean insertFromEdgeLinkedList(Edge edge){
if ( edge!=null){
//获得新增加的指向的顶点
Vertex toVertex = vertices.get(edge.toVertex);
if (toVertex.firstIn==null){
//如果指向的顶点没有竖向链表就直接将第一个边赋值给竖向链表
toVertex.firstIn = edge;
return true;
}
Edge fromEdge = toVertex.firstIn;
while (fromEdge.fromEdge!=null){
fromEdge = fromEdge.fromEdge;
}
fromEdge.fromEdge = edge;
return true;
}
return false;
}
public void print(){
for (Vertex vertex : vertices) {
Edge edge = vertex.firstOut;
System.out.print(vertex.data+": ");
while (edge!=null){
System.out.print(vertices.get(edge.fromVertex).data
+ " --> "
+vertices.get(edge.toVertex).data
+" weight="+edge.weight +"; ");
edge = edge.toEdge ;
}
System.out.println();
}
}
public void printVertexFromEdge(int fromVertex){
System.out.println("====print vertex from edges====");
Vertex vertex = vertices.get(fromVertex);
Edge firstIn = vertex.firstIn;
if (vertex.firstIn!=null){
Edge fromEdge = vertex.firstIn;
while (fromEdge!=null){//这里debug的时候录为null
System.out.println(vertices.get(fromEdge.fromVertex).data
+" --> "+
vertices.get(fromEdge.toVertex).data);
fromEdge = fromEdge.fromEdge;
}
}else {
System.out.println("null of firstIn");
}
}
}
4. 邻接多重表
邻接多重表是无向图的另一种链式存储结构。
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行
删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。比如下图中,若要删除左图的( V0
, V2 ) 这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。
![](https://i-blog.csdnimg.cn/img_convert/36928b559f3d9da2cae8714bf2ce4dde.png)
重新定义的边表结点结构如下表所示。
![](https://i-blog.csdnimg.cn/img_convert/a4a21ad1b743b538c07e5bf51ad56b0d.png)
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点ivex的下一条边,
jlink 指向依附顶点jvex的下一条边。这就是邻接多重表结构。
每个顶点也用一一个结点表示,它由如下所示的两个域组成。
![](https://i-blog.csdnimg.cn/img_convert/69a9c918916bc132a16c79c9deba261a.png)
其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下
图7所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点
画出来。
![](https://i-blog.csdnimg.cn/img_convert/8b0df9cd057b6cfe5c0447c05ea7e203.png)
我们开始连线,如图,首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex
的值相同,这很好理解。
接着,由于顶点V0 的( V0 , V1 )边的邻边有(V0 , V3) 和( V 0 , V 2 )。
因此⑤⑥的连线就是满足指向下一条依附于顶点V0的边的目标,注意ilink指向的结点的jvex一定要
和它本身的ivex的值相同。
同样的道理,连线⑦就是指(V1 , V0 )这条边,它是相当于顶点V1指向(V1,V2 ) 边后的下一条。
V2有三条边依附,所以在③之后就有了⑧⑨。
连线④的就是顶点V3在连线④之后的下一条边。
左图一共有5条边,所以右图有10条连线,完全符合预期。
到这里,可以明显的看出,邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结
点表示,而在邻接多重表中只有一个结点。
这样对边的操作就方便多了,若要删除左图的( V0 , V2) 这条边,只需要将右图的⑥⑨的链接指向
改为NULL即可。
import java.util.LinkedList;
public class GraphAdjacencyList {
// 使用LinkedList来存储邻接节点,这里假设图是无向的
private LinkedList<Integer>[] adjacencyList;
public GraphAdjacencyList(int vertices) {
adjacencyList = new LinkedList[vertices];
for (int i = 0; i < vertices; i++) {
adjacencyList[i] = new LinkedList<>();
}
}
// 添加边的方法
public void addEdge(int source, int destination) {
adjacencyList[source].add(destination);
adjacencyList[destination].add(source); // 因为是无向图,所以需要添加到两个节点
}
// 打印邻接表
public void printAdjacencyList() {
for (int vertex = 0; vertex < adjacencyList.length; vertex++) {
System.out.print(vertex + " -> ");
for (int neighbor : adjacencyList[vertex]) {
System.out.print(neighbor + " ");
}
System.out.println();
}
}
public static void main(String[] args) {
GraphAdjacencyList graph = new GraphAdjacencyList(5);
graph.addEdge(1, 0);
graph.addEdge(0, 2);
graph.addEdge(0, 3);
graph.addEdge(3, 4);
graph.printAdjacencyList();
}
}
5. 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每
个数据元素由一条边的起点下标(begin)、 终点下标(end)和权(weight)组成,如下图所示。显然边
集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。
因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
![](https://i-blog.csdnimg.cn/img_convert/3781643101c242d0562e29ab35027093.png)
package E;
import java.util.Arrays;
import java.util.Scanner;
public class A {
private static Scanner sc = new Scanner(System.in);
private static final int N = 1000;
private static int n, m, cnt;
private static String vex[] = new String[N];
private static class Edge {
int u;
int v;
int w;
}
private static Edge e[] = new Edge[N * N];
private static void init() {
Arrays.fill(vex, -1);
Arrays.fill(e, -1);
cnt = 0;
}
private static int locateVex(String x) {
for (int i = 0; i < n; i++) {
if (vex[i].equals(x)) {
return i;
}
}
return -1;
}
private static void add(int i, int j, int w) {
e[cnt] = new Edge();
e[cnt].u = i;
e[cnt].v = j;
e[cnt++].w = w;
}
private static void createGraph() {
String u, v;
int w;
while (m-- > 0) {
u = sc.next();
v = sc.next();
w = sc.nextInt();
int i = locateVex(u);
int j = locateVex(v);
if (i != -1 && j != -1) {
add(i, j, w);
} else {
System.out.println("错误");
m++;
}
}
}
private static void print() {
System.out.println("边集数组如下:");
for (int i = 0; i < cnt; i++) {
System.out.println(e[i].u + " " + e[i].v + " " + e[i].w);
}
}
public static void main(String[] args) {
n = sc.nextInt();
m = sc.nextInt();
for (int i = 0; i < n; i++) {
vex[i] = sc.next();
}
createGraph();
print();
}
}
/*
4 6
A B C D
A B 3
A C 5
B C 2
C B 6
C D 2
D B 7
*/
六、图的遍历
图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,
这一过程就叫做图的遍历(Traversing Graph)。
对于图的遍历来,通常有两种遍历次序方案:它们是深度优先遍历和广度优先遍历。
1. 深度优先遍历
深度优先遍历(Depth First Search),也有称为深度优先搜索,简称为DFS。
1.1. DFS算法
深度优先搜索类似于树的先序遍历。如其名称中所暗含的意思一样,这种搜索算法所遵循的搜索策
略是尽可能"深"地搜索一个图。
它的基本思想如下:首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一
顶点w1 ,再访问与w1 邻接且未被访问的任一顶点...重复上述过程。
当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该
点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。一般情况下,其递归形式的算法十
分简洁,算法过程如下:
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
// 用于表示图的节点
class Node {
int value;
List<Node> neighbors;
public Node(int value) {
this.value = value;
neighbors = new ArrayList<>();
}
}
public class DepthFirstSearch {
public static void dfs(Node start) {
Stack<Node> stack = new Stack<>();
stack.push(start);
while (!stack.isEmpty()) {
Node current = stack.pop();
if (current != null && !current.visited) {
current.visited = true;
System.out.print(current.value + " ");
// 将当前节点的未访问过的邻居节点入栈
for (Node neighbor : current.neighbors) {
if (!neighbor.visited) {
stack.push(neighbor);
}
}
}
}
}
public static void main(String[] args) {
// 构造图
Node node1 = new Node(1);
Node node2 = new Node(2);
Node node3 = new Node(3);
Node node4 = new Node(4);
Node node5 = new Node(5);
node1.neighbors.add(node2);
node1.neighbors.add(node3);
node2.neighbors.add(node4);
node3.neighbors.add(node4);
node4.neighbors.add(node5);
System.out.println("深度优先搜索结果:");
dfs(node1);
}
}
以下面这个无向图为例
![](https://i-blog.csdnimg.cn/img_convert/fdeb8050abe028b63df9c0e12586c762.png)
其深度优先遍历的结果为a b d e h c f g abdehcfgabdehcfg
1.2. DFS算法的性能分析
DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为O ( V ) O(V)O(V)。
对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中
的所有元素,因此都需要O ( V 2 ) O(V^2)O(V2)的时间。而邻接表做存储结构时,找邻接点所需的
时间取决于顶点和边的数量,所以是O ( V + E ) O(V+E)O(V+E)。 显然对于点多边少的稀疏图来
说,邻接表结构使得算法在时间效率上大大提高。
对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。
![](https://i-blog.csdnimg.cn/img_convert/61ca82602601736bc1413bee9028b5c5.png)
1.3. 深度优先的生成树和生成森林
深度优先搜索会产生一棵深度优先生成树。
当然,这是有条件的,即对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生
成森林,如下图所示。
基于邻接表存储的深度优先生成树是不唯一的 。
2. 广度优先遍历
广度优先遍历(Breadth First Search),又称为广度优先搜索,简称BFS。
2.1. BFS算法
如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。
广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有
往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,
以记忆正在访问的顶点的下一层顶点。以下是广度优先遍历的代码:
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
// 用于表示图的节点
class Node {
int value;
List<Node> neighbors;
public Node(int value) {
this.value = value;
neighbors = new ArrayList<>();
}
}
public class BreadthFirstSearch {
public static void bfs(Node start) {
Queue<Node> queue = new LinkedList<>();
queue.offer(start);
start.visited = true;
while (!queue.isEmpty()) {
Node current = queue.poll();
System.out.print(current.value + " ");
// 将当前节点的未访问过的邻居节点入队列
for (Node neighbor : current.neighbors) {
if (!neighbor.visited) {
queue.offer(neighbor);
neighbor.visited = true;
}
}
}
}
public static void main(String[] args) {
// 构造图
Node node1 = new Node(1);
Node node2 = new Node(2);
Node node3 = new Node(3);
Node node4 = new Node(4);
Node node5 = new Node(5);
node1.neighbors.add(node2);
node1.neighbors.add(node3);
node2.neighbors.add(node4);
node3.neighbors.add(node4);
node4.neighbors.add(node5);
System.out.println("广度优先搜索结果:");
bfs(node1);
}
}
以下面这个无向图为例
![](https://i-blog.csdnimg.cn/img_convert/b4e2b3c2a3e33039a1e822e7213aad67.png)
其广度优先遍历的结果为a b c d e f g h abcdefghabcdefgh。
2.2. BFS算法性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列Q, n个顶点均需入队一
次,在最坏的情况下,空间复杂度为O (V) 。
采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次), 在搜索任一顶点的邻接点时,每条
边至少访问一次,算法总的时间复杂度为O ( V + E ) 。采用邻接矩阵存储方式时,查找每个顶点的
邻接点所需的时间为O ( V ),故算法总的时间复杂度为O ( V2 ) 。
注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不
同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻
接表的遍历所得到的DFS序列和BFS序列是不唯一的。
3. 图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性。
对于无向图来说,若无向图是连通的,则从任一结点出发, 仅需一次遍历就能够访问图中的所有
顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所
有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始
点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故在BFSTraverse ()或DFSTraverse ()中添加了第二个for循环,再选取初始点,继续进行遍历,以
防止一次无法遍历图的所有顶点。
对于无向图,上述两个函数调用BFS (G,i)或DFS(G,i)的次数等于该图的连通分量数;而对于有向
图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分
量和非强连通分量,非强连通分量一次调用BFS (G, i)或DFS (G, i)无法访问到该连通分量的所有顶
点。
如下图所示为有向图的非强连通分量。
![](https://i-blog.csdnimg.cn/img_convert/b99981011a40f766011ecdec0ea0fb05.png)
最小生成树
一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n
− 1 n-1n−1条边,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成
图中的一条回路。
对于一个带权连通无向图G = ( V , E ) G=(V, E)G=(V,E),生成树不同,其中边的权值之和最小的那
棵生成树(构造连通网的最小代价生成树),称为G的最小生成树(Minimum-Spanning-Tree,
MST)。
构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:假设G = ( V , E )
G=(V, E)G=(V,E)是一个带权连通无向图,U UU是顶点集V VV的一个非空子集。若( u , v ) (u,v)
(u,v)是一条具有最小权值的边,其中u ∈ U , v ∈ V − U u∈U,v∈V-Uu∈U,v∈V−U,则必存在一
棵包含边( u , v ) (u, v)(u,v)的最小生成树。
基于该性质的最小生成树算法主要有Prim算法和Kruskal算法,它们都基于贪心算法的策略。
下面介绍一个通用的最小生成树算法:
通用算法每次加入一条边以逐渐形成一棵生成树,下面介绍两种实现上述通用算法的途径。
3.1. 普里姆(Prim)算法
Prim算法构造最小生成树的过程如下图所示。
初始时从图中任取一顶点(如顶点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶
点集合距离最近的顶点,并将该顶点和相应的边加入T,每次操作后T中的顶点数和边数都增1。
以此类推,直至图中所有的顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。
通俗点说就是:从一个顶点出发,在保证不形成回路的前提下,每找到并添加一条最短的边,就把
当前形成的连通分量当做一个整体或者一个点看待,然后重复"找最短的边并添加"的操作。
![](https://i-blog.csdnimg.cn/img_convert/157f34707135f46e79227284caea19bf.png)
Prim算法的步骤如下:
假设G = { V , E } 是连通图,其最小生成树T = ( U , ET ) ,ET 是最小生成树中边的集合。
初始化:向空树T = ( U , ET ) 中添加图G = ( V , E )的任一顶点u0 ,使U = { u0 } ,ET = N U L。
循环(重复下列操作直至U = V ):从图G中选择满足{ ( u , v ) ∣ u ∈ U , v ∈ V − U } 且具有最小权
值的边( u , v ) ,加入树T,置U = U ∪ { v } ,ET = ET U { ( u , v ) } 。
额,不得不说这样理解起来有点抽象,为了能描述这个算法,我们先构造一个邻接矩阵,如下图的
右图所示。
![](https://i-blog.csdnimg.cn/img_convert/ad191097ab400104f9bdbb38f962e6ba.png)
于是普里姆(Prim) 算法代码如下,左侧数字为行号。其中INFINITY为权值极大值,不妨设65535,
MAXVEX 为顶点个数最大值,此处大于等于9即可。
import java.util.*;
public class PrimAlgorithm {
public static void main(String[] args) {
int vertices = 5; // 图中顶点数量
// 创建无向连通图的邻接矩阵表示
int graph[][] = {
{0, 2, 0, 6, 4},
{2, 0, 3, 8, 0},
{0, 3, 0, 1, 7},
{6, 8, 1, 0, 2},
{4, 0, 7, 2, 0}};
// 调用prim函数计算最小生成树并输出结果
List<Integer> minSpanningTree = prim(graph);
System.out.println("最小生成树边集合为:" + minSpanningTree);
}
private static List<Integer> prim(int graph[][]) {
boolean visited[] = new boolean[graph.length]; // 记录节点是否已访问过
int parent[] = new int[graph.length]; // 存放每个节点在最小生成树上的前驱节点
int key[] = new int[graph.length]; // 存放当前节点到最小生成树上其他节点的最小权值
List<Integer> minSpanningTree = new ArrayList<>(); // 保存最小生成树的边集合
Arrays.fill(visited, false); // 初始化所有节点未被访问过
for (int i = 1; i < graph.length; ++i) {
parent[i] = -1; // 将所有节点的前驱节点设置为-1(没有前驱)
key[i] = Integer.MAX_VALUE; // 将所有节点与最小生成树之间的权值设置为正无穷大
}
key[0] = 0; // 第一个节点不需要特殊处理,直接标记为已访问且权值为0
while (!isAllVisited(visited)) {
int u = findMinKeyVertex(key, visited); // 选取权值最小的未访问节点u
if (parent[u] != -1 && !visited[parent[u]]) {
minSpanningTree.add(parent[u]); // 如果u的前驱节点还未访问过,则加入最小生成树的边集合
}
visited[u] = true; // 标记节点u为已访问
updateKeys(graph, key, visited, u); // 更新与节点u相关联的节点的权值
}
return minSpanningTree;
}
private static int findMinKeyVertex(int key[], boolean visited[]) {
int minIndex = -1;
int minValue = Integer.MAX_VALUE;
for (int v = 0; v < key.length; ++v) {
if (!visited[v] && key[v] <= minValue) {
minValue = key[v];
minIndex = v;
}
}
return minIndex;
}
private static void updateKeys(int graph[][], int key[], boolean visited[], int u) {
for (int v = 0; v < graph.length; ++v) {
if (!visited[v] && graph[u][v] > 0 && graph[u][v] < key[v]) {
key[v] = graph[u][v];
parent[v] = u;
}
}
}
private static boolean isAllVisited(boolean visited
由算法代码中的循环嵌套可得知此算法的时间复杂度为O ( n 2 ) O(n^2)O(n2)。
3.2. 克鲁斯卡尔(Kruskal)算法
与Prim算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边
来构造最小生成树的方法。
Kruskal算法构造最小生成树的过程如下图所示。初始时为只有n个顶点而无边的非连通图T = V ,每
个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最
小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入T ,否则舍弃此边而选择下
一条权值最小的边。以此类推,直至T中所有顶点都在一个连通分量上。
![](https://i-blog.csdnimg.cn/img_convert/1e385422352fdb4c66c2c13bea070e2d.png)
算法思路:
我们可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是
很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边
集数组结构。以下是edge边集数组结构的定义代码:
我们将下面左图的邻接矩阵通过程序转化为右图的边集数组,并且对它们按权值从小到大排序。
![](https://i-blog.csdnimg.cn/img_convert/c88f271d963ef7c63acf7c663dd099d1.png)
于是Kruskal算法代码如下,左侧数字为行号。其中MAXEDGE为边数量的极大值,此处大于等于
15即可,MAXVEX为顶点个数最大值,此处大于等于9即可。
import java.util.*;
public class KruskalAlgorithm {
public static List<Edge> kruskalMST(int n, List<Edge> edges) {
List<Edge> mst = new ArrayList<>();
Collections.sort(edges, (e1, e2) -> e1.weight - e2.weight);
int[] parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
for (Edge edge : edges) {
int fromRoot = findRoot(parent, edge.from);
int toRoot = findRoot(parent, edge.to);
if (fromRoot != toRoot) {
mst.add(edge);
parent[fromRoot] = toRoot;
}
}
return mst;
}
static int findRoot(int[] parent, int x) {
while (parent[x] != x) {
x = parent[x];
}
return x;
}
static class Edge {
int from;
int to;
int weight;
public Edge(int from, int to, int weight) {
this.from = from;
this.to = to;
this.weight = weight;
}
}
public static void main(String[] args) {
int n = 5;
List<Edge> edges = new ArrayList<>();
edges.add(new Edge(0, 1, 2));
edges.add(new Edge(0, 3, 6));
edges.add(new Edge(1, 2, 3));
edges.add(new Edge(1, 3, 8));
edges.add(new Edge(1, 4, 5));
edges.add(new Edge(2, 4, 7));
edges.add(new Edge(3, 4, 9));
List<Edge> mst = kruskalMST(n, edges);
System.out.println("Kruskal's 最小生成树:");
for (Edge edge : mst) {
System.out.println(edge.from + " - " + edge.to + " 权重:" + edge.weight);
}
}
}
此算法的Find函数由边数e决定,时间复杂度为O ( l o g e ) O(loge)O(loge),而外面有一个for循环
e次。所以克鲁斯卡尔算法的时间复杂度为O ( e l o g e ) O(eloge)O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有
很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
4. 总结
- DFS:深度优先遍历算法,我们在进行算法运算时,优先将该路径的当前路径执行完毕,执行完毕或失败后向上回溯尝试其他途径
- BFS:广度优先遍历算法,我们在进行算法运算时,优先将当前路径点的所有情况罗列出来,然后根据罗列出来的情况罗列下一层
- DFS和BFS的算法依据:两者均以树的形式进行展开,可以采用树的模型来进行DFS和BFS演示
七、最短路径
在网图和非网图中,最短路径的含义是不同的。由于非网图它没有边上的权值,所谓的最短路径,
其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过
的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。
1. 迪杰斯特拉( Dijkstra )算法
Dijkstra算法用于构建单源点的最短路径---,即图中某个点到任何其他点的距离都是最短的。例
如,构建地图应用时查找自己的坐标离某个地标的最短距离。可以用于有向图,但是不能存在负权
值。
![](https://i-blog.csdnimg.cn/img_convert/e7dcfdd69cf4634a14e8a9fb33b1e91e.png)
我们以上图为例,通俗点说,这个迪杰斯特拉(Dijkstra) 算法,它并不是一下子求出了v0到v8的最
短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础
上,求得更远顶点的最短路径,最终得到你要的结果。
Dijkstra算法设置一个集合S记录已求得的最短路径的顶点。
在构造的过程中还设置了个辅助数组:
dist[]:记录从源点v0到其他各顶点当前的最短路径长度,它的初态为:若从v0到vi;有弧,则dist[i]
为弧上的权值;否则置dist[i]为∞ ∞∞。
![](https://i-blog.csdnimg.cn/img_convert/df52ddc9ca23212ccfcdebc9e6feb8f6.png)
例如,对图6.17中的图应用 Dijkstra算法求从顶点1出发至其余顶点的最短路径的过程,如表6.1所
示。
算法执行过程的说明如下。
- 初始化:集合S初始为v1 ,v1可达v2和v5,v1不可达v3和v4,因此dist[]数组各元素的初值依次设置为dist[2]=10,dist[3]=∞ ∞∞,dist[4]=∞ ∞∞,dist[5]=5。
- 第一轮:选出最小值dist[5],将顶点v5并入集合S,即此时已找到v1到v5的最短路径。当v5加入S 后,从v1到集合S中可达顶点的最短路径长度可能会产生变化。因此需要更新dist[]数组。v5可达v2,因v1 → v5 → v2 的距离8比dist[2]=10小,更新dist[2]=8;v5可达v3,v 1 → v 5 → v 3 的距离14,更新dist[3]=14;v5 可达v4,v 1 → v 5 → v 4 的距离7,更新dist[4]=7。
- 第二轮:选出最小值dist[4],将顶点v4并入集合S。继续更新dist[]数组。v4不可达v2,dist[2]不变;v4可达v3,v 1 → v 5 → v 4 → v 3 的距离13比dist[3]小,故更新dist[3]=13。
- 笫三轮:选出最小值dist[2],将顶点v2并入集合S。继续更新dist[]数组。v2可达v3,v 1 → v 5 → v 2 → v 3 的距离9比dist[3]小,更新dist[3]=9。
- 第四轮:选出唯一最小值dist[3],将顶点v3并入集合S,此时全部顶点都已包含在S中。
显然,Dijkstra 算法也是基于贪心策略的。使用邻接矩阵或者带权的邻接表表示时,时间复杂度为O ( V2) 。
人们可能只希望找到从源点到某个特定顶点的最短路径,但这个问题和求解源点到其他所有顶点的最短路径一样
复杂,时间复杂度也为O ( V2) 。
public class Dijkstra {
public static final int N = 65535;
public static void main(String[] args) {
char[] vertex = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
//邻接矩阵
int[][] matrix = new int[vertex.length][vertex.length];
matrix[0] = new int[]{N, 5, 7, N, N, N, 2};
matrix[1] = new int[]{5, N, N, 9, N, N, 3};
matrix[2] = new int[]{7, N, N, N, 8, N, N};
matrix[3] = new int[]{N, 9, N, N, N, 4, N};
matrix[4] = new int[]{N, N, 8, N, N, 5, 4};
matrix[5] = new int[]{N, N, N, 4, 5, N, 6};
matrix[6] = new int[]{2, 3, N, N, 4, 6, N};
//创建Graph对象
Graph graph = new Graph(vertex, matrix);
graph.showGraph();
graph.dijkstra(6);
}
}
class Graph {
char[] vertex; //保存结点的数据
int[][] matrix; //存放边,就是邻接矩阵
VisitedVertex visitedVertex; //已经访问的顶点的集合
public Graph(char[] vertex, int[][] matrix) {
this.vertex = vertex;
this.matrix = matrix;
}
//显示邻接矩阵
public void showGraph() {
for (int i = 0; i < vertex.length; i++) {
for (int j = 1; j < vertex.length; j++) {
System.out.printf("%-8d", matrix[i][j]);
}
System.out.println();
}
}
//迪杰斯特拉算法
//index 表示出发顶点对应的下标
public void dijkstra(int index) {
visitedVertex = new VisitedVertex(vertex.length, index);
update(index);
for(int i=0;i<vertex.length;i++){
index= visitedVertex.getNextIndex(); //访问并返回新的访问节点
update(index);
}
visitedVertex.show();
}
//更新index下标顶点到周围顶点的前驱顶点,
public void update(int index) {
//根据遍历邻接矩阵的matrix[index]行
int len;
for (int i = 0; i < matrix[index].length; i++) {
if(matrix[index][i]==Dijkstra.N)
continue;
len = visitedVertex.getDistance(index) + matrix[index][i];
if (!visitedVertex.isVisted(i) && len < visitedVertex.getDistance(i)) {
visitedVertex.updateDistance(i, len); //更新出发顶点到i顶点的距离
visitedVertex.updatePre(i, index); //更新i顶点的前驱为index
}
}
}
}
//已访问项点集合
class VisitedVertex {
//记录各个顶点是否访问过 true表示访问过, false未访问,会动态更新
public boolean[] isVisted;
//每个下标对应的值为前一个顶点下标,会动态更新
public int[] pre_visited;
//记录出发顶点到其他所有顶点的距离,比如G为出发顶点,就会记录G到其它顶点的距离,会动态更新,求的最短距离就会存放到dis
public int[] distance;
//构造器
/**
* @param length 顶点的个数
* @param index 出发顶点对应的下标
*/
public VisitedVertex(int length, int index) {
this.isVisted = new boolean[length];
this.pre_visited = new int[length];
this.distance = new int[length];
this.isVisted[index] = true;
//初始化dis数组
Arrays.fill(distance, Dijkstra.N);
this.distance[index] = 0; //出发顶点的访问距离为0
}
//判断下标为index顶点是否访问过
public boolean isVisted(int index) {
return isVisted[index];
}
//更新出发顶点到index顶点的距离
public void updateDistance(int index, int length) {
distance[index] = length;
}
//更新pre这个顶点为index的前驱顶点
/**
* @param pre 当前节点的前驱节点
* @param index 当前结点
*/
public void updatePre(int index, int pre) {
pre_visited[index] = pre;
}
//返回出发顶点到index顶点的距离
public int getDistance(int index) {
return distance[index];
}
//继续选择并选择新的访问顶点
public int getNextIndex() {
int min = Dijkstra.N, index = 0;
for (int i = 0; i < isVisted.length; i++) {
if (!isVisted[i] && distance[i] < min) {
min = distance[i];
index = i;
}
}
//更新index为已经访问过的
isVisted[index] = true;
return index;
}
public void show(){
//输出isVisited数组
System.out.println(Arrays.toString(isVisted));
//输出pre_visited数组
System.out.println(Arrays.toString(pre_visited));
//输出distance数组
System.out.println(Arrays.toString(distance));
}
}
2. 弗洛伊德( Floyd )算法
定义一个n阶方阵序列A (−1) , A(0), . . . , A (n−1) ,其中,
![](https://i-blog.csdnimg.cn/img_convert/fd181fb4e774481e41ac62327c97dd67.png)
式中,A(0) [i] [j] 是从顶点vi到vj、中间顶点的序号不大于k的最短路径的长度。Floyd算法是一个迭
代的过程,每迭代一次,在从vi到vj、的最短路径上就多考虑了一个顶点;经过n次迭代后,所得到
的A (n^−1) [i] [j] 就是vi 到v~j ~的最短路径长度,即方阵A (n−1)中就保存了任意一对顶点之间的最
短路径长度。
![](https://i-blog.csdnimg.cn/img_convert/abb1970a0e7db16b66682c8ab44626a6.png)
上图所示为带权有向图G及其邻接矩阵。算法执行过程的说明如下。
- 初始化:方阵A (−1) [i] [j] = arcs [i] [j] 。
- 第一轮:将v0作为中间顶点,对于所有顶点{ i , j } ,如果有A −1 [i] [j] > A−1 [i] [0] + A−1 [0] [j] ,则将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 。
- 第二轮:将v 1作为中间顶点,继续监测全部顶点对{ i , j } 。有A 0 [ 0] [2] > A0 [0] [1] + A0 [1] [ 2 ] = 10 ,更新后的方阵标记为A 1 。
- 第三轮:将v2 作为中间顶点,继续监测全部顶点对{ i , j } 。有A1 [1] [ 0 ] > A1 [1] [2] + A1 [2] [0 ] = 9 ,更新后的方阵标记为A2。此时A2中保存的就是任意顶点对的最短路径长度。
- 应用Floyd算法求所有顶点之间的最短路径长度的过程如下表所示。
![](https://i-blog.csdnimg.cn/img_convert/900cd42dc4c43accdc931cce911870db.png)
从这个表中,可以发下一些规律:
![](https://i-blog.csdnimg.cn/img_convert/6856f7eb0e4de1a07c7c6521c5d01ced.png)
可以看出,矩阵中,每一步中红线划掉的部分都不用考虑计算,只需要计算红线外的部分,节省了
计算量。
Floyd算法的时间复杂度为O (V3) 。不过由于其代码很紧凑,且并不包含 其他复杂的数据结构,因
此隐含的常数系数是很小的,即使对于中等规模的输入来说,它仍然是相当有效的。
Floyd算法允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路。Floyd 算法同样
适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。
也可以用单源最短路径算法来解决每对顶点之间的最短路径问题。轮流将每个顶点作为源点,并且
在所有边权值均非负时,运行一次 Dijkstra算法,其时间复杂度为O ( V3 ) ∗ V = O ( V 3 )。
![](https://i-blog.csdnimg.cn/img_convert/9fc4ba041da782b3885ee6ffc76daa87.png)
public class FloydWarshall {
public static void main(String[] args) {
int INF = Integer.MAX_VALUE; // 无限大值表示不可达路径
int n = 5; // 图中节点数量
int[][] graph = new int[n][n]; // 存储图的邻接矩阵
// 初始化图的邻接矩阵(这里只展示部分边)
for (int i = 0; i < n; i++) {
Arrays.fill(graph[i], INF);
}
graph[0][1] = 2;
graph[0][3] = 4;
graph[1][2] = 1;
graph[1][4] = 6;
graph[2][3] = 8;
graph[2][4] = 7;
graph[3][4] = 9;
floydWarshall(graph);
System.out.println("最小距离矩阵为:");
printMatrix(graph);
}
private static void floydWarshall(int[][] graph) {
int n = graph.length;
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
if (graph[i][k] != Integer.MAX_VALUE && graph[k][j] != Integer.MAX_VALUE) {
graph[i][j] = Math.min(graph[i][j], graph[i][k] + graph[k][j]);
}
}
}
}
private static void printMatrix(int[][] matrix) {
for (int[] row : matrix) {
for (int num : row) {
System.out.print(num + " ");
}
System.out.println();
}
}
}
八、拓扑排序
1. 定义
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶
点表示活动的网,我们称为AOV网( Activity On VertexNetwork)。
若用DAG图(有向无环图)表示一个工程,其顶点表示活动,用有向边< V i , V j > 表示活动Vi必
须先于活动Vj进行的这样一种关系。在AOV网中,活动Vi 是活动Vj的直接前驱,活动Vj 是活动Vi的
直接后继,这种前驱和后继关系具有传递性,且任何活动Vi不能以它自己作为自己的前驱或后继。
设G = ( V , E ) 是一个具有n个顶点的有向图,V中的顶点序列V1 , V2 , . . . Vn ,满足若从顶点Vi到
Vj有一条路径,则在顶点序列中顶点Vi必在顶点Vj之前。则我们称这样的顶点序列为一个拓扑序
列。
所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。每个AOV网都有一个或多个拓扑排序
序列。
2. 算法
对一个AOV网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:
- ① 从AOV网中选择一个没有前驱的顶点并输出。
- ② 从网中删除该顶点和所有以它为起点的有向边。
- ③ 重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是AOV网。
![](https://i-blog.csdnimg.cn/img_convert/948d921b7ccae7994e173a6fe5e090f2.png)
上图所示为拓扑排序过程的示例。每一轮选择一个入度为0的顶点并输出,然后删除该顶点和所有
以它为起点的有向边,最后得到拓扑排序的结果为{ 1 , 2 , 4 , 3 , 5 } 。
拓扑排序算法的实现如下:
import java.util.*;
public class TopologicalSort {
public static List<Integer> topologicalSort(int numCourses, int[][] prerequisites) {
// 初始化入度表和邻接表
int[] inDegree = new int[numCourses];
List<List<Integer>> adjacency = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
adjacency.add(new ArrayList<>());
}
for (int[] prerequisite : prerequisites) {
inDegree[prerequisite[0]]++;
adjacency.get(prerequisite[1]).add(prerequisite[0]);
}
// 构建入度为0的节点队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
// 遍历队列,删除该节点的出边,更新入度表
List<Integer> result = new ArrayList<>();
while (!queue.isEmpty()) {
int curr = queue.poll();
result.add(curr);
for (int next : adjacency.get(curr)) {
inDegree[next]--;
if (inDegree[next] == 0) {
queue.offer(next);
}
}
}
// 判断是否有环,若有环则无法进行拓扑排序
if (result.size() != numCourses) {
return new ArrayList<>();
}
return result;
}
}
由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O ( V + E ) 。
此外,利用深度优先遍历也可实现拓扑排序。
用拓扑排序算法处理AOV网时,应注意以下问题:
① 入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代
表的活动开始或继续。
② 若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性
有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序的结果是唯一的。
③ 由于AOV网中各顶点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编
号,生成AOV网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其
邻接矩阵是三角矩阵,则存在拓扑序列;反之则不一定成立。
九、关键路径
1. 定义
拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短
时间问题。
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如
完成活动所需的
时间),称之为用边表示活动的网络,简称AOE网。AOE网和AOV网都是有向无环图,不同之处在
于它们的边和顶点所代表的含义是不同的,AOE网中的边有权值;而AOV网中的边无权值,仅表
示顶点之间的前后关系。
AOE网具有以下两个性质:
- ① 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- ② 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
![](https://i-blog.csdnimg.cn/img_convert/aeffe2ebe58fab8282dd0ec3968f9e07.png)
如上图的AOE网,在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的
开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。我们把
路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,
在关键路径上的活动叫关键活动。
完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关
键活动影响了整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因
此,只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。
2. 算法
![](https://i-blog.csdnimg.cn/img_convert/a0d527ee7628d5d0c873716bb4121743.png)
在分析算法之前,需要了解几个重要的参数:
- 事件的最早发生时间ve:即顶点Vk 的最早发生时期。
- 事件的最晚发生时间vl:即顶点Vk 的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
- 活动的最早开始时间e:即弧ai 的最早发生时间。
- 活动的最晚开始时间l:即弧ai 的最晚发生时间,也就是不推迟工期的最晚开工时间。
- 一个活动ai 的最迟开始时间 (i) 和其最早开始时间e(i)的差额d(i) = l (i) − e (i) :它是指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动ai 可以拖延的时间。若一个活动的时间余量为零,则说明该活动必须要如期完成,否则就会拖延整个工程的进度,所以称l(i) − e (i) = 0 即l(i) = e(i) 的活动ai 是关键活动。
求关键路径的算法步骤如下:
- 从源点出发,令ve (源点) = 0, 按拓扑排序求其余顶点的最早发生时间ve( )。
- 从汇点出发,令vl( 汇点 ) = ve ( 汇点 ),按逆拓扑排序求其余顶点的最迟发生时间vl( )。
- 根据各顶点的ve()值求所有弧的最早开始时间e()。
- 根据各顶点的vl()值求所有弧的最迟开始时间l()。
- 求AOE网中所有活动的差额d(), 找出所有d()=0的活动构成关键路径。
![](https://i-blog.csdnimg.cn/img_convert/7b06110c04d96050cb03935ad8c8b714.png)
上图所示为求解关键路径的过程,简单说明如下:
- 求ve():初始ve(1)=0,在拓扑排序输出顶点的过程中,求得ve(2)=3ve(2)=3,ve(3)=2,ve(4)=max{ve(2)+2,ve(3)+4}=max{5,6}=6 ,ve(6)=max{ve(5)+1,ve(4)+0,ve(3)+3}=max{7,8,5}=8。
- 求vl():初始vl(6)=8,在逆拓扑排序出栈过程之中,求得vl(5)=7,vl(4)=6,vl(3)=min{vl(4)−4,vl(6)−3}=min{2,5}=2,vl(2)=min{vl(5)−3,vl(4)−2}=min{4,4}=4,vl(1)必然为0而无需再求。
- 弧的最早开始时间e()等于该弧的起点的顶点的ve(),求得结果如上表所示。
- 弧的最迟开始时间l(i)等于该弧的终点的顶点的vl()减去该弧持续的时间,求得结果如上表所示。
- 根据l(i)-e(i)=0l(i)−e(i)=0的关键活动,得到的关键路径为( v1 , v3 , v4 , v6 ) 。
对于关键路径,需要注意以下几点:
① 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活
动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动
就可能会变成非关键活动。
② 网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键活动速
度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期
的目的。
/// <summary>
/// 关键路径算法
/// </summary>
/// <param name="graph"></param>
private void CriticalPath<T>(GraphByAdjacencyList<T> graph)
{
// 通过拓扑排序计算事件最早发生时间
var topoStack = Topological2(graph, out int[] etv);
// 定义事件最晚发生时间并初始化为终点的最早发生时间
int[] ltv = new int[graph.Count];
for (int i = 0; i < graph.Count; i++)
{
ltv[i] = etv[graph.Count - 1];
}
// 求事件最晚发生时间
while (topoStack.Count > 0)
{
int nodeIndex = topoStack.Pop();
// 遍历邻接链表
var edge = graph.Nodes[nodeIndex].next;
while (edge != null)
{
// 如果(下一个事件的最晚发生时间 - 活动时间) < 当前记录的最晚发生时间
// 则意味着需要把工期提前
if (ltv[edge.index] - edge.weight < ltv[nodeIndex] )
{
ltv[nodeIndex] = ltv[edge.index] - edge.weight;
}
edge = edge.next;
}
}
for (int i = 0; i < graph.Count; i++)
{
// 遍历所有边
var edge = graph.Nodes[i].next;
while (edge != null)
{
// 最早开工时间 = 起始事件的最早发生时间
int ete = etv[i];
// 最晚开工时间 = 结束事件的最晚发生时间 - 活动时间
int lte = ltv[edge.index] - edge.weight;
// 最早开工时间 == 最晚开工时间,说明是关键活动
if (ete == lte)
{
// 打印路径
Console.Write($" {graph.Nodes[i].data}->{graph.Nodes[edge.index].data} ");
}
edge = edge.next;
}
}
}
十、知识小结
图是计算机科学中非常常用的一类数据结构,同时也是最复杂的数据结构了,对它的学习,涉及到
顺序表、链表、栈、队列、树等之前学的几乎所有数据结构,所以学习图之前要对这几种数据结构
都要有所了解才行。