常用最短路算法详解

文章目录

  • 1.弗洛伊德 Floyd-Warshall
  • 2.迪杰斯塔拉 Dijkstra
    • 2.1.算法流程
    • 2.2.一些解释
  • 3.SPFA
    • 3.1.前面两种算法的局限性
    • 3.2.Bellman-Ford算法
    • 3.3.SPFA(Shortest Path Faster Algorithm)
  • 4.负权环路

1.弗洛伊德 Floyd-Warshall

主要想法是,通过逐渐增加允许经过的节点,来更新最短路,本质上是动态规划方法

  • 求取图中任意两点之间的距离

    • f[k][x][y] :只允许经过节点 1 到 k(不包括两个端点,两个端点自然允许),节点 x 到节点 y 的最短路长度

    • 如果有 n 个节点,则 f[n][x][y] 就是节点 x 到节点 y 的最短路长度

    • 状态转移方程如下所示:
      在这里插入图片描述

  • 我们可以将上面的三维数组优化为二维数组
    在这里插入图片描述

    • 因为在第 k 阶段,f[x][k]f[k][y] 不会被更新,因为 k 是路径上的端点(端点本来就允许,现在允许经过,和之前没有区别)
      • f[k][x][k] = min(f[k - 1][x][k], f[k - 1][x][k] + 0) = f[k - 1][x][k],可见f[x][k] 不变
      • 同理 f[k][y] 不变
  • 算法应用

    • 用于求取图中任意两点之间的关系
    • 多源最短路,任意两点的距离关系
    • 图上的传递闭包,任意两点的连通关系
    • 复杂度 O ( n 3 ) O(n^3) O(n3)
// n 是点的个数 , dis是距离数据, dis[i][j]: i 与 j 当前的最短距离
void Floyd(int n, int** dis){
    for(int k = 1; k <=n; k++)
        for(int i = 1; i <= n; i++)
            for(int j = 1; j <= n; j++)
                dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j])
}

2.迪杰斯塔拉 Dijkstra

主要用于解决图中没有负边单源最短路问题,复杂度为 O ( ( n + m ) log ⁡ n ) O((n + m)\log{n}) O((n+m)logn)

2.1.算法流程

常用最短路算法详解_第1张图片
常用最短路算法详解_第2张图片

void dijkstra(int s){
    priority_queue, greater> q; // 优先队列,从小到大排序
    for(int i = 1; i <= n; i++) dis[i] = inf, vis[i] = 0; // vis[i] = 1 代表 i 不用再访问了
    dis[s] = 0;
    q.push(make_pair(0, s)) // 前面是距离,后面是节点
    while(!q.empty()){
        int x = q.top().second;
        q.pop();
        if(vis[x]) continue; // 这保证了每个点只会进行下列操作一次
        vis[x] = 1;
        for(int i = point[x]; i != 0; i = nxt[i]) // 遍历所有邻接节点
            if(dis[v[i]] > dis[x] + w[i]){
                dis[v[i]] = dis[x] + w[i]; // 松弛
                q.push(make_pair(dis[v[i]], v[i]));
            }
    }
}

2.2.一些解释

  • 为什么一个节点会被多次加入堆中?

    • 因为我们会通过不同的路走到,而且后面走到的时候,距离可能更小
      常用最短路算法详解_第3张图片

      • 在这个例子中,我们以A为起点,进行松弛操作,会将C放入堆中,此时 dis[A][C] = 7。同时 B 也放入堆中
      • 然后我们会取出 B,这时我们还会再更新 C,并将新的 dis[A][C] = 5 再次放入堆中
  • 为什么每个点只会被弹出最小堆一次?

    • 其实不是只弹出一次,而是只有一次弹出之后会对其邻接节点做松弛操作
    • 因为弹出一次之后,我们令 vis[x] = 1,下次就会直接 continue 了。这样合理么?
      • 合理。因为当我们弹出 x 的时候,说明,目前dis[x] 是堆中最小的了。我们通过堆中其他节点,不能够以更小的距离再次到达 x。所以,只要我们将 x 从堆中弹出,我们就找到了最短的 dis[x]。第二次弹出的时候,没必要再松弛邻居节点了,因为肯定不如第一次松弛时短。
      • 这有利于降低算法复杂度,保证只从 x 对其邻接节点进行松弛 1 次。这个操作导致我们不能处理负边
  • 为什么不能处理负边?:为了降低算法复杂度

常用最短路算法详解_第4张图片

  • 我们从 A 出发,扩张一次,会将 (10, B)(7, C) 放入最小堆中
  • 然后取出小的 (7, C),并令 vis[C] = 1, dis[C] = 7,扩张一次,令 vis[D] = 7 + 3 = 10
  • 因此,在我们后面经过 B 走到 C 时,会更新 vis[C] = 10 - 5 = 5,并将 (5, C)放入堆
  • 我们会有取出 (5, C) 的时候,但是不会利用其进行扩张了,因为 vis[C] = 1
  • 这会导致,我们没有找到 A->B->C->D 这条更短的路,影响了 C 之后点的最短距离的更新
  • 我们可以删除 vis,不论有没有弹出过,我们都可以以此为节点,来松弛其邻接节点,这样就可以应对负边。但是这样复杂度会变大。为了保证算法的高效,我们只在所有边权都是正的情况下应用此算法。这样就能保证,当我们弹出一个节点x时,不会再找到一条到达x的更短的路,从而避免更新其后面的节点

3.SPFA

3.1.前面两种算法的局限性

  • Floyd是求多源最短路的,对于单源最短路来说,太复杂
  • Dijkstra在图中存在负权边时,不能保证结果的正确性

3.2.Bellman-Ford算法

我们设初始点为 s

核心思想:我们以随机的顺序进行边的松弛,每次都对所有边进行松弛。如果 su 的最短路经过 k 条边,那么在第 k 轮松弛后,我们就能找到这条最短路。

常用最短路算法详解_第5张图片

  • 以上图为例,第一轮松弛,无论我们选择的边的顺序是怎么样的,我们总能找到最短路s->as->c。因为他们的最短路都是一条边的
  • 我们进行第二轮松弛,能够确定最短路 s->a->b。因为当我们松弛边ab时,一定能够将b的最距离更新到最短。(也有可能一轮就找到,如果我们在松弛 ab 之前先松弛过 sa 的话,两轮是一定可以)
  • 同理,如果我们进行第三轮松弛,因为两条边的最距离都已经找到了,当我们松弛任意一条边时,如果这条边是某个点最短路上的边(最短路经过边数为3),那么一定能够找到这个节点的最短路。
    • 比如在 b 后面加个 c,因为 s->b 的最短路已经找到,所以可以确定 s->b + b->cs->c 的最短路
  • 这样,我们说明了,若 su 的最短路经过 k 条边,那么在第 k 轮松弛后,我们一定能找到这条最短路。

我们介绍完了核心思想,再给出一个引理:

  • 如果一个节点数为n的图中没有负权环,那么其任意两个节点之间一定存在最短路径,且其边数不会超过n-1

这直观上容易理解。

我们默认,图中不存在负权环,那么,松弛 n - 1 轮(因为,最短路的边数不会超过 n - 1),一定能找到单源最短路

for(int i = 1; i <= n; i++)
    dis[i] = INF, pre[i] = 0; // pre[i] 代表是从哪走过来的,i节点的前一个是谁
dis[s] = 0; // 起点距离为 0
for(int k = 1; k < n; k++) // 第 k 轮松弛
    for(int i = 1; i <= m; i++) // 对所有边进行松弛
        if(dis[v[i]] > dis[u[i]] + w[i]){
            dis[v[i]] = dis[u[i]] + w[i]; // 松弛成功
            pre[v[i]] = u[i]; // 更新父节点
        }
  • 时间复杂度 O ( n m ) O(nm) O(nm)

Bellman-ford 算法能够解决负边权问题,但是复杂度比较高

我们注意到,每一轮松弛都有很多无效的松弛操作,因为有些最短路在之前的松弛中已经确定了,不用再松弛了

3.3.SPFA(Shortest Path Faster Algorithm)

通过观察,可得,松弛操作仅仅发生在最短路径前导结点中已经成功松弛过的结点上。因为如果 u 成功松弛了,则 dis[u] 就变小了,通过 u 连接的其他结点的 dis 也会变小(dis[v] = dis[u] + u->v

在这里插入图片描述

因此,我们每次只做有效的松弛操作

  • 建立一个队列
  • 队列中存储被成功松弛的点(可用于后面的松弛)
  • 每次从队首取点并松弛其邻接点
  • 如果邻接点松弛成功则将其放入队列
void spfa(int s){
    for(int i = 1; i <= n; i++) vis[i] = 0, dis[i] = inf;
    dis[s] = 0; vis[s] = 1;
    queue p;
    p.push(s);
    while(!p.empty()){
        int now = p.front(); p.pop();
        for(int i = point[now]; i != 0; i = nxt[i])
            if(dis[v[i]] > dis[now] + len[i]){
                dis[v[i]] = dis[now] + len[i];
                pre[v[i]] = now;
                if(!vis[v[i]]){ // 如果 v[i] 不在队列中,才放进去
                    vis[v[i]] = 1;
                    p.push(v[i]);
                }
            }
        vis[now] = 0;
    }
}
  • 时间复杂度平均 O ( k m ) O(km) O(km), k是一个小于 n 的常数
  • 但是特殊情况下 k 可能很大
  • 特殊情况下,会退化到 O ( n m ) O(nm) O(nm)

个人觉得和上文说的去掉 visDijkstra 很像,只不过因为有负边的存在,我们不用取队列中的最小值

  • 上文取最小值是为了减少复杂度

  • 但是这里,比如dis[u] = 10dis[v] = 11, v 可以通过走负的边来到达 u,我们先取出 u 不代表到 u 的最短路已经找到了。因此,没必要去最小值

总结起来就是,哪里更新过了(变小了),就可能影响后续的值,我们就要对其邻接结点进行松弛

4.负权环路

有些时候,是无解的

  • 无法到达目标点,即 dis = INF
  • 路径上存在负环,dis 是负无穷

前面一种情况是容易判断的,我们如何判断图中存在负环呢?

对于 Bellman-ford 来说,若松弛完 n - 1 轮后,在第 n 轮松弛时,还有边能够被成功松弛,则有负环。

常用最短路算法详解_第6张图片

对于 SPFA,我们用 cnt[x] 来表示当前到达 x 的最短路的边数,如果 cnt[x] >= n ,则存在负环。

void spfa(int s){
    for(int i = 1; i <= n; i++) vis[i] = 0, dis[i] = inf, cnt[i] = 0;
    dis[s] = 0; vis[s] = 1;
    queue p;
    p.push(s);
    while(!p.empty()){
        int now = p.front(); p.pop();
        for(int i = point[now]; i != 0; i = nxt[i])
            if(dis[v[i]] > dis[now] + len[i]){
                dis[v[i]] = dis[now] + len[i];
                cnt[v[i]] = cnt[now] + 1;
                if(cnt[v[i]] >= n){
                    // 有负环
                }
                pre[v[i]] = now;
                if(!vis[v[i]]){ // 如果 v[i] 不在队列中,才放进去
                    vis[v[i]] = 1;
                    p.push(v[i]);
                }
            }
        vis[now] = 0;
    }
}

你可能感兴趣的:(leetcode,算法,图论,数据结构)