图论:最短路
参考资料:
《算法竞赛进阶指南》
《算法竞赛从入门到进阶》
浙江大学《数据结构(第二版)》
例题:
前置知识:使用邻接矩阵,邻接表存储和访问图(特别是有权图)。
简略复习一下,邻接表可以链表,数组,STL中的vector来实现。用数组的方法又称链式前向星存图。我们使用一个表头数组head记录了从每个节点出发的第一条边在ver和edge数组中的存储位置,使用边集数组ver和edge记录了每条边的终点和边权,使用数组Next模拟了链表中的指针,表示从相同起点出发的下一条边在ver和edge数组中的存储位置。核心代码如下:
//加入有向边(x, y), 权值为z
void add(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z;
Next[tot] = head[x], head[x] = tot;
}
//访问从x出发的所有边
for (int i = head[x]; i != -1; i = next[i]) {
int y = ver[i], z = edge[i];
//找到了一条有向边(x, y), 权值为z
}
单源最短路径
单源最短路径问题是说,给定一张有向图(无向图可以视为特殊的有向图)G = (V, E), V是点集, E是边集, |V| = n, |E| = m。给定一个起点s,求长度为n的数组dist,其中dist[i]表示从起点s到节点i的最短路径长度。
数据结构和算法动态可视化
Dijkstra算法
Dijkstra算法只适用于所有边的长度(权值)都是非负数的图,用于求解单源最短路径问题。
- 算法流程:
- 初始化dist[s] = 0, 其余结点的dist值为正无穷大。
- 找出一个未被标记的、dist[x]最小的节点x,然后标记节点x。
- 扫描节点以x为起点的所有边(x, y, z),若dist[y] > dist[x] + z,则使用dist[x] + z更新dist[y]。
- 若还有未标记的节点,重复2~3两个步骤。
- 算法原理:
- 将图中的点分为两部分,点集S1 = {起点s, 已经确定最短路径的点(即dist中的值就是要求的最短路径长度)}, S2 = {未确定最短路径的点}。注意在该算法中,dist数组中存储的最短路径是路径上只包含S1中的点的最短路径长度。
- 容易发现S1中的点,其dist数组的值已不可能被其他点优化。那么可以在S2中也寻找到这样的一个点,将其放入S1中。用该点优化一遍它的邻接点。重复该步骤直至所有的点都被放入S1中。注释:若存在一条边(x, y, z),有dist[y] > dist[x] + z,则从s到y的最短路径可以由s先到x,再由x到y,称为用点x优化点y,优化后dist[y] = dist[x] + z。
- S2中dist值最小的点就是我们要寻找的点。证明:记该点为v1,假设v1的最短路径长度还可以被S2中的其他点优化,记优化后的最短路径中与S1相接的点为v2,v2至v1之间的边权和为z,那么可以得到:dist[v2] + z < dist[v1],显然这与已知条件dist[v1]最小不符,故v1就是满足条件的点。可以将其放入S1中。
- 算法开始时,点集S1中只包含起点,起点的最短路径长度显然已经确定,值为0。由上文证明可知,每一次操作将确定一个点的最短路径长度,经过n-1次循环后所有点的最短路径长度都已经确定,算法结束。
- 代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 105, M = 20010;
int tot, ver[M], Next[M], head[N], edge[M];
int dist[N];
bool vis[N];
void addedge(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z;
Next[tot] = head[x], head[x] = tot;
}
void dijkstra(int s, int n) {
memset(dist, 0x3f, sizeof(dist));
memset(vis, false, sizeof(vis));
dist[s] = 0;
int x = 0;
for (int i = 0; i < n; i++) {
x = 0;
for (int j = 1; j <= n; j++) {
if (!vis[j] && (x == 0 || dist[j] < dist[x])) x = j;
}
vis[x] = true;
for (int j = head[x]; j; j = Next[j]) {
int y = ver[j], z = edge[j];
if (dist[y] > dist[x] + z) dist[y] = dist[x] + z;
}
}
}
int main() {
int n, m, s, x, y, z;
while (~scanf("%d %d %d", &n, &m, &s)) {
memset(head, -1, sizeof(head));
tot = -1;
for (int i = 0; i < m; i++) {
scanf("%d %d %d", &x, &y, &z);
addedge(x, y, z);
}
dijkstra(s, n);
for (int i = 1; i <= n; i++) {
printf("%d%c", dist[i], i == n ? '
' : ' ');
}
}
return 0;
}
Bellman-Ford算法
- 算法流程:
- 扫描所有边(x, y, z),若dist[y] > dist[x] + z,则用 dist[x] + z 更新 dist[y]。
- 重复上述步骤直至没有更新操作发生。
- Bellman算法只需进行n - 1次即可。下面给出证明。
- 对于图中最长的最短路径,最多只会包含n - 1条边(即经过n个节点),否则说明该最短路径重复经过了某些点,形成了回路(而且是一个负圈,如果是正的则最短路径中不会包含),显然这与已知矛盾。
- 既然最长的路径只可能包含n - 1条边,那么每扫描所有边一次可以确定其中一条边,循环n - 1次就可以确定全部路径。
- 代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
const int M = 2e4 + 10;
struct node {
int x, y, z;
} e[M];
int dist[N];
void Bellman(int s, int n, int m) {
memset(dist, 0x3f, sizeof(dist));
dist[s] = 0;
for (int i = 1; i < n; i++) {
for (int j = 0; j < m; j++) {
int x = e[j].x, y = e[j].y, z = e[j].z;
if (dist[y] > dist[x] + z) dist[y] = dist[x] + z;
}
}
}
int main() {
int n, m, s, x, y, z, cnt;
while (~scanf("%d %d %d", &n, &m, &s)) {
cnt = 0;
for (int i = 0; i < m; i++) {
scanf("%d %d %d", &x, &y, &z);
e[cnt++] = {x, y, z};
}
Bellman(s, n, cnt);
for (int i = 1; i <= n; i++) {
printf("%d%c", dist[i], i == n ? '
' : ' ');
}
}
return 0;
}
SPFA算法
SPFA算法通称为“队列优化的 Bellman-Ford算法”,只在中国大陆流行“SPFA算法”的称谓。
- 算法流程:
- 建立一个队列,最初队列中只含有起点s。
- 取出队头节点x,扫描它的所有出边(x, y, z),若dist[y] > dist[x] + z,则更新dist[y]。同时,若y不在队列中,则把y入队。
- 重复上一步骤直至队列为空。
- 原理: 在Bellman-Ford算法中,若dist[x]没有发生改变,则对于x的所有出边(x, y, z)的扫描都是无效的。为了减少这些冗余扫描,使用一个队列存储dist值发生了改变的节点,对它的出边进行扫描直至队列为空(即已经不存在dist值可以发生改变的点)。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
const int M = 2e4 + 10;
int head[N], ver[M], edge[M], Next[M], tot;
int dist[N];
bool vis[N];
void addedge(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z;
Next[tot] = head[x], head[x] = tot;
}
void spfa(int s) {
memset(dist, 0x3f, sizeof(dist));
memset(vis, false, sizeof(vis));
dist[s] = 0, vis[s] = true;
queue<int> Q;
Q.push(s);
while (!Q.empty()) {
int x = Q.front();
Q.pop();
vis[x] = false;
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i], z = edge[i];
if (dist[y] > dist[x] + z) {
dist[y] = dist[x] + z;
if (!vis[y]) Q.push(y), vis[y] = true;
}
}
}
}
int main() {
int n, m, s, x, y, z;
while (~scanf("%d %d %d", &n, &m, &s)) {
memset(head, 0, sizeof(head)), tot = 0;
for (int i = 0; i < m; i++) {
scanf("%d %d %d", &x, &y, &z);
addedge(x, y, z);
}
spfa(s);
for (int i = 1; i <= n; i++) {
printf("%d%c", dist[i], i == n ? '
' : ' ');
}
}
return 0;
}
任意两点间最短路径
为了求出图中任意两点间的最短路径,当然可以把每一个点当做起点求解N次单源最短路径问题。不过对于该类问题,使用Floyd算法可以用非常简单的程序解决。
Floyd算法
设D[k, i, j] 表示 “经过若干个编号不超过 k 的结点” 从 i 到 j 的最短路长度。该问题可以划分为两个子问题,经过编号不超过 k - 1 的节点从 i 到 j,或者从 i 先到 k 再到 j。于是:
D[k, i, j] = min(D[k - 1, i, j], D[k - 1, i, k] + D[k - 1, k, j]);
初值为 D[0, i, j] = A[i, j],其中A为邻接矩阵。可以发现,Floyd算法的本质是动态规划。其中k是阶段,我们可以通过D[0, i, j]求出D[1, i, j],再由D[1, i, j]求出D[2, i, j],直至求出D[n, i, j],算法结束。
- 代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
const int INF = 0x3f3f3f3f;
int dist[N][N];
void Floyd(int n) {
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
if (dist[i][k] != INF)
for (int j = 1; j <= n; j++)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
int main() {
int n, m, x, y, z;
while (~scanf("%d %d", &n, &m)) {
memset(dist, 0x3f, sizeof(dist));
for (int i = 0; i <= n; i++) dist[i][i] = 0;
for (int i = 0; i < m; i++) {
scanf("%d %d %d", &x, &y, &z);
dist[x][y] = min(dist[x][y], z);
}
Floyd(n);
for(int i=1; i<=n; i++) {
for(int j=1; j<=n; j++) {
printf("%d%c", dist[i][j], j==n?'
':' ');
}
}
}
return 0;
}