看不懂图是如何存储图的请移步acwing,所有内容均来自课堂归纳整理的笔记,附带本人踩过的坑与思考
,我认为这是最有价值的一点,适合大致了解这些算法但还是有一些似懂非懂的小伙伴。
朴素Dijkstra算法
时间复杂度O(n^2),与源点个数n有关,与边的数目m无关,适合稠密图。
稠密图使用邻接矩阵存储:
const int N=510,INF=0x3f3f3f3f;
vector<vector<int>> g(N,vector<int>(N, INF));//邻接矩阵
vector<int> dist(N,INF);//到起点的最短距离
bool st[N];//节点是否纳入最短点集
C++代码
#include<iostream>
#include<algorithm>
#include<vector>
using namespace std;
const int N=510,INF=0x3f3f3f3f;
vector<vector<int>> g(N,vector<int>(N, INF));
vector<int> dist(N,INF);
bool st[N];
int n,m;
int dijkstra(){
dist[1]=0;
for(int i=0;i<n;i++){//处理n次,每次选取一个点纳入最短点集,n次后所有点都纳入最短点集合了,因此任意一个点到起点的距离都是最短的
int t=-1;
for(int j=1;j<=n;j++){
if(!st[j] && (t==-1 || dist[t]>dist[j])){
t=j;
}
}
st[t]=true;//这里意味着每次循环都有一个点纳入最短点集,但距离的更新就不好说了,没有边可能还是无穷
for(int j=1;j<=n;j++){
dist[j]=min(dist[j],dist[t]+g[t][j]);
}
}
if(dist[n]==INF) return -1;
else return dist[n];
}
int main()
{
cin>>n>>m;
while(m--){
int x,y,z;
cin>>x>>y>>z;
g[x][y]=min(g[x][y],z);
}
cout<<dijkstra();
return 0;
}
虽然每次学完都会忘记,但一次次的温习渐渐觉得这个算法好像变简单了。
(假设求第1个点到第n个点的最短路)
- 初始化
-
1.所有点到起点的距离都是无穷,此时最短点集为空。 2.起点到自己的距离为0,是所有距离起点最近的点。注意:此时还未将起点纳入最短点集,只是为其赋予了关键的初始值
-
- 核心算法
-
1.在所有还未纳入最短点集的点中找一个距离起点最近的点,2.将这个点纳入最短点集 3.然后利用这个点更新其他点的距离。
- 最外层为一个n次的循环,因为每一次循环都会将一个点纳入最短点集,也就是说确定了一个点的最短路,当我遍历完n个点,也就获得了n个点的最短路。
-
堆优化Dijkstra算法
将上面朴素Dijkstra算法的步骤表达出来,发现
时间复杂度最高的就是在不属于最短点集的点中找出dist最小的
,最坏形况下遍历n个点,时间复杂度O(n^2)。如果我们把所有的dist插入小根堆中,那么每次找到的时间就是近乎O(1),而第三步用t更新其他点的距离本质上是在遍历边m
,也就是n次更新一共遍历了m
条边,每次维护队中的n个节点堆插入操作的时间复杂的就是logn,最大的时间复杂的就为m*logn
。也就是说适合稀疏图m,n一个数量级的。但是STL中的堆是不支持指定删除节点的,也就是说我们更新后的更短的dist只能直接插入,但是由于更新后值更小,所以并不会受到这个点之前未删除的值的影响,形成空间换时间。并且由于冗余的存在,可能会出现某个点已经被纳入最短点集了,但是某一次堆中pop出的是那个被纳入最短点集的点上一次的较小值,但这个较小值可能比堆中其他所有点的dist都要小,所以我们每次st[t]=true
的时候都要判断一下是否纳入了最小点集。
C++代码
typedef pair<int, int> PII;
int n; // 点的数量
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储所有点到1号点的距离
bool st[N]; // 存储每个点的最短距离是否已确定
// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // first存储距离,second存储节点编号
while (heap.size())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i])
{
dist[j] = distance + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
y总的写法是简洁优雅的,初始化数组的值我第一时间想到的就是使用类似vector a(N,-1)
,但是效率相比于cstring
中的memset(a,-1,sizeof a)
直接处理一个字节一个字节的内存还是太慢了。
如果我们添加一个path[N]
数组,path[i]
含义为:存储最短路径上,i
的前驱节点.直白点,就是在最短路径上i
这个节点是由哪个节点转移来的。
再次回顾一下,此时我们指的一直都是单源最短路
,其实求出来的是一个节点到其他所有节点的最短路,基于上面额path数组我们就可以回溯一条从任意终点到起点的最短路径了。
Bellman_Ford算法
思路非常简单,核心为松弛操作。按边的长度来松弛,易知如果存在
n
个点,1~n
号点之间至多存在n-1
条边。也就是说如果我按边的长度来松弛至多只需要处理n-1
次,求得的值就是该点的最短路(假设存在)。内层循环就是遍历所有的边进行松弛操作就行(dist[b]>min(dist[b],dist[a]+weight))
。因此时间复杂度为O(nm)
但显然以上思路虽然简单,却有一些明显需要考虑的问题:
- 存储:
- 由于最核心的操作是内层循环对所有便进行遍历然后松弛,因此我们只要保证能将每条边的数据遍历到就行。使用边集数组即可。
struct Edge{ int a,b,w; }edges[N];
- 如何限制更新k条边:
- 在每次内存循环开始的时候都将
dist[N]
备份成backup[N]
,松弛操作(dist[b]>min(dist[b],dist[a]+weight))
修改为dist[b]>min(dist[b],backup[a]+weight)
。请诸位思考,一开始所有点到起点之间的距离都是正无穷,dist[1]=0;此时起点自己到自己的距离为0。这时我利用边去更新dist即到起点的距离,是不是与起点相连的边都会被更新,并且由于backup数组的存在,本次更新的值并不会影响我后面值的更新,从而引发串联。如果将本次更新视为在当前层,那么我的backup
永远是用的上一层结果,从而保证这个算法一定是按距离起点边长为1,距离起点边长为2,… ,距离起点边长为n去更新的最短路。当然因为存在负权边,所以某些无穷大可能也会被更新为较小的无穷大,可以无视,因为这表示还是没有通路到起点。
- 在每次内存循环开始的时候都将
- 重边:
- 由于我们求的是最短路,因此就算有重边,最终结果也只会保留最短的那条的更新结果。
- 自环:
- 如果自环为正,路径变成长,因此不会被更新,不用考虑。
- 如果自环为负,即构成负环,这个点到起点的距离会无数次被更新,每次都会减小负环的值,但是我们最外层存在变数限制,也就是它更新的次数会被限制住。
- 负环:
- 起点到终点的最短路经过负环,通过限制边的松弛长度可以求出某个限制条件下的最短路。
- 起点到终点的最短路
不
经过负环,对结果没有影响,可以当其不存在。
初始化
建议使用memset和memcpy,属于cstring头文件的,对单个字节内存进行操作,效率高。
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
int backup[N];
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
int bellman_ford(){
memset(dist,0x3f,sizeof dist);//这是每次调用这个算法都需要的初始化
dist[1]=0;//起点到自己的距离为0
for(int i=0;i<k;i++){//至多n-1次可以求出1~n路径上所有节点的最短路,至于多循环一次两次并没有什么影响,当然这个值也可能取决于题目。
memcpy(backup,dist,sizeof dist);
for(int j=0;j<m;j++){
int a=edges[j].a,b=edges[j].b,w=edges[j].w;
dist[b]=min(dist[b],backup[a]+w);//min在algorithm头文件中
}
}
if(dist[n]>0x3f3f3f3f/2) return -2e9;
else return dist[n];
}
//只需要记录n条边就行
for(int i=0;i<m;i++){
int x,y,z;
cin>>x>>y>>z;
edges[i]={x,y,z};
}
SPFA算法
SPFA算法是对Bellman_Ford算法的优化,而Bellman_Ford算法时间复杂度最高的就是第二层循环中对所有边进行遍历更新
o(nm)
。但事实上处理a->b
边中dist[b]
是否更新取决它前面的a
节点也就是dist[a]
是否被更新,而dist[a]
又取决于它前一条更新它的边。简而言之,如果某一条边或者说某个节点被更新了,那么与这个节点相连的边是极有可能需要被更新的。
这样平均下来SPFA的算法时间复杂为o(m)。最坏情况下存在负环也要o(nm)
在上述的优化操作优化的关键点在于如果某一条边或者说某个节点被更新了,那么与这个节点相连的边是极有可能需要被更新的。
也就是实现上倾向于使用邻接表
,能把性能发挥到极致。
而这些本次被更新的点,可以放到任意数据结构里去,习惯上我们使用队列queue
存储。一旦被更新了就存起来,后面再拿出来通过边权更新其他相连的节点,直到队列为空,就求出了所有节点的最短路了。
实现上需要注意的细节:某个节点可能在队列中,等待更新其他节点,但此时也可能仍被其他节点更新,此时不需要重复放入队列,只需要更新dist
即可。因为队列中放的毕竟是节点编号,遍历处理的也是节点编号。
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
const int N=1e5+10,M=1e5+10,INF=0x3f3f3f3f;
int n,m,idx;
vector<int> dist(N,INF);
vector<int> h(N,-1);
int e[M],w[M],ne[M];
bool st[N];
void add(int a,int b,int c){
e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
int spfa(){
//dist=vector(N,INF);再次初始化
queue<int> q;
dist[1]=0;
q.push(1);
st[1]=true;
while(q.size()){
auto 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]==INF?-INF:dist[n];
}
int main()
{
cin>>n>>m;
for(int i=0;i<m;i++){
int a,b,c;
cin>>a>>b>>c;
add(a,b,c);
}
int t=spfa();
if(t==-INF) cout<<"impossible";
else cout<<t;
return 0;
}
做算法题这样写没什么,一次过了就不会再用了,但如果第二次调用spfa()
就会发现dist
没有被重新初始化。导致我在工程化项目中经常出现这种疏忽。。
注意:以上我都是在说最短路的问题,并不涉及到负环。显然如果存在负环,那么负环路径上的点就会一直被更新,队列一直不空因此整个算法会陷入死循环。所以SPFA算法效率高、好写、还可以处理负权边,因此用的非常多。但缺点是无法和它老爹bellman_ford一样通过对边数的约束处理负环问题
但是别急,我们通过抽屉原理
,加上一个cnt[N]
数组就可以解决负环问题。本质上和bellman_ford的思路是一样的。
Bellman_Ford:n个节点至多存在n-1条边。
SPFA:cnt[n]记录一下1~n之间的边数,如果负环的话cnt[j] = cnt[t] + 1;会不停更新,直到存在某个cnt[j]>=n,说明存在n条边,那就有n+1个节点,由抽屉原理可知一定有两个节点是相同的,也就是构成了环。
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
const int N=2010,M=10010,INF=0x3f3f3f3f;
int n,m,idx;
vector<int> dist(N,INF);
vector<int> h(N,-1);
int cnt[N];
int e[M],w[M],ne[M];
bool st[N];
void add(int a,int b,int c){
e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
bool spfa(){
dist=vector(N,INF);
queue<int> q;
for (int i = 1; i <= n; i ++ ) {
q.push(i);
st[i]=true;
}
while(q.size()){
auto 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];
cnt[j]=cnt[t]+1;
if(cnt[j]>n-1) return true;
if(!st[j]) q.push(j),st[j]=true;
}
}
}
return false;
}
int main()
{
cin>>n>>m;
for(int i=0;i<m;i++){
int a,b,c;
cin>>a>>b>>c;
add(a,b,c);
}
if(spfa()) cout<<"Yes";
else cout<<"No";
return 0;
}
之所以在spfa()中预先把所有点放入队列中,最主要是为了防止某一些完全独立的点内部构成负环。如果确保所有点都是联通的,那么从任意一个点开始使用spfa()都能实现对负环的判断。并且,dist数组也可以不初始化,因为我们目的就是判负环,只有遇到负环才会疯狂更新cnt[],直到cnt[j]>=n判出负环。
Floyd算法
弗洛伊德算法(Floyd-Warshall Algorithm)是一种用于解决图中节点之间最短路径问题的算法,它适用于有向图或者无向图,可以处理带有负权边但不包含负权回路的图。
弗洛伊德算法的核心思想是动态规划。它通过遍历所有节点对之间的可能路径,逐步更新从一个节点到另一个节点的最短距离,直到获得所有节点之间的最短路径为止。具体来说,算法维护一个二维数组D,其中D[i][j]表示从节点i到节点j的最短距离。然后通过以下递推关系来更新这些距离:D[i][j] = min(D[i][j], D[i][k] + D[k][j])
其中k表示所有可能的中间节点,如果从i到j经过k节点的路径比直接从i到j的路径更短,就更新D[i][j]的值。
弗洛伊德算法的时间复杂度为O(n^3),其中n为节点数,因此它适用于中等规模的图。该算法的优点是能够同时计算任意两点之间的最短路径,因此非常适合于需要多对多最短路径的场景。然而,对于大规模图来说,其时间复杂度可能会使其效率较低。
floyd算法相当好记,直接k,i,j三重循环嵌套,更新D[i][j] = min(D[i][j], D[i][k] + D[k][j])
即可。(k为中心节点、i,j为节点编号)
问题一:为什么一定是k,i,j三重嵌套,我i,k,j 或者 i,j,k不行吗?
不行!如果弄懂了这个问题,理论推导出公式我不敢说,但使用起来你将没有任何疑惑。
如果我们先遍历起点和终点,然后再遍历中间节点,就会出现某两个节点之间存在可能的最短路径,但由于中心节点尚未更新,这个最短路径可能被忽略。这样就会导致无法找到所有节点对之间的最短路径。
而当我们先遍历中间节点时,我们可以确保在考虑任意一对节点(i, j)时,中间节点已经被遍历过,从而能够考虑到经过这些中间节点的所有可能最短路径。通过遍历所有中间节点,我们最终能够找到所有节点对之间的最短路径,因此确保了算法的完备性。
一句话总结,先遍历中心节点,再遍历起点和终点,就能找到这个经过这个中心节点的所有最短路,遍历所有中心节点就得到了所有节点间的最短路。
不论是基于遍历所有节点的算法原理,还是D[i][j] = min(D[i][j], D[i][k] + D[k][j])
表达式,着眼的都是节点
。显然用邻接矩阵
比较好,方便遍历所有节点以及获得任意两节点之间的关系。
const int INF=0x3f3f3f3f;
初始化:
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;
// 算法结束后,d[a][b]表示a到b的最短距离
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]);
}
最后判断是否d[a][b]间存在最短路,会出现和Bellman_Ford算法一样的问题,因为二者归根结底还是遍历了所有边,甚至Floyd暴力程度连不存在的边也会处理一下,导致如果存在负权边,某些距离无穷大的节点会被更新成较小的无穷大
,这是需要看下数据的范围全为负值叠一起加个正无穷看下有多大。一般经验,d[a][b]>INF/2就可以认为是不存在最短路了。