在图论问题中,最短路算法是一个典型且很常用的算法。面对不同的情况,要选择不同的算法。本文用n代表点数,m代表边数。下面给出一个思维导图:
算法并不难,问题的关键在于建图,这个需要做题积累经验。下面只给出算法的基本实现,还需各位同学多多做题。
目录
1、单源最短路
1.1、所有边权都是正数
对于边权都是正数的问题,有两种解决方法:朴素Dijkstra和堆优化Dijkstra,这两种算法没有高低贵贱之分,它们适用于不同的情况。
朴素Dijkstra的时间复杂度为 O(n2),堆优化Dijkstra的时间复杂度为 O(mlogn),由此我们能得出:当一个图为稠密图(边数m和点数n的平方为一个数量级)时,使用朴素Dijkstra;当一个图为稀疏图(边数m和点数n为一个数量级)时,使用堆优化Dijkstra。两种算法的思路都是相同的,只不过实现的方式略有不同。首先来说明一下两种算法的实现思路。
在建好图后:
- 初始化所有点的距离。起点的距离自然为0,其余点的距离先初始化为正无穷(一个很大的数即可)。并且设置一个标记,用来记录已经确认最短距离的点,意思就是被标记的点到起点的最短距离已经确定(初始所有点都未确定)。
- 设置一个循环来遍历所有点(每次迭代都能确定一个点的最短距离),每次迭代首先找到未确定最短距离点中距离起点最近的点(第一次迭代自然找到的是起点,因为起点离起点最近),并将找到的点标记为已确定,之后用这个找到的点来更新所有点的距离(也就是比较一下,是之前的路径短,还是通过这个点再到指定点的距离短)。循环结束后,即可求得所有点的最短距离。
举个例子来说明这个过程,首先有这样一张图,假设点1是起点:
首先找到点1,标记点1为已确定,并且更新一下所有点的距离,更新后1到2的距离为2,1到3的距离为4;之后进入下一次迭代,所有未确定点中距离起点最近的点是点2,那么第二次迭代标记点2为已确定,并且用点2更新所有点的距离,此时发现,由起点1经过点2到点3的距离为3,比之前由1直接到点3的距离近,所以更新3的最短距离为3;最后一次迭代标记点3,并且循环结束了,所有点的最短距离已经确定。
在这里要说明一个问题:为什么每次找到未确定最短距离点中距离起点最近的点就可以打上标记。因为未确定点中最近的点的距离就是该点的最短距离,我们假设其距离为 k ,那么经由其他未确定点到该点的距离一定是一个大于k的数加上一个正数,一定会比k大,所以k即是确定的最短距离。
1.1.1、朴素Dijkstra
朴素的Dijkstra就是把前面提到的步骤简单实现出来。注意因为朴素Dijkstra适用于稠密图,所以应该用邻接矩阵来存储图。下面给出模板代码:
//二维数组g为存储图的邻接矩阵,举个例子:
//g[a][b]的值为:点a到点b的边长
int dijkstra()
{
//一维数组dist表示每个点到起点的最小距离,初始所有点都为无穷大
memset(dist, 0x3f, sizeof dist);
//起点到起点的距离为0
dist[1] = 0;
//循环 n - 1 次即可,因为最后一个点在倒数第二个点确定的时候,其最短距离也已经确定了
//如果想不明白的话,写成 i < n 也是可以的
for (int i = 0; i < n - 1; i ++ )
{
int t = -1;
//找到未确定的点中距离起点最近的点,其中st数组表示每个点的状态(已确定/未确定)
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
//用找到的点更新所有点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
//给找到的点打上标记,代表其最短距离已确定
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
else return dist[n];
}
时间复杂度分析:外层循环进行n次,内层有两个循环,一个是找到最近的点,需要寻找n次,一个是更新所有点,需要更新n次,所以总的来看,这是一个二重循环,时间复杂度为O(n2)。
1.1.2、堆优化Dijkstra
在上面提到的时间复杂读分析中,可以得到:外层循环+找最近的点时间复杂度为O(n2)。
同时可以发现,更新所有点距离的过程其实就是更新每个点的所有出边,因为稀疏图是用邻接表来存储的,所以其有效计算次数在嵌套外层循环的情况下,加起来就是所有点的出边,也就是所有的边。所以可以注意到用邻接表存储图的情况下更新操作的总时间复杂度是O(m)。
所以可以发现:在稀疏图中最慢的过程是找最近的点,其总时间复杂度为O(n2)。那么就要对其进行优化。注意到找最近的点可以通过小根堆来实现,因此每次迭代找最近的点时间复杂度就变成O(1)了,但是由于在小根堆中插入数据的时间复杂度是O(logn)的,最坏情况要插入m次(因为要更新m次),所以更新操作的时间复杂度就变为O(mlogn)。所以总的时间复杂度就变为了O(mlogn)。
实现堆优化选择优先队列来实现,但是优先队列不支持修改任意元素,所以这里采用一个冗余的做法,打个比方,某点到起点的距离可能既在堆中存了15,又存了10,但是因为是小根堆,所以15一定是被沉到下面的,所以只是浪费了一点空间而已,反正取的时候要取小的。
此时堆中元素可能会变为m,但是logm和logn是一个数量级的,所以其实也无伤大雅。主要是因为手写堆太麻烦,所以这里采用stl库中的优先队列(priority_queue)。
由于堆优化Dijkstra实现较为繁琐,所以给出裸题和完整代码,模板即为Dijkstra函数。原题:acwingDijkstra求最短路 II。题解:
#include <iostream>
#include <algorithm>
#include <cstring>
#include <queue>
using namespace std;
typedef pair<int, int> PII;
const int N = 1e6 + 10;
int n, m;
int h[N], e[N], ne[N], w[N], idx;
int dist[N];
bool st[N];
//向邻接表里加入一条边,由a指向b,边权为c
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
//小根堆的固定声明方式,注意这是一个pair堆,first存储到起点的距离,second存储点的编号
//必须要按照这个顺序,因为pair是按照first排序的
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1});
while (heap.size())
{
//取出堆顶元素
auto t = heap.top();
heap.pop();
int ver = t.second;
//因为有冗余,所以取出来的可能是之前已经遍历过的点
//如果去掉的话,相同点可能被遍历两次,时间复杂度就不能确保了
if (st[ver]) continue;
//标记该点已经被遍历
st[ver] = true;
//访问该点所有出边并更新
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[ver] + w[i])
{
dist[j] = dist[ver] + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
cout << dijkstra() << endl;
return 0;
}
1.2、存在负权边的最短路
对于存在负权边的最短路问题,有两种方法:Bellman-Ford算法和Spfa算法(Shortest Path Faster Algorithm)。其中Spfa算法是优化版的Bellman-Ford算法,其在各个方面都要优于Bellman-Ford算法,但是有一个特定的问题只能用Bellman-Ford算法来解决,即有边数限制的最短路问题。例如,求经过不超过k条边的最短路问题。所以两种算法都是需要掌握的。
要学习这两种算法,建议先把前文提到的Dijkstra抛在脑后,因为两类算法看起来很像,但实际上却有本质上的区别。Dijkstra算法是从未确定的最小点出发更新所有点,而这两种算法是每次迭代更新所有边的终点的最小距离。
1.2.1、Bellman-Ford算法
Bellman-Ford算法的实现思路是,设置一个外层循环表示步数,里层循环遍历所有边来更新每个点的最短距离。简要说明一下具体实现过程:
- 首先进入外层第一次循环(也即走第一步),这里遍历所有的边,并且将其终点更新为走一步到达这个点所需要的最小距离。
- 然后进入外层第二次循环(也即走第二步),再次遍历所有的边,并且将其终点更新为走两步到达这个点所需要的最小距离。
- 以此类推,直到走到第n步,就能得到走至多n步后,所有点到起点的最小距离。
注意:这里有一个串联问题,需要在每次迭代外层循环的时候拷贝一份当前未进行更新的原始最短距离,也就是说,内层遍历每条边的时候,使用的是上一次更新的距离,而不是在内层迭代的时候随时更新随时使用。下面会进行详细解释。
先举个例子来说明算法的实现过程:
假如需要求得三步以内由点1到点2的最短路,那么最开始点1到点1的距离为0,点1到点2的距离为无穷大。
- 进入第一步(也即外层第一次循环):先遍历边长为2的边,它的终点是点2,那么点2到起点的距离就可以更新成2;再遍历边长为-1的边,它的终点是点2,注意这里用的是上一次更新的距离(也即未进行任何更新),所以经过这条边后点2到起点的距离为无穷减1,不会进行更新。
- 进入第二步(也即外层第二次循环):先遍历边长为2的边,此时点2到起点的距离为2,不进行更新;再遍历边长为-1的边,因为此时使用的是第一次更新后的距离,所以此时点2到起点的距离可以更新为2 - 1,即1。
- 第三步同第二步类似,最后得到结果为0。
上面这个例子能解释何为一步一步走,以及为什么外层循环能控制步数,同时也暗示了串联问题,下面再举一个例子来解释串联问题:
假设要求由点1经过最多一步到点3的最短距离,这张图边上黑色数字代表编号,红色数字代表边长。
如果随时更新随时使用,那在第一步迭代的时候,先遍历边1,此时点2的距离被更新为了1,再遍历边2,此时3的距离被更新为了1,但是实际问题的答案是3,所以随时更新随时使用是不对的。因为外层循环控制一次只走一步,但是如果随时更新随时使用,就可能会出现一次外层循环走了两步的后果。这就是串联问题。
下面给出一道例题,以及题解:
原题acwing853. 有边数限制的最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数。
请你求出从 1 号点到 n 号点的最多经过 k 条边的最短距离,如果无法从 1 号点走到 n 号点,输出impossible。
注意:图中可能存在负权回路。输入格式
第一行包含三个整数 n,m,k。
接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。点的编号为 1∼n。
输出格式
输出一个整数,表示从 1 号点到 n 号点的最多经过 k 条边的最短距离。如果不存在满足条件的路径,则输出 impossible。
数据范围
1≤n,k≤500,
1≤m≤10000,
1≤x,y≤n,
任意边长的绝对值不超过 10000。输入样例:
3 3 1
1 2 1
2 3 1
1 3 3输出样例:
3
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 510, M = 10010;
//因为Bellman-Ford算法只要求遍历所有边,所以只需要用简单粗暴的方法来存图就行了。
struct Edge{
int a, b, c;
}edges[M];
int n, m, k;
int dist[N];
//last为拷贝的上一次更新的距离
int last[N];
void bellman_ford()
{
//初始化距离
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
//循环k步
for (int i = 0; i < k; i ++ )
{
//先拷贝一份上一次更新的距离
memcpy(last, dist, sizeof dist);
//遍历每一条边
for (int j = 0; j < m; j ++ )
{
auto e = edges[j];
//更新边的终点,注意要使用上一次更新的距离,也即last
dist[e.b] = min(dist[e.b], last[e.a] + e.c);
}
}
}
int main()
{
cin >> n >> m >> k;
for (int i = 0; i < m; i ++ )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
edges[i] = {a, b, c};
}
bellman_ford();
//因为不能到的点其距离初始化未无穷,但是如果其存在负环,那么其最后距离可能是无穷减一个数
//因此不能写成dist[n] == 0x3f3f3f3f,而是如果dist[n]大于一个很大的数就说明无法到达
if (dist[n] > 0x3f3f3f3f / 2) puts("impossible");
else cout << dist[n] << endl;
return 0;
}
1.2.2、Spfa算法
spfa算法是优化版的Bellman-Ford算法。那么优化在什么地方呢?注意到Bellman-Ford算法中,每次迭代都会遍历所有的边,但实际上有些边是不需要遍历的,优化策略就是找到这些边,并且在遍历的时候不遍历这些边即可。那么什么样的是需要遍历的呢?举个例子:
假设a经一条边指向b,那么只有在dist[a]变小的时候,dist[b]才有可能变小,所以只需要记录一下变小的a,再用变小的a找到需要遍历的b即可。这里采用队列的方式,对于一个点,从队列中取出它,并遍历其所有出边,然后让距离变小的点(也就是被更新的点)入队,以此类推直到队列为空。其实现方法和堆优化的Dijkstra大同小异,但不要搞混,因为两种算法本质上还是不一样的。
下面给出一道裸题及题解:
原题acwing spfa求最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数。
请你求出 1 号点到 n 号点的最短距离,如果无法从 1 号点走到 n 号点,则输出 impossible。
数据保证不存在负权回路。输入格式
第一行包含整数 n 和 m。
接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。
如果路径不存在,则输出 impossible。数据范围
1≤n,m≤105,
图中涉及边长绝对值均不超过 10000。输入样例:
3 3
1 2 5
2 3 -3
1 3 4输出样例:
2
#include <algorithm>
#include <cstring>
#include <iostream>
#include <queue>
using namespace std;
const int N = 100010;
//用邻接表来存图
int h[N], e[N], ne[N], w[N], idx;
bool st[N];
int n, m;
int dist[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
int spfa()
{
//初始化距离
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
//一开始让起点入队
queue<int> q;
q.push(1);
//标记一下,因为队列里存的是可以更新的点,所以不需要重复存储
st[1] = true;
//只要队列不空,就代表目前有可以更新的点
while (q.size())
{
//取出一个点
int t = q.front();
q.pop();
//将其取消标记,因为后续可能再次入队
st[t] = false;
//遍历其所有出边
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
//如果其出边的终点距离变小了,那么判断一下并让其终点入队
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return dist[n];
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
int t = spfa();
if (t == 0x3f3f3f3f) puts("impossible");
else cout << t << endl;
return 0;
}
一般来说,spfa算法能解决大部分最短路问题,包括边权全为正的最短路问题。所以,有些题目可以用Dijkstra来做,也能用spfa来做,因为spfa的时间复杂度一般是O(m)的。但是有些出题人比较坏,会恶意的设置一些数据点把spfa算法卡成O(mn)的时间复杂度,这样的话就只能用堆优化的Dijkstra来做了。
注意,如果存在负环的话,使用spfa求最短路就会无限循环,因为可以一直在这个负权环上绕来减小距离,因此一般spfa的算法题不会存在负环。但是难免有意外,我们可以通过一些修改用spfa判断是否存在负环。在求最短路时,dist数组存储的是从一号点到所有点的距离,而如果要求判断负环的话,需要改动一下。
先给出代码,这里和上面例题的输入一样,只是输出变为:存在负环输出Yes,不存在输出No。
#include <algorithm>
#include <cstring>
#include <iostream>
#include <queue>
using namespace std;
const int N = 100010;
int h[N], e[N], ne[N], w[N], idx;
bool st[N];
int n, m;
int dist[N], cnt[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
int spfa()
{
queue<int> q;
for (int i = 1; i <= n; i ++ )
{
st[i] = true;
q.push(i);
}
while (q.size())
{
int t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
//更新后多走了一条边,所以+1
cnt[j] = cnt[t] + 1;
if (cnt[j] >= n) return true;
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
int t = spfa();
if (spfa()) puts("Yes");
else puts("No");
return 0;
}
解释一下这里的dist数组和cnt数组以及返回true的条件:
- dist数组不再用于记录真实的距离,仅作为一个相对距离的标志,其内部的初始值是多少都无所谓,因为是否更新只和经过的边有关,只要边权为负就可以更新。
- cnt数组记录更新了多少次,只要更新一次就代表走过了一条边。
- 一开始让所有点入队,因为不判断负环不存在起点的概念。
- 如果有负环,spfa算法会一直进行下去,因为只要走过这个负环距离就会变小,所以可以一直循环变小下去。那么退出的条件就是某点的最短路更新次数大于等于n,因为一共有n个点,如果走n次的话,相当于走了n+1个点,因此必然有两个点其实是一个点,也就是存在一个负环。
2、多元汇最短路——Floyd
要询问多个起点到多个终点的最短路问题,要使用Floyd算法。Floyd算法非常的简单粗暴,就是一个三重循环每次更新即可。具体的实现原理是基于动态规划的,也即前一个状态推出后一个状态,感兴趣的朋友可以自己钻研一下,这里就不给出详细证明,仅给出裸题和题解。
原题acwing Floyd求最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,边权可能为负数。
再给定 k 个询问,每个询问包含两个整数 x 和 y,表示查询从点 x 到点 y 的最短距离,如果路径不存在,则输出 impossible。
数据保证图中不存在负权回路。输入格式
第一行包含三个整数 n,m,k。
接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
接下来 k 行,每行包含两个整数 x,y,表示询问点 x 到点 y 的最短距离。输出格式
共 k 行,每行输出一个整数,表示询问的结果,若询问两点间不存在路径,则输出 impossible。数据范围
1≤n≤200,
1≤k≤n2,
1≤m≤20000,
图中涉及边长绝对值均不超过 10000。输入样例:
3 3 2
1 2 1
2 3 2
1 3 1
2 1
1 3输出样例:
impossible
1
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 210, INF = 1e9;
int d[N][N];
int n, m, q;
void floyd()
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
int main()
{
cin >> n >> m >> q;
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
d[a][b] = min(d[a][b], c);
}
floyd();
while (q -- )
{
int a, b;
scanf("%d%d", &a, &b);
if (d[a][b] > INF / 2) puts("impossible");
else printf("%d\n", d[a][b]);
}
return 0;
}