编程语言
首页 > 编程语言> > C++解决最短路径问题

C++解决最短路径问题

作者:互联网

很多概念性问题笔者都没有理解理解清楚,所以很多基本上全篇都有待完善....

先说多源最短路径算法

1.floyd算法(暴力n3):

(Floyd当然也能解决单源最短了,它就是多个单源最短的集合。)

核心代码只有三行,文字描述就是:假设n个点,点与点间有m条边(无独立的点),我们要求每个点到到其他n-1个点的最短路径,首先就是用邻接矩阵进行存图,初始化INF,读边后赋值,然后枚举每个点,以每个点k作为中转,看看其他点A以k作为中转点去到下一个点B的距离是不是比原来不中转的的距离小,如果A-->k-->B小于原来A->B的距离,那么我们就要更新A-->B的距离变为松弛后的距离;这样对n-1个点都枚举作为中转点进行松弛后,就能得到每个点到其他点的最短距离了。

for(int k=1;k<=n;++k)
        for(int i=1;i<=n;++i)
            for(int j=1;j<=n;++j)
                dis[i][j]=min(dis[i][k]+dis[k][j],dis[i][j]);

如果是求无向图的话,那那也简单,就照上面跑了一遍,得出有向图最短时让dis[i][j]=dis[j][i]。


单源最短路径

1.dijkstra算法(基于贪心算法,每次都选最短的)

注:dij只能解决权值都为正的图,解决不了含有负权值的图,因为每次新扩展一个路程最短的点,而所有的边权都为正,这个最近点就不可能通过后面的松弛操作最短路变短,即这个点的最短路程后面也不会再改变.而负边权可能会改变已经更新的点的最短路程不会改变的特性(比如A-->B是目前到所有点最短的一条路边权为3,A-->C边权为5,但是有一条边C-->B如果这个边权是-10,那么显然从A-->C-->B才是最短的路线(5-10=-5 < 3),破坏了算法的正确性)

求源点X到各个点的最短距离,先开个dis[]数组初始化所以值为极大值dis[x]=0,算法主要思路就是先从目前的最短路径来找,枚举dis数组找到距离X最短的点(设这个点为A),然后对这个点的出边(比如有个A-->B,这条边的权值为w),然后让原本X-->B的权值和A-->B的权值+X-->A的权值比较大小(dis[B]>dis[A]+w)即dis[B]=min(dis[B],dis[A]+w),dis[B]记录的是X到B的最短距离,然后每次都进行这样的松弛,这样枚举完n-1个点,每次枚举此点的出边就可以找到最短路径了

(比如我们一开始dis数组存的只有自己到自己是0其他的都是无穷,那么第一次求就是对我们源点X的所有出边进行操作,把我们所有的X-->N点的边进行松弛,松弛完毕后我们的源点后面就用不到了,即它归为最短路径了,且这个最短路不会被后面的路线影响,然后第二轮,我们又找到一个距离源点最近的点进行同样的操作,每一轮操作都代表确定了一个最短路,我们有n个点(包括源点),意思是我们只需要进行n-1次操作就能找到所有的最短路了)

下面这个是邻接矩阵存图无优化版:

for(int i=1;i<n;i++){
    int minn=0x7ffffff;
    for(int j=1;j<=n;j++){
        if(!f[j]&&min>d[j])//找未标记中最小的
            {
                min=d[j];
                v=j;
            }
     }
    f[v]=1;//标记
     for(j=1;j<=n;j++){
            if(!f[j]&&d[j]>d[v]+a[v][j])//更新路径长度
            {
                d[j]=d[v]+a[v][j];
            }
     }

 }

我们可以从上面的过程看出可以优化的地方,根据过程我们不难得出,只有我们上一轮松弛过的点才能参加这一轮的松弛操作,即只有上一轮松弛成功的点才能在这一轮影响其它的点(我们一开始让dis[X]=0时也可以认为是对源点的一个松弛操作,之后才能对它的出边进行松弛操作,而第二轮我们能够松弛的边的也只有第一轮松弛成功的点的出边),而一轮松弛可能有多个点松弛成功,而我们每轮选择dis数组中的最小值时(除去之前被选过的),能选的点自然也只有那些松弛成功的点,我们发现,只需要用队列维护两个值(上一轮松弛成功的dis数组值,和上一轮松弛成功的点),而维护的这两个值我们在用到时需要dis最小的点,这里就可以用到我们的优先队列进行维护(即让dis值小的优先进行下一轮),这样做算法就可以进行极大的优化,

当然般我们一般用优先队列优化的话都是直接用STL中自带的priority_queue优化队列,加上边如果没有太多即边数M小于点数的N的二次方的稀疏图,我们一般都采用邻接表或静态邻接表储存法(笔者用的是静态邻接表,好像也叫链式前向星),这类题用到dijkstra都有统一的模板:

(邻接表的存图时间空间复杂度就是O(M)M为边的条数,而邻接矩阵的复杂度就到了O(N2),所以当M<N2时,用邻接表更合适,在原无优化解中,每次都要有O(N)的复杂度去找最小值,而最小堆的优化可以让复杂度降到logn级别)

1.静态邻接表的储存,我们一般都需要输入三个值,起始边,终止边,权值。针对这三个值,静态邻接表中的处理就是把每一条边按输入顺序进行编号,根据编号用数组储存边的出边和权值,重点来了:我们要把边里起始点相同的边放在一起(这个过程就是以一个静态指针数组实现)即用一个数组储存起点相同的边的编号,下面这段代码就是具体实现,可以看出我们遍历这些边的时候需要倒着来的,而head数组储存的永远都是u开头边的最后一条的编号,我们遍历u开头的边时先用到的就是head[u]这最后一条边的编号,再以head[u]作为参数传进e[head[u]].nex,它得到的值就是以u为开头边的上一条边的编号,一直这样遍历直到结束。

struct edge
{
    int go,nex,val;//起始,下一条,权值
}e[MAX];
int head[MAX],cnt;
void addedge(int u,int v,int w)//u起始边;v终边;w权值
{
    e[++cnt].go=v;
    e[cnt].val=w;
    e[cnt].nex=head[u];//如果u之前记过的话,这里记下来的就指向的是上一条边
//上下这两条是遍历的关键
    head[u]=cnt;//记下起始边u的编号
//就是一个用来判断出边,一个用来遍历出边?
}

不开结构体的记法:

void addedge(int u, int v, int w) {
    go[++cnt] = v; val[cnt] = w; from[cnt] = head[u]; head[u] = cnt;
}

2.使用priority_queue优先队列要维护的两个值的优先级:去向v和权值d,由于我们希望这个队列优先级是以d小的排在前面,即小顶(根)堆,因为笔者用到的结构体所以还要重载比较运算符:

#define M my_pair
struct my_pair
{
    int v,dis;
    my_pair(int x,int y) v(x),dis(y) {}//初始化成员
    
    bool operator < (M right)//成员函数可以缺省
    {
        return right.dis<dis;
    }
    //比较结构成员时 a < b ;等价于 a.operator<(b);
    //可知如果返回true时(即a.d>b.d),导致a<b是true,大顶堆的特性
    //就会导致b先进容器导致最终变为一段降序,即d小的在前面{ b , a };
    
};

注意,在stl的优先队列(priority_queue)中,缺省情况下,默认为大顶堆,默认比较方式就是operator<,在使用greater<T>(在头文件<functional>中)就会变成小顶堆,比较方式就变成了operatpr>;而stl的快速排序(sort),在缺省的情况下,默认为升序排列,即从小到大,默认比较方式是operator<,在使用greater<T>时, 就会变成降序排列,即从大到小排列,比较方式就变成了operator>.

 我们当然也可以重载>号了:

#define M my_pair
struct my_pair
{
    int v,dis;
    my_pair(int x,int y) v(x),dis(y) {}//初始化成员
    
    bool operator > (M right)//成员函数可以缺省
    {
        return right.dis<dis;
    }
    //在priority_queue缺省默认为大根堆(降序情况下),重载>号 return不变
    //结果就是会导致依旧是返回ture(a.d>b.d),即a>b是符合的,a先进容器
    //结果就会为{a,b}但是这显然d大的优先了,不满足需求,所以要在priority_queue中用到
    //greater<>伪函数,把优先队列的比较方式变为operator >
    
//又或者说不改变比较方式,把return 的值改变(这个思想是后面笔者认为是错的//未经验证)
//(因为默认的比较方式是<符号,用不到>符号,就不存在返回一个相反值达到原来的正确结果)
    
};

3.优先队列的遍历实现

我们对输入数据处理完且处理好队列的优先级后,就要用优先队列进行优化了,我们直接使用priority_queue生成一个优先队列容器,这里要涉及到的就是出队入队,以及理解每个变量代表的意义,注意到我们押入队列的数据是按权值小的优先,所以这里就不用像未优化时那样处理下一点时遍历dis[]数组寻找最近点,,基本操作实现是:把要找求的那一个点和0入队,因为我们等会要对它的出边进行遍历:

priority_queue<M>Q;//生成一个M类型的优先队列容器Q
dijkstra(int s)
{
    dis[s]=0;//注意这个dis数组我们用可以另一个函数初始化成最大值
    Q.push(M(s,0));//第一个点入队
    while(Q.empty()){
    }
}

循环体里面我们要做的就是对每一个点的出边进行判断,首先第一个点就是自己s,找到s的所有出边,然后更新dis数组,这时候s所有的出边都要入列进行扩列(注意是优先队列,优先级是权值小的先出列),s扩列完了后,我们就要进行松弛边了,这个实现和上面未优化的一样,判断a-->b的出边权值加上s-->a的权值,看看是否改变了s-->b的权值:

priority_queue<M>Q;//生成一个M类型的优先队列容器Q
dijkstra(int s)
{
    dis[s]=0;//注意这个dis数组我们用可以另一个函数初始化成最大值
    Q.push(M(s,0));//第一个点入队
    while(!Q.empty()){
        M cur=Q.top();                    //因为我们入队时并没有排除已经是最短路径的点
        if(dis[cur.v]<cur.dis)continue; //所以当前出列的权值就已经大于记录的就没必要继续了
                            //这里也可以用一个标记数组代替,每次扩列下一个点,就
                            //判断这个数组是否已经标记过了,因为标记后已经是最短
        
        //这里就是遍历邻接表,nex储存的就是当前点的下一条边
        //当指到最后一条边时就为零了,即遍历完所有出边了
        for(int i=head[cur.v];i;i=e[i].nex){
                   int y=e[i].v;//减少码量
            if(dis[y]>cur.dis+e[i].val){//dis就是代表s-->当前出边的点
                                            //如果进来就代表松弛成功
                dis[y]=cur.dis+e[i].val;//更新
                Q.push(y,dis[y]);//把更新的点压入队列
                                        //下一次出列就是距离s最近的
            }
        }
    }
}

完整实现:

#include<bits/stdc++.h>
#define M my_pair
#define LL long long
using namespace std;
const int MAX=1e7;
const int INF=1e9;
int dis[MAX],h[MAX];
int n,m,cnt;
struct edge
{
    int go;
    int nex;
    LL val;

}e[MAX];
struct my_pair
{
    int v;
    LL dis;
    M (int x,LL y):v(x),dis(y){}
    //初始化,等价于
    /*
        struct M (int x,int y)
        {
           v=x;//this->v=x;
           dis=y; //this->dis=y;
        }
    */
    bool operator < (M r)const{
        return r.dis<dis;//r.d<this->dis;
    }//重载运算符<

};
void addadge(int u,int v,int w)
{
    e[++cnt].go=v;
    e[cnt].val=w;
    e[cnt].nex=h[u];//记上u上一条边的编号,下一条在下面存储
    h[u]=cnt;//记下u点的当前边的编号//始终是最后一条边
}
priority_queue<M>Q;

void dijkstra(int s)
{
    for(int i=1;i<=n<<1;i++){
        dis[i]=INF;
    }
    dis[s]=0;
    Q.push(M(s,0));
    while(!Q.empty()){
        M cur=Q.top();
        Q.pop();
        if(dis[cur.v]<cur.dis)continue;
        for(int i=h[cur.v];i;i=e[i].nex){
                int y=e[i].go;
            if(dis[y]>cur.dis+e[i].val){
                dis[y]=cur.dis+e[i].val;
                Q.push(M(y,dis[y]));
            }
        }
    }

}
int main()
{
    int u,v,w,ans=0;
    cin>>n>>m;
    for(int i=1;i<=m;i++){
        cin>>u>>v>>w;
        addadge(u,v,w);
    }
    dijkstra(1);
    for(int i=1;i<=n;i++){
        ans+=dis[i];//这里就是单源最短路径的总长了
    }

    cout<<ans<<endl;
    return 0;
}


2.bellman-ford&&队列优化后的bellman-ford即spfa算法

bellman-ford算法可以解决dijkstra算法解决不了的负边权问题(听说改一下也可以用dij解决负边问题,但我不会!),算法的主要思路就是用所有的点都要经过所有边的松弛(其实有的点经历某条边就松弛完毕了,这个等会优化),有n个点m条边,我们求其中点S到其他点的最短路,我们只要松弛每条边n-1次就能得到最短路了。

每次松弛的成功都代表我们发现了一条S到此点目前的最短路,可以知道,我们把dis数组开到INF,dis[s]=0,当进行第一次迭代松弛(即枚举每条边进行松弛操作时)能松弛成功的(即dis[v]>dis[u]+w)只有边头为s的dis[s],那么第一次的松弛操作只能让我们松弛成功S过去不需要中转的点(且此边已经默认为当前的最短边),第二次松弛操作,即又尝试松弛一遍所有的边,我们可以发现,只有之前经过松弛过的点才能参加这一次的松弛操作即对它周围的点进行影响,可以从因为还没松弛过的点dis数组都还是无穷大,不可能加上边权就边小的;换句话说,第1轮在对所有的边进行松弛之后,得到的是S点“只能经过一条边”到达其余各顶点的最短路径长度。第2轮在对所有的边进行松弛之后,得到的是从S点“最多经过两条边”到达其余各顶点的当前最短路径长度。如果进行k轮的话,得到的就是1号顶点“最多经过k条边”到达其余各顶点的当前最短路径长度。

那么可以知道如果进行第N次松弛操作的话,就是顶点”最多经历N条边“到达其余个点的当前最短路径,但是我们松弛第N次真的能成功吗?(在无负权圈的情况下)肯定是不能的,因为任意两点之间的最短路径最多包含n-1条边(画一下就知道了n个顶点最少有n-1条边,就刚好连成一条线,多一条边你加在哪?)

核心代码:

for(int i=0;i<n;i++){
    
    for(int j=1;j<=m;j++){
        
        if(dis[v]>dis[u]+w)
            
            dis[v]=dis[u]+w;
    }
}

可以发现,其实我可能在松弛n-1遍前就已经得出所有的最短路径了!所有n-1只是一个最大值,那么怎么判断是否需要进行下一轮松弛呢?我们假设一下,当我们在进行第k-1次松弛操作时我们做的是S点"最多经历k-1条边"顶点到达其他点的当前最短路径,而我们之前得到过,只有经历松弛的点才能进行这次的松弛操作,假设这次松弛操作不成功,那么实际情况中我们的dis数组已经得到所有最短路了(想得通吗?每次松弛我们都遍历m条边,那么第k-1次松弛操作即"最多经历k-1条边"时我们到达当前的最短路没有变化!那么k-2次松弛操作即"最多经历k-2条边时"顶点到达其他点的当前最短路径就已经是最短了!),那么我们再进行第k次松弛操作的时候dis数组的值是必不可能再变化了的,因为上一轮都没有松弛成功点,这一轮做的不过就是上一轮的操作罢了(这里再举个小例子:如果我们有9个点,8条边,这8条边都是由1指向2~9,其实我们在第一次松弛操作时就已经找到所有的最短路了,那么剩下的8次循环就是浪费了的)(大前提是无负权圈)

归理就是:当第k次操作无松弛成功的点,那么第k-1次时我们就已经得到最短路径了,作为通理就是每次松弛操作必须成功,如果第k次松弛没有松弛成功,dis数组中就已经是最短路径了,那么就可以退出循环了

int flag;
for(int i=0;i<n;i++){

        flag=1;
    
    for(int j=1;j<=m;j++){
        
        if(dis[v]>dis[u]+w){
            
            flag=0;
         
            dis[v]=dis[u]+w;
            
        }
    }
    if(flag) break;//如果flag不变就是代表松弛失败就可以推出循环了
}

我们还就可以从分析中找到一个规律,只有松弛后的点才可以经历下一次的松弛操作即对它周围的点进行影响,我们还可以在这里进行优化,因为我们每次都是遍历m条边,其中就包括已经松弛成功的无用点,那我们为何不只松弛那些成功松弛过的点的临边呢?即构造一个队列,把松弛成功的点纳入队列中,用队列来维护这些松弛成功的点,比如我们有一点Q松弛成功,我们把这一点纳入队列,在这一轮的松弛操作结束后,再进行下一次松弛操作时,只对Q这点的临边进行松弛操作即(if(dis[v]>dis[u]+w))其中其中dis[u]为顶点到Q的距离,w为边权,dis[v]为Q的这条临边到的地点;这样做我们就大大的减少了枚举量。

原:

int flag;
for(int i=0;i<n;i++){

        flag=1;
    
    for(int j=1;j<=m;j++){//就是改变了这里
        
        if(dis[v]>dis[u]+w){
            
            flag=0;
         
            dis[v]=dis[u]+w;
            
        }
    }
    if(flag) break;//如果flag不变就是代表松弛失败就可以推出循环了
}

优化:

int book[MAX],e[MAX][MAX];//这里是存图,数据不大的情况下用邻接矩阵
int flag;
queue<int>q;
while(!Q.empty()){
    int cur=Q.top();
    Q.pop();
    book[v]=0;//这个数组是防止一个点多次进入进行判断的
    for(int i=1;i<n;i++){//依旧是上限是n-1次
        flag=1;
        if(dis[i]>dis[cur]+e[cur][i]){
            dis[i]=dis[cur]+e[cur][i];
            if(!book[v]){
                Q.push(v);
                book[v]=1;
            }
        }
        
    }
}

我们可以看得出,上面这个例子是用邻接矩阵存的图,这样对于稀疏图而言,明显是浪费空间的,而且在数据大的情况下,肯定会爆掉,所以我们都采用之前dij讲的链式前向星(即静态邻接表)存图,再进行遍历:

int book[MAX],head[MAX];
int flag;
queue<int>q;
struct edge
{
    int go;
    int nex;
    int val;

}e[MAX];

void addadge(int u,int v,int w)//链式前向星
{
    e[++cnt].go=v;
    e[cnt].val=w;
    e[cnt].nex=head[u];//记上u上一条边的编号,下一条在下面存储
    head[u]=cnt;//记下u点的当前边的编号
}

while(!Q.empty()){
    int cur=Q.top();
    Q.pop();
    book[v]=0;//这个数组是防止一个点多次进入进行判断的
    for(int i=head[cur];i;i=e[i].nex){
        flag=1;
        int v=e[i].go;
        if(dis[v]>dis[cur]+e[i].val){
            dis[v]=dis[cur]+e[i].val;
            if(!book[v]){
                Q.push(v);
                book[v]=1;
            }
        }
        
    }
}

其实我们常听到的spfa算法就是队列优化后的bellman-ford算法,它队列的操作对原算法的时间复杂度进行了极大的优化。


总结:floyd算法就是暴力枚举,数据少的情况下可以直接无脑做;dijkstra算法主要思路就是每次选一个离源点最近的点对它的出边进行松弛操作,直到选完所有的节点,目前没有了解怎么解决负边问题;bellman-ford算法主要是考虑了出边的操作,即每轮用所有边对节点进行松弛操作得到“顶点最多经过m条边”得出的每轮当前的最短路径,这个方法可以解决含有负权的图问题:原因如下

dijkstra算法,每次对最近的点的出边进行松弛操作结束后,就默认这个点已经是最短距离了,而如果后面有负边的存在就可能影响前面认定最短的判断,即可能会否认前面的最短路径,算法的正确性就得不到保证。而bellman-ford算法是对边的操作,即让顶点从经历一条边到最多经历所有边得出最短路,负边的存在不会影响它前面的正确性,因为每次松弛的最短路都是当前的最短路。

标签:松弛,int,路径,C++,最短,队列,出边,我们,dis
来源: https://blog.csdn.net/m0_60284775/article/details/121243707