Acwing - 算法基础课 - 笔记(图论 · 二)

搜索与图论(二)

这一节讲的是最短路。

常见的最短路问题,一般分为两大类:

  • 单源最短路
  • 多源汇最短路

在最短路问题中,源点也就是起点汇点也就是终点

单源最短路

单源最短路,指的是求一个点,到其他所有点的最短距离。(起点是固定的,单一的

根据是否存在权重为负数的边,又分为两种情况

  • 所有边的权重都是正数

    通常有两种算法

    • 朴素Dijkstra

      时间复杂度O(n2),其中n是图中点的个数,m是边的个数

    • 堆优化版的Dijkstra

      时间复杂度O(mlogn)

    两者孰优孰劣,取决于图的疏密程度(取决于点数n,与边数m的大小关系)。当是稀疏图(n和m是同一级别)时,可能堆优化版的Dijkstra会好一些。当是稠密图时(m和n2是同一级别),使用朴素Dijkstra会好一些。

  • 存在权重为负数的边

    通常有两种算法

    • Bellman-Ford

      时间复杂度O(nm)

    • SPFA

      时间复杂度一般是O(m),最差O(nm),是前者的优化版,但有的情况无法使用SPFA,只能使用前者,比如要求最短路不超过k条边,此时只能用Bellman-Ford

多源汇最短路

求多个起点到其他点的最短路。(起点不是固定的,而是多个

Floyd算法(时间复杂度O(n3))

最短路问题的核心在于,把问题抽象成一个最短路问题,并建图。图论相关的问题,不侧重于算法原理,而侧重于对问题的抽象

Dijkstra基于贪心,Floyd基于动态规划,Bellman-Ford基于离散数学。

算法的选用:通常来说,单源最短路的,如果没有负权重的边,用Dijkstra,有负权重边的,通常用SPFA,极少数用Bellman-Ford;多源最短路的,用Floyd。

算法思路

朴素Dijkstra

假设图中一共有n个点,下标为1~n。下面所说的某个点的距离,都是指该点到起点(1号点)的距离。

算法步骤如下,用一个集合s来存放最短距离已经确定的点。

  1. 初始化距离d[1] = 0, d[i] = +∞。即,将起点的距离初始化为0,而其余点的距离当前未确定,用正无穷表示。

  2. 循环

    每次从距离已知的点中,选取一个不在s集合中,且距离最短的点(这一步可以用小根堆来优化),遍历该点的所有出边,更新这些出边所连接的点的距离。并把该次选取的点加入到集合s中,因为该点的最短距离此时已经确定。

  3. 当所有点都都被加入到s中,表示全部点的最短距离都已经确定完毕

注意某个点的距离已知,并不代表此时这个点的距离就是最终的最短距离。在后续的循环中,可能用一条更短距离的路径,去更新。

练习题:acwing - 849: Dijkstra求最短路 I

题解(C++)

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 510;
const int INF = 0x3f3f3f3f; // 正无穷
int g[N][N]; // 稠密图采用邻接矩阵存储
int d[N]; // 距离
int n, m;
bool visited[N];

int dijkstra() {
	d[1] = 0;
	// 每次
	for(int i = 1; i <= n; i++) {
		//找到一个距起点距离最小的点
		int t = 0; // d[0]未被使用, 其值一直是 INF
		for(int j = 1; j <= n; j++) {
			if(!visited[j] && d[j] < d[t]) {
				t = j;
			}
		}
		if(t == 0) break; // 未找到一个点, 提前break
		// 找到该点
		visited[t] = true; // 放入集合s
		// 更新其他所有点的距离
		for(int i = 1; i <= n; i++) {
			d[i] = min(d[i], d[t] + g[t][i]);
		}
	}
	if(d[n] == INF) return -1;
	else return d[n];
}

int main() {
	// 初始化
	memset(d, 0x3f, sizeof d);
	memset(g, 0x3f, sizeof g);
	scanf("%d%d", &n, &m);
	while(m--) {
		int x, y, z;
		scanf("%d%d%d", &x, &y, &z);
		g[x][y] = min(g[x][y], z); // 重复边只需要保留一个权重最小的即可
	}
	printf("%d", dijkstra());
	return 0;
}

题解(Java)

import java.util.Arrays;
import java.util.Scanner;

/**
 * @Author yogurtzzz
 * @Date 2021/6/25 9:01
 *
 * 稠密图, 使用邻接矩阵存储
 **/
public class Main {

	static final int INF = 0x3f3f3f3f;

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		int n, m;
		n = scanner.nextInt();
		m = scanner.nextInt();
		int[][] g = new int[n + 1][n + 1]; // 图的邻接矩阵存储
		for(int i = 0; i < n + 1; i++) {
			for (int j = 0; j < n + 1; j++) g[i][j] = INF; // 初始化全部距离为正无穷
		}
		while(m-- > 0) {
			int x, y, z;
			x = scanner.nextInt();
			y = scanner.nextInt();
			z = scanner.nextInt();
			g[x][y] = Math.min(g[x][y], z); // 解决重边, 保留最小距离的边即可
		}
		System.out.println(dijkstra(g, n));
	}

	/**
	 * @param g 图的邻接矩阵表示
	 * @param n 图中点的个数
	 * **/
	static int dijkstra(int[][] g, int n) {
		int[] distance = new int[n + 1];
		boolean[] visited = new boolean[n + 1]; //状态变量
		Arrays.fill(distance, INF); // 初始化距离为正无穷
		distance[1] = 0; // 起点的距离为0
		// 循环n次
		for (int i = 0; i < n; i++) {
			// 先找出距离最小的点
			int min = 0;
			for(int j = 1; j <= n; j++) {
				if (!visited[j] && distance[j] < distance[min]) {
					min = j;
				}
			}
			if (min == 0) break; // 所有点都遍历结束
			// 找到了距离最小的点
			visited[min] = true; // 这里是为了解决自环
			// 更新距离, 枚举所有列
			for(int j = 1; j <= n; j++) {
				// 当存在一个出边时, 更新其距离
				if (g[min][j] != INF) distance[j] = Math.min(distance[j], distance[min] + g[min][j]);
			}
		}
		if (distance[n] == INF) return -1;
		else return distance[n];
	}
}
堆优化版Dijkstra

堆可以自己手写(用数组模拟),也可以使用现成的(C++的STL提供了priority_queue,Java的JDK中提供了PriorityQueue)
特别注意,插入堆的操作,由于更新距离时,可能对一些距离已知的点进行更新(更新为更小的距离),此时不能因为这个点已经在堆中就不进行插入了,因为其距离已经变了,堆中原有的节点已经无效了,按理说,应该修改堆中对应节点的距离值,然后做调整,实际上,可以直接插入一个新的节点(此时对于同一个节点,堆中有两份),但没有关系,堆中的重复节点不会影响最终的结果。

练习题:acwing - 850: Dijkstra求最短路 II

题解:手写堆(C++)

#include <iostream>
#include <cstring>
using namespace std;

const int N = 1e6, INF = 0x3f3f3f3f;

int h[N], e[N], w[N], ne[N], idx;

int d[N];

int hPos[N], hDis[N], hSize;

bool st[N];

int n, m, x, y, z;

void add(int x, int y, int z) {
	e[idx] = y;
	w[idx] = z;
	ne[idx] = h[x];
	h[x] = idx++;
}

void heap_swap(int i, int j) {
	swap(hPos[i], hPos[j]);
	swap(hDis[i], hDis[j]);
}

void up(int pos) {
	while(pos > 1 && hDis[pos / 2] > hDis[pos]) {
		heap_swap(pos / 2, pos);
		pos /= 2;
	}
}

void down(int pos) {
	int mx = pos;
	if(2 * pos <= hSize && hDis[2 * pos] < hDis[mx]) mx = 2 * pos;
	if(2 * pos + 1 <= hSize && hDis[2 * pos + 1] < hDis[mx]) mx = 2 * pos + 1;
	if(mx != pos) {
		heap_swap(mx, pos);
		down(mx);
	}
}

void insert_to_heap(int x, int dis) {
	hSize++;
	hPos[hSize] = x;
	hDis[hSize] = dis;
	up(hSize);
}

void dijkstra() {
	d[1] = 0;
	insert_to_heap(1, 0);

	// 当堆非空时,执行
	while(hSize > 0) {
		int x = hPos[1];
		heap_swap(1, hSize--);
		down(1);
		if(st[x]) continue; // 由于堆中可能存在重复的节点, 需要判断, 否则会超时
		st[x] = true; // 拿出来后, 该点的距离就已经确定
		for(int j = h[x]; j != -1; j = ne[j]) {
			int u = e[j];
			if(d[u] > d[x] + w[j]) {
				d[u] = d[x] + w[j];
				insert_to_heap(u, d[u]);
			}
		}
	}
}

int main() {
	memset(h, -1, sizeof h);
	memset(d, 0x3f, sizeof d);
	scanf("%d%d", &n, &m);
	while(m--) {
		scanf("%d%d%d", &x, &y, &z);
		add(x, y, z);
	}
	dijkstra();
	if(d[n] == INF) printf("-1");
	else printf("%d", d[n]);
}

题解:使用现成的堆(Java)

import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Scanner;

/**
 * @Author yogurtzzz
 * @Date 2021/6/25 9:33
 *
 * 堆优化版的 Dijkstra
 * 稀疏图, 用邻接链表来存
 **/
public class Main {
	
	static class Pair {
		int first;
		int second;

		Pair(int first, int second) {
			this.first = first;
			this.second = second;
		}
	}

	static Scanner scanner = new Scanner(System.in);
	
	static int INF = 0x3f3f3f3f;

	static final int N = 200000;

	static int[] h;
	static int[] e = new int[N], w = new int[N], ne = new int[N];// 图的邻接表表示, 数组模拟链表
	static int idx; // 用来分配链表节点
	
	public static void main(String[] args) {
		int n = readInt(), m = readInt();
		h = new int[n + 1];
		Arrays.fill(h, -1); // 初始化, 全部邻接表都是-1, 表示空链表
		while(m-- > 0) {
			int x = readInt(), y = readInt(), z = readInt();
			add(x, y, z);
		}
		System.out.println(dijkstra(n));
	}
	
	private static int dijkstra(int n) {
		int[] distance = new int[n + 1];
		boolean[] visited = new boolean[n + 1];
		Arrays.fill(distance, INF);
		distance[1] = 0; // 初始化起点距离
		// 小根堆
		PriorityQueue<Pair> heap = new PriorityQueue<>(Comparator.comparingInt(o -> o.first));
		heap.offer(new Pair(0, 1));  // 插入起点到堆中, first是距离, second是节点编号
		for(int i = 0; i < n; i++) {
			Pair p = heap.poll(); // 获取当前距离最小的节点
			if (p == null) break; // 堆里没有元素了, 提前结束
			int nodeNo = p.second, nodeDis = p.first; // 获取该节点的编号和距离
			visited[nodeNo] = true; // 解决自环
			for(int j = h[nodeNo]; j != -1; j = ne[j]) {
				// 从邻接表中获取该节点的所有出边, 依次更新
				int nextNodeNo = e[j];
				int nextNodeWeight = w[j];
				if (distance[nextNodeNo] > nodeDis + nextNodeWeight) {
					// 更新
					distance[nextNodeNo] = nodeDis + nextNodeWeight;
					// 插入到堆中
					heap.offer(new Pair(distance[nextNodeNo], nextNodeNo));
				}
			}
		}
		return distance[n] == INF ? -1 : distance[n];
	}
	
	// 添加一条边
	private static void add(int x, int y, int z) {
		e[idx] = y;
		w[idx] = z;
		ne[idx] = h[x];
		h[x] = idx++;
	}

	private static int readInt() {
		return scanner.nextInt();
	}
}

Bellman-Ford

算法思路

循环n次

每次循环,遍历图中所有的边。对每条边(a, b, w),(指的是从a点到b点,权重是w的一条边)更新d[b] = min(d[b], d[a] + w)

(可以定义一个类,或者C++里面的结构体,存储a,b,w。表示存在一条边a点指向b点,权重为w)。则遍历所有边时,只要遍历全部的结构体数组即可

循环的次数的含义:假设循环了k次,则表示,从起点,经过不超过k条边,走到每个点的最短距离。

该算法能够保证,在循环n次后,对所有的边(a, b, w),都满足d[b] <= d[a] + w。这个不等式被称为三角不等式。上面的更新操作称为松弛操作

该算法适用于有负权边的情况。

注意:如果有负权回路的话,最短路就不一定存在了。(注意是不一定存在)。当这个负权回路处于1号点到n号点的路径上,则每沿负权回路走一圈,距离都会减少,则可以无限走下去,1到n的距离就变得无限小(负无穷),此时1号点到n号点的最短距离就不存在。而如果负权回路不在1号点到n号点的路径上,则1到n的最短距离仍然存在。

该算法可以求出来,图中是否存在负权回路。如果迭代到第n次,还会进行更新,则说明存在一条最短路,路径上有n条边,n条边则需要n + 1个点,而由于图中一共只有n个点,所以这n + 1个点中一定有2个点是同一个点,则说明这条路径上有环;有环,并且此次进行了更新,说明这个环的权重是负的(只有更新后总的距离变得更小,才会执行更新)。

但求解负权回路,通常用SPFA算法,而不用Bellman-Ford算法,因为前者的时间复杂度更低。

由于循环了n次,每次遍历所有边(m条边)。故Bellman-Ford算法的时间复杂度是O(n×m)。

练习题:acwing - 853: 有边数限制的最短路

#include<iostream>
#include<cstring>
using namespace std;

const int N = 510, M = 10010;
const int INF = 0x3f3f3f3f;
struct Edge
{
	int a, b, w;
} edge[M]; // 直接用结构体来存储全部边

int n, m, k, d[N], tmp[N];

void bellman_ford() {
	memset(d, 0x3f, sizeof d);
	d[1] = 0; // 初始化
	for(int i = 0; i < k; i++) {
		memcpy(tmp, d, sizeof d); // 需要备份
		for(int j = 0; j < m; j++) {
			Edge e = edge[j];
			int a = e.a, b = e.b, w = e.w;
			if(tmp[a] == INF) continue;
			if(d[b] > tmp[a] + w) { 
                // 用备份的tmp来进行计算, 以防出现串联更新的情况
                // 串联更新虽然不影响最终的最短距离, 但会影响[经过不超过k条边的最短距离]这个语义
                // 比如在外层循环进行了2次时, 此时应当找到了从起点到其他点,经过不超过2条边的最短距离
                // 而如果串联更新的话, 可能在这第2次循环时, 已经更新了多次, 得到的最短距离是经过了3条边的最短距离
                
                // 另外, 上面的if中应该用 d[b] > tmp[a] + w
                // 而不应当用 tmp[b] > tmp[a] + w
                // 当a和b之间存在重边时, 若使用后者作为条件, 则无法取到a,b之间权重最小的那条边
				// 更新
				d[b] = tmp[a] + w;
			}
		}
	}
	if(d[n] == INF) printf("impossible");
	else printf("%d", d[n]);
}

int main() {
	scanf("%d%d%d", &n, &m, &k);
	for(int i = 0; i < m; i++) {
		int x, y, z;
		scanf("%d%d%d", &x, &y, &z);
		edge[i] = {x, y, z};
	}
	bellman_ford();
	return 0;
}
SPFA

若要使用SPFA算法,一定要求图中不能有负权回路。只要图中没有负权回路,都可以用SPFA,这个算法的限制是比较小的。

SPFA其实是对Bellman-Ford的一种优化。

它优化的是这一步:d[b] = min(d[b], d[a] + w)

我们观察可以发现,只有当d[a]变小了,才会在下一轮循环中更新d[b]

考虑用BFS来做优化。用一个队列queue,来存放距离变小的节点。(当图中存在负权回路时,队列永远都不会为空,因为总是会存在某个点,在一次松弛操作后,距离变小)

(和Dijkstra很像)

练习题:acwing - 851: spfa求最短路

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

const int N = 1e5 + 10;

const int INF = 0x3f3f3f3f;

int h[N], e[N], w[N], ne[N], idx;

int n, m;

int d[N];

int q[N], hh, tt = -1;

bool st[N];

void add(int a, int b, int c) {
    e[idx] = b;
    w[idx] = c;
    ne[idx] = h[a];
    h[a] = idx++;
}

void spfa() {
    memset(d, 0x3f, sizeof d);
    d[1] = 0;
    q[++tt] = 1;
    st[1] = true;
    while(tt >= hh) {
        int u = q[hh++];
        st[u] = false;
        for(int i = h[u]; i != -1; i = ne[i]) {
            int j = e[i];
            if(d[j] > d[u] + w[i]) {
                d[j] = d[u] + w[i];
                if(!st[j]) {
                    // 为了防止已在队列中的点, 被重复添加进队列
                    // 虽然不影响最终结果, 但会拖慢一点性能
                    st[j] = true;
                    q[++tt] = j;
                }
            }
        }
    }
}

int main() {
    memset(h, -1, sizeof h);
    scanf("%d%d", &n, &m);
    while(m--) {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        add(x, y, z);
    }
    spfa();
    if(d[n] != INF) printf("%d", d[n]);
    else printf("impossible");
    return 0;
}

SPFA的好处:能解决无负权边的问题,也能解决有负权边的问题,并且效率还比较高。但是当需要求在走不超过k条边的最短路问题上,就只能用Bellman-Ford算法了。

练习题:acwing - 852: spfa求负环

基本思路是,添加一个变量int ctn[N],来记录走到第i个点所经过的边的长度即可,如果走到某个点的边的个数大于n,则说明存在负权回路。题解思路可以参考https://www.acwing.com/solution/content/6336/。由于是求是否存在负环,而不是求从1号点能够走到的负权回路,所以我们要把全部点加入到队列。可以这样理解,在原图的基础上新建一个虚拟源点,从该点向其他所有点连一条权值为0的有向边。那么原图有负环等价于新图有负环。此时在新图上做spfa,将虚拟源点加入队列中。然后进行spfa的第一次迭代,这时会将所有点的距离更新并将所有点插入队列中。执行到这一步,就等价于下面代码的做法了。

#include<iostream>
#include<cstring>
using namespace std;

const int N = 1e4 + 10, M = 1e8 + 10;

const int INF = 0x3f3f3f3f;

int h[N], e[N], w[N], ne[N], idx;

int n, m;

int d[N], ctn[N];

bool st[N];

int q[M], hh, tt = -1;

void add(int a, int b, int c) {
    e[idx] = b;
    w[idx] = c;
    ne[idx] = h[a];
    h[a] = idx++;
}

bool spfa() {
    for(int i = 1; i <= n; i++) {
        q[++tt] = i;
        st[i] = true;
    }

    while(tt >= hh) {
        int u = q[hh++];
        st[u] = false;
        for(int i = h[u]; i != -1; i = ne[i]) {
            int j = e[i];
            if(d[j] > d[u] + w[i]) {
                d[j] = d[u] + w[i];
                ctn[j] = ctn[u] + 1;
                if(ctn[j] >= n) return true;
                if(!st[j]) {
                    q[++tt] = j;
                    st[j] = true;
                }
            }
        }
    }
    return false;
}

int main() {
    memset(h, -1, sizeof h);
    scanf("%d%d", &n, &m);
    while(m--) {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        add(x, y, z);
    }
    if(spfa()) printf("Yes");
    else printf("No");
    return 0;
}

其实这道题用Bellman-Ford来做,思路更简单

#include<iostream>
#include<cstring>
using namespace std;

const int N = 1e5;

int n, m;

struct Edge {
    int a, b, w;
} edges[N];

int d[N], tmp[N];


bool bellman_ford() {
    for(int i = 1; i <= n; i++) {
        memcpy(tmp, d, sizeof d);
        for(int j = 0; j < m; j++) {
            Edge e = edges[j];
            int a = e.a, b = e.b, w = e.w;
            if(d[b] > tmp[a] + w) {
                d[b] = tmp[a] + w;
                if(i == n) return true;
            }
        }
    }
    return false;
}

// bellman-ford判断负环
int main() {
    scanf("%d%d", &n, &m);
    for(int i = 0; i < m; i++) {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        edges[i] = {x, y, z};
    }
    if(bellman_ford()) printf("Yes");
    else printf("No");
    return 0;
}
Floyd

求解多源汇最短路问题,也能处理边权为负数的情况,但是无法处理存在负权回路的情况。

使用邻接矩阵来存储图。初始使用d[i][j]来存储这个图,存储所有的边

算法思路:三层循环

for(k = 1; k <= n; k++)

​ for(i = 1; i <= n; i++)

​ for(j = 1; j <= n; j++)

​ d[i,j] = min(d[i,j] , d[i,k] + d[k,j])

循环结束后,d[i][j]存的就是点ij的最短距离。

原理是基于动态规划(具体原理在后续的动态规划章节再做详解)。

其状态表示是:d(k, i, j),从点i,只经过1 ~ k这些中间点,到达点j的最短距离

练习题:acwing - 854: Floyd求最短路

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;
int g[N][N]; // 邻接矩阵存储
int n, m, k;

void floyd() {
	for(int p = 1; p <= n; p++) {
		for(int i = 1; i <= n; i++) {
			for(int j = 1; j <= n; j++) {
				if(g[i][p] != INF && g[p][j] != INF) g[i][j] = min(g[i][j], g[i][p] + g[p][j]);
			}
		}
	}
}

int main() {
	scanf("%d%d%d", &n, &m, &k);
	for(int i = 1; i <= n; i++) {
		for(int j = 1; j <= n; j++) {
			if(i == j) g[i][j] = 0;
			else g[i][j] = INF;
		}
	}
	while(m--) {
		int x, y, z;
		scanf("%d%d%d", &x, &y, &z);
		g[x][y] = min(g[x][y], z); // 处理重边
	}
	floyd();
	while(k--) {
		int x, y;
		scanf("%d%d", &x, &y);
		if(g[x][y] == INF) printf("impossible\n");
		else printf("%d\n", g[x][y]);
	}
	return 0;
}

总结

几种求解最短路的算法,各自的思路都不太相同,现做一个简单的总结

  • Dijkstra

    只适用于边权为正的情况。是从点的维度出发,每次循环确定起点到某一个点的最短距离,循环n次,则确定全部n个点到起点的最短距离。其思想有点类似BFS或者贪心,每次选一个距离最小的点,很保守的一点点地往外扩张,而不像DFS一条路走到黑。

    注意有一个集合s的概念,每轮循环确定了一个点的最短距离后,就把这个点加入集合s。集合s表示的就是当前哪些点已经确定了最短距离了。已经确定了的点,之后的循环就不再考虑它们了。

    每次循环,从未确定最短距离的点中,找一个距离最小的点a,则点a的最短距离已经确定,将其加入集合s,随后用a点去更新其他点(准确的说是和a相连的那些点(a的出边))的距离,本轮循环结束。

    实际代码中,我们用一个布尔类型的数组,来表示一个点是否加入了集合s

    堆优化版的Dijkstra:优化的就是每轮循环,选择一个距离最小的点这一步,使用小根堆可以在O(1)的时间复杂度选出距离最小的点

    在选取一个距离最小的点后,只需要更新这个点所有出边,连接的那些点即可。所以用邻接表来存储比较合适。而邻接表适合存储稀疏图。所以在稀疏图时,可以采用堆优化Dijkstra,使用邻接表来存储图。稠密图的情况,则选用朴素Dijkstra,使用邻接矩阵来存储图。

    朴素Dijkstra的时间复杂度是O(n2),堆优化Dijkstra的时间复杂度是O(mlogn)

  • Bellman-Ford

    适用于存在负权边的情况。是从边的维度出发。首先还是初始化所有点的距离为正无穷,然后初始化起点的距离为0。

    循环n次(准确地说应该是n-1次,因为只需要走n-1条边就能从1号点走到n号点),每次循环遍历所有的边(一个边用[a, b, w]表示,a点到b点,权重为w),每次更新这条边右端点(b点)的距离。d[b] = min(d[b], d[a] + w)

    其实只有当d[a]不是正无穷时,才需要更新。如果无脑更新,则当d[a]是正无穷,且w是负数的话,d[b]会被更新为一个比正无穷稍小一点的数,在实际编码中可能出错。而由于d[a]为正无穷(a点不可达),则其实b点应该也保持为正无穷(b点也不可达)。

    Bellman-Ford可以用来求解,经过不超过k条边的最短距离。因为每次迭代,都是把边往前走了一步(对于那些a点不可达的边[a, b, w],不更新d[b])。需要注意,更新距离数组d时,需要提前备份一下d,并使用上一轮的备份来进行更新。这是为了防止串联更新的情况。

    串联更新,就比如有3个点,2条边,a -> b -> c,则第一次循环时,先根据[a, b]这条边更新了d[b],然后访问到[b ,c]这条边时又会更新d[c],但第一次循环时,只走出去一条边,是不应当走到c点的。但如果直接使用d数组来更新,则访问[b ,c]这条边时,由于先前访问[a, b]时更新了d[b],则这次使用了新的d[b],则会更新d[c]

    串联更新会影响【第k次循环,找到不超过k条边的最短距离】这一语义。虽然它不会影响最终的最短距离。

    另外,更新时的条件判断还要注意,需要特别关注重边的情况,说明如下

    void bellman_ford() {
        // 循环k次
        for(int i = 0; i < k; i++) {
            // 使用备份数组来进行更新, 以避免出现串联更新的情况, 串联更新会影响k次循环找到经过不超过k条边的最短距离的语义
            memcpy(tmp, d, sizeof d);
            // 每次枚举全部边
            for(int j = 0; j < m; j++) {
                int a = edges[j].a, b = edges[j].b, w = edges[j].w;
                if(tmp[a] != INF && d[b] > tmp[a] + w) {
                    // 注意这里后面要用 d[b] > tmp[a] + w
                    // 而不能用        tmp[b] > tmp[a] + w  
                    // 当a和b存在重边时, 需要保证用到的是最短的那条边
                    // 如果采用 tmp[b] > tmp[a] + w , 则无法保证这一点, 可自行验证
                    d[b] = tmp[a] + w;
                }
            }
        }
    }
    

    Bellman-Ford也能用来判断图中是否存在负权回路。上面说了,实际只需要循环n-1次,若第n次循环仍然执行了距离的更新,则说明,从1号点到另一个点的最短距离,经过了n条边,n条边,则路径上有n+1个点,而由于一共只有n个点,所以这n+1个点中一定有2个点相同,则这条最短距离的路径上存在一个环,并且只有当距离变小时,才会执行更新,那么说明这个环的权重是负的。

  • SPFA

    SPFA是对Bellman-Ford的优化。上面我们知道,Bellman-Ford是每次遍历所有边,并更新d[b] = min(d[b], d[a] + w)

    初始时所有点的距离都是正无穷的,而根据上面的式子,一条边的w是不变的,则只有当一条边的a点的距离d[a]变小了,下一轮循环才有可能会更新d[b]。于是我们只需要关注那些距离变小的点即可。

    则采用类似BFS的思路,使用一个队列来保存那些距离变小的点。其代码思路相比Bellman-Ford而言,非常简单。所以通常对于存在负权边的最短路问题,通常直接用SPFA(甚至不存在负权边的问题,也可以用SPFA)。只有当类似于【求不超过k条边的最短路问题】时,才需要用Bellman-Ford。

    **但是!SPFA无法用于存在负权回路的情况!**因为SPFA是用一个队列来存储距离变小的点,若存在负权回路,则队列永远不会为空,会无限循环。而Bellman-Ford由于只循环n次,即使有负权回路,也不会出现死循环。

    SPFA代码如下,非常简单。

    void spfa() {
        memset(d, 0x3f, sizeof d); // 距离初始化为正无穷
        d[1] = 0; // 起点距离为0
        q[++tt] = 1; // 起点距离变小, 入队
        while(tt >= hh) { // 当队列不空
            int u = q[hh++]; // pop 队头
            // 遍历所有出边, 进行可能的更新
            for(int i = h[u]; i != -1; i = ne[i]) {
                int j = e[i];
                if(d[j] > d[u] + w[i]) { 
                    // 一定要写这个判断, 因为d[a]变小后, 对于边[a, b, w], 不一定会保证d[b]也变小(可能b点有其他更短的路径)
                    d[j] = d[u] + w[i];
                    q[++tt] = j; // j点距离变小了, 入队
                }
            }
        }
    }
    

    突然感受到SPFA的魅力,思路和代码都极其简洁。单源最短路问题可以直接用SPFA通吃!完全不用什么Dijkstra和Bellman-Ford!

    然而,通过几道练习题,当需要判断图中是否有负权回路时,还是Bellman-Ford比SPFA好用。

  • Floyd

    这个算法没怎么去细细研究其背后的数学原理(貌似是动态规划中的状态转移),暂时先留着,等学到后面dp的部分再来补。TODO

(2021/07/07 更新)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值