当前位置: 首页 > news >正文

【单源最短路经】Dijkstra 算法(朴素版和堆优化版)、Bellman-Ford 算法、spfa 算法 及 负环判断

单源最短路径

单源最短路径(Single-Source Shortest Path, SSSP)问题是图论中的一个经典问题,指在加权图中找出从一个固定源点到所有其他顶点的最短路径。

有四种常见算法解决这类问题,下面依次讲解各种算法的执行流程

例题说明

朴素版 Dijkstra 算法、Bellman-Ford算法 由于时间复杂度较高,spfa算法由于容易被卡时间复杂度,使用 P3371 【模板】单源最短路径(弱化版)进行演示;
堆优化版 Dijkstra 算法 使用 P4779 【模板】单源最短路径(标准版)进行演示。

弱化版题目:
在这里插入图片描述
标准版题目:
在这里插入图片描述

朴素版 Dijkstra 算法

内容

核心思想:贪心扩展

准备:

  • dist 数组:记录每个结点的距离起点的路径长度
  • st 数组:标记每个结点是否已经确定了最短路径

初始化:

  • 将 dist 数组全部初始化为 0x3f3f3f3f(包括 0 下标的位置),将要研究的起点的 dist 值设置为 0。

步骤:

  1. 遍历所有结点的 dist 值,找到具有当前最小 dist 值的结点 M ,将 M 标记为已经确定最短路径的结点。
  2. 遍历 M 的出边,更新其他节点的最短路径。(松弛操作)

重复以上两个步骤,直到确定全部结点的最短路径(最多需要 n - 1 次操作)

分析

  • 为什么步骤1找到当前最小的 dist 值就可以确定这个结点的最短路径就是当前的 dist 值?这就是贪心思想的体现。
    我们可以使用反证法证明其正确性:在这里插入图片描述

时间复杂度

朴素版 Dijkstra 算法主要耗时的地方就在于每次操作2执行完之后,执行操作1暴力寻找最小的 dist 值。于是我们可以用一个小根堆来维护所有结点的 dist 值,这样算法就会更快,每次取出最小路径只需要 O(logm) 的时间复杂度。

步骤1的时间复杂度为O(n^2),步骤2的时间复杂度为O(m)。
总的时间复杂度为O(n^2)

*注:结点个数为 n ,边的个数为 m。

代码

#include <iostream>
#include <cmath>
#include <vector> using namespace std;typedef pair<int,int> PII; const int N = 1e4 + 10, INF = pow(2,31) - 1;int n,m,s;
int dist[N];
bool st[N];
vector<PII> edge[N];void dijkstra()
{//初始化for(int i=0;i<=n;i++) dist[i] = INF;dist[s] = 0;for(int i=1;i<n;i++) //找n-1次 {//找当前最小int t = 0; for(int i=1;i<=n;i++){if(!st[i] && dist[i] < dist[t]) t = i; }st[t] = true; //给t打上标记 //松弛操作 for(auto p:edge[t]){int y = p.first, z = p.second;if(dist[t] + z < dist[y]) dist[y] = dist[t] + z;}}
}int main() 
{cin >> n >> m >> s;for(int i=1;i<=m;i++){int u,v,w; cin >> u >> v >> w;edge[u].push_back({v,w}); }		dijkstra();for(int i=1;i<=n;i++) cout << dist[i] << " ";return 0;
}

最外层循环怎么理解

Q:为什么只用找n-1次?
A:我们可以从 Dijkstra 算法的定义出发去理解这个问题。Dijkstra 算法每次从未确定最短距离的节点中,选择一个当前距离最小的节点,并松弛它的出边
每次松弛操作可以确定一个节点的最短距离。初始化的时候已经确定了起点的最短距离,于是我们再进行 n-1 次操作就可以确定所有点的最短距离,从而完成算法。所以我们在写最外层循环的时候,直接写 n-1 次循环。

堆优化版 Dijkstra 算法

准备:

  • dist 数组:记录每个结点的距离起点的路径长度
  • st 数组:标记每个结点是否已经确定了最短路径
  • 在朴素版的基础上多了一个小根堆,维护需要确定最短路的结点和它的 dist 值。

初始化:

  • 将 dist 数组全部初始化为 0x3f3f3f3f,将要研究的起点的 dist 值设置为 0。
  • 在朴素版的基础上还要将 {0,s} 添加到堆中,表示起点 s 的当前最短路径为 0。

步骤:
弹出堆顶元素(已经确定是当前最短路径),看看有没有被标记过,如果被标记了就跳过,没有被标记就打上标记;并且遍历该结点的出边,进行松弛操作;将更新后的 dist 值和该结点值一并 push 到堆中。
重复以上步骤,直到堆中没有元素。

时间复杂度

while(heap.size())循环中的for(auto& x:edges[a])实际上总的下来是遍历了一遍所有的边,时间复杂度是O(m);
每次heap.push({dist[b],b})往堆中插入元素后调整堆的时间复杂度是 O(log m)。
总的时间复杂度是O(mlogm)。

  • 在稀疏图中,m = O(n),堆优化的 Dijkstra 算法具有较大的效率优势;而在稠密图中,m = O(n^2),这时候使用朴素实现更优。

代码

#include<iostream>
#include<queue>
#include<cstring>
#include<vector>using namespace std;typedef pair<int,int> PII;const int N = 1e5 + 10, INF = 0x3f3f3f3f;priority_queue<PII,vector<PII>,greater<PII>> heap; //<距离,结点>int n,m,s;
int dist[N];
bool st[N];
vector<PII> edges[N];void dijkstra()
{//初始化memset(dist,0x3f,sizeof dist);dist[s] = 0;heap.push({0,s}); while(heap.size()){auto t = heap.top(); heap.pop();int a = t.second;//判断是否已经确定最短路 if(st[a]) continue;st[a] = true;//松弛操作 for(auto& x:edges[a]){int b = x.first, c = x.second;if(dist[a] + c < dist[b]) {dist[b] = dist[a] + c;heap.push({dist[b],b}); }}} 
}int main()
{cin >> n >> m >> s;for(int i=1;i<=m;i++){int u,v,w; cin >> u >> v >> w;edges[u].push_back({v,w}); }dijkstra();for(int i=1;i<=n;i++) cout << dist[i] << " ";return 0;
}

Bellman-Ford算法

核心思想:不断对每一条边进行松弛操作,直到所有的点都无法进行松弛操作为止。对于某一个结点(u 代表前驱结点,v 代表当前结点,w表示 u、v 间的边权),无法进行松弛操作就代表 dist[u] + w >= dist[v] ,那么说明 v 的最短路径已经确定了。当所有的点都无法进行松弛操作,那么说明所有点的最短路径都已经确定了。

特点:

  1. 单纯的 bf 算法是不需要存图的,他可以直接对边的信息进行操作。(不代表在其他题目中不需要存图,具体还是要看题目要求)
  2. bf 算法可以求带负边权值图的最短路径。
  3. bf 算法可以判断不存在最短路的情况(存在负环)。

准备:

  • dist 数组:记录每个结点的距离起点的路径长度

初始化:

  • 将 dist 数组全部初始化为 0x3f3f3f3f,将要研究的起点的 dist 值设置为 0。

最多重复多少次松弛操作?

在最短路径问题中,一条最短路径最多包含 n−1 条边(其中 n 是图中的顶点数)
最短路径必须是简单路径,即不重复经过任何顶点(否则路径中会存在环,而环只会增加路径长度,除非有负权环,但是如果存在负权环那么就不存在最短路)。

在最短路存在的情况下,最多重复 n - 1 次松弛操作,因为每次松弛操作会使最短路的边数至少加1,而最短路的边数最多为 n - 1,因此最多重复 n - 1 次松弛操作就确定了所有点的最短路径。

时间复杂度

每次松弛操作会遍历所有的边,时间复杂度为O(m);最多会进行n-1次松弛操作,时间复杂度为O(n)。
总时间复杂度为O(nm)。

代码

可以进行优化,当发现某一次遍历所有边时,没有发生松弛操作,就可以提前结束循环。

#include<iostream>
#include<cmath>
#include<vector>using namespace std;typedef pair<int,int> PII;const int N = 1e4 + 10, INF = pow(2,31) - 1;int n,m,s;
int dist[N];vector<PII> edges[N];void bf()
{for(int i=0;i<=n;i++) dist[i] = INF;dist[s] = 0;bool flag = false;for(int i=1;i<n;i++){			flag = false;for(int u=1;u<=n;u++){//dist[u]如果是INF就代表还没有更新值,直接跳过,没有必要遍历它的出边进行松弛操作 if(dist[u] == INF) continue; for(auto& t:edges[u]){int v = t.first, w = t.second;if(dist[u] + w < dist[v]) {dist[v] = dist[u] + w;flag = true;}}}if(flag == false) break;}}int main()
{cin >> n >> m >> s;for(int i=1;i<=m;i++){int u,v,w; cin >> u >> v >> w;edges[u].push_back({v,w}); }bf();for(int i=1;i<=n;i++) cout << dist[i] << " "; return 0;
}

spfa算法(Shortest Path Faster Algorithm)

本质上是对 bf 算法使用队列进行优化。
为什么 bf 算法可以进行优化呢?bf 算法是对每一条边都尝试进行松弛操作,但实际上只有在上一轮进行过松弛操作的边才会有可能在本轮进行松弛操作。所以我们只用一个队列来维护“哪些结点可能发生松弛操作”,将上一轮进行过松弛操作的边加入到队列中,这样以来大大减少了没有必要的访问。

准备:

  • dist 数组:记录每个结点的距离起点的路径长度
  • st 数组相较于 Dijkstra 算法的 st 数组有所变化。在这里的含义是标记结点是否在队列中
  • queue 队列:将进行过松弛操作的结点加入队列,

初始化:

  • 将 dist 数组全部初始化为 0x3f3f3f3f,将要研究的起点的 dist 值设置为 0。
  • 将起点 s 加入到队列中,并标记 st[s] = true,表示起点 s 已经在队列中。

步骤:
每次拿出队头元素 u,去掉标记,然后遍历与 u 相连的所有点 v,对 v 进行松弛操作。如果结点 v 被松弛了,就将 v 加入队列并且打上标记
重复以上操作直到队列中没有元素为止。

  • “遍历与 u 相连的所有点 v”,从这里可以看出来 spfa 算法是必须要建图的。

时间复杂度

O(km)~O(nm)

  • 最坏情况(恶意构造的图):
    O(nm)(和 Bellman-Ford 相同),其中 n 是顶点数,m 是边数。
    例如:网格图、负权环检测时可能退化。
  • 平均情况(随机图):
    O(km)(k 是常数,通常 k ≈ 2),比 Bellman-Ford 快很多。
    在稀疏图(如 n ≈ m)中,接近 O(m)。

代码

#include<iostream>
#include<queue>
#include<vector>
#include<cmath>using namespace std;typedef pair<int,int> PII;const int N = 1e4 + 10, INF = pow(2,31) - 1;vector<PII> edges[N];int n,m,s;
int dist[N];
bool st[N]; // spfa的st数组是标记结点是否已经在队列中了 void spfa()
{for(int i=0;i<=n;i++) dist[i] = INF;dist[s] = 0;queue<int> q;q.push(s);st[s] = true;while(q.size()){auto a = q.front(); q.pop();st[a] = false;for(auto& t:edges[a]){int b = t.first, c = t.second;if(dist[a] + c < dist[b]){dist[b] = dist[a] + c;if(!st[b]){q.push(b);st[b] = true;}				 }}}
}int main()
{cin >> n >> m >> s;for(int i=1;i<=m;i++) {int u,v,w;cin >> u >> v >> w;edges[u].push_back({v,w});}spfa();for(int i=1;i<=n;i++) cout << dist[i] << " ";return 0;
} 

虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度为 O(nm),将其卡到这个复杂度也是不难的,所以考试时要谨慎使用,在没有负权边时最好使用 Dijkstra 算法。

负环

负环 是指在一个有向图(或无向图)中,存在一个环(即从某个顶点出发,经过若干条边后又能回到自身),并且这个环的 所有边的权重之和为负数。

在最短路径问题中,如果图中存在负环,并且某个最短路径经过这个环,那么可以 无限绕行该环,使得路径总权值趋近于 -∞,导致最短路径无意义

例题

P3385 【模板】负环
在这里插入图片描述
这道题做之前先把提示看三遍谢谢。
*君哥梦想笑了:南方见。

还有多组测试用例注意清空数据。

负环判断

Dijkstra 不能处理负权边,更不能处理负环,而 Bellman-Ford 和 SPFA 可以检测负环。

Bellman-Ford算法判断负环

Bellman-Ford 算法在 n-1 次松弛后,如果还能继续松弛,说明存在负环。

//bf判断负环 
#include<iostream>
#include<cstring>using namespace std;const int N = 2010, M = 3010 * 2;int pos;
struct node
{int x,y,z;
}e[M]; //存的是边,就要按照边的数据范围开辟 int dist[N];int n,m;bool bf()
{memset(dist,0x3f,sizeof dist);dist[1] = 0;bool flag;for(int i=1;i<=n;i++) //bf算法最多进行n-1次松弛操作,再多一次判断负环 {	 flag = false;for(int i=1;i<=pos;i++) //实际有pos条边 {int x = e[i].x, y = e[i].y, z = e[i].z;if(dist[x] == 0x3f3f3f3f) continue;if(dist[x] + z < dist[y]){dist[y] = dist[x] + z;flag = true;} }	if(flag == false) return flag;		}return flag;}int main()
{int T; cin >> T;while(T--){pos = 0;cin >> n >> m;for(int i=1;i<=m;i++){int u,v,w; cin >> u >> v >> w;pos++;e[pos].x = u, e[pos].y = v, e[pos].z = w;if(w >= 0){pos++;e[pos].x = v, e[pos].y = u, e[pos].z = w;}}if(bf()) cout << "YES" << endl;else cout << "NO" << endl;}return 0;
}

spfa 算法判断负环

spfa 算法可以用 cnt[i] 记录节点 i 的入队次数,如果 cnt[i] >= n,说明 i 在负环上。因为一个结点最多进行 n-1 次松弛操作,进行 n-1 次松弛操作后该结点的最短路径的边数会来到 n-1 条边,再多就说明不存在最短路径,即存在负环。

#include<iostream>
#include<queue>
#include<cstring>
#include<vector>using namespace std; typedef pair<int,int> PII;const int N = 2e3, M = 3010 * 2;//spfa要存图,不能直接存边 
//int pos;
//struct
//{
//	int x,y,z;
//}e[M];vector<PII> edges[N];int dist[N];
bool st[N];
int n,m;
int cnt[N]; //记录从1到达每个点需要经过的边数 bool spfa()
{//清空上一轮的数据 memset(dist,0x3f,sizeof dist);memset(st,0,sizeof st);memset(cnt,0,sizeof cnt);//初始化 queue<int> q;dist[1] = 0;q.push(1);st[1] = true;cnt[1] = 0; while(q.size()){int a = q.front(); q.pop();st[a] = false; //a出队了就要重新打上false标记 for(auto& t:edges[a]){int b = t.first, c = t.second;if(dist[a] + c < dist[b]){dist[b] = dist[a] + c;	cnt[b] = cnt[a] + 1; if(cnt[a] >= n) return true;if(!st[b]) //b进行了松弛操作,需要加入队列,但是前提是b不在队列中,如果已经在队列中不需要重复添加 {q.push(b); st[b] = true;}}}}return false;
}int main()
{int T; cin >> T;while(T--){cin >> n >> m;for(int i=1;i<=n;i++) edges[i].clear(); for(int i=1;i<=m;i++){int u,v,w; cin >> u >> v >> w;edges[u].push_back({v,w});if(w >= 0) edges[v].push_back({u,w});  }if(spfa()) cout << "YES" << endl;else cout << "NO" << endl;} return 0;} 

相关文章:

  • 数据结构算法(C语言)
  • 从golang的sync.pool到linux的slab分配器
  • python中从队列里取出全部元素的两种写法
  • vue注册自定义指令
  • CSS 预处理器与工具
  • MCP 技术完全指南:微软开源项目助力 AI 开发标准化学习
  • PostgreSQL 的扩展pageinspect
  • github中main与master,master无法合并到main
  • 408第一季 - 数据结构 - 树与二叉树II
  • Python实例题:Python计算微积分
  • C++ 中的编译期计算(Compile-Time Computation)
  • Nature子刊:16S宏基因组+代谢组学联动,借助MicrobiomeGS2建模揭示IBD代谢治疗新靶点
  • 《经济学原理》第9版第6章供给、需求和政府政策
  • 历史数据分析——唐山港
  • 探索NoSQL注入的奥秘:如何消除MongoDB查询中的前置与后置条件
  • Unity | AmplifyShaderEditor插件基础(第五集:简易膨胀shader)
  • Android LinearLayout、FrameLayout、RelativeLayout、ConstraintLayout大混战
  • 向 AI Search 迈进,腾讯云 ES 自研 v-pack 向量增强插件揭秘
  • 【基础算法】差分算法详解
  • 在 Windows 11 或 10 上将 Visual Studio Code 添加到系统路径
  • 做网站美工 电脑配件要多大/网络宣传渠道
  • 用java做网页如何建立网站/英文站友情链接去哪里查
  • 我们的网站正在建设之中/附近电脑培训班零基础
  • 舟山城乡建设培训中心网站/汕头网站建设方案维护
  • 做网站的找哪个/公司网站首页设计
  • o2o电商网站开发/b站视频推广网站2023年