最短路
前言
在学习最短路算法中,有单源最短路和多源最短路,考虑各种情况,并且以提出问题解决问题的方式巩固知识,建议读者先学习广度优先遍历和动态规划等知识点,这里仅做最短路整合
朴素Dijkstra算法
在广度优先搜索上引入代价的概念,通过计算每个节点距离起点的代价,然后用贪心策略更新,这样最终就能得到源点距离节点的最小值
算法步骤:
1.找点,也就是搜索所有节点,找出之前没有更新过且距离源点最小值的节点
2.标记,也就是标记已经找到的合法点,防止下次继续更新
3.更新,也就是更新选定节点的相邻点距离源点的权值
这里引入某大佬画的图作为例子
比如上图中,1为源点,一开始要更新节点2和4的权值为1和4
数据结构
1.需要用二维数组维护整个图,并且还要知道边权,所以可以考虑用结构体维护出边和相邻点
2.从算法步骤1可以知道,需要在所有的节点中找出最小的值,意味着需要用一个一维数组维护每个节点距离起点的距离
3.从算法步骤2易知,需要用一个一维数组维护所有的节点,表示是否已经选过一次
例题:科学家大会
C++核心代码
#include<iostream>
#include<vector>
#include<algorithm>
#include<unordered_map>
#include<queue>
using namespace std;
const int N = 501;
const int INF = 0x3f3f3f3f;
int minDist[N];
struct edge { int v1, w; };
vector<edge> g[N];
bool vis[N];
int n, m;
int s, e, v;
int bfs_djkstra(int node) {
memset(minDist, INF, sizeof(minDist));
minDist[node] = 0;
for (int i = 1; i < n; ++i) {
int u = 0;
for (int j = 1; j <= n; ++j) {
if (!vis[j] && minDist[j] < minDist[u]) u = j;
}
vis[u] = 1;
for (auto ed : g[u]) {
int v1 = ed.v1, w=ed.w;
if (w + minDist[u] < minDist[v1])
minDist[v1] = minDist[u] + w;
}
}
return minDist[n] == INF ? -1 : minDist[n];
}
由于经历了n-1次循环,并且每次循环都要在n个点中找最小的节点,找到最小的节点还要更新出边的的点的最短路;实际上总的来说是每条边只遍历了一次,所以时间复杂度为O(n^2+m),也就是说运行时间受限于点即稀疏图效果比较差
常见问题
Dijkstra算法可以处理负边权吗?
不可以,因为标记数组的存在,可能导致后续节点已更新,然后才经历负权边的节点,这样只能得到局部的最优
如下图(摘自代码随想录),根据dijkstra,最后先更新了节点4和5,然后才更新节点2的邻边距离源点的距离,后续因为节点4和5已经标记,所以不能得到后续节点的最优解;这其实也可以理解为贪心的短视
如何判断环路?
显然可以通过minDist数组来判断
堆优化的djkstra算法
从之前的代码可以有一个直接的优化思路,对于扫描所有的节点来寻找离源点最近的节点实在很费时,所以直接的想法是能不能直接得到距离源点最近的节点呢?显然可以用优先队列得到
那么算法步骤上,相当于是将步骤一和二合并了,如下C++核心代码
int heap_djkstra(int node) { //node为源点
memset(minDist, INF, sizeof(minDist));
minDist[node] = 0;
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> q;
q.push({ 0,node });
while(!q.empty()) {
auto t = q.top(); q.pop();
int u = t.second;
if (vis[u]) continue;
vis[u] = 1;
for (auto ed : g[u]) {
int v1 = ed.v1, w = ed.w;
if (w + minDist[u] < minDist[v1]) {
minDist[v1] = minDist[u] + w;
q.push({ minDist[v1],v1 });
}
}
}
return minDist[n] == INF ? -1 : minDist[n];
}
经过堆优化,时间复杂度明显与堆有关,显然优先队列入队和出队都是操作边,假如有m条边,那么入队和出队都是log(m),按照过程,需要遍历所有的边,所以时间复杂度为mlog(m)
朴素贝尔曼福特算法
之前我们说dijkstra算法不能处理负边权问题,而贝尔曼算法恰好可以处理负边权问题,算法核心就在于做松弛操作,所谓的松弛操作就是dijkstra中的更新节点最小值的操作
算法思想:
在整个无环图中不断找出并更新每个节点到源点的最短路,对于n个节点最多需要n-1次就能知道所有节点的最短路;有环图在n-1次之后还会继续更新
算法步骤:
1.循环遍历所有次数,考虑有环图,遍历n个节点次
2.遍历所有节点,找出更新过最短路的节点
3.更新节点的最短路,如果本轮没有可更新的节点,直接退出
C++核心代码
/// <summary>
/// 朴素贝尔曼福特算法
/// </summary>
/// <param name="node">源点</param>
/// <returns>判断是否有环或负环</returns>
bool Bellman_Ford(int node) {
memset(minDist, INF, sizeof(minDist)); //初始化数组
minDist[node] = 0;
bool flag;
for (int i = 1; i <= n; ++i) {
flag = false;
for (int j = 1; j <= n; ++j) {
if (minDist[j] == INF) continue;
for (auto ed : g[j]) {
int v = ed.v1, w = ed.w;
if (minDist[j] + w < minDist[v]) {
minDist[v] = minDist[j] + w;
flag = true;
}
}
}
if (!flag) break;
}
return flag;
}
从代码中也能看出,贝尔曼福特同时注重节点和边,时间复杂度也受此影响,由于要执行n-1次松弛操作,每次操作遍历所有顶点的出边进行松弛,所以为O(nm),n为顶点数,m为边数
SPFA算法——贝尔曼福特队列优化算法
从先前的朴素贝尔曼福特算法可以看出,内循环中固定循环遍历每个点,然后遍历每个点的出边很费时,与堆优化Dijkstra一样,能不能直接找到一个点再对出边做松弛操作呢?
实际上可以将找点的过程优化成队列,只要把节点都通过队列的入队和出队来表示就能将代码量优化成像堆优化Djkstra那样
C++核心代码
int cnt[N]; //记录每个节点的最大松弛次数,判断环
/// <summary>
/// 贝尔曼福特队列优化算法-SPFA
/// </summary>
/// <param name="node">源点</param>
/// <returns>判断是否有环(负环)</returns>
bool spfa(int node) {
memset(minDist, INF, sizeof(minDist));
queue<int> q;
minDist[node] = 0; vis[node] = 1; q.push(node); //vis标记节点在队内
while (!q.empty()) {
int u = q.front(); vis[u] = 0; q.pop();
for (auto ed : g[u]) {
int v1 = ed.v1, w = ed.w;
if (minDist[u] + w < minDist[v1]) {
minDist[v1] = minDist[u] + w;
cnt[v1] = cnt[u] + 1;
if (cnt[v1] >= n) return true;
if (!vis[v1]) q.push(v1),vis[v1]=1;
}
}
}
return false;
}
时间复杂度显然为O(nm),与朴素贝尔曼算法数量级一致的,但实际上队列优化的贝尔曼算法能够去除掉很多无用的松弛情况,对于边数多的稠密图效率接近于朴素贝尔曼算法,比如双向边的图;然而,SPFA算法的入队和出队都有消耗,可能有时候效率不如朴素贝尔曼算法
SPFA算法和朴素贝尔曼福特算法有什么不同?
1.找点方式:SPFA将找点过程用队列表示,朴素贝尔曼福特是通过两层循环找点
2.判环方式:由于SPFA仅依靠队列内元素来做松弛操作,所以不能像朴素贝尔曼福特那样通过标志变量flag来在遍历过程中得知是否有环,所以使用一个长度为节点个数n的数组cnt来表示所有节点的最大松弛次数
3.标记:由于SPFA使用队列来进行松弛操作,那么对于队列内还有待松弛的节点就不能重复入队;而朴素贝尔曼福特每轮直接遍历所有节点,也就是每个节点每轮只会松弛一次,也就不需要标记了
上述算法都是单源最短路算法,对于多源最短路算法比较知名的有
弗洛伊德(floyd)算法
多源最短路算法——floyd算法
弗洛伊德算法是基于动态规划思想的算法,而动态规划本就是属于暴力算法,只不过是用额外的数组存储来快速得到后续的值
算法大致思路:利用桥的思想,选取一个点作为桥,然后选择起点和终点,起点与终点之间的路径需要经过桥,通过桥来进行松弛操作
状态定义:d[k][i][j]为从源点i到目标j的且经过k的路径最小值
递推公式:显然有两种情况需要考虑,一种是i-j之间经过k点,这时候要做松弛操作;一种是i-j之间不经过k点时要保留原值,实际上不管怎样都要取最小值,所以d[k][i][j]=min(d[k-1][i][j], d[k-1][i][k]+d[k-1][k][j])
初始化:从递推公式可以看出,实际上一层的状态依赖于下一层的状态,毕竟是三维空间的;为了保证得到正确的最小值,所以一开始全部初始化为类型的最大值,而且自己到自己的路径必然为0,也就是d[k][1][1] = 0
遍历顺序:从递推公式和状态定义可以看出,先选定要经过的点,然后选定源点,再选定终点即可
其实从递推公式可以看出第k层完全来源于第k-1层的数据,所以利用滚动数组的思想,可以将三维数组优化为二维,也就是递推公式为
d[i][j]=min(d[i][k]+d[k][j], d[i][j])
算法步骤
:
1.初始化,将动态规划数组d所有值初始化为最大,并且将自身与自身的路径值设为0
2.循环遍历所有的节点,选择k点作为桥,然后循环遍历选择源点,再一层循环遍历选择终点
3.套用递推公式
C++核心代码
/*多源最短路算法*/
int d[N][N]; //记录i-j的最短路
/// <summary>
/// 弗洛伊德算法
/// </summary>
void floyd() {
for (int k = 1; k <= n; ++k) {
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
int tmp = d[i][k] + d[k][j];
if (tmp < d[i][j]) {
d[i][j] = tmp;
}
}
}
}
}
可以看到这里其实没有使用min求最小值,实际上利用CPU的分支预测可以加快效率,但弗洛伊德整体的时间复杂度量级仍然是肉眼可见的O(n^3),因为不管怎样都要枚举三次循环的三个点,这也意味着弗洛伊德算法不太适合稀疏图,比较适合稠密图也就是点少边多的图
总结
摘自卡哥的图,总结的非常好,也基本是本文的内容