#953. [笔记]图论II_最短路
[笔记]图论II_最短路
No testdata at current.
No submission language available for this problem.
数据结构应用——图论 II 最短路
问题特点
求解图论问题中,两点之间权值和最值问题。(多数情况是最小值问题,偶会出现求解最大值问题)
图论中最短路算法是最常见问题模型以及其它算法的基础,如图上动规往往会有最短路的影子,特别是 Floyed 算法是由动规划分阶段而得。
最短路算法:
多源最短路:计算任意两点之间的最短路
- Floyed-Warshall
单源最短路:计算起点到其它点之间的最短路
- Dijkstra
- Bellman-Ford
- SPFA
设计图1:

// + 表示有向边 - 表示无向边
3 3
- 1 2 3
- 1 3 2
+ 2 3 2
设计图2:

// + 表示有向边 - 表示无向边
3 3
- 1 2 3
- 1 3 2
+ 2 3 -2
设计图3:

// + 表示有向边 - 表示无向边
3 4
- 1 2 3
- 1 3 2
+ 2 3 -2
+ 3 2 -2
// 最后两条边,相当于一条无向边,边权为 -2
// 即 - 2 3 -2
一、Floyed-Warshall
作为最简单的最短路算法,只是写法上相对简单。
核心思想:计算两点间最短路时,发现可以通过第三个点绕路会更优,则更新两点间最短路。另 g[i][j] 表示顶点 到顶点 的最短路径,数学释义为:
简单证明(动态规划)
确定状态:令 表示以前 个顶点集合获得的
i->j的最短路长度。阶段划分:讨论第 个顶点,根据核心思想,即将
i->j以i->k->j方式进行讨论。表示
i->j在前 个顶点集合时的最短路表示
i->k在前 个顶点集合时的最短路表示
k->j在前 个顶点集合时的最短路状态转移:
$$g[i][j][k] = min\{g[i][j][k - 1], g[i][k][k-1] + g[k][j][k-1]\}; $$初始化:g[i][j][0] 表示在前 0 个顶点集合时的最短路,即原始图的直接顶点边数据。若 i->j 无边则以无穷大表示无边,且 g[i][i][0] = 0;
计算顺序:
用 g[i][j][0] 计算 g[i][j][1]
用 g[i][j][1] 计算 g[i][j][2]
...
用 g[i][j][k-1] 计算 g[i][j][k]
...
用 g[i][j][N-1] 计算 g[i][j][N]
即最终 g[i][j][N] 表示前 N 个顶点集合时候,i->j 的最短路。
// 设计图 1 的样例代码
#include <bits/stdc++.h>
using namespace std;
const int N = 101;
const int INF = 0x3f3f3f3f;
int g[N][N][N]; // g[N][N][0] 相当于邻接矩阵
int n, m; // n 顶点数,m 边数
int main() {
memset(g, 0x3f, sizeof g);
cin >> n >> m;
// 正常情况下,不考虑重边与自环
for (int i = 1; i <= m; i ++) {
char edge_type; // + 有向边,- 无向边
int u, v, w;
cin >> edge_type >> u >> v >> w;
if (edge_type == '+') {
g[u][v][0] = w;
// 若有重边则:g[u][v][0] = min{g[u][v][0], w};
} else if (edge_type == '-') {
g[u][v][0] = g[v][u][0] = w;
// 若有重边则:g[u][v][0] = g[v][u][0] = min(g[u][v][0], w);
}
}
for (int i = 1; i <= n; i ++)
g[i][i][0] = 0;
for (int k = 1; k <= n; k ++) {
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= n; j ++) {
if (g[i][j][k - 1] > g[i][k][k - 1] + g[k][j][k - 1]) {
g[i][j][k] = g[i][k][k - 1] + g[k][j][k - 1];
} else {
g[i][j][k] = g[i][j][k - 1];
}
}
}
}
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= n; j ++) {
printf("(%2d->%-2d)%-4d ", i, j, g[i][j][n]);
}
cout << endl;
}
return 0;
}
观察发现,无论从计算过程还是代码过程,[k] 状态只会被 [k - 1] 状态所影响,因此可以用滚动数组思想,等价变换转移方程:
将三维数组降为二维数组
// 设计图1
#include <bits/stdc++.h>
using namespace std;
const int N = 101;
const int INF = 0x3f3f3f3f;
int g[N][N]; // g[N][N][0] 相当于邻接矩阵
int n, m; // n 顶点数,m 边数
int main() {
memset(g, 0x3f, sizeof g);
cin >> n >> m;
// 正常情况下,不考虑重边与自环
for (int i = 1; i <= m; i ++) {
char edge_type; // + 有向边,- 无向边
int u, v, w;
cin >> edge_type >> u >> v >> w;
if (edge_type == '+') {
g[u][v] = w;
// 若有重边则:g[u][v] = min{g[u][v], w};
} else if (edge_type == '-') {
g[u][v] = g[v][u] = w;
// 若有重边则:g[u][v] = g[v][u] = min(g[u][v], w);
}
}
for (int i = 1; i <= n; i ++)
g[i][i] = 0;
for (int k = 1; k <= n; k ++) {
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= n; j ++) {
if (g[i][j] > g[i][k] + g[k][j]) {
g[i][j] = g[i][k] + g[k][j];
}
// if (g[i][j] > g[i][k] + g[k][j]) {
// g[i][j] = g[i][k] + g[k][j];
// } else {
// g[i][j] = g[i][j];
// }
}
}
}
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= n; j ++) {
printf("(%2d->%-2d)%-4d ", i, j, g[i][j]);
}
cout << endl;
}
return 0;
}
动规算法属于规划性最优,因此Floyed 可以解决负边权问题。
判负环也比较容易,若出现 g[i][i] < 0 则说明有负环。
二、Dijkstra
单源最短路径中,比较常用的算法,讨论的是从一个起点到其它顶点的最短路问题。
朴素 Dijkstra 可以进一步优化,这里暂不进行讨论优化部分。
核心思想:蓝白点标记+贪心。蓝点表示还未达成最短路的顶点,白点表示已经达成该算法的最短路顶点。
贪心思想:从蓝点中找到离起点最近的一个顶点作为新起点,从它开始遍历所有的邻接边,松弛它的邻接顶点。(类似每次都找一条最短的路行走更新顶点)
通过以上描述:
- 邻接表存图比较合适该算法。
- 需要标记数组做蓝白点标记。
- 需要距离数组记录距起点的距离,初始除了起点为 0,其它都初始化为无穷远。
松弛:单源点为 s,找到一个顶点 u 作为新起点,一条邻接边为u->v: w,可以考虑 s->u->v 是否更优,数学释义则:
// 设计图1
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
const int N = 101;
const int INF = 0x3f3f3f3f;
struct Edge{ int v, w; };
int n, m; // n 顶点数,m 边数
vector<Edge> g[N]; // 邻接表
int dis[N]; // 距离数组
int vis[N]; // 标记数组 0 表示蓝点,1 表示白点
// false:蓝点 true:白点
int main() {
cin >> n >> m;
// 正常情况下,不考虑重边与自环
for (int i = 1; i <= m; i ++) {
char edge_type; // + 有向边,- 无向边
int u, v, w;
cin >> edge_type >> u >> v >> w;
if (edge_type == '+') { // 有向边是单向边
g[u].push_back(Edge{v, w});
} else if (edge_type == '-') { // 无向边是双向边
g[u].push_back(Edge{v, w});
g[v].push_back(Edge{u, w});
}
}
memset(dis, 0x3f, sizeof dis);
int s = 1; // 单源起点
dis[s] = 0;
for (int i = 1; i <= n; i ++) {
// 找一个最近的点
int u = -1, minn = INF;
for (int j = 1; j <= n; j ++) {
if (vis[j] == 0) { // 蓝点
if (minn > dis[j]) {
u = j;
}
}
}
// 表示找不到近点了,比如单源起点是孤岛,直接退
if (u == -1) { break; }
vis[u] = 1; // 将起点标记为白点
// 遍历邻接边
for (int j = 0; j < (int)g[u].size(); j ++) {
int v = g[u][j].v, w = g[u][j].w;
// 松弛
if (vis[v] == 1) continue; // 事实上可以删去,但逻辑上就破坏了 Dijkstra 的蓝白标记法
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
// 实际上还可以做前源点记录,如 pre[v] = u;
}
}
}
for (int i = 1; i <= n; i ++) { printf("%4d ", i); }
cout << endl;
for (int i = 1; i <= n; i ++) {
if (dis[i] == INF) printf(" inf");
else printf("%4d ", dis[i]);
}
cout << endl;
return 0;
}
贪心算法的最优解需要在某条件下的严格证明,对于 Dijkstra 的贪心思路,无法解决负边权问题(设计图2)进而无法判断负环。
三、Bellman-Ford
单源最短路中,考虑到负边与负环问题,Dijkstra 无法解决。于是提出新的单源最短路径算法:Bellman-Ford。
在算法逻辑上,可以认为是对 Dijkstra 的内容递进,底层原理也是蓝白点标记。
核心思路:直接进行图上全边松弛。借由 Dijkstra 原理,一个白点标记松弛后,必然出现下一个白点。而其松弛的方式为松弛该白点所有邻接边。显然某个顶点的邻接边为图上全边的子集,则若进行一次图上全边松弛,至少在图上会出现一个白点。由于起点一开始便是白点,因此只要进行 n - 1 次图上全边松弛即可。
显然在这种解法下,只要出现图上全边松弛中的松弛操作一次都不成功,说明图上最短路已经完成。Bellman 可以解决合理负边权问题,且事实上经常不需要进行 n - 1 次的全边松弛操作,图上最短路就已经完成。(可以使用标记提前退出最短路算法)
但若负边权值过大,或负环,如无向负权边 (设计图3),由于绕一圈便会令最短路更短,因此松弛操作将在第 n 次全边操作时候还会发生,可由此判是否出现图上负环。
// 所有设计图
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
const int N = 101;
const int INF = 0x3f3f3f3f;
// 边
struct Edge{ int u, v, w; };
Edge eg[N]; // 边集数组
int ei; // 边集数量
int dis[N]; // 距离数组
int n, m; // n 顶点数,m 边数
int main() {
cin >> n >> m;
// 正常情况下,不考虑重边与自环
for (int i = 1; i <= m; i ++) {
char edge_type; // + 有向边,- 无向边
int u, v, w;
cin >> edge_type >> u >> v >> w;
if (edge_type == '+') { // 有向边是单向边
eg[++ ei] = Edge{u, v, w};
} else if (edge_type == '-') { // 无向边是双向边
eg[++ ei] = Edge{u, v, w};
eg[++ ei] = Edge{v, u, w};
}
}
memset(dis, 0x3f, sizeof dis);
int s = 1; // 单源起点
dis[s] = 0;
bool ok;
// n - 1 次
for (int i = 1; i <= n - 1; i ++) {
ok = true; // 松弛标记
// 全边松弛
for (int j = 1; j <= ei; j ++) {
int u = eg[j].u, v = eg[j].v, w = eg[j].w;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
ok = false; // 有松弛成功
}
}
if (ok) break; // 全边无松弛则直接退
}
ok = true;
for (int j = 1; j <= ei; j ++) {
int u = eg[j].u, v = eg[j].v, w = eg[j].w;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
ok = false; // 有松弛成功
}
}
if (!ok) {
cout << "有负环" << endl;
} else {
for (int i = 1; i <= n; i ++) { printf("%4d ", i); }
cout << endl;
for (int i = 1; i <= n; i ++) {
if (dis[i] == INF) printf(" inf");
else printf("%4d ", dis[i]);
}
cout << endl;
}
return 0;
}
四、SPFA
该算法实际上是 Bellman-Ford 的队列优化。Bellman 是进行全边松弛,中间会有很多的 "废操",即冗余判断。那么既然可知全边松弛可以增加至少一个白点,为了减少冗余,可以直接从单源点 (第一个白点) 出发,做一个可撤销操作的蓝白点标记法,且关注所有相关的邻接边松弛即可。(有点像 Dijkstra + Bellman-Ford)
核心思想:只关心被松弛的顶点,减少无效冗余。
若有比较熟练的 Dijkstra 和 Bellman 的代码构建能力,根据上面的描述,基本可以构建 SPFA 的代码。
构建核心:广搜思想,用队列维护被松弛的顶点(白点),每次取出一个白点标为蓝点进行邻接边松弛操作。
由于 SPFA 是 Bellman 的优化,因此同样可以解决负边权和判负环问题。但对负环的判定方式有所不同,可以对顶点入队列次数进行计数:若某一顶点入队列的次数超过 n 次,则表示有负环。(另一种办法暂不在此处表述)
#include <iostream>
#include <vector>
#include <cstring>
#include <queue>
using namespace std;
const int N = 101;
const int INF = 0x3f3f3f3f;
struct Edge{ int v, w; };
vector<Edge> g[N];
int dis[N]; // 距离数组
int vis[N]; // 标记 1 白点 0 蓝点
int cnt[N]; // 顶点进入队列的计数
int n, m; // n 顶点数,m 边数
queue<int> qe;
int main() {
cin >> n >> m;
// 正常情况下,不考虑重边与自环
for (int i = 1; i <= m; i ++) {
char edge_type; // + 有向边,- 无向边
int u, v, w;
cin >> edge_type >> u >> v >> w;
if (edge_type == '+') { // 有向边是单向边
g[u].push_back(Edge{v, w});
} else if (edge_type == '-') { // 无向边是双向边
g[u].push_back(Edge{v, w});
g[v].push_back(Edge{u, w});
}
}
memset(dis, 0x3f, sizeof dis);
int s = 1; // 单源起点
dis[s] = 0;
// 判负环,也许负环不包含源点
for (int i = 1; i <= n; i ++) {
vis[i] = 1;
cnt[i]++;
qe.push(i);
}
bool ok = true;
while (!qe.empty() && ok) {
int u = qe.front(); // 取出一个白点作为起点
vis[u] = 0; // 撤销白点作为蓝点,一边下次松弛
qe.pop(); // 从队列中删去 u 顶点
for (int j = 0; j < (int)g[u].size(); j ++) {
int v = g[u][j].v, w = g[u][j].w;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w; // 松弛成功
if (vis[v] == 0) { // 若此刻它是蓝点,标记为白点入队列
vis[v] = 1;
qe.push(v);
cnt[v] ++;
if (cnt[v] >= n) { // 出现负环
ok = false;
break;
}
}
}
}
}
if (!ok) {
cout << "有负环" << endl;
} else {
for (int i = 1; i <= n; i ++) { printf("%4d ", i); }
cout << endl;
for (int i = 1; i <= n; i ++) {
if (dis[i] == INF) printf(" inf");
else printf("%4d ", dis[i]);
}
cout << endl;
}
return 0;
}
Related
In following homework: