【图论】最短路

【图论】最短路


前言#

最短路是图论最重要的算法之一,也是算法难点。经过这篇的学习,你会发现你离成功就差一个最短路的距离(虽然它难以被找到)。最短路是指,某个点到某个点之间的距离最短的路径。

必要的知识#

学会存储一张有向图,我们一般使用邻接矩阵邻接表。因为邻接矩阵的储存方式有时无法满足题目规定的空间复杂度限制,为此这里我们只使用邻接表储存有向图。储存无向图可以看成是两条相反方向的有向边

关于邻接表的存图方式,请浏览【数据结构】邻接表与链式前向星

2023/6/16 修正: 此存图方式应为链式前向星,邻接表使用vector而非数组模拟链表,详见上方链接

最短路算法#

一、Dijkstra 算法#

1.算法基本介绍

Dijkstra 算法通常是求解单源最短路中最快的算法,但它无法处理存在负权边的情况(原因在正确性证明中)。Dijkstra 本质上是一种贪心算法,通过不断调整每个点的“当前距离”最终得到最优结果,其实后面要讲到的几种算法也大都是采用这种步步逼近的手段。这种不断调整的过程,维基百科上面称为“relax”。以上可能有点抽象,下面是算法的流程。

2.算法流程

假设现在要求出从某一点 s 到其他所有点的最短距离,对于每个点 v 均维护一个“当前距离”(dist[v]dist[v])和“是否访问过”(visited[v]visited[v])。首先将dist[s]dist[s]初始化为 0,将其他点的距离初始化为无穷,并将所有点初始化为未访问的。记u>vu->v的边权为weight[u>v]weight[u->v](程序中我们使用 edge 数组存储,下标为u>vu->v这条边的序号,详见邻接表一栏)。然后进行以下步骤:

  1. 从所有未访问的点中,找出当前距离最小的,设为 u,并将其标记为已访问的。
  2. 调整 u 的所有边(若是有向图则为出边)连接的并且未被访问过的点:若weight[u>v]+dist[u]<dist[v]weight[u->v] + dist[u] < dist[v], 则将dist[v]dist[v]更新为dist[u]+weight[u>v]dist[u]+weight[u->v]
  3. 重复 1 和 2 步骤,直到所有点都被标记为已访问的,则dist[i]dist[i]即 s 到 i 的最短距离。如果只想求从 s 到某一点的最短距离,那么当该点被标记为访问过之后可直接退出。
  4. 补充:如果除了最短距离之外还想求出具体的路径,只需建立一个prepre数组,在步骤 2 后添加操作:pre[v]=upre[v] = u(前提是dist[v]dist[v]被更新)。类似于邻接表中 son 数组的链式结构。

3.正确性证明

我们证明,当某个点 v 被标记为已访问后,s 到其的最短距离即为dist[v]dist[v],即在步骤 1 中选出的每个点的当前距离即为最短距离。

首先容易理解,如果从 s 到 v 的路径只允许经过已访问过的点,那么dist[v]dist[v]是这些路径中最短的。倘若存在一条包含未访问过点(除 v 之外)的更短的路径,设这条路径上第一个未被访问过的点为 m,则显然有dist[m]<dist[v]dist[m] < dist[v](注意因为 m 是这条路径上第一个未被访问的点,所以沿着这条路径走到 m 的距离一定为dist[m]dist[m])。而这与 v 的选取方式——从所有未访问过点中选取当前距离最小的相矛盾。

解释一下 Dijkstra 算法为什么不能用于有负权的边的图:

text4.png

因为 Dijkstra 算法是通过当前离起点最近的点来更新其他的点的距离,例如上图中的 4 号结点会被 2 号结点更新为 2+3=5,但实际上 4 号结点的最短路径是 4+(-4)=0!,这样你就知道为什么 Dijkstra 算法不能用于有负权的边的图吧。

4.代码实现

这里使用邻接表储存有向图,关于邻接矩阵的算法读者可以自己上网查询。

CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <algorithm>
#include <cstring>
#include <cmath>
#define MAX 10000
int head[MAX]; //代表一个结点,也是子结点链的起点
int edge[MAX]; //储存边的值
int son[MAX]; //子结点链
int ver[MAX]; //代表有向边
int dist[MAX]; //最短路
bool tag[MAX]; //标记结点
int tot = 0; // 边的数量
using namespace std;
void Dijkstra()
{
    memset(dist, 0x3f, sizeof(dist)); //初始化dist
    memset(tag, 0, sizeof(tag)); //初始化tag
    dist[1] = 0; //设置起点
    //计算除终点以外的结点出发的子结点的最短路
    for(int i = 1; i < N; i++)
    {
        int crd = 0; //父结点下标
        //遍历N个结点,找到dist最小的结点
        for(int j = 1; j <= N; j++)
        {
            if(!tag[j] && (crd == 0 || dist[crd] > dist[j]))
            {
                crd = j;
            }
        }
        tag[crd] = true; //标记已经走过的点
        //遍历此结点的子结点
        for(int j = head[crd]; j; j = son[j])
        {
            int ne = ver[j];
            dist[ne] = min(dist[ne], dist[crd] + edge[j]); //更新子结点的dist
        }
    }
    return;
}

堆优化 Dijkstra:

我们使用小根堆对 dist 数组进行维护,每次只扩展有可能改变的结点,我们只需要O(logN)O(log N)的时间即可维护一个二叉堆,而找到最小distdist结点只需要O(1)O(1)

关于小根堆的实现方法读者可自行查询,这里我们直接存储负值。

CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <algorithm>
#include <cstring>
#include <queue>
#include <cmath>
#define MAX 10000
int head[MAX]; //代表一个结点,也是子结点链的起点
int edge[MAX]; //储存边的值
int son[MAX]; //子结点链
int ver[MAX]; //代表有向边
int dist[MAX]; //最短路
bool tag[MAX]; //标记结点
int tot = 0; // 边的数量
using namespace std;
//pair的第一维是dist的相反数(把大根堆转换成小根堆)
//pair的第二维是结点下标
priority_queue<pair<int int>> q; //用二叉堆存结点
void Dijkstra()
{
    memset(dist, 0x3f, sizeof(dist)); //初始化dist
    memset(tag, 0, sizeof(tag)); //初始化tag
    dist[1] = 0; //设置起点
    q.push(make_pair(0, 1)); //把起点加入二叉堆
    //一直计算直到队列为空
    while(q.size())
    {
        int crd = q.top().second; //父结点下标
        q.pop(); //移出二叉堆
        if(tag[crd]) continue; //确保此结点没有被扩展过
        tag[crd] = true; //标记已经走过的点
        //遍历此结点的子结点
        for(int j = head[crd]; j; j = son[j])
        {
            if(dist[crd] + edge[j] < dist[ne])
            {
                 dist[ne] = dist[crd] + edge[j]; //更新子结点的dist
                 q.push(make_pair(-dist[ne], ne)); //把新的二元组插入堆
            }
        }
    }
    return;
}

二、Bellmon-Ford 算法和 SPFA#

首先介绍 bellmon-ford 算法,SPFA(shortest path faster algorithm)是对它的一个改进。

bellmon-ford 是一种单源最短路算法,时间复杂度是 O(VE)O(VE),显然不如 Dijkstra 快,但它可以处理负权边和负权环的情况。它基于一个很基本的事实: 对于一个不包含负权环的 V 个点的图,任意两点之间的最短路径至多包含V1V-1条边。 如果存在负权环,每次在负权环上走一圈都会使环上的每一个点的距离减少 (如果计算时步数超过了V1V-1,说明有负权环),因此不存在最短路径。bellmon-ford 算法可以检测出这种情况。

算法的实现也很简单,根据三角形不等式,对于图中的某一条边(u,v,t)(u,v,t),有dist[v]<=dist[u]+tdist[v] <= dist[u] + t成立,就说它满足三角形不等式,可以发现,此不等式就是这一条边在有限范围内的最优解。通过重复扫描边,直到所有边都符合三角不等式,此时 dist 数组就是结果(是不是很暴力很简单)。

正因为它不像 Dijkstra 这么贪心,不会漏过负权边,所以它可以正确处理有负权边的图的最短路问题。

CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <algorithm>
#include <cstring>
#include <cmath>
#define MAX 10000
int edge[MAX]; //储存边的值
int ver[MAX]; //代表有向边出发的结点
int son[MAX]; //代表有向边指向的结点
/*
注意这里ver和son的含义改变了
ver存的是一条边的起点下标
son存的是一条边的终点下标
请读者自己更改添加边函数
*/
int dist[MAX]; //最短路
int tot = 0; // 边的数量
using namespace std;
void BellmonFord()
{
    memset(dist, 0x3f, sizeof(dist)); //初始化dist
    dist[1] = 0; //设置起点
    //循环N – 1次,因为无负环的最短路步数最多为N – 1
    for(int i = 1; i < N; i++)
    {
        //遍历tot条边,找到指向结点dist最小的边
        for(int j = 1; j <= tot; j++)
        {
            if(dist[ver[j]] + edge[j] < dist[son[j]])
            {
                dist[son[j]] = dist[ver[j]] + edge[j];
            }
        }
    }
    return;
}

此版本的 Bellmon-Ford 算法不需要邻接表,但SPFA仍然要使用邻接表,望读者一定要熟悉邻接表的使用方法,不要被暴力迷了双眼

算法中,dist[i]dist[i]为源点到 i 的当前最短距离。算法最外层是一个N1N – 1次的循环,在每次循环中,算法遍历所有的边并执行操作。

bellmon-ford 算法不断对每条边进行所谓的松弛(relax)操作,如果 u 到 v 有一条边,那么dist[u]dist[u]减小意味着dist[v]dist[v]可能也可以进行更新,然而由于不知道到底哪些点的当前距离需要更新,bellmon-ford 算法选择暴力地去遍历每条边来检查哪些点的当前距离可以更新 (这也是我们要优化的地方)

如果在某次外层循环中发现所有点的当前距离都没有被更新,那么可以直接停止算法,因为接下来无论再进行多少次循环点的距离也不会被继续更新了。

bellmon-ford 算法可以判断负权环的存在:只需在算法的最后对每条边再松弛一次,如果发现有点的距离得到更新 (说明经过N1N – 1次循环仍然有边不满足三角形不等式,而只有负环会出现这种情况,因为负环永远不可能满足三角形不等式),说明存在负权环——因为没有负权环时最短路径的长度至多为N1N – 1

SPFA:

从上面的介绍我们知道 bellmon-ford 算法是带着一定的盲目性的,因为三角形不等式是这一条边在有限范围内的最优解,很多时候(比如刚开始赋值无限大)根本不存在最优解,白白浪费了时间。作为对它的优化,SPFA 采用类似 BFS 的思想,使用一个队列,只松弛那些可能更新点的距离的边,也就是那些已经被改变,不再是无限大(也可能再次被改变),可能对下一个结点有影响的结点队列。算法的流程为:

  1. 将除源点之外的所有的点当前距离初始化为无穷,并标记为未入队。源点的当前距离为 0,将源点入队。
  2. 取出队首 u,遍历 u 的所有出边,检查是否能更新所连接的点 v 的当前距离。如果 v 的当前距离被更新并且 v 不在队中,则将 v 入队。重复该操作直到队列为空。
  3. 检查是否存在负权环的方法为:记录一个点的入队次数,如果超过N1N – 1次说明存在负权环,因为最短路径上除自身外至多N1N – 1个点,故一个点不可能被更新超过N1N – 1次。
CPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <algorithm>
#include <cstring>
#include <queue>
#include <cmath>
#define MAX 10000
int head[MAX]; //代表一个结点,也是子结点链的起点
int edge[MAX]; //储存边的值
int son[MAX]; //子结点链
int ver[MAX]; //代表有向边
int dist[MAX]; //最短路
bool tag[MAX]; //标记结点
int tot = 0; //边的数量
int N;
using namespace std;
void BellmonFord()
{
    memset(dist, 0x3f, sizeof(dist)); //初始化dist
    dist[1] = 0; //设置起点
    queue<int> q; //定义队列,存储可能改变的结点
    q.push(1); //把起点添加进队列
    tag[1] = true; //打上标记
    while (!q.empty()) //一直循环,直到队列为空
    {
        int crd = q.front(); //从队列中取一个结点
        q.pop(); //移出队列
        tag[crd] = false; //去掉标记
        //遍历连接crd的子节点
        for(int j = head[crd]; j; j = son[j])
        {
            int ne = ver[j];
            if(dist[crd] + edge[j] < dist[ne])
            {
                dist[ne] = dist[crd] + edge[j];
                if(!tag[ne]) //如果子节点并不在队列中
                {
                    q.push(ne); //加入队列
                    tag[ne] = true; //打上标记
                }
            }
        }
    }
    return;
}

三、Floyd 算法#

Floyd 算法又称为 Floyd-Warshell 算法,其实 Warshell 算法是离散数学中求传递闭包的算法,两者的思想是一致的。Floyd 算法是求解多源最短路时通常选用的算法,经过一次算法即可求出任意两点之间的最短距离,并且可以处理有负权边的情况(但无法处理负权环)。

原理:

Floyd 本质上是动态规划的思想。倘若现在我们想求 i 到 j 的最短路径长度,我们限制这条路径上除 i 和 j 之外只准经过k 个点(这样的路径称为 k 允许路径),我们在算法的最外层循环每次将 k 加 1,那么当 k 等于点数时求得的结果便是最优的。下面用数学归纳法证明算法的正确性:

即证明在第 n 次循环后 dist[i][j]为 i 到 j 的最短 n 允许路径,当 n=0 时,i 到 j 不准经过任何点,dist[i][j]即为 weight[i->j]或者无穷大,显然成立。

假设 n=k 时成立,那么当 n=k+1 时,倘若 i 到 j 的最短 k+1 允许路径不经过第 k+1 个点,则 dist[i][j]不发生改变。若 i 到 j 的最短 k+1 允许路径经过第 k+1 个点,由归纳假设,此时 dist[i][k+1]为 i 到 k+1 的最短 k 允许路径,dist[k+1][j]为 k+1 到 j 的最短 k 允许路径,故 i 到 j 的最短 k+1 允许路径长为 dist[i][k+1] + dist[k+1][j]。这正是算法中的状态转移方程。

算法的实现非常简单,是一个三重循环:

CPP
1
2
3
4
5
6
7
8
9
10
11
int dist[100][100]; //初始化dist数组
for (int k = 0; k < v; k++)
{
    for (int i = 0; i < v; i++)
    {
        for (int j = 0; j < v; j++)
        {
            dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
        }
    }
}