[算法系列]图论03-最短路问题

197 阅读5分钟

最短路算法

在图中,我们常常会需要知道某一点到其余点最短路或者各点之间的最短路,此时我们就需要借助最短路算法,其中,Dijkstra算法和Floyd算法,Bellman-Ford算法,spfa算法都是是最为常用的最短路算法

朴素Dijkstra算法

Dijkstra算法非常重要的解决单源最短路的算法,用于处理某一点到其余各点最短路,时间复杂度为O(n^2)

缺点:无法处理带负权边的最短路

算法步骤:

假设我们希望求出1号点到其余各点之间的距离最小值

1.我们定义一个dist数组,初始化dist[1]=0,其余均为正无穷

2.我们设想一个集合S,表示我们已经用过确定了最小值的点

3.我们每次找出一个不在S中的点,但它满足到S的距离最小的点k,并用它来更新dist数组

4.如果上一步中出现了dist[k] >dist[i] +g[i] [k];我们就更新dist的值

5.反复执行上面的3、4两步,直到每一个点都被用于确定一次最值过

代码实现:

例题:给定点数n,边数m,要求n号点到1号点最短路,若无法到达输出-1,注意:可能有重边

AC Code:

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int inf=0x3f3f3f3f;
const int N=510;
int g[N][N];
int n,m;
int dist[N];
bool st[N];
int dijkstra(){
    memset(dist,inf,sizeof dist);
    dist[1]=0;
    for(int i=0;i<n;i++){
        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;
    return dist[n];
}
int main(){
    cin>>n>>m;
    memset(g,inf,sizeof g);
    while(m--){
        int a,b,c;
        cin>>a>>b>>c;
        g[a][b]=min(g[a][b],c);
    }
    int t=dijkstra();
    cout<<t<<endl;
    return 0;
}

上面是一种朴素的Dijkstra算法,我们下面看看堆优化后的Dijkstra算法

堆优化Dijkstra算法

优化方式:

我们使用优先队列来简化寻找距离最近点的操作,对于数据范围比较大的图,比较适用

上一题如果使用这种方式来写,代码如下:

#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
typedef pair<int,int> pii;
const int N=2e6+10;
int h[N],w[N],e[N],ne[N],idx;
int dist[N];
bool st[N];
int n,m;
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;
    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;
        int dt=t.first;
        if(st[ver])continue;
        st[ver]=1;
        for(int i=h[ver];i!=-1;i=ne[i]){
            int j=e[i];
            if(dist[j]>dt+w[i]){
                dist[j]=dt+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);
    for(int i=0;i<m;i++){
        int a,b,c;
        cin>>a>>b>>c;
        add(a,b,c);
    }
    int t=dijkstra();
    cout<<t<<endl;
​
    return 0;
}

Floyd算法

Floyd算法是解决多源最短路问题的最为常用的算法,其时间复杂度较高,是O(n^3),但也正因如此,其代码比较简单,思路比较清晰

算法步骤:

Floyd算法原理是动态规划,其状态表示为d(k,i,j),意思是i到j,只经过1到k这些中间点的路径长度

其状态转移为:d(k,i,j)=d(k-1,i,k)+d(k-1,k,j)

其中k是可以优先枚举的,因此我们用二维数组即可

注意: 无法处理含有负权回路的图(这样的图最短路径不一定存在)

代码实现:

#include<iostream>
#include<cstring>
using namespace std;
int n,m,k;
const int N=210;
const int inf=0x3f3f3f3f;   //0x3f3f3f3f没有1e9大,也没有INT_MAX大,后面二者会溢出int
int g[N][N];
void floyd(){
    for(int k=1;k<=n;k++){
        for(int i=1;i<=n;i++){
            for(int j=1;j<=n;j++){
                g[i][j]=min(g[i][j],g[i][k]+g[k][j]);
            }
        }
    }
}
int main(){
    cin>>n>>m>>k;
    memset(g,inf,sizeof g);
    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            if(i==j){
                g[i][j]=0;     //自环的处理
            }
        }
    }
    while(m--){
        int a,b,c;
        cin>>a>>b>>c; 
        g[a][b]=min(g[a][b],c);    //重边的处理
    }
    floyd();
    while(k--){
        int x,y;
        cin>>x>>y;
        if(g[x][y]>=inf/2) cout<<"impossible"<<endl;
        else cout<<g[x][y]<<endl;
    }
    
    return 0;
}

Bellman-Ford算法

Bellman-Ford算法常用于解决带有负权边的最短路问题,算法非常简单,并且对于边的存储方式要求很低,可以判断负环(不常用)

时间复杂度:O(mn)

算法步骤:

进行n次循环,每次循环遍历所有的边,假设边是a->b,权重为w,那么更新dist[b]=min(dist[b],dist[a]+w);

这种更新的操作也被叫做松弛操作

注意: 存在负权回路不一定有最短路

Bellman-Ford算法通常用于解决有边数限制的带负权边的最短路问题,并且其允许合理的负环

代码实现:

例题:给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数

请你求出从 1 号点到 n 号点的最多经过 k 条边的最短距离,如果无法从 1 号点走到 n 号点,输出 impossible

注意:图中可能 存在负权回路

输入:第一行包含三个整数 n,m,k

接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。

点的编号为 1∼n。

输出:输出一个整数,表示从 1 号点到 n 号点的最多经过 k 条边的最短距离。

如果不存在满足条件的路径,则输出 impossible

#include<iostream>
#include<cstring>using namespace std;
const int N=510,M=100010;
int dist[N],last[N];
struct node{
    int x,y,w;
}edges[M];
int n,m,k;
void bellman()
{
    memset(dist, 0x3f, sizeof dist);
​
    dist[1] = 0;
    for (int i = 0; i < k; i ++ )               //最多经过k条边,所以只更新k次
    {
        memcpy(last, dist, sizeof dist);        //last数组作用是防止新更新的值的影响,有点类似数据库事务里的脏读错误
        for (int j = 0; j < m; j ++ )            
        {
            auto e = edges[j];
            dist[e.y] = min(dist[e.y], last[e.x] + e.w);
        }
    }
}
​
int main(){
    cin>>n>>m>>k;
    for(int i=0;i<m;i++){
        int a,b,c;
        cin>>a>>b>>c;
        edges[i]={a,b,c};
    }
    bellman();
    if(dist[n]>=0x3f3f3f3f/2) puts("impossible");
    else cout<<dist[n]<<endl;
​
    return 0;
}

SPFA算法

SPFA算法同样可以解决带负权的最短路问题,同时还可判断是否存在负环,该算法比Bellman-Ford更为优秀

算法步骤:

1.建一个队列,将起点放进去,队列存所有dist变小的节点

2.只要队列不空,取出队列头t,并且删除,更新t的所有出边,如果成功,就把另一头的点加进队列(不重复加入)

我们不难看出,SPFA优化了Bellman-Ford算法,避免了更新不必要的边

代码实现:

例题1: 同上一题,只是没有边数限制

#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
typedef pair<int,int> pii;
const int N=2e6+10;
int h[N],w[N],e[N],ne[N],idx;
int dist[N];
bool st[N];
int n,m;
void add(int a,int b,int c){
    e[idx]=b;w[idx]=c;ne[idx]=h[a];h[a]=idx++;
}
void spfa(){
    memset(dist,0x3f,sizeof dist);
    dist[1]=0;
    queue<int>q;
    q.push(1);
    st[1]=true;
    while(!q.empty()){
        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;
                }
            }
        }
    }
}
int main(){
    cin>>n>>m;
    memset(h,-1,sizeof h);
    for(int i=0;i<m;i++){
        int a,b,c;
        cin>>a>>b>>c;
        add(a,b,c);
    }
    spfa();
    if(dist[n]==0x3f3f3f3f)puts("impossible");
    else cout<<dist[n]<<endl;
​
    return 0;
}

我们不难看出,SPFA求最短路的实现与Dijkstra很是相似

例题2:判断负环

SPFA算法适用于判断负环,并且实现简单,是最为常用的判断负环的算法

题目与上一题一致,只是输出改为输出是否有负环

算法思路:

我们另外建一个cnt数组表示最短路的边数,假设边是t->x,更新dist[x]时候,我们把cnt[x]=cnt[t]+1

如果发现cnt[i]>=n,那么一定存在负环,因为如果是正环,我们不会去更新到这里

代码实现:

#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
typedef pair<int,int> pii;
const int N=2e6+10;
int h[N],w[N],e[N],ne[N],idx;
int dist[N]
bool st[N];
int cnt[N];
​
int n,m;
void add(int a,int b,int c){
    e[idx]=b;w[idx]=c;ne[idx]=h[a];h[a]=idx++;
}
bool spfa(){
    queue<int>q;
    for(int i=1;i<=n;i++){         //起点此时不一定是1,要把所有点都考虑进去
        st[i]=true;                //我们只关心是否有负环,所有dist数组不需要初始化为inf
        q.push(i);
    }
    while(!q.empty()){
        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];
                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);
    for(int i=0;i<m;i++){
        int a,b,c;
        cin>>a>>b>>c;
        add(a,b,c);
    }
    bool res=spfa();
    if(res)cout<<"Yes"<<endl;
    else cout<<"No"<<endl;
​
    return 0;
}