上两篇博客介绍了计算单源最短路径的Bellman-Ford算法和Dijkstra算法。Bellman-Ford算法适用于任何有向图,即使图中包含负环路,它还能报告此问题。Dijkstra算法运行速度比Bellman-Ford算法要快,但是其要求图中不能包含负权重的边。
单源最短路径之Bellman-Ford算法
单源最短路径之Dijkstra算法
在很多实际问题中,我们需要计算图中所有结点对间的最短路径。当然,我们可以使用上述两种算法来计算每一个顶点的单源最短路径,对于图G=(V,E)来说,使用Bellman-Ford算法计算结点对最短路径的时间复杂度为O(V^2 * E),使用Dijkstra算法计算结点对最短路径的时间复杂度为O(V^3)。本文将会介绍一种应用更广泛的算法,而且它可以应用于有负权重边但没有负环路的图中,其时间复杂度为O(V^3),那就是Floyd-Warshall算法。
在上一篇博客 单源最短路径之Dijkstra算法 中,提到了图的一个重要性质:一条最短路径的子路径也是一条最短路径。因此,一条最短路径要么只包含一条直接相连的边,要么就经过一条或多条到达其它顶点的最短路径。
上图给出的是顶点i到顶点j的路径示意图。i到j的路径为,其中顶点k是路径i到j的一个编号最大的中间顶点,即路径中的所有顶点编号求取自集合{1,2,3,...,k-1},路径
于是,我们可以推出如下递归公式。
dij(k) = wij 当k=0;
dij(k) = min(dij(k-1), dik(k-1)+dkj(k-1)) 当k>0;
上述公式中dij为顶点i到顶点j的当前路径的长度,k是当前递归中路径的最大顶点编号。当k=0时,路径的中间顶点的编号不大于0,即不存在任何中间顶点,这种情况顶点i到顶点j的路径必然只是一条连接这两个顶点的边,因此其长度为该边的权重。当k>0,每次递归时加入编号为k的顶点,可以根据其它"当前最短路径"构造顶点i到顶点j的一条新路径,并与其原路径进行比较,从中选择更短的。这是一种自底向上的动态规划算法。
本文实现的Floyd算法所需要的输入与前面的博客介绍的不一样。前面介绍的所有图算法需要的图都是用邻接表表示的。下面给出的Floyd算法需要的图使用邻接矩阵表示的,即权重图。该实现使用前驱子图(二维矩阵)来记录结点对的最短路径的目的顶点的前驱顶点编号(前一个顶点的编号)。
/**
* Floyd 寻找结点对的最短路径算法
* w 权重图
* vertexNum 顶点个数
* lenMatrix 计算结果的最短路径长度存储矩阵(二维)
* priorMatrix 前驱子图(二维),路径重点j的前一个顶点k存储在priorMatrix[i][j]中
*/
void Floyd_WallShall(int **w, int vertexNum, int **lenMatrix, int **priorMatrix)
{
// 初始化
for (int i = 0; i < vertexNum; i++)
{
for (int j = 0; j < vertexNum; j++)
{
*((int*)lenMatrix + i*vertexNum + j) = *((int*)w + i*vertexNum + j);
if (*((int*)w + i*vertexNum + j) != INF && i != j)
{
*((int*)priorMatrix + i*vertexNum + j) = i;
}
else
{
*((int*)priorMatrix + i*vertexNum + j) = -1;
}
}
}
// Floyd算法
for (int k = 0; k < vertexNum; k++)
{
for (int i = 0; i < vertexNum; i++)
{
for (int j = 0; j < vertexNum; j++)
{
int Dij = *((int*)lenMatrix + i*vertexNum + j);
int Dik = *((int*)lenMatrix + i*vertexNum + k);
int Dkj = *((int*)lenMatrix + k*vertexNum + j);
if (Dik != INF && Dkj != INF && Dij > Dik + Dkj)
{
*((int*)lenMatrix + i*vertexNum + j) = Dik + Dkj;
*((int*)priorMatrix + i*vertexNum + j) = *((int*)priorMatrix + k*vertexNum + j);
}
}
}
}
}
上述程序需要输入一个邻接矩阵,顶点的个数,以及用于存储结果路径长度的矩阵和前驱子图矩阵。这些矩阵本质上均是一个二维数组。该算法首先对长度矩阵和前驱子图进行初始化,也就是递推公式当k=0时的操作,然后就进入循环反复更新结点对的路径。算法没计算一次所有结点对的路径,需要进行V^2次运算,而算法需要从小到大依次将V个顶点加入到图中进行运算,于是整个算法的时间复杂度为O(V^3)。
这里简单说一下前驱子图priorMatrix。我们可以通过前驱子图找到任意结点对的最短路径。例如我们要找到顶点i到顶点j的一条最短路径,可以先找到k=priorMatrix[i][j],此时就知道路径为,然后我们再找到路径的前驱顶点,即priorMatrix[i][k],如此类推。这一操作的正确性由上面提到的性质(一条最短路径的子路径也是一条最短路径)保证。
下面给出一个应用上述算法的例子。
int w[5][5] = { 0, 3, 8, INF, -4,
INF, 0, INF, 1, 7,
INF, 4, 0, INF, INF,
2, INF, -5, 0, INF,
INF, INF, INF, 6, 0};
int lenMatrix[5][5];
int priorMatrix[5][5];
Floyd_WallShall((int**)w, 5, (int**)lenMatrix, (int**)priorMatrix);
for (int i = 0; i < 5; i++)
{
for (int j = 0; j < 5; j++)
{
if (lenMatrix[i][j] == INF)
{
printf("从%d到%d\t\t长度:INF\n", i, j);
}
else
{
printf("从%d到%d\t\t长度:%d\t\t路径:", i, j, lenMatrix[i][j]);
printIJPath((int**)priorMatrix, 5, i + 1, j + 1);
}
}
}
printIJPath方法的定义如下。
/**
* 根据前驱子图打印i到j的路径,输入顶点编号从1开始,输出顶点编号从1开始
*/
void printIJPath(int **prior, int vertexNum, int i, int j)
{
i--; j--;
printf("%d", j + 1);
int k = *((int*)prior + i*vertexNum + j);
while (k != -1)
{
printf(" <- %d", k + 1);
k = *((int*)prior + i*vertexNum + k);
}
printf("\n");
}
上述例程构造的图以及运行结果如下图所示。前驱子图总priorMatrix[i][i]=-1。
0 | 1 | -3 | 2 | -4 |
3 | 0 | -4 | 1 | -1 |
7 | 4 | 0 | 5 | 3 |
2 | -1 | -5 | 0 | -2 |
8 | 5 | 1 | 6 | 0 |
-1 | 2 | 3 | 4 | 0 |
3 | -1 | 3 | 1 | 0 |
3 | 2 | -1 | 1 | 0 |
3 | 2 | 3 | -1 | 0 |
3 | 2 | 3 | 4 | - |
Floyd算法的时间复杂度为O(V^3),因为其实现代码很紧凑,所以时间复杂度的常数项很小。Floyd算法是一种应用非常广泛的计算结点对最短路径的算法。其实还有一种结合了Bellman-Ford算法和Dijkstra算法的Johnson算法,该算法在用于稀疏图时运行速度比Floyd算法更快,并且能够报告图中存在负环路的情况(得益于Bellman-Ford算法)。Johnson算法的时间复杂度为Bellman-Ford算法的时间复杂度加上Dijkstra算法的时间复杂度。如果使用二叉堆实现Dijkstra算法的最小优先队列,那么Johnson算法时间复杂度为O(VElgV+VE)=O(VElgV)。Johnson算法的具体介绍可以参考其它资料,下面给出的个github项目中也有具体的C实现代码。
完整的程序可以看到我的github项目 数据结构与算法
这个项目里面有本博客介绍过的和没有介绍的以及将要介绍的《算法导论》中部分主要的数据结构和算法的C实现,有兴趣的可以fork或者star一下哦~ 由于本人还在研究《算法导论》,所以这个项目还会持续更新哦~ 大家一起好好学习~