《啊哈算法》学习笔记(三)——最短路径

本文深入解析了Floyd-Warshall的多源最短路径算法,对比了Dijkstra的单源最短路径和Bellman-Ford的负权边处理。通过实例展示了算法原理、优化策略和适用场景,以及稠密图与稀疏图的选择原则。
摘要由CSDN通过智能技术生成

最短路径问题

这是整本书学得我最痛苦的地方…

而最后一章“其他算法”中有些代码已经看不明白了,也先搁置,以后系统学习数据结构和算法时再涉及。下面一起来看看几个精彩的最短路径算法。

Floyd-Warshall——多源最短路径

问题如下:

根据题意要求任意两个城市之间的最短路径,我们称这类问题为“多源最短路径问题”。

解决这类问题的弗洛伊德算法在初看时给我留下了深刻的印象,因为它的核心代码仅仅四行。

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

我们关注最里面的判断条件,翻译一下就是:是否i到z再到j,会比i直接到j的距离更近,如果更近,就更新i到j的距离。

也就是不断的在两点之间插入另一个点,然后判断是否更新最短路径。在z的循环视角下,最后将所有额外的点都试着插入各个两点之间,使得所有点与点之间的路径都经过尝试与判断,于是求得了所有的最短路径。

来看完整代码:

#include <stdio.h>

int main()
{
    int n, m;
    int e[20][20] = {0};
    int book[20] = {0};
    int min = 999;
    int a, b, c;
    int i, j, z;
    int start, end;
    printf("请输入图的结点数和边数:\n");
    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] = 999;
        }
    printf("请输入起始点和终点:\n");
    scanf("%d %d", &start, &end);

    printf("请输入图中相邻的结点和两者的距离:\n");
    for (i = 1; i <= m; i++)
    {
        scanf("%d %d %d", &a, &b, &c);
        e[a][b] = c;
    }

    for (z = 1; z <= n; z++) //插入中转结点
        for (i = 1; i <= n; i++)
            for (j = 1; j <= n; j++)
                if (e[i][j] > e[i][z] + e[z][j]) //如果i~z~j的距离比直接的i~j短,则更新i~j
                    e[i][j] = e[i][z] + e[z][j];
    //这里e[i][z]+e[z][j],因为e的值本来是要设成正无穷来说明两结点之间没有边的
    //严谨的想,这里可能出现设的两个正无穷,可能是999999之类的,相加可能超出int范围,所以我们可以在判断条件上再
    //加两条, e[i][z]<inf && e[z][j]<inf 保证i~z和z~j都是有边的,就不用担心超出范围的情况了。
    printf("最短路径是%d", e[start][end]);
    
    return 0;
}

虽然弗洛伊德算法的思路很巧妙,很好理解,实现也很简单,但明显它的时间复杂度高的吓人,高达O(N^3)。

需要注意的是,它是能解决“负边权”情况的。可以理解为结点与结点之间的距离为负。但不能存在负权回路——后续会提到,负权回路情况是求不出最短路径的。

Dijkstra(迪杰斯特拉)——单源最短路径

这个算法的名字听起来就很霸气,它是用来解决单源最短路径问题的。即求图中的一个点到其他各点的最短路径。

#include <stdio.h>

int main()
{
    int e[10][10], book[10] = {0};
    int dis[10] = {0};
    int n, m;
    int a, b, c;
    int i, j;
    int inf = 999999; // infinity的缩写,无穷的意思

    printf("请输入图的结点数和边数:\n");
    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;
        }

    printf("请输入有边的两结点和其间的距离:\n");
    for (i = 1; i <= m; i++)
    {
        scanf("%d %d %d", &a, &b, &c);
        e[a][b] = c;
    }

    // dis数组初始化
    for (i = 1; i <= n; i++)
        dis[i] = e[1][i]; //注意,从此以后,1~其他结点的距离只由dis实时更新,而e[1][i]不准确,因为1~i可能本没有边
                          //中间因为经过了一些结点中转,才有路可言,所以只需更新dis
    book[1] = 1;
    int min, u, v;
    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) //判断如果u与v之间有路,再考虑后面的
            {
                if (dis[u] + e[u][v] < dis[v]) //由刚刚确定下来的结点往不确定的地方延伸。
                {
                    dis[v] = dis[u] + e[u][v];
                }
            }
        }
    }
    for (i = 1; i <= n; i++)
    {
        printf("%d ", dis[i]);
    }

    return 0;
}

简单分析一下:相比于上一篇博客中提到的图的遍历,Dijkstra算法多了一个dis数组,用来存储特定结点到其他各个结点的距离,初始除了自己到自己,都设成无穷大inf(假设)。待后续一系列“松弛”操作更新所有距离后,可进行打印。

在我看来,Dijkstra其实有点bfs的影子,但它是有选择性的,因为最终目的是最短路径,所以它每一次松弛操作的对象都是离确定点最近的那个结点。

另外,第一个for循环找到离特点结点最近的结点后,直接将它确定(book[u]=1),我们可以发现,Dijkstra的一个局限性——不能处理有负权的情况。因为如果出现负权,book[u]=1的操作就是有误的,它的距离值不能被确定,因为后续遍历会出现源点经过负权边后到达结点的过程可松弛,导致不一定源点直达u结点的距离最短。

  • 简单介绍完这个特性后,书中还介绍了用邻接表来存储图信息:
int n, m, i;
int u[6], v[6], w[6];
int first[6], next[6];//first的角标代表结点,存储的是边的序号
                        //next的角标是边的编号,与first链接
scanf("%d %d", &n, &m);
for (i = 0; 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;
}

​ 这一段代码并不好懂,简单来说就是给每一个结点都准备了一个链表,链表里存储着以这个结点为起点的所有边。使用邻接表存储图的信息,当我们要访问一个结点的所有边,跟邻接矩阵相比,就变成了一个线性的过程**,时间复杂度从O(N^2)变成了O(M)**,空间复杂度也变小不少。

  • 另外,循环中每次找到离特殊结点最近的结点u,我们都是循环n次,不难发现,如果已经有一部分结点被确定,那么遍历中就会存在越来越多的无效操作。这也是可以优化的。即运用到堆结构。这一点在书的最后一章有提到,不过较为复杂,仍是暂时搁置。

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];      

观察它的判断条件,我们很容易联想到Dijkstra的“松弛”操作,确实如此,在我看来它有点像Dijkstra和Floyd的结合,因为外层循环就是在两两节点间遍历式地添加一个点。而k的值,我们可以理解为,从特定结点只能经过k条边,到达其余各顶点。而为什么只用循环n-1次呢,稍微想一想可以得出,总共n个结点,起始结点最多经过n-1条边就能到达任意结点。

概括一下流程只有1句话:对所有的边进行n-1次松弛操作。

完整代码:

#include <stdio.h>
int main()
{
    int dis[20];
    int i, j;
    int n, m;
    int u[20];
    int v[20];
    int w[20];
    int inf = 999999;

    printf("请输入图的结点数和边数:\n");
    scanf("%d %d", &n, &m);

    printf("请输入有边的相邻结点序号和边的权数:\n");
    for (i = 1; i <= m; i++)
        scanf("%d %d %d", &u[i], &v[i], &w[i]);

    for (i = 1; i <= n; i++)
        dis[i] = inf;
    dis[1] = 0;

    int check;
    for (i = 1; i <= n - 1; i++)
    {
        check = 0;
        for (j = 1; j <= m; j++)
        {
            if (dis[v[j]] > dis[u[j]] + w[j])
            {
                dis[v[j]] = dis[u[j]] + w[j];
                check = 1;
            }
        }
        if (check == 0)
            break;
    }

    //最短路径中一定不包含回路,因为如果包含正权回路,去掉这个回路路径会更短
    //如果包含负权回路的话,一直加入回路路径会越来越短
    // for(i=1;i<=m;i++)
    // if(dis[v[i]]>dis[u[i]+w[i]])
    // flag = 1;
    //以上代码可以检验n-1轮松弛后是否还有边可以松弛,如果有,这说明存在负权回路

    for (i = 1; i <= n; i++)
        printf("%d ", dis[i]);

    return 0;
}

和Dijkstra类似,上述代码也有无效操作,存在优化空间,比如存在和起始点相邻的边,不用松弛,却在循环中多次判断,无效松弛,怎么改善呢?

Bellman-Ford 队列优化

书中有一个很好的概括,为什么会出现无效松弛?即每一次松弛后都会有顶点已经求得最短路,要是换作在Dijkstra,是要被book数组标记1的。那这些就是后续不用松弛的,而哪些边需要继续松弛?即最短路径信息dis[]发生变化的顶点的所有出边都要松弛。

简单解释一下:如果有一个顶点(非起始点)到起始点的距离变化了——即它的dis[]数组变化了,那么它的所有出边(即以它为起点的边)都要进行松弛,即再次判断这些出边抵达的点,到起始点的距离是否能够缩短。

如何记录哪些是发生变化的点,怎么样找到与发生变化的点的所有出边?这样我们用到队列来优化,并用邻接表进行存储(这样就能线性找到相关的所有出边)

代码如下:

#include <stdio.h>

int main()
{
    int i, j, k;
    int n, m;
    int first[10], next[10]; // first应该比n大1,next应该比m大1
    int u[10], v[10], w[10];
    int que[10];
    int head = 1, tail = 1;
    int book[10] = {0};
    int dis[10];
    int inf = 99999;

    printf("请输入图的结点数和边数:\n");
    scanf("%d %d", &n, &m);

    for (i = 1; i <= n; i++)//dis数组初始化
        dis[i] = inf;
    dis[1] = 0;

    for (i = 1; i <= n; i++)//邻接表初始化
        first[i] = -1;

    printf("请输入相邻有边的结点和其边权:\n");
    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;
    }

    que[tail] = 1;//初始化队列
    que[tail] = 1;
    tail++;
    book[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];
                if (book[v[k]] == 0)//如果能够松弛,且边所抵达的结点还没确定
                {
                    que[tail] = v[k];//让它入队,进队后,就能再判断它的所有出边了
                    tail++;
                    book[v[k]] = 1;
                }
            }
            k = next[k];//找相关结点的下一条出边
        }
        book[head] = 0;//这步很关键,后续还可能松弛。
        head++;//分析完所有出边后,头结点出队
    }

    for (i = 1; i <= n; i++)
        printf("%d ", dis[i]);
}

一定要领会队列优化的关键:只有哪些在前一遍松弛中改变了最短路程的顶点,才可能引起他们邻接点最短路程发生改变。

测试数据:
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 2

最短路径算法对比

对比前,再补充一个知识——稠密图/稀疏图。稠密和稀疏是通过图的结点数和边数来评判的,规定边数m<结点数n2,为稀疏图,边相对比较少。m>n2的,为稠密图,边相对较多。

为什么要区分这个?因为需要根据不同的情况来运用合适的算法,比如Floyd算法,往两个结点中遍历式插入结点,看看距离是否变短,即算法和结点关系密切,更加适用于稠密图(边多不好处理,通过结点处理),而Bellman算法,边用邻接表存,所有的操作也和松弛边有关,即算法和边关系密切,更加适用于稀疏图(边少,处理起来方便)。

最后用书里章节末尾的对比表总结一下:
在这里插入图片描述

每一种算法都各有各的特点,后续做题应该也会碰到它们的各种变式运用,希望能早点灵活地掌握并运用它们。

感谢你能看到这里,希望你有所收获,祝好!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值