Dijkstra 算法(迪杰斯特拉算法)是图论中最经典、最基础的单源最短路径算法之一。它由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)于 1956 年提出,用于解决带权有向图或无向图中,从一个固定源点(Source Vertex)到图中其他所有顶点的最短路径问题。
一、 算法目标
给定一个带权图 G=(V,E)G = (V, E)G=(V,E) 和一个源点 s∈Vs \in Vs∈V,Dijkstra 算法的目标是计算出从 sss 到图中每一个其他顶点 vvv 的最短路径长度(距离),并可选择性地记录下路径本身。
二、 核心思想:贪心策略 + 优先队列
Dijkstra 算法的核心思想是贪心策略(Greedy Strategy)。它维护一个集合 SSS,表示已确定最短路径的顶点集合。算法在每一步都选择一个距离源点最近的、尚未加入 SSS 的顶点 uuu,将其加入 SSS,然后松弛(Relax)所有从 uuu 出发的边,更新这些边所指向顶点的最短距离估计值。
关键概念
- 松弛 (Relaxation):这是最短路径算法中的核心操作。对于一条从顶点 uuu 到顶点 vvv 的边,权重为 w(u,v)w(u,v)w(u,v),如果通过 uuu 到达 vvv 的路径比目前已知的从源点 sss 到 vvv 的最短距离更短,则更新这个距离。
- 松弛操作:
if (dist[u] + w(u, v) < dist[v]) { dist[v] = dist[u] + w(u, v); predecessor[v] = u; // 更新前驱,用于重建路径 }
- 松弛操作:
- 贪心选择:每次选择当前
dist
值最小的未确定顶点。这个选择之所以是“贪心”的,是因为它在当前看来是局部最优的。Dijkstra 算法的正确性依赖于一个关键前提:图中不能有负权边。因为如果有负权边,即使当前dist[u]
最小,未来也可能通过一个负权边找到更短的路径,从而推翻之前的“最优”选择。
三、 算法步骤
1. 初始化:
- 创建一个距离数组
dist[]
,大小为 ∣V∣|V|∣V∣。- 对于所有顶点 vvv:
dist[v] = \infty
(表示初始时不可达)。
dist[s] = 0
(源点到自身的距离为 0)。- 创建一个集合 SSS(或布尔数组
visited[]
),初始为空,用于记录已确定最短路径的顶点。 - (可选)创建一个前驱数组
predecessor[]
,用于路径重建,初始为-1
或nullptr
。
- 对于所有顶点 vvv:
2. 主循环:
- 当集合 SSS 的大小小于 ∣V∣|V|∣V∣ 时(即还有顶点未确定最短路径):
a. 选择:从未加入 SSS 的顶点中,选择dist[u]
值最小的顶点 uuu。
b. 加入集合:将顶点 uuu 加入集合 SSS(标记为已确定)。
c. 松弛:遍历所有从 uuu 出发的边 (u,v)(u, v)(u,v):- 如果 vvv 不在 SSS 中,执行松弛操作:
if (dist[u] + weight(u, v) < dist[v])
dist[v] = dist[u] + weight(u, v)
predecessor[v] = u
- 如果 vvv 不在 SSS 中,执行松弛操作:
3. 路径重建:
- 使用
predecessor[]
数组,从目标顶点 ttt 开始,不断回溯其前驱,直到回到源点 sss,即可得到从 sss 到 ttt 的最短路径。
4. 数据结构的选择与复杂度
Dijkstra 算法的效率高度依赖于如何实现“选择
dist
值最小的未确定顶点”这一步骤。
-
方法一:朴素实现(数组/线性扫描)
- 数据结构:使用一个数组存储
dist
值,每次扫描整个数组找到最小值。 - 时间复杂度:
- 选择最小值:O(V)O(V)O(V),共 VVV 次,总计 O(V2)O(V^2)O(V2)。
- 松弛操作:每条边最多被松弛一次,O(E)O(E)O(E)。
- 总时间复杂度:O(V2+E)O(V^2 + E)O(V2+E)。对于稠密图 (E≈V2E \approx V^2E≈V2),约为 O(V2)O(V^2)O(V2)。
- 空间复杂度:O(V)O(V)O(V)。
- 数据结构:使用一个数组存储
-
方法二:优先队列优化(最小堆)
- 数据结构:使用一个最小堆(优先队列),存储
(距离, 顶点)
对。堆顶总是距离最小的顶点。 - 操作:
- 初始化:将
(0, s)
加入堆。 - 选择:从堆顶取出元素(
O(log V)
)。 - 松弛:当更新
dist[v]
时,将新的(dist[v], v)
加入堆。注意:同一个顶点 vvv 可能有多个不同的距离值在堆中。当从堆中取出一个顶点 uuu 时,需要检查其距离值是否与当前dist[u]
一致(即是否是过时的值),如果一致才处理,否则忽略。
- 初始化:将
- 时间复杂度:
- 堆操作(插入和删除):O(logV)O(\log V)O(logV)。
- 每条边可能触发一次堆插入(松弛成功时),最多 EEE 次插入。
- 每个顶点最多被删除一次,VVV 次删除。
- 总时间复杂度:O((V+E)logV)O((V + E) \log V)O((V+E)logV)。对于稀疏图 (E≪V2E \ll V^2E≪V2),这比 O(V2)O(V^2)O(V2) 更优。
- 空间复杂度:O(V+E)O(V + E)O(V+E)(堆中最多可能有 EEE 个元素)。
- 数据结构:使用一个最小堆(优先队列),存储
5. 算法前提与限制
- 前提:图中所有边的权重必须为非负数(即 ≥0\geq 0≥0)。这是 Dijkstra 算法正确性的基石。如果存在负权边,贪心策略失效,算法可能得到错误结果。
- 优点:
- 算法思想直观,易于理解。
- 优先队列优化后,在稀疏图上效率较高。
- 一旦某个顶点被加入 SSS,其最短距离就确定了,后续不会改变(这是非负权边保证的)。
- 缺点:
- 不能处理负权边。
- 朴素实现对于稠密图效率尚可,但对于稀疏图不如优先队列优化版本。
- 优先队列优化版本实现稍复杂,且堆中可能有重复顶点。
6. 与其他算法的比较
- vs Bellman-Ford:Dijkstra 更快(O((V+E)logV)O((V+E)\log V)O((V+E)logV) vs O(VE)O(VE)O(VE)),但 Bellman-Ford 可以处理负权边并检测负权环。
- vs Floyd-Warshall:Dijkstra 是单源算法,Floyd 是全源算法。如果只需要单源最短路径,Dijkstra 通常比 Floyd (O(V3)O(V^3)O(V3)) 更高效。
四、 C++ 实现
我们将实现两种版本:优先队列优化版 和 朴素数组版。
版本一:优先队列优化版
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
#include <algorithm>
#include <stack>
using namespace std;
// 常量:表示无穷大(不可达)
const int INF = INT_MAX;
// 定义图的邻接表表示
// adj[u] 是一个 vector<pair<int, int>>,存储 (邻居顶点v, 边权w(u,v))
using Graph = vector<vector<pair<int, int>>>;
class Dijkstra {
private:
int n; // 顶点数量
Graph adj; // 邻接表
vector<int> dist; // 距离数组
vector<int> predecessor; // 前驱数组,用于路径重建
bool computed; // 标记是否已计算过
public:
// 构造函数
Dijkstra(int vertices) : n(vertices), adj(vertices), computed(false) {}
// 添加有向边
void addEdge(int from, int to, int weight) {
adj[from].push_back({to, weight});
// 对于无向图,还需添加反向边
// adj[to].push_back({from, weight});
}
// 执行 Dijkstra 算法,从源点 source 开始
void computeShortestPaths(int source) {
// 初始化
dist.assign(n, INF);
predecessor.assign(n, -1); // -1 表示无前驱
dist[source] = 0;
computed = true;
// 最小堆:存储 (距离, 顶点)
// 注意:priority_queue 默认是最大堆,所以用 greater 使其变为最小堆
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
pq.push({0, source});
while (!pq.empty()) {
auto [d, u] = pq.top(); // C++17 结构化绑定
pq.pop();
// 跳过过时的条目(懒惰删除)
if (d != dist[u]) {
continue;
}
// 遍历所有从 u 出发的边
for (auto &[v, w] : adj[u]) { // C++17 结构化绑定
// 尝试通过 u 松弛到 v 的路径
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
predecessor[v] = u;
pq.push({dist[v], v}); // 将新距离加入堆
}
}
}
}
// 查询从源点到顶点 v 的最短距离
int getDistance(int v) const {
if (!computed) {
cout << "请先调用 computeShortestPaths()!" << endl;
return INF;
}
return dist[v];
}
// 获取从源点到顶点 v 的最短路径(顶点序列)
vector<int> getPath(int v) const {
if (!computed) {
cout << "请先调用 computeShortestPaths()!" << endl;
return {};
}
if (dist[v] == INF) {
cout << "从源点到 " << v << " 不可达。" << endl;
return {};
}
vector<int> path;
// 从 v 开始,通过前驱数组回溯到源点
for (int current = v; current != -1; current = predecessor[current]) {
path.push_back(current);
}
// 回溯得到的路径是逆序的,需要反转
reverse(path.begin(), path.end());
return path;
}
// 打印从源点到所有顶点的最短距离和路径
void printAllResults(int source) const {
if (!computed) {
cout << "请先调用 computeShortestPaths()!" << endl;
return;
}
cout << "从源点 " << source << " 出发的最短路径结果:" << endl;
for (int v = 0; v < n; ++v) {
cout << "到顶点 " << v << ": ";
if (dist[v] == INF) {
cout << "不可达" << endl;
} else {
cout << "距离 = " << dist[v] << ", 路径: ";
auto path = getPath(v);
for (size_t i = 0; i < path.size(); ++i) {
cout << path[i];
if (i < path.size() - 1) cout << " -> ";
}
cout << endl;
}
}
}
};
版本二:朴素数组实现版
class DijkstraNaive {
private:
int n;
vector<vector<int>> adjMatrix; // 邻接矩阵,adjMatrix[u][v] = 边权,无边则为 INF
vector<int> dist;
vector<int> predecessor;
vector<bool> visited; // visited[u] = true 表示 u 已确定最短路径
bool computed;
public:
DijkstraNaive(int vertices) : n(vertices), computed(false) {
// 初始化邻接矩阵
adjMatrix.assign(n, vector<int>(n, INF));
for (int i = 0; i < n; ++i) {
adjMatrix[i][i] = 0;
}
}
void addEdge(int from, int to, int weight) {
adjMatrix[from][to] = weight;
// 无向图:adjMatrix[to][from] = weight;
}
void computeShortestPaths(int source) {
dist.assign(n, INF);
predecessor.assign(n, -1);
visited.assign(n, false);
dist[source] = 0;
computed = true;
for (int iter = 0; iter < n; ++iter) { // 进行 n 次迭代
// 1. 找到未访问顶点中 dist 最小的
int u = -1;
int minDist = INF;
for (int v = 0; v < n; ++v) {
if (!visited[v] && dist[v] < minDist) {
minDist = dist[v];
u = v;
}
}
// 如果找不到,说明剩余顶点均不可达,跳出
if (u == -1) break;
// 2. 将 u 标记为已访问(已确定)
visited[u] = true;
// 3. 松弛所有从 u 出发的边
for (int v = 0; v < n; ++v) {
if (!visited[v] && adjMatrix[u][v] != INF) { // v 未确定 且 存在边 u->v
if (dist[u] + adjMatrix[u][v] < dist[v]) {
dist[v] = dist[u] + adjMatrix[u][v];
predecessor[v] = u;
}
}
}
}
}
// 其他查询函数与优先队列版本类似,此处省略...
// (为节省篇幅,省略了 getDistance, getPath, printAllResults)
};
测试函数
// 测试函数
int main() {
// 示例:一个简单的正权有向图
cout << "=== Dijkstra 算法测试 ===" << endl;
Dijkstra dijkstra(6); // 6 个顶点:0, 1, 2, 3, 4, 5
// 添加边 (from, to, weight)
dijkstra.addEdge(0, 1, 4);
dijkstra.addEdge(0, 2, 2);
dijkstra.addEdge(1, 2, 1);
dijkstra.addEdge(1, 3, 5);
dijkstra.addEdge(2, 3, 8);
dijkstra.addEdge(2, 4, 10);
dijkstra.addEdge(3, 4, 2);
dijkstra.addEdge(3, 5, 6);
dijkstra.addEdge(4, 5, 3);
int source = 0;
dijkstra.computeShortestPaths(source);
dijkstra.printAllResults(source);
// 测试特定路径
int target = 5;
auto path = dijkstra.getPath(target);
cout << "\n从 " << source << " 到 " << target << " 的路径: ";
for (size_t i = 0; i < path.size(); ++i) {
cout << path[i];
if (i < path.size() - 1) cout << " -> ";
}
cout << " (距离: " << dijkstra.getDistance(target) << ")" << endl;
return 0;
}
代码说明
Graph
类型:使用vector<vector<pair<int, int>>>
表示邻接表,高效且节省空间。priority_queue
:使用greater<pair<int, int>>
使其成为最小堆。pair
的第一个元素是距离,用于堆的排序。- 懒惰删除 (Lazy Deletion):这是关键技巧。当更新
dist[v]
时,我们不从堆中删除旧的(old_dist, v)
,而是直接插入新的(new_dist, v)
。当从堆顶取出一个元素(d, u)
时,检查d
是否等于当前的dist[u]
。如果不等,说明这个条目已经过时(dist[u]
已被更新为更小的值),直接忽略它。 - 路径重建:使用
predecessor
数组回溯,并将结果反转得到正确顺序。 - C++17 特性:代码中使用了结构化绑定 (
auto [d, u] = ...
),使代码更简洁。如果编译器不支持,可以用int d = pq.top().first; int u = pq.top().second;
替代。
五、总结
Dijkstra 算法是解决非负权图单源最短路径问题的基石。其贪心策略保证了在非负权边条件下,一旦顶点被确定,其最短距离就不再改变。优先队列优化使其在稀疏图上具有优秀的 O((V+E)logV)O((V+E)\log V)O((V+E)logV) 时间复杂度。理解其松弛操作、贪心选择以及优先队列的“懒惰删除”技巧,对于掌握该算法至关重要。