目录
图的基本概念
图是一种抽象的数据结构,用于表示对象之间的关系。图包含两个主要元素:顶点集合和边集合。下面对图的基本概念进行扩充和总结:
-
图的基本概念:
- 图的定义:图是由顶点集合及顶点间的关系组成的一种数据结构,通常表示为 G = (V, E),其中 V 是顶点集合,E 是边集合。
- 顶点集合:V = {x | x 属于某个数据对象集},是图中的所有顶点的集合,是有穷非空的。
- 边集合:E = {(x, y) | x, y 属于 V} 或者 E = {<x, y> | x, y 属于 V && Path(x, y)},是顶点间关系的有穷集合,也叫做边的集合。
- 有向图和无向图:有向图中,顶点对 <x, y> 是有序的,表示从顶点 x 到顶点 y 的有向边;无向图中,顶点对 (x, y) 是无序的,表示顶点 x 和顶点 y 之间有无方向的边。
-
顶点和边:
- 顶点:图中的结点称为顶点,用 v 表示,第 i 个顶点记作 vi。
- 边:图中的边表示顶点之间的关系,第 k 条边记作 ek,ek = (vi, vj) 或 <vi, vj>。
-
有向图和无向图的区别:
- 有向图:顶点对 <x, y> 是有序的,表示从顶点 x 到顶点 y 的有向边,<x, y> 和 <y, x> 是两条不同的边。比如下图G3和G4为有向图。
- 无向图:顶点对 (x, y) 是无序的,表示顶点 x 和顶点 y 之间有无方向的边,(x, y) 和 (y, x) 是同一条边。比如下图G1和G2为 无向图。
-
路径和通路:
- 路径:在有向图中,路径 Path<x, y> 表示从顶点 x 到顶点 y 的一条有方向的通路。
- 通路:在无向图中,通路 (x, y) 表示顶点 x 到顶点 y 的一条无方向的通路。
图是在计算机科学和其他领域中广泛应用的数据结构,用于建模各种关系和网络结构。有向图和无向图分别适用于不同的应用场景,如网络拓扑、社交网络等。深入理解图的概念和性质有助于更有效地解决与图相关的算法和问题。
-
完全图:
- 无向完全图:在有 n 个顶点的无向图中,若有 n * (n-1)/2 条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图。例如,上图 G1 就是一个无向完全图。
- 有向完全图:在有 n 个顶点的有向图中,若有 n * (n-1) 条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图。例如,上图 G4 就是一个有向完全图。
-
邻接顶点:
- 无向图中:若 (u, v) 是 E(G) 中的一条边,则称顶点 u 和 v 互为邻接顶点,边 (u, v) 依附于顶点 u 和 v。
- 有向图中:若 <u, v> 是 E(G) 中的一条边,则称顶点 u 邻接到 v,顶点 v 邻接自顶点 u,边 <u, v> 与顶点 u 和顶点 v 相关联。
-
顶点的度:
- 无向图中:顶点 v 的度是指与它相关联的边的条数,记作 deg(v)。在无向图中,顶点的度等于该顶点的入度和出度,即 deg(v) = indeg(v) = outdeg(v)。
- 有向图中:顶点 v 的度等于该顶点的入度与出度之和,其中顶点 v 的入度是以 v 为终点的有向边的条数,记作 indeg(v);顶点 v 的出度是以 v 为起始点的有向边的条数,记作 outdeg(v)。因此,deg(v) = indeg(v) + outdeg(v)。
-
路径和路径长度:
- 路径:在图 G = (V, E) 中,若从顶点 vi 出发有一组边使其可到达顶点 vj,则称顶点 vi 到顶点 vj 的顶点序列为从顶点 vi 到顶点 vj 的路径。
- 路径长度:对于不带权的图,路径长度是指该路径上的边的条数;对于带权的图,路径长度是指该路径上各个边权值的总和。路径长度可以用于衡量两个顶点之间的距离或代价。
简单路径与回路:
- 简单路径:若路径上各顶点 v1,v2,v3,…,vm 均不重复,则称这样的路径为简单路径。简单路径不包含重复的顶点,除了路径的第一个和最后一个顶点可以相同,当然,此时为回路。
- 回路或环:若路径上第一个顶点 v1 和最后一个顶点 vm 重合,则称这样的路径为回路或环。回路形成了一个闭合的循环。
子图:
- 定义:设图 G = {V, E} 和图 G1 = {V1,E1},若 V1 属于 V 且 E1 属于 E,则称 G1 是 G 的子图。
- 含义:子图是从原图中选取一部分顶点和边构成的图,其中选取的顶点和边仍然满足图的定义。子图可以是原图的一个部分或者整个图本身。
-
连通图:
- 定义:在无向图中,若从顶点 v1 到顶点 v2 有路径,则称顶点 v1 与顶点 v2 是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。
- 含义:连通图表示图中的任意两个顶点之间都存在路径,没有孤立的顶点。
-
强连通图:
- 定义:在有向图中,若在每一对顶点 vi 和 vj 之间都存在一条从 vi 到 vj 的路径,也存在一条从 vj 到 vi 的路径,则称此图是强连通图。
- 含义:强连通图要求有向图中的任意两个顶点之间都存在双向路径,保证了在有向图中的任意两个顶点之间都能实现双向的连通性。
-
生成树:
- 定义:一个连通图的最小连通子图称作该图的生成树。有 n 个顶点的连通图的生成树有 n 个顶点和 n-1 条边。
- 性质:生成树包含了原图的所有顶点,且通过连接这些顶点的边数最少。生成树是保持图连通的最小子图,没有回路。
注意:树是特殊的图,但是图不一定是树。
树是图的一种特殊情况,其中图是一个没有循环的无向图(无环图),而且任意两个节点之间只有一条简单路径。树具有层级结构,其中每个节点都有一个父节点(除了根节点),而根节点没有父节点。
然而,图则是一种更为一般化的数据结构,可以包含循环和任意形式的连接关系。在图中,节点之间的关系可以更加复杂,可能存在环路,多个连接等。图的遍历算法需要考虑这些复杂性,并采取适当的措施来处理环路,防止无限循环。
图的存储结构
由于图中包含节点和节点之间的边(即节点与节点之间的关系),因此,在图的存储中只需保存节点和边的关系。节点的存储相对简单,只需要一段连续的空间即可。然而,边的关系又应该如何考虑呢?
邻接矩阵
因为节点与节点之间的关系就是连通与否,即为0或者1,因此邻接矩阵(二维数组)即是一种常用于表示图的二维数组结构,通过矩阵元素的取值来反映图中节点之间的连通关系。
-
表示关系:
- 邻接矩阵的基本思想是使用矩阵元素表示节点之间的关系,其中 1 表示两个节点之间有边,即连通;0 表示没有边。即不连通。
- 对于无向图,邻接矩阵是关于对角线对称的,即 matrix[i][j] 等于 matrix[j][i]。这是因为无向图的边没有方向性,从顶点 i 到顶点 j 的关系与从顶点 j 到顶点 i 是等价的。
-
度和邻接矩阵:
- 对于无向图,顶点 i 的度等于邻接矩阵第 i 行(或第 i 列)元素之和。这是因为每一条边都会在两个相关联的顶点的度中各贡献一次。
- 对于有向图,顶点 i 的出度是邻接矩阵第 i 行元素之和,入度是邻接矩阵第 i 列元素之和。因此,顶点 i 的度等于出度和入度之和。
-
带权图的邻接矩阵:
- 如果图的边带有权值,邻接矩阵中的元素可以表示边的权值。这使得邻接矩阵成为一种灵活的数据结构,适用于处理带权图的情况。
- 当两个顶点之间没有边时,可以用一个特定的值(通常是无穷大)表示。
-
空间效率和时间效率:
- 用邻接矩阵存储图的优点是能够在常数时间内快速知道两个顶点是否连通,适用于密集图或边比较多的情况。
- 缺点是在稀疏图中,大量的 0 会导致存储空间的浪费。此外,对于那些顶点比较多而边比较少的图,矩阵中存储了大量的 0,形成系数矩阵,相应地浪费了空间。
-
路径的表示和查找:
- 邻接矩阵可用于直观地表示图中的路径,其中路径上的元素值表示相应的边权值或连通关系。
- 通过邻接矩阵,可以在常数时间内判断两个节点之间是否存在路径。但对于找到具体的路径,可能需要遍历整个矩阵,时间效率相对较低。
优点:
-
快速的连通性判断:
- 邻接矩阵允许在常数时间内快速知道两个顶点是否连通,这对于很多图算法和操作是非常有利的。
-
简单直观:
- 邻接矩阵提供了一种直观的图表示方法,易于理解和实现。矩阵元素直接映射到图中的边,使得图的结构在矩阵中呈现得非常清晰。
-
适用于密集图:
- 邻接矩阵适用于表示密集图,其中大部分顶点之间都有边,因为矩阵中的非零元素直接对应于图中的边。
缺点:
-
空间浪费:
- 当图是稀疏图(边比较少)时,矩阵中存储了大量的 0,导致空间的浪费。这会占用大量的存储空间,特别是在顶点数量很大但边数量相对较少的情况下。
-
不适用于大规模图:
- 对于大规模图,邻接矩阵可能会导致存储和计算方面的性能问题。存储大规模的二维矩阵需要大量内存,并且在某些操作上可能变得非常耗时。
-
路径查找效率低:
- 当需要查找具体的路径时,邻接矩阵的效率较低。因为在稀疏图中,可能需要遍历大量的 0,而且对于大规模图,遍历整个矩阵的效率较低。
-
动态性差:
- 对于动态图,即图结构可能随时间变化的情况,邻接矩阵的修改操作可能相对复杂,因为需要调整矩阵的大小。
综合考虑这些优缺点,选择图的表示方法需要根据具体应用场景和图的性质,权衡空间和时间效率。在某些情况下,其他图表示方法,如邻接表,可能更为适用。
import java.util.LinkedList;
import java.util.Queue;
/**
* Created with IntelliJ IDEA.
* User: dora
* Description: 邻接矩阵表示图
*/
public class GraphByMatrix {
private char[] arrayV; // 存放顶点
private int[][] matrix; // 存放权值或者边
private boolean isDirect; // 是否是有向图
public GraphByMatrix(int size, boolean isDirect) {
arrayV = new char[size];
matrix = new int[size][size];
/* for(int i =0;i<size;i++){
Arrays.fill(matrix[i],Integer.MAX_VALUE);
}
*/
this.isDirect = isDirect;
}
/**
* 初始化顶点数组
* @param array 顶点集合
*/
public void initArrayV(char[] array) {
for (int i = 0; i < array.length; i++) {
arrayV[i] = array[i];
}
}
/**
* 添加两个顶点之间的边及权重
* @param v1 顶点1
* @param v2 顶点2
* @param weight 权重
*/
public void addEdge(char v1, char v2, int weight) {
// 1、查找出两个顶点在顶点数组中的位置
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
if (!isDirect) {
// 无向图的话,两边都要设置,因为有向图每条边都是单独的
matrix[index2][index1] = weight;
}
}
/**
* 获取顶点元素在其数组中的下标
* @param v 顶点元素
* @return 顶点在数组中的下标,若不存在返回-1
* 这里可以使用Map做映射来取代线性搜索
*/
public int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
/**
* 获取顶点的度
* @param v 顶点
* @return 顶点的度
*/
public int getDegreeOfV(char v) {
// 1、在顶点数组arrayV中找到顶点的下标
int indexV = getIndexOfV(v);
int count = 0;
// 2、无向图只需要计算出度
for (int i = 0; i < arrayV.length; i++) {
if (matrix[indexV][i] != 0) {
count++;
}
}
// 3、如果是有向图,有向图的度 = 入度 + 出度
if (isDirect) {
for (int i = 0; i < arrayV.length; i++) {
// 此下标是有向图的入度
if (matrix[i][indexV] != 0) {
count++;
}
}
}
// 4、此时的count中存储的就是顶点V的度
return count;
}
/**
* 打印图的顶点和邻接矩阵
*/
public void printGraph() {
for (int i = 0; i < arrayV.length; i++) {
System.out.print(arrayV[i] + " ");
}
System.out.println();
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[i].length; j++) {
System.out.print(matrix[i][j] + " ");
}
System.out.println();
}
}
public static void main(String[] args) {
GraphByMatrix graph = new GraphByMatrix(4, false);
char[] array = {'A', 'B', 'C', 'D'};
graph.initArrayV(array);
graph.addEdge('A', 'B', 1);
graph.addEdge('A', 'D', 1);
graph.addEdge('B', 'A', 1);
graph.addEdge('B', 'C', 1);
graph.addEdge('C', 'B', 1);
graph.addEdge('C', 'D', 1);
graph.addEdge('D', 'A', 1);
graph.addEdge('D', 'C', 1);
graph.printGraph();
}
}
/*
测试用例:
"ABCDE"
'A', 'D' , 10
'A', 'E' , 20
'B', 'C' , 10
'B', 'D' , 20
'B', 'E' , 30
'C', 'E' , 40
*/
邻接表
邻接表是一种图的表示方法,它使用数组来表示顶点的集合,而使用链表来表示边的关系。在邻接表中,每个顶点对应一个链表,链表中存储了与该顶点相邻的顶点信息。
1. 无向图邻接表存储
在无向图中,每条边都以两个顶点的形式出现在邻接表中。例如,如果图中有一条边 (vi, vj),那么在顶点vi的邻接表中会有一个指向vj的结点,同时在顶点vj的邻接表中也会有一个指向vi的结点。
注意:
- 无向图中同一条边在邻接表中会出现两次,因为每个顶点的邻接表都记录了与它相邻的顶点。
- 若要知道顶点vi的度(即相邻的顶点数),只需要查看vi对应的邻接表中的结点数量即可。
2. 有向图邻接表存储
在有向图中,每条边在邻接表中只出现一次。如果有一条有向边 (vi, vj),那么在顶点vi的邻接表中会有一个指向vj的结点。为了获取顶点的出度和入度信息,需要检查其他所有顶点的邻接表。
注意:
- 有向图中每条边只在源顶点的邻接表中出现,因此顶点vi的出度(出边的数量)可以通过检查vi的邻接表结点数得到。
- 获取vi的入度(入边的数量)需要检查其他所有顶点对应的邻接表,看有多少边的目标顶点是vi。
补充总结
- 邻接表通过链表的方式更灵活地表示图的结构,特别适用于稀疏图。
- 对于无向图,邻接表中每条边都在两个顶点的邻接表中出现,而对于有向图,每条边只在源顶点的邻接表中出现。
- 通过邻接表,可以轻松获取顶点的度、入度和出度信息。
- 邻接表的空间复杂度相对较低,适用于存储稀疏图。
import java.util.ArrayList;
/**
* Created with IntelliJ IDEA.
* User: 12629
* Description: 邻接表表示图
*/
public class Graph {
// 定义节点类,用于表示邻接表中的边
static class Node {
public int src; // 起点下标
public int dest; // 终点下标
public int weight; // 权重
public Node next; // 指向下一个节点
public Node(int src, int dest, int weight) {
this.src = src;
this.dest = dest;
this.weight = weight;
}
}
private ArrayList<Node> edgeList; // 存储邻接表的数组
private char[] arrayV; // 存放顶点
private boolean isDirect; // 是否是有向图
public Graph(int size, boolean isDirect) {
arrayV = new char[size];
edgeList = new ArrayList<>(size);
// 初始化每个节点为null
for (int i = 0; i < size; i++) {
edgeList.add(null);
}
this.isDirect = isDirect;
}
/**
* 初始化顶点数组的数据
* @param array 顶点集合
*/
public void initArrayV(char[] array) {
for (int i = 0; i < array.length; i++) {
arrayV[i] = array[i];
}
}
/**
* 获取顶点元素在其数组中的下标
* @param v 顶点元素
* @return 顶点在数组中的下标,若不存在返回-1
*/
public int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
/**
* 添加两个顶点之间的边及权重
* @param v1 顶点1
* @param v2 顶点2
* @param weight 权重
*/
public void addEdge(char v1, char v2, int weight) {
// 1、查找出两个顶点在顶点数组中的位置
int src = getIndexOfV(v1);
int dest = getIndexOfV(v2);
addEdgeChild(src, dest, weight);
// 对于无向图,需要在两个顶点的邻接表中都添加相应的边
if (!isDirect) {
addEdgeChild(dest, src, weight);
}
}
// 添加边的具体实现
private void addEdgeChild(int src, int dest, int weight) {
Node cur = edgeList.get(src);
while (cur != null) {
// 说明这个目标顶点已经在当前的链表中
if (cur.dest == dest) {
return;
}
cur = cur.next;
}
// 如果不在的话,重新生成节点,头插法插入
Node node = new Node(src, dest, weight);
node.next = edgeList.get(src);
edgeList.set(src, node);
}
/**
* 获取顶点的度
* 顶点v的度是指与它相关联的边的条数
* @param v 顶点
* @return 顶点的度
*/
public int getDegreeOfV(char v) {
// 1、现在顶点数组 arrayV 中找到顶点的下标
int indexV = getIndexOfV(v);
Node cur = edgeList.get(indexV);
int count = 0;
// 2、遍历链表的节点个数
while (cur != null) {
count++;
cur = cur.next;
}
// 3、如果是有向图,检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i
if (isDirect) {
int dest = indexV;
// 遍历顶点数组的每个元素
for (int src = 0; src < arrayV.length; src++) {
if (src == dest) {
continue; // 跳过当前自己的顶点链表
} else {
Node pCur = edgeList.get(src);
while (pCur != null) {
// 目的地是当前顶点的,也就是入度
if (pCur.dest == dest) {
count++;
}
pCur = pCur.next;
}
}
}
}
return count;
}
/**
* 打印图的邻接表
*/
public void printGraph() {
for (int i = 0; i < arrayV.length; i++) {
System.out.print(arrayV[i] + "-> ");
Node cur = edgeList.get(i);
while (cur != null) {
System.out.print(arrayV[cur.dest] + "(" + cur.weight + ")" + "-> ");
cur = cur.next;
}
System.out.println("null");
}
}
public static void main(String[] args) {
Graph graph = new Graph(3, true);
char[] array = {'A', 'B', 'C'};
graph.initArrayV(array);
graph.addEdge('A', 'B', 1);
graph.addEdge('B', 'A', 1);
graph.addEdge('B', 'C', 1);
graph.printGraph();
}
}
图的遍历
对于给定的图G和其中的任意一个顶点v0,从v0出发,沿着图中的各边访问所有顶点,且每个顶点仅被遍历一次的过程,通常被称为图的遍历。在图论中,有两种主要的图遍历方法,即深度优先搜索(Depth-First Search,DFS)和广度优先搜索(Breadth-First Search,BFS)。
现在来思考一下树是如何遍历的,并考虑是否可以直接应用相同的方法来遍历图。
-
树的遍历:
- 深度优先遍历(DFS):从根节点开始,沿着每个分支尽可能深地遍历,然后回溯到未探索的节点。DFS可以通过递归或使用栈来实现。
- 广度优先遍历(BFS):从根节点开始,按层次逐级遍历。BFS通常使用队列来实现。
-
图的遍历:
- 深度优先搜索(DFS):对于图,DFS同样是从起始顶点开始,沿着一条路径深入尽可能远,然后回溯并探索其他路径。DFS可以用递归或栈来实现。
- 广度优先搜索(BFS):在图中,BFS同样可以从起始顶点开始,逐层遍历顶点。BFS使用队列来实现。
因此,可以看到,树的遍历方法(DFS和BFS)是可以直接应用于图的遍历的。实际上,图可以被视为一种更一般化的数据结构,树只是图的一种特殊形式。在图的遍历中,DFS和BFS同样可以帮助我们访问图中的所有节点,并且可以在图中找到特定的路径或执行其他操作。
需要注意的是,在图的遍历过程中,为了确保每个顶点仅被访问一次,通常需要使用标记(visited)来跟踪已经访问过的顶点,以避免重复访问和可能的死循环。
图的广度优先遍历
广度优先搜索(Breadth-First Search,简称BFS)是一种图的遍历算法,主要用于图的搜索和最短路径等问题。BFS 从起始顶点开始,逐层遍历图的顶点,首先访问起始顶点,然后访问与起始顶点直接相邻的顶点,再访问与这些相邻顶点相邻的顶点,依此类推。
问题:如何防止节点被重复遍历?
在广度优先搜索(BFS)中,为了防止节点被重复遍历,需要使用一个数据结构来记录已经访问过的节点。我们通常可以使用一个布尔数组或集合来实现。
方法一:使用布尔数组
在BFS的实现中,可以使用一个布尔数组 visited 来记录每个顶点是否已经被访问过。在将顶点加入队列前,检查该顶点是否已经被访问,如果已访问则跳过。在访问过该顶点后,将其标记为已访问。
在广度优先搜索(BFS)算法中,标记顶点为已访问的时机是一个关键设计决策,它影响到算法的正确性和性能。让我们来探讨一下为什么通常选择在将顶点加入队列之前就将其标记为已访问,而不是在弹出队列的时候。
-
Correctness (正确性): BFS的核心思想是逐层遍历图的顶点。如果我们选择在弹出队列的时候才将顶点标记为已访问,那么在将其加入队列之前,其他顶点可能会再次将它加入队列。这可能导致对同一顶点多次访问,违反了BFS的逐层遍历原则。通过在加入队列前就标记为已访问,我们需要避免这种情况。
-
避免重复加入队列: 具体来说,如果在弹出队列的时候才将顶点标记为已访问,那么在某个顶点第一次弹出队列时,它的所有相邻顶点都会被加入队列,但它们的访问状态尚未改变。这可能导致相邻顶点在后续的遍历中再次被加入队列,造成冗余的操作。
-
提高性能: 在BFS中,我们希望避免重复的工作,以提高算法的性能。所以如果我们在加入队列之前就标记元素为已访问,可以减少后续的冗余操作,从而提高性能。
public void BFS(int startVertex) {
boolean[] visited = new boolean[vertexCount];
Queue<Integer> queue = new LinkedList<>();
visited[startVertex] = true;
queue.add(startVertex);
while (!queue.isEmpty()) {
int currentVertex = queue.poll();
System.out.print(currentVertex + " ");
for (int adjacentVertex : adjList[currentVertex]) {
if (!visited[adjacentVertex]) {
visited[adjacentVertex] = true;
queue.add(adjacentVertex);
}
}
}
}
方法二:使用集合
使用一个集合(例如 HashSet)来记录已经访问的节点,初始时将起始节点加入集合,然后在访问相邻节点时检查集合中是否已经存在,如果不存在则加入集合。
public void BFS(int startVertex) {
Set<Integer> visitedSet = new HashSet<>();
Queue<Integer> queue = new LinkedList<>();
visitedSet.add(startVertex);
queue.add(startVertex);
while (!queue.isEmpty()) {
int currentVertex = queue.poll();
System.out.print(currentVertex + " ");
for (int adjacentVertex : adjList[currentVertex]) {
if (!visitedSet.contains(adjacentVertex)) {
visitedSet.add(adjacentVertex);
queue.add(adjacentVertex);
}
}
}
}
BFS的具体步骤:
-
选择起始顶点: 选择图中的一个顶点作为起始顶点,将其标记为已访问。
-
将起始顶点入队: 将起始顶点加入队列,作为待访问的节点。
-
循环遍历:
- 从队列中取出一个顶点,访问该顶点。
- 遍历与当前顶点相邻且尚未访问的顶点,将它们标记为已访问,并入队。
- 重复以上步骤,直到队列为空。
-
处理未访问的连通分量: 如果图中存在未访问的顶点,选择一个未访问的顶点作为新的起始顶点,重复上述步骤。
BFS的核心思想是从起始顶点开始逐层遍历,确保对每一层的顶点进行访问,直到图中所有可达的顶点都被访问为止。这样可以保证找到起始顶点到其他顶点的最短路径。
import java.util.LinkedList;
import java.util.Queue;
/**
* 广度优先遍历
*/
public class GraphByMatrix2 {
private boolean isDirect;
private char[] arrayV; // 存放顶点
private int[][] matrix; // 存放权值或者边
private boolean[] visited; // 顶点是否被访问过的标记数组
private Queue<Integer> qu; // 辅助队列
public GraphByMatrix2(int size, boolean isDirect) {
arrayV = new char[size];
matrix = new int[size][size];
visited = new boolean[size];
qu = new LinkedList<>();
}
/**
* 初始化顶点数组的数据
*
* @param array 顶点集合
*/
public void initArrayV(char[] array) {
for (int i = 0; i < array.length; i++) {
arrayV[i] = array[i];
}
}
/**
* 给两个顶点的边上,添加权重
*
* @param v1 起点
* @param v2 终点
* @param weight 权重
*/
public void addEdge(char v1, char v2, int weight) {
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
if (!isDirect) {
matrix[index2][index1] = weight;
}
}
/**
* 获取顶点元素在其数组中的下标
*
* @param v 顶点元素
* @return 顶点下标
*/
public int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
/**
* 广度优先遍历
*
* @param startVertex 起始顶点
*/
public void bfs(char startVertex) {
// 1、初始化布尔数组,默认所有顶点都没有被遍历到
for (int i = 0; i < arrayV.length; i++) {
visited[i] = false;
}
// 2、找到起点在顶点数组中的下标
int startIndex = getIndexOfV(startVertex);
// 3、将起点放入队列
qu.offer(startIndex);
// 4、循环遍历队列
while (!qu.isEmpty()) {
// 5、取出当前队列的头部
int top = qu.poll();
System.out.print(arrayV[top] + ":" + top);
// 判断是否是最后一个邻接顶点
if (!qu.isEmpty()) {
System.out.print("->");
}
visited[top] = true;
// 6、将当前顶点的所有相邻顶点放入队列
for (int i = 0; i < arrayV.length; i++) {
if (matrix[top][i] != 0 && !visited[i]) {
qu.offer(i);
// 注意这里,防止重复打印。只要入队列,就置为已访问
visited[i] = true;
}
}
}
}
public static void main(String[] args) {
GraphByMatrix2 graph = new GraphByMatrix2(4, true);
char[] array = {'A', 'B', 'C', 'D'};
graph.initArrayV(array);
graph.addEdge('A', 'B', 1);
graph.addEdge('A', 'D', 1);
graph.addEdge('B', 'A', 1);
graph.addEdge('B', 'C', 1);
graph.addEdge('C', 'B', 1);
graph.addEdge('C', 'D', 1);
graph.addEdge('D', 'A', 1);
graph.addEdge('D', 'C', 1);
graph.bfs('B');
}
}
图的深度优先遍历
深度优先遍历(Depth-First Search,DFS)是一种用于图的遍历和搜索的算法。其核心思想是从起始顶点开始,沿着一条路径一直深入访问,直到不能再继续为止,然后回溯到上一个节点,继续探索其他路径。该算法常用于解决图的搜索问题,如寻找路径、连通性等。
概念和核心思想:
-
概念: 深度优先遍历是一种图遍历算法,用于访问图中所有的顶点,并且保证每个顶点都会被访问到,且每条边都会被探索到。这个过程通常采用递归或者栈的方式实现。
-
核心思想: 从图的某个顶点出发,沿着一条路径尽可能深入,直到到达最深的节点,然后回溯到上一个节点,继续深入其他路径,直到所有的顶点都被访问。
具体步骤:
-
初始化: 创建一个布尔类型的数组,用于标记顶点是否被访问过,初始状态下所有标记为未访问。选择一个起始顶点开始遍历。
-
访问节点: 访问当前顶点,标记为已访问。
-
递归或栈: 针对当前顶点,递归访问其相邻未被访问的顶点。如果采用栈实现,将当前顶点入栈。
-
回溯: 如果当前顶点的所有相邻顶点都已被访问,回溯到上一个顶点,继续递归或出栈。
-
终止条件: 重复步骤3和步骤4,直到所有顶点都被访问。
/**
* 深度优先遍历
* @param v 起始顶点
*/
public void dfs(char v) {
// 1、初始化一个布尔类型数组,默认所有顶点都没有被遍历到
boolean[] visited = new boolean[arrayV.length];
// 2、获取当前开始的顶点 v 的下标
int index = getIndexOfV(v);
// 3、开始从 index 位置进行深度遍历
dfsChild(index, visited);
//System.out.println();
}
/**
* 从 index 位置开始深度优先遍历
* @param index 当前遍历的顶点下标
* @param visited 记录顶点是否被访问过的数组
*/
private void dfsChild(int index, boolean[] visited) {
System.out.print(arrayV[index] + ":" + index + "->");
visited[index] = true;
// 当前 index 位置的,所有的连接点都在这一行
for (int i = 0; i < arrayV.length; i++) {
// 如果这一行的 i 下标不等于 0,并且也没有被访问过
if (matrix[index][i] != 0 && !visited[i]) {
dfsChild(i, visited);
}
}
}
实现的步骤说明:
-
初始化: 创建一个布尔类型的数组 visited,用于记录每个顶点的访问状态,初始值为 false。
-
获取起始顶点的下标: 根据给定的起始顶点,找到其在顶点数组中的下标。
-
深度优先遍历函数: 创建一个递归函数 dfsChild,以当前顶点为起点,递归访问其相邻未被访问的顶点。
-
标记访问: 在访问顶点时,将其标记为已访问。
-
递归调用: 对于当前顶点的每个相邻未被访问的顶点,递归调用 dfsChild。
-
回溯: 当前顶点的所有相邻顶点都被访问后,递归返回上一层,继续访问其他相邻顶点。
-
调用深度优先遍历: 在主程序中,调用深度优先遍历函数,并传入起始顶点。
以上步骤就是深度优先遍历的实现过程。这种遍历方式的特点是尽可能深入地访问图的分支,直到无法再深入为止,然后回溯到上一个分支,继续遍历其他分支。
最小生成树
ChatGPT
ChatGPT
最小生成树(Minimum Spanning Tree,MST)是连通图中的一棵极大无环子图,即从其中删去任何一条边,生成树就不再连通;反之,引入任何一条新边,都会形成回路。如果连通图有n个顶点,其生成树必含n个顶点和n-1条边。构造最小生成树的准则包括三条:
-
只能使用图中的边来构造最小生成树: 最小生成树只包含原图的边,且是原图中边的一个子集。
-
只能使用恰好n-1条边来连接图中的n个顶点: 最小生成树是连通图,必须通过n-1条边连接所有n个顶点,保证生成树的连通性。
-
选用的n-1条边不能构成回路: 为了保证生成树无环,所选用的边必须构成一棵树,即不能形成回路。
构造最小生成树的方法:主要有Kruskal算法和Prim算法。这两个算法都采用了贪心策略,即逐步求解的方法,每一步都选择当前看起来最好的局部解。虽然贪心算法不一定能得到整体最优解,但在构造最小生成树的问题上,Kruskal和Prim算法都能够提供最优解。
贪心算法:贪心算法是一种解决问题的方法,其核心思想是在每一步选择中都做出当前看起来最好的选择,而不考虑全局的最优解。在最小生成树问题中,贪心算法通过局部最优选择来逐步构造整个最小生成树。
-
初始化: 将图中的所有边按照权重从小到大排序。
-
选择边: 从排序后的边中选择当前权重最小的边,如果该边不形成回路,则加入最小生成树的集合。
-
更新集合: 更新当前集合,将新加入的边的两个顶点合并为一个集合,形成一个更大的子树。
-
重复: 重复步骤2和步骤3,直到最小生成树的边数达到n-1,其中n为顶点的个数。
尽管贪心算法可能无法保证对所有问题都能得到整体最优解,但在最小生成树问题中,它确实提供了一种高效且有效的求解方法。Kruskal算法和Prim算法都属于贪心算法的范畴,它们通过逐步选择边来构造最小生成树,满足生成树的连通性和无环性。
Kruskal算法:
概念解释:
Kruskal算法是解决连通图最小生成树问题的一种基于贪心策略的算法。最小生成树是原图的一个子图,包含了图中所有的顶点,并且是一棵树,使得所有边的权重之和最小。
最小生成树的概念:
- 生成树: 一个连通图的生成树是指包含图中所有顶点的树。
- 最小生成树: 在一个带权连通图中,所有生成树中,边的权重之和最小的生成树称为最小生成树。
Kruskal算法通过贪心策略逐步选取权重最小的边,构建生成树的过程中保证不形成回路,最终得到的生成树即为最小生成树。
核心思想:
Kruskal算法的核心思想是始终选择权重最小的边,但在加入边的过程中要确保不会形成回路。通过不断选择权重最小的边并添加到生成树中,最终得到的生成树即为最小生成树。
算法详细步骤:
-
初始化: 给定一个有n个顶点的连通网络N={V,E},构造一个由n个顶点组成、不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量。
-
边排序: 将图的所有边按照权重从小到大进行排序。
-
迭代选择: 从排好序的边集中选择权重最小的边,若该边的两个顶点来自不同的连通分量,则将此边加入到生成树中。
-
更新连通分量: 将被选中的边连接的两个顶点合并为一个连通分量。
-
重复操作: 重复步骤3和步骤4,直到所有顶点在同一个连通分量上为止。
示例:
考虑一个有n个顶点和m条边的图,边的权重分别为w1, w2, ..., wm。Kruskal算法会按照权重从小到大的顺序选择边,确保加入的边不形成回路,最终得到最小生成树。
但是我们怎么快速确定新加入的边会不会形成回路呢?
通过我们之前学习的并查集,可以快速解决战斗!
并查集是一种用于处理集合合并与查找问题的数据结构。
在Kruskal算法中,每个顶点最初被视为一个单独的集合,而每条边的加入就是在合并两个集合。当我们考虑添加一条边时,我们检查这条边连接的两个顶点是否属于同一个集合。如果不属于同一个集合,那么将这两个集合合并,否则添加这条边将形成回路。
使用并查集来实现Kruskal算法的一般步骤:
- 初始化并查集,使每个顶点都是一个单独的集合。
- 对所有边按权重从小到大进行排序。
- 遍历排序后的边,对于每条边,检查它连接的两个顶点是否属于同一个集合。
- 如果不属于同一个集合,则将这两个集合合并,并将这条边加入最小生成树。
- 如果属于同一个集合,则忽略这条边,以防形成回路。
- 重复步骤3,直到最小生成树的边数达到 n-1(n 是顶点数)。
那么我们又如何快速找到当前最小的边?
简单,优先级队列。
使用小根堆(Min Heap)来实现优先级队列是一种非常有效的方法。小根堆确保每次弹出的元素都是具有最小权重的边,这正符合Kruskal算法中按照权重从小到大选择边的要求。
代码实现:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.PriorityQueue;
// 边的类,用于表示图中的边
class Edge {
int srcIndex;
int destIndex;
int weight;
public Edge(int srcIndex, int destIndex, int weight) {
this.srcIndex = srcIndex;
this.destIndex = destIndex;
this.weight = weight;
}
}
// 并查集类,用于判断两个顶点是否在同一个集合中
class UnionFindSet {
private int[] parent;
public UnionFindSet(int size) {
parent = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
}
}
public int findSet(int x) {
if (x != parent[x]) {
parent[x] = findSet(parent[x]); // 路径压缩
}
return parent[x];
}
public void unionSet(int x, int y) {
int rootX = findSet(x);
int rootY = findSet(y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
public boolean isSameSet(int x, int y) {
return findSet(x) == findSet(y);
}
}
public class GraphByMatrix {
private char[] arrayV;
private int[][] matrix;
public GraphByMatrix(int size, boolean isDirect) {
arrayV = new char[size];
matrix = new int[size][size];
}
// 初始化顶点数组
public void initArrayV(char[] array) {
for (int i = 0; i < array.length; i++) {
arrayV[i] = array[i];
}
}
// 添加带权重的边
public void addEdge(char v1, char v2, int weight) {
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
matrix[index2][index1] = weight;
}
// 获取顶点在数组中的下标
public int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
// 打印图的邻接矩阵表示
public void printGraph() {
for (int i = 0; i < arrayV.length; i++) {
System.out.print(arrayV[i] + "-> ");
for (int j = 0; j < arrayV.length; j++) {
System.out.print(matrix[i][j] + "-> ");
}
System.out.println("null");
}
}
// Kruskal算法实现,返回最小生成树的总权重
public int kruskal(GraphByMatrix minTree) {
// 优先级队列,用于存储边,构建小根堆,按权重比较
PriorityQueue<Edge> minHeap = new PriorityQueue<>(Comparator.comparingInt(o -> o.weight));
int n = matrix.length;
// 将图中的边加入优先级队列
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i < j && matrix[i][j] != 0) {
minHeap.offer(new Edge(i, j, matrix[i][j]));
}
}
}
int totalWeight = 0;
UnionFindSet ufs = new UnionFindSet(n);
int size = 0;
// 选取n-1条边
while (size < n - 1 && !minHeap.isEmpty()) {
Edge min = minHeap.poll();
int srcIndex = min.srcIndex;
int destIndex = min.destIndex;
// 判断是否在同一个集合中,不在同一个集合才能添加
if (!ufs.isSameSet(srcIndex, destIndex)) {
System.out.println(arrayV[srcIndex] + "->" + arrayV[destIndex] + " : " + matrix[srcIndex][destIndex]);
minTree.addEdgeUseIndex(srcIndex, destIndex, min.weight);
totalWeight += min.weight;
ufs.unionSet(srcIndex, destIndex);
size++;
}
}
// 如果选出n-1条边,返回总权重,否则说明不是连通图
if (size == n - 1) {
return totalWeight;
} else {
return -1;
}
}
// 根据下标添加带权重的边
public void addEdgeUseIndex(int index1, int index2, int weight) {
matrix[index1][index2] = weight;
matrix[index2][index1] = weight;
}
// 测试最小生成树的方法
public static void testGraphMinTree() {
String str = "abcdefghi";
char[] array = str.toCharArray();
GraphByMatrix g = new GraphByMatrix(str.length(), false);
g.initArrayV(array);
g.addEdge('a', 'b', 4);
g.addEdge('a', 'h', 8);
//g.addEdge('a', 'h', 9);
g.addEdge('b', 'c', 8);
g.addEdge('b', 'h', 11);
g.addEdge('c', 'i', 2);
g.addEdge('c', 'f', 4);
g.addEdge('c', 'd', 7);
g.addEdge('d', 'f', 14);
g.addEdge('d', 'e', 9);
g.addEdge('e', 'f', 10);
g.addEdge('f', 'g', 2);
g.addEdge('g', 'h', 1);
g.addEdge('g', 'i', 6);
g.addEdge('h', 'i', 7);
GraphByMatrix kminTree = new GraphByMatrix(str.length(), false);
System.out.println(g.kruskal(kminTree));
kminTree.printGraph();
}
}
性能:
Kruskal算法的性能主要取决于边排序的时间复杂度和查并集操作的效率。
-
边排序: Kruskal算法需要对所有边进行排序,这通常是算法的主要时间开销。排序的时间复杂度为O(m log m),其中m是边的数量。
-
查并集操作: 在Kruskal算法中,需要使用查并集(Union-Find)数据结构来判断两个顶点是否在同一连通分量中,以防止形成环。查并集操作的实现方式会影响算法的性能,而一些高效的查并集实现可以提高算法的效率。
-
边的稀疏性: Kruskal算法对于边的稀疏图性能更好。在边稀疏的情况下,排序的开销相对较小,而且算法在选择边时不受图的稠密性影响。
-
优化措施: 使用一些优化措施,例如路径压缩和按秩合并等,可以提高查并集操作的效率,从而改善Kruskal算法的性能。
总的来说,Kruskal算法的时间复杂度通常取决于边排序的时间复杂度,一般为O(m log m),其中m为边的数量。算法的优势在于其简单、直观的实现,适用于边稀疏的图。 Kruskal算法通常对边稀疏的图有较好的性能。通过选择适当的边排序算法、实现高效的查并集操作,并考虑图的特性,可以优化Kruskal算法的执行效率。
Prim算法
概念解释:
Prim算法是一种解决连通图最小生成树问题的贪心算法。最小生成树是原图的一个子图,包含了图中所有的顶点,并且是一棵树,使得所有边的权重之和最小。Prim算法通过逐步选择顶点来构建最小生成树。
核心思想:
Prim算法的核心思想是从一个初始顶点开始,每次选择一个与当前最小生成树相邻的顶点,将权重最小的边加入最小生成树,直到所有顶点都被包含在最小生成树中。在整个过程中,保持当前最小生成树的所有顶点都是连通的。Prim算法的每一步都选择当前最小生成树与其余顶点之间的最小权重边,逐步构建最小生成树。这个过程保证了每一步都是局部最优的,最终得到的最小生成树是全局最优的。
-
Prim算法中的边选择:
- 在Prim算法的执行过程中,每一步都是从已经选择的顶点集合到未选择的顶点中选择一条最小权重的边。这条边将一个未选择的顶点连接到已经选择的顶点集合中。
- 由于每一步都是通过选择连接已有树和未选择顶点的最小权重边,新加入的边不会形成环。这是因为Prim算法每次都是选择最小权重的边,而非随机选择。
-
无需专门考虑成环问题:
- 由于Prim算法每一步的选择都确保了不会形成环,所以无需在算法实现中专门考虑成环问题。
- 成环问题通常涉及到判断当前考虑的边是否会形成环,需要使用一些额外的数据结构(例如并查集)来判断。Kruskal算法就需要在每一步中判断加入的边是否形成环,因此需要处理成环问题。
-
简化实现:
- 由于Prim算法在每一步的选择中已经考虑了不形成环这一点,实现上更加简单。Prim算法通常可以通过优先队列(最小堆)等数据结构来高效地选择最小权重的边。
- 相对而言,Kruskal算法需要额外的成环判断,通常涉及更复杂的数据结构和算法。
算法具体详细步骤:
-
选择初始顶点: 任选图中一个顶点作为初始顶点,加入最小生成树。
-
更新顶点集合: 将初始顶点的所有邻接顶点加入到候选顶点集合。
-
选择最小边: 从候选顶点集合中选择一条权重最小的边,将其连接的顶点加入最小生成树,并从候选顶点集合中移除该顶点。
-
重复步骤2和步骤3: 不断重复步骤2和步骤3,直到最小生成树包含了图中的所有顶点。
//多一个起点指定参数
public int prim(GraphByMatrix minTree, char chV) {
// 1. 先获取当前顶点的下标
int srcI = getIndexOfV(chV);
// 2. 定义一个X集合,把当前的起点下标存进去
Set<Integer> setX = new HashSet<>();
setX.add(srcI);
// 3. 定义一个Y集合,存储目标顶点的元素
Set<Integer> setY = new HashSet<>();
// 4. 除了刚刚的起点,其他的顶点需要放到Y集合
int n = matrix.length;
for (int i = 0; i < n; i++) {
if (i != srcI) {
setY.add(i);
}
}
// 5. 从X集合中的点到Y集合的点中,连接的边中找出最小值 放到优先级队列
PriorityQueue<Edge> minHeap = new PriorityQueue<>(Comparator.comparingInt(o -> o.weight));
// 6. 把当前顶点连接出去的所有的边 放入队列
for (int i = 0; i < n; i++) {
if (matrix[srcI][i] != 0) {
minHeap.offer(new Edge(srcI, i, matrix[srcI][i]));
}
}
int total = 0;
int edgeCount = 0;
while (!minHeap.isEmpty()) {
// 7. 取出队列中的第一条边
Edge min = minHeap.poll();
int srcIndex = min.srcIndex;
int destIndex = min.destIndex;
/**
* 开始写的时候,不用写if,直接把选出来的边直接放入队列
*
* 14.添加边的时候,如果目标点也在我这个集合当中,则不能添加
* 所以得判断X集合 ,否则就是环。
*
* 起始点本身就在X集合,所以这里只需要判断目标点即可
*/
if (setX.contains(destIndex)) {
// System.out.println("构成环:");
// System.out.println(arrayV[srcIndex] + "-> " + arrayV[destIndex] + " : " + matrix[srcIndex][destIndex]);
} else {
// 8. 直接将该边 放入最小生成树
minTree.addEdgeUseIndex(srcIndex, destIndex, matrix[srcIndex][destIndex]);
// 9. 每选一条边 就打印一条语句
System.out.println(arrayV[srcIndex] + "-> " + arrayV[destIndex] + " : " + matrix[srcIndex][destIndex]);
edgeCount++; // 需要添加 n-1 条边
total += matrix[srcIndex][destIndex];
if (edgeCount == n - 1) {
break;
}
// 10.把这次的目标点,添加到X集合,变成了起点
setX.add(destIndex);
// 11.记得把之前的目标点,从Y集合删除掉
setY.remove(destIndex);
// 12. 遍历刚刚添加的新起点destIndex,连接出去的所有边,再次添加到优先级队列
for (int i = 0; i < n; i++) {
// 13. !setX.contains(i) 判断目标点不能再X这个集合 例如: a->b 就包含了b->a
if (matrix[destIndex][i] != 0 && !setX.contains(i)) {
minHeap.offer(new Edge(destIndex, i, matrix[destIndex][i]));
}
}
}
}
if (edgeCount == n - 1) {
return total;
} else {
return -1;
}
}
public static void testPrimGraphMinTree() {
String str = "abcdefghi";
char[] array = str.toCharArray();
GraphByMatrix g = new GraphByMatrix(str.length(), false);
g.initArrayV(array);
g.addEdge('a', 'b', 4);
g.addEdge('a', 'h', 8);
// g.addEdge('a', 'h', 9);
g.addEdge('b', 'c', 8);
g.addEdge('b', 'h', 11);
g.addEdge('c', 'i', 2);
g.addEdge('c', 'f', 4);
g.addEdge('c', 'd', 7);
g.addEdge('d', 'f', 14);
g.addEdge('d', 'e', 9);
g.addEdge('e', 'f', 10);
g.addEdge('f', 'g', 2);
g.addEdge('g', 'h', 1);
g.addEdge('g', 'i', 6);
g.addEdge('h', 'i', 7);
GraphByMatrix primTree = new GraphByMatrix(str.length(), false);
System.out.println(g.prim(primTree, 'a'));
primTree.printGraph();
}
性能:
Prim算法的性能主要取决于两个方面:优先队列的实现和图的特性。
-
优先队列的选择: Prim算法同样需要使用一个优先队列来维护当前顶点集合到剩余顶点的最小权重边。不同的优先队列实现有不同的时间复杂度。使用二叉堆、斐波那契堆等高效的数据结构可以降低算法的复杂度。
-
图的稠密性: 与Kruskal算法相似,Prim算法对于稀疏图的性能更好。在每一步中,Prim算法需要考虑的边的数量相对较少,因此对于边稀疏的图效果更佳。
-
优化措施: 通过使用一些优化措施,例如在优先队列中实现边的懒删除、使用斐波那契堆等,我们可以提高Prim算法的性能。
-
图的特性: 图的特性也会影响Prim算法的性能。如果图的结构符合二叉堆等数据结构的优势,那么Prim算法的表现将更好。
在一般情况下,Prim算法的时间复杂度可以表示为O((V + E) log V),其中V是顶点的数量,E是边的数量。这是因为在优先队列中进行的操作次数与顶点和边的数量相关。
综合而言,Prim算法通常表现良好,尤其在边稀疏的图中。通过选择适当的数据结构和实现优化,可以提高算法的性能。
最短路径
最短路径问题是图论中的经典问题之一,其主要目标是从带权图中的特定起点出发,寻找一条通往目标顶点的路径,使得沿途经过的边的权值总和最小。最短路径问题在实际应用中具有广泛的意义,例如在网络通信、交通规划和资源分配等领域中都有着重要的应用。
解决最短路径问题的算法有多种,其中最著名的包括迪杰斯特拉算法(Dijkstra's algorithm)和贝尔曼-福德算法(Bellman-Ford algorithm)。这些算法利用了图的拓扑结构和边的权值信息,通过不同的策略逐步确定路径的最短距离,并最终得到从起点到目标点的最短路径。
我们接下来就具体看看:
单源最短路径--Dijkstra算法
单源最短路径问题是图论中一个经典而重要的问题,它要求在给定的有向图G = (V, E)中,从一个特定的源结点s到图中每个结点v的最短路径。
Dijkstra算法被广泛应用于解决这类问题,特别是在带权重的有向图中。然而,需要注意的是,该算法要求图中所有边的权重必须是非负的。
Dijkstra算法的基本思想是通过逐步确定从源结点s到其他结点的最短路径,从而推导得到起点到终点的最短路径。实现这一过程的关键在于不断选择当前路径代价最小的结点进行更新。
算法开始时,将所有结点分为两个集合:S(已确定最短路径的结点集合)和Q(尚未确定最短路径的结点集合)。初始时,S为空集,而Q包含图中的所有结点。然后,从Q中选择代价最小的结点u,将其移出Q并加入S。接着,对u的每个相邻结点v进行松弛操作。松弛即对每一个相邻结点v ,判断源节点 s 到结点 u 的代价与 u 到 v 的代价之和是否比原来 s 到 v 的代 价更小,若代价比原来小则要将 s 到 v 的代价更新为 s 到 u 与 u 到 v 的代价之和,否则维持原样。如此一直循环直至集合 Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点,在算法循环后其代价仍为初始设定的值,不发生变化。
Dijkstra算法的贪心策略体现在每次选择代价最小的结点进行更新,这确保了每个结点的最短路径都是基于当前已知最短路径的最优选择。
需要强调的是,Dijkstra算法无法处理图中存在负权路径的情况。如果图中包含负权路径,该算法可能会产生不正确的结果,因为它不能适应负权的影响。在这种情况下,通常会考虑其他算法,如贝尔曼-福德算法,来处理图中可能存在负权路径的情况。因此,在选择最短路径算法时,我们需要根据实际情况考虑图的特性和算法的适用性。
Dijkstra算法无法处理图中存在负权路径的情况的原因:
Dijkstra算法无法处理图中存在负权路径的情况的主要原因在于其贪心的选择策略和局部最优选择。
具体来说,Dijkstra算法的基本思想是从起始顶点开始,逐步选择当前路径上权重最小的顶点,然后将该顶点纳入最短路径集合。在每一步中,Dijkstra算法都贪心地选择当前路径上权重最小的顶点,更新与该顶点相邻的其他顶点的最短路径。这个贪心策略在非负权图中是有效的,因为在非负权图中,每一步选择的最小权重路径是全局最优的。
在存在负权路径的图中,Dijkstra算法的贪心策略可能导致错误的结果。考虑一个简单的例子,图中存在一个负权边,而Dijkstra算法正在处理这个边的目标结点。由于贪心选择最小路径代价的策略,算法可能会错误地认为通过负权边到达目标结点的路径代价更小,从而更新目标结点的最短路径。然而,由于负权边的存在,实际上这条路径可能并不是最短路径。
负权路径的存在意味着通过某些路径可以获得更小的路径长度,但Dijkstra算法贪心地选择当前路径上权重最小的顶点,可能会错过这样的路径。如果某个负权路径的权重足够小,它有可能在经过多个正权路径后变得更短,但Dijkstra算法无法动态地适应这种情况。
举例来说,考虑一个简单的图,其中包含一个负权路径。如果Dijkstra算法已经确定了一个顶点的最短路径,然后遇到负权路径,它无法在之后的迭代中调整已经做出的选择,因为它不会考虑负权路径可能导致的最短路径更新。
因此,为了处理负权路径,通常需要使用其他算法,如Bellman-Ford算法,它能够处理负权边,并能够在算法执行过程中动态地适应最短路径的变化,还能检测负权回路。
总之,负权边的存在导致了Dijkstra算法的失效,因为它不能适应负权对最短路径的影响。相比之下,Bellman-Ford算法是一种更为通用的单源最短路径算法,它能够处理带有负权边的图。该算法通过对所有边进行松弛操作的有限次循环来逐步逼近最短路径,因此能够应对负权的情况,但其时间复杂度相对较高。
什么是松弛操作?
在图算法中,松弛操作是指通过比较当前已知的最短路径和新路径的权重,来更新顶点之间的距离信息。具体而言,松弛操作是用新路径的权重尝试减小从源点到目标顶点的距离。如果新路径的权重小于当前已知的最短路径,则更新最短路径和相关的信息。
在单源最短路径算法中,松弛操作是算法的核心步骤之一,它确保在每一次迭代中,都以最小的路径长度更新顶点的距离信息。
对于Dijkstra算法和Bellman-Ford算法,松弛操作的实现略有不同:
-
Dijkstra算法中的松弛操作:
- 对于顶点u和v,如果从源点s到u的距离加上u到v的边的权重小于从源点s到v的已知最短路径,就更新从源点s到v的最短路径和v的父顶点。这确保每次选择最小路径的顶点,并逐步确定最短路径。
-
Bellman-Ford算法中的松弛操作:
- 对于每一条边(u, v),如果通过u到v的路径的权重小于从源点s到v的已知最短路径,则更新从源点s到v的最短路径和v的父顶点。Bellman-Ford算法通过对所有边的松弛操作进行多次迭代,逐步逼近最短路径。
总体而言,松弛操作是图算法中一种重要的策略,用于在探索过程中不断更新最短路径信息,以找到从源点到其他顶点的最短路径。
public void dijkstra(char vSrc, int[] dist, int[] pPath) {
// 1. 获取顶点下标
int srcIndex = getIndexOfV(vSrc);
// 2. 初始化父顶点数组下标为-1
Arrays.fill(pPath, -1);
// 3. 初始化dist数组
Arrays.fill(dist, Integer.MAX_VALUE);
// 4. 对起点进行初始化,给一个最小值方便第一次就能找到最小值
dist[srcIndex] = 0;
// 起点的父顶点下标是自己
pPath[srcIndex] = srcIndex;
// 5. 定义s为已经确定的顶点数组的集合,默认为false
int n = arrayV.length;
boolean[] s = new boolean[n];
// 6. 更新N个顶点
for (int k = 0; k < n; k++) {
// 第1步:找到srcIndex到不在S中路径最短的那个顶点u
// 设最短路径为整数最大值
int min = Integer.MAX_VALUE;
// 顶点srcIndex开始找
int u = srcIndex;
for (int i = 0; i < n; i++) {
// 如果s集合中i的顶点没有 && 距离也是比最小值小
if (s[i] == false && dist[i] < min) {
// 更新此时的最小值
min = dist[i];
// 更新此时在U集合找到的顶点
u = i;
}
}
// 每遍历一遍n个顶点,找到最短的顶点U
s[u] = true;
/*
* 第2步: 松弛算法:更新一遍u连接的所有边,看是否能更新出更短连接路径 src-u知道了 u连接出去的点u->v 需要更新 就能知道src-v的值
*/
// 在邻接矩阵当中U连接出去的边可能有N个
for (int v = 0; v < n; v++) {
// v顶点不能被更新 并且 u->v 是有权值的 并且 加起来的权值 < dist[v]
if (s[v] == false && matrix[u][v] != Integer.MAX_VALUE
&& dist[u] + matrix[u][v] < dist[v]) {
// 更新当前v顶点对应下标
dist[v] = dist[u] + matrix[u][v];
pPath[v] = u;
}
}
}
}
public void printShortPath(char vSrc, int[] dist, int[] pPath) {
// 1. 获取顶点下标
int srcIndex = getIndexOfV(vSrc);
int n = arrayV.length;
// 2. 遍历pPath数组的n个值
// 每个值到起点S的路径都打印一遍
for (int i = 0; i < n; i++) {
// 自己到自己的路径不打印
if (i != srcIndex) {
ArrayList<Integer> path = new ArrayList<>();
int parentI = i;
while (parentI != srcIndex) {
path.add(parentI);
parentI = pPath[parentI];
}
path.add(srcIndex);
// 翻转path当中的路径
Collections.reverse(path);
for (int pos : path) {
System.out.print(arrayV[pos] + " -> ");
}
System.out.println(dist[i]);
}
}
}
public static void testGraphDijkstra() {
// 创建带权有向图
String str = "syztx";
char[] array = str.toCharArray();
GraphByMatrix g = new GraphByMatrix(str.length(), true);
g.initArrayV(array);
g.addEdge('s', 't', 10);
g.addEdge('s', 'y', 5);
g.addEdge('y', 't', 3);
g.addEdge('y', 'x', 9);
g.addEdge('y', 'z', 2);
g.addEdge('z', 's', 7);
g.addEdge('z', 'x', 6);
g.addEdge('t', 'y', 2);
g.addEdge('t', 'x', 1);
g.addEdge('x', 'z', 4);
// 运行Dijkstra算法并打印最短路径
int[] dist = new int[array.length];
int[] parentPath = new int[array.length];
g.dijkstra('s', dist, parentPath);
g.printShortPath('s', dist, parentPath);
}
public static void main(String[] args) {
testGraphDijkstra();
}
算法步骤如下:
-
初始化数据结构:
- 获取源顶点的下标。
- 初始化父顶点数组的下标为-1。
- 初始化距离数组为整数的最大值,表示初始时到各顶点的距离为无穷大。
- 将源顶点的距离设为0,因为从源顶点到自身的距离为0。
-
迭代更新距离:
- 定义一个布尔数组
s
表示已经确定最短路径的顶点集合,默认为false。 - 外层循环执行n次,其中n为图中顶点的数量。
- 内层循环用于找到在未确定最短路径的顶点集合中,距离源顶点最近的顶点u。
- 将u加入已确定最短路径的集合 s。
- 对于每一个与u相邻的顶点v,通过松弛操作更新从源顶点到v的距离和父顶点。
- 定义一个布尔数组
正如我们之前讲到的,Dijkstra算法无法处理图中存在负权路径的情况。贝尔曼-福德算法却能够处理带有负权边的图,所以我们选择来学学贝尔曼-福德算法。
单源最短路径--Bellman-Ford算法
单源最短路径问题是图论中一个经典而重要的问题,而Bellman-Ford算法则是一种能够处理带有负权边的图的解决方案。相较于Dijkstra算法,Bellman-Ford算法的优势在于它能够应对负权图,并且还能检测是否存在负权回路。然而,这种优势也伴随着明显的缺点,即其时间复杂度较高,通常为O(N*E),其中N是图的顶点数,E是边数。
Dijkstra算法在处理正权图时表现出色,但当图中存在负权边时,Dijkstra算法的贪心策略可能导致错误的结果。这时候,Bellman-Ford算法就成为一种可行的替代方案。Bellman-Ford算法采用了一种相对较为暴力的策略,通过迭代的方式对所有边进行松弛操作,逐步逼近最短路径的解。
Bellman-Ford算法的核心思想是进行|V|−1次松弛操作,其中|V|是图中顶点的数量。这保证了在经过最多|V|−1条边的路径中,能够找到源结点到其他各结点的最短路径。若在这些迭代中仍然存在松弛的操作,则说明图中存在负权回路。
虽然Bellman-Ford算法在解决负权图的单源最短路径问题上非常实用,但其时间复杂度较高,尤其在邻接矩阵实现时,需要遍历所有边的数量,时间复杂度可能更高。这使得在一些场景中,特别是当图规模较大时,需要谨慎选择使用Bellman-Ford算法还是其他更为高效的算法。
什么是“负权回路”?
负权回路的定义解释:
在图论中,负权回路是指图中存在至少一条回路(环),沿着该回路走一圈回到起点时,路径上所有边的权重之和为负值。具体来说,对于一个有向图或无向图中的回路,如果沿着这个回路经过的所有边的权重之和为负数,那么这个回路就被称为负权回路。
数学上,如果存在一个由边组成的环路(可能包括正权边和负权边),使得环路上所有边的权重之和为负数,则称该图包含负权回路。这个概念在图论中是与路径权重累加相关的,特别是在最短路径算法中,负权回路可能导致算法无法正常终止,因为算法会不断试图通过这个负权回路来减小路径的权重,而这是无穷的过程。所以负权回路的存在会对最短路径算法产生影响,因为它违反了通常的最短路径定义。
Bellman-Ford算法的一个重要缺陷:
当图中存在负权回路时,算法可能会陷入无限循环,无法停止。负权回路是指图中某一条回路(环)上的所有边的权重之和为负值。这种情况会导致算法在每次迭代中都试图通过这个负权回路不断降低路径的权重,但由于权重是负的,算法会永远不会停止。
解决办法:
-
检测负权回路: 在Bellman-Ford算法中,通常在算法执行完毕后,再进行一次额外的循环来检测是否存在负权回路。如果在这个额外循环中,仍然存在可以松弛的边,说明存在负权回路。
-
限制迭代次数: 在实际应用中,可以限制Bellman-Ford算法的迭代次数。如果在限制次数内算法尚未收敛,可以认为存在负权回路。
-
使用其他算法: 如果负权回路是已知的,或者能够预先检测到,可以考虑使用其他算法,如Dijkstra算法(对于无负权边的情况)或Floyd-Warshall算法(可以处理负权边但不会陷入无限循环)。
基本思路可以总结为以下几个步骤:
-
初始化: 对于图中的每个顶点,初始化到达该顶点的最短路径长度为无穷大,但起始顶点的最短路径长度为0。同时,初始化一个数组用于记录每个顶点的父节点,即最短路径中的上一个顶点。
-
松弛操作: 进行n-1次松弛操作,其中n是图中顶点的数量。每次松弛操作都尝试通过当前路径更新到达每个顶点的最短路径。具体步骤如下:
- 遍历图中的每一条边,尝试通过当前边松弛到达该边终点的最短路径。
- 如果经过该边到达终点的路径比当前已知的最短路径更短,则更新终点的最短路径和父节点信息。
-
检测负权回路: 在完成n-1次松弛操作后,再进行一次遍历图中的所有边。如果仍然存在可以通过边松弛到达更短路径的情况,说明图中存在负权回路。
总体而言,Bellman-Ford算法的关键在于通过多次松弛操作逐步逼近最短路径,同时检测是否存在负权回路。这使得算法能够应对负权边的情况,并在图中存在负权回路时做出相应的标识。算法的时间复杂度为O(VE),其中V是顶点数,E是边数。
这也算它的明显缺点,它的时间复杂度 O(N*E) (N是点数,E是边数)普遍是要高于Dijkstra算法O(N²)的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N^3),这里也可以看出来Bellman-Ford就是一种暴力求解更新。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
class Edge {
int srcIndex;
int destIndex;
int weight;//权重
public Edge(int srcIndex, int destIndex, int weight) {
this.srcIndex = srcIndex;
this.destIndex = destIndex;
this.weight = weight;
}
}
// 并查集类,用于判断两个顶点是否在同一个集合中
class UnionFindSet {
private int[] parent;
public UnionFindSet(int size) {
parent = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
}
}
public int findSet(int x) {
if (x != parent[x]) {
parent[x] = findSet(parent[x]); // 路径压缩
}
return parent[x];
}
public void unionSet(int x, int y) {
int rootX = findSet(x);
int rootY = findSet(y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
public boolean isSameSet(int x, int y) {
return findSet(x) == findSet(y);
}
}
public class BellmanFordTest {
private char[] arrayV;
private int[][] matrix;
public BellmanFordTest(int size, boolean isDirect) {
arrayV = new char[size];
matrix = new int[size][size];
}
// 初始化顶点数组
public void initArrayV(char[] array) {
for (int i = 0; i < array.length; i++) {
arrayV[i] = array[i];
}
}
// 添加带权重的边
public void addEdge(char v1, char v2, int weight) {
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
matrix[index2][index1] = weight;
}
public int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
// 打印图的邻接矩阵表示
public void printGraph() {
for (int i = 0; i < arrayV.length; i++) {
System.out.print(arrayV[i] + "-> ");
for (int j = 0; j < arrayV.length; j++) {
System.out.print(matrix[i][j] + "-> ");
}
System.out.println("null");
}
}
public boolean bellmanFord(char vSrc, int[] dist, int[] pPath) {
// 获取源顶点在数组V中的索引
int srcIndex = getIndexOfV(vSrc);
// 初始化父路径数组和距离数组
Arrays.fill(pPath, -1);
Arrays.fill(dist, Integer.MAX_VALUE);
dist[srcIndex] = 0;
// 获取图中顶点的数量
int n = arrayV.length;
// 迭代(n-1)轮,每轮松弛所有边
for (int k = 0; k < n - 1; k++) {
// 遍历图中的所有边
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
// 如果发现更短的路径,则松弛边
if (matrix[i][j] != Integer.MAX_VALUE && dist[i] + matrix[i][j] < dist[j]) {
dist[j] = dist[i] + matrix[i][j];
pPath[j] = i;
}
}
}
}
// 完成迭代后检查负权回路
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
// 如果找到更短的路径,说明存在负权回路
if (matrix[i][j] != Integer.MAX_VALUE && dist[i] != Integer.MAX_VALUE && dist[i] + matrix[i][j] < dist[j]) {
System.out.println("检测到负权回路:" + arrayV[i] + " -> " + arrayV[j]);
return true; // 存在负权回路
}
}
}
// 未发现负权回路
return false;
}
// 在示例图上测试贝尔曼-福特算法
public static void testGraphBellmanFord() {
// 定义顶点字符串
String str = "ABC";
char[] array = str.toCharArray();
// 初始化BellmanFordTest对象,指定顶点数量和有向图
BellmanFordTest test = new BellmanFordTest(str.length(), true);
test.initArrayV(array);
// 向图中添加边
test.addEdge('A', 'B', -1);
test.addEdge('A', 'C', -2);
test.addEdge('B', 'A', 2);
test.addEdge('B', 'C', -3);
test.addEdge('C', 'A', 1);
test.addEdge('C', 'B', 0);
// 初始化数组以存储距离和父路径
int[] dist = new int[array.length];
int[] parentPath = new int[array.length];
char sourceVertex = 'A';
// 运行贝尔曼-福特算法并检查是否存在负权回路
boolean hasNegativeCycle = test.bellmanFord(sourceVertex, dist, parentPath);
// 根据是否存在负权回路输出结果
if (!hasNegativeCycle) {
System.out.println("不存在负权回路");
test.printShortPath('A', dist, parentPath);
} else {
System.out.println("存在负权回路");
}
}
public static void main(String[] args) {
testGraphBellmanFord();
}
// 打印从源点vSrc到目标顶点vDest的最短路径
public void printShortPath(char vSrc, int[] dist, int[] pPath) {
// 1. 获取顶点下标
int srcIndex = getIndexOfV(vSrc);
int n = arrayV.length;
// 2. 遍历pPath数组的n个值
// 每个值到起点S的路径都打印一遍
for (int i = 0; i < n; i++) {
// 自己到自己的路径不打印
if (i != srcIndex) {
ArrayList<Integer> path = new ArrayList<>();
int parentI = i;
while (parentI != srcIndex) {
path.add(parentI);
parentI = pPath[parentI];
}
path.add(srcIndex);
// 翻转path当中的路径
Collections.reverse(path);
// 打印路径和权值
System.out.print("Shortest Path from " + vSrc + " to " + arrayV[i] + ": ");
for (int pos : path) {
System.out.print(arrayV[pos] + " -> ");
}
System.out.println("Distance: " + dist[i]);
}
}
}
}
算法具体步骤如下:
-
初始化:
- 首先,通过 getIndexOfV(vSrc) 获取源顶点在顶点数组 arrayV 中的索引 srcIndex。
- 然后,初始化两个数组:pPath 用于存储最短路径上每个顶点的父节点索引,dist 用于存储从源顶点到每个顶点的最短距离。
- 将 pPath 数组所有元素初始化为 -1,表示初始时没有父节点。将 dist 数组所有元素初始化为正无穷大,表示初始时距离未知,但将源顶点的距离初始化为 0。
-
迭代松弛:
- 使用两层循环进行 (n-1) 次迭代,其中 n 为图中顶点的数量。
- 外层循环 k 表示迭代次数,内层两个循环 i 和 j 遍历所有可能的边。
- 在每次迭代中,检查从顶点
i
到顶点j
的边,如果通过顶点i
能够获得更短的路径(dist[i] + matrix[i][j] < dist[j]),则更新 dist[j] 和 pPath[j]。
-
检查负权回路:
- 完成 (n-1) 次迭代后,进行额外的一轮遍历。
- 再次遍历所有边,如果在这一轮仍然存在更短路径,则说明图中存在负权回路。
- 在发现负权回路时,输出相关信息,并返回 true 表示存在负权回路。
-
返回结果:
如果迭代过程中未发现负权回路,则返回 false 表示不存在负权回路。 -
算法复杂度:
时间复杂度为 O(V * E),其中V
为顶点数量,E 为边数量。这是因为算法每次迭代都要遍历所有的边。
现在我们检测有负权回路的情况:
// 向图中添加边
test.addEdge('A', 'B', -1);
test.addEdge('A', 'C', -2);
test.addEdge('B', 'A', -2);
test.addEdge('B', 'C', -3);
test.addEdge('C', 'A', 1);
test.addEdge('C', 'B', 0);
由此可知,Bellman-Ford算法可以解决有负权边的情况,但是不能够解决有负权回路的情况。
另外,Bellman-Ford算法的时间复杂度在使用不同的图表示方式时可能有所不同。
在邻接矩阵中,每个顶点与所有其他顶点都有一条边,因此需要 O(V) 的时间来检查每一条边。总共有 O(V^2) 条边,所以总的时间复杂度是 O(V^3)。
如果使用邻接表的方式表示图,Bellman-Ford算法的时间复杂度可以降低到 O(VE),其中 E 是边的数量。这是因为在邻接表中,每个顶点只存储与其相邻的边,而不是所有可能的边。
此处的Bellman-Ford 算法我们是使用邻接矩阵实现的,所以它的的时间复杂度是 O(V^3),其中 V 是顶点的数量。在我们的实现中,有三重嵌套循环,每个循环都会遍历所有的顶点。
-
外层循环: for (int k = 0; k < n-1; k++)
- 这个循环控制算法迭代的次数,遍历了所有的顶点。
- 时间复杂度是 O(V)。
-
中间循环: for (int i = 0; i < n; ++i)
- 这个循环用于遍历所有可能的起始顶点。
- 时间复杂度是 O(V)。
-
内层循环: for (int j = 0; j < n; ++j)
- 这个循环用于遍历所有可能的终点顶点。
- 时间复杂度是 O(V)。
-
总时间复杂度: O(V^3)
- 由于三个循环嵌套在一起,所以总体时间复杂度是 O(V^3)。
反正不论如何,你需要注意的是,Bellman-Ford 算法的时间复杂度相对较高,因此在处理大规模图时可能不够高效。在实际应用中,如果图较大,我们可能需要考虑使用其他更高效的最短路径算法,如 Dijkstra 算法或 Floyd-Warshall 算法,特别是在图的边稠密的情况下。
现在就来看看 Floyd-Warshall 算法。
多源最短路径--Floyd-Warshall算法
Floyd-Warshall算法是解决任意两点间最短路径的一种全局优化算法。相较于Dijkstra和Bellman-Ford算法,Floyd-Warshall算法能够同时计算图中所有顶点对之间的最短路径,适用于带有负权边的图。
Floyd-Warshall算法基于动态规划的思想,通过逐步考虑所有可能的中间节点,来更新每一对顶点之间的最短路径。具体而言,考虑一条最短路径p={v1, v2, ..., vn}上的中间节点k,其中k是p的一个中间节点,将路径p分成两段最短路径p1和p2。其中,p1是从顶点i到中间节点k的最短路径,p2是从中间节点k到顶点j的最短路径。
这一分割使得整条路径p的最短路径可以被拆解成p1和p2两个子路径,其中p1的中间节点属于{1, 2, ..., k-1},p2的中间节点同样属于{1, 2, ..., k-1}。通过对所有可能的中间节点进行遍历,Floyd-Warshall算法可以不断更新顶点对之间的最短路径。
总体而言,Floyd-Warshall算法通过递推式不断更新顶点对之间的最短路径信息,最终得到所有顶点对之间的最短路径矩阵。算法的时间复杂度为O(V^3),其中V为图中顶点的数量。
即Floyd算法本质是三维动态规划。
Floyd算法使用三维动态规划来表示从点i到点j只经过0到k个点的最短路径长度。具体而言,定义D[i][j][k]为从顶点i到顶点j只经过前k个顶点的最短路径长度。
通过动态规划的思想,Floyd算法建立了转移方程,通过逐步考虑所有可能的中间节点k,来更新D[i][j][k]的值。转移方程如下:
D[ i ][ j ][ k ] = min ( D[ i ][ j ][ k - 1 ] , D[ i ][ k ][ k - 1 ] + D[ k ][ j ][ k - 1 ] )
其中,min表示取最小值。这个方程的含义是,如果从i到j的最短路径不经过k(即D[i][j][k-1]),那么最短路径的长度就是D[i][j][k-1];如果经过k,那么最短路径的长度是从i到k再到j的路径长度(即D[i][k][k-1] + D[k][j][k-1])。
通过不断更新这个转移方程,Floyd算法逐步计算出所有顶点对之间的最短路径长度。最后,通过空间优化,可以将三维数组优化为二维数组,得到一个最短路径的迭代算法。这种算法具有全局性,能够同时计算图中所有顶点对之间的最短路径,是一种适用于多源最短路径问题的强大算法。
import java.util.Arrays;
public class GraphByMatrix2 {
private char[] arrayV;
private int[][] matrix;
private boolean isDirect;
// 构造函数,初始化图的大小和类型
public GraphByMatrix2(int size, boolean isDirect) {
this.isDirect = isDirect;
this.arrayV = new char[size];
this.matrix = new int[size][size];
}
// 初始化顶点数组
public void initArrayV(char[] array) {
this.arrayV = Arrays.copyOf(array, array.length);
}
// 添加边到邻接矩阵
public void addEdge(char v1, char v2, int weight) {
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
if (!isDirect) {
matrix[index2][index1] = weight;
}
}
// Floyd-Warshall算法,用于计算所有点对之间的最短路径
public void floydWarshall(int[][] dist, int[][] pPath) {
int n = arrayV.length;
// 步骤1:初始化dist和pPath数组
for (int i = 0; i < n; i++) {
Arrays.fill(dist[i], Integer.MAX_VALUE);
Arrays.fill(pPath[i], -1);
}
// 步骤2:将直接相连的权值更新到dist和pPath数组中
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] != 0) {
dist[i][j] = matrix[i][j];
pPath[i][j] = i;
} else {
pPath[i][j] = -1;
}
// 自己到自己的距离为0,没有父路径
if (i == j) {
dist[i][j] = 0;
pPath[i][j] = -1;
}
}
}
// 步骤3:使用中间顶点更新dist和pPath数组
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 如果从i到k有路径,并且从k到j有路径,并且从i到k再到j的距离小于直接从i到j的距离
if (dist[i][k] != Integer.MAX_VALUE
&& dist[k][j] != Integer.MAX_VALUE
&& dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
pPath[i][j] = pPath[k][j];
}
}
}
}
}
// 打印从源顶点开始的最短路径
public void printShortPath(char vSrc, int[] dist, int[] pPath) {
int srcIndex = getIndexOfV(vSrc);
int n = arrayV.length;
for (int i = 0; i < n; i++) {
if (i != srcIndex) {
StringBuilder path = new StringBuilder();
int parentI = i;
// 从目标顶点向源顶点重建路径
while (parentI != srcIndex) {
path.insert(0, arrayV[parentI] + " -> ");
parentI = pPath[parentI];
}
path.insert(0, arrayV[srcIndex] + " -> ");
path.setLength(path.length() - 4); // 移除末尾的 " -> "
// 打印路径和距离
System.out.println(path.toString() + ": " + dist[i]);
}
}
}
// 获取顶点在数组中的下标
private int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
// 测试Floyd-Warshall算法
public static void testGraphFloydWarshall() {
String str = "12345";
char[] array = str.toCharArray();
GraphByMatrix2 g = new GraphByMatrix2(str.length(), true);
g.initArrayV(array);
g.addEdge('1', '2', 3);
g.addEdge('1', '3', 8);
g.addEdge('1', '5', -4);
g.addEdge('2', '4', 1);
g.addEdge('2', '5', 7);
g.addEdge('3', '2', 4);
g.addEdge('4', '1', 2);
g.addEdge('4', '3', -5);
g.addEdge('5', '4', 6);
int[][] dist = new int[array.length][array.length];
int[][] parentPath = new int[array.length][array.length];
g.floydWarshall(dist, parentPath);
for (int i = 0; i < array.length; i++) {
g.printShortPath(array[i], dist[i], parentPath[i]);
System.out.println("&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&");
}
}
public static void main(String[] args) {
testGraphFloydWarshall();
}
}
Floyd-Warshall算法的步骤:
-
初始化 dist 和 pPath 数组:
- dist[i][j] 表示从顶点 i 到顶点 j 的最短路径长度。
- pPath[i][j] 表示从顶点 i 到顶点 j 的最短路径上,顶点 j 的前一个顶点的索引。
- 初始化所有 dist 元素为正无穷大,表示初始时距离未知。初始化 pPath 中的所有元素为 -1,表示初始时没有已知的路径。
-
直接相连的权值更新到
dist
和pPath
数组:- 对于图中每一对直接相连的顶点 i 和 j,如果存在直接的边(matrix[i][j] != 0),则将这个边的权值更新到 dist 数组,并将 pPath 数组中的相应值设为 i。
- 如果自己到自己的情况(i == j),将 dist[i][j] 设为 0,表示自己到自己的距离为0,pPath[i][j] 设为 -1。
-
使用中间顶点更新 dist 和 pPath 数组:
- 对于每一个中间顶点 k,遍历所有的顶点对 i 和 j,检查是否存在通过顶点 k 而获得更短路径的情况。
- 如果从顶点 i 到顶点 k 有路径,从顶点 k 到顶点 j 有路径,并且从 i 到 k 再到 j 的距离小于直接从 i 到 j 的距离(dist[i][k] + dist[k][j] < dist[i][j])。
- 更新 dist[i][j] 和 pPath[i][j],将中间顶点 k 加入路径中。
-
算法完成:
经过上述步骤,dist 数组中存储的就是所有点对之间的最短路径长度,pPath 数组中存储的是最短路径上各个顶点的父节点索引。
总体而言,Floyd-Warshall算法通过不断更新中间顶点,逐步求解所有点对之间的最短路径。由于采用了三重嵌套循环,其时间复杂度为 O(V^3),其中 V 是顶点的数量。
import java.util.Arrays;
public class GraphByMatrix2 {
private char[] arrayV;
private int[][] matrix;
private boolean isDirect;
// 构造函数,初始化图的大小和类型
public GraphByMatrix2(int size, boolean isDirect) {
this.isDirect = isDirect;
this.arrayV = new char[size];
this.matrix = new int[size][size];
}
// 初始化顶点数组
public void initArrayV(char[] array) {
this.arrayV = Arrays.copyOf(array, array.length);
}
// 添加边到邻接矩阵
public void addEdge(char v1, char v2, int weight) {
int index1 = getIndexOfV(v1);
int index2 = getIndexOfV(v2);
matrix[index1][index2] = weight;
if (!isDirect) {
matrix[index2][index1] = weight;
}
}
// Floyd-Warshall算法,用于计算所有点对之间的最短路径
public void floydWarshall(int[][] dist, int[][] pPath) {
int n = arrayV.length;
// 步骤1:初始化dist和pPath数组
for (int i = 0; i < n; i++) {
Arrays.fill(dist[i], Integer.MAX_VALUE);
Arrays.fill(pPath[i], -1);
}
// 步骤2:将直接相连的权值更新到dist和pPath数组中
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] != 0) {
dist[i][j] = matrix[i][j];
pPath[i][j] = i;
} else {
pPath[i][j] = -1;
}
// 自己到自己的距离为0,没有父路径
if (i == j) {
dist[i][j] = 0;
pPath[i][j] = -1;
}
}
}
// 步骤3:使用中间顶点更新dist和pPath数组
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 如果从i到k有路径,并且从k到j有路径,并且从i到k再到j的距离小于直接从i到j的距离
if (dist[i][k] != Integer.MAX_VALUE
&& dist[k][j] != Integer.MAX_VALUE
&& dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
pPath[i][j] = pPath[k][j];
}
}
}
// 测试 打印权值和路径矩阵观察数据
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if(dist[i][j] == Integer.MAX_VALUE) {
System.out.print(" * ");
}else{
System.out.print(dist[i][j]+" ");
}
}
System.out.println();
}
System.out.println("=========打印路径==========");
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
System.out.print(pPath[i][j]+" ");
}
System.out.println();
}
System.out.println("=================");
}
}
// 打印从源顶点开始的最短路径
public void printShortPath(char vSrc, int[] dist, int[] pPath) {
int srcIndex = getIndexOfV(vSrc);
int n = arrayV.length;
for (int i = 0; i < n; i++) {
if (i != srcIndex) {
StringBuilder path = new StringBuilder();
int parentI = i;
// 从目标顶点向源顶点重建路径
while (parentI != srcIndex) {
path.insert(0, arrayV[parentI] + " -> ");
parentI = pPath[parentI];
}
path.insert(0, arrayV[srcIndex] + " -> ");
path.setLength(path.length() - 4); // 移除末尾的 " -> "
// 打印路径和距离
System.out.println(path.toString() + ": " + dist[i]);
}
}
}
// 获取顶点在数组中的下标
private int getIndexOfV(char v) {
for (int i = 0; i < arrayV.length; i++) {
if (arrayV[i] == v) {
return i;
}
}
return -1;
}
// 测试Floyd-Warshall算法
public static void testGraphFloydWarshall() {
String str = "12345";
char[] array = str.toCharArray();
GraphByMatrix2 g = new GraphByMatrix2(str.length(), true);
g.initArrayV(array);
g.addEdge('1', '2', 3);
g.addEdge('1', '3', 8);
g.addEdge('1', '5', -4);
g.addEdge('2', '4', 1);
g.addEdge('2', '5', 7);
g.addEdge('3', '2', 4);
g.addEdge('4', '1', 2);
g.addEdge('4', '3', -5);
g.addEdge('5', '4', 6);
int[][] dist = new int[array.length][array.length];
int[][] parentPath = new int[array.length][array.length];
g.floydWarshall(dist, parentPath);
/* for (int i = 0; i < array.length; i++) {
g.printShortPath(array[i], dist[i], parentPath[i]);
}*/
}
public static void main(String[] args) {
testGraphFloydWarshall();
}
}
如果还是不太懂,可以看看这篇文章,专门讲弗洛伊德算法的: