目录
一、引言
二、基本概念
三、图的定义
四、图的基本概念和术语
1. 有向图
2. 无向图
3. 简单图
4. 多重图
5. 完全图(也称简单完全图)
6. 子图
7. 连通、连通图和连通分量
8. 强连通图、强连通分量
9. 生成树、生成森林
10. 顶点的度、入度和出度
11. 边的权和网
12. 稠密图、稀疏图
13. 路径、路径长度和回路
14. 简单路径、简单回路
15. 距离
16. 有向树
五、图的存储结构
1. 邻接矩阵
2. 邻接表
3. 十字链表
4. 邻接多重表
5. 边集数组
六、图的遍历
1. 深度优先遍历
1.1. DFS算法
1.2. DFS算法的性能分析
1.3. 深度优先的生成树和生成森林
2. 广度优先遍历
2.1. BFS算法
2.2. BFS算法性能分析
3. 图的遍历与图的连通性
3.1. 普里姆(Prim)算法
3.2. 克鲁斯卡尔(Kruskal)算法
4. 总结
七、最短路径
1. 迪杰斯特拉( Dijkstra )算法
2. 弗洛伊德( Floyd )算法
八、拓扑排序
1. 定义
2. 算法
九、关键路径
1. 定义
2. 算法
十、总结
一、引言
图(Graph)描述的是一些个体之间的关系。
与线性表和二叉树不同的是:这些个体之间既不是前驱后继的顺序关系,也不是祖先后代的层次关系,而是错综
复杂的网状关系。图也是数据结构中经常使用的一种结构,让我们来学习一下使用图的算法吧。
【知识框架】
二、基本概念
在线性表中, 数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继。
在树形结构中, 数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素相关,但
只能和上一层中一个元素相关。图是一种较线性表和树更加复杂的数据结构。在图形结构中,结点之间的关系可
以是任意的,图中任意两个数据元素之间都可能相关。
三、图的定义
图(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。
图(a)所示的有向图G1 可表示为
2. 无向图
若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w,v),因为(v,w)=(w,v), 其
中v,w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说边(v, w)和顶点v, w相关联。
图(b)所示的无向图G2可表示为
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为有向完全
图。
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)所示。
注意:弄清连通、连通图、连通分量的概念非常重要。首先要区分极大连通子图和极小连通子图,极大连通子图
是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要保持图连通又要使得边数最少
的子图。
8. 强连通图、强连通分量
在有向图中,若从顶点v到顶点w和从顶点w到项点v之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶
点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量,图G 1 的强连通分
量如下图所示。
注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考虑强连通性。
9. 生成树、生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n − 1条边。对生
成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。在非连通图中,连通分量
的生成树构成了非连通图的生成森林。图G2的一个生成树如下图所示。
注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连
通。
10. 顶点的度、入度和出度
图中每个顶点的度定义为以该项点为一个端点的边的数目。
对于无向图,顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
在具有n个顶点、e条边的无向图中
即无向图的全部顶点的度的和等于边数的2倍,因为每条边和两个顶点相关联。
对于有向图,顶点v的度分为入度和出度,入度是以顶点v为终点的有向边的数目,记为ID(v) ; 而出度是以顶点v为起
点的有向边的数目,记为OD(v)。顶点v的度等于其入度和出度之和,即TD(v) = ID (v) + OD (v) 。
在具有n个顶点、e条边的有向图中
即有向图的全部顶点的入度之和与出度之和相等,并且等于边数。这是因为每条有向边都有一个起点和终点。
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的方阵,定义为:
下图是一个无向图和它的邻接矩阵:
可以看出:
- 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是第i个顶点的度TD(vi) 。比如顶点v1 的度就是1 + 0 + 1 + 0 = 2。
求顶点vi的所有邻接点就是将矩阵中第i行元素扫描一遍, A[i] [j] 为 1就是邻接点。
下图是有向图和它的邻接矩阵:
可以
- 主对角线上数值依然为0。但因为是有向图,所以此矩阵并不对称。
- 有向图讲究入度与出度,顶点v1的入度为1,正好是第v1列各数之顶点v1 的出度为2,即第v 1v行的各数
- 与无向图同样的办法,判断顶点vi 到vj 是否存在弧,只需要查找矩阵中A [i] [j] 是否为1
对于带权图而言,若顶点vi和vj 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值
下图是有向网图和它的邻接矩阵:
通过以上对无向图、有向图和网的描述,可定义出邻接矩阵的存储结构:
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. 邻接表
当一个图为稀疏图时(边数相对顶点较少),使用邻接矩阵法显然要浪费大量的存储空间,如下图所示:
而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
所谓邻接表,是指对图G中的每个顶点v i建立一个单链表,第i个单链表中的结点表示依附于顶点vi 的边(对于有向
图则是以顶点v i为尾的弧),这个单链表就称为顶点v i的边表(对于有向图则称为出边表)。边表的头指针和顶点的
数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如下图所示。
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc) 构成,边表(邻接表)结点由邻接点域(adjvex)和指
向下一条邻接边的指针域(nextarc) 构成。
无向图的邻接表的实例如下图所示。
有向图的邻接表的实例如下图所示。
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个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)。
我们重新定义顶点表结点结构如下表所示。
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指向该顶点的出
边表中的第一个结点。重新定义的边表结点结构如下表所示。
其中tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink是指入边表指针域,
指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个
weight域来存储权值。
接下来通过一个例子详细介绍十字链表的结构。
如下图所示,顶点依然是存入一个一维数组{ V0, V1 , V2, V3} ,实线箭头指针的图示完全与的邻接表的结构相
同。就以顶点V0 来说,firstout 指向的是出边表中的第一个结点V3 。所以V0 边表结点的headvex = 3 ,而
tailvex就是当前顶点V0 的下标0,由于V0只有一个出边顶点,所以headlink和taillink都是空。
我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。对于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的时候录为nullSystem.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 ) 这条边,需要对邻接
表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。
重新定义的边表结点结构如下表所示。
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点ivex的下一条边,jlink 指向依附
顶点jvex的下一条边。这就是邻接多重表结构。
每个顶点也用一一个结点表示,它由如下所示的两个域组成。
其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图7所示,左图
告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。
我们开始连线,如图,首先连线的①②③④就是将顶点的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)组成,如下图所示。显然边集数组关注的是边的集合,在
边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而
不适合对顶点相关的操作。
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);}
}
以下面这个无向图为例
其深度优先遍历的结果为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)。 显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大
大提高。对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。
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);}
}
以下面这个无向图为例
其广度优先遍历的结果为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)无法访问到该连通分量的所有顶点。
如下图所示为有向图的非强连通分量。
最小生成树
一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的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条边。
通俗点说就是:从一个顶点出发,在保证不形成回路的前提下,每找到并添加一条最短的边,就把当前形成的连
通分量当做一个整体或者一个点看待,然后重复“找最短的边并添加”的操作。
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 ) } 。
额,不得不说这样理解起来有点抽象,为了能描述这个算法,我们先构造一个邻接矩阵,如下图的右图所示。
于是普里姆(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; // 第一个节点不需要特殊处理,直接标记为已访问且权值为0while (!isAllVisited(visited)) {int u = findMinKeyVertex(key, visited); // 选取权值最小的未访问节点uif (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中所
有顶点都在一个连通分量上。
算法思路:
我们可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想
法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边集数组结构。以下是edge
边集数组结构的定义代码:
我们将下面左图的邻接矩阵通过程序转化为右图的边集数组,并且对它们按权值从小到大排序。
于是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算法用于构建单源点的最短路径—,即图中某个点到任何其他点的距离都是最短的。例如,构建地图应用
时查找自己的坐标离某个地标的最短距离。可以用于有向图,但是不能存在负权值。
我们以上图为例,通俗点说,这个迪杰斯特拉(Dijkstra) 算法,它并不是一下子求出了v0到v8的最短路径,而是一
步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,
最终得到你要的结果。
Dijkstra算法设置一个集合S记录已求得的最短路径的顶点。
在构造的过程中还设置了个辅助数组:
dist[]:记录从源点v0到其他各顶点当前的最短路径长度,它的初态为:若从v0到vi;有弧,则dist[i]为弧上的权
值;否则置dist[i]为∞ ∞∞。
例如,对图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到其它顶点的距离,会动态更新,求的最短距离就会存放到dispublic 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) ,其中,
式中,A(0) [i] [j] 是从顶点vi到vj、中间顶点的序号不大于k的最短路径的长度。Floyd算法是一个迭代的过程,每
迭代一次,在从vi到vj、的最短路径上就多考虑了一个顶点;经过n次迭代后,所得到的A (n^−1) [i] [j] 就是vi 到
v~j ~的最短路径长度,即方阵A (n−1)中就保存了任意一对顶点之间的最短路径长度。
上图所示为带权有向图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算法求所有顶点之间的最短路径长度的过程如下表所示。
从这个表中,可以发下一些规律:
可以看出,矩阵中,每一步中红线划掉的部分都不用考虑计算,只需要计算红线外的部分,节省了计算量。
Floyd算法的时间复杂度为O (V3) 。不过由于其代码很紧凑,且并不包含 其他复杂的数据结构,因此隐含的常数
系数是很小的,即使对于中等规模的输入来说,它仍然是相当有效的。
Floyd算法允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路。Floyd 算法同样适用于带权无向
图,因为带权无向图可视为权值相同往返二重边的有向图。
也可以用单源最短路径算法来解决每对顶点之间的最短路径问题。轮流将每个顶点作为源点,并且在所有边权值
均非负时,运行一次 Dijkstra算法,其时间复杂度为O ( V3 ) ∗ V = O ( V 3 )。
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网。
上图所示为拓扑排序过程的示例。每一轮选择一个入度为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网具有以下两个性质:
- ① 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- ② 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
如上图的AOE网,在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;网中也仅
存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。我们把路径上各个活动所持续的时间之
和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。
完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关键活动影响了
整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因此,只要找到了关键活动,
就找到了关键路径,也就可以得出最短完成时间。
2. 算法
在分析算法之前,需要了解几个重要的参数:
- 事件的最早发生时间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的活动构成关键路径。
上图所示为求解关键路径的过程,简单说明如下:
- 求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;}}
}
十、总结
图是计算机科学中非常常用的一类数据结构,同时也是最复杂的数据结构了,对它的学习,涉及到顺序表、链
表、栈、队列、树等之前学的几乎所有数据结构,所以学习图之前要对这几种数据结构都要有所了解才行。