最短路算法和最小生成树算法详解
核心概念区分
在开始之前,我们先明确两个概念的区别:
- 最短路 (Shortest Path):关注的是一个点(源点)到另一个点(或所有其他点)的路径总权重最小。它是一条“路径”。
- 最小生成树 (Minimum Spanning Tree, MST):关注的是如何用最少的总权重连接图中的所有点,形成一棵树。它是一个覆盖所有顶点的“子图”。
Part 1: 最短路算法 (Shortest Path Algorithms)
最短路问题主要分为两种:
- 单源最短路 (Single-Source Shortest Path):计算从一个指定的源点
s到图中所有其他顶点的最短路径。 - 所有顶点对最短路 (All-Pairs Shortest Path):计算图中每一对顶点
(u, v)之间的最短路径。
1. Dijkstra 算法
Dijkstra 算法是解决单源最短路问题的经典算法,但它有一个重要的前提:图中不能有负权边。
-
核心思想:
Dijkstra 算法采用迭代的方式,维护一个已找到最短路径的顶点集合S。- 初始时,
S中只有源点s。 - 每次迭代,从尚未确定最短路径的顶点(
V-S集合)中,选择一个距离源点s最近的顶点u。 - 将
u加入S集合。 - 然后,通过顶点
u来更新u的所有邻居v的距离。这个过程称为“松弛”(Relaxation):如果从s经过u到达v的路径比当前已知的s到v的路径更短,则更新s到v的距离。 - 重复这个过程,直到所有顶点都加入
S。
- 初始时,
-
贪心策略分析:
是,Dijkstra 是一个典型的贪心算法。
它的贪心策略在于:每一步都选择当前看来距离源点最近的未访问顶点。它相信这个局部最优选择(当前最近)就是全局最优选择(这个点的最终最短路径已经确定)。这个贪心策略的正确性依赖于“边的权重非负”这一前提。因为没有负权边,一旦一个点被选为“最近”,就不可能再通过其他未访问的点绕一下,从而找到一条更短的路径了。 -
C++ 代码实现 (使用优先队列优化)
这是最常见的实现方式,也叫堆优化版的 Dijkstra。时间复杂度为 O(ElogV)O(E \log V)O(ElogV)。
#include <iostream>
#include <vector>
#include <queue>
#include <limits>
const int INF = std::numeric_limits<int>::max();
// 图的边结构
struct Edge {
int to;
int weight;
};
// 优先队列中存储的节点状态
struct State {
int u;
int dist;
// 自定义比较函数,使其成为最小堆
bool operator>(const State& other) const {
return dist > other.dist;
}
};
void dijkstra(int start, int n, const std::vector<std::vector<Edge>>& adj) {
std::vector<int> dist(n + 1, INF);
std::vector<bool> visited(n + 1, false);
std::priority_queue<State, std::vector<State>, std::greater<State>> pq;
dist[start] = 0;
pq.push({start, 0});
while (!pq.empty()) {
State current = pq.top();
pq.pop();
int u = current.u;
// 如果已经处理过,则跳过
if (visited[u]) {
continue;
}
visited[u] = true;
// 松弛操作
for (const auto& edge : adj[u]) {
int v = edge.to;
int weight = edge.weight;
if (dist[u] != INF && dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
pq.push({v, dist[v]});
}
}
}
// 打印结果
std::cout << "Dijkstra: Shortest distances from source " << start << ":\n";
for (int i = 1; i <= n; ++i) {
if (dist[i] == INF) {
std::cout << "Node " << i << ": Infinity\n";
} else {
std::cout << "Node " << i << ": " << dist[i] << "\n";
}
}
}
int main() {
int n = 5, m = 7, start = 1;
std::vector<std::vector<Edge>> adj(n + 1);
// 添加边: from, to, weight
adj[1].push_back({2, 10});
adj[1].push_back({4, 5});
adj[2].push_back({3, 1});
adj[2].push_back({4, 2});
adj[3].push_back({5, 4});
adj[4].push_back({3, 9});
adj[4].push_back({5, 2});
dijkstra(start, n, adj);
return 0;
}
2. Bellman-Ford 算法
Bellman-Ford 算法也是解决单源最短路问题,但它的优势在于可以处理带负权边的图,并且能够检测出负权环。
-
核心思想:
算法基于动态规划的思想。它对图中的所有边进行V-1轮松弛操作。- 初始化源点
s的距离为 0,其他所有点为无穷大。 - 进行
V-1轮迭代。在每一轮中,遍历图中的所有边(u, v),并进行松弛操作:如果dist[u] + weight(u, v) < dist[v],则更新dist[v]。 V-1轮后,如果图中不存在负权环,那么所有顶点的最短路径就已经计算出来了。- 为了检测负权环,再进行第
V轮松弛。如果在这一轮中,仍然有边的距离可以被更新,说明图中存在一个从源点可达的负权环。
- 初始化源点
-
贪心策略分析:
否,Bellman-Ford 不是贪心算法。
它没有在每一步做出局部最优的选择。相反,它是一种非常“暴力”或者说“系统性”的方法。它通过V-1次迭代,系统地计算出所有最多经过1条边、2条边、…、直到V-1条边的最短路径。它的思想更接近于动态规划,dp[k][v]表示从源点出发最多经过k条边到达v的最短路径。 -
C++ 代码实现
时间复杂度为 O(V⋅E)O(V \cdot E)O(V⋅E)。
#include <iostream>
#include <vector>
#include <limits>
const int INF = std::numeric_limits<int>::max();
struct Edge {
int from;
int to;
int weight;
};
void bellman_ford(int start, int n, int m, const std::vector<Edge>& edges) {
std::vector<int> dist(n + 1, INF);
dist[start] = 0;
// V-1 轮松弛
for (int i = 0; i < n - 1; ++i) {
for (const auto& edge : edges) {
if (dist[edge.from] != INF && dist[edge.from] + edge.weight < dist[edge.to]) {
dist[edge.to] = dist[edge.from] + edge.weight;
}
}
}
// 检测负权环
bool has_negative_cycle = false;
for (const auto& edge : edges) {
if (dist[edge.from] != INF && dist[edge.from] + edge.weight < dist[edge.to]) {
has_negative_cycle = true;
break;
}
}
if (has_negative_cycle) {
std::cout << "Bellman-Ford: Graph contains a negative-weight cycle.\n";
} else {
std::cout << "Bellman-Ford: Shortest distances from source " << start << ":\n";
for (int i = 1; i <= n; ++i) {
if (dist[i] == INF) {
std::cout << "Node " << i << ": Infinity\n";
} else {
std::cout << "Node " << i << ": " << dist[i] << "\n";
}
}
}
}
int main() {
int n = 5, m = 8, start = 1;
std::vector<Edge> edges = {
{1, 2, 6}, {1, 3, 7}, {2, 4, 5}, {2, 5, -4},
{3, 2, 8}, {3, 4, -3}, {4, 5, 9}, {5, 2, 2}
};
// 示例负权环
// std::vector<Edge> edges_with_cycle = {
// {1, 2, 1}, {2, 3, 1}, {3, 1, -3}
// };
bellman_ford(start, n, m, edges);
return 0;
}
3. SPFA 算法 (Shortest Path Faster Algorithm)
SPFA 是 Bellman-Ford 算法的队列优化版本,在很多情况下(特别是稀疏图)比 Bellman-Ford 更快。它同样可以处理负权边和检测负权环。
-
核心思想:
Bellman-Ford 每一轮都盲目地松弛所有边。SPFA 观察到,只有当一个点u的dist值变小时,才可能引起其邻居v的dist值变小。因此,SPFA 使用一个队列来维护那些dist值刚刚被更新过的顶点。- 将源点
s入队。 - 当队列不为空时,出队一个顶点
u。 - 对
u的所有邻居v进行松弛操作。 - 如果
v的dist值被更新了,并且v不在队列中,则将v入队。 - 为了检测负权环,可以记录每个顶点入队的次数。如果一个顶点入队次数超过
V次,则说明存在负权环。
- 将源点
-
贪心策略分析:
否,SPFA 也不是贪心算法。
它处理顶点的顺序是由队列的先进先出(FIFO)特性决定的,而不是基于任何局部最优的属性(如距离大小)。它是一种动态的、基于传播的松弛方法。 -
C++ 代码实现
平均时间复杂度为 O(kE)O(kE)O(kE),其中k是一个小的常数。但在最坏情况下(例如在特殊构造的网格图中),会退化到 O(V⋅E)O(V \cdot E)O(V⋅E)。
#include <iostream>
#include <vector>
#include <queue>
#include <limits>
const int INF = std::numeric_limits<int>::max();
struct Edge {
int to;
int weight;
};
bool spfa(int start, int n, const std::vector<std::vector<Edge>>& adj) {
std::vector<int> dist(n + 1, INF);
std::vector<bool> in_queue(n + 1, false);
std::vector<int> count(n + 1, 0); // 记录入队次数
std::queue<int> q;
dist[start] = 0;
q.push(start);
in_queue[start] = true;
count[start]++;
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& edge : adj[u]) {
int v = edge.to;
int weight = edge.weight;
if (dist[u] != INF && dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
if (!in_queue[v]) {
q.push(v);
in_queue[v] = true;
count[v]++;
if (count[v] > n) {
std::cout << "SPFA: Graph contains a negative-weight cycle.\n";
return false; // 发现负权环
}
}
}
}
}
std::cout << "SPFA: Shortest distances from source " << start << ":\n";
for (int i = 1; i <= n; ++i) {
if (dist[i] == INF) {
std::cout << "Node " << i << ": Infinity\n";
} else {
std::cout << "Node " << i << ": " << dist[i] << "\n";
}
}
return true;
}
int main() {
int n = 5, start = 1;
std::vector<std::vector<Edge>> adj(n + 1);
adj[1].push_back({2, 6});
adj[1].push_back({3, 7});
adj[2].push_back({4, 5});
adj[2].push_back({5, -4});
adj[3].push_back({2, 8});
adj[3].push_back({4, -3});
adj[4].push_back({5, 9});
adj[5].push_back({2, 2});
spfa(start, n, adj);
return 0;
}
4. Floyd-Warshall 算法
Floyd-Warshall 算法用于解决所有顶点对最短路问题。它可以处理带负权边的图,但不能处理负权环(如果存在负权环,算法可以检测出来)。
-
核心思想:
这是一个非常经典的动态规划算法。它使用一个二维数组dp[i][j]来存储从顶点i到j的最短路径长度。算法的核心思想是:对于每一对顶点(i, j),我们尝试通过一个中间点k来缩短它们的路径。- 初始化
dp[i][j]为i到j的直接边权(如果无直连边则为无穷大),dp[i][i]为 0。 - 进行
V轮迭代,k从 1 到V。 - 在第
k轮迭代中,对于所有的顶点对(i, j),检查dp[i][k] + dp[k][j]是否小于dp[i][j]。如果是,则更新dp[i][j]。
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]) dp[i][j] = \min(dp[i][j], dp[i][k] + dp[k][j]) dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]) - 所有迭代结束后,
dp[i][j]就存储了i到j的最短路径。如果dp[i][i] < 0,则说明存在从i出发的负权环。
- 初始化
-
贪心策略分析:
否,Floyd-Warshall 是动态规划算法,不是贪心算法。
它没有做出任何局部最优选择。相反,它通过引入中间点k,系统地、由简到繁地构建出最终解。dp[k][i][j]的含义是“只允许经过前k个点作为中间点时,从i到j的最短路径”,这是典型的动态规划状态转移。 -
C++ 代码实现
时间复杂度为 O(V3)O(V^3)O(V3)。
#include <iostream>
#include <vector>
#include <algorithm>
const int INF = 1e9; // 使用一个较大的数表示无穷大
void floyd_warshall(int n, std::vector<std::vector<int>>& dist) {
// 核心 DP 过程
for (int k = 1; k <= n; ++k) {
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
if (dist[i][k] != INF && dist[k][j] != INF) {
dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
// 打印结果
std::cout << "Floyd-Warshall: All-Pairs Shortest Paths:\n";
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
if (dist[i][j] == INF) {
std::cout << "INF\t";
} else {
std::cout << dist[i][j] << "\t";
}
}
std::cout << "\n";
}
}
int main() {
int n = 4;
std::vector<std::vector<int>> dist(n + 1, std::vector<int>(n + 1, INF));
// 初始化邻接矩阵
for (int i = 1; i <= n; ++i) dist[i][i] = 0;
dist[1][2] = 3;
dist[1][4] = 5;
dist[2][1] = 2;
dist[2][4] = 4;
dist[3][2] = -2;
dist[4][3] = 1;
floyd_warshall(n, dist);
return 0;
}
Part 2: 最小生成树算法 (Minimum Spanning Tree Algorithms)
最小生成树(MST)的目标是在一个加权的无向连通图中,找出一个边的子集,这个子集连接了图中所有的顶点,且没有环,同时总权重最小。
1. Prim 算法
-
核心思想:
Prim 算法和 Dijkstra 算法非常相似。它也是从一个顶点开始,逐步扩大一棵树。- 选择任意一个顶点作为起始点,将其加入到生成树集合
T中。 - 维护一个
dist数组,dist[i]表示顶点i到集合T的最短边的权重。 - 重复以下步骤
V-1次:
a. 从所有不在T中的顶点里,找到dist值最小的顶点u。
b. 将u和连接它与T的那条最短边加入到生成树中。
c. 更新u的所有邻居v的dist值:如果u到v的边权小于当前的dist[v],则更新dist[v]。
- 选择任意一个顶点作为起始点,将其加入到生成树集合
-
贪心策略分析:
是,Prim 是一个典型的贪心算法。
它的贪心策略是:每一步都选择一条权重最小的边,这条边连接一个已在树中的顶点和一个未在树中的顶点。它总是选择“扩张”这棵树的“最便宜”的方式。这个贪心选择的正确性由图论中的“切割定理”(Cut Property)保证:对于图的任意一个切分(将顶点分为两部分),连接这两个部分的权重最小的边,必然属于图的某个最小生成树。Prim 算法的每一步选择都符合这个定理。 -
C++ 代码实现 (类似 Dijkstra 的优先队列优化)
时间复杂度为 O(ElogV)O(E \log V)O(ElogV)。
#include <iostream>
#include <vector>
#include <queue>
#include <limits>
const int INF = std::numeric_limits<int>::max();
// 边结构
struct Edge {
int to;
int weight;
};
// 优先队列中存储的状态
struct State {
int u;
int key; // 类似 dist,表示到树的最小边权
bool operator>(const State& other) const {
return key > other.key;
}
};
void prim(int n, const std::vector<std::vector<Edge>>& adj) {
std::vector<int> key(n + 1, INF);
std::vector<int> parent(n + 1, -1);
std::vector<bool> in_mst(n + 1, false);
std::priority_queue<State, std::vector<State>, std::greater<State>> pq;
int start_node = 1;
key[start_node] = 0;
pq.push({start_node, 0});
long long total_weight = 0;
while (!pq.empty()) {
State current = pq.top();
pq.pop();
int u = current.u;
if (in_mst[u]) {
continue;
}
in_mst[u] = true;
total_weight += current.key;
for (const auto& edge : adj[u]) {
int v = edge.to;
int weight = edge.weight;
if (!in_mst[v] && weight < key[v]) {
key[v] = weight;
parent[v] = u;
pq.push({v, key[v]});
}
}
}
std::cout << "Prim's Algorithm: Minimum Spanning Tree Weight = " << total_weight << "\n";
std::cout << "Edges in MST:\n";
for (int i = 2; i <= n; ++i) {
std::cout << parent[i] << " - " << i << "\n";
}
}
int main() {
int n = 5;
std::vector<std::vector<Edge>> adj(n + 1);
// 无向图,加两次边
auto add_edge = [&](int u, int v, int w) {
adj[u].push_back({v, w});
adj[v].push_back({u, w});
};
add_edge(1, 2, 2);
add_edge(1, 3, 6);
add_edge(2, 3, 1);
add_edge(2, 4, 5);
add_edge(3, 4, 8);
add_edge(3, 5, 7);
add_edge(4, 5, 9);
prim(n, adj);
return 0;
}
2. Kruskal 算法
-
核心思想:
Kruskal 算法的思路与 Prim 完全不同,它关注的是全局的边,而不是局部的点。- 将图中所有的边按权重从小到大排序。
- 创建一个森林,其中每个顶点自成一棵树。
- 遍历排序后的边。对于每一条边
(u, v),如果u和v不在同一棵树中(即加入这条边不会形成环),则将这条边加入到最小生成树中,并合并u和v所在的树。 - 重复此过程,直到加入了
V-1条边。
判断两个顶点是否在同一棵树中,通常使用**并查集(Disjoint Set Union, DSU)**数据结构来高效实现。
-
贪心策略分析:
是,Kruskal 也是一个经典的贪心算法。
它的贪心策略是:在所有可选的边中,永远选择权重最小的,并且保证这个选择不会破坏树的结构(即不形成环)。它总是从全局视角做出最“便宜”的选择。这个贪心策略的正确性同样可以由“切割定理”证明。每当算法选择一条边时,这条边必然是连接某个切分两部分的最小权重边。 -
C++ 代码实现 (使用并查集)
时间复杂度为 O(ElogE)O(E \log E)O(ElogE)(主要是排序的开销)。
#include <iostream>
#include <vector>
#include <algorithm>
struct Edge {
int from, to, weight;
};
bool compareEdges(const Edge& a, const Edge& b) {
return a.weight < b.weight;
}
// 并查集 (Disjoint Set Union)
struct DSU {
std::vector<int> parent;
DSU(int n) {
parent.resize(n + 1);
for (int i = 1; i <= n; ++i) parent[i] = i;
}
int find(int i) {
if (parent[i] == i) return i;
return parent[i] = find(parent[i]); // 路径压缩
}
void unite(int i, int j) {
int root_i = find(i);
int root_j = find(j);
if (root_i != root_j) {
parent[root_i] = root_j;
}
}
};
void kruskal(int n, std::vector<Edge>& edges) {
// 1. 排序所有边
std::sort(edges.begin(), edges.end(), compareEdges);
DSU dsu(n);
long long total_weight = 0;
std::vector<Edge> mst_edges;
// 2. 遍历边并构建MST
for (const auto& edge : edges) {
if (dsu.find(edge.from) != dsu.find(edge.to)) {
dsu.unite(edge.from, edge.to);
total_weight += edge.weight;
mst_edges.push_back(edge);
}
}
std::cout << "Kruskal's Algorithm: Minimum Spanning Tree Weight = " << total_weight << "\n";
std::cout << "Edges in MST:\n";
for (const auto& edge : mst_edges) {
std::cout << edge.from << " - " << edge.to << " (weight " << edge.weight << ")\n";
}
}
int main() {
int n = 5, m = 7;
std::vector<Edge> edges = {
{1, 2, 2}, {1, 3, 6}, {2, 3, 1}, {2, 4, 5},
{3, 4, 8}, {3, 5, 7}, {4, 5, 9}
};
kruskal(n, edges);
return 0;
}
总结
| 算法 | 解决问题 | 核心思想 | 时间复杂度 | 负权边 | 是否贪心 |
|---|---|---|---|---|---|
| Dijkstra | 单源最短路 | 选最近的点进行扩展 | O(ElogV)O(E \log V)O(ElogV) | 不支持 | 是 |
| Bellman-Ford | 单源最短路 | 对所有边进行V-1轮松弛 | O(V⋅E)O(V \cdot E)O(V⋅E) | 支持,可检负权环 | 否 (DP) |
| SPFA | 单源最短路 | Bellman-Ford的队列优化 | 平均O(kE)O(kE)O(kE),最坏O(V⋅E)O(V \cdot E)O(V⋅E) | 支持,可检负权环 | 否 |
| Floyd-Warshall | 所有顶点对最短路 | 动态规划,枚举中转点 | O(V3)O(V^3)O(V3) | 支持,不可有负权环 | 否 (DP) |
| Prim | 最小生成树 | 从一个点开始,逐步加点入树 | O(ElogV)O(E \log V)O(ElogV) | 支持 (无意义) | 是 |
| Kruskal | 最小生成树 | 按边权排序,逐步加边成树 | O(ElogE)O(E \log E)O(ElogE) | 支持 (无意义) | 是 |
注:对于最小生成树算法,负权边是“支持”的,因为其目标是总权重最小,负权边只会让这个总权重更小,不影响算法正确性。但在实际问题中,MST的边权通常表示成本、距离等非负概念。
更多推荐
所有评论(0)