第六章 最短路径

Floyd-Warshall

多源最短路径问题:求任意两点之间的最短路径。
可以通过深度和广度优先搜索求出两点之间的最短路径:进行n^2遍深度或广度优先搜索,即对每两个点都进行一次深度或广度优先搜索,便可以求得任意两点之间的最短路径。
如果要让任意两点(例如a->b)之间的路程变短,只能引入第三个点(例如k),并通过这个顶点k中转即a->k->b,才可能缩短原来从a到b的路程。
甚至有时候不只通过一个点,而是经过两个点或者更多点中转会更短。
每个顶点都有可能使得另外两个顶点之间的路程变短。
当任意两点之间不允许经过第三个点时,这些点之间的最短路程就是初始路程。
假如现在只允许经过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的路程之和。
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和2两个顶点的情况下任意两点之间的最短路程。只需在只允许经过1时任意两点的最短路径的结果下,再判断如果经过2是否可以使得i到j之间的路程变得更短,即判断e[i][2]+e[2][j]是否比e[i][j]更短?
//经过1
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];

//经过2
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进行中转,接下来只允许经过1和2进行中转……允许经过1~n号所有顶点进行中转,求任意两点之间的最短路程。这是一种“动态规划”思想,从i到j只经过前k号点的最短路程。
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];
示例
#include
#include
#include
#include

using namespace std;

int main(void)
{
    int v = 4;  //顶点数
    int e = 8;  //边数

    vector<vector<int>> graph(v, vector<int>(v, INT_MAX));
    for (int i = 0; i < v; ++i)
        graph[i][i] = 0;
    graph[0][1] = 2;
    graph[0][2] = 6;
    graph[0][3] = 4;
    graph[1][2] = 3;
    graph[2][0] = 7;
    graph[2][3] = 1;
    graph[3][0] = 5;
    graph[3][2] = 12;
    /*
    0  2  6  4
    ∞ 0  3  ∞
    7  ∞ 0  1
    5  ∞ 12 0
    */
    //1~k作为中转点
    for (int k = 0; k < v; ++k)
    {
        for (int i = 0; i < v; ++i)
        {
            for (int j = 0; j < v; ++j)
            {
                if (graph[i][k] < INT_MAX && graph[k][j] < INT_MAX
                    && graph[i][j] > graph[i][k] + graph[k][j])  //防止相加溢出
                    graph[i][j] = graph[i][k] + graph[k][j];
            }
        }
    }

    for (const auto & line : graph)
    {
        for (const auto & point : line)
            cout << setw(3) << point;
        cout << endl;
    }

    return 0;
}
通过Floyd-Warshall算法可以求出任意两个点之间的最短路径,时间复杂度为O(N^3)。由于Floyd-Warshall算法实现起来非常容易,所以如果时间复杂度要求不高,使用Floyd-Warshall来指定两点之间的最短路径或者指定一个点到其余各个顶点的最短路径也是可行的。
Floyd-Warshall算法不能解决带有“负权回路”(负权环)的图,因为带有“负权回路”的图没有最短路径。

如果一个图中带有“负权回路”,那么这个图则没有最短路径。

Floyd-Warshall算法由Robert W.Floyd于1962年发表在Communications of the ACM上。同年Stephen Warshall也独立发表了这个算法。Robert W.Floyd还和J.W.J.Williams于1964年共同发明了著名的堆排序算法,他在1978年获得了图灵奖。

Dijkstra算法——通过边松弛实现

单源最短路径:求一个点(源点)到其余各个点的最短路径。
Dijkstra算法主要思想:通过“边”松弛源点到其余各个顶点的路程。
每次找到离源点最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径。
  1. 将所有的顶点分为两部分:已知最短路程的顶点集合P和未知最短路径的顶点集合Q。最开始,已知最短路径的顶点集合P中只有源点一个顶点。
  2. 设置源点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数组中的值就是源点到所有顶点的最短路径。
#include
#include
#include

using namespace std;

int main(void)
{
    int v = 6;  //顶点数
    int e = 9;  //边数

    vector<vector<int>> graph(v, vector<int>(v, INT_MAX));
    vector<bool> flags(v, false);
    for (int i = 0; i < v; ++i)
        graph[i][i] = 0;
    graph[0][1] = 1;
    graph[0][2] = 12;
    graph[1][2] = 9;
    graph[1][3] = 3;
    graph[2][4] = 5;
    graph[3][2] = 4;
    graph[3][4] = 13;
    graph[3][5] = 15;
    graph[4][5] = 4;
    /*
    0  2  6  4
    ∞ 0  3  ∞
    7  ∞ 0  1
    5  ∞ 12 0
    */

    vector<int> dis(v);
    //以0为源点,初始化
    for (int i = 0; i < v; ++i)
        dis[i] = graph[0][i];
    flags[0] = true;

    //Dijkstra
    for (int i = 1; i < v; ++i)  //只需v-1次循环
    {
        //找到目前离源点最近的未标记的点
        int min_dis = INT_MAX;  //记录最小
        int v_index;  //最近是哪个点
        for (int j = 0; j < v; ++j)
        {
            if (flags[j] == false && dis[j] < min_dis)
            {
                min_dis = dis[j];
                v_index = j;
            }
        }
        flags[v_index] = true;  //标记这个最近的点为确定

        //以找到的点所关联的边来进行松弛操作
        for (int j = 0; j < graph[v_index].size(); ++j)
        {
            if (graph[v_index][j] < INT_MAX)
            {
                //这条边是存在的
                if (dis[j] > dis[v_index] + graph[v_index][j])
                    dis[j] = dis[v_index] + graph[v_index][j];
            }
        }
    }

    //单源最短路径结果
    for (const auto & i : dis)
        cout << i << ' ';
    cout << endl;

    return 0;
}
上述实现的算法时间复杂度为O(N^2),每次找到离源点最近的点的时间复杂度为O(N),可以用堆进行优化,使得O(N)降低为O(logN)
边数M远少于N^2的图称为稀疏图,而相对较大的图称为稠密图,对于稀疏图可以用邻接表来代替邻接矩阵,使得整个时间复杂度优化到O((M+N)logN)

在最坏情况下M就是N^2,这样(M+N)logN比N^2还大,但是大多数情况下并不会有那么多边,所以通常(M+N)logN要比N^2小很多。

求最短路径的Dijkstra算法是一种基于贪心策略的算法。每次新扩展一个路程最短的点,更新与其相邻的点的路程。当所有边权都为正时,由于不会存在一个路程更短的没扩展过的点,所以这个点的路程永远不会再被改变,因而保证了算法的正确性。不过根据这个原理,用Dijkstra算法求最短路径的图是不能有负权边的,因为扩展到负权边的时候会产生更短的路程,有可能破坏了已经更新的点路程不会改变的性质。
Dijkstra算法由荷兰计算机科学家Edsger Wybe Dijkstra于1959年提出,发表在Numerische Mathematik的创刊号上,但1956年他就发现了这个算法。

Bellman-Ford——解决负权边

Dijkstra算法虽然好,但是它不能解决带有负权边的图。
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];
上面代码中,外循环一共循环了n-1次(n为顶点的个数),内循环循环了m次(m为边的个数),即枚举每一条边。
dis数组的作用与Dijkstra算法一样,用来记录源点到其余各个顶点的最短路径。
u、v和w三个数组用来记录边的信息,对于第i条边,从顶点u[i]到顶点v[i]这条边的权值为w[i]。
if语句的意思是,看能够通过u[i]->v[i](权值w[i])这条边,使得源点到v[i]的距离变短。即源点到u[i]的距离(dis[u[i]])加上u[i]->v[i]这条边的值w[i]是否会比原来源点到v[i]的距离(dis[v[i]])要小。这一点跟Dijkstra算法的松弛操作是一样的。
第一轮在对所有的边进行松弛之后,得到的是从源点“只能经过一条边”到达其余各顶点的最短路径长度。第二轮在对所有的边进行松弛之后,得到的是从源点“最多经过两条边”到达其余各顶点的最短路径长度。如果进行k轮的话,得到的就是源点“最多经过k条边”到达其余各顶点的最短路径长度。

那需要进行多少轮?

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

n-1?最短路径中不可能包含回路吗?

不可能!最短路径肯定是一个不包含回路的简单路径。回路分为正权回路(回路权值之和为正)和负权回路(回路权值之和为负)。
如果最短路径中包含正权回路,那么去掉这个回路,一定可以得到更短的路径。
如果最短路径中包含负权回路,那么肯定没有最短路径,因为每多走一次负权回路就可以得到更短的路劲。
因此,最短路径肯定是一个不包含回路的简单路径,即最多包含n-1条边,所以进行n-1此松弛就可以了。
因为最短路径上“最多”有n-1条边,因此Bellman-Ford算法“最多”有n-1个阶段。(可能n-1个阶段之前就已经稳定了)
在每一个阶段,对每一条边都要执行松弛操作。每实施一次松弛操作,就会有一些顶点已经求得最短路径,即这些顶点的最短路径的“估计值”变为“确定值”。此后这些顶点的最短路径的值就会一直保持不变,不再受后续松弛操作的影响(由于后面每个阶段还是会判断是否需要松弛,所以要进行优化)。在前k个阶段结束后,就已经找出了从源点发出“最多经过k条边”到达各个顶点的最短路径。直到进行完n-1个阶段后,便得出了最多经过n-1条边的最短路径。
#include
#include
#include

using namespace std;

struct Edge
{
    int from;    //边起点
    int to;      //边终点
    int weight;  //边权重
};

int main(void)
{
    int v = 5;  //顶点数
    int e = 5;  //边数

    const int INT_INF = INT_MAX - 10000;  //防止溢出的最大值

    vector<int> dis(v);
    //以0为源点,初始化
    for (int i = 0; i < v; ++i)
        dis[i] = INT_INF;
    dis[0] = 0;

    vector edges = {
        {1, 2, 2},
        {0, 1, -3},
        {0, 4, 5},
        {3, 4, 2},
        {2, 3, 3}
    };

    //Bellman-Ford
    for (int turn = 0; turn < v - 1; ++turn)
    {
        vector<int> detect = dis;  //备份

        for (const auto & edge : edges)
        {
            if (dis[edge.to] > dis[edge.from] + edge.weight)
                dis[edge.to] = dis[edge.from] + edge.weight;
        }

        if (detect == dis)  //如果dis数组没有更新,提前退出循环结束算法
            break;
    }

    for (const auto & point : dis)
        cout << point << ' ';
    cout << endl;

    //检测负权回路
    bool isloop = false;
    for (const auto & edge : edges)
        if (dis[edge.to] > dis[edge.from] + edge.weight)
            isloop = true;
    cout << (isloop ? "存在" : "不存在") << "回路" << endl;

    return 0;
}
Bellman-Ford算法可以检测一个图是否含有负权回路。如果在进行n-1轮松弛之后,仍然可以继续成功松弛,那么此图必然存在负权回路。(如果一个图没有负权回路,那么最短路径所包含的边最多为n-1条,即进行n-1轮松弛之后最短路径不会再发生变化。如果在n-1轮松弛之后,最短路径仍然会发生变化,则该图必然存在负权回路。)
Bellman-Ford算法的时间复杂度是O(NM)(比Dijkstra算法还高?),可以继续优化。在实际操作中,Bellman-Ford算法经常会在未达到n-1轮松弛前就已经计算出最短路径(n-1只是最大值)。因此可以添加一个数组来备份dis,如果在新一轮的松弛中数组dis没有发生变化,则可以提前跳出循环。(优化的前提基于整个数组没有变化)
另一种优化:在每实施一次松弛操作后,就会有一些顶点已经求得其最短路径,此后这些顶点的最短路径的估计值就会一直保持不变,不再受后续松弛操作的影响,但是每次还要判断是否需要松弛,这里浪费了时间。这就启发:每次仅对最短路径估计值发生变化了的顶点的所有出边执行松弛操作。
美国应用数学家Richard Bellman于1958年发表了该算法。此外Lester Ford,Jr.在1956年也发表了该算法。其实Edward F.Moore(提出广度优先搜索算法)在1957年也发表了同样的算法,所以这个算法也成为Bellman-Ford-Moore算法。

Bellman-Ford的队列优化

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

using namespace std;

struct Edge
{
    int from;    //边起点
    int to;      //边终点
    int weight;  //边权重
};

int main(void)
{
    int v = 5;  //顶点数
    int e = 7;  //边数

    const int INT_INF = INT_MAX - 10000;  //防止溢出的最大值

    vector<int> dis(v);
    queue<int> vex_opt;  //bellman-ford优化队列
    vector<bool> flags(v, false);  //标记点是否在队列中

    vector<vector> graph(v);  //list和forward_list也行
    graph[0].push_back({ 0, 1, 2 });
    graph[0].push_back({ 0, 4, 10 });
    graph[1].push_back({ 1, 2, 3 });
    graph[1].push_back({ 1, 4, 7 });
    graph[2].push_back({ 2, 3, 4 });
    graph[3].push_back({ 3, 4, 5 });
    graph[4].push_back({ 4, 2, 6 });

    //以0为源点,初始化
    for (int i = 0; i < v; ++i)
        dis[i] = INT_INF;
    dis[0] = 0;
    flags[0] = true;
    vex_opt.push(0);

    //判断是否存在负权回路
    vector<int> loop(v, 0);
    ++loop[0];

    while (!vex_opt.empty() && find(loop.begin(), loop.end(), v) == loop.end())
    //队列不为空和没检测出负权回路的时候循环
    {
        //扫描当前顶点的所有边
        for (const auto & edge : graph[vex_opt.front()])
        {
            //判断是否松弛成功
            if (dis[edge.to] > dis[edge.from] + edge.weight)
            {
                dis[edge.to] = dis[edge.from] + edge.weight;
                //判断顶点是否在队列中
                if (flags[edge.to] == false)
                {
                    //入队
                    flags[edge.to] = true;
                    vex_opt.push(edge.to);
                    ++loop[edge.to];
                }
            }
        }
        //出队
        flags[vex_opt.front()] = false;
        vex_opt.pop();
    }

    if (vex_opt.empty())
    {
        for (const auto & point : dis)
            cout << point << ' ';
        cout << endl;
        cout << "不存在负权回路" << endl;
    }
    else
    {
        cout << "存在负权回路" << endl;
    }

    return 0;
}
初始时将源点加入队列。每次从队首取出一个顶点,并对与其相邻的所有顶点进行松弛尝试,若某个相邻的顶点松弛成功,且这个相邻的顶点不在队列中,则将它加入到队列中。对当前顶点处理完毕后出队,并对下一个新队首进行如上操作,直到队列为空时算法结束。
使用队列优化的Bellman-Ford算法在形式上和广度优先搜索非常类似,不同的是在广度优先搜索的时候一个顶点出队后通常就不会再重新进入队列。而这里一个顶点很可能在出队列之后再次被放入队列,也就是当一个顶点的最短路程估计值变小后,需要对其所有出边进行松弛,但是如果这个顶点的最短路程再次变小,仍需要对其所有出边再次进行松弛,这样才能保证相邻顶点的最短路程估计值同步更新。
使用队列优化的Bellman-Ford算法的时间复杂度在最坏情况下也是O(MN)

通过队列优化的Bellman-Ford算法如何判断一个图是否有负环呢?

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

最短路径算法对比分析

Floyd Dijkstra Bellman-Ford 队列优化Bellman-Ford
空间复杂度 O(N^2) O(M) O(M)
时间复杂度 O(N^3) O((M+N)logN) O(MN)
适用情况 稠密图,和顶点关系密切 稠密图,和顶点关系密切 稀疏图,和边关系密切
负权 可以解决负权 不能解决负权 可以解决负权
Floyd算法虽然总体时间复杂度高,但是可以解决负权边,并且均摊到每一点对上,在所有的算法中还是属于较优的,另外,较小的编码复杂度也是一大优势。所以,如果要求的是所有点对间的最短路径,或者如果数据范围较小,则Floyd算法比较适合。
Dijkstra算法最大的弊端是它无法适应有负权边的图,但是Dijkstra具有良好的可扩展性,扩展后可以适应很多问题。另外用堆优化的Dijkstra算法的时间复杂度可以达到O((M+N)logN)。
当边有负权时,需要使用Bellman-Ford算法或者队列优化的Bellman-Ford算法。

你可能感兴趣的:(C++,算法与数据结构)