文章目录
最短路问题概述
最短路问题(Shortest Path Problem)是图论中一个经典的问题,旨在找到从一个顶点到另一个顶点的最短路径。
所给定的图可以是有向图(directed graph)也可以是无向图(undirected graph),并且边可以有权重(weights),即每条边有一个数值表示从一个顶点到另一个顶点的距离或成本。
最短路问题的常见变种包括:
- 单源最短路径问题:找到从图中某个特定顶点到所有其他顶点的最短路径。
- 单对最短路径问题:找到从图中某个特定顶点到另一个特定顶点的最短路径。
- 全源最短路径问题:找到图中每对顶点之间的最短路径。
解决最短路问题通常有包含以下算法
- Dijkstra算法:
- 定义:用于解决单源最短路径问题,适用于边权重非负的图。
- 原理:通过贪心策略,逐步选择具有最小估计距离的顶点,并更新其邻接顶点的距离。
- 时间复杂度:O((V + E) log V),其中 V 是顶点数,E 是边数。
- Bellman-Ford算法:
- 定义:用于解决单源最短路径问题,可以处理负权重的边。
- 原理:通过对所有边进行多次松弛操作,逐步逼近最短路径。
- 时间复杂度:O(VE),其中 V 是顶点数,E 是边数。
- Floyd算法:
- 定义:用于解决全源最短路径问题。
- 原理:通过动态规划的方式,逐步考虑每个顶点作为中间点,更新所有顶点对之间的最短路径。
- 时间复杂度:O(V^3),其中 V 是顶点数。
本篇讲解主要介绍Floyd算法的原理以及应用
带边权的图的全源最短路径
对于以下无向图,我们能够看到图中包含了若干节点以及节点之间的边权
这里的边权可以简单理解为两个节点之间的距离
大部分题目(但不是绝对的)会用一个外层长度为m
,内层长度为3
的二维数组edges
,来表示包含图。
其中m
是整个图的边数,edges[i] = [start_i, end_i, w_i]
,表示在第i
条边中,start_i
节点和end_i
节点之间的距离为w_i
。
譬如,上述无向图可以用以下二维数组edges
来表示
edges = [
[0, 1, 2],
[0, 4, 8],
[1, 2, 3],
[1, 4, 2],
[2, 3, 1],
[3, 4, 1]
]
有几点需要注意:
m = len(edges) = 6
是该无向图的边数- 边
edges[i]
在edges
中的顺序并不重要- 对于无向图而言,每一条边的起点
start_i
和终点end_i
的顺序也不重要- 如果是一个有向图,可能存在一条边的起点和终点互换则边权值不相等的情况出现(在实际生活中表现为两点之间的路径存在单行道、红灯停留时间等等)
假设我们现在需要一个储存数据的结构,通过该结构能够快速查询图中任意两个点的最短路径,那么这个数据应该如何设计?
由于查询任意两个点之间的距离都必须知道这两个点的具体编号,容易想到可以设计如下的一个二维数组。
我们可以把这个二维数组称为dist
,其中行索引为起点的编号(绿色),列索引为终点的编号(黄色)。
dist
是distance
的缩写
如果我们要查询节点i
到节点j
的最短路径,我们直接通过查找dist[i][j]
的值就可以得到。
譬如,dist[0][3]
表示从节点0
到节点3
的最短路径为5
。
在图中,我们容易看出这条路径是0 -> 1 -> 4 -> 3
。
容易注意到二维数组dist
存在以下特点
- 该二维数组的内层和外层均为
n
(n
行n
列),表示该数组包含了n*n
对节点的最短路径查询情况。 - 该二维数组主对角线的值均为
0
(灰色部分),即存在dist[i][i] = 0
成立,这表示任意一个节点到达它自身的最短路径是0
(不需要任何其他路径) - 该二维数组沿主对角线对称,即存在
dist[i][j] = dist[j][i]
成立,表示从节点i
出发到节点j
的最短路径,和从节点j
出发到节点i
的最短路径是相同的。(这个特点仅对无向图成立)换句话说,只需要看这个二维数组的右上部分(紫色)或左下部分(白色),即可得知所有信息。
Floyd算法解决全源最短路问题
截止到目前,我们已经知道,解决全源最短路问题,要求我们得到的结果就是这个dist
。
接下来努力的方向就是如何找到这个dist
。
而Floyd算法就是基于动态规划思想,通过多次迭代找到这个二维数组dist
的过程。
dist
数组初始化
回到例子
edges = [
[0, 1, 2],
[0, 4, 8],
[1, 2, 3],
[1, 4, 2],
[2, 3, 1],
[3, 4, 1]
]
我们根据上述edges
数组所给出的起始边情况,初始化dist
数组。若
- 节点
start_i
和节点end_i
在edges
中是一条初始存在的边,则设置dist[start_i][end_i]
为其对应的权值或距离w_i
- 节点
start_i
和节点end_i
在edges
中不是一条初始存在的边,则设置dist[start_i][end_i]
为正无穷inf
或一个极大的数,表示在初始边中,无法从start_i
到达end_i
- 节点
start_i
和end_i
相等的时候,则设置dist[start_i][end_i] = 0
,即对角线上的0
其对应代码如下
from math import inf
def Floyd(n, edges):
dist = [[inf] * n for _ in range(n)]
for start, end, w in edges:
dist[start][end], dist[end][start] = w, w
for i in range(n):
dist[i][i] = 0
pass
dist
数组迭代以及动态转移方程
由于初始化后dist
数组中的数字仅仅代表着这些直接连接的边的长度,我们需要思考,如果我们把某些点作为桥梁(或称之为中间点)把某两个点连起来,那么会不会使得这两个点之间的最短路径相较于之前变得更短?
换句话说,假设我们选择节点k
,作为节点i
和节点j
之间所经过的中间点,能否使得节点i
和节点j
之间的最短路径dist[i][j]
变得更小?
譬如,考虑节点1
作为节点0
和节点4
之间的中间点。
由于dist[0][1] = 2
, dist[1][4] = 2
,dist[0][4] = 8
。
我们发现存在dist[0][1] + dist[1][4] < dist[0][4]
成立。
这意味着,如果我们想从节点0
出发到达节点4
,走0 -> 1 -> 4
这样的路径,会优于0 -> 4
这样的路径。
所以,我们会将dist[0][4]
修改为比之前更小的dist[0][1] + dist[1][4] = 4
。
而在这之后,当我们涉及到以节点4
作为中间节点去连接从节点0
出发到其他节点(譬如节点3
)的时候,就可以使用dist[0][4] = 4
而不是dist[0][4] = 8
来作为节点0
到节点4
的最短路径了。
随着这个例子的推出,Floyd算法的动态转移方程也就呼之欲出了。
假设我们选择节点k
,作为节点i
和节点j
之间所经过的中间点。那么
d i s t [ i ] [ j ] = m i n ( d i s t [ i ] [ j ] , d i s t [ i ] [ k ] + d i s t [ k ] [ j ] ) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j]) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j])
换句话说,当考虑经过中间点k
能使得节点i
和节点j
之间的路径更短,则修改dist[i][j]
为这个更短距离。
由于每一个节点都有可能成为另外任意两个节点的中间节点,为了不漏掉任何一个可能的中间节点,我们必须对所有的中间节点k
进行遍历,并且在固定k
的前提下,再次进行起点i
和终点j
的双重遍历。
其对应的代码为
def Floyd(n, edges):
pass
for k in range(n):
for i in range(n):
for j in range(n):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
return dist
Floyd算法求解dist数组完整代码
python
def Floyd(n, edges):
# 初始化dist二维数组,用一个大数代替inf
dist = [[100000] * n for _ in range(n)]
# 初始化dist二维数组中的边为edges的起始边的情况
for start, end, w in edges:
dist[start][end], dist[end][start] = w, w
# 初始化dist二维数组的对角线均为0
for i in range(n):
dist[i][i] = 0
# 遍历中间节点k,每一个节点都有可能成为中间节点
for k in range(n):
# 遍历起始节点i,每一个节点都有可能成为起始节点
for i in range(n):
# 遍历终止节点j,每一个节点都有可能成为终止节点
for j in range(n):
# 动态转移方程
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
# 返回dist数组
return dist
java
import java.util.Arrays;
public class Main {
public static int[][] Floyd(int n, int[][] edges) {
// 初始化dist二维数组,用一个大数代替inf
int[][] dist = new int[n][n];
for (int[] row : dist) {
Arrays.fill(row, 100000);
}
// 初始化dist二维数组中的边为edges的起始边的情况
for (int[] edge : edges) {
int start = edge[0];
int end = edge[1];
int w = edge[2];
dist[start][end] = w;
dist[end][start] = w;
}
// 初始化dist二维数组的对角线均为0
for (int i = 0; i < n; i++) {
dist[i][i] = 0;
}
// 遍历中间节点k,每一个节点都有可能成为中间节点
for (int k = 0; k < n; k++) {
// 遍历起始节点i,每一个节点都有可能成为起始节点
for (int i = 0; i < n; i++) {
// 遍历终止节点j,每一个节点都有可能成为终止节点
for (int j = 0; j < n; j++) {
// 动态转移方程
dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
// 返回dist数组
return dist;
}
public static void main(String[] args) {
// 初始化n和edges数组,可以自行进行修改和调整
int n = 5;
int[][] edges = {
{0, 1, 2},
{0, 4, 8},
{1, 2, 3},
{1, 4, 2},
{2, 3, 1},
{3, 4, 1}
};
// 调用Floyd函数得到dist最小距离矩阵
int[][] dist = Floyd(n, edges);
// 输出dist最小距离矩阵
for (int[] row : dist) {
for (int val : row) {
System.out.print(val + " ");
}
System.out.println();
}
}
}
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
vector<vector<int>> Floyd(int n, vector<vector<int>>& edges) {
// 初始化dist二维数组,用一个大数代替inf
vector<vector<int>> dist(n, vector<int>(n, 100000));
// 初始化dist二维数组中的边为edges的起始边的情况
for (const auto& edge : edges) {
int start = edge[0];
int end = edge[1];
int w = edge[2];
dist[start][end] = w;
dist[end][start] = w;
}
// 初始化dist二维数组的对角线均为0
for (int i = 0; i < n; i++) {
dist[i][i] = 0;
}
// 遍历中间节点k,每一个节点都有可能成为中间节点
for (int k = 0; k < n; k++) {
// 遍历起始节点i,每一个节点都有可能成为起始节点
for (int i = 0; i < n; i++) {
// 遍历终止节点j,每一个节点都有可能成为终止节点
for (int j = 0; j < n; j++) {
// 动态转移方程
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
// 返回dist数组
return dist;
}
int main() {
// 初始化n和edges数组,可以自行进行修改和调整
int n = 5;
vector<vector<int>> edges = {
{0, 1, 2},
{0, 4, 8},
{1, 2, 3},
{1, 4, 2},
{2, 3, 1},
{3, 4, 1}
};
// 调用Floyd函数得到dist最小距离矩阵
vector<vector<int>> dist = Floyd(n, edges);
// 输出dist最小距离矩阵
for (const auto& row : dist) {
for (int val : row) {
cout << val << " ";
}
cout << endl;
}
return 0;
}
时空复杂度
时间复杂度:O(N^3)
。三重循环所需时间复杂度。
空间复杂度:O(N^2)
。最小距离矩阵dist
所占空间。
其中N
为节点数目。
*Floyd算法正确性证明
本处内容不做具体讲解,感兴趣的同学可以自行学习。
Floyd算法的核心是其动态转移方程
d i s t [ i ] [ j ] = m i n ( d i s t [ i ] [ j ] , d i s t [ i ] [ k ] + d i s t [ k ] [ j ] ) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j]) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j])
该转移方程表示,如果通过顶点 k
作为中间点可以使 i
到 j
的路径更短,则更新 dist[i][j]
为 dist[i][k] + dist[k][j]
。
证明过程
我们可以使用归纳法来证明Floyd算法的正确性。
初始情况
在初始情况下, dist[i][j]
被设定为以下几种情况之一:
- 如果
i == j
,则dist[i][j] = 0
。 - 如果
i
和j
之间有一条边,则dist[i][j]
为该边的权重。 - 否则,
dist[i][j]
设为无穷大(inf
),表示两点之间不可达。
这个初始化保证了在没有中间点的情况下,每对顶点 (i, j)
的最短路径是正确的。
归纳假设
假设在迭代到某个中间点 k
之前,dist[i][j]
表示从顶点 i
到顶点 j
的最短路径,且这个路径仅包含编号不大于 k-1
的中间点。
归纳步骤
对于每一对顶点 (i, j)
,我们在考虑是否通过中间点 k
更新路径时有以下两种情况:
- 不通过中间点
k
:在这种情况下,最短路径长度保持为dist[i][j]
,即没有比原有路径更短的路径。 - 通过中间点
k
:如果通过k
能够使路径更短,则路径长度应更新为dist[i][k] + dist[k][j]
。这是因为dist[i][k]
是从i
到k
的最短路径长度,而dist[k][j]
是从k
到j
的最短路径长度。
根据动态转移方程:
d i s t [ i ] [ j ] = m i n ( d i s t [ i ] [ j ] , d i s t [ i ] [ k ] + d i s t [ k ] [ j ] ) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j]) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j])
我们选择较小的值来更新 dist[i][j]
,确保 dist[i][j]
始终表示从 i
到 j
的最短路径长度。
终止条件
当所有顶点都作为中间点被考虑完毕后,dist[i][j]
就表示从 i
到 j
的最短路径长度,因为所有可能的中间点都已经被考虑在内。
完整证明
- 初始条件正确:初始化阶段保证了直接路径的正确性。
- 保持最优子结构性质:通过动态转移方程,我们在每一步中都选择最优解(较小的路径长度),确保子问题的解是最优的。
- 正确性归纳:在迭代的每一步中,假设之前的所有步骤已经正确计算了路径长度,那么当前步骤也能正确更新路径长度,从而保证最终结果的正确性。
通过上述步骤,我们证明了Floyd算法的动态转移方程的正确性。该方程在每一步中都能够有效地更新最短路径长度,最终保证在所有中间点被考虑之后,dist[i][j]
表示从顶点 i
到顶点 j
的最短路径长度。