单源最短路径算法
引入:
单源,本质是一个“一对多”的关系,给定图中的一个唯一的起始点,计算出从这个点出发,到达图中所有其他可达顶点的最短距离,也是后续探寻单节点对的最短路径的基础。
前置定义:
图与权重
在最短路径问题中,给定一个带权重的有向图 和权重函数 ,该权重函数将每条边映射到实数值的权重上。图中一条路径 的权重 是构成该路径的所有边的权重之和,数学表达为:
最短路径权重
定义从结点 到结点 的最短路径权重 如下:
故结点 到结点 的最短路径,则为任何一条权重恰好等于最短路径权重(即 )的从 到 的路径 。
==不可达的情况:==
什么时候会不存在呢?我们得考虑一个结构:环路。
- 环路总权值为负:显然,你可以随心所欲在上面绕圈,中间不管选哪条路径,都可以达成 的成就,但我们需要一个有限的下界。
- 环路总权值为正:假设存在一条从 到 的最短路径 包含了这个正权重的环路 。当我们用剪贴法把正环剔除掉,我们会得到一条相较于之前总权重更小的路径,假设失效。
剩下权重为 的环路,通过把它重复删去,我们一样可以得到权重相同、更为简化的路径(简单路径)。
因此,我们可以假定在找到的最短路径中没有环路,也不会丢失一般性。
毕竟算法关注的也是权值本身,而不是路径形状
除去环路,路径的权值可以为负。在接下来的算法当中,Bellman-Ford 算法允许输入包含负权重的边,并且可以报告路径中是否含有权重为负的环路。而Dijkstra 算法的前提则为输入的边权重为非负值。
(权重的本质是一种广义的“代价”,并非物理距离,故与之相应的“收益”若大于代价时,净权重就可以被定义为负数)
前置提要:
- 最优子结构:最短路径的核心规律是“最短路径的子路径依然是最短路径”。
- 最短路径的表示:除了计算出最短路径权重,我们还需要知道这个路径是怎么走,因此需要一种数据结构来记录路径。
给定图 ,对于每个结点 ,我们维持一个前驱结点 。该前驱结点可能是另一个结点或者 。
- :用来记录在最短路径上 的“前驱结点”是谁。如果还没找到路,它就是
NIL。 - : 到 的最短路径估计,即 到 最短路径权重的上界。
由 值所衍生的前驱子图 ,我们定义结点集 为图 中的前驱结点不为 的结点的集合,再加上源结点 ,即:
有向边集合 是由 中的结点的 值所衍生的边的集合,即:
(对于上面找出的每一个结点 (除了源点 本身),把它和它的前驱结点连起来,形成一条从“前驱”指向“当前结点”的边,即 )——路径还原。
当最短路径算法跑完后, 是一棵树,即最短路径树 (Shortest Path Tree)。此处我们可以先直观理解一下原因:
- 每个结点最多只有一个前驱(只有一个父节点)。
- 这些被选中的边不会形成环路(因为环路会导致无限绕圈,违背最短路径的贪心原则)。
最短路径树的定义:
设 是一条带权重的有向图,其权重函数为 。假定 不包含从 可以到达的权重为负值的环路,因此,所有的最短路径都有定义。一棵根结点为 的最短路径树是一个有向子图 ,这里 ,:
- 包含只要是源点能到达的所有结点。
- 每个结点只有一个父节点。
- 对于页面的结点 ,图 中从结点 到结点 的唯一简单路径是图 中从结点 到结点 的一条最短路径。
CLRS 也提到,这和 BFS 的广度优先树本质一致,只不过我们可以把它看作是在解决无权图的最短路径问题,所有边的权重都是 ,边数总和最少就是最短路径。
当然,最短路径不一定是唯一的,最短路径树也不一定是唯一的。

注意评判标准:是保证从源点 出发,顺着树枝走到任何一个单独结点的距离,都是全图理论上的最小值。
- 松弛(Relaxation):
- 三角不等式性质:对于任何边 ,我们有 。
- 上界性质:对于所有的结点 ,我们总是有 。一旦 的取值达到 ,其值将不再发生变化。
- 路径松弛性质:如果 是从源结点 到结点 的一条最短路径,并且我们对 中的边所进行松弛的次序为 ,则 。该性质的成立与任何其他的松弛操作无关,即使这些松弛操作是与对 上的边所进行的松弛操作穿插进行的。
- 前驱子图性质:对于任何带权有向图,如果经过一系列的松弛操作后,使得对于所有能从源点 到达的结点 ,都有 ,那么此时的前驱子图 必定是一棵根节点为 的最短路径树。
INITIALIZE-SINGLE-SOURCE() 1 for each vertex 2 3 4
对一条边的 的松弛过程为,将从 到 之间的最短路径距离加上结点 与 之间的边权重,并与当前的 到 的最短路径估计进行比较。
RELAX() 1 if 2 3

为什么不能把等号加给 >(即写成 >= 并且执行更新)呢?
为了防范零权环。若写成 >= 并且执行更新,图中存在两个互相连接且边权为 0 的结点 和 ,那它们算出来的距离永远相等,就会陷入“A 认为 B 是前驱,B 又认为 A 是前驱”的无限套娃,导致死循环。
有向无环图的单源最短路径问题(Directed Acyclic Graph)
无环,意味着图中各个结点的先后顺序是清晰的,故我们可以先用拓扑排序将其线性排序,以此作为处理各节点的处理顺序,然后分别去找相应的邻接链表进行松弛操作。
那多种拓扑排序会有影响吗?(因为处于同一层级的节点会衍生多种情况)
不妨假设图中存在边 和 ,拓扑排序保证 和 都必定在 之前被处理。此时, 和 已经计算完毕,且是最优解。
假设:
情况一:拓扑排序是
- 处理 时:内存执行
C.d = min(C.d, Path_A)。因为此时 是 ,所以 被覆写为 。 - 处理 时:内存执行
C.d = min(C.d, Path_B)。此时 里面存的是 ,所以实际执行的是C.d = min(Path_A, Path_B)。
情况二:拓扑排序是
- 处理 时:内存执行
C.d = min(C.d, Path_B)。因为此时 是 ,所以 被覆写为 。 - 处理 时:内存执行
C.d = min(C.d, Path_A)。此时 里面存的是 ,所以实际执行的是C.d = min(Path_B, Path_A)。
因此,拓扑排序只是保证在处理当前结点时,它的所有前置的松弛操作全部处理完毕,前面的松弛顺序对最终结果无影响。
DAG 最短路径算法的定理陈述:
如果带权有向图 没有环(DAG),且有源点 。那么在 DAG-SHORTEST-PATHS 算法终止时:
- 对于所有结点 ,其最短路径估计值必定等于真实最短距离:。
- 前驱子图 必定是一棵最短路径树。

分析:
拓扑排序:
初始化:
双层循环:
聚合分析:对于一个数据结构执行一个由 个操作组成的序列,如果无论如何最坏情况下,这 个操作的总运行时间上限是 。那么在聚合分析中,我们认为每个操作的平均(摊还)代价就是 。
触发条件 1:内层操作的次数,受到全局物理资源的严格限制
触发条件 2:状态的改变是“单向”且“不可逆”的
也就是找到全局总限制,比如指针总共只能走 步,或者栈里的元素最多只能被pop出去 次
给出该算法的正确性证明:
情况 1:如果结点 根本无法从源点 到达
真实最短距离 。根据算法初始化(设为 )且没有边能更新它(无路径性质),最终 。
情况 2:如果结点 可以从源点 到达
则必然存在一条真实的最短路径 (其中 , )。
根据路径松弛性质,只要我们按照最短路径上边的顺序,依次松弛 ,然后 ,一直到 ,那么最终 绝对等于真实的最短距离 。至于在这些关键松弛操作之间穿插了多少其他乱七八糟的边松弛,都完全不影响最终结果。
在 DAG 中,由于路径 是一条有向路径,所以 指向 , 指向 ,那根据拓扑排序的定义:如果存在边 ,那么 必定排在 前面,则这条最短路径上,结点在拓扑排序中的出场顺序一定是 。
既然算法是按照拓扑排序的顺序来取出结点并松弛它的出边,所以,边 绝对比边 先被松弛。
结论:当算法终止时, 必然成立。由于所有距离都正确,根据前驱子图性质, 自然就是一棵最短路径树。
当然,DAG 解决的是无环的情况,那对于有环而言,我们无法得到一个线性序,就需要采取动态决策(贪心)的思路。
那么从特殊的情况,可以有环但不能有负权值的边开始,我们引入 Dijkstra 算法。
Dijkstra 算法
核心:全局状况未知,根据**“贪心思想”,那就在目前所有还没处理完的节点中,每次只挑那个当前离正在处理的点最近( 最小)**的结点,放入集合 (已确定最短路径的结点),然后更新优先队列 里的每点距离,取其最小,移入 ,如此循环。
Dijkstra 算法正确性证明:
- 非路径性质:如果从结点 到结点 之间不存在路径,则总是有 。
- 收敛性质:对象某些结点 ,如果 是图 中的一条最短路径,并且在对边 进行松弛前的任意时间有 ,则在之后的所有时间有 。
我们使用反证法:设结点 是第一个被算法错误地放入 的,即 。那么在原图中,必定存在一条真正的最短路径 从源点 连到 。因为源点 早就在集合 里了,目标点 正准备被拉入 ,所以 必然有一脚跨出了集合 的边界。设这条路径上,最后一个在 内部的节点是 ,它跨出边界后的第一个在 外部的节点是 ,即 。
因为 在 里面,。 当初被加入 时,它一定对边 进行了松弛, 的距离也被完美算对了,即 。在真正的最短路径 上, 是 的前置路段。因为图里没有负权边,从 走到 的这段路 的权重 ,则:
从而有:
而我们假设算法算错了 ,即 ,连带推出 。那因为优先队列 里 和 都在,贪心策略却没有选择估计值更小的 而是选择了 ,这就和贪心思想的选择产生矛盾。
结论:Dijkstra 算法贪心拉入集合 的每一个节点,其距离都是绝对的最短距离。
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
const int INF = INT_MAX;
struct Edge {
int to;
int weight;
};
struct State {
int id;
int distFromS;
// 重载大于号,让 priority_queue 变成最小堆 (默认是最大堆)
bool operator>(const State& other) const {
return distFromS > other.distFromS;
}
};
class Graph {
private:
int V;
std::vector<std::vector<Edge>> adj;
public:
Graph(int v) : V(v), adj(v) {}
void addEdge(int u, int v, int weight) {
adj[u].push_back({v, weight});
}
void dijkstra(int source) {
// 记录到各个节点的最短距离,初始化为无穷大
std::vector<int> dist(V, INF);
// 记录前驱节点,用于最后还原路径 (对应书中的 \pi)
std::vector<int> prev(V, -1);
// 最小优先队列
std::priority_queue<State, std::vector<State>, std::greater<State>> pq;
dist[source] = 0;
pq.push({source, 0}); // 对应书中的 INSERT 操作
// 主循环
while (!pq.empty()) {
// 【步骤 2: 提取当前距离最小的节点】对应 EXTRACT-MIN
State current = pq.top();
pq.pop();
int u = current.id;
int d = current.distFromS;
// 【核心防御:懒惰删除机制】
// 如果弹出的这个状态,它的距离比我目前已知的最短距离还要大,
// 说明这是一个历史遗留的“废弃”状态,直接忽略。
if (d > dist[u]) {
continue;
}
// 【步骤 3: 遍历并松弛所有出边】
for (const auto& edge : adj[u]) {
int v = edge.to;
int weight = edge.weight;
// RELAX (松弛操作)
if (dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
prev[v] = u; // 记录前驱,维持最短路径树
// 【对应书中的 DECREASE-KEY】
// 我们不修改队列中已有的 v,而是直接推入一个新的更优状态
pq.push({v, dist[v]});
}
}
}
printResult(source, dist, prev);
}
private:
void printResult(int source, const std::vector<int>& dist, const std::vector<int>& prev) {
std::cout << "源点 " << source << " 到各节点的最短距离:\n";
for (int i = 0; i < V; ++i) {
if (dist[i] == INF) {
std::cout << "节点 " << i << ": 无法到达\n";
} else {
std::cout << "节点 " << i << ": 距离 = " << dist[i];
// 还原并打印路径
std::cout << " (路径: ";
std::vector<int> path;
for (int curr = i; curr != -1; curr = prev[curr]) {
path.push_back(curr);
}
for (int j = path.size() - 1; j >= 0; --j) {
std::cout << path[j] << (j == 0 ? "" : " -> ");
}
std::cout << ")\n";
}
}
}
};
int main() {
// 构造一个 5 个节点的图 (编号 0 到 4)
int V = 5;
Graph g(V);
// 添加边 (u, v, weight)
g.addEdge(0, 1, 10);
g.addEdge(0, 2, 3);
g.addEdge(1, 3, 2);
g.addEdge(2, 1, 4);
g.addEdge(2, 3, 8);
g.addEdge(2, 4, 2);
g.addEdge(4, 3, 9);
g.dijkstra(0);
return 0;
}
分析:
大头取决于最小优先队列实现。
1.Array =
2.Binary Min-Heap = = 稀疏图
3.Fibonacci Heap ( ,EXTRACT-MIN 的摊还代价地证明为
考虑到有的图存在负权值的边,于是引入Bellman-Ford 算法。
Bellman-Ford 算法
核心:无法预知节点顺序,贪心因负权边失效,那就暴力地在每一轮迭代中对图里所有的有向边进行无差别松弛,更新邻居的最短距离。通过连续进行 轮的全局遍历(相当于求出最多经过 条边的最优解),让所有结点的距离达到理想的最短路径;若第 轮仍能成功松弛,则可判定存在负权环。
路径松弛性质 如果 是从源结点 到结点 的一条最短路径,并且我们对 中的边所进行松弛的次序为 ,则 。该性质的成立与任何其他的松弛操作无关,即使这些松弛操作是与对 上的边所进行的松弛操作穿插进行的。
以下给出证明:
1.假设图中没有负权环

(归纳证明:,当算法松弛边 时,根据松弛操作的定义,它会把 的距离更新为 ,得到,由于归纳过程是按顺序的,成立)
得证,不可到达点初始化的 值就是正解,故所有 均达到了最优值
松弛结束后,会检查,根据三角不等式(对于任意边 ,必有 ),则有,故只返回TRUE。
2.假设图中存在负权环(反证)
假设:假设图中存在可从 到达的负权环 (其中 ),且算法返回了 TRUE
条件约束:如果算法返回 TRUE,说明对于环上的每一条边 ,在最后一次迭代后,均满足:
对环上的 条边,将这些不等式相加:
环是封闭的,,所以项 和 包含了完全相同的顶点集合
抵消后,
因为最初定义 为负权环,即 ,经推导得出,<=负数,故假设不成立。
#include <iostream>
#include <vector>
#include <climits>
const int INF = INT_MAX;
struct Edge {
int u; // 起点
int v; // 终点
int weight;
};
class Graph {
private:
int V;
int E;
std::vector<Edge> edges;
public:
Graph(int v, int e) : V(v), E(e) {}
void addEdge(int u, int v, int weight) {
edges.push_back({u, v, weight});
}
bool bellmanFord(int source, std::vector<int>& dist) {
dist.assign(V, INF);
dist[source] = 0;
for (int i = 1; i <= V - 1; ++i) {
bool relaxed = false; // 某一轮没有任何更新,说明已经收敛,提前退出
for (const auto& edge : edges) {
int u = edge.u;
int v = edge.v;
int weight = edge.weight;
// 防止 INF + weight 导致整数溢出
if (dist[u] != INF && dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
relaxed = true;
}
}
if (!relaxed) {
break;
}
}
// 第 V 轮负责检测负权环
for (const auto& edge : edges) {
int u = edge.u;
int v = edge.v;
int weight = edge.weight;
if (dist[u] != INF && dist[u] + weight < dist[v]) {
// 还能松弛则一定存在负权环
return false;
}
}
return true;
}
};
分析:
差分约束与最短路径
差分约束系统:一个线性规划矩阵A的每一行里,只有一个 和一个 ,其他全是
我们要求出一组满足所有这些限制的 的值
**平移不变性:**如果 是一组解,那么对于任何常数 ,给每个未知数都加上 ,即 ,依然是正确的解
(当然,y=x+b,该直线上的点都满足)
<-> (最短路的性质)
即:从起点走到顶点 的距离,不能超过从起点先走到顶点 ,再通过一条权重为 的边走到 的距离
因而得到约束图Constraint Graph:
顶点: 对应 有向边: ,从顶点 指向顶点 的边,边的权重是 。
==还要加上源点==:图不一定全连通,故引入该点, 向图中其他顶点 ,都连一条权重为 的边。
通过bellman-ford算法,有两种情况。
1.无负权环,算出了从 到每个点 的最短距离 ,则。
2.有负权环,无解。
因为有 个顶点和 条边,所以时间复杂度为 。
最短路径性质证明
现在,我们回顾一下前面提到的各种性质,给出证明:
三角不等式性质(引理 24.10)
对于任何边 ,我们有 。
上界性质(引理 24.11)
设 是一个带权有向图,权值函数 。设 为源点,且图已经被初始化。对于所有的结点 ,我们总是有 。一旦 的取值达到 ,其值将不再发生变化。
数学归纳法:
我们先证明所有的 ,始终有 ,归纳主体为松弛步骤的数量。
basis: 刚初始化完 (n=0), 显然成立。因为对于所有的 ,都有 。对于源点,有 (注:如果 在负权环上,,否则为 0)
我们假设:整个图经历了 次松弛操作之后,对于图中的所有顶点 ,它的距离估计值 都必定>=真正的最短路径
inductive step: (n+1) 考虑对某条边 执行松弛操作。由归纳假设,在松弛之前对于所有的 都有 。本次操作中唯一可能改变的估计值是 。如果它真的发生了改变,则有:
因为我们已经证明了 ,所以它已经达到下界,不可能再减小;同时,松弛操作的设计决定了它永远不会去增加 的值,所以它也无法变大,故一旦 就不会再改变。
非路径性质(推论 24.12)
如果从结点 到结点 之间不存在路径,则总是有
证明: 由刚才证得的上界性质可知,始终有 。因此必然得出
收敛性质(引理 24.14)
对于某些结点 ,如果 是图 中的一条最短路径,并且在对边 进行松弛前的任意时间有 ,则在之后的所有时间有 **引理24.13:**对于边 ,在执行了
RELAX(u, v, w)之后,立刻会满足
证明:
根据上界性质,如果 在松弛 前的某一点达到了 ,那么这个等式此后将一直成立。特别地,在松弛边 之后,我们有:
再结合上界性质 ,夹逼得出 。
路径松弛性质(引理 24.15)
如果 是从源结点 到结点 的一条最短路径,并且我们对 中的边所进行松弛的次序为 ,则 该性质的成立与任何其他的松弛操作无关,即使这些松弛操作是与对 上的边所进行的松弛操作穿插进行的。
证明:
使用归纳法证明:在路径 的第 条边被松弛后,有 。
-
基础情况 (): 在任何边被松弛前,初始化使得 。由上界性质, 永远不会改变。
-
归纳步骤: 假设在松弛 之前已有 。根据刚才证明的收敛性质,只要松弛了边 ,立刻会有 ,并且一直保持。
最后,需要证明,当估计值全部收敛后,我们记录的前驱指针( 属性)所衍生出的子图 ,就是一棵最短路径树。
引理 24.16:假设图中不包含从源点 可达的负权环。那么在初始化之后,前驱子图 会形成一棵以 为根的有根树,并且此后的任何松弛序列都不会破坏这一不变量
证明:初始时 只有源点 ,显然成立。
经历一系列松弛后,我们先用反证法证明 是无环的:
假设某次松弛在 中意外产生一个环 (其中 )。那么对于环上的每一个点,必定有 。不失一般性,假设是最后松弛边 时把环给封闭了。
环 上的所有点必定都是从 可达的(因为它们拥有前驱,意味着被赋予了有限的估计值,由上界性质可知一定可达)。
在调用导致成环的 RELAX 之前,对于前面所有的点 ,都有 。这说明 之前是因为执行了 而更新的。由于距离值只会变小,所以在这个决定性的 RELAX 瞬间,我们有:
而因为最后一次调用改变了 ,说明当时满足了严格不等式条件:
∴
因此, 绝对是无环的。
最后再证明每个节点在 中只有唯一一条路径通向 (图中每个节点只有一个父节点,不可能分叉)。假设有两条路径,必然会在某一点 汇合,这意味着 同时拥有两个不同的前驱 和 ,这与数据结构中一个节点只能存一个 值矛盾 。
前驱子图性质(引理 24.17)
假设图不包含负权环。执行完能让所有点收敛(即 )的松弛操作后,前驱子图 就是一棵以 为根的最短路径树。
证明:
只需验证它满足最短路径树的三大要素:
-
节点全包含:最短路径存在当且仅当节点可达,而可达当且仅当 ,所以 包含了所有可达点。
-
树结构:引理 24.16 证明了它是一棵以 为根的树。
-
路径就是最短路径*:假设树中存在路径 (, )。
由于每个点都已经收敛:,并且满足更新条件 。移项得到:
沿着整条路径把权重加起来:
这里形成了一个裂项相消,中间项全部抵消:
我们得出这根树枝的总权重 。但根据定义,任何路径的权重都不可能小于数学意义上的最短路径下界 ,所以只能是 。
拓展
Floyd 算法