0
点赞
收藏
分享

微信扫一扫

第六节——最短路径

目录

专栏

算法(C语言)

第六节——最短路径

Floyd-Warshall

城市旅游

暑假,小明准备去一些城市旅游。有些城市之间有公路,有些城市之间则没有,如下图。为了节省经费以及方便计划旅程,小明希望在出发之前知道任意两个城市之间的最短路程。

任意两个城市之间的最短路程

上图中有 4 个城市 8 条公路,公路上的数字表示这条公路的长短。请注意这些公路是单向的。我们现在需要求任意两个城市之间的最短路程,也就是求任意两个点之间的最短路径。这个问题也被称为 “多源最短路径” 问题。

现在需要一个数据结构来存储图的信息,我们仍然可以用一个 4*4 的矩阵(二维数组 e)来存储。比如 1 号城市到 2 号城市的路程为 2,则设 e[1] [2] 的值为 2。2号城市无法到达 4 号城市,则设置 e[2] [4] 的值为 ∞。另外此处约定一个城市自己到自己的路程也是 0,例如 e[1] [1] 为 0,具体如下。

二维矩阵存储图的信息

现在回到问题:如何求任意两点之间的最短路径呢?通过之前的学习,我们知道通过深度或广度优先搜索可以求出两点之间的最短路径。所以进行 n² 遍深度或广度优先搜索,即对每两个点都进行一次深度或广度优先搜索,便可以求得任意两点之间的最短路径。可是还有没有别的方法呢?

我们来想一想,根据以往的经验,如果要让任意两点(例如从顶点 a 到顶点 b)之间的路程变短,只能引入第三个点(顶点 k),并通过这个顶点 k 中转即 a→k→b,才可能缩短原来从顶点 a 到顶点 b 的路程。那么这个中转的顶点 k 是 1~n 中的哪个点呢?甚至有时候不只通过一个点,而是经过两个点或者更多点中转会更短,即 a→k1→k2→b 或者 a→k1→k2→···ki···→b。比如上图中从 4 号城市到 3 号城市(4→3)的路程 e[4] [3] 原本是 12,如果只通过 1 号城市中转(4→1→3),路程将缩短为11 (e[4] [1]+e[1] [3]=5+6=11)。其实 1 号城市到 3 号城市也可以通过 2 号城市中转,使得 1 号到 3 号城市的路程缩短为5(e[1] [2]+e[2] [3]=2+3=5)。所以如果同时经过 1 号和 2 号两个城市中转的话,从 4 号城市到 3 号城市的路程会进一步缩短为 10。通过这个例子,我们发现每个顶点都有可能使得另外两个顶点之间的路程变短。好,下面我们将这个问题一般化。

当任意两点之间不允许经过第三个点时,这些城市之间的最短路程就是初始路程,如下。

二维矩阵存储图的信息

假如现在只允许经过 1 号顶点,求任意两点之间的最短路程,应该如何求呢?只需判断 e[i] [1]+e[1] [j] 是否比 e[i] [j] 要小即可。e[i] [j] 表示的是从 i 号顶点到 j 号顶点之间的路程。e[i] [1]+e[1] [j] 表示的是从 i 号顶点先到 1 号顶点,再从 1 号顶点到 j 号顶点的路程之和。其中 i 是1~ n 循环,j 也是1 ~ n 循环,代码实现如下。

for(i=1;i<=n;i++)
{
    for(j=1;j<=n;j++)
    {
        if(e[i][j] > e[i][1]+e[1][j])
            e[i][j] = e[i][1]+e[1][j];
    }
}

在只允许经过 1 号顶点的情况下,任意两点之间的最短路径更新为:

经过1号顶点的情况下最短路径更新

通过上图我们发现:在只通过 1 号顶点中转的情况下,3 号顶点到 2 号顶点(e[3] [2])、4 号顶点到 2 号顶点(e[4] [2])以及 4 号顶点到 3 号顶点(e[4] [3])的路程都变短了。

接下来继续求在只允许经过 1 和 2 号两个顶点的情况下任意两点之间的最短路程。如何做呢?我们需要在只允许经过 1 号顶点时任意两点的最短路程的结果下,再判断如果经过 2 号顶点是否可以使得 i 号顶点到 j 号顶点之间的路程变得更短,即判断 e[i] [2]+e[2] [i] 是否比 e[i] [j] 要小,代码实现为如下。

for(i=1;i<=n;i++)
    for(j=1;j<=n;j++)
        if(e[i][j] > e[i][1]+e[1][j]) e[i][j] = e[i][1]+e[1][j];

for(i=1;i<=n;i++)
    for(j=1;j<=n;j++)
        if(e[i][j] > e[i][2]+e[2][j]) e[i][j] = e[i][2]+e[2][j];

在只允许经过 1 号和 2 号顶点的情况下,任意两点之间的最短路径更新为:

经过1号和2号顶点的情况下最短路径更新

通过上图得知,在相比只允许通过 1 号顶点进行中转的情况下,这里允许通过 1 和 2 号顶点进行中转,使得 e[1] [3] 和 e[4] [3] 的路程变得更短了。
同理,继续在只允许经过 1、2 和 3 号顶点进行中转的情况下,求任意两点之间的最短路程。任意两点之间的最短路程更新为:

经过1号、2号和3号顶点的情况下最短路径更新

最后允许通过所有顶点作为中转,任意两点之间最终的最短路程为:

任意两点之间最终的最短路径

整个算法过程虽然说起来很麻烦,但是代码实现却非常简单,核心代码只有五行:

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>
int main()
{
    int e[10][10],k,i,j,n,m,t1,t2,t3;
    int inf=99999999;	//用inf(infinity的缩写)存储一个我们认为的正无穷值
    //读入n和m,n表示顶点个数,m表示边的条条数
    scanf("%d %d",&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<=m;i++)
    {
        scanf("%d %d %d",&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++)
        {
            printf("%10d",e[i][j]);
        }
        printf("\n");
    }
    
    return 0;
}

有一点需要注意的是:如何表示正无穷。我们通常将正无穷定义为 99999999,因为这样即使两个正无穷相加,其和仍然不超过 int 类型的范围(C 语言 int 类型可以存储的最大正整数是 2147483647)。在实际应用中最好估计一下最短路径的上限,只需要设置比它大一点即可。例如有 100 条边,每条边不超过 100 的话,只需将正无穷设置为 10001 即可。如果你认为正无穷和其他值相加得到一个大于正无穷的数是不被允许的话,我们只需在比较的时候加两个判断条件就可以了,请注意下面的 if 的语句。

//Floyd-Warshall算法核心语句
    for(k=1;k<=n;k++)
    	for(i=1;i<=n;i++)
    		for(j=1;j<=n;j++)
        		if(e[i][k]<inf && e[k][j]<inf && e[i][j]>e[i][k]+e[k][j])
                	e[i][j]=e[i][k]+e[k][j];

上面代码的输入数据样式为:

4 8
1 2 2
1 3 6
1 4 4
2 3 3
3 1 7
3 4 1
4 1 5
4 3 12

第一行两个数为 n 和 m,n 表示顶点个数,m 表示边的条数。

接下来 m 行,每一行有三个数 t1、t2 和 t3,表示顶点 t1 到顶点 t2 的路程是 t3。

得到最终结果如下:

最终结果

通过这种方法我们可以求出任意两个点之间的最短路径。它的时间复杂度是 O(N³)。令人很震撼的是它竟然只有五行代码,实现起来非常容易。正是因为它实现起来非常容易,如果时间复杂度要求不高,使用 Floyd-Warshall 来求指定两点之间的最短路径或者指定一个点到其余各个顶点的最短路径也是可行的。当然也有更快的算法,请看下面的 Dijkstra 算法。

另外需要注意的是,Floyd-Warshall 算法不能解决带有 “负权回路” (或者叫 “负权环” )的图,因为带有 “负权回路” 的图没有最短路径。例如下面这个图就不存在 1 号顶点到 3 号顶点的最短路径,因为1→2→3→1→2→3→···1→2→3 这样路径中,每绕一次 1→2→3 这样的环,最短路径就会减少 1,永远找不到最短路径。其实如果一个图中带有 “负权回路”,那么这个图则没有最短路径。

Dijkstra

本节来学习指定一个点(源点)到其余各个顶点的最短路径,也叫做 “单源最短路径” 。例如求下图中的 1 号顶点到 2、3、4、5、6 号顶点的最短路径。

求1号顶点到各个顶点的最短路径

与 Floyd-Warshall 算法一样,这里仍然使用二维数组 e 来存储顶点之间边的关系,初始值如下。

二维数组存储顶点之间边的关系

我们还需要用一个一维数组 dis 来存储 1 号顶点到其余各个顶点的初始路程,如下。

一维数组dis

我们将此时 dis 数组中的值称为最短路程的“估计值”。

既然是求 1 号顶点到其余各个顶点的最短路程,那就先找一个离 1 号顶点最近的顶点。通过数组 dis 可知当前离 1 号顶点最近的是 2 号顶点。当选择了 2 号顶点后,dis[2] 的值就已经从“估计值”变为了“确定值”,即 1 号顶点到 2 号顶点的最短路程就是当前 dis[2] 值。为什么呢?你想啊,目前离 1 号顶点最近的是 2 号顶点,并且这个图所有的边都是正数,那么肯定不可能通过第三个顶点中转,使得 1 号顶点到 2 号顶点的路程进一步缩短了。因为 1 号顶点到其他顶点的路程肯定没有 1 号到 2 号顶点短,对吧。

既然选了 2 号顶点,接下来再来看 2 号顶点有哪些出边呢。有 2→3 和 2→4 这两条边。先讨论通过 2→3 这条边能否让 1 号顶点到 3 号顶点的路程变短,也就是说现在来比较 dis[3] 和 dis[2]+e[2] [3]的大小。其中 dis[3] 表示 1 号顶点到 3 号顶点的路程;dis[2]+e[2] [3] 中 dis[2] 表示 1 号顶点到 2 号顶点的路程,e[2] [3]表示 2→3 这条边。所以 dis[2]+e[2] [3] 就表示从 1 号顶点先到 2 号顶点,再通过 2→3 这条边,到达 3 号顶点的路程。

我们发现 dis[3]=12,dis[2]+e[2] [3]=1+9=10,dis[3]>dis[2]+e[2] [3],因此 dis[3] 要更新为 10。这个过程有个专业术语叫做“松弛”,1 号顶点到 3 号顶点的路程即 dis[3],通过 2→3 这条边松弛成功。这便是 Dijkstra 算法的主要思想:通过“边”来松弛 1 号顶点到其余各个顶点的路程。

同理,通过 2→4 (e[2] [4]),可以将 dis[4] 的值从 ∞ 松弛为 4(dis[4]初始为 ∞,dis[2]+e[2] [4]=1+3=4,dis[4]>dis[2]+e[2] [4],因此 dis[4] 要更新为 4)。

刚才我们对 2 号顶点所有的出边进行了松弛。松弛完毕之后 dis 数组为:

松弛完毕之后的dis数组

接下来,继续在剩下的 3、4、5 和 6 号顶点中,选出离 1 号顶点最近的顶点。通过上面更新过的 dis 数组,当前离 1 号顶点最近的是 4 号顶点。此时,dis[4] 的值已经从“估计值”变为了“确定值”。下面继续对 4 号顶点的所有出边(4→3,4→5 和 4→6)用刚才的方法进行松弛。松弛完毕之后 dis 数组为:

松弛完毕之后的dis数组1

继续在剩下的 3、5 和 6 号顶点中,选出离 1 号顶点最近的顶点,这次选择 3 号顶点。此时,dis[3] 的值已经从“估计值”变为了“确定值”。对 3 号顶点的所有出边(3→5)进行松弛。松弛完毕之后 dis 数组为:

松弛完毕之后的dis数组2

继续在剩下的 5 和 6 号顶点中,选出离 1 号顶点最近的顶点,这次选择 5 号顶点。此时,dis[5] 的值已经从“估计值”变为了“确定值”。对 5 号顶点的所有出边(5→4)进行松弛。松弛完毕之后 dis 数组为:

松弛完毕之后的dis数组3

最后对 6 号顶点的所有出边进行松弛。因为这个例子中 6 号顶点没有出边,因此不用处理。到此,dis 数组中所有的值都已经从“估计值”变为了“确定值”。

最终 dis 数组如下,这便是 1 号顶点到其余各个顶点的最短路径。

松弛完毕之后的dis数组4

OK,现在来总结一下刚才的算法。算法的基本思想是:每次找到离源点(上面例子的源点就是1号顶点)最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径。基本步骤如下:

  1. 将所有的顶点分为两部分:已知最短路程的顶点集合 Р 和未知最短路径的顶点集合 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] 最小)加入到集合 P。并考察所有以点 u 为起点的边,对每一条边进行松弛操作。例如存在一条从 u 到 v 的边,那么可以通过将边 u→v 添加到尾部来拓展一条从 s 到 v 的路径,这条路径的长度是 dis[u]+e[u] [v]。如果这个值比目前已知的 dis[v] 的值要小,我们可以用新值来替代当前 dis[v] 中的值。
  4. 重复第 3 步,如果集合 Q 为空,算法结束。最终 dis 数组中的值就是源点到所有顶点的最短路径。

完整的 Dijkstra 算法代码如下:

#include <stdio.h>
int main()
{
    int e[10][10],dis[10],book[10],i,j,n,m,t1,t2,t3,u,v,min;
    int inf=99999999;	//用inf(infinity的缩写)存储一个我们认为的正无穷值
    //读入n和m,n表示顶点个数,m表示边的条数
    scanf("%d %d",&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<=m;i++)
    {
        scanf("%d %d %d",&t1,&t2,&t3);
        e[t1][t2]=t3;
    }
    
    //初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
    for(i=1;i<=n;i++)
        dis[i]=e[1][i];
    
    //book数组初始化
    for(i=1;i<=n;i++)
        book[i]=0;
    book[1]=1;
    
    //Dijkstra算法核心语句
    for(i=1;i<=n-1;i++)
    {
        //找到离1号顶点最近的顶点
        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++)
        printf("%d ",dis[i]);
    
    getchar();
    getchar();
    return 0;
}

可以输入以下数据进行验证。第一行两个整数 n 和 m。n表示顶点个数(顶点编号为1~n),m 表示边的条数。接下来 m 行,每行有 3 个数 x y z,表示顶点 x 到顶点 y 边的权值为 z。

6 9
1 2 1
1 3 12
2 3 9
2 4 3
3 5 5
4 3 4
4 5 13
4 6 15
5 6 4

运行结果是:

0 1 8 4 13 17

通过上面的代码我们可以看出,这个算法的时间复杂度是 O(N²)。其中每次找到离 1 号顶点最近的顶点的时间复杂度是 O(N),这里我们可以用“堆”(将在下一节学到)来优化,使得这一部分的时间复杂度降低到 O(logN)。另外对于边数 M 少于 N² 的稀疏图来说(我们把 M 远小于 N² 的图称为稀疏图,而 M 相对较大的图称为稠密图),我们可以用邻接表(这是个什么东西?不要着急,待会再仔细讲解)来代替邻接矩阵,使得整个时间复杂度优化到 O(M+N)logN。请注意!在最坏的情况下 M 就是 N²,这样的话(M+N)logN要比 N² 还要大。但是大多数情况下并不会有那么多边,因此(M+N)logN要比 N² 小很多。

这里我们主要来讲解如何使用邻接表来存储一个图,先上数据。

4 5
1 4 9
2 4 6
1 2 5
4 3 8
1 3 7

第一行两个整数 n m。n表示顶点个数(顶点编号为 1~n),m 表示边的条数。接下来 m 行,每行有 3 个数 x y z。表示顶点 x 到顶点 y 的边的权值为 z。

现在用邻接表来存储这个图,先给出代码如下。

int n,m,i;
//u、v和w的数组大小要根据实际情况来设置,要比m的最大值要大1
int u[6],v[6],w[6];
//first和next的数组大小要根据实际情况来设置,要比n的最大值要大1
int first[5],next[5];
scanf("%d %d",&n,&m);
//初始化first数组下标1~n的值为-1,表示1~n顶点暂时都没有边
for(i=1;i<=n;i++)
    first[i]=-1;
for(i=1;i<=m;i++)
{
    scanf("%d %d %d",&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[i] 存储“编号为 i 的边”的“下一条边”的编号。

邻接表

邻接表1

接下来如何遍历每一条边呢?我们之前说过其实 first 数组存储的就是每个顶点 i(i 从1~n)的第一条边。比如 1 号顶点的第一条边是编号为 5 的边(1 3 7),2号顶点的第一条边是编号为 4 的边(2 4 6),3 号顶点没有出向边,4 号顶点的第一条边是编号为 2 的边(2 4 6)。那么如何遍历 1 号顶点的每一条边呢?也很简单。请看下图:

遍历1号顶点的每一条边

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

k=first[1];
while(k!=-1)
{
    printf("%d %d %d\n",u[k],v[k],w[k]);
    k=next[k];
}

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

for(i=1;i<=n;i++)
{
    k=first[1];
    while(k!=-1)
    {
        printf("%d %d %d\n",u[k],v[k],w[k]);
        k=next[k];
    }
}

可以发现使用邻接表来存储图的时间空间复杂度是 O(M),遍历每一条边的时间复杂度也是 O(M)。如果一个图是稀疏图的话,M要远小于N。因此稀疏图选用邻接表来存储要比用邻接矩阵来存储好很多。

最后,本节介绍的求最短路径的算法是一种基于贪心策略的算法。每次新扩展一个路程最短的点,更新与其相邻的点的路程。当所有边权都为正时,由于不会存在一个路程更短的没扩展过的点,所以这个点的路程永远不会再被改变,因而保证了算法的正确性。不过根据这个原理,用本算法求最短路径的图是不能有负权边的,因为扩展到负权边的时候会产生更短的路程,有可能就破坏了已经更新的点路程不会改变的性质。既然用这个算法求最短路径的图不能有负权边,那有没有可以求带有负权边的指定顶点到其余各个顶点的最短路径算法呢?请看下面。

Bellman-Ford

Dijkstra 算法虽然好,但是它不能解决带有负权边(边的权值为负数)的图。现在我要来介绍一个无论是思想上还是代码实现上都堪称完美的最短路算法:Bellman-Ford。Bellman-Ford 算法非常简单,核心代码只有 4 行,并且可以完美地解决带有负权边的图,先来看看它长啥样:

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 次(n 为顶点的个数),内循环循环了 m 次(m为边的个数),即枚举每一条边。dis 数组的作用与 Dijkstra 算法一样,是用来记录源点到其余各个顶点的最短路径。u、v 和 w 三个数组是用来记录边的信息。例如第 i 条边存储在 u[i]、v[i] 和 w[i] 中,表示从顶点 u[i] 到顶点 v[i] 这条边(u[i]→v[i])权值为 w[i]。

if(dis[v[i]] > dis[u[i]] + w[i])
    dis[v[i]] = dis[u[i]] + w[i];

上面这两行代码的意思是:看看能否通过 u[i]→v[i](权值为 w[i] )这条边,使得 1 号顶点到 v[i] 号顶点的距离变短。即 1 号顶点到 u[i] 号顶点的距离(dis[u[i]])加上 u[i]→v[i] 这条边(权值为 w[i])的值是否会比原先 1 号顶点到 v[i] 号顶点的距离(dis[v[i]])要小。这一点其实与 Dijkstra 的“松弛”操作是一样的。现在我们要把所有的边都松弛一遍,代码如下。

for(i=1;i<=m;i++)
    if(dis[v[i]] > dis[u[i]] + w[i])
        dis[v[i]] = dis[u[i]] + w[i];

那把每一条边都“松弛”一遍后,究竟会有什么效果呢?现在来举个具体的例子。求下图 1 号顶点到其余所有顶点的最短路径。

求1号顶点到其余所有顶点的最短路径

我们还是用一个 dis 数组来存储 1 号顶点到所有顶点的距离。

用dis数组存储1号顶点到所有顶点的距离

上方右图中每个顶点旁的值(带下划线的数字)为该顶点的最短路“估计值”(当前 1 号顶点到该顶点的距离),即数组 dis 中对应的值。根据边给出的顺序,先来处理第 1 条边“2 3 2”(2→3,通过这条边进行松弛),即判断 dis[3] 是否大于 dis[2]+2。此时 dis[3] 是 ∞,dis[2]是 ∞,因此 dis[2]+2 也是 ∞,所以通过“2 3 2”这条边不能使 dis[3] 的值变小,松弛失败。

同理,继续处理第 2 条边“1 2-3”(1→2),我们发现 dis[2] 大于 dis[1]+(-3),通过这条边可以使 dis[2] 的值从 ∞ 变为 -3,因此松弛成功。用同样的方法处理剩下的每一条边。对所有的边松弛一遍后的结果如下。

第1轮对所有边进行松弛之后

我们发现,在对每条边都进行一次松弛后,已经使得 dis[2] 和 dis[5] 的值变小,即 1 号顶到 2 号顶点的距离和 1 号顶点到 5 号顶点的距离都变短了。

接下来我们需要对所有的边再进行一轮松弛,操作过程与上一轮一样,再来看看又会发生什么变化。

第2轮对所有边进行松弛之后

在这一轮松弛时,我们发现,现在通过“2 3 2”(2→3)这条边,可以使 1 号顶点到 3 号顶点的距离(dis[3])变短了。爱思考的同学就会问了,这条边在上一轮也松弛过啊,为什么上一轮松弛失败了,这一轮却成功了呢?因为在第一轮松弛过后,1 号顶点到 2 号顶点的距离(dis[2])已经发生了变化,这一轮再通过“2 3 2”(2→3)这条边进行松弛的时候,已经可以使 1 号顶点到 3 号顶点的距离(dis[3])的值变小。

换句话说,第 1 轮在对所有的边进行松弛之后,得到的是从 1 号顶点“只能经过一条边”到达其余各顶点的最短路径长度。第 2 轮在对所有的边进行松弛之后,得到的是从 1 号顶点“最多经过两条边”到达其余各顶点的最短路径长度。如果进行 k 轮的话,得到的就是 1 号顶点“最多经过 k 条边”到达其余各顶点的最短路径长度。现在又有一个新问题:需要进行多少轮呢?

只需要进行 n-1 轮就可以了。因为在一个含有 n 个顶点的图中,任意两点之间的最短路径最多包含 n-1 边。

有些特别爱思考的同学又会发出一个疑问:真的最多只能包含 n-1 条边?最短路径中不可能包含回路吗?

答案是:不可能!最短路径肯定是一个不包含回路的简单路径。回路分为正权回路(即回路权值之和为正)和负权回路(即回路权值之和为负)。我们分别来讨论一下为什么这两种回路都不可能有。如果最短路径中包含正权回路,那么去掉这个回路,一定可以得到更短的路径。如果最短路径中包含负权回路,那么肯定没有最短路径,因为每多走一次负权回路就可以得到更短的路径。因此,最短路径肯定是一个不包含回路的简单路径,即最多包含 n-1 条边,所以进行 n-1 轮松弛就可以了。

扯了半天,回到之前的例子,继续进行第 3 轮和第 4 轮松弛操作,这里只需进行 4 轮就可以了,因为这个图一共只有 5 个顶点。

第3轮对所有边进行松弛之后

第4轮对所有边进行松弛之后

有些特别特别爱思考的同学又会有一个疑问,这里貌似不用进行第 4 轮嘛,因为进行第 4 轮之后 dis 数组没有发生任何变化!没错!你说的真是太对了!其实是最多进行 n-1 轮松弛。

整个 Bellman-Ford 算法用一句话概括就是:对所有的边进行 n-1 次“松弛”操作。核心代码只有 4 行,如下。

for(k=1;k<=n-1;k++)	//进行n-1轮松弛
    for(i=1;i<=m;i++)	//枚举每一条边
        if(dis[v[i]] > dis[u[i]] + w[i])	//尝试对每一条边进行松弛
            dis[v[i]] = dis[u[i]] + w[i];

Ok,总结一下。因为最短路径上最多有 n-1 条边,因此 Bellman-Ford 算法最多有 n-1 个阶段。在每一个阶段,我们对每一条边都要执行松弛操作。其实每实施一次松弛操作,就会有一些顶点已经求得其最短路,即这些顶点的最短路的“估计值”变为“确定值”。此后这些顶点的最短路的值就会一直保持不变,不再受后续松弛操作的影响(但是,每次还是会判断是否需要松弛,这里浪费了时间,是否可以优化呢?)。在前 k 个阶段结束后,就已经找出了从源点发出“最多经过 k 条边”到达各个顶点的最短路。直到进行完 n-1个阶段后,便得出了最多经过 n-1 条边的最短路。

Bellman-Ford 算法的完整的代码如下。

#include <stdio.h>
int main()
{
    int dis[10],i,k,n,m,u[10],v[10],w[10];
    int inf=99999999;	//用inf(infinity的缩写)存储一个我们认为的正无穷值
    //读入n和m,n表示顶点个数,m表示边的条数
    scanf("%d %d",&n,&m);
    
    //读入边
    for(i=1;i<=m;i++)
        scanf("%d %d %d",&u[i],&v[i],&w[i]);
    
    //初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
    for(i=1;i<=n;i++)
        dis[i]=inf;
    dis[1]=0;
    
    //Bellman-Ford算法核心语句
    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];
    
    //输出最终的结果
    for(i=1;i<=n;i++)
        printf("%d ",dis[i]);
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。第一行两个整数 n m。n 表示顶点个数(顶点编号为1~N),m 表示边的条数。接下来 m 行表示,每行有 3 个数 x y z。表示从顶点 x 到顶点 y 的边的权值为 z。

5 5
2 3 2
1 2 -3
1 5 5
4 5 2
3 4 3

运行结果是:

0 -3 -1 2 4

此外,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 轮松弛之后最短路仍然会发生变化,则该图必然存在负权回路,关键代码如下:

//Bellman-Ford算法核心语句
    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];
//检查负权回路
flag=0;
for(i=1;i<=m;i++)
    if(dis[v[i]] > dis[u[i]] + w[i]) flag=1;
if(flag==1) printf("此图含有负权回路");

显然,Bellman-Ford 算法的时间复杂度是 O(NM),这个时间复杂度貌似比 Dijkstra 算法还要高,我们还可以对其进行优化。在实际操作中,Bellman-Ford 算法经常会在未达到 n-1 轮松弛前就已经计算出最短路,之前我们已经说过,n-1 其实是最大值。因此可以添加一个一维数组用来备份数组 dis。如果在新一轮的松弛中数组 dis 没有发生变化,则可以提前跳出循环,代码如下。

#include <stdio.h>
int main()
{
    int dis[10],bak[10],i,k,n,m,u[10],v[10],w[10],check,flag;
    int inf=99999999;	//用inf(infinity的缩写)存储一个我们认为的正无穷值
    //读入n和m,n表示顶点个数,m表示边的条数
    scanf("%d %d",&n,&m);
    
    //读入边
    for(i=1;i<=m;i++)
        scanf("%d %d %d",&u[i],&v[i],&w[i]);
    
    //初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
    for(i=1;i<=n;i++)
        dis[i]=inf;
    dis[1]=0;
    
    //Bellman-Ford算法核心语句
    for(k=1;k<=n-1;k++)
    {
        //将dis数组备份至bak数组中
    	for(i=1;i<=n;i++) bak[i]=dis[i];
        //进行一轮松弛
        for(i=1;i<=m;i++)
            if(dis[v[i]] > dis[u[i]] + w[i])
                dis[v[i]] = dis[u[i]] + w[i];
        //松弛完毕后检测dis数组是否有更新
        check=0;
        for(i=1;i<=n;i++) if(bak[i]!=dis[i]){check=1;break;}
        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) printf("此图含有负权回路");
    else
    {
        //输出最终的结果
    	for(i=1;i<=n;i++)
        	printf("%d ",dis[i]);
    }
    getchar();getchar();
    return 0;
}

Bellman-Ford 算法的另外一种优化在文中已经有所提示:在每实施一次松弛操作后,就会有一些顶点已经求得其最短路,此后这些顶点的最短路的估计值就会一直保持不变,不再受后续松弛操作的影响,但是每次还要判断是否需要松弛,这里浪费了时间。这就启发我们:每次仅对最短路估计值发生变化了的顶点的所有出边执行松弛操作。详情请看下面:Bellman-Ford 的队列优化。

Bellman-Ford的队列优化

在上一节,我们提到 Bellman-Ford 算法的另一种优化:每次仅对最短路程发生变化了的点的相邻边执行松弛操作。但是如何知道当前哪些点的最短路程发生了变化呢?这里可以用一个队列来维护这些点,算法大致如下。

每次选取队首顶点 u,对顶点 u 的所有出边进行松弛操作。例如有一条 u→v 的边,如果通过 u→v 这条边使得源点到顶点 v 的最短路程变短(dis[u]+e[u] [v]<dis[v]),且顶点 v 不在当前的队列中,就将顶点 v 放入队尾。需要注意的是,同一个顶点同时在队列中出现多次是毫无意义的,所以我们需要一个数组来判重(判断哪些点已经在队列中)。在对顶点 u 的所有出边松弛完毕后,就将顶点 v 出队。接下来不断从队列中取出新的队首顶点再进行如上操作,直至队列空为止。

下面我们用一个具体的例子来详细讲解。

5 7
1 2 2
1 5 10
2 3 3
2 5 7
3 4 4
4 5 5
5 3 6

第一行两个整数 n m。n 表示顶点个数(顶点编号为 1~N),m 表示边的条数。接下来 m 行,每行有 3 个数 x y z。表示顶点 x 到顶点 y 的边权值为 z。

例子

我们用数组 dis 来存放 1 号顶点到其余各个顶点的最短路径。初始时 dis[1] 为 0,其余为无穷大。接下来将 1 号顶点入队。队列这里用一个数组 que 以及两个分别指向队列头和尾的变量 head 和 tail 来实现(队列的实现我们在第 2 节已经讲过)。

将1号顶点入队

先来看当前队首 1 号顶点的边 1→2,看通过 1→2 能否让 1 号顶点到 2 号顶点的路程(即dis[2])变短,也就是说先来比较 dis[2] 和 dis[1]+(1→2) 的大小。dis[2] 原来的值为 ∞,dis[1]+(1→2)的值为 2,因此松弛成功,dis[2] 的值从 ∞ 更新为 2。并且当前 2 号顶点不在队列中,因此将 2 号顶点入队。

将2号顶点入队

同样,对 1 号顶点剩余的出边进行如上操作,处理完毕后数组 dis 和队列 que 状态如下:

将1号顶点出队

对 1 号顶点处理完毕后,就将 1 号顶点出队(head++即可),再对新队首 2 号顶点进行如上处理。在处理 2→5 这条边时需要特别注意一下,2→5 这条边虽然可以让 1 号顶点到 5 号顶点的路程变短(dis[5]的值从 10 更新为 9),但是 5 号顶点已经在队列中了,因此 5 号顶点不能再次入队。对 2 号顶点处理完毕后数组 dis 和队列 que 状态如下:

对2号顶点处理完毕后数组和队列的状态

在对 2 号顶点处理完毕后,需要将 2 号顶点出队,并依次对剩下的顶点做相同的处理,直到队列为空为止。最终数组 dis 和队列 que 状态如下:

最终数组和队列的状态如下

下面是代码实现,我们还是用邻接表来存储这个图,具体如下。

#include <stdio.h>
int main()
{
    int n,m,i,j,k;
    //u、v和w的数组大小要根据实际情况来设置,要比m的最大值要大1
    int u[8],v[8],w[8];
    //first要比n的最大值要大1,next要比m的最大值大1
    int first[6],next[8];
    int dis[6]={0},book[6]={0};//book数组用来记录哪些顶点已经在队列中
    int que[101]={0},head=1,tail=1;	//定义一个队列,并初始化队列
    int inf=99999999;	//用inf(infinity的缩写)存储一个我们认为的正无穷值
     //读入n和m,n表示顶点个数,m表示边的条数
    scanf("%d %d",&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,表示1~n顶点暂时都没有边
    for(i=1;i<=n;i++) first[i]=-1;
    
    for(i=1;i<=m;i++)
    {
        //读入每一条边
        scanf("%d %d %d",&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]]+w[k])//判断是否松弛成功
            {
                dis[v[k]]=dis[u[k]]+w[k];//更新顶点1到顶点v[k]的路程
                //这里的book数组用来判断顶点v[k]是否在队列中
                //如果不使用一个数组来标记的话,判断一个顶点是否在队列中每次都需要从队列的head到tail扫一遍,很浪费时间
                if(book[v[k]]==0)//0表示不在队列中,将顶点v[k]加入队列中
                {
                    //下面两句是入队操作
                    que[tail]=v[k];
                    tail++;
                    book[v[k]]=1;//同时标记顶点v[k]已经入队
                }
            }
            k=next[k];
        }
        //出队
        book[que[head]]=0;
        head++;
    }
    
    //输出1号顶点到其余各个顶点的最短路径
    for(i=1;i<=n;i++)
        printf("%d ",dis[i]);
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。

5 7
1 2 2
1 5 10
2 3 3
2 5 7
3 4 4
4 5 5
5 3 6

运行结果是:

0 2 5 9 9 5 3 6

下面来总结一下。初始时将源点加入队列。每次从队首(head)取出一个顶点,并对与其相邻的所有顶点进行松弛尝试,若某个相邻的顶点松弛成功,且这个相邻的顶点不在队列中(不在 head 和 tail 之间),则将它加入到队列中。对当前顶点处理完毕后立即出队,并对下一个新队首进行如上操作,直到队列为空时算法结束。这里用了一个数组 book 来记录每个顶点是否处在队列中。其实也可以不要 book 数组,检查一个顶点是否在队列中,只需要把 que[head] 到 que[tail] 依次判断一遍就可以了,但是这样做的时间复杂度是 O(N),而使用 book 数组来记录的话时间复杂度会降至 O(1)。

使用队列优化的 Bellman-Ford 算法在形式上和广度优先搜索非常类似,不同的是在广度优先搜索的时候一个顶点出队后通常就不会再重新进入队列。而这里一个顶点很可能在出队列之后再次被放入队列,也就是当一个顶点的最短路程估计值变小后,需要对其所有出边进行松弛,但是如果这个顶点的最短路程估计值再次变小,仍需要对其所有出边再次进行松弛,这样才能保证相邻顶点的最短路程估计值同步更新。需要特别说明一下的是,使用队列优化的 Bellman-Ford 算法的时间复杂度在最坏情况下也是 O(NM)。通过队列优化的 Bellman-Ford 算法如何判断一个图是否有负环呢?如果某个点进入队列的次数超过 n 次,那么这个图则肯定存在负环。

用队列优化的 Bellman-Ford 算法的关键之处在于:只有那些在前一遍松弛中改变了最短路程估计值的顶点,才可能引起它们邻接点最短路程估计值发生改变。因此,用一个队列来存放被成功松弛的顶点,之后只对队列中的点进行处理,这就降低了算法的时间复杂度。

最短路径算法对比分析

FloydDijkstraBellman-Ford队列优化的Bellman-Ford
空间复杂度O(N²)O(M)O(M)O(M)
时间复杂度O(N³)O((M+N)logN)O(NM)最坏也是O(NM)
适用情况稠密图和顶点关系密切稠密图和顶点关系密切稠密图和边关系密切稠密图和边关系密切
负权可以解决负权不能解决负权可以解决负权可以解决负权
有负权边可以处理不能处理可以处理可以处理
判定是否存在负权回路不能不能可以判定可以判定

Floyd 算法虽然总体时间复杂度高,但是可以解决负权边,并且均摊到每一点对上,在所有的算法中还是属于较优的。另外,Floyd 算法较小的编码复杂度也是它的一大优势。所以,如果要求的是所有点对间的最短路径,或者如果数据范围较小,则 Floyd 算法比较适合。Dijkstra 算法最大的弊端是它无法适应有负权边的图。但是 Dijkstra 具有良好的可扩展性,扩展后可以适应很多问题。另外用堆优化的 Dijkstra 算法的时间复杂度可以达到 O(MlogN)。当边有负权时,需要使用 Bellman-Ford 算法或者队列优化的 Bellman-Ford 算法。因此我们选择最短路径算法时,要根据实际需求和每一种算法的特性,选择适合的算法。

参考:《啊哈!算法》

举报

相关推荐

0 条评论