【数据结构】——图的最短路径算法补充(贝尔曼-福特+SPFA)

另两种图的最短路径算法,迪杰斯特拉&弗洛伊德:https://blog.csdn.net/namewdy/article/details/106330604

为了方便以上面链接中的无向图为例,同样求顶点A与顶点E的最短路径长度及最短路径。

1.贝尔曼-福特(Bellman-Ford)

Bellman-Ford算法和Dijkstra算法一样,都是图的单源最短路径算法,也都是通过松弛操作求解。从Dijkstra中知道,对一条边(u, v)松弛操作,其实就是测试是否可以通过 u,对迄今找到的 v 的最短路径进行改进,松弛操作的目的就是使估计的最短路径值渐渐地被更加准确的值替代,直至得到最优解。

区别是Dijkstra算法是一种往深度进行的松弛操作,每次以贪心法选取具有最小距离的顶点,然后对其的出边(与所有未被处理顶点之间)进行松弛操作。而Bellman-Ford算法是在广度上不断重复松弛直到求出最优解,它简单地一轮轮的对所有边进行松弛操作,松弛时 dist[u] 不一定是到源点的最短距离,松弛后的 dist[v] 也只是在原来的基础上进行优化。

对于一个 n 个顶点的图,从源点到任一个顶点 u 最多经过 n-1 条边,所以每条边经过 n-1 次的松弛操作后得到的 dist[u] 就是最终从源点到该顶点的最短路径长度。当然如果某轮松弛中没有任何操作,说明此时各点与源点的距离都已经达到最小,程序可以提前结束。

详见代码:

public class BellmanFord {
	char[] vertex = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I' };
	int[][] matrix = new int[9][9];
	int INF = 1 << 31 - 1;

	private void creatMartix() {
		matrix[locate('A')][locate('B')] = matrix[locate('B')][locate('A')] = 4;
		matrix[locate('A')][locate('G')] = matrix[locate('G')][locate('A')] = 8;
		matrix[locate('B')][locate('G')] = matrix[locate('G')][locate('B')] = 3;
		matrix[locate('B')][locate('C')] = matrix[locate('C')][locate('B')] = 8;
		matrix[locate('G')][locate('F')] = matrix[locate('F')][locate('G')] = 1;
		matrix[locate('G')][locate('H')] = matrix[locate('H')][locate('G')] = 6;
		matrix[locate('C')][locate('F')] = matrix[locate('F')][locate('C')] = 2;
		matrix[locate('F')][locate('H')] = matrix[locate('H')][locate('F')] = 6;
		matrix[locate('C')][locate('D')] = matrix[locate('D')][locate('C')] = 7;
		matrix[locate('C')][locate('I')] = matrix[locate('I')][locate('C')] = 4;
		matrix[locate('H')][locate('I')] = matrix[locate('I')][locate('H')] = 2;
		matrix[locate('D')][locate('I')] = matrix[locate('I')][locate('D')] = 14;
		matrix[locate('D')][locate('E')] = matrix[locate('E')][locate('D')] = 9;
		matrix[locate('E')][locate('I')] = matrix[locate('I')][locate('E')] = 10;

		for (int i = 0; i < matrix.length; i++) {
			for (int j = 0; j < matrix.length; j++) {
				if (matrix[i][j] == 0) {
					matrix[i][j] = INF;
				}
			}
		}
	}

	private int locate(char v) {
		int i = 0;
		for (; i < vertex.length; i++) {
			if (v == vertex[i])
				break;
		}
		return i;
	}

	/*
	 * --------------------上面创建邻接矩阵,以下是Bellman-Ford代码部分--------------------
	 */

	int[] dist = new int[vertex.length];
	int[] path = new int[vertex.length];

	public void bellmanFord(char start, char end) {
		// 初始化还是和Dijkstra一样
		for (int i = 0; i < vertex.length; i++) {
			dist[i] = matrix[locate(start)][i];
			if (matrix[locate(start)][i] < INF) {
				path[i] = locate(start);
			} else {
				path[i] = -1;
			}
		}
		// 源点距离置为0
		dist[locate(start)] = 0;
		
		boolean isRelax = false;
		// n个顶点的图n-1轮松弛操作
		for (int k = 1; k < vertex.length; k++) {
			isRelax = false;
			// 依次针对除源点外的不同顶点
			for (int u = 0; u < vertex.length; u++) {
				if (u != locate(start)) {
					// 以该顶点对所有的边进行一次松弛操作,目的是找到更加准确的值替代
					for (int i = 0; i < vertex.length; i++) {
						if (matrix[u][i] < INF && dist[u] > matrix[u][i] + dist[i]) {
							dist[u] = dist[i] + matrix[i][u];
							path[u] = i;
							isRelax = true;
						}
					}
				}
			}
			if (isRelax == false) {
				break;
			}
		}
		System.out.println("最短路径长度:" + dist[locate(end)]);
		getPath(end);
	}

	// 打印最短路径
	private void getPath(char end) {
		System.out.print("最短路径:" + end);
		int i = locate(end);
		while (path[i] != -1) {
			System.out.print("<--" + vertex[path[i]]);
			path[i] = path[path[i]];
		}
	}

	public static void main(String[] args) {
		BellmanFord bf = new BellmanFord();
		bf.creatMartix();
		bf.bellmanFord('A', 'E');
	}
}

算法分析

  • 时间复杂度 O ( n e ) Ο(ne) O(ne),n为顶点数,e为边数。
  • 空间复杂度 O ( n ) Ο(n) O(n),为2个辅助数组所占的空间。
  • 允许图有负边,但不允许有负环。其实不针对某个具体算法,对于所有含负回路的图,最短路径问题无解。为什么?仔细想想!
  • Bellman-Ford算法可以用来检测图中是否含有负回路。因为Bellman-Ford算法在 n-1 轮的松弛操作之后理论上已经使所有顶点到源点的距离最短,此时继续第 n 轮松弛,如果存在边 edge,可以继续松弛 dist[ j ] > dist[ i ] + edge[ i ][ j ],则说明图中存在负环。
  • 同样属于图的单源最短路径算法,得到的是一个数组,表示源点到其它各点的最短距离。
贝尔曼判断负环的补充

坦白说写这儿块儿时还是花费了比较多的时间,因为书上只是告诉你怎么判断,而为什么这样只能靠自己慢慢理解。下面说一下自己的理解,不足之处还请指正。

从上面的负环判断方法中可以看到判断负环其实是针对边进行判断的,那么就从边说起。

首先拿无向图来说,因为存在负边那么无论所在的环总权值为正还是负,第n轮对这条负边都会松弛成功。但上面不是说检测负环吗?我是这样理解的,无向图,边可以来回重复,也就是对于这条负边一直往返重复,那么最终所在环总权值一定为负。也就是说无向图中只要存在负边其实就是存在负环。

再说有向图,如果不存在负环,那么对于每个顶点,它的最短路径距离一定是存在的,并在n-1轮松弛之后全部达到最小值。也许有人会想,第n轮我仍然可以对这条负边松弛啊,但其实如果不存在负环,那么第n轮对这条负边松弛一定不会成功,因为在第n-1轮的松弛操作中,负边的弧头顶点就已经是在考虑这条负边后优化的,也就是说对于负边 edge,第n-1轮的优化更新中dist[ j ]的值已经等于dist[ i ]+edge了。又因为不存在负环,所以dist[ i ]的值不会通过这个环被更新变小,那么第n轮的松弛中对负边优化结果只能是维持不变。

负边如此,正边更不必说,所以判断的正确性得证。不同意见欢迎交流~

判断负环代码:

private boolean findCycle() {
	for (int i = 0; i < vertex.length; i++) {
		for (int j = 0; j < vertex.length; j++) {
			// 对所有边松弛操作
			if (matrix[i][j] < INF && dist[j] > dist[i] + matrix[i][j]) {
				return true;
			}
		}
	}
	return false;
}

2.SPFA

SPFA是最短路径快速算法(Shortest Path Faster Algorithm)的简称,具体什么是SPFA算法,下面引用一段百度上面的原话:

它采取的方法是动态逼近法:设立一个先进先出的队列用来保存待优化的结点,优化时每次取出队首结点u,并且用u点当前的最短路径估计值对离开u点所指向的结点v(也就是u的邻接点)进行松弛操作,如果v点的最短路径估计值有所调整,且v点不在当前的队列中,就将v点放入队尾。这样不断从队列中取出结点来进行松弛操作,直至队列空为止。

这段原话对应的伪代码:

ProcedureSPFA;
Begin
    initialize-single-source(G,s);
    initialize-queue(Q);
    enqueue(Q,s);
    while not empty(Q) do begin
        u:=dequeue(Q);
        for each v∈adj[u] do begin
            tmp:=d[v];
            relax(u,v);
            if(tmp<>d[v])and(not v in Q)then enqueue(Q,v);
        end;
    end;
End; 

再引用一段百度上对该算法的正确性证明:

每次将点放入队尾,都是经过松弛操作达到的。换言之,每次的优化将会有某个点v的最短路径估计值d[v]变小。所以算法的执行会使d越来越小。由于我们假定图中不存在负权回路,所以每个结点都有最短路径值。因此,算法不会无限执行下去,随着d值的逐渐变小,直到到达最短路径值时,算法结束,这时的最短路径估计值就是对应结点的最短路径值。

java实现SPFA

关于SPFA算法上面引用部分已经总结很到位了,下面根据算法内容用java实现一遍。

因为SPFA每次在出队一个顶点的时,都会对它的所有邻接点进行松弛操作,所以算法最好针对的是邻接表形式存储的图,因为这样可以直接拿到它的邻接点。但也不是说邻接矩阵就不行,只是需要遍历判断是否为邻接点,增大了算法的复杂度。

关于邻接表,可以查看https://blog.csdn.net/namewdy/article/details/105668761

SPFA代码(完整代码有些长,终点是80~133行部分):

根据提示依次输入9 14,A B C D E F G H I,和下面各边的信息就可以得到文章最上面链接中的图的邻接表,并直接返回顶点A, E之间的最短距离和最短路径。
A B 4
A G 8
B G 3
B C 8
G F 1
G H 6
C F 2
H F 6
C D 7
C I 4
H I 2
D I 14
D E 9
I E 10

import java.util.LinkedList;
import java.util.Queue;
import java.util.Scanner;

public class SPFA {
	// 顶点结构
	class Vertex {
		char verName;						// 顶点名字
		int index;							// 顶点编号
		int weight;							// 边顶点作为头顶点的一个邻接点,weight存储到头结点的权值
		Vertex next;						// 下一个顶点的指针

		public Vertex(char verName, int index, int weight) {
			this.verName = verName;
			this.index = index;
			this.weight = weight;
		}
	}

	private int locate(char verName) {
		for (int i = 0; i < array.length; i++) {
			if (verName == array[i].verName) {
				return i;
			}
		}
		return -1;
	}

	Vertex[] array; 						// 保存头顶点的数组
	int verNums, edgeNums;					// 图的顶点总数和边总数

	// 初始化头顶点的数组
	public void initList(Scanner sc) {
		array = new Vertex[verNums];
		String str = sc.nextLine().replaceAll(" ", "");
		for (int i = 0; i < verNums; i++) {
			array[i] = new Vertex(str.charAt(i), i, 0);
		}
	}

	// 前插法创建邻接表
	public void creatList(Scanner sc) {
		Vertex vertex;
		String[] arr = new String[3];
		char head, tail;
		int weight;
		for (int i = 0; i < edgeNums; i++) {
			arr = sc.nextLine().split(" ");
			head = arr[0].charAt(0);
			tail = arr[1].charAt(0);
			weight = Integer.parseInt(arr[2]);

			vertex = new Vertex(head, locate(head), weight);
			vertex.next = array[locate(tail)].next;
			array[locate(tail)].next = vertex;

			vertex = new Vertex(tail, locate(tail), weight);
			vertex.next = array[locate(head)].next;
			array[locate(head)].next = vertex;
		}
		System.out.println("创建成功,邻接表如下:");
		printList();
	}

	// 打印邻接表
	private void printList() {
		Vertex vertex;
		for (int i = 0; i < array.length; i++) {
			System.out.print(i + ":(" + array[i].verName + "," + array[i].index + "," + array[i].weight + ")");
			vertex = array[i].next;
			while (vertex != null) {
				System.out.print("-->(" + vertex.verName + "," + vertex.index + "," + vertex.weight + ")");
				vertex = vertex.next;
			}
			System.out.println();
		}
	}

	/*
	 * ----------上面输入创建邻接矩阵,不是重点可忽略,以下是SPFA代码----------
	 */

	int[] dist;
	int[] path;
	// 这里INF表示无穷大,-10000因为1<<31-1是当前计算机所能表示的最大正数,在此基础上加正数结果反而变成负数变小
	int INF = 1 << 31 - 10000;

	public void spfa(char start, char end) {
		dist = new int[array.length];
		path = new int[array.length];
		Queue<Vertex> qu = new LinkedList();
		// 标记一个顶点是否处于队列中
		boolean[] inQu = new boolean[array.length];

		// 初始化
		for (int i = 0; i < array.length; i++) {
			dist[i] = INF;
			path[i] = -1;
			inQu[i] = false;
		}

		dist[locate(start)] = 0;					// 源点最短距离置为0
		qu.add(array[locate(start)]);				// 源点进队
		inQu[locate(start)] = true;					// 标记源点在队列中
		Vertex v, p;
		while (!qu.isEmpty()) {
			v = qu.remove();
			inQu[v.index] = false;
			// 找到出队顶点的第一个邻接点
			p = array[v.index].next;		
			// 对出队顶点的所有邻接点松弛
			while (p != null) {
				if (dist[p.index] > dist[v.index] + p.weight) {
					dist[p.index] = dist[v.index] + p.weight;
					path[p.index] = v.index;

					// 松弛成功的顶点会对它邻接点的最短距离产生影响,所以进队
					if (inQu[p.index] == false) {
						qu.add(p);
						inQu[p.index] = true;
					}
				}
				p = p.next;
			}
		}
		System.out.println("最短路径长度:" + dist[locate(end)]);
		getPath(end);
	}

	private void getPath(char end) {
		System.out.print("最短路径:" + end);
		int i = locate(end);
		while (path[i] != -1) {
			System.out.print("<--" + array[path[i]].verName);
			path[i] = path[path[i]];
		}
	}

	public static void main(String[] args) {
		SPFA sp = new SPFA();
		Scanner sc = new Scanner(System.in);
		System.out.println("输入顶点总数和边总数,空格隔开:");
		sp.verNums = sc.nextInt();
		sp.edgeNums = sc.nextInt();

		System.out.println("输入所有顶点,顶点间空格隔开:");
		sc.nextLine();						// 清空输入缓冲区 
		sp.initList(sc);

		System.out.println("依次输入每条边依附的两个顶点及权值,格式:v1 v2 weight,每输入一对回车:");
		sp.creatList(sc);
		sc.close();

		sp.spfa('A', 'E');
	}
}

算法分析

  • 时间复杂度不太好证明,《算法设计与分析第2版》的原话是:while循环的执行次数大致为图中边数e,所以时间复杂度 O ( e ) Ο(e) O(e)。也有说段凡丁老师证明的结果是 O ( k e ) Ο(ke) O(ke)(k是小常数),但百度百科上又说这个证明是错误的。其实个人觉得无论对错,小常数都可以被忽略,也就是时间复杂度可以认为就是 O ( e ) Ο(e) O(e)。但SPFA算法的时间复杂度不稳定,最坏可以退回到BellmanFord的时间复杂度 O ( n e ) Ο(ne) O(ne),n为顶点数。
  • 空间复杂度 O ( n ) Ο(n) O(n),为几个和顶点数等长的数组和一个长度小于顶点数的辅助队列所占据的空间。
  • 适用含负边的图,不适用含负回路的图。
  • SPFA可以看成是BellmanFord算法的队列优化版本,它只针对被松弛成功的顶点的出度边松弛,而BellmanFord是简单的对所有边进行松弛。
  • SPFA在形式上和广度优先遍历相似,区别是广度优先遍历顶点出队便不再入队了,而SPFA中出队顶点可能再次入队。

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