一、最短路径
求最短路径问题,是算法中很常见的一种问题,也是相对较为复杂的问题。比如在游戏设计的角色行走路径以及导航中的最优路径的判断中,都是经常可以看到的应用。
最短路径算法很好,就是求图中两个点的最短距离。它一般分为两人种常见的情况即:
- 单源最短路径
所谓单源最短路径是指在图中,设定一个固定的起点,计算它到图中任意点间的最短距离 - 全源最短路径
即计算指定的图中的任意两个点之间的最短距离,进而生成一个距离矩阵
二、主要的算法
对于求最短路径的算法中,单源算法主要有Dijkstra算法、Bellman-Ford算法、SPFA算法和A*算法;全源算法主要有Floyd-Warshall算法、Johnson算法等等。一般来说,单源最短路径算法,可以理解为导航路线设计,从起点出发到目的地,计算一次即可;而全源路径最短算法可以理解为电商平台的仓库群部点。需要考虑仓库到任意货运点的运输距离即任意两个城市间的最短距离。
三、分析说明
下面针对上面的最短路径算法说明进行逐一分析:
-
Dikstra
迪杰斯特拉算法是贪心算法的典型应用之一,它属于单源路径最短算法。它本质是贪心算法加最优子结构的一种算法。即在连接边的权值非负的情况下,如果从源点S到终点T的路径是最短路径,那么该路径上任意中间节点V到S的子路径也必然是最短路径。
其算法的复杂度为:O((V+E)logV)O((V+E)\log V)O((V+E)logV)。在实际的应用中,路由协议OSPF就是一种工业界中的典型应用
-
Bellman-Ford
这个算法也是图论中一个经典的单源最短路径算法,它的效率低于Dikstra算法,但它支持负权边的场景。本质是动态规划法来进行逐步逼近。它可以用来检测负权环,而且由于其复杂度较高,一般不适合于大规模的稠密图的正权最短路径问题。
其算法的复杂度为:O(VE)O(VE)O(VE)。其算法稳定性高,缺点就是慢。正是因为慢,所以一般工业界常规计算不会使用它,不过在负环检测时经常使用它或者可以作为Johnson算法的前置工具
-
SPFA
Shortest Path Faster Algorithm,它可以认为是Bellman-Ford算法的队列优化版本。它属于一个实践导向的启发式优化,并非一种算法理论的突破。其核心思想主要是避免Bellman-Ford算法中无效的松弛操作,在大多数场景中显著的提高了性能。
不过,其最差的情况下的复杂度并未改善,仍然是O(VE)O(VE)O(VE),平均情况为O(kE)O(kE)O(kE)(k大约为2~5),在随机稀疏图上,平均复杂度O(E)O(E)O(E)。早期由于其简单在算法比赛中应用较多,但现在已经不再推荐或被禁止使用。主要在于其工程应用太慢。一般在有负权但无负环且图极稀疏时可以考虑。可以用Dijkstra加差分约束来替代
-
A*
这种算法是路径规划领域最重要的算法之一,本质上是Dijkstra(贪心)算法与启发式搜索的整合,通过引入对目标的"预估距离",将盲目扩展转化为有方向的智能搜索,在保证最优性的前提下大幅减少搜索空间。其在实际应用中非常广泛,如游戏ai,机器人路径规划等。
这种算法对内存的要求很高,它的算法复杂度为O(bd)O(b^d)O(bd),在某些情况下可能退化为O((V+E)logV)O((V+E)\log V)O((V+E)logV)。
-
Floyd-Warshall
这种算法是使用DP算法来解决全源最短路径的算法之一。以极其简洁的代码实现了全源最短路计算,同时具备检测负权环、传递闭包等扩展能力。
算法本身的复杂度为O(V3)O(V^3)O(V3)。它主要用于解决小规模的稠密图和理论分析中
-
Johnson
这种算法是全源最短路径的最优的通用算法之一。其核心是将Bellman-Ford和Dijkstra结合起来,通过"重赋权"技术消除负权边,使得在稀疏图上全源最短路的计算效率远超Floyd-Warshall。
其算法的复杂度在不同的情况下均有不同,一般为BF算法复杂度为 O(VlogV+VE)O(V \log V + VE)O(VlogV+VE)和V次Dijkstra的O(V(V+E)logV)O(V(V+E)\log V)O(V(V+E)logV)。在工业界的应用是稀疏负权图全源最短路径的唯一解法
这些算法不会在此处详细的展开,只作为一种整体的分析说明。正如前面分析的动态规划法一样,会在后面遇到实际的应用时,逐一再展开各自的详细的分析说明。
四、应用场景
所谓最短路径,名义上看是从A到B点的物理最短距离,但实际上,这种最短距离也可以是其它抽象意义上的最短。如时间最短、成本最小、社交场景最近等等。下面就对其具体的应用进行说明"
- 游戏和地图中的导航
这是最短路径应用的经典的,用于各种电子地图和游戏开发中。当然实际的应用也会因为场景的复杂而比书本上分析的要复杂很多。比如一个经典的例子就是,城市中两人个最短的物理距离未必是最快到达的物理距离。这就看设计者的所谓"短"指的是物理距离还是时间成本。它一般使用Dijkstra和A*算法 - 网络和通信链路规划
这种场景其实就是Dijkstra最短路径的经典应用情况,如何找到通信源点到其它节点中的最短路径。从而最终生成一个最短的路径树 - 社交画像与推荐
这种距离的短其实就是逻辑意义的短。比如通过经常购买的商品和娱乐来发现类似的相同人群或者划出相关的逻辑关系等。从而展开相同或类似的服务的推荐。它一般使用的是非负权的单源最短路径算法,可以根据情况选择合适的相关算法 - 金融业务
这也算是另外一种短,即成本的短。可以通过某种方法实现外汇或股票、期货的相关处理,达到以最"短"的成本实现最大的收益。这里需要特别注意的是,金融应用中往往有负权边(如各种中间费用的出现),算法的选择需要注意。一般会选择支持负权的Bellman-Ford算法 - 生物血缘和疾病传播
这种可以看作是血缘距离短的一种应用,比如生国物间的亲缘远近确定。另外,通过传染病的传播路径可以判断传染病的传播速度和未来发展情况等等。一般多用Floyd-Warshall算法
五、例子
下面看给出的基础的例程:
c
#include <iostream>
#include <limits>
#include <queue>
#include <stdexcept>
#include <vector>
#include <iomanip>
using ll = long long;
const ll INF = std::numeric_limits<ll>::max() / 4;
struct Edge {
int from;
int to;
ll weight;
};
using Graph = std::vector<std::vector<std::pair<int, ll>>>;
// Dijkstra:单源最短路径,要求所有边权非负。
std::vector<ll> dijkstra(int source, const Graph& graph) {
int n = static_cast<int>(graph.size());
std::vector<ll> dist(n, INF);
std::priority_queue<
std::pair<ll, int>,
std::vector<std::pair<ll, int>>,
std::greater<std::pair<ll, int>>
> pq;
dist[source] = 0;
pq.push({0, source});
while (!pq.empty()) {
auto [currentDist, u] = pq.top();
pq.pop();
if (currentDist != dist[u]) {
continue;
}
for (auto [v, weight] : graph[u]) {
if (weight < 0) {
throw std::runtime_error("Dijkstra cannot be negative edges");
}
if (dist[v] > dist[u] + weight) {
dist[v] = dist[u] + weight;
pq.push({dist[v], v});
}
}
}
return dist;
}
// Johnson:全源最短路径。
std::vector<std::vector<ll>> johnson(int n, const std::vector<Edge>& edges) {
// 等价于增加超级源点,并向每个顶点连接一条权值为0的边。
std::vector<ll> h(n, 0);
for (int i = 0; i < n; ++i) {
bool updated = false;
for (const auto& edge : edges) {
if (h[edge.to] > h[edge.from] + edge.weight) {
h[edge.to] = h[edge.from] + edge.weight;
updated = true;
if (i == n - 1) {
throw std::runtime_error("There is a negative weight ring in the figure");
}
}
}
if (!updated) {
break;
}
}
// 重新赋权,保证新边权非负。
Graph reweightedGraph(n);
for (const auto& edge : edges) {
ll newWeight = edge.weight + h[edge.from] - h[edge.to];
reweightedGraph[edge.from].push_back({edge.to, newWeight});
}
// 每个顶点执行一次Dijkstra。
std::vector<std::vector<ll>> result(n, std::vector<ll>(n, INF));
for (int source = 0; source < n; ++source) {
std::vector<ll> d = dijkstra(source, reweightedGraph);
for (int target = 0; target < n; ++target) {
if (d[target] != INF) {
result[source][target] =
d[target] - h[source] + h[target];
}
}
}
return result;
}
void printDistances(const std::vector<ll>& dist, int source) {
std::cout << "From the vertex " << source << " The shortest distance to depart:"<<std::endl;
for (int i = 0; i < static_cast<int>(dist.size()); ++i) {
std::cout << source << " -> " << i << " : ";
if (dist[i] == INF) {
std::cout << "unreachable"<<std::endl;
} else {
std::cout << dist[i] << std::endl;
}
}
std::cout << std::endl;
}
void printMatrix(const std::vector<std::vector<ll>>& matrix) {
std::cout << "Johnson All-pairs shortest path matrix:"<<std::endl;
for (const auto& row : matrix) {
for (ll value : row) {
if (value == INF) {
std::cout << std::setw(8) << "INF";
} else {
std::cout << std::setw(8) << value;
}
}
std::cout << std::endl;
}
}
int main() {
// Dijkstra-不能有负边权:单源
{
int n = 5;
Graph graph(n);
graph[0].push_back({1, 4});
graph[0].push_back({2, 1});
graph[2].push_back({1, 2});
graph[1].push_back({3, 1});
graph[2].push_back({3, 5});
graph[3].push_back({4, 3});
std::vector<ll> dist = dijkstra(0, graph);
printDistances(dist, 0);
}
// Johnson-含负权边,但无负权环:全源
{
int n = 5;
std::vector<Edge> edges = {
{0, 1, -1},
{0, 2, 4},
{1, 2, 3},
{1, 3, 2},
{1, 4, 2},
{3, 2, 5},
{3, 1, 1},
{4, 3, -3}
};
try {
std::vector<std::vector<ll>> dist = johnson(n, edges);
printMatrix(dist);
} catch (const std::exception& e) {
std::cerr << "Johnson err:" << e.what() << std::endl;
}
}
return 0;
}
代码本身没有什么复杂的,主要是对算法要理解。有什么不明白的,好好看看算法实现本身就明白了。
六、总结
一个看上去很简单的最短路径问题,其实内部有着很多种情况。它可以有环也可以无环,可以有正权也可以有负权。还可以指定必经点等等限制。这都需要开发者根据情况来选择相关的算法。大多数情况下,算法只有最合适的,没有最优的或者说只有局部最优的。
算法的开发者或应用者就是要能够拨开眼前的迷雾,找到根源所在,从而准确的选择合适的解决问题的方法。