单源最短路径
单源最短路径(Single-Source Shortest Path,SSSP)问题,即给定一个图,求从单一起点到任意给定终点的最短路径
单位权图
即各个边的权重均为 1,此时只要使用 BFS 即可
void bfs(int start) {
queue<int> q;
q.push(start);
dist[start] = 0; // 起点距离为 0
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) { // 未访问过才更新!
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
}
这个代码对无向图和有向图均可适用
最终 dist 数组中存储的就是从 start 到各个节点的最短路径长度
如果想要得到路径可以再使用一个 parent 前驱数组来记录,最终使用回溯再反转得到
void bfs(int start) {
queue<int> q;
q.push(start);
dist[start] = 0;
parent[start] = -1; // 起点无前驱,设为 -1
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
parent[v] = u; // 记录v的前驱是u
q.push(v);
}
}
}
}
vector<int> get_shortest_path(int start, int end) {
vector<int> path;
if (dist[end] == -1) {
return path; // 不可达,返回空路径
}
for (int cur = end; cur != -1; cur = parent[cur]) {
path.push_back(cur); // 从 end 回溯到 start
}
reverse(path.begin(), path.end()); // 反转得到正序
return path;
}
正权图
权重的引入需要新的数据结构进行存储,这里可以修改邻接表,将边的信息从 v 改为 pair<int, int>,其中第一个元素为 v,第二个元素为 w,表示从 u 到 v 的权重为 w
typedef pair<int, int> pii; // 简化
vector adj(n+1, vector<pii>());
cin >> u >> v >> w;
adj[u].emplace_back(v,w);
当引入权重之后,BFS 就不再适用了,而当权重为正时可以使用 Dijkstra 算法
Dijkstra 算法
Dijkstra 算法的基本思想是贪心,每次选择当前距离源点最近且未被访问的顶点,以该顶点为中介更新其邻接顶点到源点的距离,重复此过程直到所有顶点都被访问
朴素 Dijkstra
void native_dijkstra(const vector<vector<pii>> &adj, vector<int> &dist)
{
// fill(dist.begin(), dist.end(), INF); // 或保证外部实现
dist[start] = 0;
vector visited(n+1, false);
for (int i = 0; i < n; i++)
{
int u = -1; // 寻未访问且距离最小者
for (int j = 1; j <= n; j++)
{
if (!visited[j] && (u == -1 || dist[j] < dist[u]))
{
u = j;
}
}
if (u == -1 || dist[u] == INF) break; // 找不到点或剩下的点均不可达
visited[u] = true;
for (const auto [v, w] : adj[u])
{
if (!visited[v] && dist[v] > dist[u] + w)
{
dist[v] = dist[u] + w;
}
}
}
}
- 时间复杂度:\(O(n^2)\)
堆优化 Dijkstra
当然,找最小距离这一步可以使用小根堆进行优化
void dijkstra(const vector<vector<pii>> &adj, vector<int> &dist)
{
// fill(dist.begin(), dist.end(), INF);
// 权重,节点
priority_queue<pii, vector<pii>, greater<>> pq;
pq.emplace(0,start);
dist[start] = 0;
while (!pq.empty())
{
auto [curW, u] = pq.top();
pq.pop();
// 剪枝:跳过旧数据
if (curW > dist[u]) continue;
for (const auto [v, w] : adj[u])
{
if (dist[v] > dist[u] + w) // 松弛
{
dist[v] = dist[u] + w;
pq.emplace(dist[v], v);
}
}
}
}
- 时间复杂度:\(O((n+m)\log n)\)
朴素 VS 堆优化
即 \(O(n^2)\) VS \(O((n+m)\log n)\),其中 \(m\) 为边数
- 当图为稠密图时,即 \(m\) 接近 \(n^2\),朴素算法更优,少了一个 \(\log n\)
- 当图为稀疏图时,即 \(m\) 远小于 \(n^2\),堆优化更优
既然都稠密图了,一般用邻接矩阵了
void native_dijkstra(const vector<vector<int>> &adj, vector<int> &dist)
{
// fill(dist.begin(), dist.end(), INF);
dist[s] = 0;
vector visited(n+1, false);
for (int i = 0; i < n; i++)
{
int u = -1; // 寻未访问且距离最小者
for (int j = 1; j <= n; j++)
{
if (!visited[j] && (u == -1 || dist[j] < dist[u]))
{
u = j;
}
}
if (u == -1 || dist[u] == INF) break; // 找不到点或剩下的点均不可达
visited[u] = true;
for (int v = 1; v <= n; v++)
{
// 用邻接矩阵需要特别判断是否可达 adj[u][v] != INF
if (!visited[v] && adj[u][v] != INF && dist[v] > dist[u] + adj[u][v])
{
dist[v] = dist[u] + adj[u][v];
}
}
}
}
无向图和有向图都可以用 Dijkstra 算法
负权图
当边的权重存在为负时,Dijkstra 算法将不再适用,因为该算法通过不断扩大 “已知最短路径节点” 的集合来工作
在负权图中一般默认有向,否则只要有一条边为负权,就存在负权环,此时无法确定最短路径
Bellman-Ford 算法
在一个没有负权环的图中,任意两点间的最短路径不可能包含回路。因此,一条最短路径最多包含 \(n\) 个顶点,即最多包含 \(n-1\) 条边
重复 “对所有边进行松弛” 这一操作 \(n-1\) 次,足以覆盖所有可能的最短路径:第 1 轮循环结束后,算法保证找到了所有“从源点出发,最多经过 1 条边”能到达的节点的最短路径。第 \(k\) 轮循环结束后,保证找到了 “最多经过 \(k\) 条边” 的最短路径
另外的,Bellman-Ford 的核心操作是 “遍历所有边”,而邻接表的设计目标是 “按顶点快速找其关联的边”,两者的需求不匹配,故实现上一般直接使用边列表
constexpr long long INF = 1e18; // 不到顶可以防止溢出
struct Edge { int u, v, w; };
int n, m, s;
bool bellman_ford(const vector<Edge>& edges, vector<long long>& dist)
{
// fill(dist.begin(), dist.end(), INF);
dist[s] = 0;
// 重复 n-1 次松弛操作
for (int i = 0; i < n - 1; i++)
{
bool any_update = false; // 如果一轮中没有任何更新,可以提前结束
for (const auto& [u, v, w] : edges)
{
// 如果 u 可达且通过 u 到 v 更近
if (dist[u] != INF && dist[v] > dist[u] + w)
{
dist[v] = dist[u] + w;
any_update = true;
}
}
if (!any_update) break;
}
// 如果在 n-1 轮之后还能进行松弛,说明存在负权环
for (const auto& [u, v, w] : edges)
{
if (dist[u] != INF && dist[v] > dist[u] + w)
{
return false; // 存在负权环
}
}
return true;
}
- 时间复杂度:\(O(n(m+n))\)
SPFA 算法*
SPFA (Shortest Path Faster Algorithm) 算法是对 Bellman-Ford 算法的优化,标准的 Bellman-Ford 每一轮都盲目地遍历所有边。但实际上,只有 “起点的距离刚刚被更新过” 的边,才有可能引起终点距离的更新。SPFA 使用一个队列来只处理这些 “值得处理” 的节点,从而大幅减少无效计算
void spfa(vector<vector<pair<int, int>>>& adj, vector<long long>& dist)
{
// fill(dist.begin(), dist.end(), INF);
dist[s] = 0;
queue<int> q;
q.push(s);
vector in_queue(n + 1, false); // 标记是否在队列里
in_queue[s] = true;
while (!q.empty())
{
int u = q.front();
q.pop();
in_queue[u] = false;
// 核心:只遍历 u 的出边,这依然是松弛操作
for (const auto& [v, w] : adj[u])
{
if (dist[v] > dist[u] + w)
{
dist[v] = dist[u] + w;
if (!in_queue[v]) // 避免重复处理
{
q.push(v);
in_queue[v] = true;
}
}
}
}
}
- 时间复杂度:
- 平均 \(O(km)\),其中 \(k\) 是常数,通常在 2 到 3 之间
- 最坏 \(O(nm)\)
SPFA 同样可以判断有无负环:维护一个数组 cnt[x],表示从源点到节点 x 的最短路径当前包含的边数
- 初始化:整个 cnt 数组为 0
- 更新逻辑:当发生松弛操作
dist[v] > dist[u] + w时,同时更新cnt[v] = cnt[u] + 1 - 判定条件:如果某个时刻
cnt[v] >= n,说明路径上至少有 \(n+1\) 个点,根据抽屉原理,必然存在重复节点(即存在环),返回 true(发现负环)
bool spfa(vector<vector<pair<int, int>>>& adj, vector<long long>& dist)
{
// fill(dist.begin(), dist.end(), INF);
queue<int> q;
vector in_queue(n + 1, false); // 标记是否在队列里
vector cnt(n + 1, 0); // 最短路上的边数
// 场景 A:只判断从 s 出发能否到达负环(标准最短路)
dist[s] = 0;
q.push(s);
in_queue[s] = true;
// 场景 B:判断全图是否存在负环(无论是否连通)
/* for (int i = 1; i <= n; i++) {
dist[i] = 0;
q.push(i);
in_queue[i] = true;
}*/
while (!q.empty())
{
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& [v, w] : adj[u])
{
if (dist[v] > dist[u] + w)
{
dist[v] = dist[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) return false;
if (!in_queue[v]) // 避免重复处理
{
q.push(v);
in_queue[v] = true;
}
}
}
}
return true;
}
虽然 SPFA 在随机图上表现很好,但在精心构造的网格图或菊花图上,SPFA 的复杂度会退化成 \(O(nm)\),和朴素 Bellman-Ford 一样慢
建议: 只要没有负权边,永远优先用 Dijkstra。只有在必须处理负权边或判负环时,才用 SPFA
特例:有向无环负权图
在 DAG 中,每条路径,也就是每条最短路径,都是拓扑序中的子序列,故可以按照拓扑序进行动态规划
最短路题目没找到,最长路倒是有。也很自然地想到,从最短路到最长路只需将初始化 INF 改从 -INF,松弛操作的判断符号改变(即取 max)即可
拓扑排序 + 动态规划
void DAGSSSP(int s, vector<int> in, vector<int> &dist, vector<vector<tuple<int,int>>> &adj)
{
dist[s] = 0; // 只有起点的距离设置为 0
queue<int> q;
for (int i = 0; i < n; i++)
{
if (in[i] == 0)
{
q.push(i);
}
}
while (!q.empty())
{
const auto u = q.front(); q.pop();
for (auto [v ,w] : adj[u])
{
if (dist[u] != INF && dist[v] > dist[u] + w)
{
dist[v] = dist[u] + w;
}
if (--in[v] == 0) q.push(v);
}
}
}
btw,判断负环也可使用拓扑排序,若最终有入度不为 0 的节点则存在负环
- 时间复杂度:\(O(n+m)\)
其他
标题:单源最短路径
作者:Zwing
创建于:2025-12-01 08:20:25
更新于:2026-08-07 16:17:52
链接:https://zanytriumph.github.io/posts/单源最短路径.html
版权声明:本文章采用 CC BY-NC-SA 4.0 进行许可