前三节,我们讲了三个比较复杂的最短路算法,分别是迪杰斯特拉,bellman-ford和SPFA。dij适合求非负权无向图或有向图最短路径,而后两者适用于有负权边的有向图。
这一节再介绍一个叫做Floyd算法。这个弗洛伊德可不是奥地利那个心理学家哦,只是刚好重名而已。相比前三个算法,它非常简洁,思想是简单的动态规划,原理也异常易懂。它是一个多源最短路径算法,运行一次,就可以求出任意两点之间的最短路径。时间复杂度是 O ( V 3 ) O(V^3) O(V3)。另外,它还能检测负权环的存在。可以说,如果按照平均性能来说,它是最优的。
如果是单源最短路径,用Floyd有点浪费。如果是多源最短路径,它又优于跑V次迪杰斯特拉(V次就是 O ( V E l o g E ) O(VElogE) O(VElogE))。所以,我们要根据具体的题目去选取合适的算法。
首先,我们知道一个定理:一个有向图中,任意两点间存在最短路径的充要条件就是,图中没有负权环。证明从略。
然后,我们假设一个图中没有负权环。那么,又有另外一个定理:如果最短路径存在,那么它一定是简单路径(简单路径就是经过每一条边最多一次),而且是基本路径(基本路径就是经过每一个顶点最多一次)。因为,如果经过某条边或某个顶点多次,则表明已经走过一个环路,这个环路权值之和一定为正,所以,把它去掉之后才可能成为最短路径。
因此,我们可以这样来定义最短路径。
假设 d ( i , j , k ) d(i, j, k) d(i,j,k)表示从顶点 i i i到顶点 j j j,并且只经过 { 1 , 2 , . . . , k } \{1, 2, ..., k\} {1,2,...,k}中的某些顶点(也就是说,不经过顶点 k + 1 , k + 2 , . . . , V k + 1, k + 2, ..., V k+1,k+2,...,V中的任何一个)的最短路径长度。当 k = 0 k=0 k=0时,表示从 i i i出发直达 j j j的路径长度。
如果这样的最短路径 d ( i , j , k ) d(i, j, k) d(i,j,k)不经过顶点 k k k,则 d ( i , j , k ) = d ( i , j , k − 1 ) d(i, j, k) = d(i, j, k-1) d(i,j,k)=d(i,j,k−1)。否则,这条路径将被顶点 k k k分成两段,分别是 d ( i , k , k − 1 ) d(i, k, k-1) d(i,k,k−1)和 d ( k , j , k − 1 ) d(k, j, k-1) d(k,j,k−1)。所以,我们可以得到下列递推方程:
d ( i , j , 0 ) = w ( i , j ) d(i, j, 0) = w(i, j) d(i,j,0)=w(i,j)
d ( i , j , k ) = m i n { d ( i , j , k − 1 ) , d ( i , k , k − 1 ) + d ( k , j , k − 1 ) } d(i, j, k) = min\{d(i, j, k-1), d(i, k, k-1) + d(k, j, k-1)\} d(i,j,k)=min{d(i,j,k−1),d(i,k,k−1)+d(k,j,k−1)}
所以很明显,弗洛伊德算法适合用邻接矩阵存储图,而邻接表则不合适。
另外,还有一个重要的问题就是判断是否存在负权环。我们知道,在递推的时候,是不断往小更新邻接矩阵的。如果存在负权环,则沿着某条路径,将使得一个顶点从它自己出发回到它自己,距离减小为负数。因此,只要某一次递推,发现 d ( i , i , k ) < 0 d(i, i, k)<0 d(i,i,k)<0,则立刻得知存在负环。
刚才经过推导,我们已经得到了递推方程。这明显是一个三维动态规划问题。那么,我们是否需要三维数组呢?当然不是。观察一下,递推方程是沿着 k k k递减的,而且,初始状态 k = 0 k=0 k=0时,距离矩阵刚好就是邻接矩阵。所以,我们可以不断覆盖原来的矩阵,把空间压缩到二维。
这个算法写起来非常简单,直接上代码:
输入:第一行两个整数 v , e v, e v,e,分别为顶点数、边数; v < = 100 , e < = 10000 v<=100,e<=10000 v<=100,e<=10000。接下来 e e e行,每行三个整数 i , j , w i, j, w i,j,w,分别为这条边的起点,终点以及权值。
输出:v行,每行用空格隔开的v个整数,第i行第j个整数表示从i出发到j的最短距离。如果i到j不可达,输出0x3f3f3f3f
对应的十进制数。数据保证任何最短路径长度小于这个数。
如果存在负权环,输出一行“has negative loop!”(不含引号)。
#include
#include
#include
using namespace std;
int graph[101][101];
int floyd(int v) // 当有负权边时返回1,否则返回0
{
int i, j, k;
for (k = 1; k <= v; k++)
{
for (i = 1; i <= v; i++)
{
if (i == k) continue;
for (j = 1; j <= v; j++)
{
if (j == k) continue; // 思考这两个continue是为什么,能否去掉?
if (graph[i][j] > graph[i][k] + graph[k][j])
{
graph[i][j] = graph[i][k] + graph[k][j];
}
}
if (graph[i][i] < 0) return 1; // 判断负权环
}
}
return 0;
}
int main()
{
memset(graph[0], 0x3f, sizeof(graph));
// 这个初始化是有讲究的
// 首先要保证,graph的每个元素都是一个很大的数
// 其次要保证任意两个元素相加不会超int范围
// 再加上memset是按字节的,所以这个数不能大于0x3f
int v, e, w, i, j;
scanf("%d %d", &v, &e);
for (i = 1; i <= v; i++) graph[i][i] = 0;
while (e--)
{
scanf("%d %d %d", &i, &j, &w);
if (w < graph[i][j]) graph[i][j] = w; // 这是去掉重边和自环的方法
// 当然,如果自环是负权环,则依然更新上来,这样算法在就能找到这个负权环了
// 如果是无向图,则应该在这里把graph[j][i]也更新了
}
if (floyd(v)) printf("has negative loop!");
else for (i = 1; i <= v; i++)
{
for (j = 1; j <= v; j++)
{
printf("%d ", graph[i][j]);
}
printf("\n");
}
return 0;
}
代码中有两行continue,思考出为什么了吗?
答案在这里:因为,根据递推方程,被顶点 k k k分开的两条路径,分别是 d ( i , k , k − 1 ) d(i, k, k-1) d(i,k,k−1)和 d ( k , j , k − 1 ) d(k, j, k-1) d(k,j,k−1)。在最短路径存在的情况下, d ( i , i , k ) = 0 d(i, i, k)=0 d(i,i,k)=0恒成立。如果 i = = k i==k i==k,则递推方程变为 d ( k , j , k ) = m i n { d ( k , j , k − 1 ) , d ( k , k , k − 1 ) + d ( k , j , k − 1 ) } d(k, j, k)=min\{d(k, j, k-1), d(k, k, k-1) + d(k, j, k-1)\} d(k,j,k)=min{d(k,j,k−1),d(k,k,k−1)+d(k,j,k−1)},化简得到 d ( k , j , k ) = d ( k , j , k − 1 ) d(k, j, k)=d(k, j, k-1) d(k,j,k)=d(k,j,k−1),也就是说graph[k][j]
未发生任何改动。同理, j = = k j==k j==k也是类似情况。我们用continue,可以跳过不必要的递推。
因此,这两个continue是可以去掉的,不影响算法的正确性,但因为多些递推,会对时间效率有一点影响。
时间复杂度显然是 O ( V 3 ) O(V^3) O(V3)
空间复杂度显然是 O ( V 2 ) O(V^2) O(V2)
如果用于求单源最短路径,其实有点浪费。而且,弗洛伊德算法在稠密图上的表现更好,因为和边数无关。
OK,弗洛伊德算法很简单,今天就讲到这里。至此,最短路径的四种算法就全部讲完了。