夜深人静写算法(二十三)- 最短路

文章目录

  • 一、前言
  • 二、最短路
    • 1、最短路问题简介
    • 2、图的概念
    • 3、图的存储
      • 1)邻接矩阵
      • 2)邻接表
      • 3)前向星
      • 4)链式前向星
  • 三、最短路算法
    • 1、Dijkstra
    • 2、Dijkstra + 优先队列
    • 3、Bellman-Ford
    • 4、SPFA
      • 1)最短路径存在
      • 2)最短路径不存在
    • 5、Floyd-Warshall
  • 四、最短路相关题集整理
    • 1、Dijkstra
    • 2、Bellman-Ford
    • 3、SPFA
    • 4、Floyd-Warshall

一、前言

  这是一篇经过翻新的文章,最早写于 2015-11-19 23:44:00,那是一个风雨交加的夜晚,是我逝去的青春!
  本文比原文增加了更多图解,并且将示例代码写得更加通俗易懂,争取把每个函数控制在五到十行,以前一些描述的较为晦涩的内容也进行了改革,有兴趣涉足图论的小伙伴可以从这篇文章进行入门,如有描述不清或者错误的地方也希望能加以指正,共同成长,齐头并进!

二、最短路

1、最短路问题简介

  • 所谓最短路问题,就是给定一个起点,一个终点,以及一些有权值的边,求从起点到终点的最小权值和。

【例题1】给定四个小岛以及小岛之间的有向距离,问从第 0 个岛到第 3 个岛的最短距离。如图二-1-1所示,带箭头的线段代表两个小岛之间的有向边,箭头上的数字代表有向边的权值。夜深人静写算法(二十三)- 最短路_第1张图片

图二-1-1
  • 这个问题就是经典的最短路问题。由于这个图比较简单,我们可以枚举所有的路线,发现总共三条路线,如下:
  • 1)0 → \to 3,长度为 8;
  • 2)0 → \to 2 → \to 3,长度为 7 + 2 = 9;
  • 3)0 → \to 1 → \to 2 → \to 3,长度为 2 + 3 + 2 = 7;
  • 最短路为三条线路中的长度的最小值即 7,所以最短路的长度就是 7。

2、图的概念

  • 在讲解最短路问题之前,首先需要介绍一下计算机中图(图论)的概念,如下:
  • G G G 是一个有序二元组 ( V , E ) (V,E) (V,E),其中 V V V 称为顶点集合, E E E 称为边集合, E E E V V V 不相交。顶点集合的元素被称为顶点,边集合的元素被称为边。
  • 对于无权图,边由二元组 ( u , v ) (u,v) (u,v) 表示,其中 u , v ∈ V u, v \in V u,vV。对于带权图,边由三元组 ( u , v , w ) (u,v, w) (u,v,w) 表示,其中 u , v ∈ V u, v \in V u,vV w w w 为权值,可以是任意类型。
  • 图分为有向图和无向图,对于有向图, ( u , v ) (u, v) (u,v) 表示的是 从顶点 u u u 到 顶点 v v v 的边,即 u → v u \to v uv;对于无向图, ( u , v ) (u, v) (u,v) 可以理解成两条边,一条是 从顶点 u u u 到 顶点 v v v 的边,即 u → v u \to v uv,另一条是从顶点 v v v 到 顶点 u u u 的边,即 v → u v \to u vu

3、图的存储

  • 对于图的存储,程序实现上也有多种方案,根据不同情况采用不同的方案。接下来以图二-3-1所表示的图为例,讲解四种存储图的方案。
    图二-3-1

1)邻接矩阵

  • 邻接矩阵是直接利用一个二维数组对边的关系进行存储,矩阵的第 i i i 行第 j j j 列的值 表示 i → j i \to j ij 这条边的权值;特殊的,如果不存在这条边,用一个特殊标记 ∞ \infty 来表示;如果 i = j i = j i=j,则权值为 0 0 0
  • 它的优点是:实现非常简单,而且很容易理解;缺点也很明显,如果这个图是一个非常稀疏的图,图中边很少,但是点很多,就会造成非常大的内存浪费,点数过大的时候根本就无法存储。
  • [ 0 ∞ 3 ∞ 1 0 2 ∞ ∞ ∞ 0 3 9 8 ∞ 0 ] \left[ \begin{matrix} 0 & \infty & 3 & \infty \\ 1 & 0 & 2 & \infty \\ \infty & \infty & 0 & 3 \\ 9 & 8 & \infty & 0 \end{matrix} \right] 0190832030

2)邻接表

  • 邻接表是图中常用的存储结构之一,采用链表来存储,每个顶点都有一个链表,链表的数据表示和当前顶点直接相邻的顶点的数据 ( v , w ) (v, w) (v,w),即 顶点 和 边权。
  • 它的优点是:对于稀疏图不会有数据浪费;缺点就是实现相对邻接矩阵来说较麻烦,需要自己实现链表,动态分配内存。
  • 如图二-3-2 所示, d a t a data data ( v , w ) (v, w) (v,w) 二元组,代表和对应顶点 u u u 直接相连的顶点数据, w w w 代表 u → v u \to v uv 的边权, n e x t next next 是一个指针,指向下一个 ( v , w ) (v, w) (v,w) 二元组。
    图二-3-2
  • 在 C++ 中,还可以使用 vector 这个容器来代替链表的功能;
    vector<Edge> edges[maxn];

3)前向星

  • 前向星是以存储边的方式来存储图,先将边读入并存储在连续的数组中,然后按照边的起点进行排序,这样数组中起点相等的边就能够在数组中进行连续访问了。
  • 它的优点是实现简单,容易理解;缺点是需要在所有边都读入完毕的情况下对所有边进行一次排序,带来了时间开销,实用性也较差,只适合离线算法。
  • 如图二-3-3 所示,表示的是三元组 ( u , v , w ) (u, v, w) (u,v,w) 的数组, i d x idx idx 代表数组下标。
    在这里插入图片描述
    图二-3-3
  • 那么用哪种数据结构才能满足所有图的需求呢?
  • 接下来介绍一种新的数据结构 —— 链式前向星。

4)链式前向星

  • 链式前向星和邻接表类似,也是链式结构和数组结构的结合,每个结点 i i i 都有一个链表,链表的所有数据是从 i i i 出发的所有边的集合(对比邻接表存的是顶点集合),边的表示为一个四元组 ( u , v , w , n e x t ) (u, v, w, next) (u,v,w,next),其中 ( u , v ) (u, v) (u,v) 代表该条边的有向顶点对 u → v u \to v uv w w w 代表边上的权值, n e x t next next 指向下一条边。
  • 具体的,我们需要一个边的结构体数组 edge[maxm]maxm表示边的总数,所有边都存储在这个结构体数组中,并且用head[i]来指向 i i i 结点的第一条边。
  • 边的结构体声明如下:
struct Edge {
     
    int u, v, w, next;
    Edge() {
     }
    Edge(int _u, int _v, int _w, int _next) :
        u(_u), v(_v), w(_w), next(_next) 
    {
     
    }
}edge[maxm];
  • 初始化所有的head[i] = -1,当前边总数 edgeCount = 0
  • 每读入一条 u → v u \to v uv 的边,调用 addEdge(u, v, w),具体函数的实现如下:
void addEdge(int u, int v, int w) {
     
    edge[edgeCount] = Edge(u, v, w, head[u]);
    head[u] = edgeCount++;
}
  • 这个函数的含义是每加入一条边 ( u , v , w ) (u, v, w) (u,v,w),就在原有的链表结构的首部插入这条边,使得每次插入的时间复杂度为 O ( 1 ) O(1) O(1),所以链表的边的顺序和读入顺序正好是逆序的。这种结构在无论是稠密的还是稀疏的图上都有非常好的表现,空间上没有浪费,时间上也是最小开销。
  • 调用的时候只要通过head[i]就能访问到由 i i i 出发的第一条边的编号,通过编号到edge数组进行索引可以得到边的具体信息,然后根据这条边的next域可以得到第二条边的编号,以此类推,直到 next域为 -1 为止。
for (int e = head[u]; ~e; e = edges[e].next) {
     
    int v = edges[e].v;
    ValueType w = edges[e].w;
    ...
}
  • 文中的 ~e等价于 e != -1,是对e进行二进制取反的操作(-1 的的补码二进制全是 1,取反后变成全 0,这样就使得条件不满足跳出循环)。

三、最短路算法

1、Dijkstra

【例题1】给出一个 n ( n ≤ 1000 ) n(n \le 1000) n(n1000) 个结点, m ( m ≤ 10000 ) m(m \le 10000) m(m10000) 条边的有向图,边权都为正数,求从编号 0 到 n n n-1 的最短路。

  • 这个问题可以用经典的 Dijkstra 算法求解,中文名 迪杰斯特拉 。对于一个有向图或无向图,所有边权为正,给定 a a a b b b,求 a a a b b b 的最短路,保证 a a a 一定能够到达 b b b。这条最短路是否一定存在呢?答案是肯定的。相反,最长路就不一定了,由于边权为正,如果遇到有环的时候,可以一直在这个环上走,因为要找最长的,这样就使得路径越变越长,永无止境,所以对于正权图,在可达的情况下最短路一定存在,最长路则不一定存在。这里先讨论正权图的最短路问题。
  • 最短路满足最优子结构性质,最优子结构可以描述为:
  • D ( s , t ) = ( V s → V i . . . V j → V t ) D(s, t) = (V_s \to V_i ... V_j \to V_t) D(s,t)=(VsVi...VjVt)
  • 它表示的是 s s s t t t 的最短路,其中 i i i j j j 是这条路径上的两个中间结点,那么 D ( i , j ) D(i, j) D(i,j) 必定是 i i i j j j 的最短路,这个性质是显然的,可以用反证法证明。如图三-1-1所示:
    夜深人静写算法(二十三)- 最短路_第2张图片
图三-1-1
  • 基于上面的最优子结构性质,如果存在这样一条最短路 D ( s , t ) = ( V s . . . V i → V t ) D(s, t) = (V_s ... V_i \to V_t) D(s,t)=(Vs...ViVt),其中 i i i t t t 是最短路上相邻的点,那么 D ( s , i ) = ( V s . . . V i ) D(s, i) = (V_s ... V_i) D(s,i)=(Vs...Vi) 必定是 s s s i i i 的最短路。Dijkstra 算法就是基于这样一个性质,通过最短路径长度递增,逐渐生成所有结点的最短路。如图三-1-2所示:
    夜深人静写算法(二十三)- 最短路_第3张图片
图三-1-2
  • Dijkstra 算法是最经典的最短路算法,用于计算正权图的单源最短路(Single Source Shortest Path,源点给定,通过该算法可以求出起点到所有点的最短路),它是基于这样一个事实:如果源点到 x x x 点的最短路已经求出,并且保存在 d [ x ] d[x] d[x] ( 可以将它理解为 D ( s , x ) D(s, x) D(s,x) ) 上,那么可以利用 x x x 去更新 x x x 能够直接到达的点 y y y 的最短路。即:
  • d [ y ] = min ⁡ ( d [ y ] , d [ x ] + w ( x , y ) ) d[y] = \min( d[y], d[x] + w(x, y) ) d[y]=min(d[y],d[x]+w(x,y))
  • y y y x x x 能够直接到达的点, w ( x , y ) w(x, y) w(x,y) 则表示 x → y x \to y xy 这条有向边的边权;

具体算法描述如下:对于图 G = < V , E > G = G=<V,E>,源点为 s s s d [ i ] d[i] d[i] 表示 s s s i i i 的最短路, v i s i t e d [ i ] visited[i] visited[i] 表示 d [ i ] d[i] d[i] 是否已经确定(布尔值),即 s s s i i i 的最短路 是否已经确定。
  1) 初始化 所有顶点 d [ i ] = i n f d[i] = inf d[i]=inf, v i s i t e d [ i ] = f a l s e visited[i] = false visited[i]=false,令 d [ s ] = 0 d[s] = 0 d[s]=0
  2) 从所有 v i s i t [ i ] visit[i] visit[i] f a l s e false false 的顶点中找到一个 d [ i ] d[i] d[i] 值最小的,令 x = i x = i x=i;如果找不到,算法结束;
  3) 标记 v i s i t e d [ x ] = t r u e visited[x] = true visited[x]=true, 更新和 x x x 直接相邻的所有顶点 y y y 的最短路: d [ y ] = min ⁡ ( d [ y ] , d [ x ] + w ( x , y ) ) d[y] = \min( d[y], d[x] + w(x, y) ) d[y]=min(d[y],d[x]+w(x,y));回到 2);

  • 算法主框架如下:
void Dijkstra(int n, int st, ValueType *dist) {
      // 1)
    Dijkstra_Init(n, st, dist);                 // 2)
    while (true) {
     
        int u = Dijkstra_FindMin(n, dist);      // 3)
        if (u == inf) break;
        Dijkstra_Update(u, dist);               // 4)
    }
}
  • 1)n代表结点个数,st代表起点,dist[]用来存储当前最短路的数组,即 dist[i]代表 初始点 sti的最短路;
  • 2)Dijkstra_Init对算法进行初始化:dist[i]当且仅当 i = s t i=st i=st 时取 0,否则为无穷大,visited[i]标记所有点都未被访问过,时间复杂度 O ( n ) O(n) O(n),c++代码实现如下:
void Dijkstra_Init(int n, int st, ValueType *dist) {
     
    for (int i = 0; i < n; ++i) {
     
        dist[i] = (st == i) ? 0 : inf;
        visited[i] = false;
    }
}
  • 3)Dijkstra_FindMin返回尚未访问过的离起点最近的点 u u u,如果不存在则返回 INVALID_INDEX,这个函数最多执行 n − 1 n-1 n1 次,所以总的时间复杂度 O ( n 2 ) O(n^2) O(n2),c++ 代码实现如下:
int Dijkstra_FindMin(int n, ValueType *dist) {
     
    int u = INVALID_INDEX;
    for (int i = 0; i < n; ++i) {
     
        if (visited[i])
            continue;
        if (u == INVALID_INDEX || dist[i] < dist[u]) {
     
            u = i;
        }
    }
    return u;
}
  • 4)Dijkstra_Update用点 u u u 更新和它相邻点的最短路,因为 u u u 只会被访问一次(访问完被visited数组标记后下次不会再访问到),所以所有更新操作的时间复杂度总和等于总边数,时间复杂度 O ( m ) O(m) O(m),c++ 代码实现如下:
void Dijkstra_Update(int u, ValueType *dist) {
     
    visited[u] = true;
    for (int e = head[u]; ~e; e = edges[e].next) {
     
        int v = edges[e].v;
        int w = edges[e].w;
        dist[v] = min(dist[v], (dist[u] + w));
    }
}
  • 根据以上分析,这种迭代的方式求解的 Dijkstra 算法,时间复杂度为 O ( n 2 + n + m ) O(n^2 + n + m) O(n2+n+m),边数 m m m 在没有重边的情况下肯定是小于 n 2 n^2 n2 的, n n n 又是 n 2 n^2 n2 的高阶无穷小,所以整个算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

2、Dijkstra + 优先队列

【例题2】给出一个 n ( n ≤ 100000 ) n(n \le 100000) n(n100000) 个结点, m ( m ≤ 100000 ) m(m \le 100000) m(m100000) 条边的有向图,边权都为正数,求从编号 0 到 n n n-1 的最短路。

  • 【例题2】和【例题1】的差别是点数和边数都变多了,这种情况下 O ( n 2 ) O(n^2) O(n2) 的时间复杂度太高了,所以我们需要对原有 Dijkstra 算法进行优化。
  • 我们观察上文 Dijkstra 算法的几个函数,Dijkstra_FindMin是在 n n n 个点中找 d i s t [ i ] dist[i] dist[i] 最小的点,并且最多找 n n n 次,所以算法的瓶颈是在这里,如果有一种数据结构,能够在 O ( l o g 2 n ) O(log_2n) O(log2n) 或者 O ( 1 ) O(1) O(1) 的时间复杂度下,支持 插入元素、询问最小值、删除最小值 的操作,那么这个问题就能很好的被解决了。
  • 所以,这个时候我们引入堆,它是一种完全二叉树,能够在 O ( l o g 2 n ) O(log_2n) O(log2n) 的时间内插入和删除数据,并且在 O ( 1 ) O(1) O(1) 的时间内得到当前数据的最小值。
  • 加入堆优化后,算法主框架如下:
void DijkstraHeap(int n, int st, ValueType *dist) {
     
    Heap heap;                               // 1)
    Dijkstra_Init(n, st, heap, dist);        // 2)
    while (!heap.empty()) {
     
        int u = Dijkstra_FindMin(heap);      // 3)
        Dijkstra_Update(u, heap, dist);      // 4)
    }
}
  • 1)这里引入了一个堆,可以自己实现,也可以利用 c++ 中 STL 的优先队列 ( priority_queue ) ,它的底层也是用堆实现的。需要定义一个 (顶点、距离) 二元组Dist,并且用距离作为排序关键字,重载小于运算符,c++ 实现如下:
struct Dist {
     
    int u;
    ValueType w;
    Dist(){
     }
    Dist(int _u, ValueType _w) : u(_u), w(_w) {
     }
    bool operator < (const Dist& d) const {
     
        return w > d.w;        // 小顶堆
    }
};
typedef priority_queue<Dist> Heap;
  • 2)Dijkstra_Init需要比之前的实现多一行代码,即往堆插入一个起点,c++ 代码实现如下:
void Dijkstra_Init(int n, int st, Heap& heap, ValueType *dist) {
     
    for (int i = 0; i < n; ++i) {
     
        dist[i] = (st == i) ? 0 : inf;
        visited[i] = false;
    }
    heap.push(Dist(st, 0));
}
  • 3)Dijkstra_FindMin函数实现就很简单了,直接调用堆的 API,弹出堆顶元素,c++ 代码实现如下:
int Dijkstra_FindMin(Heap& heap) {
     
    Dist s = heap.top();
    heap.pop();            
    return s.u;
}
  • 4)Dijkstra_Update的实现多了一步插入堆的操作,c++ 代码实现如下:
void Dijkstra_Update(int u, Heap& heap, ValueType *dist) {
     
    if (visited[u]) {
     
        return;
    }
    visited[u] = true;
    for (int e = head[u]; ~e; e = edges[e].next) {
     
        int v = edges[e].v;
        int w = edges[e].w;
        dist[v] = min(dist[v], (dist[u] + w));
        if (dist[u] + w == dist[v])
            heap.push(Dist(v, dist[v]));
    }
}
  • 考虑这个算法的时间复杂度,优先队列中最多可能存在的点数有多少个呢?
  • 因为我们在把顶点插入优先队列的时候并没有判断队列中有没有这个点,而且也不能进行这样的判断,因为距离更短才会执行插入,所以新插入的点一定会取代之前的点,同一时间优先队列中的点是有可能重复的,但是基本也是和 O ( n ) O(n) O(n) 同阶 而不会和 O ( n 2 ) O(n^2) O(n2) 同阶,这个我没有严格证明,也不敢妄加定论其正确性,如果有能力证明的小伙伴可以加以尝试。
  • heap.top()的时间复杂度为 O ( 1 ) O(1) O(1)heap.pop()的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)Dijkstra_FindMin函数弹出元素的数量级是 O ( n ) O(n) O(n) ,所以这部分的时间复杂度就是 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 而对于Dijkstra_Update这个函数,每次取距离最小的点,对于有多个相同点的情况,如果那个点已经出过一次队列了,下次同一个点出队列的时候它对应的距离一定比之前的大,不需要用它去更新其它点,因为一定不可能更新成功,所以真正执行更新操作的点的个数其实只有 n n n 个,即每个点的每条出边只会被执行一次,时间复杂度就是 O ( m ) O(m) O(m)head.push O ( l o g 2 n ) O(log_2n) O(log2n)的,所以这个函数的总时间复杂度为 O ( m l o g 2 n ) O(mlog_2n) O(mlog2n)
  • 总体下来,整个算法的时间复杂度为 O ( ( m + n ) l o g 2 n ) O( (m+n)log_2 n) O((m+n)log2n),而这个只是理论上界,当点数很多,边数相对较少的问题,都是很快就能找到最短路的,所以实际复杂度会比这个小很多,相比 O ( n 2 ) O(n^2) O(n2) 的算法已经优化了很多了。
  • Dijkstra 算法求的是正权图的单源最短路问题,对于权值有负数的情况就不能用 Dijkstra 求解了,因为如果图中存在负环,可以从起点走到负环处一直将权值变小,从而使得 Dijkstra 带优先队列优化的算法就会进入一个死循 。对于带负权的图的最短路问题就需要用到 Bellman-Ford 算法了。

3、Bellman-Ford

【例题3】 n ( n ≤ 500 ) n (n \le 500) n(n500) 个点 和 m ( m ≤ 3000 ) m (m \le 3000) m(m3000) 条边的图上,边有两种类型,一种是正常的路,一种是虫洞,正常的路行走时花费时间,虫洞行走时能让时间倒退,问是否存在某个点出发,并且在过去的某个时间回到该点。

  • Bellman-Ford 算法可以在最短路存在的情况下求出最短路,并且在存在负权圈的情况下告诉你最短路不存在,前提是起点能够到达这个负权圈,因为即使图中有负权圈,但是起点到不了负权圈,最短路还是有可能存在的。
  • Bellman-Ford 算法是基于这样一个事实:一个图的最短路如果存在,那么最短路中必定不存在圈,所以最短路的顶点数除了起点外最多只有 n − 1 n-1 n1 个。
  • Bellman-Ford 同样也是利用了最短路的最优子结构性质,用 d [ i ] d[i] d[i] 表示起点 s s s i i i 的最短路,那么边数上限为 j j j 的最短路可以通过边数上限为 j − 1 j-1 j1 的最短路 加入一条边 得到,通过 n − 1 n-1 n1 次迭代,最后求得 s s s 到所有点的最短路。

具体算法描述如下:对于图 G = < V , E > G = G=<V,E>,源点为 s s s d [ i ] d[i] d[i] 表示 s s s i i i 的最短路。
  1) 初始化 所有顶点 d [ i ] = i n f d[i] = inf d[i]=inf, 令 d [ s ] = 0 d[s] = 0 d[s]=0,计数器 c = 0 c = 0 c=0
  2) 枚举每条边 w ( u , v ) w(u, v) w(u,v),如果 d [ u ] ≠ i n f d[u] \neq inf d[u]=inf ,则更新 d [ v ] = min ⁡ ( d [ v ] , d [ u ] + w ( u , v ) ) d[v] = \min(d[v], d[u] + w(u, v)) d[v]=min(d[v],d[u]+w(u,v))
  3) 计数器 c c c 自增 1,当 c = n − 1 c = n - 1 c=n1 时算法结束,否则继续重复2)的步骤;

  • 第 2) 步的一次更新称为边的 “松弛” 操作。
  • 以上算法并没有考虑到负权圈的问题,如果存在负圈权,那么第 2) 步操作的更新会永无止境,所以判定负权圈的算法也就出来了,只需要在第 n n n 次继续进行第 2) 步的松弛操作,如果有至少一条边能够被更新,那么必定存在负权圈。
  • 算法主框架如下:
bool BellmanFord(int n) {
     
    BellmanFordInit(n);                 // 1)
    for (int i = 0; i < n - 1; ++i) {
     
        if (!BellmanFordUpdate()) {
          // 2)
            return false;
        }
    }
    return BellmanFordUpdate();         // 3)
}
  • 1)n代表了结点总数,BellmanFordInit对整个图进行初始化,假设有个虚拟起始点,并且它到所有点的距离都为 0,c++ 代码实现如下:
void BellmanFordInit(int n) {
     
    for (int i = 0; i < n; ++i) {
     
        dist[i] = 0;
    }
}
  • 2)BellmanFordUpdate是执行上文提到的松弛操作,这里有一个小优化,因为每次迭代都是做同一件事情,也就是说如果第 k ( k ≤ n − 1 ) k(k \le n-1) k(kn1) 次迭代的时候没有任何的最短路发生更新,即所有的 d [ i ] d[i] d[i] 值都未发生变化,那么第 k + 1 k+1 k+1 次必定也不会发生变化了,也就是说这个算法提前结束了。所以可以在开始的时候记录一个flag标志,标志初始为 false,如果有一条边发生了松弛,那么标志置为 true,所有边枚举完毕如果标志还是 false则提前结束算法。“松弛” 操作总共迭代 n − 1 n-1 n1 次,理论上如果没有负权圈的话,函数应该会在某次 BellmanFordUpdate()返回 false,c++ 代码实现如下:
bool BellmanFordUpdate() {
     
    bool flag = false;
    for (int i = 0; i < edgeCount; ++i) {
     
        Edge &edge = edges[i];
        if (dist[edge.u] + edge.w < dist[edge.v]) {
     
            flag = true;
            dist[edge.v] = dist[edge.u] + edge.w;
        }
    }
    return flag;
}
  • 3)当 n − 1 n-1 n1 次都没有返回时,最后再做一次松弛,来真正确定是否存在 负权圈;
  • 整个算法的时间复杂度为 O ( n m ) O(nm) O(nm) 。大部分最短路求解时,在前几次迭代就已经找到最优解了,但是也不排除上文提到的负权圈的情况,会一直更新,使得整个算法的时间复杂度达到上限 O ( n m ) O(nm) O(nm),那么如何改善这个算法的效率呢?接下来介绍改进版的 Bellman-Ford 一一 SPFA。

4、SPFA

  • SPFA( Shortest Path Faster Algorithm ) 是基于 Bellman-Ford 的思想,采用先进先出 (FIFO) 队列进行优化的一个计算单源最短路的快速算法。

类似 Bellman-Ford 的做法,我们用数组 d [ i ] d[i] d[i] 记录每个结点的最短路径估计值,并用链式前向星来存储图 G G G
  1) 利用一个先进先出的队列用来保存待松弛的结点,每次取出队首结点 u u u,并且枚举从 u u u 出发的所有边 ( u , v ) (u, v) (u,v),更新 d [ v ] = min ⁡ ( d [ v ] , d [ u ] + w ( u , v ) ) d[v] = \min(d[v], d[u] + w(u, v)) d[v]=min(d[v],d[u]+w(u,v))
  2) 然后判断 v v v 点在不在队列中,如果不在就将 v v v 点放入队列。这样不断从队列中取出结点来进行松弛操作,直至队列空为止。

1)最短路径存在

  • 只要最短路径存在,SPFA算法必定能求出最小值。因为每次将点放入队尾,都是经过松弛操作达到的。即每次入队的点 v v v 对应的最短路径估计值 d [ v ] d[v] d[v] 都在变小。所以算法的执行会使 d d d 数组越来越小。由于我们假定最短路一定存在,即图中没有负权圈,所以每个结点都有最短路径值。因此,算法不会无限执行下去,随着 d d d 值的逐渐变小,直到到达最短路径值时,算法结束,这时的最短路径估计值就是对应结点的最短路径值。

2)最短路径不存在

  • 那么最短路径不存在呢?如果存在负权圈,并且起点可以通过一些顶点到达负权圈,那么利用 SPFA 算法会进入一个死循环,因为 d d d 数组的值会越来越小,并且没有下限,使得最短路不存在。
  • 那么我们假设不存在负权圈,则任何最短路上的点必定小于等于 n n n 个(没有圈),换言之,用一个数组 c [ i ] c[i] c[i] 来记录 i i i 这个点入队的次数,所有的 c [ i ] c[i] c[i] 必定都小于等于 n n n ,所以一旦有一个 c [ i ] > n c[i] > n c[i]>n,则表明这个图中存在负权圈。
  • 接下来给出SPFA更加直观的理解,假设图中所有边的边权都为1,那么 SPFA 其实就是一个BFS(Breadth First Search,广度优先搜索),对于BFS的介绍可以参阅 单向广搜 一节。BFS 首先到达的顶点所经历的路径一定是最短路(也就是经过的最少顶点数),所以此时利用数组记录节点访问可以使每个顶点只进队一次,但在至少有一条边的边权不为 1 的带权图中,最先到达的顶点的路径不一定是最短路,这就是为什么要用 d d d 数组来记录当前最短路估计值的原因了。
  • SPFA 的算法框架如下:
bool SPFA(int n, int st, ValueType *dist) {
     
    queue <int> que;
    SPFAInit(n, st, que, dist);        // 1)
    while (!que.empty()) {
     
        int u = que.front();           
        que.pop();
        inqueue[u] = false;            // 2)
        if (visited[u] ++ > n) {
            // 3)
            return true;
        }
        SPFAUpdate(u, que, dist);      // 4)
    }
    return false;
}
  • 1)初始化三个数组:dist[u]代表起点到结点 u u u 的最短路估值,visited[u]代表结点 u u u 的出队次数,inqueue[u]是个布尔数组,代表结点 u u u 是否在队列中。并且将起点插入到队列中,c++代码实现如下:
void SPFAInit(int n, int st, queue <int>& que, ValueType *dist) {
     
    for (int i = 0; i < n; ++i) {
     
        dist[i] = (st == i) ? 0 : inf;
        inqueue[i] = (st == i);
        visited[i] = 0;
    }
    que.push(st);
}
  • 2)每次从队列中弹出一个结点,并且标记 inqueue[u] = false
  • 3)如果一个结点进队列超过 n n n 次,那么必然存在环,直接返回;
  • 4)SPFAUpdate进行松弛操作,并且将松弛的点继续入队,c++代码实现如下:
void SPFAUpdate(int u, queue <int>& que, ValueType *dist) {
     
    for (int e = head[u]; ~e; e = edges[e].next) {
     
        int v = edges[e].v;
        if (dist[u] + edges[e].w < dist[v]) {
     
            dist[v] = dist[u] + edges[e].w;
            if (!inqueue[v]) {
     
                inqueue[v] = true;
                que.push(v);
            }
        }
    }
}
  • SPFA算法的最坏时间复杂度为 O ( n m ) O(nm) O(nm),一般的期望时间复杂度为 O ( k m ) O(km) O(km) k k k 为常数, m m m 为边数(这个时间复杂度只是估计值,具体和图的结构有很大关系,而且很难证明,不过可以肯定的是至少比传统的 Bellman-Ford 高效很多,所以一般采用SPFA来求解带负权圈的最短路问题)。

5、Floyd-Warshall

  • 最后介绍一个求任意两点最短路的算法,很显然,我们可以通过枚举起点,然后求 n n n 次单源最短路来解决,但是下面这种方法更加容易编码,而且很巧妙,它是基于动态规划的思想。
  • d [ k ] [ i ] [ j ] d[k][i][j] d[k][i][j] 为只允许经过结点 [ 0 , k ] [0, k] [0,k] 的情况下, i i i j j j 的最短路。那么利用最优子结构性质,有两种情况:
  • 1)如果最短路经过 k k k 点,则 d [ k ] [ i ] [ j ] = d [ k − 1 ] [ i ] [ k ] + d [ k − 1 ] [ k ] [ j ] d[k][i][j] = d[k-1][i][k] + d[k-1][k][j] d[k][i][j]=d[k1][i][k]+d[k1][k][j];
  • 2)如果最短路不经过 k k k 点,则 d [ k ] [ i ] [ j ] = d [ k − 1 ] [ i ] [ j ] d[k][i][j] = d[k-1][i][j] d[k][i][j]=d[k1][i][j];
  • 于是有状态转移方程:
  • d [ k ] [ i ] [ j ] = min ⁡ ( d [ k − 1 ] [ i ] [ j ] , d [ k − 1 ] [ i ] [ k ] + d [ k − 1 ] [ k ] [ j ] ) ( 0 ≤ i , j , k < n ) d[k][i][j] = \min( d[k-1][i][j], d[k-1][i][k] + d[k-1][k][j] ) \\ (0 \le i, j, k < n) d[k][i][j]=min(d[k1][i][j],d[k1][i][k]+d[k1][k][j])(0i,j,k<n)
  • 这是一个3D/0D问题,只需要按照 k k k 递增的顺序进行枚举,就能在 O ( n 3 ) O(n^3) O(n3) 的时间内求解,又第一维的状态可以采用滚动数组进行优化,所以空间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • c++ 代码实现如下:
void FloydWarshall(int n) {
     
    int i, j, k;
    for (k = 0; k < n; ++k)
        for (i = 0; i < n; ++i)
            for (j = 0; j < n; ++j)
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
  • 在有向图中,某些情况下我们关心的不是路径的长度,而是两点间是否连通,例如可以用 d[i][j] = 0代表 i i i 不能到 j j j,用 d[i][j] = 1代表 i i i 能到 j j j,同样可以用 Floyd-Warshall 算法来求所有点之间的连通关系,即有向图的 “传递闭包”。c++ 代码实现如下:
void FloydWarshall(int n) {
     
    int i, j, k;
    for (k = 0; k < n; ++k)
        for (i = 0; i < n; ++i)
            for (j = 0; j < n; ++j)
                d[i][j] = d[i][j] || (d[i][k] && d[k][j]);
}

  • 关于 最短路 的内容到这里就结束了。
  • 如果还有不懂的问题,可以 想方设法 找到作者的微信进行在线咨询。

  • 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/Code_Templates


四、最短路相关题集整理

1、Dijkstra

题目链接 难度 解析
HDU 1874 畅通工程续 ★☆☆☆☆ Dijkstra
HDU 2066 一个人的旅行 ★☆☆☆☆ Dijkstra
HDU 2112 HDU Today ★☆☆☆☆ Dijkstra
HDU 2544 Shortest Path ★☆☆☆☆ Dijkstra
HDU 3790 Shortest Path Problem ★☆☆☆☆ Dijkstra
HDU 1546 Idiomatic Phrases Game ★☆☆☆☆ Dijkstra
HDU 2722 Here We Go(relians) Again ★☆☆☆☆ Dijkstra
HDU 1596 find the safest road ★☆☆☆☆ Dijkstra
HDU 1245 Saving James Bond ★☆☆☆☆ Dijkstra
HDU 1548 A strange lift ★☆☆☆☆ Dijkstra
HDU 2377 Bus Pass ★★☆☆☆ Dijkstra
HDU 2145 zz’s Mysterious Present ★★☆☆☆ Dijkstra
HDU 2354 Another Brick in the Wall ★★☆☆☆ Dijkstra
HDU 4522 湫湫系列故事——过年回家 ★★☆☆☆ Dijkstra
HDU 1224 Free DIY Tour ★★☆☆☆ Dijkstra + 路径还原
HDU 1385 Minimum Transport Cost ★★☆☆☆ Dijkstra + 路径还原
HDU 1142 A Walk Through the Forest ★★☆☆☆ Dijkstra + 路径数
HDU 2680 Choose the best route ★★☆☆☆ Dijkstra + 预处理
HDU 1599 find the mincost route ★★☆☆☆ Dijkstra + 枚举删边
HDU 1595 find the longest of the shortest ★★☆☆☆ Dijkstra + 枚举删边
HDU 2363 Cycling ★★☆☆☆ 二分枚举 + Dijkstra
HDU 1535 Invitation Cards ★★☆☆☆ Dijkstra/堆优化
HDU 2962 Trucking ★★☆☆☆ 二分枚举 + Dijkstra/堆优化
HDU 4001 To Miss Our Children Time ★★☆☆☆ 构图 + Dijkstra/堆优化
HDU 1839 Delay Constrained Maximum Capacity ★★☆☆☆ 二分枚举 + Dijkstra/堆优化
HDU 1690 Bus System ★★★☆☆ Dijkstra
HDU 4885 TIANKENG’s travel ★★★☆☆ 极角排序 + Dijkstra/堆优化
HDU 3768 Shopping ★★★☆☆ 状态压缩 + Dijkstra/堆优化

2、Bellman-Ford

题目链接 难度 解析
PKU 1860 Currency Exchange ★★☆☆☆ Bellman-Ford 判正权环
HDU 1217 Arbitrage ★★☆☆☆ Bellman-Ford 判正权环
HDU 2388 Playground Hideout ★★☆☆☆ Bellman-Ford
PKU 3259 Wormholes ★★☆☆☆ Bellman-Ford 判负权环
HDU 1317 XYZZY ★★☆☆☆ Bellman-Ford 判正权环 + Floyd-Warshall

3、SPFA

题目链接 难度 解析
HDU 1535 Invitation Cards ★★☆☆☆ SPFA
HDU 3339 In Action ★★☆☆☆ SPFA + 背包
HDU 2833 WuKong ★★★☆☆ SPFA / 最短路径公共点
HDU 2782 The Worm Turns ★★☆☆☆ 四向图最长路 / 深搜
HDU 2992 Hotel booking ★★★☆☆ 两次最短路
HDU 3499 Flight ★★★☆☆ SPFA
HDU 2482 Transit search ★★★★☆ SPFA + 坐标转换 + 字符串哈希
HDU 3873 Invade the Mars ★★★★☆ SPFA

4、Floyd-Warshall

题目链接 难度 解析
HDU 2807 The Shortest Path ★★☆☆☆ Floyd-Warshall
HDU 2923 Einbahnstrasse ★★☆☆☆ Floyd-Warshall
HDU 3179 Effective Government Spokesman ★★☆☆☆ Floyd-Warshall
HDU 3631 Shortest Path ★★★☆☆ Floyd-Warshall
HDU 4034 Graph ★★★☆☆ Floyd-Warshall
HDU 3021 Tree Fence ★★★★☆ Floyd-Warshall + 凸包

你可能感兴趣的:(《夜深人静写算法》,算法,Dijkstra,Bellman,Floyd,最短路)