Dijkstra 算法入门笔记 (适用于算法竞赛初学者) - C++ 代码版
目录
- 算法是做什么的?
- 核心思想:贪就完事了!
- 算法前提:不能有负权边!
- 需要哪些工具?(数据结构)
- 算法具体步骤
- 关键操作:松弛 (Relaxation)
- 两种实现方式 (C++ 代码)
- 朴素版 Dijkstra (O(V^2))
- 堆优化版 Dijkstra (O(E log V))【竞赛常用】
- 复杂度分析
- 注意事项与常见问题
- 练习建议
- 总结
1. 算法是做什么的?
Dijkstra (迪杰斯特拉) 算法主要用于解决 单源最短路径 问题。
- 单源 (Single Source): 指的是从图中的 一个 指定的起始点(源点)出发。
- 最短路径 (Shortest Path): 找到从这个源点到图中 所有 其他可达点的路径中,权重(或距离、成本)之和最小的那条路径。
- 图 (Graph): 算法作用于带权重的有向图或无向图。
简单说: 给你一张地图(图),上面有各个地点(顶点)以及地点之间的道路长度(边的权重),Dijkstra 算法能帮你快速找到从你的出发点到其他所有地方的最短路线。
2. 核心思想:贪就完事了!
Dijkstra 算法的核心思想是 贪心策略。
- 初始化: 选定一个源点
s
。我们用一个数组dist[v]
记录源点s
到顶点v
的 当前已知最短距离。一开始,dist[s] = 0
,其他所有dist[v] = ∞
(表示暂时不可达)。 - 迭代: 每次从 还没有确定最终最短路径 的顶点中,选择一个
dist
值最小的顶点u
。 - 确定与扩展: 这个
u
的dist[u]
值就是源点s
到u
的 最终最短距离 (因为没有负权边,后面不可能有更短的路绕回来了)。然后,我们利用u
来 更新 (或者叫 松弛) 与它相邻的顶点的dist
值。如果通过u
到达其邻居v
的路径 (dist[u] + weight(u, v)
) 比当前已知的dist[v]
更短,就更新dist[v]
。 - 重复: 重复步骤 2 和 3,直到所有顶点都被选中(或者所有可达顶点都被选中)。
形象比喻: 想象源点像一个火源开始燃烧,火势(最短路径)总是先蔓延到最近的地方,然后从这些已燃烧的地方继续向外蔓延。
3. 算法前提:不能有负权边!
极其重要: Dijkstra 算法 不能 正确处理带有 负权重边 的图。
- 原因: 算法的贪心策略基于一个假设:一旦一个顶点的最短路径被确定,它就是最终的了。但如果存在负权边,后面可能通过一个负权边“绕回来”,使得已确定“最短路径”的点有了更短的路径,这就破坏了贪心选择的基础。
- 如果遇到负权边怎么办? 需要使用其他算法,例如 Bellman-Ford 算法或 SPFA 算法 (SPFA 在某些情况下可能被特殊数据卡住,Bellman-Ford 更稳健但效率较低)。
4. 需要哪些工具?(数据结构)
实现 Dijkstra 算法通常需要以下数据结构:
- 图的表示:
- 邻接矩阵 (Adjacency Matrix):
g[i][j]
存储从顶点i
到顶点j
的边的权重。简单直观,但对于稀疏图(边数远小于点数的平方)空间浪费大。 - 邻接表 (Adjacency List):
vector<pair<int, int>> adj[N]
或类似的结构,adj[u]
存储从u
出发的所有边,每条边表示为{v, weight}
(到达顶点v
,权重为weight
)。竞赛中最常用,节省空间。
- 邻接矩阵 (Adjacency Matrix):
- 距离数组
dist[N]
:dist[i]
存储源点到顶点i
的当前最短距离。初始化为无穷大(一个足够大的数,如0x3f3f3f3f
在 C++ 中常用,防止加法溢出),源点dist[source] = 0
。 注意使用long long
防止溢出! - 标记数组
visited[N]
或st[N]
(状态 state):visited[i]
标记顶点i
是否已经找到了最终的最短路径(即是否已经被选为上文步骤 2 中的u
)。初始化为false
。 (朴素版必需,堆优化版可选) - (堆优化版需要) 优先队列 (Priority Queue): 用于快速找到
dist
值最小的未访问顶点。通常存储自定义结构体State{node, dist}
或pair<long long, int>
,表示{distance, vertex}
。注意 C++std::priority_queue
默认是大顶堆,我们需要小顶堆(存储负距离或者自定义比较器,或使用std::greater
)。
5. 算法具体步骤
以源点 s
为例:
- 初始化:
dist
数组所有元素设为无穷大 (LINF
)。dist[s] = 0
。visited
数组所有元素设为false
(如果使用)。- (堆优化版) 将
{s, 0}
(节点, 距离) 压入优先队列pq
。
- 循环 (朴素版 V 次,堆优化版直到队列为空):
- a. 找到下一个顶点
u
:- 朴素版: 在所有
visited[i] == false
的顶点i
中,找到dist[i]
最小的那个顶点u
。 - 堆优化版: 从优先队列
pq
中取出堆顶元素{u, d}
(当前距离d
最小的顶点u
)。检查d > dist[u]
,如果是,则跳过 (旧状态)。
- 朴素版: 在所有
- b. 标记 (可选): 将
visited[u]
设为true
(朴素版必需)。 - c. 松弛
u
的邻居: 遍历所有从u
出发的边(u, v)
,其权重为w
:- 如果
dist[u] + w < dist[v]
,那么:- 更新
dist[v] = dist[u] + w
。 - (堆优化版) 将新的、更短的距离信息
{v, dist[v]}
压入优先队列pq
。
- 更新
- 如果
- a. 找到下一个顶点
- 结束: 循环结束后,
dist
数组中就存储了源点s
到所有可达顶点的最短距离。如果某个dist[i]
仍然是LINF
,表示从s
无法到达i
。
6. 关键操作:松弛 (Relaxation)
松弛是 Dijkstra 算法的核心步骤之一。它的意思是:对于边 (u, v)
,我们检查是否可以通过顶点 u
来 改善 (缩短) 到达顶点 v
的路径。
// 假设 u 已经被确定了最短路径 dist[u] (或至少是当前最优)
// v 是 u 的一个邻居,边的权重是 weight
// dist 是存储最短距离估计值的数组 (类型通常为 long long)
// LINF 是表示无穷大的常量if (dist[u] != LINF && dist[u] + weight < dist[v]) {dist[v] = dist[u] + weight; // 更新 v 的最短距离估计值// 如果是堆优化版本,还需要将更新后的状态放入优先队列// pq.push({v, dist[v]}); // (伪代码,具体看下方实现)
}
7. 两种实现方式 (C++ 代码)
(假设已包含头文件 <vector>
, <queue>
, <cstring>
, <functional>
, 并定义了 LINF
, MAXN
以及 Edge
结构体,并且 adj
, dist
, visited
, V
是可访问的全局变量或通过参数传递)
// --- 需要的前置定义 (示例) ---
#include <vector>
#include <queue>
#include <cstring>
#include <functional> // for std::greaterconst long long LINF = 0x3f3f3f3f3f3f3f3fLL;
const int MAXN = 100005;struct Edge {int to;int weight;
};struct State {int node;long long dist;bool operator>(const State& other) const {return dist > other.dist;}
};extern std::vector<Edge> adj[MAXN]; // 邻接表 (需在外部定义和填充)
extern long long dist[MAXN]; // 距离数组 (需在外部定义)
extern bool visited[MAXN]; // 访问标记 (需在外部定义)
extern int V; // 顶点数 (需在外部定义)
// --- 前置定义结束 ---
朴素版 Dijkstra (O(V^2))
// --- 朴素版 Dijkstra 函数 ---
void dijkstra_simple(int start_node) {// 1. 初始化for (int i = 1; i <= V; ++i) { // 假设顶点从 1 到 Vdist[i] = LINF;visited[i] = false;}dist[start_node] = 0;// 2. 循环 V 次for (int i = 0; i < V; ++i) {int u = -1;long long min_dist = LINF;// a. 找到 dist 最小的未访问顶点 ufor (int j = 1; j <= V; ++j) {if (!visited[j] && dist[j] < min_dist) {min_dist = dist[j];u = j;}}// 如果找不到或剩下的点不可达if (u == -1) {break;}// b. 标记 u 为已访问visited[u] = true;// c. 松弛 u 的邻居for (const auto& edge : adj[u]) {int v = edge.to;int weight = edge.weight;// 确保 dist[u] 不是 LINF 且 v 未访问if (dist[u] != LINF && !visited[v] && dist[u] + weight < dist[v]) {dist[v] = dist[u] + weight;}}}
}
堆优化版 Dijkstra (O(E log V))【竞赛常用】
// --- 堆优化版 Dijkstra 函数 ---
void dijkstra_heap(int start_node) {// 1. 初始化for (int i = 1; i <= V; ++i) { // 假设顶点从 1 到 Vdist[i] = LINF;// visited 在堆优化版中通常不是必需的,但有时可用于小幅优化// visited[i] = false;}dist[start_node] = 0;// 优先队列,存储 {节点, 距离},按距离从小到大排序 (小顶堆)std::priority_queue<State, std::vector<State>, std::greater<State>> pq;pq.push({start_node, 0});// 2. 主循环while (!pq.empty()) {// a. 取出当前距离最小的状态State current = pq.top();pq.pop();int u = current.node;long long current_dist = current.dist;// **核心优化**: 如果取出的距离比记录的还大,说明是旧状态,跳过if (current_dist > dist[u]) {continue;}// 如果使用 visited 数组优化:// if (visited[u]) continue;// visited[u] = true;// b. 松弛 u 的邻居for (const auto& edge : adj[u]) {int v = edge.to;int weight = edge.weight;// 确保 dist[u] 不是 LINFif (dist[u] != LINF && dist[u] + weight < dist[v]) {dist[v] = dist[u] + weight;// 将更新后的、更短的距离信息加入优先队列pq.push({v, dist[v]});}}}
}
注意 long long
: 在竞赛中,路径长度之和很容易超过 int
的最大值 (约 2 * 10^9)。强烈建议使用 long long
来存储 dist
数组,避免溢出导致错误。
8. 复杂度分析
- 朴素版:
- 时间复杂度:O(V^2)。
- 空间复杂度:O(V + E) (邻接表) 或 O(V^2) (邻接矩阵)。
- 堆优化版:
- 时间复杂度:O(E log V)。
- 空间复杂度:O(V + E)。
竞赛中,由于 V 通常较大,E 相对 V^2 较小,堆优化版的 O(E log V) 是必须掌握的。
9. 注意事项与常见问题
- 负权边: 再次强调,Dijkstra 不能处理负权边。
- 图的表示: 竞赛通常用邻接表。
- 重边和自环: Dijkstra 能正确处理。
- 图不连通:
dist
数组中不可达顶点的距离将保持为LINF
。 - INF 的选择:
LINF
要足够大,0x3f3f3f3f3f3f3f3fLL
是常用的long long
型无穷大。 - 起点和终点编号: 注意题目是从 0 还是 1 开始。代码示例假设从 1 开始。
- 路径记录: 如果需要输出路径,可在松弛时记录前驱节点
parent[v] = u
。
10. 练习建议
- 模板题: 洛谷 P3371 (朴素/堆), P4779 (堆优化), AcWing 849 (朴素), 850 (堆优化)。
- 练习平台: 洛谷, AcWing, LeetCode, 牛客网。
- 做题策略: 先实现模板,再做变化题。
11. 总结
Dijkstra 算法是图论中基础且重要的单源最短路径算法,核心是 贪心 和 松弛。掌握 堆优化版本 对于算法竞赛至关重要。理解原理、前提、细节,并通过 C++ 代码实践和做题来巩固。