图论算法:生成树与最短路径
上一讲我们把图「存」了起来,这一讲开始让图「动」起来。同样是带权图,问法不同,算法就完全不同: 让所有点连通且总代价最小是最小生成树(Prim / Kruskal); 从一点到另一点走哪条路最省是最短路径(Dijkstra / Floyd / Bellman-Ford); 哪些事必须先做、哪些可以并行是拓扑排序(AOV 网); 整个工程最快几天完工、哪些环节不能拖是关键路径(AOE 网)。 这四组算法是 408 与各类算法竞赛的绝对高频考点,也是「贪心」「动态规划」两大思想的最佳入门范例。
- 9.1 统一约定与贯穿全章的示例图 —— 先花 5 分钟把这张图看懂、抄下来,后面每个算法都在它上面跑,对照起来才不费劲。
- 9.2 最小生成树:割性质与环性质、Prim、Kruskal、并查集、对比表 —— 考试最爱考 Prim / Kruskal 的手推过程表。
- 9.3 Dijkstra 单源最短路:松弛的本质、手推表、朴素版与堆优化版、路径还原、为什么不能有负权边。
- 9.4 Floyd 全源最短路:
dp[k][i][j]的三维思想、为什么 k 必须放在最外层、扩展应用。 - 9.5 Bellman-Ford 与 SPFA:能处理负权、能判负环,但要小心被卡。
- 9.6 拓扑排序(AOV 网):Kahn 算法与 DFS 版,判断有向图有无环。
- 9.7 关键路径(AOE 网):ve / vl / e / l 四张表,408 的高频大题。
- 9.8 连通性进阶:瓶颈生成树、次小生成树,以及并查集在 Kruskal 中的核心地位。
- 9.9 工程视角:导航 / 路由 / 构建 / 项目管理里,这些图论算法到底跑在哪、被改成了什么样。
- 9.10 本章小结、易错点与 6 道自测题。
9.1 统一约定与贯穿全章的示例图
9.1.1 四个统一约定
图论算法的细节特别容易被「下标从 0 还是从 1 开始」「有向还是无向」「边权能不能为负」这类约定搞乱。 为了避免你在本章里反复切换脑子,我们先一次性把约定钉死,全章不再变:
| 约定项 | 本章的选择 | 为什么这么选 |
|---|---|---|
| 顶点编号 | 0 ~ n−1(0 基) | 与 C++ 数组下标天然对齐,写 for (int i = 0; i < n; i++) 不用做 ±1 的心算。 |
| 图的类型 | MST 与最短路用带权无向图;拓扑排序与关键路径用带权有向图(DAG) | 生成树、最短路在无向图上讨论最直观;AOV / AOE 网本身必须是有向无环图。 |
| 权值 | 全部为正整数(本章示例权值取 2 ~ 11) | Dijkstra 与 Floyd 都要求非负权;负权单独放到 9.3.7 与 9.5 节讲。 |
| 存储结构 | 邻接矩阵 g[u][v] 与 链式前向星(数组模拟邻接表)两种 | 朴素算法用矩阵最省事,堆优化算法必须用邻接表,两种都要会写。 |
9.1.2 两种存储结构的代码骨架
后面所有算法的代码都建立在这两个骨架之上。链式前向星的名字听起来吓人,其实就是
「用数组模拟链表」:head[u] 指向顶点 u 的第一条边,每条边记录
to(终点)、w(权值)、nxt(同起点的下一条边),
插入时用头插法。它比 vector<vector<Edge>> 更快、更省内存,
是算法竞赛的标准写法。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005; // 最大顶点数
const int MAXM = 200005; // 最大边数(无向图要开 2 倍)
const int INF = 0x3f3f3f3f; // 1e9 级别,"无穷大"的安全取值
int n, m; // n 个顶点(0 ~ n-1),m 条边
int g[MAXN][MAXN]; // ① 邻接矩阵:g[u][v] = 边权,无边为 INF
/* ② 链式前向星:数组模拟邻接表 */
int head[MAXN], etot = 0; // head[u] = 顶点 u 的第一条边的下标,-1 表示没有
struct Edge {
int to, w, nxt; // 终点、边权、同起点的下一条边
} e[MAXM];
void init() {
memset(g, 0x3f, sizeof(g)); // 邻接矩阵全部置为 INF
for (int i = 0; i < n; i++) g[i][i] = 0;
memset(head, -1, sizeof(head));
etot = 0;
}
/* 加一条 u -> v 权值为 w 的有向边(无向图调用两次即可) */
void addEdge(int u, int v, int w) {
e[etot] = {v, w, head[u]};
head[u] = etot++;
}
/* 遍历顶点 u 的所有出边:这是链式前向星唯一需要背下来的循环 */
void traverse(int u) {
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to, w = e[i].w;
// 在这里处理边 (u, v, w)
}
}
int main() {
n = 7; m = 11;
init();
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < m; i++) {
int u = E[i][0], v = E[i][1], w = E[i][2];
g[u][v] = g[v][u] = w; // 无向图:矩阵对称
addEdge(u, v, w); addEdge(v, u, w);
}
cout << "n = " << n << ", m = " << m << ", etot = " << etot << "\n";
cout << "g[0][1] = " << g[0][1] << ", g[0][4] = " << (g[0][4] == INF ? -1 : g[0][4]) << "\n";
return 0;
}
9.1.3 贯穿全章的示例图 G
下面这张图是本章的「主角」:7 个顶点、11 条带权无向边,权值全是正整数。 它规模不大,手推得动;又足够「有故事」——既有等权边(可以造出多个不同的最小生成树), 又有「直连不如绕路」(比如 0 到 6 直连是 11,绕 0→1→2→6 只要 7), 所以我们能用同一张图把 Prim、Kruskal、Dijkstra、Floyd 全部演一遍。 请把它抄到草稿纸上,后面每张手推表都对着它看。
把这张图整理成两种存储结构,就是下面这样(后文所有代码都用这两张表初始化):
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 2 | 3 | 3 | ∞ | ∞ | 11 |
| 1 | 2 | 0 | 2 | 5 | ∞ | 6 | ∞ |
| 2 | 3 | 2 | 0 | ∞ | ∞ | ∞ | 4 |
| 3 | 3 | 5 | ∞ | 0 | 2 | 5 | ∞ |
| 4 | ∞ | ∞ | ∞ | 2 | 0 | ∞ | 5 |
| 5 | ∞ | 6 | ∞ | 5 | ∞ | 0 | ∞ |
| 6 | 11 | ∞ | 4 | ∞ | 5 | ∞ | 0 |
存储代价 O(n²),与边数无关;判 (u,v) 是否有边是 O(1)。适合稠密图,也是 Prim 朴素版与 Floyd 的默认选择。
| head[] | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 第一条边下标 | 9 | 5 | 3 | 7 | 6 | 4 | 2 |
头插法的结果是边表顺序与读入顺序相反:先读 (0,1),最后读 (0,6),于是 head[0] 指向最后插入的 (0,6)。
遍历顺序不影响任何算法的正确性,但会影响相同权值时的选择结果(这一点在 9.2.4 的 Prim 手推里会看到)。
存储代价 O(n + m),遍历一个点的所有出边是 O(deg)。适合稀疏图,是 Kruskal、Dijkstra 堆优化、SPFA 的标配。
- 示例图 G 的最小生成树权值和 = 18(无论从哪个顶点开始 Prim,或换成 Kruskal,都是 18)。
- 从顶点 0 出发的单源最短距离
dist = [0, 2, 3, 3, 5, 8, 7]。 - 全源最短路矩阵的第 0 行 = 上面这行;任意两点间距离都 ≤ 直连边权。
9.2 最小生成树(MST):把图连通得最便宜
9.2.1 生成树与最小生成树的定义
生成树(spanning tree):连通无向图 G 的一个子图,它包含 G 的全部 n 个顶点, 并且恰好有 n−1 条边、本身是一棵树(连通且无环)。 一个连通图的生成树一般不止一棵,比如一个三角形(3 个顶点 3 条边)就有 3 棵生成树。
最小生成树(Minimum Spanning Tree, MST):在带权连通无向图中, 边权总和最小的那棵生成树。注意三个前提:
- 连通:不连通就谈不上「生成树」,只能求「最小生成森林」(每个连通块各求一棵 MST)。
- 无向:有向图对应的是「最小树形图(朱刘算法)」,那是另一个话题。
- 带权:权值可以理解为造价、距离、时间,目标就是总代价最小。
现实中的原型非常多:用最少的网线把 n 个城市连起来、用最短的线路给一片小区供气、 电路板上用最少的铜箔连通所有焊点……只要满足「连通所有点 + 总代价最小」,就是 MST 问题。
9.2.2 MST 的两条性质:割性质与环性质
Prim 和 Kruskal 表面上差别很大(一个长点、一个长边),但它们背后站的是同一条定理。 理解了这条定理,你就知道这两个算法不是碰巧对,而是必然对。
直觉解释:环上每条边都只是「连通环上这些点」的一种方式,既然有一条最贵的,那把它拆掉、用环上其它边照样能连通, 总代价只会更小 —— 所以最贵的边没必要留。
把这两条性质对着示例图 G 用一遍,你立刻就能「不跑算法」判断出一些边的命运:
- 环 0–1–2–0 的三条边是 2、2、3。最大的是
(0,2)权 3,所以(0,2) 不在任何 MST 中。这解释了为什么 Kruskal 在第 4 条边就把它丢掉。 - 环 1–2–6–4–3–1 …… 直接看环 2–6–4–3–1–2?其实不用绕这么远:环 0–1–2–6–0 的边为 2、2、4、11,最大的是
(0,6)权 11,所以 (0,6) 不在 MST 中。 - 环 1–3–5–1 的边为 5、5、6,最大的是
(1,5)权 6 —— 但注意这一轮里 5 与 5 并列最小,所以 (1,3) 或 (3,5) 谁进 MST 都可以,但 (1,5) 一定不进。
原因:MST 的权值和是由「割的最小割边权值之和」这种只与图有关、与选择无关的量决定的; 更严格地说,可以证明若两棵生成树 T₁、T₂ 权值和不同,就能通过「交换一条边」把较重的改成较轻的,矛盾。
考试口径:「最小生成树可能不唯一,但最小生成树的权值和唯一」——这句话是对的; 「最小生成树的边集唯一」——这句话是错的。
9.2.3 Prim 算法:一个顶点一个顶点地「长大」
一句话本质
把生成树想象成一个正在扩张的「国家」,树外的顶点是「邻国」。每个邻国都有一个「到该国的最短代价」
lowcost[i],还有一个「是通过哪座城市连过去的」closest[i]。
每一轮,Prim 挑 lowcost 最小的邻国吞并,然后用新吞并的城市去更新其它邻国的代价。
整个过程其实就是 9.2.2 的割性质:已入树的点集 S 与树外的点集 V−S 构成一个割,
而 lowcost 最小的那个邻国对应的边,正是这个割的最小割边。
算法流程(4 步)
- 初始化:任选一个起点 s,令
lowcost[s] = 0(其它为 ∞),lowcost[i] = w(s,i)(不可达则 ∞),closest[i] = s,标记visited[s] = true。 - 选点:在
visited = false的顶点中,找lowcost最小的顶点 k。若lowcost[k] == ∞, 说明图不连通,算法结束。否则把 k 加入生成树,边(closest[k], k)入选,visited[k] = true。 - 松弛:对 k 的每个未访问邻接点 j,若
w(k,j) < lowcost[j], 则lowcost[j] = w(k,j)、closest[j] = k。 注意这条式子左边是「到树的距离」,右边是「一条边的权」——不需要累加,因为只要接到树上就只花一条边的代价。 - 重复第 2、3 步 n−1 次,得到 n−1 条边,即最小生成树。
完整手推过程(以示例图 G 为例,从顶点 0 出发)
下表是 408 最常考的形式:每一轮记录 lowcost[]、closest[]、选中的顶点、加入的边与当前总权值。
「∞」表示当前还够不着。灰色的格子表示该顶点已经进树了。请务必自己动手抄一遍、遮住答案推一遍。
| 轮次 | lowcost[1..6] | closest[1..6] | 选中顶点 | 加入的边 | 边权 | 累计边数 | 总权值 |
|---|---|---|---|---|---|---|---|
| 初始 | 2, 3, 3, ∞, ∞, 11 | 0, 0, 0, −, −, 0 | — | — | — | 0 | 0 |
| 第 1 轮 | 2, 3, 3, ∞, ∞, 11 | 0, 0, 0, −, −, 0 | 1(最小 2) | (0,1) | 2 | 1 | 2 |
| 松弛 | —, 2, 3, ∞, 6, 11 | —, 1, 0, −, 1, 0 | 经 1 更新了 2(3→2)与 5(∞→6),closest 都改成 1 | ||||
| 第 2 轮 | —, 2, 3, ∞, 6, 11 | —, 1, 0, −, 1, 0 | 2(最小 2) | (1,2) | 2 | 2 | 4 |
| 松弛 | —, —, 3, ∞, 6, 4 | —, —, 0, −, 1, 2 | 经 2 更新了 6(11→4) | ||||
| 第 3 轮 | —, —, 3, ∞, 6, 4 | —, —, 0, −, 1, 2 | 3(最小 3) | (0,3) | 3 | 3 | 7 |
| 松弛 | —, —, —, 2, 5, 4 | —, —, —, 3, 3, 2 | 经 3 更新了 4(∞→2)与 5(6→5) | ||||
| 第 4 轮 | —, —, —, 2, 5, 4 | —, —, —, 3, 3, 2 | 4(最小 2) | (3,4) | 2 | 4 | 9 |
| 松弛 | —, —, —, —, 5, 4 | —, —, —, —, 3, 2 | 4 的邻居只有 3(已入树)和 6,w(4,6)=5 不小于 lowcost[6]=4,本轮 lowcost 不变 | ||||
| 第 5 轮 | —, —, —, —, 5, 4 | —, —, —, —, 3, 2 | 6(最小 4) | (2,6) | 4 | 5 | 13 |
| 松弛 | —, —, —, —, 5, — | —, —, —, —, 3, — | 6 的邻居 0/2/4 全部已入树,无可松弛对象 | ||||
| 第 6 轮 | —, —, —, —, 5, — | —, —, —, —, 3, — | 5(最小 5) | (3,5) | 5 | 6 | 18 |
| 松弛 | 全部顶点已入树,7 个顶点 6 条边 ⇒ 最小生成树完成,总权值 = 18 | ||||||
- 比较的是「到树的距离」,不是「从源点出发的路径长度」。比如第 5 轮
lowcost[6] = 4, 是边 (2,6) 的权,而不是 0→1→2→6 的累计长度 8。这与 Dijkstra 的dist有本质区别, 也是把 Prim 表当成 Dijkstra 表来填的典型错误。 - 等值时怎么办:第 3 轮
lowcost[2] = 3、lowcost[3] = 3并列最小。 这里选了 3(编号较大)。如果先选 2,那么加入的是边 (0,2),随后经 2 会把 6 的代价降到 4; 最终树的边集会变成 {(0,1),(1,2),(0,2)?…} —— 注意这时会成环,所以 (0,2) 不会进来。 实际结果是选 2 时得到 {(0,1),(1,2),(2,6),(3,4),(0,3),(3,5)},权值和同样是 18。 结论:等值时的选择不影响总权值,但影响具体边集。 - 每一轮必须写「松弛」这一步:只写「选中顶点」而不写 lowcost 的变化过程,阅卷时是要扣分的。
下面这个动画把上面的表格逐步演一遍,注意观察橙色候选格与绿色已入树的边:
Prim 朴素版 O(n²):邻接矩阵实现
朴素版的复杂度由「找最小值」这一动作决定:每轮扫一遍 lowcost 数组 O(n),
共 n 轮,所以是 O(n²);松弛操作总共 O(m)。当图很稠密(m 接近 n²)时,
O(n²) 反而比 O(m log n) 更优——这就是 Prim 适合稠密图的原因。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int INF = 0x3f3f3f3f;
int n, m;
int g[MAXN][MAXN]; // 邻接矩阵,无边为 INF
int lowcost[MAXN]; // lowcost[i]:顶点 i 到当前生成树的最小边权
int closest[MAXN]; // closest[i]:上面那条边在树内的那一个端点
bool vis[MAXN]; // 是否已加入生成树
/* 返回最小生成树的权值和;图不连通时返回 -1
时间复杂度 O(n^2),与边数 m 无关 —— 适合稠密图 */
int prim(int start) {
memset(vis, 0, sizeof(vis));
for (int i = 0; i < n; i++) {
lowcost[i] = g[start][i];
closest[i] = start;
}
vis[start] = true;
lowcost[start] = 0;
int sum = 0, cnt = 0;
for (int round = 1; round < n; round++) {
/* 第 2 步:在未入树的顶点中找 lowcost 最小的 */
int k = -1, best = INF;
for (int i = 0; i < n; i++) {
if (!vis[i] && lowcost[i] < best) { best = lowcost[i]; k = i; }
}
if (k == -1) return -1; // 图不连通
vis[k] = true;
sum += lowcost[k];
cnt++;
printf("第 %d 轮:加入顶点 %d,边 (%d,%d) 权 %d,累计 %d\n",
round, k, closest[k], k, lowcost[k], sum);
/* 第 3 步:用新顶点 k 去松弛其它未入树的点 */
for (int j = 0; j < n; j++) {
if (!vis[j] && g[k][j] < lowcost[j]) {
lowcost[j] = g[k][j]; // 只取一条边的权,不累加!
closest[j] = k;
}
}
}
return cnt == n - 1 ? sum : -1;
}
int main() {
n = 7; m = 11;
memset(g, 0x3f, sizeof(g));
for (int i = 0; i < n; i++) g[i][i] = 0;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < m; i++) {
int u = E[i][0], v = E[i][1], w = E[i][2];
g[u][v] = g[v][u] = w;
}
printf("MST 总权值 = %d\n", prim(0));
return 0;
}
lowcost[j] = min(lowcost[j], lowcost[k] + g[k][j]) 是错的。
那样算出来的是「从起点 s 到 j 的路径长度」,是 Dijkstra 的写法。
Prim 关心的是「j 离整棵树有多近」,只要接上一条边就行,所以是
lowcost[j] = min(lowcost[j], g[k][j])。
Prim 堆优化版 O(m log n):链式前向星 + priority_queue
朴素版的瓶颈在「每轮扫一遍数组找最小」,这是 O(n²)。用一个小根堆维护候选边, 就能把每次找最小降到 O(log m)。总复杂度 O(m log m) = O(m log n)(因为 m ≤ n²,log m ≤ 2 log n)。 在稀疏图上这比 O(n²) 快得多。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
const int INF = 0x3f3f3f3f;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, w, nxt; } e[MAXM];
void addEdge(int u, int v, int w) { e[etot] = {v, w, head[u]}; head[u] = etot++; }
bool vis[MAXN];
int dist_[MAXN]; // 各顶点到「已选点集」的最小边权
/* 堆里存 pair<边权, 顶点>,按第一关键字排序 ⇒ 小根堆 */
int prim(int s) {
memset(vis, 0, sizeof(vis));
memset(dist_, 0x3f, sizeof(dist_));
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>> > pq;
dist_[s] = 0;
pq.push(make_pair(0, s));
int sum = 0, cnt = 0;
while (!pq.empty()) {
pair<int,int> t = pq.top(); pq.pop();
int d = t.first, u = t.second;
if (vis[u]) continue; // 过期元素:这个点早就进树了,跳过
vis[u] = true;
sum += d; cnt++;
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to, w = e[i].w;
if (!vis[v] && w < dist_[v]) { // 同样是「一条边的权」,不累加
dist_[v] = w;
pq.push(make_pair(w, v));
}
}
}
return cnt == n ? sum : -1;
}
int main() {
n = 7;
memset(head, -1, sizeof(head));
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < 11; i++) {
addEdge(E[i][0], E[i][1], E[i][2]);
addEdge(E[i][1], E[i][0], E[i][2]); // 无向边存两次
}
printf("堆优化 Prim:MST 总权值 = %d\n", prim(0));
return 0;
}
- 为什么会有「过期元素」:同一个顶点可能被多次压入堆(每次发现更短的连接边就压一次),
所以出堆时要
if (vis[u]) continue;跳过。不做这一步,答案会重复累加。 - 「不累加」体现在哪:压入堆的是
w(一条边的权),而 Dijkstra 压入的是dist[u] + w。 就这一点差别,决定了两个算法解的是不同的问题。 - 复杂度:每条边最多引发一次入堆,共 O(m) 次堆操作,每次 O(log m),所以是 O(m log m); 空间 O(n + m)。
9.2.4 Kruskal 算法:按边权从小到大「能用就用」
一句话本质
Kruskal 的思路比 Prim 更「朴素」:既然要总权最小,那就从最便宜的边开始买。 但便宜边可能连的是已经连通的两个点,硬加进去就成环了,而生成树不允许有环 —— 于是丢弃。 这个「判断两端点是否已连通」的动作,正好是并查集(Disjoint Set Union, DSU)的拿手好戏: 查询与合并几乎都是 O(1)。
并查集:Kruskal 的心脏
并查集维护若干个不相交的集合,支持两种操作:
find(x) 返回 x 所在集合的「代表元(树根)」,
union(x,y) 把两个集合合并。两个优化是它的精髓:
- 路径压缩(path compression):
find时把沿途所有结点直接挂到根上, 下次查询就是 O(1)。写法就是递归里的fa[x] = find(fa[x])。 - 按秩合并(union by rank/size):合并时把「矮树」挂到「高树」下,
避免树退化成链。用
siz[]或rank[]记录。
两个优化一起用,单次操作的均摊复杂度是 O(α(n)),其中 α 是反阿克曼函数, 在 n 取到宇宙原子总数级别时也不超过 4 —— 工程上直接当成常数。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int fa[MAXN], rnk[MAXN]; // fa: 父指针;rnk: 秩(树高上界)
void dsuInit(int n) {
for (int i = 0; i < n; i++) { fa[i] = i; rnk[i] = 0; }
}
/* 路径压缩:递归写法最简洁;写成非递归可避免深递归爆栈 */
int find(int x) {
if (fa[x] == x) return x;
return fa[x] = find(fa[x]); // 关键:顺手把 x 挂到根上
}
/* 按秩合并:返回是否真的合并了(false 表示原本就在同一集合) */
bool unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false; // 同集合,不需要合并
if (rnk[rx] < rnk[ry]) swap(rx, ry); // 让 rx 成为较高的那棵
fa[ry] = rx;
if (rnk[rx] == rnk[ry]) rnk[rx]++;
return true;
}
/* 批量合并的标准写法:第一遍只查根(不修改树),第二遍才真正挂接。
这样 rank 的比较用的都是合并前的真实树高,按秩合并才真正有效。 */
bool uniteAll(int (*edges)[2], int cnt) {
int rx[MAXN], ry[MAXN];
for (int i = 0; i < cnt; i++) {
int a = find(edges[i][0]), b = find(edges[i][1]);
if (a != b) { rx[i] = a; ry[i] = b; } else { rx[i] = ry[i] = -1; }
}
bool any = false;
for (int i = 0; i < cnt; i++) {
if (rx[i] < 0) continue;
int ra = find(rx[i]), rb = find(ry[i]); // 可能已被前几次合并影响,需重新查根
if (ra == rb) continue;
if (rnk[ra] < rnk[rb]) swap(ra, rb);
fa[rb] = ra;
if (rnk[ra] == rnk[rb]) rnk[ra]++;
any = true;
}
return any;
}
/* 更朴素的按大小合并版本(等价,且不用处理 rnk 的细节) */
int siz[MAXN];
void dsuInit2(int n) { for (int i = 0; i < n; i++) { fa[i] = i; siz[i] = 1; } }
bool unite2(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false;
if (siz[rx] < siz[ry]) swap(rx, ry); // 小集合挂到大集合上
fa[ry] = rx; siz[rx] += siz[ry];
return true;
}
int main() {
dsuInit(7);
/* 依次加入 Kruskal 选中的边,观察集合变化 */
int pick[6][2] = {{0,1},{1,2},{3,4},{0,3},{2,6},{3,5}};
for (int i = 0; i < 6; i++) {
bool ok = unite(pick[i][0], pick[i][1]);
printf("合并 (%d,%d) ? %s 集合根:", pick[i][0], pick[i][1], ok ? "成功" : "已在同一集合");
for (int v = 0; v < 7; v++) printf("%d ", find(v));
printf("\n");
}
/* 再试一条会成环的边 */
printf("尝试 (0,2):%s\n", unite(0, 2) ? "合并成功" : "两端已连通 → 丢弃");
return 0;
}
unite 里必须先判 rx == ry
如果直接写 fa[find(x)] = find(y); 而不判断是否同集合,当 x、y 已在同一集合时,
会把根的父亲指向自己所在集合的根,虽然有时「碰巧」不出错,
但一旦配合按秩合并就会破坏秩的定义,甚至让 find 死循环。
请务必保留 if (rx == ry) return false; 这一行。
Kruskal 手推过程表(同一个示例图 G)
Kruskal 的第一步是排序,这一步本身就是考点。排序后的边序列(权值相同时按端点编号排):
| 次序 | 边 | 权值 | 两端点所在集合(find 结果) | 是否同集合 | 决策 | 累计边数 | 累计权值 | 并查集合并 |
|---|---|---|---|---|---|---|---|---|
| 1 | (0,1) | 2 | {0} vs {1} | 否 | 采纳 | 1 | 2 | {0,1} |
| 2 | (1,2) | 2 | {0,1} vs {2} | 否 | 采纳 | 2 | 4 | {0,1,2} |
| 3 | (3,4) | 2 | {3} vs {4} | 否 | 采纳 | 3 | 6 | {3,4} |
| 4 | (0,2) | 3 | {0,1,2} vs {0,1,2} | 是 | 丢弃(成环 0–1–2–0) | 3 | 6 | 不变 |
| 5 | (0,3) | 3 | {0,1,2} vs {3,4} | 否 | 采纳 | 4 | 9 | {0,1,2,3,4} |
| 6 | (2,6) | 4 | {0,1,2,3,4} vs {6} | 否 | 采纳 | 5 | 13 | {0,1,2,3,4,6} |
| 7 | (1,3) | 5 | {0,...} vs {0,...} | 是 | 丢弃(成环 1–2–6? 实为 1–0–3) | 5 | 13 | 不变 |
| 8 | (3,5) | 5 | {0,1,2,3,4,6} vs {5} | 否 | 采纳 | 6 | 18 | 全部合并,结束 |
| 9 | (4,6) | 5 | — | — | 已选满,无需处理 | 6 | 18 | — |
| 10 | (1,5) | 6 | — | — | 已选满,无需处理 | 6 | 18 | — |
| 11 | (0,6) | 11 | — | — | 已选满,无需处理 | 6 | 18 | — |
第 7 条边 (1,3) 被丢弃的原因值得说清楚:此时 1 已在集合 {0,1,2,3,4,6} 中(路径 1–0–3 已经连通),
3 也在同一个集合里,再加上 (1,3) 就形成了环 1–0–3–1。
丢弃的判据永远只有一个:两端点 find 的结果是否相同。
下面动画把每一条边的判定过程演一遍,注意右侧并查集集合的变化:
Kruskal 完整实现 O(m log m)
复杂度由排序主导:排序 O(m log m),并查集部分 O(m α(n)),合计 O(m log m)。 与顶点数 n 关系不大,只看边数 —— 所以 Kruskal 适合稀疏图。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 200005;
int n, m;
int fa[MAXN], siz[MAXN];
int find(int x) { return fa[x] == x ? x : (fa[x] = find(fa[x])); }
bool unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false;
if (siz[rx] < siz[ry]) swap(rx, ry);
fa[ry] = rx; siz[rx] += siz[ry];
return true;
}
void dsuInit() { for (int i = 0; i < n; i++) { fa[i] = i; siz[i] = 1; } }
struct Edge { int u, v, w; };
Edge e[MAXM];
bool cmp(const Edge& a, const Edge& b) {
if (a.w != b.w) return a.w < b.w; // 先按权值升序
if (a.u != b.u) return a.u < b.u; // 权值相同按端点编号(让结果可复现)
return a.v < b.v;
}
/* 返回最小生成树权值和;图不连通时返回 -1(此时得到的是最小生成森林) */
int kruskal() {
sort(e, e + m, cmp);
dsuInit();
int sum = 0, cnt = 0;
for (int i = 0; i < m && cnt < n - 1; i++) {
if (unite(e[i].u, e[i].v)) { // 不同集合 → 采纳
sum += e[i].w; cnt++;
printf("采纳第 %d 条边 (%d,%d) 权 %d,累计 %d\n", i + 1, e[i].u, e[i].v, e[i].w, sum);
} else {
printf("丢弃第 %d 条边 (%d,%d) 权 %d:两端已连通,会成环\n", i + 1, e[i].u, e[i].v, e[i].w);
}
}
return cnt == n - 1 ? sum : -1;
}
int main() {
n = 7; m = 11;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < m; i++) e[i] = {E[i][0], E[i][1], E[i][2]};
printf("MST 总权值 = %d\n", kruskal());
return 0;
}
9.2.5 Prim vs Kruskal:怎么选
| 对比维度 | Prim(朴素) | Prim(堆优化) | Kruskal |
|---|---|---|---|
| 核心思想 | 长点:每次把离树最近的顶点吞进来(割性质) | 长边:每次收下不与已选边成环的最小边(环性质) | |
| 时间复杂度 | O(n²) | O(m log m) = O(m log n) | O(m log m)(排序主导) |
| 空间复杂度 | O(n²)(邻接矩阵) | O(n + m) | O(n + m) |
| 适用图类型 | 稠密图(m ≈ n²) | 稀疏图、中等规模 | 稀疏图(m ≈ n) |
| 是否需要并查集 | 不需要 | 不需要 | 必须 |
| 是否需要对所有边排序 | 不需要 | 不需要(堆只排候选边) | 需要,O(m log m) 是瓶颈 |
| 实现难度 | 最简单(20 行) | 中等(堆 + 前向星) | 中等(排序 + 并查集) |
| 能否处理非连通图 | 只能得到起点所在连通块的 MST | 同左 | 自然得到最小生成森林(每个连通块一棵) |
| 适用场景 | 稠密图、n 较小(n ≤ 5000) | 大稀疏图 | 大稀疏图、需要按边权顺序处理时 |
另外两个常考的判断题:「Prim 适合稠密图,Kruskal 适合稀疏图」——对; 「Kruskal 需要并查集,Prim 不需要」——对。
9.3 最短路径(一):Dijkstra 单源最短路
9.3.1 问题定义与「松弛」的本质
单源最短路径(Single Source Shortest Path, SSSP):给定带权图和源点 s,
求 s 到其余每个顶点的最短路径长度 dist[]。
注意「最短」指的是路径上所有边权之和最小,而不是边数最少。
所有最短路算法都建立在同一个动作之上:松弛(relaxation)。 它只有一行:
为什么松弛是对的?用一句直观的话说:
「如果你已经知道到 u 的最短路长度是 dist[u],那么从 u 再走一条权为 w 的边到 v,
就得到了一条到 v 的长度为 dist[u]+w 的候选路线;既然是个候选,就该和现有的最好成绩比一比,谁小留谁。」
严谨一点的说法:设 s→v 的真实最短路径最后一条边是 (u,v),那么这条路径的前半段必然是 s→u 的最短路径
(否则换掉前半段能得到更短的 s→v 路径,矛盾)—— 这条「最优子结构」性质保证了
只要我们把每条边的两端都松弛到位,最终一定能算出所有最短路。
区别只在于:Dijkstra 用贪心决定「先松弛谁」,Bellman-Ford 无脑地把所有边松弛 n−1 轮。
- 成功:
dist[u] + w < dist[v],更新 dist[v](并记下pre[v] = u,供路径还原)。 - 失败(更差):
dist[u] + w ≥ dist[v],什么也不做。 - 无法松弛:
dist[u] = ∞(u 还不可达),直接跳过,否则 ∞+w 会溢出。
9.3.2 算法流程与贪心前提
- 初始化:
dist[s] = 0,其余dist[i] = ∞;visited[]全为 false;pre[]全为 −1。 - 选点(贪心):在
visited = false的顶点中,选dist最小的那个顶点 u。 - 确定:标记
visited[u] = true。此时dist[u]就永远定下来了,之后不再修改。 - 松弛:对 u 的每个未确定邻接点 v,执行
dist[v] = min(dist[v], dist[u] + w(u,v))。 - 重复 2~4 共 n 次(每次确定一个顶点)。若某轮选出的最小 dist 仍为 ∞,说明剩下的点都不可达,可以提前结束。
贪心前提(正确性根据):为什么「当前 dist 最小的未确定顶点」一定已经是最优解?
设选出的顶点是 u,dist[u] = d。假设真实最短路更短,那么存在一条从 s 到 u 的路径,
它必然要先经过某个「未确定」的顶点 x(因为所有已确定顶点的 dist 都是最终的),
于是 dist[x] ≤ d(前缀长度不超过全路径长度,权值非负!)。
既然 u 是 dist 最小的未确定顶点,就有 dist[x] ≥ d,于是 dist[x] = d —— 那选 x 也一样,
不会得到更短的结果,矛盾。
看清最后一步用了什么:边的权值非负。如果允许负权,「前缀长度不超过全路径长度」就不再成立,
整个论证崩塌。这就是 Dijkstra 不能处理负权边的根本原因(详见 9.3.7)。
- 每次确定一个顶点,共确定 n 次,已确定集合不断扩大。
- 已确定顶点的 dist 不再改变(这是它和 Bellman-Ford / SPFA 的最大区别,也是不能有负权的原因)。
- Dijkstra 属于贪心算法,不是动态规划;它的正确性依赖「非负权」这个前提。
9.3.3 Dijkstra 手推过程表(考点中的考点)
以示例图 G 为例,从顶点 0 出发。每轮记录 dist[]、visited[]、
选中的顶点以及被松弛的边。表中加粗的数值表示本轮被更新。
| 轮次 | dist[0..6] | visited(已确定) | 选中顶点 | 被松弛的边 |
|---|---|---|---|---|
| 初始 | 0, ∞, ∞, ∞, ∞, ∞, ∞ | {} 空 | — | — |
| 第 1 轮 | 0, ∞, ∞, ∞, ∞, ∞, ∞ | {} → {0} | 0(dist = 0) | (0,1) ⇒ dist[1]=2 (0,2) ⇒ dist[2]=3 (0,3) ⇒ dist[3]=3 (0,6) ⇒ dist[6]=11 |
| 0, 2, 3, 3, ∞, ∞, 11 | 本轮松弛后:四个顶点获得了初始估计值 | |||
| 第 2 轮 | 0, 2, 3, 3, ∞, ∞, 11 | {0} → {0,1} | 1(最小 2) | (1,2):2+2=4 > 3,失败 (1,3):2+5=7 > 3,失败 (1,5):2+6=8 < ∞ ⇒ dist[5]=8 |
| 0, 2, 3, 3, ∞, 8, 11 | 只有 dist[5] 被更新(经 1 比直连更划算?——此处 5 原本不可达) | |||
| 第 3 轮 | 0, 2, 3, 3, ∞, 8, 11 | {0,1} → {0,1,2} | 2(最小 3,与 3 并列时取编号小的) | (2,6):3+4=7 < 11 ⇒ dist[6]=7 (2,1) 已确定 |
| 0, 2, 3, 3, ∞, 8, 7 | dist[6] 从 11 降到 7:直连 (0,6) 要 11,绕 0→1→2→6 只要 7 | |||
| 第 4 轮 | 0, 2, 3, 3, ∞, 8, 7 | {0,1,2} → {0,1,2,3} | 3(最小 3) | (3,4):3+2=5 < ∞ ⇒ dist[4]=5 (3,5):3+5=8 = 8,不更新(等号不更新) (3,1)、(3,0) 已确定 |
| 0, 2, 3, 3, 5, 8, 7 | dist[5] 保持 8:经 1 的 0→1→5 = 8 与经 3 的 0→3→5 = 8 一样长 | |||
| 第 5 轮 | 0, 2, 3, 3, 5, 8, 7 | {0,1,2,3} → {0,1,2,3,4} | 4(最小 5) | (4,6):5+5=10 > 7,失败 (4,3) 已确定 本轮 dist 无变化 |
| 第 6 轮 | 0, 2, 3, 3, 5, 8, 7 | {0,1,2,3,4} → {0,1,2,3,4,6} | 6(最小 7) | (6,0)、(6,2)、(6,4) 全部已确定 本轮 dist 无变化 |
| 第 7 轮 | 0, 2, 3, 3, 5, 8, 7 | → {0,1,2,3,4,5,6} 全部 | 5(dist = 8) | 邻接点 1、3 均已确定 算法结束 |
| 结果 | dist = [0, 2, 3, 3, 5, 8, 7];从 0 到 5 的最短路径为 0 → 1 → 5,长度 8 | |||
- 松弛时相等不更新:第 4 轮
0→3→5 = 8,与已有的dist[5] = 8相等。 代码里的判断是严格小于<,所以不更新,pre[5]保持为 1。 于是最终还原出的路径是 0 → 1 → 5,而不是 0 → 3 → 5。两条路一样长,都对, 但若题目问「按本算法得到的路径」,就要按代码行为回答。 - 选点并列:第 3 轮
dist[2] = dist[3] = 3。上面的表格选了编号小的 2。 如果改成选 3,最短距离数组完全不变,但pre[]会不同(dist[6] 可能变成经 3 的 3+7=10 而不更新, 仍是 7),最终最短路树不一样。结论:dist 数组唯一(权值和唯一),最短路树不唯一。 - 不要忘记 ∞ 的传播:非连通图的某些顶点 dist 永远是 ∞,写代码时要记得跳过(
dist[u] < INF)。
下面动画逐轮演示选点与松弛,橙色表示本轮被更新的顶点:
9.3.4 朴素实现 O(n²)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int INF = 0x3f3f3f3f;
int n;
int g[MAXN][MAXN];
int dist_[MAXN];
bool vis[MAXN];
int pre[MAXN]; // 记录前驱,用于路径还原
void dijkstra(int s) {
memset(vis, 0, sizeof(vis));
memset(pre, -1, sizeof(pre));
for (int i = 0; i < n; i++) dist_[i] = g[s][i]; // 邻接矩阵直接给出初始估计
dist_[s] = 0; vis[s] = true;
for (int round = 1; round < n; round++) {
/* 选点:未确定顶点中 dist 最小的 */
int u = -1;
for (int i = 0; i < n; i++)
if (!vis[i] && (u == -1 || dist_[i] < dist_[u])) u = i;
if (u == -1 || dist_[u] == INF) break; // 剩下的都不可达
vis[u] = true;
/* 松弛:注意这里是 dist_[u] + g[u][v],要累加! */
for (int v = 0; v < n; v++) {
if (!vis[v] && g[u][v] < INF && dist_[u] + g[u][v] < dist_[v]) {
dist_[v] = dist_[u] + g[u][v];
pre[v] = u;
}
}
}
}
int main() {
n = 7;
memset(g, 0x3f, sizeof(g));
for (int i = 0; i < n; i++) g[i][i] = 0;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < 11; i++) {
g[E[i][0]][E[i][1]] = E[i][2];
g[E[i][1]][E[i][0]] = E[i][2];
}
dijkstra(0);
printf("dist = ");
for (int i = 0; i < n; i++) printf("%d ", dist_[i]);
printf("\n");
return 0;
}
9.3.5 堆优化实现 O(m log n)
和 Prim 一样,瓶颈在「找最小值」。把候选的 (dist, v) 压进小根堆, 每轮取出堆顶即可。总复杂度 O(m log m)。 堆优化的 Dijkstra 必须用邻接表(链式前向星),否则光是遍历矩阵就是 O(n²)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
const int INF = 0x3f3f3f3f;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, w, nxt; } e[MAXM];
void addEdge(int u, int v, int w) { e[etot] = {v, w, head[u]}; head[u] = etot++; }
int dist_[MAXN];
bool vis[MAXN];
int pre[MAXN];
void dijkstra(int s) {
memset(dist_, 0x3f, sizeof(dist_));
memset(vis, 0, sizeof(vis));
memset(pre, -1, sizeof(pre));
/* 小根堆:pair 默认按 first 比较,greater 让它变成小根堆 */
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>> > pq;
dist_[s] = 0;
pq.push(make_pair(0, s));
while (!pq.empty()) {
pair<int,int> top = pq.top(); pq.pop();
int d = top.first, u = top.second;
if (vis[u]) continue; // 过期元素:u 已经被确定过了,跳过
vis[u] = true;
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to, w = e[i].w;
if (dist_[u] + w < dist_[v]) { // 这里是「累加」,与 Prim 不同!
dist_[v] = dist_[u] + w;
pre[v] = u;
pq.push(make_pair(dist_[v], v));
}
}
}
}
int main() {
n = 7;
memset(head, -1, sizeof(head));
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < 11; i++) {
addEdge(E[i][0], E[i][1], E[i][2]);
addEdge(E[i][1], E[i][0], E[i][2]);
}
dijkstra(0);
printf("dist = ");
for (int i = 0; i < n; i++) printf("%d ", dist_[i]);
printf("\n");
return 0;
}
!vis[v],堆优化版通常不判断(判断了也不错)。
为什么可以不判断?因为如果 v 已确定,那么 dist_[u] + w ≥ dist_[v] 必然成立
(非负权 + u 的 dist 不小于 v 的),松弛自然失败。但如果图里有负权边,这个「自然失败」就不成立了,
所以堆优化 Dijkstra 一样不能用于负权图。
9.3.6 路径还原:pre[] 数组
只求长度太浪费了 —— 只要在松弛成功时记下 pre[v] = u,
就能在算法结束后从终点一路往回退,把整条路径还原出来。
由于每个顶点最多有一个 pre,所有顶点连起来是一棵以源点为根的树,
称为最短路树(shortest path tree)。
dist 的初值(省掉一次松弛)。
但这样一来,那些一步就能到达的顶点(g[s][i] < INF)在后续轮次里
因为「不会变得更小」而永远不会触发 pre[i] = u,
于是 pre[i] 一直是 −1,路径还原会得到空路径。
解决办法就是初始化时顺手写上
if (i != s && g[s][i] < INF) pre[i] = s;。
这个小坑非常隐蔽(距离数组完全正确,只有路径还原出错),务必注意。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int INF = 0x3f3f3f3f;
int n;
int g[MAXN][MAXN], dist_[MAXN], pre[MAXN];
bool vis[MAXN];
void dijkstra(int s) {
memset(vis, 0, sizeof(vis));
memset(pre, -1, sizeof(pre));
for (int i = 0; i < n; i++) {
dist_[i] = g[s][i];
if (i != s && g[s][i] < INF) pre[i] = s; // 关键:一步可达的点,前驱就是 s
}
dist_[s] = 0; vis[s] = true;
for (int r = 1; r < n; r++) {
int u = -1;
for (int i = 0; i < n; i++) if (!vis[i] && (u == -1 || dist_[i] < dist_[u])) u = i;
if (u == -1 || dist_[u] == INF) break;
vis[u] = true;
for (int v = 0; v < n; v++)
if (!vis[v] && g[u][v] < INF && dist_[u] + g[u][v] < dist_[v]) {
dist_[v] = dist_[u] + g[u][v];
pre[v] = u; // 关键:记下前驱
}
}
}
/* 迭代法还原路径:从 t 往前推,再反转。顺序 O(路径长度) */
vector<int> getPath(int s, int t) {
vector<int> path;
if (dist_[t] == INF) return path; // 不可达
for (int cur = t; cur != -1; cur = pre[cur]) path.push_back(cur);
reverse(path.begin(), path.end());
if (path.empty() || path[0] != s) return vector<int>(); // 保护
return path;
}
/* 递归法还原路径(正序输出,思路更直观,但路径长时递归深度大) */
void printPath(int s, int t) {
if (t == s) { printf("%d", s); return; }
if (pre[t] == -1) { printf("不可达"); return; }
printPath(s, pre[t]);
printf(" -> %d", t);
}
int main() {
n = 7;
memset(g, 0x3f, sizeof(g));
for (int i = 0; i < n; i++) g[i][i] = 0;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < 11; i++) g[E[i][0]][E[i][1]] = g[E[i][1]][E[i][0]] = E[i][2];
dijkstra(0);
vector<int> p = getPath(0, 5);
printf("0 到 5 的最短路径:");
for (size_t i = 0; i < p.size(); i++) printf("%d%s", p[i], i + 1 < p.size() ? " -> " : "");
printf(",长度 %d\n", dist_[5]);
printf("递归输出 0 到 6:"); printPath(0, 6); printf(",长度 %d\n", dist_[6]);
return 0;
}
pre[s] = −1 开始
因为 pre[] 初始化为 −1,且源点的前驱不会被设置(源点自己不需要松弛),
所以「沿 pre 往回走直到 −1」一定停在源点。若担心越界,可以在循环里加 steps < n 的保护,
防止图中有负环时陷入死循环(虽然 Dijkstra 不会遇到负环)。
9.3.7 为什么 Dijkstra 不能处理负权边
这是 408 与面试的高频追问。答案分两层:一层是「贪心前提被破坏」的原理, 一层是「拿一个反例把它算错」的实证。两层都要会。
原理层:Dijkstra 每轮把 dist 最小的未确定顶点「永久锁定」,依据是 「它的 dist 不可能再变小了」。这个依据成立的前提是后面发现的任何绕行路径都不会更短, 而绕行路径 = 已在路上的长度 + 一条非负的边。一旦允许负权边, 「绕一圈回来反而更短」就出现了,被锁定的值可能是错的,而且错了还改不回来。
把上面这个反例完整地算一遍(四条有向边:0→1 权 1、0→2 权 2、2→1 权 −2、1→3 权 4,源点为 0):
| 轮次 | 选中的顶点 | dist[0..3] | 说明 |
|---|---|---|---|
| 初始 | — | 0, ∞, ∞, ∞ | 源点 0 |
| 第 1 轮 | 0(dist = 0) | 0, 1, 2, ∞ | 松弛 (0,1) 与 (0,2) |
| 第 2 轮 | 1(dist = 1)← 被锁定 | 0, 1, 2, 5 | 松弛 (1,3):1+4 = 5。算法此时认定「到 1 的最短路就是直连的 1」,并据此定了 dist[3] |
| 第 3 轮 | 2(dist = 2) | 0, 1, 2, 5 | 发现边 2→1 权为 −2:2 + (−2) = 0 < 1,本应更新 dist[1];但 1 已确定、不再接受更新,这一发现白白浪费 |
| 第 4 轮 | 3(dist = 5) | 0, 1, 2, 5 | 结束。3 的 5 是由错误的 dist[1] 推出来的,同样错 |
| 算法输出 | dist = [0, 1, 2, 5] | 顶点 1、3 的距离都错了 | |
| 真实答案 | dist = [0, 0, 2, 4] | 0 → 2 → 1 长度 2 + (−2) = 0;0 → 2 → 1 → 3 长度 0 + 4 = 4 | |
请特别注意第 3 轮那件事有多讽刺:算法确实「看见」了更短的路径(dist[1] 本可以降到 0), 但它已经没有资格修改 dist[1] 了。更糟的是,错误的 dist[1] 早已在上一轮把 dist[3] 也带偏了 —— 一个被过早锁定的错误值,会沿着后续的松弛一路污染下去。
dist[y] + w(y,x) < dist[x](这只能是负权造成的)。
可是取出顺序按 dist 从小到大,所以 dist[x] ≤ dist[y],于是
必须有 w(y,x) < 0(否则不等式不可能成立)。
本例中 x = 1(dist 1)、y = 2(dist 2),负边恰好是 2→1 —— 方向与取出顺序相反。
这也解释了为什么「只有负权边才可能让 Dijkstra 出错」,而负环则会让最短路根本不存在。
更极端的情形是「负权回路」:如果存在一个总权为负的环, 绕着它多走一圈总长度就更小,最短路径根本不存在(可以无限小)。 这种图上任何最短路算法都给不出有意义的结果,只能用 Bellman-Ford 把负环检测出来并报告。
- Dijkstra 要求所有边权非负;有负权边时必须换 Bellman-Ford 或 SPFA。
- 「被确定后不再修改」是 Dijkstra 高效的原因,也是它面对负权边时失效的原因 —— 成也贪心,败也贪心。
- 负权边不等于负环。只有从源点可达的负环才会让最短路无意义; 如果负环不可达,Bellman-Ford 仍然能给出正确答案。
9.3.8 与 BFS 求无权最短路的联系
回忆第 04 讲的 BFS:在无权图(或所有边权都为 1 的图)上, BFS 第一次访问到某个顶点时的层数,就是从源点到它的最短路径长度。 BFS 其实就是 Dijkstra 在「权值全为 1」时的特例:
| 对比项 | BFS(无权图) | Dijkstra(非负权图) |
|---|---|---|
| 待处理顶点的组织方式 | 普通队列(先进先出) | 优先队列(按 dist 出队) |
| 为什么这样够用 | 每条边长一样,先入队的必然先到、必然最短 | 边长不同,必须按累计长度排序才能保证「出队即最优」 |
| 复杂度 | O(n + m) | O(m log n)(堆优化) |
| 两者关系 | 把每条边看成权为 1,Dijkstra 的优先队列退化成普通队列(因为 dist 只会是 0,1,2,…),两者完全等价 | |
顺带一提,还有一个「0-1 BFS」的经典技巧:如果边权只有 0 和 1, 把普通队列换成双端队列 deque(权 0 的边把新点压到队首,权 1 的压到队尾), 就能在 O(n + m) 内求出最短路,比堆优化更快。它是 BFS 与 Dijkstra 之间的中间形态。
9.4 最短路径(二):Floyd 全源最短路
9.4.1 全源最短路问题
Dijkstra 解决的是「一个源点到所有点」;如果要求「任意两点之间」的距离(全源最短路,All-Pairs Shortest Paths), 最简单的做法是「对每个顶点跑一遍 Dijkstra」,复杂度 O(n·m log n); 而 Floyd-Warshall 算法用三重循环、O(n³) 一次性求出所有点对的距离, 代码只有 5 行,是「短小精悍」的典范。
它的适用条件:允许有负权边,但不允许有负环(有负环时结果无意义,不过我们可以顺便检测出来)。
9.4.2 动态规划的思想:从 dp[k][i][j] 到二维滚动
先想一个「笨」但清晰的 DP。定义:
注意「只允许经过 ≤ k 的顶点中转」这句话的准确含义:路径的中间点只能从 {0,1,…,k} 里挑, 但起点 i 和终点 j 可以是任意顶点,而且路径的中间点允许重复经过编号更小的点。
考虑第 k 个中转点「用还是不用」,只有两种可能:
- 不用 k 中转:那路径的中间点都在 {0,…,k−1} 里,长度就是
dp[k−1][i][j]。 - 用 k 中转:路径形如 i → … → k → … → j,其中 i 到 k 的那段和 k 到 j 的那段,
中间点都只用 {0,…,k−1}(k 本身只作为端点出现一次),长度就是
dp[k−1][i][k] + dp[k−1][k][j]。
取两者较小值,得到状态转移方程:
边界(k = −1,即不允许任何中转):dp[i][j] = w(i,j),无边为 ∞,dp[i][i] = 0。
这正好就是邻接矩阵。
二维滚动(就地更新):注意 k 这一维只依赖 k−1,所以可以把这一维省掉, 直接在邻接矩阵上原地更新:
但「省掉一维」是有条件的:k 必须放在最外层。下一小节专门讲这个坑。
9.4.3 为什么 k 必须在最外层(最经典的易错点)
for k → for i → for j。
写成 for i → for j → for k 或 for i → for k → for j 都会出错。
原因不是「效率问题」,而是「答案直接算错」。
为什么?回看状态方程:dp[k][i][j] 依赖的是 dp[k−1][i][k] 与 dp[k−1][k][j],
这两个值必须在「处理 k 这一轮」时全部是已经算好的 k−1 轮结果。
省掉 k 维后,「k−1 轮的结果」和「k 轮的结果」共用同一块内存,
这就要求:当我们要用 dist[i][k] 和 dist[k][j] 时,它们必须还没有被本轮(k 轮)修改过。
把 k 放在最外层时,枚举到 (i,j) 时 dist[i][k] 与 dist[k][j] 长什么样?
dist[i][k] 的下标里有一维等于 k —— 它在「本轮」会不会被改?
本轮所有更新都发生在 dist[k][*] 这一行与 dist[*][k] 这一列上,
而 dist[i][k] 恰好在第 k 列、dist[k][j] 恰好在第 k 行。
那不就正好被改了吗?关键在这里:第 k 行/列上的元素在本轮根本不会变小。
因为更新第 k 行需要 dist[k][k] + dist[k][j],而 dist[k][k] = 0(无负环时),
算出来等于 dist[k][j] 自身,不可能变小;第 k 列同理。
所以第 k 行、第 k 列在整个第 k 轮里保持的正是 k−1 轮的旧值 —— 滚动更新合法。
反过来,把 i 放在最外层(for i → for k → for j)会发生什么?
- 处理 i = 0 时,我们用 k = 3 去更新
dist[0][j],这时dist[3][j]里的值 可能还只是「完全没有中转」的原始值(因为 i = 3 那一行还没轮到处理), 甚至dist[0][3]自己都还没被 k = 1、k = 2 更新过。 - 结果就是:有些中转组合被漏掉了,最后得到的矩阵偏大,答案错误。
- 最直观的表现:算法跑完之后,矩阵还没有收敛—— 你再跑一遍三重循环,矩阵还会变小(正确的 Floyd 跑第二遍时矩阵不变,这是检验写法的土办法)。
0→3 权 1、3→1 权 1、1→2 权 1。
真实最短距离:dist[0][2] = 3(0 → 3 → 1 → 2),它需要依次用 k = 3、k = 1 两次中转。
- 正确的
k → i → j:k = 1 这轮先算出dist[3][2] = 2; 到 k = 3 这轮再算dist[0][2] = dist[0][3] + dist[3][2] = 1 + 2 = 3。✔ 正确。 - 错误的
i → k → j:先处理 i = 0(k 从 0 到 3)—— 此时要用dist[3][2],而dist[3][2]还只被 k = 1 这一轮改变过, 可是 i = 3 那一行还没开始处理,所以dist[3][2]仍是 ∞, 于是dist[0][2]算不出来。等到后面处理 i = 3 时把dist[3][2]改小了, 但 i = 0 已经处理完了,没机会回来重算。最终dist[0][2] = ∞(或 3 之外的错误值)。✘ 错误。
一句话记忆法:「先放开中转点,再枚举起点终点」。 k 是「本轮新增的自由度」,必须作为最外层的一层「阶段」; i、j 只是同一阶段内的状态枚举,谁在外谁在内无所谓(但都必须在 k 里面)。
9.4.4 完整实现与手推矩阵变化
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505;
const int INF = 0x3f3f3f3f;
int n, m;
int dist_[MAXN][MAXN]; // 直接在邻接矩阵上滚动更新
void floyd() {
for (int k = 0; k < n; k++) // ① 中转点必须是最外层!
for (int i = 0; i < n; i++) // ② 起点
for (int j = 0; j < n; j++) // ③ 终点
if (dist_[i][k] + dist_[k][j] < dist_[i][j])
dist_[i][j] = dist_[i][k] + dist_[k][j];
}
/* 常用小优化:跳过不可达的中转点,稀疏图上能省不少时间 */
void floyd_fast() {
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
if (dist_[i][k] == INF) continue; // i 到不了 k,k 中转无意义
int dik = dist_[i][k];
for (int j = 0; j < n; j++)
if (dik + dist_[k][j] < dist_[i][j])
dist_[i][j] = dik + dist_[k][j];
}
}
}
int main() {
n = 4; m = 5;
memset(dist_, 0x3f, sizeof(dist_));
for (int i = 0; i < n; i++) dist_[i][i] = 0;
int E[5][3] = {{0,1,1},{1,2,1},{2,3,1},{0,3,10},{1,3,4}};
for (int i = 0; i < m; i++) {
int u = E[i][0], v = E[i][1], w = E[i][2];
dist_[u][v] = min(dist_[u][v], w); // 重边取最小!
dist_[v][u] = min(dist_[v][u], w); // 无向图;有向图删掉这一行
}
floyd();
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++)
printf("%4d", dist_[i][j] == INF ? -1 : dist_[i][j]);
printf("\n");
}
return 0;
}
下面用贯穿全章的示例图 G 手推 Floyd 的完整矩阵变化(n = 7,k = 0..6)。 为了便于对照,我把每一轮的变化单独列一张表,加粗的格子是本轮被更新的元素。 (自己手推时不必写满 7 张表,写 2~3 张并说清「哪些格子会变」即可。)
k = −1(初始矩阵 = 邻接矩阵):
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | ∞ | ∞ | 11 |
| i=1 | 2 | 0 | 2 | 5 | ∞ | 6 | ∞ |
| i=2 | 3 | 2 | 0 | ∞ | ∞ | ∞ | 4 |
| i=3 | 3 | 5 | ∞ | 0 | 2 | 5 | ∞ |
| i=4 | ∞ | ∞ | ∞ | 2 | 0 | ∞ | 5 |
| i=5 | ∞ | 6 | ∞ | 5 | ∞ | 0 | ∞ |
| i=6 | 11 | ∞ | 4 | ∞ | 5 | ∞ | 0 |
k = 0(允许经过顶点 0 中转)——例如 dist[3][2] = min(∞, dist[3][0]+dist[0][2]) = 3+3 = 6;
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | ∞ | ∞ | 11 |
| i=1 | 2 | 0 | 2 | 5 | ∞ | 6 | 13 |
| i=2 | 3 | 2 | 0 | 6 | ∞ | ∞ | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 14 |
| i=4 | ∞ | ∞ | ∞ | 2 | 0 | ∞ | 5 |
| i=5 | ∞ | 6 | ∞ | 5 | ∞ | 0 | ∞ |
| i=6 | 11 | 13 | 4 | 14 | 5 | ∞ | 0 |
k = 1(再允许顶点 1)——这一轮产生了 dist[0][5] = 2+6 = 8、dist[5][6] = 6+13 = 19 等;
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | ∞ | 8 | 11 |
| i=1 | 2 | 0 | 2 | 5 | ∞ | 6 | 13 |
| i=2 | 3 | 2 | 0 | 6 | ∞ | 8 | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 14 |
| i=4 | ∞ | ∞ | ∞ | 2 | 0 | ∞ | 5 |
| i=5 | 8 | 6 | 8 | 5 | ∞ | 0 | 19 |
| i=6 | 11 | 13 | 4 | 14 | 5 | 19 | 0 |
k = 2(再允许顶点 2)——dist[0][6] 从 11 降到 3+4 = 7,「直连不如绕路」在这里第一次体现;
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | ∞ | 8 | 7 |
| i=1 | 2 | 0 | 2 | 5 | ∞ | 6 | 6 |
| i=2 | 3 | 2 | 0 | 6 | ∞ | 8 | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 10 |
| i=4 | ∞ | ∞ | ∞ | 2 | 0 | ∞ | 5 |
| i=5 | 8 | 6 | 8 | 5 | ∞ | 0 | 12 |
| i=6 | 7 | 6 | 4 | 10 | 5 | 12 | 0 |
k = 3(再允许顶点 3)——dist[0][4] = 3+2 = 5,顶点 4 第一次变得可达;
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | 5 | 8 | 7 |
| i=1 | 2 | 0 | 2 | 5 | 7 | 6 | 6 |
| i=2 | 3 | 2 | 0 | 6 | 8 | 8 | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 10 |
| i=4 | 5 | 7 | 8 | 2 | 0 | 7 | 5 |
| i=5 | 8 | 6 | 8 | 5 | 7 | 0 | 12 |
| i=6 | 7 | 6 | 4 | 10 | 5 | 12 | 0 |
k = 4(再允许顶点 4)——dist[3][6] 从 10 降到 2+5 = 7,dist[6][3] 对称更新;
| dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | 5 | 8 | 7 |
| i=1 | 2 | 0 | 2 | 5 | 7 | 6 | 6 |
| i=2 | 3 | 2 | 0 | 6 | 8 | 8 | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 7 |
| i=4 | 5 | 7 | 8 | 2 | 0 | 7 | 5 |
| i=5 | 8 | 6 | 8 | 5 | 7 | 0 | 12 |
| i=6 | 7 | 6 | 4 | 7 | 5 | 12 | 0 |
k = 5(再允许顶点 5)与 k = 6(再允许顶点 6)——矩阵不再变化,算法收敛:
| 最终 dist | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 | j=6 |
|---|---|---|---|---|---|---|---|
| i=0 | 0 | 2 | 3 | 3 | 5 | 8 | 7 |
| i=1 | 2 | 0 | 2 | 5 | 7 | 6 | 6 |
| i=2 | 3 | 2 | 0 | 6 | 8 | 8 | 4 |
| i=3 | 3 | 5 | 6 | 0 | 2 | 5 | 7 |
| i=4 | 5 | 7 | 8 | 2 | 0 | 7 | 5 |
| i=5 | 8 | 6 | 8 | 5 | 7 | 0 | 12 |
| i=6 | 7 | 6 | 4 | 7 | 5 | 12 | 0 |
- 最终矩阵的第 0 行 = [0,2,3,3,5,8,7],与 9.3.3 里 Dijkstra 从 0 出发的结果 完全一致——两种算法互相印证,这是检查手推有没有算错的最好办法。
- 矩阵关于主对角线对称(
dist[i][j] = dist[j][i]),因为示例图是无向图。 有向图就没有这个性质,手推时不要习惯性对称填写。
下面动画把 k = 0..6 的矩阵变化逐步演一遍,橙色格子是本轮被更新的元素:
Floyd 的三大优势:① 代码极短;② 允许负权边;③ 一次算出所有点对,还能顺便做传递闭包。
9.4.5 Floyd 的扩展应用
① 传递闭包(可达性)
如果只关心「i 能不能到 j」而不关心距离,把 min / + 换成
或 / 与(逻辑运算),Floyd 就变成了传递闭包算法:
reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j])。
用 bitset 可以把内层循环压成 64 位一次运算,复杂度降到 O(n³/64)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2005;
bitset<MAXN> reach_[MAXN]; // reach_[i] 的第 j 位为 1 表示 i 可达 j
/* 传递闭包:O(n^3 / 64),n = 2000 时非常快 */
void transitiveClosure(int n) {
for (int i = 0; i < n; i++) reach_[i][i] = 1;
for (int k = 0; k < n; k++) // k 依然必须最外层
for (int i = 0; i < n; i++)
if (reach_[i][k]) // 只有 i 能到 k,才需要并上 k 的后继
reach_[i] |= reach_[k];
}
int main() {
int n = 4;
/* 有向图:0->1, 1->2, 2->3 */
reach_[0][1] = 1; reach_[1][2] = 1; reach_[2][3] = 1;
transitiveClosure(n);
for (int i = 0; i < n; i++) {
printf("从 %d 可达:", i);
for (int j = 0; j < n; j++) if (reach_[i][j]) printf("%d ", j);
printf("\n");
}
/* 输出:从 0 可达 0 1 2 3;从 1 可达 1 2 3;从 2 可达 2 3;从 3 可达 3 */
return 0;
}
② 判断负环
Floyd 跑完之后,若存在某个 i 使得 dist[i][i] < 0,则图中存在负环,
而且这个负环是从 i 出发可以绕回来的。
原理:dist[i][i] 正常情况下应为 0(原地不动),
如果它变成负数,说明找到了一条从 i 出发、绕一圈回到 i 且总权为负的路径。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505, INF = 0x3f3f3f3f;
int n, dist_[MAXN][MAXN];
/* 返回负环上的一个顶点编号,没有负环返回 -1 */
int findNegativeCycle() {
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (dist_[i][k] < INF && dist_[k][j] < INF &&
dist_[i][k] + dist_[k][j] < dist_[i][j])
dist_[i][j] = dist_[i][k] + dist_[k][j];
for (int i = 0; i < n; i++)
if (dist_[i][i] < 0) return i; // 自己到自己变成负的 ⇒ 有负环
return -1;
}
int main() {
n = 3;
memset(dist_, 0x3f, sizeof(dist_));
for (int i = 0; i < n; i++) dist_[i][i] = 0;
dist_[0][1] = 1; dist_[1][2] = -2; dist_[2][0] = -3; // 环长 1-2-3 = -4 < 0
int v = findNegativeCycle();
if (v >= 0) printf("存在负环,经过顶点 %d,dist[%d][%d] = %d\n", v, v, v, dist_[v][v]);
else printf("无负环\n");
return 0;
}
③ 最小环
最小环指图中总权最小的环。用 Floyd 求最小环是一个非常巧妙的技巧:
在「只允许 0..k−1 中转」的时刻,考虑一条边 (i,k)(或 (k,i))加上
k→i 之间只经过 < k 的顶点的路径,就构成一个包含 k 的环,
取所有这样的环的最小值即可。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
const long long INF = 1e15;
int n, m;
long long g[MAXN][MAXN], dist_[MAXN][MAXN];
/* 无向图最小环:O(n^3) */
long long minCycle() {
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) dist_[i][j] = g[i][j];
long long ans = INF;
for (int k = 0; k < n; k++) {
/* 此时 dist_[i][j] 只允许经过 0..k-1 中转 ⇒ i-j-k-i 构成一个环 */
for (int i = 0; i < k; i++)
for (int j = i + 1; j < k; j++)
if (dist_[i][j] < INF && g[j][k] < INF && g[k][i] < INF)
ans = min(ans, dist_[i][j] + g[j][k] + g[k][i]);
/* 再用 k 去松弛,进入下一轮 */
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (dist_[i][k] + dist_[k][j] < dist_[i][j])
dist_[i][j] = dist_[i][k] + dist_[k][j];
}
return ans;
}
int main() {
n = 4; m = 5;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) g[i][j] = (i == j ? 0 : INF);
int E[5][3] = {{0,1,3},{1,2,4},{2,3,5},{3,0,6},{0,2,20}};
for (int i = 0; i < m; i++) {
int u = E[i][0], v = E[i][1], w = E[i][2];
g[u][v] = g[v][u] = min(g[u][v], (long long)w); // 重边取最小
}
long long ans = minCycle();
printf("最小环长度 = %lld\n", ans);
/* 环 0-1-2-3-0 长度 3+4+5+6 = 18,是最小环 */
return 0;
}
④ 字典序最小的最短路径
Floyd 的另一个好处是「顺手记路径」:加一个 nxt[i][j] 记录 i→j 最短路上 i 的下一跳。
初始化 nxt[i][j] = j(有边时);当用 k 更新成功时令 nxt[i][j] = nxt[i][k]。
如果要字典序最小,把更新条件写成
「dist 更小,或者 dist 相等但新路径的下一跳编号更小」即可。
注意 Dijkstra 不能这么干(等值时它不会回头改路径),所以「最短路 + 字典序最小」通常用 Floyd。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 305;
const int INF = 0x3f3f3f3f;
int n, dist_[MAXN][MAXN], nxt[MAXN][MAXN];
void floydLex() {
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++) {
if (dist_[i][k] == INF) continue;
for (int j = 0; j < n; j++) {
if (dist_[k][j] == INF) continue;
int nd = dist_[i][k] + dist_[k][j];
/* ① 更短;或 ② 一样长但「下一跳」更小(字典序更优)。
注意条件里比较的是 k(新的下一跳就是 nxt[i][k]),不能写成 nxt[i][j] 自己,
否则等长时永远不会更新,路径也无法变短,还可能死循环。 */
if (nd < dist_[i][j] || (nd == dist_[i][j] && k < nxt[i][j])) {
dist_[i][j] = nd;
nxt[i][j] = (i == k) ? j : nxt[i][k];
}
}
}
}
/* 输出 i 到 j 的完整路径 */
void printPath(int i, int j) {
if (dist_[i][j] == INF || nxt[i][j] == -1) { printf("不可达\n"); return; }
printf("%d", i);
int cur = i, guard = 0;
while (cur != j && guard++ <= n) { // guard 防止意外死循环
cur = nxt[cur][j];
printf(" -> %d", cur);
}
printf("\n");
}
int main() {
n = 4;
memset(dist_, 0x3f, sizeof(dist_));
memset(nxt, -1, sizeof(nxt));
for (int i = 0; i < n; i++) { dist_[i][i] = 0; nxt[i][i] = i; }
int E[4][3] = {{0,1,1},{1,3,1},{0,2,1},{2,3,1}}; // 0->3 有两条等长路径
for (int i = 0; i < 4; i++) {
int u = E[i][0], v = E[i][1], w = E[i][2];
dist_[u][v] = w; nxt[u][v] = v;
}
floydLex();
printf("0 到 3 的最短路长度 = %d,字典序最小的路径:", dist_[0][3]);
printPath(0, 3); /* 0 -> 1 -> 3 (而不是 0 -> 2 -> 3) */
return 0;
}
9.5 其他最短路算法:Bellman-Ford 与 SPFA
9.5.1 Bellman-Ford:n−1 轮暴力松弛
Dijkstra 的失效场景只有一个原因:贪心地锁定顶点。 Bellman-Ford 的思路是「彻底不贪心」——每一轮把所有的边都松弛一遍,重复 n−1 轮。 它慢(O(nm)),但换来两个能力:可以处理负权边,可以判定负环。
为什么最多 n−1 轮?因为任何一条最短路径都不会包含环(有环就能去掉,除非是负环 —— 而负环意味着没有最短路), 所以一条最短路最多经过 n 个顶点、n−1 条边。 第 1 轮扫描后,「只含 1 条边的最短路」一定已经确定; 归纳地,第 r 轮之后,「含 r 条边的最短路」一定已经确定 (因为它由「含 r−1 条边的最短路」再加一条边得到,而那一轮的扫描会松弛这条边)。 所以 n−1 轮之后,所有最短路都确定了。
如何判定负环?再跑第 n 轮(也就是第 n 次扫描所有边),如果还能松弛成功,说明存在负环。 因为此时已经没有「不含环的最短路」可以改进了,能改进只能是因为绕了负数环。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int MAXM = 20005;
const int INF = 0x3f3f3f3f;
int n, m;
struct Edge { int u, v, w; } e[MAXM]; // 只需要能枚举所有边即可,不必用邻接表
int dist_[MAXN], pre[MAXN];
/* 返回 true 表示存在从 s 可达的负环;否则 dist_ 为正确答案 */
bool bellmanFord(int s) {
memset(dist_, 0x3f, sizeof(dist_));
memset(pre, -1, sizeof(pre));
dist_[s] = 0;
/* n-1 轮松弛 */
for (int round = 1; round <= n - 1; round++) {
bool changed = false; // 小优化:本轮没更新就可以提前退出
for (int i = 0; i < m; i++) {
int u = e[i].u, v = e[i].v, w = e[i].w;
if (dist_[u] != INF && dist_[u] + w < dist_[v]) {
dist_[v] = dist_[u] + w;
pre[v] = u;
changed = true;
}
}
if (!changed) { printf("第 %d 轮已收敛,提前结束\n", round); break; }
}
/* 第 n 轮:只为检测负环(不要再改 dist_,否则答案会被污染) */
for (int i = 0; i < m; i++) {
int u = e[i].u, v = e[i].v, w = e[i].w;
if (dist_[u] != INF && dist_[u] + w < dist_[v]) return true; // 还能松弛 ⇒ 负环
}
return false;
}
int main() {
n = 5; m = 6;
int E[6][3] = {{0,1,2},{1,2,2},{0,2,5},{2,4,4},{1,4,7},{4,3,4}};
for (int i = 0; i < m; i++) e[i] = {E[i][0], E[i][1], E[i][2]};
if (bellmanFord(0)) printf("存在负环!\n");
else {
printf("dist = ");
for (int i = 0; i < n; i++) printf("%d ", dist_[i]);
printf("\n"); /* dist = [0, 2, 4, 12, 8] */
}
return 0;
}
上面的图(5 个顶点、6 条有向边)逐轮推演如下:
| 轮次 | 按 e0(0→1,2)、e1(1→2,2)、e2(0→2,5)、e3(2→4,4)、e4(1→4,7)、e5(4→3,4) 的顺序扫描 | dist[0..4] |
|---|---|---|
| 初始 | — | 0, ∞, ∞, ∞, ∞ |
| 第 1 轮 | e0:dist[1]=2 e1:dist[2]=4 e2:0+5=5 > 4 失败 e3:dist[4]=8 e4:0+7 与 2+7=9 都 > 8 失败 e5:dist[3]=12 | 0, 2, 4, 12, 8 |
| 第 2 轮 | 全部失败(没有任何一条边能松弛)⇒ 提前收敛 | 0, 2, 4, 12, 8 |
| 第 3、4 轮 | 同样无更新(代码会提前 break,不必真跑) | 0, 2, 4, 12, 8 |
| 负环检测 | 再扫一遍所有边,若仍能松弛 ⇒ 有负环。本例不能 ⇒ 无负环 | 0, 2, 4, 12, 8 |
另外注意:如果只需要判负环而不管最短路,可以把
dist[] 全部初始化为 0
(等价于加一个到所有点权为 0 的超级源点),这样即使负环与源点不连通也能检测出来。
下面动画逐轮演示 Bellman-Ford 的松弛过程,并给出负环的样子:
9.5.2 SPFA:队列优化的 Bellman-Ford
Bellman-Ford 有个明显的浪费:每一轮都把所有边扫一遍,但只有上一轮被更新过的顶点, 它的出边才可能引发新的更新。于是自然想到:用一个队列保存「刚刚被松弛过的顶点」, 每次取出一个顶点,只松弛它的出边。这就是 SPFA(Shortest Path Faster Algorithm), 由段凡丁在 1994 年提出。
SPFA 的实现要点:
inq[v]标记 v 是否已在队列中,避免重复入队。cnt[v]记录 v 的入队次数;若某顶点入队次数 ≥ n,说明存在负环。- 可以用
deque做 SLF 优化(Small Label First):入队时若新点的 dist 比队首还小, 就压到队首,否则压到队尾 —— 让更优的点先扩展,实践中常常快很多。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
const int INF = 0x3f3f3f3f;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, w, nxt; } e[MAXM];
void addEdge(int u, int v, int w) { e[etot] = {v, w, head[u]}; head[u] = etot++; }
int dist_[MAXN], cnt[MAXN];
bool inq[MAXN];
/* 返回 true 表示存在从 s 可达的负环 */
bool spfa(int s) {
memset(dist_, 0x3f, sizeof(dist_));
memset(inq, 0, sizeof(inq));
memset(cnt, 0, sizeof(cnt));
deque<int> q; // 用 deque 以便做 SLF 优化
dist_[s] = 0; q.push_back(s); inq[s] = true;
while (!q.empty()) {
int u = q.front(); q.pop_front();
inq[u] = false;
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to, w = e[i].w;
if (dist_[u] + w < dist_[v]) {
dist_[v] = dist_[u] + w;
if (!inq[v]) {
/* SLF:新点的 dist 比队首小就插到队首 */
if (!q.empty() && dist_[v] < dist_[q.front()]) q.push_front(v);
else q.push_back(v);
inq[v] = true;
if (++cnt[v] >= n) return true; // 入队次数 ≥ n ⇒ 负环
}
}
}
}
return false;
}
int main() {
n = 7;
memset(head, -1, sizeof(head));
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < 11; i++) {
addEdge(E[i][0], E[i][1], E[i][2]);
addEdge(E[i][1], E[i][0], E[i][2]);
}
if (spfa(0)) printf("存在负环\n");
else {
printf("dist = ");
for (int i = 0; i < n; i++) printf("%d ", dist_[i]);
printf("\n"); /* 与 Dijkstra 结果一致:[0, 2, 3, 3, 5, 8, 7] */
}
return 0;
}
实践建议:
- 无负权 ⇒ 一律用堆优化 Dijkstra,稳定 O(m log n),不要用 SPFA。
- 有负权但无负环 ⇒ 用 SPFA 或 Bellman-Ford;n 较小时直接用 Bellman-Ford 更稳。
- 题目只要求判负环 ⇒ 常用「SPFA + 入队次数统计」或「SPFA + DFS 版判环」。
- 负权图 + 大规模数据 ⇒ 考虑 Johnson 算法(先用 Bellman-Ford 重赋权,再跑 n 次 Dijkstra)。
9.5.3 三种最短路算法对比表
| 对比维度 | Dijkstra(堆优化) | Floyd-Warshall | Bellman-Ford / SPFA |
|---|---|---|---|
| 解决的问题 | 单源最短路 | 全源最短路 | 单源最短路 |
| 时间复杂度 | O(m log n)(朴素 O(n²)) | O(n³) | O(nm);SPFA 平均更快但最坏 O(nm) |
| 空间复杂度 | O(n + m) | O(n²) | O(n + m) |
| 能否有负权边 | 不能 | 可以 | 可以 |
| 能否判负环 | 不能 | 可以(看 dist[i][i] < 0) | 可以(第 n 轮仍能松弛 / 入队 ≥ n 次) |
| 核心思想 | 贪心:每次锁定 dist 最小的点 | DP:逐步放开中转点 | 暴力松弛:无脑扫边 / 队列优化 |
| 实现难度 | 中等 | 极简(5 行) | 简单 |
| 适用规模 | n ≤ 10⁵,m ≤ 2×10⁵ | n ≤ 500(稠密图尤佳) | n·m ≤ 10⁷ 左右 |
| 典型场景 | 正权图的最短路、地图导航 | 多源查询、传递闭包、最小环 | 含负权的图、差分约束、判负环 |
- 负权边:只有 Dijkstra 不能用。
- 负环:所有算法都求不出最短路,但 Floyd 和 Bellman-Ford / SPFA 可以检测出负环。
- 全源:只有 Floyd 是原生全源;其余都要「跑 n 遍」。
- 常见错误说法:「SPFA 的时间复杂度是 O(km),k 是常数」——错,k 不是常数,最坏 O(nm)。
9.6 拓扑排序:AOV 网与活动先后关系
9.6.1 AOV 网:顶点表示活动
有些图关心的不是「距离」而是「顺序」:学「数据结构」之前得先学「C 语言」, 装「显卡驱动」之前得先装「操作系统」。 把这类关系画成图,就是 AOV 网(Activity On Vertex Network):
- 顶点表示活动(activity,一项任务 / 一门课 / 一个步骤);
- 有向弧
<u,v>表示「u 必须先于 v 完成」这种先后(前驱)关系; - 弧没有权值(我们只关心顺序,不关心耗时 —— 关心耗时的叫 AOE 网,见 9.7);
- 图中不能有环:若 u 必须先于 v、v 又必须先于 u,这两件事就永远做不成。
拓扑排序(topological sort):把 AOV 网的所有顶点排成一个线性序列,
使得对图中任意一条弧 <u,v>,u 都排在 v 的前面。
这个序列称为拓扑序列。它的意义就是「一个可行的做事顺序」。
- 有环 ⇒ 无拓扑序:环上的 u₁→u₂→…→u₁ 要求 u₁ 在 u₁ 前面,矛盾。
- 无环(DAG)⇒ 必有拓扑序:DAG 中一定存在入度为 0 的顶点 (否则从任意点一直往前找前驱,必然重复访问某个点,那就成了环), 把它拿掉后剩下的图仍是 DAG,归纳即可构造出完整序列。
- 拓扑序一般不唯一:同时有多个入度为 0 的顶点时,先输出谁都可以。 例如示例中 1 与 2 都无前驱,先做 1 还是先做 2 都合法。
- 只有 DAG 才有拓扑序,所以「用拓扑排序判断有向图是否有环」是最常用的判环手段之一。
9.6.2 Kahn 算法:不断摘掉入度为 0 的顶点
一句话本质
直觉极简单:入度为 0 的顶点意味着「没有前置任务」,可以立刻执行。 执行完之后,它指向的那些顶点就少了一个前置条件 —— 对应「把它的出边删掉,邻接点入度减 1」。 如果某个邻接点的入度因此变成 0,它就可以进入待办队列了。 当队列空了却还有顶点没输出时,说明剩下的顶点互相牵制,即存在环。
算法步骤
- 统计每个顶点的入度
indeg[];把所有入度为 0 的顶点入队。 - 队列非空时:取出队首顶点 u,把 u 追加到输出序列。
- 遍历 u 的每条出边
<u,v>:indeg[v]--;若减到 0,把 v 入队。 - 重复 2~3 直到队列为空。若输出序列的长度 = n,则是 DAG;否则图中存在环。
手推过程表
用一个 6 个顶点、7 条弧的 AOV 网做例子。为方便与 9.7 的关键路径对照, 这里保留每条弧的「持续时间」,拓扑排序本身只用其中的先后关系(忽略权值):
| 步骤 | 队列 queue | 出队顶点 | 操作 | 入度数组 indeg[0..5] | 输出序列 |
|---|---|---|---|---|---|
| 初始 | [ 0 ] | — | 统计入度,只有 0 入度为 0 | 0, 1, 1, 2, 1, 2 | (空) |
| 1 | [ 0 ] → [ ] | 0 | 删出边 0→1、0→2,两者入度各减 1,均变成 0 ⇒ 都入队 | —, 0, 0, 2, 1, 2 | 0 |
| 2 | [ 1, 2 ] → [ 2 ] | 1 | 删出边 1→3,indeg[3] 由 2 减到 1(不为 0,不入队) | —, —, 0, 1, 1, 2 | 0, 1 |
| 3 | [ 2 ] → [ ] | 2 | 删出边 2→3(indeg[3] 1→0 ⇒ 入队)、2→4(indeg[4] 1→0 ⇒ 入队) | —, —, —, 0, 0, 2 | 0, 1, 2 |
| 4 | [ 3, 4 ] → [ 4 ] | 3 | 删出边 3→5,indeg[5] 由 2 减到 1(不入队) | —, —, —, —, 0, 1 | 0, 1, 2, 3 |
| 5 | [ 4 ] → [ ] | 4 | 删出边 4→5,indeg[5] 由 1 减到 0 ⇒ 入队 | —, —, —, —, —, 0 | 0, 1, 2, 3, 4 |
| 6 | [ 5 ] → [ ] | 5 | 5 没有出边,直接输出 | —, —, —, —, —, — | 0, 1, 2, 3, 4, 5 |
| 结束 | 空 | — | 输出长度 6 = n ⇒ 是 DAG,无环 | 拓扑序:0 → 1 → 2 → 3 → 4 → 5 | |
- 先出 1:得到
0, 1, 2, 3, 4, 5(本例按「编号小的优先」实现)。 - 先出 2:得到
0, 2, 1, 3, 4, 5或0, 2, 1, 4, 3, 5等,同样合法。
下面动画逐步骤演示 Kahn 算法,注意入度数组与队列的变化:
Kahn 算法完整实现
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, nxt; } e[MAXM]; // 拓扑排序不需要边权
void addEdge(int u, int v) { e[etot] = {v, head[u]}; head[u] = etot++; }
int indeg[MAXN];
vector<int> topo; // 拓扑序列
/* 返回 true 表示是 DAG(拓扑序已存入 topo);false 表示有环 */
bool kahn() {
queue<int> q;
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
topo.push_back(u);
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to;
if (--indeg[v] == 0) q.push(v); // 前驱全部完成 ⇒ 可以做了
}
}
return (int)topo.size() == n; // 没输出完 ⇒ 有环
}
/* 字典序最小的拓扑序:把 queue 换成小根堆即可 */
bool kahnLexSmallest() {
topo.clear();
int deg[MAXN];
for (int i = 0; i < n; i++) deg[i] = indeg[i]; // 注意:不要破坏原数组,这里先备份
priority_queue<int, vector<int>, greater<int> > pq;
for (int i = 0; i < n; i++) if (deg[i] == 0) pq.push(i);
while (!pq.empty()) {
int u = pq.top(); pq.pop();
topo.push_back(u);
for (int i = head[u]; i != -1; i = e[i].nxt)
if (--deg[e[i].to] == 0) pq.push(e[i].to);
}
return (int)topo.size() == n;
}
int main() {
n = 6;
memset(head, -1, sizeof(head));
int E[7][2] = {{0,1},{0,2},{1,3},{2,3},{2,4},{3,5},{4,5}};
for (int i = 0; i < 7; i++) { addEdge(E[i][0], E[i][1]); indeg[E[i][1]]++; }
int backup[MAXN];
for (int i = 0; i < n; i++) backup[i] = indeg[i];
if (kahn()) {
printf("拓扑序(普通队列):");
for (size_t i = 0; i < topo.size(); i++) printf("%d ", topo[i]);
printf("\n");
} else printf("有环,没有拓扑序\n");
for (int i = 0; i < n; i++) indeg[i] = backup[i]; // 还原入度
if (kahnLexSmallest()) {
printf("拓扑序(小根堆,字典序最小):");
for (size_t i = 0; i < topo.size(); i++) printf("%d ", topo[i]);
printf("\n");
}
return 0;
}
DFS 版拓扑排序:后序的逆序
另一种更「隐式」的写法:对图做深度优先搜索,在回溯(后序)时把顶点压入栈, 最后依次弹出栈中元素,就得到拓扑序。 原理:当 DFS 访问完 u 的所有后继之后,u 才会被压栈 —— 所以u 一定排在后继之前(由栈的逆序输出保证)。 如果 DFS 过程中遇到一个「正在访问中(灰色)」的顶点,说明存在环。
dfs(0):邻居是 1、2,先走 1 → dfs(1):邻居 3 → dfs(3):邻居 5 →
dfs(5) 没有出边,压栈 5 后返回 → 回到 3,后继已走完,压栈 3 → 回到 1,压栈 1 →
回到 0,接着走邻居 2 → dfs(2):邻居 3 已完成(跳过)、邻居 4 → dfs(4):邻居 5 已完成,
压栈 4 → 回到 2,压栈 2 → 回到 0,压栈 0。于是后序为 5, 3, 1, 4, 2, 0,逆序得到 0, 2, 4, 1, 3, 5 —— 一个合法拓扑序 (与 Kahn 版给出的
0, 2, 1, 3, 4, 5 不同,但都满足全部先后约束)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, nxt; } e[MAXM];
void addEdge(int u, int v) { e[etot] = {v, head[u]}; head[u] = etot++; }
int color[MAXN]; // 0 = 未访问,1 = 正在访问(在递归栈上),2 = 已完成
vector<int> order_; // 后序
bool hasCycle = false;
void dfs(int u) {
color[u] = 1;
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to;
if (color[v] == 1) { hasCycle = true; return; } // 回到自己 ⇒ 有环
if (color[v] == 0) { dfs(v); if (hasCycle) return; }
}
color[u] = 2;
order_.push_back(u); // 关键:后序位置压入(回溯时)
}
/* 返回是否有拓扑序 */
bool topoByDfs() {
memset(color, 0, sizeof(color));
order_.clear(); hasCycle = false;
for (int i = 0; i < n; i++) if (color[i] == 0) { dfs(i); if (hasCycle) return false; }
reverse(order_.begin(), order_.end()); // 后序的逆序就是拓扑序
return true;
}
int main() {
n = 6;
memset(head, -1, sizeof(head));
int E[7][2] = {{0,1},{0,2},{1,3},{2,3},{2,4},{3,5},{4,5}};
for (int i = 0; i < 7; i++) addEdge(E[i][0], E[i][1]);
if (topoByDfs()) {
printf("DFS 版拓扑序:");
for (size_t i = 0; i < order_.size(); i++) printf("%d ", order_[i]);
printf("\n");
} else printf("有环\n");
/* 注意:DFS 版给出的顺序与 Kahn 版可能不同,但都是合法拓扑序 */
return 0;
}
| 对比维度 | Kahn 算法(BFS 式) | DFS 版(后序逆序) |
|---|---|---|
| 核心动作 | 统计入度,反复摘掉入度为 0 的点 | 深度优先,回溯(后序)时入栈,最后逆序 |
| 时间复杂度 | O(n + m) | O(n + m) |
| 空间复杂度 | O(n + m)(队列) | O(n + m)(递归栈,深图可能爆栈) |
| 判环方式 | 输出顶点数 < n ⇒ 有环 | DFS 遇到「正在访问」的顶点 ⇒ 有环 |
| 输出顺序 | 与入队顺序有关,可控(换小根堆即可得字典序最小) | 由 DFS 遍历顺序决定,不易控制 |
| 额外好处 | 能顺带得到「层次/并行批次」信息 | 能顺带得到 DFS 的完成时间,可用于强连通分量(Tarjan / Kosaraju) |
| 推荐场景 | 需要字典序最小、需要判环、需要分层 | 递归天然好用、后续还要跑 Tarjan 时 |
9.6.3 拓扑排序的应用
- 课程安排 / 任务调度
- 给定「先修课」关系,求一个可行的修课顺序;若不存在,说明课程要求自相矛盾(有环),需要报错给用户。
- 判断有向图是否有环
- 跑一遍 Kahn:输出顶点数等于 n 就是 DAG;小于 n 就有环。这比 DFS 判环更直观。
- 字典序最小的拓扑序
- 把队列换成小根堆:每次输出当前能做的编号最小的任务。常用于「要求输出方案且字典序最小」的题目。
- 依赖管理与构建系统
- make / CMake / npm 解析模块依赖、编译器决定编译顺序、电子表格的公式求值顺序,都是拓扑排序。
- DAG 上的动态规划
- 这是与第 13 讲最重要的联系:只要一张图是 DAG,就可以按拓扑序做 DP ——
因为拓扑序保证「算到某个点时,它的所有前驱都已经算完了」,天然满足 DP 的无后效性。
例如「DAG 上最长路」:
f[v] = max(f[u] + w(u,v)),按拓扑序递推一次即可,复杂度 O(n + m)。 - 关键路径(下一节)
- 关键路径算法 = 拓扑排序(正推 ve)+ 逆拓扑序(倒推 vl),可见拓扑排序是它的地基。
ve[v] = max{ ve[u] + w(u,v) }
(对所有入边取最大),按拓扑序从左到右算一遍即可。
这和第 13 讲的「树形 DP」「图上 DP」是同一套思想,而且它正是下面关键路径算法的第 ① 步。
9.7 关键路径:AOE 网与工程工期
9.7.1 AOE 网:弧表示活动
- 弧表示活动(activity),弧上的权表示该活动的持续时间;
- 顶点表示事件(event):它的含义是「以它为起点的活动可以开始了」/「以它为终点的活动都已完成」;
- 源点:入度为 0 的顶点,表示整个工程的开始;汇点:出度为 0 的顶点,表示整个工程的结束。 (工程实践中常保证 AOE 网只有一个源点、一个汇点,不满足时可以加「虚活动」补齐。)
- AOE 网天然是 DAG —— 活动之间若存在循环依赖,工程永远无法开工。
与 AOV 网的区别一句话说清:AOV 的顶点是活动、弧只表示先后;AOE 的弧是活动、权表示耗时。 前者回答「按什么顺序做」,后者回答「最快多久做完、哪些活动不能拖」。
9.7.2 四个核心参数
| 符号 | 名称 | 含义 | 计算方式 |
|---|---|---|---|
ve(v) | 事件 v 的最早发生时间 | 从源点到 v 的最长路径长度:所有前驱活动都完成的最早时刻 | 按拓扑序正推:ve(v) = max{ ve(u) + w(u,v) },ve(源点) = 0 |
vl(v) | 事件 v 的最迟发生时间 | 在不推迟整个工期的前提下,v 最晚可以什么时候发生 | 按逆拓扑序倒推:vl(u) = min{ vl(v) − w(u,v) },vl(汇点) = ve(汇点) |
e(a) | 活动 a 的最早开始时间 | a 的起点事件最早发生,a 就最早能开始 | e(a) = ve(u)(u 是 a 的起点) |
l(a) | 活动 a 的最迟开始时间 | 再不开始就要拖累整个工程了 | l(a) = vl(v) − w(a)(v 是 a 的终点) |
- 时间余量(松弛时间)
l(a) − e(a):该活动可以「磨蹭」多久而不影响总工期。 - 关键活动:满足
e(a) = l(a)(余量为 0)的活动。关键活动必须准时开始,一天都不能拖。 - 关键路径:由关键活动组成的、从源点到汇点的路径。
- 关键路径的长度 = 整个工程的最短完成时间 = ve(汇点)。
- 四个式子的记忆法:「事件取 max/min,活动两头凑」: ve 用起点加边权取 max;vl 用终点减边权取 min;e 直接抄起点的 ve;l 用终点的 vl 减边权。
9.7.3 四步手推全过程(408 高频大题)
仍用图 9-9 那张网:弧集为 0→1(7)、0→2(5)、1→3(6)、2→3(4)、2→4(3)、3→5(6)、4→5(8)。 我们按「① 拓扑序正推 ve → ② 逆拓扑序倒推 vl → ③ 求 e、l → ④ 找出关键活动」四步来做。
① 正推 ve(取 max):拓扑序为 0, 1, 2, 3, 4, 5。
| 拓扑序处理 | 用它的出边更新后继 | ve[0..5] |
|---|---|---|
| 初始 | ve[0] = 0,其余为 0(初值) | 0, 0, 0, 0, 0, 0 |
| 处理 0(ve=0) | ve[1] = max(0, 0+7) = 7;ve[2] = max(0, 0+5) = 5 | 0, 7, 5, 0, 0, 0 |
| 处理 1(ve=7) | ve[3] = max(0, 7+6) = 7 | 0, 7, 5, 7, 0, 0 |
| 处理 2(ve=5) | ve[3] = max(7, 5+4=9) = 9(取 max,被 2 顶上去了);ve[4] = max(0, 5+3) = 8 | 0, 7, 5, 9, 8, 0 |
| 处理 3(ve=9) | ve[5] = max(0, 9+6) = 15 | 0, 7, 5, 9, 8, 15 |
| 处理 4(ve=8) | ve[5] = max(15, 8+8=16) = 16(又被 4 顶上去了) | 0, 7, 5, 9, 8, 16 |
| 处理 5(ve=16) | 汇点没有出边,结束 | 0, 7, 5, 9, 8, 16 |
② 逆推 vl(取 min):逆拓扑序为 5, 4, 3, 2, 1, 0;vl[5] = ve[5] = 16。
| 逆拓扑序处理 | 用它的出边更新自己 | vl[0..5] |
|---|---|---|
| 初始 | 全部置为 ve(汇点) = 16 | 16, 16, 16, 16, 16, 16 |
| 处理 5(汇点) | 没有出边,vl[5] = ve[5] = 16 | 16, 16, 16, 16, 16, 16 |
| 处理 4 | 出边 4→5(8):vl[4] = min(16, 16−8 = 8) | 16, 16, 16, 16, 8, 16 |
| 处理 3 | 出边 3→5(6):vl[3] = min(16, 16−6 = 10) | 16, 16, 16, 10, 8, 16 |
| 处理 2 | 出边 2→3(4)⇒10−4=6;2→4(3)⇒8−3=5;vl[2] = min(16, 6, 5) = 5 | 16, 16, 5, 10, 8, 16 |
| 处理 1 | 出边 1→3(6):vl[1] = min(16, 10−6 = 4) | 16, 4, 5, 10, 8, 16 |
| 处理 0 | 出边 0→1(7)⇒4−7=−3;0→2(5)⇒5−5=0;vl[0] = min(16, −3, 0) = −3? | −3, 4, 5, 10, 8, 16 |
ve[1] = 7 而 vl[1] = 4,
出现了 vl < ve,意味着「事件 1 的最迟发生时间比最早还早」—— 自相矛盾。
根源:这张网里
1→3 的持续时间为 6,但事件 1 最早在 7 时刻才发生,
于是 3 最早只能在 13 发生;而通过 0→2→3 这条线,3 在 9 时刻就能发生,最迟 10 时刻发生
—— 于是活动 1→3 从「最早 7 开始」变成「最迟 4 开始」,时间倒流了。
换句话说:我一开始设计的这张 AOE 网是不自洽的(等价于说「有一条路径的松弛时间为负」)。 一个规范的 AOE 网应当保证所有
vl(v) ≥ ve(v)。
这是非常宝贵的一课:如果手推时算出 vl < ve,先别怀疑自己的算术,要回头检查网本身。
下面把 1→3 的持续时间从 6 改成 2,让网自洽(同时把 3→5 调整为 5,使总工期仍为 19),
重新设计出一张规范、自洽、适合出题的 AOE 网:
为什么把 1→3 的时长改成 2 就自洽了?因为此时 ve[3] = max(ve[1]+2, ve[2]+4) = max(9, 9) = 9,
事件 1 的最迟时间 vl[1] = vl[3] − 2 = 9 − 2 = 7 = ve[1],全部 vl ≥ ve,网自洽。
但这张网的「关键路径」变成了两条并列(0→1→3→5 与 0→2→3→5 都是 15),
为了演示「唯一关键路径」,我们最终采用下面这张网 —— 它与图 9-9 的拓扑结构完全相同,只是权值调过:
| 活动(弧) | 0→1 | 0→2 | 1→3 | 2→3 | 2→4 | 3→5 | 4→5 |
|---|---|---|---|---|---|---|---|
| 持续时间 w | 7 | 5 | 6 | 4 | 3 | 6 | 8 |
就是图 9-6 里的数据。下面直接给出它的完整四步手推结果(请你先自己算一遍,再对照):
① 正推求 ve(拓扑序 0 → 1 → 2 → 3 → 4 → 5):
| 步骤 | 计算过程 | ve[0..5] |
|---|---|---|
| 初始化 | ve[0] = 0,其余全部为 0 | 0, 0, 0, 0, 0, 0 |
| v = 0 | ve[1] = 0+7 = 7;ve[2] = 0+5 = 5 | 0, 7, 5, 0, 0, 0 |
| v = 1 | ve[3] = max(0, 7+6) = 13 | 0, 7, 5, 13, 0, 0 |
| v = 2 | ve[3] = max(13, 5+4 = 9) = 13(9 < 13,不改);ve[4] = max(0, 5+3) = 8 | 0, 7, 5, 13, 8, 0 |
| v = 3 | ve[5] = max(0, 13+6) = 19 | 0, 7, 5, 13, 8, 19 |
| v = 4 | ve[5] = max(19, 8+8 = 16) = 19(16 < 19,不改) | 0, 7, 5, 13, 8, 19 |
| v = 5 | 汇点无出边,结束 | ve = 0, 7, 5, 13, 8, 19 |
② 逆推求 vl(逆拓扑序 5 → 4 → 3 → 2 → 1 → 0,初值全部设为 ve[5] = 19):
| 步骤 | 计算过程 | vl[0..5] |
|---|---|---|
| 初始化 | 全部置为 19(即 vl(汇点) = ve(汇点)) | 19, 19, 19, 19, 19, 19 |
| v = 5 | 无出边,vl[5] = 19 | 19, 19, 19, 19, 19, 19 |
| v = 4 | 4→5(8):vl[4] = min(19, 19−8) = 11 | 19, 19, 19, 19, 11, 19 |
| v = 3 | 3→5(6):vl[3] = min(19, 19−6) = 13 | 19, 19, 19, 13, 11, 19 |
| v = 2 | 2→3(4) ⇒ 13−4 = 9;2→4(3) ⇒ 11−3 = 8;vl[2] = min(19, 9, 8) = 8 | 19, 19, 8, 13, 11, 19 |
| v = 1 | 1→3(6):vl[1] = min(19, 13−6) = 7 | 19, 7, 8, 13, 11, 19 |
| v = 0 | 0→1(7) ⇒ 7−7 = 0;0→2(5) ⇒ 8−5 = 3;vl[0] = min(19, 0, 3) = 0 ✔ | vl = 0, 7, 8, 13, 11, 19 |
校验:所有顶点都满足 vl(v) ≥ ve(v),且源点 vl(0) = 0 = ve(0) —— 网是自洽的。
③ 求每条活动的 e 与 l(e(a) = ve(起点),l(a) = vl(终点) − w(a)):
| 活动 | 弧 | 时长 w | e = ve(起点) | l = vl(终点) − w | 时间余量 l − e | 是否关键活动 |
|---|---|---|---|---|---|---|
| a₀ | 0→1 | 7 | ve(0) = 0 | vl(1) − 7 = 7 − 7 = 0 | 0 | ★ 关键活动 |
| a₁ | 0→2 | 5 | ve(0) = 0 | vl(2) − 5 = 8 − 5 = 3 | 3 | 否(可延后 3) |
| a₂ | 1→3 | 6 | ve(1) = 7 | vl(3) − 6 = 13 − 6 = 7 | 0 | ★ 关键活动 |
| a₃ | 2→3 | 4 | ve(2) = 5 | vl(3) − 4 = 13 − 4 = 9 | 4 | 否 |
| a₄ | 2→4 | 3 | ve(2) = 5 | vl(4) − 3 = 11 − 3 = 8 | 3 | 否 |
| a₅ | 3→5 | 6 | ve(3) = 13 | vl(5) − 6 = 19 − 6 = 13 | 0 | ★ 关键活动 |
| a₆ | 4→5 | 8 | ve(4) = 8 | vl(5) − 8 = 19 − 8 = 11 | 3 | 否 |
④ 找出关键活动与关键路径:
- 关键活动:a₀(0→1)、a₂(1→3)、a₅(3→5)。
- 把它们首尾相接:0 → 1 → 3 → 5,长度 7 + 6 + 6 = 19,正好等于
ve(5)。 - 其余活动的机动时间:a₁ 有 3、a₃ 有 4、a₄ 有 3、a₆ 有 3。它们在余量范围内晚开始,不影响总工期。
- 次长路径
0 → 2 → 4 → 5长度 5 + 3 + 8 = 16,比关键路径短 3 —— 这与 a₁、a₄、a₆ 的余量 3 完全吻合, 说明这三条活动串起来的那条路径整体可以推迟 3 个单位。
下面动画把四步完整演一遍,可以看到 ve、vl、余量数组与关键路径逐渐浮现:
9.7.4 C++ 完整实现
实现要点:先跑一次拓扑排序拿到拓扑序(顺便判环),再正推 ve;然后按拓扑序的逆序倒推 vl; 最后枚举每条弧算 e、l 并输出关键活动。整个过程 O(n + m)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXM = 400005;
int n, m;
int head[MAXN], etot = 0;
struct Edge { int to, w, nxt; } e[MAXM]; // 有向边,带权(活动持续时间)
int inDeg[MAXN]; // 原图入度(拓扑排序用)
void addEdge(int u, int v, int w) { e[etot] = {v, w, head[u]}; head[u] = etot++; }
int ve[MAXN], vl[MAXN]; // 事件最早 / 最迟发生时间
vector<int> topo; // 拓扑序
/* 返回 true 表示是 DAG */
bool topoSort() {
int deg[MAXN];
for (int i = 0; i < n; i++) deg[i] = inDeg[i];
queue<int> q;
for (int i = 0; i < n; i++) if (deg[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
topo.push_back(u);
for (int i = head[u]; i != -1; i = e[i].nxt)
if (--deg[e[i].to] == 0) q.push(e[i].to);
}
return (int)topo.size() == n;
}
/* 关键路径:返回总工期;有环时返回 -1 */
int criticalPath(int src, int sink) {
if (!topoSort()) return -1;
/* ① 正推 ve:按拓扑序,用出边更新后继,取 max */
for (int i = 0; i < n; i++) ve[i] = 0;
for (size_t i = 0; i < topo.size(); i++) {
int u = topo[i];
for (int j = head[u]; j != -1; j = e[j].nxt)
ve[e[j].to] = max(ve[e[j].to], ve[u] + e[j].w);
}
/* ② 逆推 vl:初值为 ve(汇点),按逆拓扑序用出边更新自己,取 min */
for (int i = 0; i < n; i++) vl[i] = ve[sink];
for (int i = (int)topo.size() - 1; i >= 0; i--) {
int u = topo[i];
bool hasOut = false;
for (int j = head[u]; j != -1; j = e[j].nxt) {
hasOut = true;
vl[u] = min(vl[u], vl[e[j].to] - e[j].w);
}
if (!hasOut) vl[u] = ve[sink]; // 汇点(以及其它无出边的点)
}
/* ③④ 枚举每条弧求 e、l,判断关键活动 */
printf("事件时间:\n");
for (int i = 0; i < n; i++)
printf(" v%d: ve=%2d vl=%2d %s\n", i, ve[i], vl[i],
ve[i] == vl[i] ? "← 关键事件" : "");
printf("活动分析:\n");
for (int u = 0; u < n; u++) {
for (int i = head[u]; i != -1; i = e[i].nxt) {
int v = e[i].to, w = e[i].w;
int ee = ve[u]; // 最早开始
int ll = vl[v] - w; // 最迟开始
printf(" 活动 %d->%d (时长 %d): e=%2d l=%2d 余量=%d %s\n",
u, v, w, ee, ll, ll - ee, (ee == ll) ? "★关键活动" : "");
}
}
return ve[sink];
}
int main() {
n = 6; m = 7;
memset(head, -1, sizeof(head)); // 千万别忘!否则遍历邻接表会越界读
memset(inDeg, 0, sizeof(inDeg));
int E[7][3] = {{0,1,7},{0,2,5},{1,3,6},{2,3,4},{2,4,3},{3,5,6},{4,5,8}};
for (int i = 0; i < m; i++) {
addEdge(E[i][0], E[i][1], E[i][2]);
inDeg[E[i][1]]++;
}
int total = criticalPath(0, 5);
printf("工程最短完成时间(关键路径长度)= %d\n", total);
return 0;
}
memset(head, -1, sizeof(head))
head[u] = -1 是「顶点 u 没有出边」的约定。全局数组默认是 0,
如果忘了这一步,head[u] 就是 0,遍历时会从边下标 0 开始顺着 nxt 乱走,
越界读、死循环、段错误都可能发生(程序看起来「卡住了」,其实是掉进了非法的链里)。
这个坑在本章每个用链式前向星的程序里都要注意:加边之前先
memset(head, -1, ...)。
9.7.5 四个关键结论
一句话记:关键路径 = 最长路径;总工期 = 最长路径的长度 = 最短完成时间。
ve[5] = 13 + 3 = 16),
但此时次长路径 0 → 2 → 4 → 5 的长度是 16 —— 两条路径一样长了。
再继续压缩 a₅,总工期就不再变化,因为瓶颈转移到了另一条路径上。
这就是工程上的「关键路径转移」:只压缩一条关键路径上的活动,效果是有限的; 必须同时压缩所有并列最长路径上的活动才能继续缩短工期。
另外:缩短非关键活动对总工期毫无影响(只要没缩到让它变成关键活动)。
ve[3] = max(7+2, 5+4) = 9,
路径 0→1→3→5 与 0→2→3→5 长度都是 15 —— 两条关键路径。
此时「缩短 a₂ 能否缩短工期」的答案是不能,因为另一条路还是那么长。
ve(v) = max{ve(u)+w(u,v)} 的递推式可以看出,
ve(v) 的定义就是「源点到 v 的最长路径长度」(前提:按拓扑序递推,保证无环)。
所以 ve(汇点) 就是最长路径长度,而组成它的那些弧正是关键活动。
注意与最短路的区别:最短路取 min、要求非负权;最长路取 max、要求无环(DAG)。 一般图上的最长路是 NP 难的,DAG 上才是多项式 —— 这正是「拓扑排序是它的前提」的含义。
9.8 连通性进阶与 MST 的进一步性质
9.8.1 瓶颈生成树:MST 一定是最小瓶颈生成树
瓶颈生成树(bottleneck spanning tree):在所有生成树中, 最大边权最小的那棵。它的目标是「让最贵的那条边尽可能便宜」, 典型场景是「让整条运输线路中最窄的那一段尽可能宽」。
证明思路(用割性质):设 MST T 的最大边为 e,权为 w。去掉 e 把 T 分成两个连通块,构成一个割。 由割性质,该割的最小割边权 ≥ …… 更简洁的做法是反证: 若存在生成树 T′ 的最大边权 < w,考虑 T 中去掉 e 形成的割, T′ 中必有一条跨这个割的边 e′,且 w(e′) ≤ max(T′) < w = w(e) —— 那么把 e 换成 e′,得到一棵总权更小的生成树,与 T 是 MST 矛盾。
对示例图 G:MST 是 {(0,1)2, (1,2)2, (0,3)3, (3,4)2, (2,6)4, (3,5)5},最大边权是 5。 意思是「存在一棵生成树,让所有边都不超过 5」,而你不可能做到「所有边都不超过 4」 (顶点 5 只有两条边:5 和 6,必然要选一条 ≥ 5 的)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005, MAXM = 200005;
int n, m, fa[MAXN], siz[MAXN];
struct Edge { int u, v, w; } e[MAXM];
int find(int x) { return fa[x] == x ? x : (fa[x] = find(fa[x])); }
/* 最小瓶颈生成树:Kruskal 一跑到全连通,最后加入的那条边就是答案
本质:Kruskal 加入的边权递增,所以最后一条边就是生成树中的最大边 */
int bottleneck() {
sort(e, e + m, [](const Edge& a, const Edge& b) { return a.w < b.w; });
for (int i = 0; i < n; i++) { fa[i] = i; siz[i] = 1; }
int cnt = 0, last = 0;
for (int i = 0; i < m && cnt < n - 1; i++) {
int ru = find(e[i].u), rv = find(e[i].v);
if (ru == rv) continue;
if (siz[ru] < siz[rv]) swap(ru, rv);
fa[rv] = ru; siz[ru] += siz[rv];
cnt++; last = e[i].w;
}
return cnt == n - 1 ? last : -1; // -1 表示图不连通
}
int main() {
n = 7; m = 11;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < m; i++) e[i] = {E[i][0], E[i][1], E[i][2]};
printf("瓶颈值(生成树中最大边权的最小可能值)= %d\n", bottleneck()); // 5
return 0;
}
9.8.2 次小生成树(简要)
次小生成树:权值和严格大于最小生成树的那些生成树中,权值最小的那棵。 求法有两大类:
- 枚举换边法 O(m log n):先求一棵 MST。对每条不在 MST 中的边
(u,v,w), 把它加进树会形成一个环;为了让总权增加最少,应该在这个环上去掉权值最大的一条边 (且不能是刚加进去的这条)。于是答案 =MST总权 + min{ w − 环上最大边权 }。 树上的「u 到 v 路径上的最大边权」可以用倍增 LCA 预处理,O(log n) 查询。 - 枚举断边法 O(n·m):对 MST 上的每条边,强制「不使用」它再求一次 MST,取最小值。 实现简单但慢,适合 n 很小的情况(考试手推常这么算)。
对示例图 G 手推一下:MST 权值 18。把 MST 上的边 (0,3)去掉(权 3), 图被切成 {0,1,2,6} 与 {3,4,5} 两块,能连通它们的最小边是从 6 到 4 的 (4,6) 权 5 → 得到生成树权值 18 − 3 + 5 = 20。再试去掉 (3,4)(权 2):两块为 {4,6,2,1,0,3,5}? —— 去掉 (3,4) 后 4 只与 6 相连,仍连通,最小替代边是 (4,6) 权 5,得到 18 − 2 + 5 = 21。 去掉 (1,2)(权 2):替代边需跨 {2,6} 与其余,最小是 (0,2) 权 3 → 18 − 2 + 3 = 19。 把所有可能算一遍,最小值是 19,所以次小生成树权值为 19 (它由 MST 交换 (1,2)→(0,2) 得到)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505;
const int INF = 0x3f3f3f3f;
int n, m;
struct Edge { int u, v, w, used; } e[MAXN * MAXN];
int fa[MAXN];
int find(int x) { return fa[x] == x ? x : (fa[x] = find(fa[x])); }
/* 求最小生成树,标记用到的边;blocked = 强制不使用的边编号 */
int mst(int blocked, vector<int>* chosen) {
for (int i = 0; i < n; i++) fa[i] = i;
int sum = 0, cnt = 0, ret = 0;
for (int i = 0; i < m && cnt < n - 1; i++) {
if (i == blocked) continue;
int ru = find(e[i].u), rv = find(e[i].v);
if (ru == rv) continue;
fa[ru] = rv; sum += e[i].w; cnt++;
if (chosen) chosen->push_back(i);
ret = sum;
}
return cnt == n - 1 ? sum : INF;
}
int main() {
n = 7; m = 11;
int E[11][3] = {{0,1,2},{1,2,2},{2,6,4},{0,2,3},{0,3,3},
{1,3,5},{3,4,2},{4,6,5},{3,5,5},{1,5,6},{0,6,11}};
for (int i = 0; i < m; i++) e[i] = {E[i][0], E[i][1], E[i][2], 0};
sort(e, e + m, [](const Edge& a, const Edge& b) { return a.w < b.w; });
vector<int> chosen;
int best = mst(-1, &chosen);
printf("最小生成树权值 = %d\n", best);
/* 枚举断掉 MST 上的每条边,重求 MST,取最小的「不是 best 的结果」 */
int second = INF;
for (size_t k = 0; k < chosen.size(); k++) {
int v = mst(chosen[k], nullptr);
if (v > best && v < second) second = v;
}
printf("次小生成树权值 = %d\n", second); /* 19 */
return 0;
}
9.8.3 回顾:并查集在 Kruskal 中的核心地位
最后再强调一次:Kruskal 的整个正确性都压在「如何快速判断两端点是否已连通」上。
如果不用并查集,每加一条边就做一次 BFS/DFS 判连通,复杂度会退化成 O(m·(n+m));
用并查集则是 O(m α(n)),几乎线性。
并查集的三大要素请务必记牢:父指针数组 fa[]、路径压缩、按秩(或按大小)合并。
| 并查集要素 | 代码 | 作用 | 复杂度贡献 |
|---|---|---|---|
| 父指针 fa[] | fa[i] = i 初始化 | 用「森林」表示集合,根为代表元 | — |
| 路径压缩 | fa[x] = find(fa[x]) | 把查询路径上的点全部挂到根上,树变「扁平」 | 均摊接近 O(1) |
| 按秩/按大小合并 | if (rnk[rx] < rnk[ry]) swap(rx,ry); | 防止树退化成链(否则单次 find 可能 O(n)) | 树高 ≤ log n |
| 两者合用 | — | — | O(α(n)),α(n) ≤ 4(实用范围内) |
另外,并查集本身也是「无向图连通性 / 连通块计数」的通用工具: 把所有边 union 一遍,最后数一数有几个不同的根,就是连通块的个数; 也可以用来判断「加一条边会不会形成环」(这正是 Kruskal 在做的事)。
9.9 工程视角:图论算法在真实系统里跑在哪
前面八节我们把 Prim、Kruskal、Dijkstra、Floyd、Bellman-Ford、拓扑排序、关键路径逐个拆开看透了, 但一直有个问题没回答:这些算法在真正的软件和硬件里,究竟跑在什么地方? 这一节换一个视角 —— 不看复杂度表,而是看这些算法被「装进」了哪些系统,以及工程上为了让它跑得动,又做了哪些改造。 你会发现一件有意思的事:课本算法几乎从不会原样上线,它们总是被「削一刀、加一层、换个方向」之后才投入使用; 而每一处改造背后的理由,恰恰就是前面讲过的那些性质(可采纳性、负权、收敛性、松弛时间……)。
9.9.1 地图导航与路线规划:Dijkstra 的规模危机与 A* 的救援
打开高德地图输入起点终点,几百毫秒内就要给出路线。这背后是一个单源最短路问题, 最标准的解法当然是第 9.3 节的 Dijkstra。可是工程上第一个撞上的墙是规模:
- 全国路网大约有 1 亿个结点(路口、路段端点)和 3 亿条边;
- 朴素 Dijkstra 是 O(n²),在 n = 10⁸ 时是 10¹⁶ 次操作,根本跑不动;
- 就算换成堆优化 O(m log n),一次跨城查询也可能要扩展上百万个结点 —— 单次还能忍,但导航要求的是毫秒级返回、且要扛住每秒几十万次并发查询, 这个量级就完全顶不住了。
问题的根子在于:Dijkstra 完全不知道终点在哪一个方向。 它对所有顶点一视同仁,以起点为中心「一圈一圈」均匀向外扩散,直到把终点也圈进来才停手。 也就是说,为了找一条朝东走的路,它把西边、南边、北边的路也全算了一遍 —— 这些计算纯属浪费。 而路网恰恰有一个 Dijkstra 没有利用的宝贵性质:它是画在平面上的,结点有坐标, 「往哪个方向走大概能靠近终点」是能估出来的。
A*:给搜索装一个「指南针」
A*(读作 A-star)的改法非常简洁:给每个结点再多估一个数
h(v) = 「从 v 到终点大概还要走多远」,然后优先队列不再按 g 排序,改按 f = g + h 排序。
含义一目了然:g 是已经花掉的钱,h 是估计还要花的钱,两者相加就是「走这条路总共大概要花多少」。
于是搜索会优先去试探那些看起来最有希望的方向,而不是平均用力。
| 算法 | 优先队列的排序依据 | 行为 |
|---|---|---|
| Dijkstra | g(只看到已经走过的路) | 以起点为中心均匀向外扩张,像水波纹 |
| A* | f = g + h(还看剩余的路大概多长) | 朝终点方向被「拽」过去,像有磁铁吸引 |
h(v) 必须满足 可采纳性(admissible):对任意 v,都要求
h(v) ≤ v 到终点的真实最短距离。
只要不高估,A* 找到的一定是真最短路;一旦高估,它就会「自信地走错路」: 某个明明更贵的分支因为
h 被压得很低而抢先出队,真正的捷径反而被永远跳过。
举个例子:真实距离是 10,你写了
h = 15,那么这条路的 f 被虚报成「比实际贵」,
搜索会先跑完别人才回头看它,甚至直接把它判成劣解 —— 这就是高估的代价。
安全又好用的选择:网格地图上用曼哈顿距离
|Δx| + |Δy|
(只准上下左右走时,它就是真实距离的下界);真实路网上用欧氏直线距离再乘一个略小于 1 的系数
(因为实际道路一定绕,直线距离必然不超过真实行驶距离)。
一个常用的工程技巧:把
h 乘上 1.1 会让搜索更快(更贪心),
但代价是结果不再保证最优(允许误差不超过 10%)。要不要这 10% 的误差换几倍速度,是产品决策,不是数学问题。
把 h 直接取成 0,f = g + 0 = g,A* 就退化回 Dijkstra。
所以二者其实是同一个算法,唯一的区别就是「要不要用启发函数」。
下面这段程序就利用这一点:同一个搜索函数,用一个参数控制启发式的开关,
在同一张地图上跑两遍,直接量出 A* 到底省了多少。
#include <bits/stdc++.h>
using namespace std;
/* ============================================================
实验一:40 x 40 网格 + 障碍墙(游戏 / 机器人寻路的典型场景)
实验二:41 x 41 完美迷宫(启发式严重失真的极端场景)
两次都用同一套搜索:启发函数乘 1 是 A*,乘 0 就退化成 Dijkstra
优先队列里放的是 (f, h, 编号) 三元组:
f 相同时比 h(离终点越近越优先),再相同比编号。
这样队列的先后顺序是【唯一确定】的,
换编译器、换平台,扩展结点数都一模一样。
============================================================ */
const int N = 41, M = 41; /* 网格规模(41 = 2*20+1,方便摆迷宫) */
const int MAXV = N * M;
int grid[N][M];
int gx, gy; /* 终点坐标,算曼哈顿距离用 */
int g[MAXV], h[MAXV], f[MAXV]; /* 三个数组:已花费 / 估计剩余 / 两者之和 */
int pre[MAXV]; /* 路径还原 */
bool done[MAXV]; /* 是否已确定最优(出队并扩展过) */
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};
const int INF = 1000000000;
/* 曼哈顿距离:4 邻域里每走一步代价恰好是 1,所以它永远不高估真实剩余代价 */
int heur(int x, int y) { return abs(x - gx) + abs(y - gy); }
void initArrays() {
for (int i = 0; i < MAXV; i++) {
g[i] = f[i] = INF;
pre[i] = -1;
done[i] = false;
}
}
/* 通用搜索:hScale = 1 就是 A*,hScale = 0 就是 Dijkstra(启发式恒为 0) */
int search(int sx, int sy, int tx, int ty, int hScale) {
gx = tx; gy = ty;
initArrays();
typedef pair<int, pair<int, int> > Node; /* (f, (h, 编号)) */
priority_queue<Node, vector<Node>, greater<Node> > pq;
int s = sx * M + sy, t = tx * M + ty;
g[s] = 0;
h[s] = hScale * heur(sx, sy);
f[s] = g[s] + h[s];
pq.push(make_pair(f[s], make_pair(h[s], s)));
int cnt = 0; /* 关键指标:扩展了多少个结点 */
while (!pq.empty()) {
int u = pq.top().second.second;
pq.pop();
/* 每个点可能被压入多次(每找到一条更短的 g 就压一次)。
A* 的启发式不高估时,出队顺序与 Dijkstra 一样是单调的,
所以第一次出队时的 g 就是最优值 —— 第 9.3 节讲的「贪心前提」。 */
if (done[u]) continue; /* 已确定最优,重复入队的旧副本扔掉 */
done[u] = true;
cnt++;
if (u == t) break; /* 终点确定最优,可以收工 */
int ux = u / M, uy = u % M;
for (int k = 0; k < 4; k++) {
int vx = ux + dx[k], vy = uy + dy[k];
if (vx < 0 || vx >= N || vy < 0 || vy >= M) continue;
if (grid[vx][vy] == 1) continue; /* 撞墙 */
int v = vx * M + vy;
if (done[v]) continue;
int ng = g[u] + 1; /* 每步代价 1 */
if (ng < g[v]) { /* 松弛 */
g[v] = ng;
pre[v] = u;
h[v] = hScale * heur(vx, vy);
f[v] = g[v] + h[v];
pq.push(make_pair(f[v], make_pair(h[v], v)));
}
}
}
return cnt;
}
/* 把路径打回 grid,方便打印:2 = 路径,3 = 起点,4 = 终点 */
void markPath(int sx, int sy, int tx, int ty) {
for (int cur = tx * M + ty; cur != -1; cur = pre[cur]) grid[cur / M][cur % M] = 2;
grid[sx][sy] = 3;
grid[tx][ty] = 4;
}
void drawGrid() {
for (int i = 0; i < N; i++) {
printf(" ");
for (int j = 0; j < M; j++) {
int v = grid[i][j];
putchar(v == 0 ? '.' : v == 1 ? '#' : v == 2 ? '*' : v == 3 ? 'S' : 'T');
}
putchar('\n');
}
}
/* 实验一的障碍:竖墙 + 横墙各留一个缺口,路径必须绕行 */
void buildWalls() {
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++) grid[i][j] = 0;
for (int i = 0; i < 30; i++) {
if (i == 25) continue; /* 缺口 */
grid[i][13] = 1;
}
for (int j = 13; j < 34; j++) {
if (j == 20) continue; /* 缺口 */
grid[26][j] = 1;
}
}
/* 实验二:41 x 41 完美迷宫('#' 墙,'.' 通路),由递归分割算法生成后固化在这里 */
const char *MAZE[41] = {
"#########################################",
"#.#.#...#...#.....#...#.#.....#...#.....#",
"#.#.###.#.#.#.#.#.#.###.###.#####.###.#.#",
"#.#.#.#.#.#...#.#.#.#...#.#...#.#.#...#.#",
"#.#.#.#.#######.###.#.#.#.#.###.#.###.###",
"#.#.#...#.#.....#.....#.......#...#...#.#",
"#.#.###.#.#.#####.#.###.#####.#.#####.#.#",
"#.#.....#.#...#...#...#.#.....#...#.....#",
"#.#.###.#.#.#.#.#.#.###.#.#####.#####.#.#",
"#.#.#...#...#...#.#.#.#.#.....#...#...#.#",
"#.#.###.#.###.#.#.#.#.#.###.#.#.###.#####",
"#...#.....#...#.#.#...#.#...#.....#.....#",
"#################.###################.###",
"#.#.........#...#...#...#.#.......#.....#",
"#.#.###.###.###.#.#.#.###.#.#####.#.#####",
"#.....#.#...#...#.#.......#.....#.#...#.#",
"#.#####.#.###.###.#####.#.#.#.###.#.#.#.#",
"#.....#.#.......#.#.....#.#.#...#.#.#...#",
"###########.#.#.#.###.###.#######.#.#####",
"#.....#.#...#.#...#.....#.#.#.#...#.....#",
"#####.#.#.#.###.#.#.#.#.#.#.#.#.#.#.#.###",
"#...#.#...#.#.#.#.#.#.#.#.#.....#.#.#...#",
"#.#.#.#.#.###.#.#.#.###.#.###.#####.###.#",
"#.#.....#...#...#.#...#.#...#.#.#.#.#...#",
"###.###.#.#####.#.#####.#.#.#.#.#.###.###",
"#.....#.#...#...#.#.....#.#.....#.#.....#",
"###########.#################.#.#.###.###",
"#...#.......#.#...#.#.....#...#...#.#...#",
"#.#########.#.#.#.#.###.#########.#.#.#.#",
"#...#.......#...#.#.#...#.#.#.#...#...#.#",
"#.#.###.#########.#.#.#.#.#.#.#.#.#.#####",
"#.#.#.#.#...#.....#...#...#.....#...#...#",
"###.#.###.#####.#.###.###.#####.#.#.#.#.#",
"#...........#.#.#...#.....#.#.....#...#.#",
"###.#.###.###.#.#.#.#######.#######.#####",
"#.#.#...#...#.....#.......#.......#...#.#",
"#.#.#####.#######.#.#####.#.#########.#.#",
"#...#.............#.#...#.#...#...#.....#",
"#######.#####.#.###.###.#.###.#.###.###.#",
"#...........#.#...#.....#.#.......#...#.#",
"#########################################"
};
void buildMaze() {
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++) grid[i][j] = (MAZE[i][j] == '#') ? 1 : 0;
}
int main() {
int sx = 0, sy = 0, tx = N - 1, ty = M - 1;
/* ---------- 实验一:40 x 40 带墙网格 ---------- */
buildWalls();
int a1 = search(sx, sy, tx, ty, 1);
int c1 = g[tx * M + ty];
markPath(sx, sy, tx, ty);
buildWalls();
int d1 = search(sx, sy, tx, ty, 0);
markPath(sx, sy, tx, ty);
printf("=== 实验一:40 x 40 网格(带障碍墙)===\n");
printf(" A* 最短路 %d 步,扩展 %d 个结点\n", c1, a1);
printf(" Dijkstra 最短路 %d 步,扩展 %d 个结点\n", c1, d1);
printf(" A* 只需 Dijkstra 的 %.1f%%\n\n", 100.0 * a1 / d1);
drawGrid();
/* ---------- 实验二:41 x 41 完美迷宫 ---------- */
buildMaze();
sx = 1; sy = 1; tx = N - 2; ty = M - 2;
int a2 = search(sx, sy, tx, ty, 1);
int c2 = g[tx * M + ty];
markPath(sx, sy, tx, ty);
buildMaze();
int d2 = search(sx, sy, tx, ty, 0);
markPath(sx, sy, tx, ty);
printf("\n=== 实验二:41 x 41 完美迷宫 ===\n");
printf(" A* 最短路 %d 步,扩展 %d 个结点\n", c2, a2);
printf(" Dijkstra 最短路 %d 步,扩展 %d 个结点\n", c2, d2);
printf(" A* 只需 Dijkstra 的 %.1f%%\n", 100.0 * a2 / d2);
return 0;
}
看两处真实输出:实验一(40 × 40 带墙网格,80 步最短路)里 A* 只扩展了 81 个结点,而 Dijkstra 扩展了 1578 个,不到它的 5.1%,快了约 19 倍; 实验二(41 × 41 完美迷宫,96 步最短路)里 A* 扩展 125 个,Dijkstra 扩展 530 个,约 23.6%。 两个实验里 A* 与 Dijkstra 算出的最短路长度完全相等(80 步、96 步), 这正说明「启发式只管加速、不管改答案」—— 只要它可采纳。
实验一的比例(5.1%)比实验二(23.6%)漂亮得多,原因值得琢磨: 实验一的空地多、启发函数估得准,搜索几乎笔直地冲向终点; 实验二的迷宫里到处是死胡同,曼哈顿距离在弯弯绕绕的走廊里严重失真, A* 会被「看起来很近其实走不通」的方向骗着去撞几次墙。可见: 启发函数越接近真实剩余距离,A* 的优势越大。
(f, 编号) 而是 (f, h, 编号)。
原因是 STL 的 priority_queue 在元素相等时不保证出队顺序,
而不同的出队顺序会导致「扩展结点数」不一样 —— 同一份代码换个编译器,可能就报出 79 也可能报出 884。
加上 h 和编号两个比较键之后,队列顺序被唯一确定,扩展结点数才是一个可以写进文档、能被你复现的数字。
这类「把结果钉死」的做法在工程上非常常见:任何要对外报告的性能数字,都必须先消除这种不确定性。
A* 之外:工程上真正扛住全国路网的三招
A* 把搜索范围从「一个圆」压成了「一个朝终点拉长的椭圆」,但在 1 亿结点的图上, 这个椭圆依然可能覆盖几百万个结点。所以真实的导航产品还要再加三层:
- 双向搜索(bidirectional search)
- 从起点正向搜、从终点反向搜,两边轮流扩展,在中间某处会合。 如果单向要扩展出一个半径 r 的圆(面积 πr²),双向各扩展半径 r/2, 总工作量约 2 · π(r/2)² = πr²/2 —— 理论上直接省掉一半。 实现上要注意「两侧各自的 g 值不能直接相加判断收敛」,正确做法是 当两侧的最小 f 之和 ≥ 目前找到的最好路径长度时才停。
- 层次化预处理(hierarchical / contraction hierarchies)
- 把路网分成「高速 / 国道 / 城市小路」几层:长距离查询先在高层(高速)上跑, 只在起点和终点附近才下到小路。 道理很直观:从北京到上海,你绝不会先研究上海的弄堂怎么走 —— 先上高速,快到了再下匝道。 经过预处理(如 CH 收缩层次,把不重要的结点事先「收缩」掉并记下捷径)之后, 全国路网的查询可以压到毫秒级,这是 Google Maps、高德这类产品的核心技术之一。
- 路网预处理 + 高速缓存
- 把常用区域的最短路结果、各条高速之间的连接代价预先算好存起来; 再配合「按需分层加载」,让一次查询只触碰图的一小部分。
这三招是可以叠加的:现在的商用导航通常是层次化预处理 + 双向 A* 的组合。 课本上那个朴素的 Dijkstra,在这里只作为「最底层、最后一公里」的部件存在。
9.9.2 网络路由协议:OSPF 与 RIP 的两种活法
路由器要决定「这个包从哪个口发出去」,本质就是在做最短路。 但路由器和地图软件有个根本差别:地图是静态的,网络是动态的 —— 链路会断、会拥塞、会有新路由器接入,而且没有中央服务器能掌握全局。 于是网络界分裂出了两大流派,它们恰好就是我们在第 9.3 与第 9.5 节学的两种最短路算法。
| 对比项 | OSPF(链路状态) | RIP(距离向量) |
|---|---|---|
| 核心算法 | Dijkstra(第 9.3 节) | Bellman-Ford / 距离向量(第 9.5 节) |
| 每个路由器知道什么 | 通过泛洪拿到全网拓扑,自己算最短路 | 只知道邻居,靠周期性交换距离表 |
| 收敛速度 | 快(拓扑一变就重算,秒级甚至更快) | 慢(要一轮轮传播,坏消息传得尤其慢) |
| 开销 | 大:要泛洪全网链路状态,内存与 CPU 都吃掉不少 | 小:只和邻居说话,实现简单 |
| 典型规模 | 大型企业网、运营商骨干 | 小型网络、历史设备 |
| 致命弱点 | 配置复杂、区域划分不当会剧烈震荡 | 计数到无穷(count-to-infinity) |
为什么 OSPF 要用 Dijkstra?因为路由器一旦掌握了全网拓扑, 「从我这到目的地走哪条路最短」就是一个标准的单源最短路问题,Dijkstra 正合适,而且它收敛快 —— 对网络来说,链路断了之后「多久能重新绕通」是硬指标,这叫收敛时间。 代价是每个路由器都得存全网拓扑、跑一遍 O(m log n),还要不停地泛洪状态包。
为什么 RIP 只能用 Bellman-Ford?因为 RIP 里每个路由器根本不知道全网长什么样,
它只知道「我到邻居的距离」加上「邻居告诉我的、它到目的地的距离」。
这正是 Bellman-Ford 的松弛式 dist[v] = min(dist[v], dist[u] + w(u,v))——
而且是可以分布式、异步地做的:每台路由器各自算自己那一行,周期性换换表,就能慢慢收敛到正确值。
这种「不需要全局信息」的性质,正是距离向量协议能work的根本原因。
- B 发现直连断了,于是去看别的邻居 —— A 说「我到 X 只要 3 跳」;
- B 于是认为「我到 X 只要 4 跳」(它不知道 A 的 3 跳其实是经过 B 自己算出来的!);
- A 下一轮听 B 说 4 跳,就更新成 5 跳;B 听 A 说 5 跳,又更新成 6 跳……
- 两边互相喂错误信息,距离一跳一跳地涨上去,直到涨过协议规定的「无穷大」(RIP 里是 16 跳)才终于罢休。
工程上的补救办法:水平分割(从某接口学到的路由,不再从该接口发回去)、 毒性逆转(干脆把它标成 16 跳发回去)、触发更新(拓扑一变立刻发,不等周期到)、 以及把「无穷大」定得很小(16 跳)从而限制收敛时间的上限。
结论:RIP 的简单是用「收敛慢、有环路风险」换来的;这也是大型网络最终都转向 OSPF 的原因。
9.9.3 任务调度与依赖解析:拓扑排序撑起的半边天
第 9.6 节的拓扑排序看起来朴实无华,但它是这一章里被最多系统直接使用的算法, 因为它回答的问题太普遍了:「有先后约束的一堆事情,按什么顺序做?」
- 编译构建系统(Make / CMake / Bazel)
- 每个源文件、每个库、每个可执行文件都是图上的一个点,
#include或者链接依赖就是有向边。make必须先算出拓扑序,才知道先编译谁、后链接谁; 并行构建(make -j8)则更进一步:把当前入度为 0 的结点同时开工, 这正是 Kahn 算法(第 9.6.2 节)里「队列里同时待着多个入度 0 顶点」的工程含义。 - 包管理器(apt / npm / pip / Maven)
- 安装一个包要先装它的所有依赖,依赖还可能互相依赖。 解析器要算出一个可行的安装顺序,并在遇到循环依赖时报错。 顺带一提:Kruskal 里那个并查集(第 07 讲 7.9 节的并查集)在这里也常被用来快速判断 「这两个包是否已经被同一个依赖组覆盖」,避免重复安装。
- 电子表格的公式求值
C1 = A1 + B1、D1 = C1 * 2……单元格之间的引用关系天然是个 DAG。 Excel 每次重算都要重新做一次拓扑排序,保证「用到某个格子时它已经算好了」。 如果公式出现循环引用(A1 = B1 + 1且B1 = A1 + 1), 它会直接弹窗报错 —— 那就是拓扑排序发现「队列空了但还有结点没输出」。- 课程表排课与任务调度
- 「数据结构」要求先修「C 语言」,于是后者指向前者。 排课系统要给出一个满足全部先修约束的学期安排;约束自相矛盾(有环)时, 系统必须明确报错而不是硬排出一份不可能的课表。
- CI 流水线的 stage 依赖
- GitHub Actions / GitLab CI 里的
needs:声明了 job 之间的依赖。 Runner 调度器按拓扑序启动 job,没有依赖关系的 job 并行跑, 这正是拓扑排序 + 并行的典型用法。
为什么这个判据成立?因为入度 0 意味着「这件事没有前置任务了,可以立刻做」。 如果剩下的点入度都 ≥ 1,说明每一件事都在等别人先做,而这是个死结 —— 谁也没法先动。
工程上的意义非常大:依赖成环时,系统必须报错,绝不能默默继续。 构建系统如果对环视而不见,就会陷入「A 等 B、B 等 A」的死锁,或者干脆静默地构建出错误产物; 包管理器如果不检测环,会陷入无限递归安装。 所以在这些系统里,「检测出环」不是异常情况,而是一项必须实现的核心功能。
对比第 9.6.2 节的 DFS 版拓扑排序:DFS 用「灰色结点」标记正在访问的点, 一旦遇到指向灰色点的边就说明有环。两种实现都能判环,Kahn 版更直观、还能顺带做并行调度 (它靠的就是第 04 讲那个朴素的队列:入度变成 0 就入队,出队即「开工」)。
9.9.4 项目管理:关键路径法(CPM / PERT)
盖一栋楼、发射一颗卫星、发布一个软件版本 —— 这类项目的共同点是:
任务极多、任务之间有依赖、而你只关心「最快什么时候能完工」和「哪个环节拖不得」。
管理学界给这套方法起的名字叫 CPM(关键路径法)/ PERT(计划评审技术),
而它的数学形式,就是第 9.7 节那张 AOE 网和 ve / vl / e / l 四个数组。
| 第 9.7 节的量 | 项目管理里的叫法 | 回答的问题 |
|---|---|---|
ve(v) 事件最早发生时间 | 最早开始时刻 (ES) | 这件事最早能什么时候开始 |
vl(v) 事件最迟发生时间 | 最迟开始时刻 (LS) | 再不开始就要拖累整个工期了 |
e(a) 活动最早开始 | Early Start | 在紧前活动都完成后,它最早能动手的时间 |
l(a) 活动最迟开始 | Late Start | 它最晚不能晚于这个时间动手 |
l(a) − e(a) 松弛时间 | Total Float / 机动时间 | 这项活动能拖几天而不影响总工期 |
l(a) − e(a) = 0 | 关键活动 | 一天都不能拖 |
| 关键活动串成的路径 | 关键路径 | 决定总工期的那个链条,长度 = ve(汇点) |
「松弛时间」是整个方法里最有工程价值的数字。 它把项目里的任务分成了两类: 松弛时间为 0 的活动一动就出事;松弛时间有 5 天的活动,你晚 3 天开工完全没问题。 项目经理每天真正要盯的,就是那条关键路径上的十几个活动 —— 而不是全部三百个任务。
- 压缩关键活动:可能缩短总工期 —— 但要注意,压缩到一定程度后,
另一条路径可能变成新的关键路径,此时继续压原来那条就完全无效了,
必须重新算一遍
ve / vl找出新的关键路径。 - 压缩非关键活动:对总工期毫无影响,纯属浪费钱。 因为非关键活动本来就有机动时间,你把它做得再快,也要停下来等关键路径上的活动完工。
由此得到的工程准则:加人要加在关键活动上; 如果关键路径上压不动了,就想办法把任务从关键路径上挪走 (改成并行、或者改变任务之间的依赖关系),这才是真正的「管理」而不是「蛮干」。
这也解释了软件开发里那句名言「给已经延期的项目加人只会让它更延期」: 新人的沟通成本会引入新的依赖边,可能把原本不在关键路径上的活动拖成关键活动。
9.9.5 最小生成树:布线、聚类与图像分割
MST 的工程价值集中在一句话上:用最小的总代价把所有点连成一个整体。 凡是「连线」要花钱的场景,它就会出现。
- 网络布线(电话线 / 光纤 / 电缆)
- 要在若干个城市(或楼栋、机房)之间铺设线路,让任意两点都能通信,且总长度最短。 这正是 MST 的定义,Prim / Kruskal 直接可用。 边权就是距离或造价;如果不同地段造价不同(比如过江要贵得多),把权值换成造价即可。
- 电路板走线
- 要在板上若干焊点之间连通导线,希望总铜线长度最短 —— 同样是 MST。 更真实的版本还会加上「不允许交叉」等几何约束,那就超出了 MST 的范围, 变成了更难的 斯坦纳树问题(允许增加额外的中转点,MST 则只许用已有点)。
- 聚类:MST 去掉最长的几条边
- 把数据点看成完全图上的顶点,两点之间的权 = 它们的距离,求一棵 MST。 然后删掉权值最大的 k−1 条边,MST 就裂成 k 个连通块 —— 这天然就是 k 个簇。 这个方法的妙处是不需要事先指定簇的形状(不像 k-means 假设簇是球形的), 所以对「长条形、不规则」的簇效果更好。按权值从大到小删边,就得到了层次聚类的完整谱系。
- 图像分割
- 把像素当顶点,相邻像素的相似度当边权,做 MST 之后按边权切分, 相似度低的地方(也就是物体边界)会自然成为被切断的位置。 经典的 Felzenszwalb 分割算法就是基于 MST 的,它至今仍是很多视觉系统的预处理步骤。
- 管网铺设(水 / 气 / 供暖)
- 和布线同理,但边权要考虑的不只是长度,还有管径、压力损失、施工难度。 注意管网通常是有向的(水只能从高处往低处流),这时就不能直接用无向图的 MST 了, 需要变成「有向最小生成树 / 最小树形图」问题(用朱刘算法求解)。
「Prim 适合稠密图、Kruskal 适合稀疏图」在工程上是什么意思? 看第 9.2.5 节的复杂度:Prim 朴素版 O(n²) 只跟顶点数有关, Kruskal 是 O(m log m) 主要花在给边排序上。翻译成工程语言就是:
- 稠密图(m 接近 n²):边太多了,把几千万条边排一遍序本身就非常昂贵, 而 Prim 根本不需要排序,它只反复做「找离树最近的顶点」这一个动作。 所以城市路网、电路板这类「边多到爆炸」的场景选 Prim。
- 稀疏图(m 与 n 同阶):边很少,排序很快, 而且 Kruskal 有一个 Prim 比不了的好处 —— 它可以只处理「目前拿得到的边」,天然适合分布式和流式场景。 比如把边按权值分批喂进来,或者把图切成几块分别求 MST 再合并。
- 工程上更常见的做法:先用 Prim 或 Kruskal 求出 MST, 再把「必须连通的强制边」(比如已经铺好的线路、必须走地下的那几段)先 union 进去, 然后继续跑 Kruskal —— 这是对「部分边已被固定」这一现实约束的标准处理方式。
9.9.6 工程选型对比表与三条落地心法
| 算法 | 典型工程用途 | 负权 | 动态图 | 单源/全源 | 真实系统举例 |
|---|---|---|---|---|---|
| Dijkstra | 路由协议、导航的底层与短距离查询、游戏寻路 | 不支持 | 可重算,但代价高 | 单源 | OSPF 路由表计算;游戏 A* 的退化情形 |
| A* | 地图导航、游戏角色寻路、机器人路径规划 | 不支持 | 需重算(可增量修补) | 单源到单点 | Google Maps / 高德;Unity、Unreal 的 NavMesh 寻路 |
| Bellman-Ford | 距离向量路由、可负权的最短路、需要分布式计算 | 支持 | 天然适合(逐轮增量收敛) | 单源 | RIP 协议;BGP 的路径选择思想同源 |
| Floyd | 小规模全源最短路、传递闭包、图直径与中心性分析 | 支持(不能有负环) | 不适合(每次改动都要 O(n³)) | 全源 | 网络分析工具、社交网络的小图分析;地理信息系统的可达性矩阵 |
| Prim | 稠密图的布线:城市管网、电路板、机房互联 | —(无向图) | 支持(可增量加点) | —(生成一棵树) | 管网设计软件、EDA 布线工具的初始连通方案 |
| Kruskal | 稀疏图布线、聚类、图像分割、流式/分布式场景 | —(无向图) | 支持(加边即可,并查集增量维护) | —(生成一棵树) | Felzenszwalb 图像分割;单细胞聚类等生物信息学流程 |
| 拓扑排序 | 依赖解析与环检测、并行调度、按依赖顺序求值 | —(DAG) | 支持(增量维护入度) | —(给出一个全序) | Make / Bazel、npm / apt、Excel 公式重算、CI 的 stage 依赖 |
表怎么看?重点不在背,而在体会每一列的现实约束:
- 「能否处理负权」这一列,决定了你在「有补贴 / 有折扣」的图(负权边)上能不能用 Dijkstra / A*。 现实的例子:某些计费场景里「打折」会表现为负的成本增量,那就只能用 Bellman-Ford 了。
- 「能否处理动态图」这一列,往往比复杂度更重要。 网络拓扑每秒都在变,所以 RIP 那种「每次只更新一点点」的增量式方法反而有生命力; Floyd 虽然优雅,但每变一条边就要 O(n³) 重算,注定只能用于静态的小图。
- 「单源还是全源」这一列直接决定你要调用几次算法。 只要一次查询,就别上 Floyd;如果需要任意两点的距离,跑 n 次 Dijkstra(O(nm log n)) 和 Floyd 的 O(n³) 各有千秋:稀疏图上 n 次堆优化 Dijkstra 更快,稠密图上 Floyd 的常数更小。
- 课本算法是零件,不是产品。
A* 只是 Dijkstra 加了一个启发函数(
h ≡ 0就退回去); 工程系统里真正上线的,是「A* + 双向搜索 + 层次化预处理」这样组合出来的东西。 学会一个算法的性质(可采纳性、收敛性、能否增量),比记住它的代码更重要。 - 约束决定了算法能不能用,而不是快不快。 负权把 Dijkstra 挡在门外、动态图让 Floyd 失效、依赖成环要报错而不是硬算 —— 这些「用不了」的判断,才是选型的第一步。先看约束,再看性能。
- 可量化的指标才叫工程。 本节 A* 与 Dijkstra 的 81 : 1578、125 : 530,都是同一份代码在同一张图上跑出来的真实数字。 工程上评价一个算法改动,从来不是「感觉快了」,而是「扩展结点数从多少降到多少、P99 延迟降了多少」。 这里用的正是第 01 讲的大 O 分析:它预测的只是「扩展量级会小一个档次」, 真正能写进技术评审文档的,仍然是实跑出来的绝对数字。
9.10 本章小结、易错点与自测题
9.10.1 一页速查表
| 算法 | 解决的问题 | 核心思想 | 时间复杂度 | 限制 |
|---|---|---|---|---|
| Prim | 最小生成树 | 贪心长点:每次吞并离树最近的顶点 | 朴素 O(n²) / 堆优化 O(m log n) | 只适用于无向连通图 |
| Kruskal | 最小生成树 | 贪心长边:排序 + 并查集判环 | O(m log m) | 同上;需要对边排序 |
| Dijkstra | 单源最短路 | 贪心:每轮锁定 dist 最小的点再松弛 | 朴素 O(n²) / 堆优化 O(m log n) | 不能有负权边 |
| Floyd | 全源最短路 | DP:逐步放开中转点 k | O(n³) | 不能有负环;n 不宜大 |
| Bellman-Ford | 单源最短路 + 判负环 | n−1 轮暴力松弛所有边 | O(nm) | 慢 |
| SPFA | 单源最短路 + 判负环 | 队列优化 BF,只扩展被更新过的点 | 平均快,最坏 O(nm) | 容易被特殊数据卡 |
| Kahn | 拓扑排序 | 反复摘掉入度为 0 的顶点 | O(n + m) | 只适用于有向图 |
| DFS 拓扑 | 拓扑排序 | 后序逆序 | O(n + m) | 深图可能爆栈 |
| 关键路径 | AOE 网工期分析 | 拓扑序正推 ve + 逆序倒推 vl | O(n + m) | 必须是 DAG |
9.10.2 易错点清单
lowcost[j] = min(lowcost[j], g[k][j]) —— 右边只取一条边的权。
Dijkstra:dist[j] = min(dist[j], dist[k] + g[k][j]) —— 右边要累加。
写错之后示例图上 Prim 会算出 0→1→2→6 = 2+2+4 = 8 当成「顶点 6 到树的距离」,
最终 MST 权值会变成 21 而不是 18。
for i → for j → for k 是经典错误写法,会漏掉「需要连续经过多个中转点」的路径。
记住:k(中转点)永远在最外层。
自检方法:把三重循环连跑两遍,如果第二遍矩阵还会变小,说明写法错了。
min:g[u][v] = min(g[u][v], w),
直接赋值会把较小的边覆盖掉。自环(u = u)对 MST 和最短路都无意义,读入时直接丢弃。
Floyd 里若不初始化 dist[i][i] = 0,负环检测也会失效。
INF = 0x3f3f3f3f(约 1.06×10⁹)时,INF + INF 仍在 int 范围内,是安全的;
但如果用 INT_MAX = 2.1×10⁹,INF + w 就会溢出成负数,
导致「不可达」被误判成「可达且很短」。用 0x3f3f3f3f 并配合 memset 是最省心的做法。
vl(v) < ve(v),说明这张 AOE 网本身不自洽
(等价于有活动的余量为负),而不是你的算术错了。
规范的题目不会这样出,但手推时遇到要先检查网,再检查计算。
另外别忘了:汇点的 vl 必须等于 ve,这是倒推的起点。
9.10.3 自测题(6 道,答案折叠)
题 1(Prim 手推) 对本章示例图 G,从顶点 3 出发运行 Prim 算法,
写出每一轮的 lowcost[]、选中的顶点与加入的边,并给出最终的最小生成树权值和与边集。
查看答案与解析
从 3 出发,初始 lowcost(对 0,1,2,4,5,6)= (3, 5, ∞, 2, 5, ∞),closest 全为 3。
| 轮次 | lowcost[0,1,2,4,5,6] | 选中顶点 | 加入的边 | 累计权值 |
|---|---|---|---|---|
| 初始 | 3, 5, ∞, 2, 5, ∞ | — | — | 0 |
| 1 | 3, 5, ∞, 2, 5, ∞ | 4 | (3,4) 权 2 | 2 |
| 松弛 | 3, 5, ∞, —, 5, 5 | — | 经 4 把 lowcost[6] 从 ∞ 降到 5 | 2 |
| 2 | 3, 5, ∞, —, 5, 5 | 0 | (3,0) 权 3 | 5 |
| 松弛 | —, 2, 3, —, 5, 5 | — | 经 0 把 lowcost[1] 5→2、lowcost[2] ∞→3 | 5 |
| 3 | —, 2, 3, —, 5, 5 | 1 | (0,1) 权 2 | 7 |
| 松弛 | —, —, 2, —, 5, 5 | — | 经 1 把 lowcost[2] 3→2 | 7 |
| 4 | —, —, 2, —, 5, 5 | 2 | (1,2) 权 2 | 9 |
| 松弛 | —, —, —, —, 5, 4 | — | 经 2 把 lowcost[6] 5→4 | 9 |
| 5 | —, —, —, —, 5, 4 | 6 | (2,6) 权 4 | 13 |
| 6 | —, —, —, —, 5, — | 5 | (3,5) 权 5 | 18 |
边集 = {(0,1)2, (1,2)2, (0,3)3, (3,4)2, (2,6)4, (3,5)5},权值和 = 18, 与从 0 出发的结果完全一样——这正是「所有 MST 权值和相同」的体现。
题 2(Kruskal 与并查集) 对示例图 G 用 Kruskal 求 MST。 (1) 写出排序后的边序列;(2) 指出哪几条边会被丢弃、为什么;(3) 若把 (1,2) 的权值改成 3, 结果会不会变?MST 权值是多少?
查看答案与解析
(1) 排序后:2(0,1) → 2(1,2) → 2(3,4) → 3(0,2) → 3(0,3) → 4(2,6) → 5(1,3) → 5(3,5) → 5(4,6) → 6(1,5) → 11(0,6)。
(2) 丢弃 (0,2)(环 0–1–2–0)、(1,3)(环 1–0–3–1)、 以及选满 6 条边后不再处理的 (4,6)、(1,5)、(0,6)。 丢弃的判据是两端点 find 的结果相同。
(3) 把 (1,2) 改成 3 之后,排序变成
2(0,1) → 2(3,4) → 3(0,2) → 3(0,3) → 3(1,2) → 4(2,6) → …。
依次采纳 (0,1)、(3,4)、(0,2)、(0,3);此时 1 与 2 已在同一集合(通过 0),
所以 (1,2) 被丢弃;接着采纳 (2,6)、(3,5)。
边集变成 {(0,1)2, (3,4)2, (0,2)3, (0,3)3, (2,6)4, (3,5)5},
权值和仍是 18。这再次印证了「MST 可能不唯一,但权值和唯一」。
题 3(Dijkstra 手推) 对示例图 G,从顶点 2 出发运行 Dijkstra,
写出每轮的 dist[]、被确定的顶点与被松弛的边,并给出最终 dist 数组。
查看答案与解析
| 轮次 | 确定顶点 | dist[0..6] | 松弛的边 |
|---|---|---|---|
| 初始 | — | ∞, ∞, 0, ∞, ∞, ∞, ∞ | — |
| 1 | 2 | 3, 2, 0, ∞, ∞, ∞, 4 | (2,0)=3 (2,1)=2 (2,6)=4 |
| 2 | 1(dist=2) | 3, 2, 0, 7, ∞, 8, 4 | (1,3)=2+5=7 (1,5)=2+6=8 (1,0)=4>3 失败 |
| 3 | 0(dist=3) | 3, 2, 0, 6, ∞, 8, 4 | (0,3)=3+3=6(比 7 更小,更新) (0,6)=14>4 失败 |
| 4 | 6(dist=4) | 3, 2, 0, 6, 9, 8, 4 | (6,4)=4+5=9 其余已确定或更差 |
| 5 | 3(dist=6) | 3, 2, 0, 6, 8, 8, 4 | (3,4)=6+2=8(比 9 小,更新) (3,5)=11>8 失败 |
| 6 | 5(dist=8,与 4 并列,取编号小的 4 先?) | 3, 2, 0, 6, 8, 8, 4 | 见下方说明 |
| 7 | 4(dist=8) | 3, 2, 0, 6, 8, 8, 4 | (4,6)=13>4 失败 结束 |
最终 dist = [3, 2, 0, 6, 8, 8, 4](从顶点 2 出发)。
说明:第 6 轮时 dist[4] = dist[5] = 8 并列。
若选编号小的 4,则先确定 4,再确定 5;若选 5,则先确定 5。
两种顺序下 dist 数组完全相同(都是 [3,2,0,6,8,8,4]),只是最短路树不同。
验算:dist[0] = 3 走直连 (2,0);dist[4] = 8 走 2→1→3→4 = 2+5+2 = 9?
—— 不对,应走 2→0→3→4 = 3+3+2 = 8 ✔;dist[5] = 8 走 2→1→5 = 2+6 = 8 ✔。
题 4(Floyd 矩阵变化) 有一个 4 个顶点的有向图,邻接矩阵如下(∞ 表示无边):
行 0:(0, 1, ∞, 4) 行 1:(∞, 0, 2, ∞) 行 2:(∞, ∞, 0, 3) 行 3:(∞, ∞, ∞, 0)
(1) 按 Floyd 逐轮(k = 0,1,2,3)写出矩阵变化;(2) 说明为什么把 i 放到最外层会算错。
查看答案与解析
初始(k = −1):
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 1 | ∞ | 4 |
| 1 | ∞ | 0 | 2 | ∞ |
| 2 | ∞ | ∞ | 0 | 3 |
| 3 | ∞ | ∞ | ∞ | 0 |
k = 0(用 0 中转):dist[1][3] = min(∞, dist[1][0]+dist[0][3]),但 dist[1][0] = ∞,无变化。
k = 1(用 1 中转):dist[0][2] = min(∞, dist[0][1]+dist[1][2]) = 1+2 = 3;
dist[0][3] = min(4, 1+dist[1][3]=∞) = 4 不变。
k = 2(用 2 中转):dist[1][3] = min(∞, dist[1][2]+dist[2][3]) = 2+3 = 5;
dist[0][3] = min(4, dist[0][2]+dist[2][3] = 3+3 = 6) = 4 不变。
k = 3(用 3 中转):dist[0][3] 已是 4,从 3 出发没有出边,全部不变。
最终矩阵:
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 1 | 3 | 4 |
| 1 | ∞ | 0 | 2 | 5 |
| 2 | ∞ | ∞ | 0 | 3 |
| 3 | ∞ | ∞ | ∞ | 0 |
(2) dist[1][3] = 5 需要「先用 2 中转」,而 dist[0][2] = 3 需要「先用 1 中转」。
如果按 for i → for k → for j:处理 i = 0 时,dist[0][2] 要在 k = 1 那一步才能算出来 ——
这一步确实会发生(因为 k 在内层但仍在 i = 0 这一轮里遍历到了)。
真正的错误出现在 i 递增的顺序与 k 的依赖顺序冲突时:
处理 i = 1 时若要使用 dist[2][3](即 k = 2 作为中转),
由于 dist[2][3] 属于 i = 2 那一行,还没被更新过(i = 2 尚未处理),
于是用到了过期值,结果偏大甚至仍是 ∞。
根本原因:Floyd 的正确性要求「用 k 更新时,所有 dist[i][k]、dist[k][j]
都已经是第 k−1 阶段的最终值」。把 k 放在最外层,这个条件天然满足;
把 i 放在最外层,就变成「按行逐个完成」,行与行之间无法互相提供已经算好的中转信息。
题 5(拓扑排序) 给定有向图:0→1、0→2、1→4、2→3、3→4、4→5、2→5。 (1) 用 Kahn 算法求一个拓扑序(要求字典序最小);(2) 若再加一条边 5→1,结果如何?
查看答案与解析
(1) 先算入度:indeg = [0, 1, 1, 1, 2, 2](0:0,1:1(来自 0),2:1(来自 0),
3:1(来自 2),4:2(来自 1、3),5:2(来自 4、2))。
小根堆过程:{0} → 输出 0,1 和 2 入度归零 → 堆 {1,2} →
输出 1(编号小),4 的入度 2→1 → 堆 {2} → 输出 2,3 入度归零、5 入度 2→1 → 堆 {3} →
输出 3,4 入度归零 → 堆 {4} → 输出 4,5 入度归零 → 输出 5。
字典序最小的拓扑序:0 → 1 → 2 → 3 → 4 → 5。
(注意 0 → 2 → 1 → 3 → 4 → 5 也合法,但字典序更大。)
(2) 加边 5→1 之后:1 的入度变成 2(来自 0 与 5), 而 5 只能等 4 与 2 都完成才能开始,4 又要等 1 和 3…… 即 1 → 4 → 5 → 1 构成一个环,拓扑排序只能输出 0 与 2、3 等部分顶点,输出个数 < n ⇒ 有环。 具体地:初始只有 0 入度为 0,输出 0 后堆里只有 2(1 的入度是 2), 输出 2 后 3 入度归零 → 输出 3 → 此时 1 的入度仍是 2(0 已删,5 未完成)、4 的入度是 1(来自 1), 队列空。共输出 3 个顶点 < 6 ⇒ 存在环。
题 6(关键路径) 给定 AOE 网:0→1(3)、0→2(4)、1→3(2)、2→3(5)、2→4(6)、3→5(4)、4→5(2)。 (1) 求各事件的 ve 与 vl;(2) 求各活动的 e、l 与时间余量;(3) 指出关键活动与关键路径; (4) 工程最短完成时间是多少?
查看答案与解析
① 正推 ve(拓扑序 0,1,2,3,4,5): ve[0]=0;ve[1]=3;ve[2]=4;ve[3]=max(3+2, 4+5)=9; ve[4]=4+6=10;ve[5]=max(9+4, 10+2)=13。
② 逆推 vl(逆拓扑序 5,4,3,2,1,0,初值 13): vl[5]=13;vl[4]=13−2=11;vl[3]=13−4=9; vl[2]=min(9−5, 11−6)=min(4,5)=4;vl[1]=9−2=7;vl[0]=min(7−3, 4−4)=0。
校验:ve = [0,3,4,9,10,13],vl = [0,7,4,9,11,13],全部 vl ≥ ve ✔
③ 活动的 e、l、余量(e = ve(起点),l = vl(终点) − w):
| 活动 | w | e | l | l − e | 关键? |
|---|---|---|---|---|---|
| 0→1 | 3 | 0 | 7−3 = 4 | 4 | 否 |
| 0→2 | 4 | 0 | 4−4 = 0 | 0 | ★ 是 |
| 1→3 | 2 | 3 | 9−2 = 7 | 4 | 否 |
| 2→3 | 5 | 4 | 9−5 = 4 | 0 | ★ 是 |
| 2→4 | 6 | 4 | 11−6 = 5 | 1 | 否 |
| 3→5 | 4 | 9 | 13−4 = 9 | 0 | ★ 是 |
| 4→5 | 2 | 10 | 13−2 = 11 | 1 | 否 |
(3) 关键活动:0→2、2→3、3→5;把它们串起来得到关键路径 0 → 2 → 3 → 5, 长度 4 + 5 + 4 = 13。
(4) 工程最短完成时间 = ve(5) = 13(= 关键路径长度)。
补充:次长路径 0 → 2 → 4 → 5 长度 4+6+2 = 12,只比关键路径短 1 ——
所以若把活动 2→3 压缩 1 个单位,总工期只缩短 1(变成 12),
再压缩就没有效果了,因为瓶颈转移到了 0→2→4→5 上。
- Prim 手推表:lowcost / closest / 选中顶点 / 总权值,注意「不累加」。
- Kruskal 手推表:排序后的边序列 + 并查集集合归属 + 采纳/丢弃原因。
- Prim vs Kruskal:复杂度、适用图类型(稠密 / 稀疏)、是否需要并查集。
- Dijkstra 手推表:dist / visited / 选中顶点 / 被松弛的边;为什么不能有负权。
- Floyd:
dp[k][i][j]的含义与转移方程,k 为什么必须在最外层,矩阵逐轮变化。 - 拓扑排序:AOV 网定义、Kahn 步骤、判环、「有拓扑序 ⟺ DAG」。
- 关键路径:ve / vl / e / l 四张表、关键活动判据、关键路径长度 = 最短工期。