无中生有之突击NOIP(6)--最短路径

  1. Floyd-Warshall
    例题描述:给你一些公路,这些公路与城市之间相连,现在我们要求任意两个城市之间的最短路径问题,也就是求任意两点的最短路径,这个问题也被称为:“多源最短路径”问题 。
    分析:如何求得任意两点的最短路径呢?通过之前的学习,我们知道可以利用深搜和广搜来求出任意两点的最短路径问题,所以要进行n²遍深度或广度优先搜索,即对每两个点都进行一次深度或广度优先搜索,便可以求得任意两点之间的最短路径,可还有别的方法吗?
    我们来想一想,根据以往的经验,我们要求任意两点之间的最短路径,如果要让两点之间的路径变短,只能引入第三个点,并使得这个顶点成为一个中转点,有的时候甚至是多个点,若不允许经过第三个点的时候,每个城市之间的最短路径就是他的初始值,而我们此时却想求出任意两点之间的最短路程,其实我们只有判断e[i][k]+e[k][j]是否小于e[i][j]即可,若小于则更替即可,时间复杂度为n²。此时我们表示出了所有e[i][j]的最短路径,似乎看起来很麻烦,但实际上就只有五行核心代码而已。
for(k=1;k<=n;k++)
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(e[i][j]>e[i][k]+e[k][j])
                e[i][j]=e[i][k]+e[k][j];

这段代码的基本思想是:最开始只允许经过1号顶点进行中转,接下来只允许经过1号和2号顶点进行中转……允许经过1——n号所有的顶点进行中转,求任意两点之间的最短路程,用一句话概括是:从i号顶点到j号顶点只经过前k号点的最短路程。其实是一种动态规划的思想,完整代码实现如下:

#include<stdio.h>
#include<algorithm>
int main()
{
    int e[10][10],k,i,j,n,m,t1,t2,t3;
    int inf=99999999;//定义一个正无穷的数
    cin>>n>>m;//n为顶点个数,m表示边的个数
    //初始化
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            for(i==j) e[i][j]=0;
            else e[i][j]=inf;
    //读入边
    for(i=1;i<=n;i++)
    {
        cin>>t1>>t2>>t3;
        e[t1][t2]=t3;
    }
    //Floyd-Warshall算法核心语句
    for(k=1;k<=n;k++)
        for(i=1;i<=n;i++)
            for(j=1;j<=n;j++)
                if(e[i][j]>e[i][k]+e[k][j])
                    e[i][j]=e[i][k]+e[k][j];
    //输出结果
    for(i=1;i<=n;i++)
    {
        for(j=1;j<=n;j++)
            cout<<e[i][j]<<"  ";//每对顶点的边
        cout<<endl;
    }
    teturn 0;
}

小结:通过这种方法我们可以求出任意两个点之间的最短路径。它的时间复杂度为n三次方,他的代码很短,只有五行,所以实现起来很容易,如果时间复杂度要求不高,使用该算法来求指定两点之间的最短路径或者指定一个点到其余各个顶点的最短路径也是可行的,不过请注意:Floyd-Warshall算法不能解决带有负权回路(或者叫做负权环)的图,因为带有负权回路的图没有最短路径。例如下面这个图就不存在1号顶点到3号的环,因为1->2->3->1->2->3->……1->2->3这样的路径中,每绕一次1->2->3这样的环,最短路径就会减少1,永远找不到最短路径,其实如果一个图中带有负权回路,那么这个图则没有最短路径。

2.Dijkstra–单源最短路径
题目:我们要求每一点对其他各点的的最短路程。
算法分析:
我们先设立一个一维数组dis来储存从1号顶点到其余各个顶点的初始化路程,我们将此时dis数组中的值称为最短路程的估计值,
既然是求1号顶点到其余各顶点之间的最短路程,那就先找一个离1号最近的顶点,然后1号顶点到这个顶点的值变为了确定值,即1号顶点最近的是该顶点,并且这个图所有的边都是正数,那么肯定不可能通过第三个顶点中转,使得1号顶点到2号顶点的路程进一步缩短,因为1号顶点到其他顶点的路程肯定没有1号到该顶点的路程短,对吧?
然后我们再利用找到的这个点看它的出边,看是否可以更新1号顶点到其他顶点的值,利用松弛(松弛:是一个专业术语,即dis[k]>dis[a]+e[a][k],then dis[k]=dis[a]+e[a][k])然后我们在剩下的点中再找一个离1号顶点最近的顶点,继续松弛,最终所有的估计值全部变成了确定值。
总结:我们来总结一下该算法:Dijkstra的主要思想是:我们通过边来松弛1号顶点到其他各个顶点的路程,每次找到离源点最近的一个顶顶啊,然后以该项点为中心进行扩展,最终找到其余所有点的最短路径,基本步骤如下:
1.将所有的顶点分为两部分,已知最短路程的顶点集合P和未知最短路径的顶点集合Q开始,已知的最短路径的顶点集合P中只有源点一个顶点,我们这里用一个book数组来记录那些点在集合P中,例如对于某个顶点i,如果book[i]为1则表示这个顶点在集合P中,如果book[i]为0则表示这个顶点在集合Q中。
2.设置源点s到自己的最短路径为0即dis[s]=0。若存在有源点能直接到达的顶点i,则把dis[i]设为e[s][i]。同时把所有其他(源点不能直接到达的)顶点的最短路径设为正无穷。
3.在集合Q的所有顶点中选择一个离源点s最近的顶点u(即dis[u]最小u!=s)加入到集合P中,并考察所有以点U为起点的边,对每一条边进行松弛操作,例如存在一条从u到v的边,那么可以通过将边u->v添加到微博来拓展一条从s到v的路径,这条路径的长度是dis[u]+e[u][v]。如果这个值比目前已知的dis[v]药效,我们就可以用新值来代替当前dis[v]中的值。
4.重复第三步,如果集合Q为空,算法结束,最终dis数组中的值就是源点到所有项点的最短路径。
完整的Dijkstra算法代码如下:

#include<stdio.h>
#include<algorithm>
int main()
{
    int e[10][10],dis[10].book[10],i,j,n,m,t1,t2,t3,u,v,min;
    int inf=99999999;
    cin>>n>>m;
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i == j) e[i][j]=0;
            else e[i][j]=inf;
    for(i=1;i<=n;i++)
    {
        cin>>t1>>t2>>t3;
        e[t1][t2]=t3;
    }
    for(i=1;i<=n;i++)
        dis[i]=e[1][i];
    for(i=1;i<=n;i++)
        book[i]=0;
    book[1]=1;
    //核心算法
    for(i=1;i<=n-1;i++)//找到未遍历的且与源点最小值的点
    {
        min=inf;
        for(j=1;j<=n;j++)
        {
            if(book[j]==0 && dis[j]<min)
            {
                min=dis[j];//求出最小值
                u=j;//标记该点
            }
        }
        book[u]=1;//更替估计值
        for(v=1;v<=n;v++)
        {
            if(e[u][v]<inf)
            {
                if(dis[v]>dis[u]+e[u][v])
                    dis[v]=dis[u]+e[u][v];
            }
        }
    }

    //输出最终的结果
    for(i=1;i<=n;i++)
        cout<<dis[i];
    getchar();
    getchar();
    return 0;
}

分析:大家不难看出,该算法的时间复杂度是O(N²)其中每次找到离1号最近的顶点的时间度为N,后面我们会学到如何用堆来优化,使其降为logN,同样我们可以用邻接链表来代替矩阵,使整个时间复杂度优化为O(M+N)logN请注意,M的最坏情况是等于N²,这样的话会比N²还要大,但是大多数情况不会有那么多边,因此(M+N)logN要比N²小很多。

    下面我们讲一下如何用链表存储一个图。
    4 5
    1 4 9
    4 3 8
    1 2 5
    2 4 6
    1 3 7
    第一行n m。n表示顶点个数(顶点编号为1~n),m表示边的条数。接下来m行,每行有3个数x,y,z。表示顶点x到顶点y的坐标。
    现在用邻接表存储这个图,代码如下:
int n,m,i;
//u、v和w的数组大小要根据实际情况来设置,要比m的最大值要大一。
int u[6],v[6],w[6];
//first和next的数组大小要根据实际情况来设置,要比n的最大值大一。
int first[5],next[5];
cin>>n>>m;
//初始化first数组下标1~n的值为-1,表示1~n顶点暂时都没有边
for(i=1;i<=n;i++)
    first[i]=-1;
for(i=1;i<=m;i++)
{
    cin>>u[i]>>v[i]>>w[i];
    next[i]=first[u[i]];
    first[u[i]]=i;
}
这里大家介绍使用数组来实现邻接表,而没有使用真正的指针链表,这是一种在实际应用中非常容易实现方法,这种方法为每个顶点i(i从1到n)都设置了一个链表,里面保存了从顶点i出发的所有边,这里用first和next数组来实现。首先我们需要为每一条边进行1~m的编号。用u,v和w三个数组来记录每条边的信息,即u[i],v[i]和w[i]表示第i条边是从第u[i]号顶点到v[i]号顶点(u[i]->v[i]),且权值为w[i]。first数组的1~n号单元格分别用来存储1~n号顶点的第一条边的编号,初始的时候因为没有边加入所以都是-1.即first[u[i]]保存顶点u[i]的第一条边的编号,next[u[i]]保存顶点u[i]的第一条边的编号,next[i]存储“编号为i的边”的“下一条边”编号。

这里写图片描述
这里写图片描述

接下来如何遍历每一条边呢》first数组储存的是1~n号顶点
比如1号顶点的第一条边是编号为5的边(1 3 7),2号顶点的第一条边是编号为4的边(2 4 6),3号顶点没有出向边,4号顶点的第一条边是编号为2的边(4 3 8),那么如何遍历1号顶点的每一条边呢?!

这里写图片描述

再找到1号顶点的一条边后,剩下的边都可以在next数组中找到。

k=first[1];
while(k!=-1)
{
    cout<<u[k]<<" "<<v[k]<<" "<<w[k];
    k=next[k];
}

细心的同学会发现,此时遍历的顺序恰恰与输入顺序相反,因为在每个顶点插入边的时候都是直接插入链表的首部而不是尾部。不过这并不会产生任何问题,这正是这种方法的奇妙之处。遍历每个顶点的坐标,代码如下:

for(i=1;i<=n;i++)
{
    k=first[i];
    while(k!=1)
    {
        cout<<u[k]//………………此处省略不写,不一定要输出
        k=next[k];
    }
}

总结:
可以发现用邻接链表来储存的话,m要远小于N²。因此系数图选用邻接链表比矩阵储存要好很多。
最后,这一章的最短路径算法是一种基于贪心的算法,每次扩展一个路程最短的点,更新与其相邻的点的路程。当所有边权为正时,不会存在一个路程更短的没扩展的点,所以这个点的路程永远不会再改变,因而保证了算法的正确性,不过根据这个原理,用本算法求最短路径的图是不能有负权边的,因为扩展到负权边的时候会产生更短的路程,有可能就破坏了已经更新的点路程不会改变的性质,既然用这个算法求最短路径的图不能有负权边,那有没有可以求带有负权边的指定顶点到其余各个顶点的最短路径算法?It’s coming!

3、Bellman-Ford_______解决负权边

首先简单介绍一下这个无论思想还是代码实现上都堪称完美的最短路算法:Bellman-Ford。ta长什么样呢?其实非常简单,我们看看它长什么样?
for(k=1;k<=n-1;k++)
    for(i=1;i<=m;i++)
        if( dis[v[i]] > dis[u[i]]+w[i])
            dis[v[i]= dis[u[i]]+ w[i];
上面的代码中,外循环循环了n-1次(顶点个数减一次),内循环循环了m次,(m为边的个数)即枚举每一条边。dis数组的作用与dijkstra算法一样,是用来记录源点到其他各个点的最短路径,u,v和w三个数字记录变得信息。其实大体和Dijkstra的松弛思想是一样的,我们把所有边都松弛一边。
    并且,第一轮在对所有的边松弛之后,得到的是从一号顶点只能经过一条边到达其余各项点的最短路径长度。第二轮在对所有的边进行松弛之后,得到的就是1号顶点最多经过k条边到达其余各项点的最短路径长度。,需要进行多少轮呢??
    只需要进行n-1轮其实就可以啦,因为在一个含有n个点的图中,任意两点之间的最短路径最多包含n-1条边。是最多,最多,最多!

OK总结一下,因为最短路径上最多有n-1条边,因此Bellman-Ford算法最多有n-1个阶段。在每一个阶段,我们对每条边都要执行松弛操作。其实没事时一次松弛操作,就可以获得一些顶点的最短路,即这些顶点的最短路的值就会一直保持不变不再收后续松弛操作的影响。在前K个阶段结束后,就已经找出了从源点发出最多K条边到达各个顶点的最短路。直到完成n-1个阶段后,便得出了最多经过n-1条边的最短路。
完整代码如下:
#include<stdio.h>
#include<algorithm>
int main()
{
    int dis[10],i,k,n,m,u[10],v[10],w[10];
    int inf=99999999;//存储正无穷
    cin>>n>>m;//读入顶点和边的条数
    for(i=1;i<=m;i++)
        cin>>u[i]>>v[i]>>w[i];
    for(i=1;i<=n;i++)//初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
        dis[i]=inf;
    dis[1]=0;
    //核心代码开始
    for(k=1;k<=n;k++)
        for(i=1;i<=n;i++)
            if( dis[v[i]] > dis[u[i]]+w[i] )
                dis[v[i]] = dis[u[i]]+w[i];
    //核心代码结束,输出结果
    for(i=1;i<=n;i++)
        cout<<dis[i];
    getchar();getchar();
    return 0;
}

此外Bellman-ford还可以检测一个图是否含有负权回路,如果在进行n-1轮松弛之后,仍然存在if( dis[v[i]] > dis[u[i]]+w[i] )
dis[v[i]] = dis[u[i]]+w[i];
的情况,那么说明在进行了n-1次松弛之后,还可以继续松弛,那么此图必然存在负权回路。而且在之前我们说过,负权回路是没有最短路的,而且一个最短路径所包含的边最多n-1条,即进行n-1轮松弛之后,必然最短路不会发生变化。如果在n-1轮松弛之后最短路仍然会发生变化,在改图必然存在负权回路。
核心代码如下

//检测是否有负权回路
flag=0;
for(i=1;i<=m;i++)
    if( dis[v[i]] > dis[u[i]]+w[i] )
        flag=1;
if(flag==1)cout<<"Have it";

其实bellman-ford算法的时间复杂度是O(NM),这比Dijkstra算法还要高,我们可以对其优化,在实际操作中,其实并不是只有到最后n-1的时候才能出现最大值,因此可以添加一个变量cheek用来标记数组dis在本轮松弛中是否发生了变化,若无变化,则可以提前跳出循环,代码如下:

#include<stdio.h>
#include<algorithm>
int main()
{
    int dis[10],check,flag,i,k,n,m,u[10],v[10],w[10];
    int inf=99999999;//存储正无穷
    cin>>n>>m;//读入顶点和边的条数
    for(i=1;i<=m;i++)
        cin>>u[i]>>v[i]>>w[i];
    for(i=1;i<=n;i++)//初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
        dis[i]=inf;
    dis[1]=0;
    //核心代码开始
    for(k=1;k<=n;k++)
    {
        check=0;//用来标记在本轮松弛中数组dis是否会发生更新
        //进行新一轮松弛
        for(i=1;i<=n;i++)
            {
            if( dis[v[i]] > dis[u[i]]+w[i] )
                {
                dis[v[i]] = dis[u[i]]+w[i];
                check = 1;//数组dis发生更新,改变check的值
                }
            }
            //检验dis是否有更新
        if(check == 0) break;//如果没有更新则退出循环结束算法
    }
    //检测是否有负权回路
    flag=0;
    for(i=1;i<=m;i++)
        if( dis[v[i]] > dis[u[i]]+w[i] )
            flag=1;
    if(flag==1)cout<<"Have it";
    else
    {
         //输出结果
        for(i=1;i<=n;i++)
            cout<<dis[i]<<" ";
    }
    getchar();getchar();
    return 0;
}
该算法的另一种优化已经已经有所提示,在每实施一次松弛之后,就会有一些顶点已经求得其最短路,此后这些顶点的最短路的估计值就会一直保持不变,不在受后续松弛操作的影响,但是每次还要判断是否松弛,浪费时间,这就启发我们,每次仅对最短路估计值发生了编号了的顶点的所有出边进行松弛操作。详看下一节。

4.Bellman-Ford的队列优化。

分析:每次仅对最短路程发生了变化了的点的相邻边执行松弛操作。但是如何知道当前哪些点的最短路程发生了变化呢?这里可以用一个队列来维护算法如下:
每次取首顶点u,然后对其所有出边进行松弛,若有一条u->v的边,可以使其松弛成功,且v不在队列时,我们就让v入队尾。需要注意的是,同一个顶点同时在队列中出现多次是毫无意义的,所以我们需要一个数组来判定(判断哪些点已经在队列中),对顶点u的所有出边松弛完毕后,就将顶点u出队。接下来不断从队列中取出新的队首项点再进行如上操作,直到队列为空为止。
这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

这个解释真的很棒,不知道大家发现没有,这个优化其实与Dijkstra与广搜的融合有点异曲同工之妙。
下面是代码实现,我们用邻接链表
#include<stdio.h>
#include<algorithm>
int main()
{
    int n,m,i,j,k;
    int u[8],v[8],w[8];
    //u,v和w的数组大小要根据实际情况来设置,要比m的最大值要大11
    int first[6],next[8];//first要比n大一,next要比m最大值大一
    int dis[6]={0},book[6]={0};
    int que[101]={0},head=1;tail=1;//用book数组来记录那些已经在队列中的顶点
    int inf=99999999;//正无穷
    cin>>n>>m;
    //初始化dis,表示1号顶点到其余各点的初始路程
    for(i=1;i<=n;i++)
        dis[i]=inf;
    dis[1]=0;
    //初始化book为0
    for(i=1;i<=n;i++)
        book[i]=0;
    //初始化first数组下标1~n表示1~n顶点暂时都没有边
    for(i=1;i<=n;i++)
        first[i]=-1;
    for(i=1;i<=m;i++)
    {
        //读入每一条边
        cin>>u[i]>>v[i]>>w[i];
        //下面两句是建立邻接表的关键
        next[i]=first[u[i]];
        first[u[i]]=i;
    }
    //1号点入队
    que[tail]=1;
    tail++;
    book[1]=1;//标记1号顶点已经入队
    while(head<tail)//队列不为空的时候循环
    {
        k=first[que[head]];//当前需要处理的队首项点
        while(k!=-1)//扫描当前项点所需要的边
        {
            if(dis[v[k]]>dis[u[k]+[k])//判读是否松弛
            {
                dis[v[k]]=dis[u[k]]+w[k];
                if(book[v[k]]==0)//表示该点在不在队列中
                //这的book数组用来判断v[k]是否在队列中
                //如果不是有一个数组标记,那么判断一个顶点是否                                                在队列中得从头循环一遍,很浪费时间
                {//入队
                    que[tail]=v[k];
                    tail++;
                    book[v[k]]=1;
                }
            }
            k=next[k]
        }
        //出队
        book[que[head]]=0;
        head++;
    }
    //输出
    for(i=1;i<=n;i++)
        cout<<dis[i]<<" ";
    getchar();getchar();
    return 0;
}                   

总结:初始时,将源点加入队列,每次从队首(head)取出一个顶点,并对与其相邻的所有顶点进行松弛长谁,若某个相邻的顶点松弛成功后,且这个相邻顶点不在队列内,就将其入队,对当前处理完毕后立即出队,并对下一个新队列进行如上操作,。若某个点进入队列的次数超过n次,就一定会存在负环。

最后给大家一份礼物,也希望大家可以根据不同题目的实际需求和算法特性来选择合适的算法,别忘记,我们还有一个用堆优化的迪杰斯特拉,我们会日后提到。
这里写图片描述

编者自述,好累好累,颈椎痛啊,大家可以尝试一下两倍香草糖的焦糖玛奇朵,味道不错哦。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值