第 09 讲

图论算法:生成树与最短路径

上一讲我们把图「存」了起来,这一讲开始让图「动」起来。同样是带权图,问法不同,算法就完全不同: 让所有点连通且总代价最小是最小生成树(Prim / Kruskal); 从一点到另一点走哪条路最省是最短路径(Dijkstra / Floyd / Bellman-Ford); 哪些事必须先做、哪些可以并行是拓扑排序(AOV 网); 整个工程最快几天完工、哪些环节不能拖是关键路径(AOE 网)。 这四组算法是 408 与各类算法竞赛的绝对高频考点,也是「贪心」「动态规划」两大思想的最佳入门范例。

预计 150 分钟 前置:第 08 讲图的存储、第 07 讲树、第 01 讲大 O 关键词:贪心 · 松弛 · 并查集 · 拓扑序 · 关键路径
本章导读
  • 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 全部演一遍。 请把它抄到草稿纸上,后面每张手推表都对着它看。

2 2 3 3 5 11 6 4 2 5 5 0 1 2 3 4 5 6 V = {0,1,2,3,4,5,6}  E = {(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}
图 9-1 贯穿全章的示例图 G:7 个顶点、11 条带权无向边(权值为正)

把这张图整理成两种存储结构,就是下面这样(后文所有代码都用这两张表初始化):

邻接矩阵(∞ 表示不可直达,对角线为 0)
0123456
0023311
120256
23204
335025
4205
5650
611450

存储代价 O(n²),与边数无关;判 (u,v) 是否有边是 O(1)。适合稠密图,也是 Prim 朴素版与 Floyd 的默认选择。

链式前向星(无向边存成两条有向边,共 22 条)
head[]0123456
第一条边下标9537642

头插法的结果是边表顺序与读入顺序相反:先读 (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):在带权连通无向图中, 边权总和最小的那棵生成树。注意三个前提

现实中的原型非常多:用最少的网线把 n 个城市连起来用最短的线路给一片小区供气电路板上用最少的铜箔连通所有焊点……只要满足「连通所有点 + 总代价最小」,就是 MST 问题。

① 原带权无向图 3 4 5 6 2 A B C D E 4 个顶点、5 条边,其中环 A-B-D-C-A 的总权 = 3+4+6+5 = 18 生成树必须「n=4 个点、n−1=3 条边、连通且无环」 ② 三种取法都能得到生成树(3 条边、连通、无环) 方案 甲:权值和 3+4+2 = 9 A B C D E 虚线 = 未选用的边 ← 最小生成树(MST) 方案 乙:权值和 3+5+2 = 10 A B C D E 也是生成树,但不是最小的 权值和 10 > 9,被淘汰 ③ 两个反例:为什么「随便选 3 条边」不行? 若选 A-B、B-D、C-D、A-C(4 条边)→ 出现环 A-B-D-C-A,不是树; 若选 A-B、C-D 只有 2 条边 → 不连通,E 掉了队
图 9-2 生成树与最小生成树:同一个图有多棵生成树,MST 是权值和最小的那棵

9.2.2 MST 的两条性质:割性质与环性质

Prim 和 Kruskal 表面上差别很大(一个长点、一个长边),但它们背后站的是同一条定理。 理解了这条定理,你就知道这两个算法不是碰巧对,而是必然对。

割性质(cut property)—— MST 的「存在性」保证 把顶点集 V 任意划分成两个非空部分 S 与 V−S,这个划分称为一个割(cut); 横跨两边的边称为割边。则:跨越任意一个割的权值最小的那条边,一定属于某棵最小生成树。 (若有多条权值并列最小,则至少有一条属于某棵 MST。)
① 割性质:跨割最便宜的边一定在 MST 里 集合 S 集合 V − S w=7 w=2 ← 最小割边 w=5 任意割 S / V−S:跨割边里权值最小的那条 → 必在某棵 MST 中 推论:Prim 每次把「离树最近的割边」收进来,Kruskal 每次收下「不与已选边成环的最小边」,本质都在用割性质。
图 9-3 割性质:跨越任意割的最小权边必属于某棵最小生成树
环性质(cycle property)—— MST 的「排除性」保证 对图中的任意一个,环上权值最大的那条边一定不属于任何一棵最小生成树 (若最大权有并列,则至少可以去掉其中一条而不影响最优性)。
直觉解释:环上每条边都只是「连通环上这些点」的一种方式,既然有一条最贵的,那把它拆掉、用环上其它边照样能连通, 总代价只会更小 —— 所以最贵的边没必要留。

把这两条性质对着示例图 G 用一遍,你立刻就能「不跑算法」判断出一些边的命运:

「所有 MST 权值和相同」到底是什么意思 MST 可以有很多棵:示例图 G 里权值为 2 的边有 3 条、权值为 3 的边有 2 条, 所以 (0,2) 与 (0,3) 有时可以互换,(1,3) 与 (3,5) 也可以互换,能得到多棵不同的最小生成树。 但定理告诉我们:它们的边权总和必然完全相同(本例恒为 18)。
原因:MST 的权值和是由「割的最小割边权值之和」这种只与图有关、与选择无关的量决定的; 更严格地说,可以证明若两棵生成树 T₁、T₂ 权值和不同,就能通过「交换一条边」把较重的改成较轻的,矛盾。
考试口径:「最小生成树可能不唯一,但最小生成树的权值和唯一」——这句话是对的; 「最小生成树的边集唯一」——这句话是错的。

9.2.3 Prim 算法:一个顶点一个顶点地「长大」

一句话本质

Prim = 从一个顶点出发,每次选「距离当前生成树最近的那个顶点」,把它和那条最短的连接边一起吞进树里

把生成树想象成一个正在扩张的「国家」,树外的顶点是「邻国」。每个邻国都有一个「到该国的最短代价」 lowcost[i],还有一个「是通过哪座城市连过去的」closest[i]。 每一轮,Prim 挑 lowcost 最小的邻国吞并,然后用新吞并的城市去更新其它邻国的代价。 整个过程其实就是 9.2.2 的割性质:已入树的点集 S 与树外的点集 V−S 构成一个割, 而 lowcost 最小的那个邻国对应的边,正是这个割的最小割边。

算法流程(4 步)

  1. 初始化:任选一个起点 s,令 lowcost[s] = 0(其它为 ∞), lowcost[i] = w(s,i)(不可达则 ∞),closest[i] = s,标记 visited[s] = true
  2. 选点:在 visited = false 的顶点中,找 lowcost 最小的顶点 k。若 lowcost[k] == ∞, 说明图不连通,算法结束。否则把 k 加入生成树,边 (closest[k], k) 入选,visited[k] = true
  3. 松弛:对 k 的每个未访问邻接点 j,若 w(k,j) < lowcost[j], 则 lowcost[j] = w(k,j)closest[j] = k注意这条式子左边是「到树的距离」,右边是「一条边的权」——不需要累加,因为只要接到树上就只花一条边的代价。
  4. 重复第 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 00
第 1 轮 2, 3, 3, ∞, ∞, 11 0, 0, 0, −, −, 0 1(最小 2) (0,1)212
松弛 —, 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)224
松弛 —, —, 3, ∞, 6, 4 —, —, 0, −, 1, 2 经 2 更新了 6(11→4)
第 3 轮 —, —, 3, ∞, 6, 4 —, —, 0, −, 1, 2 3(最小 3) (0,3)337
松弛 —, —, —, 2, 5, 4 —, —, —, 3, 3, 2 经 3 更新了 4(∞→2)与 5(6→5)
第 4 轮 —, —, —, 2, 5, 4 —, —, —, 3, 3, 2 4(最小 2) (3,4)249
松弛 —, —, —, —, 5, 4 —, —, —, —, 3, 2 4 的邻居只有 3(已入树)和 6,w(4,6)=5 不小于 lowcost[6]=4,本轮 lowcost 不变
第 5 轮 —, —, —, —, 5, 4 —, —, —, —, 3, 2 6(最小 4) (2,6)4513
松弛 —, —, —, —, 5, — —, —, —, —, 3, — 6 的邻居 0/2/4 全部已入树,无可松弛对象
第 6 轮 —, —, —, —, 5, — —, —, —, —, 3, — 5(最小 5) (3,5)5618
松弛 全部顶点已入树,7 个顶点 6 条边 ⇒ 最小生成树完成,总权值 = 18
考点:手推 Prim 表的三个细节
  1. 比较的是「到树的距离」,不是「从源点出发的路径长度」。比如第 5 轮 lowcost[6] = 4, 是边 (2,6) 的权,而不是 0→1→2→6 的累计长度 8。这与 Dijkstra 的 dist 有本质区别, 也是把 Prim 表当成 Dijkstra 表来填的典型错误。
  2. 等值时怎么办:第 3 轮 lowcost[2] = 3lowcost[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。 结论:等值时的选择不影响总权值,但影响具体边集
  3. 每一轮必须写「松弛」这一步:只写「选中顶点」而不写 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 当成 dist 累加 写成 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;
}
堆优化 Prim 的三个细节
  • 为什么会有「过期元素」:同一个顶点可能被多次压入堆(每次发现更短的连接边就压一次), 所以出堆时要 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 = 把边按权值排序,从小到大依次尝试:两端点还不在同一个连通块 → 收下;已在同一块 → 丢弃(会成环)

Kruskal 的思路比 Prim 更「朴素」:既然要总权最小,那就从最便宜的边开始买。 但便宜边可能连的是已经连通的两个点,硬加进去就成环了,而生成树不允许有环 —— 于是丢弃。 这个「判断两端点是否已连通」的动作,正好是并查集(Disjoint Set Union, DSU)的拿手好戏: 查询与合并几乎都是 O(1)。

Kruskal 的贪心循环:按权值从小到大,逐条判断「能不能用」 2 2 2 3 3 4 5 5 5 6 11 ← 排序升序(示例图 G) 绿 = 采纳(6 条) 红 = 丢弃(成环) 灰 = 选满 6 条后无需再看 取下一条边 e = (u,v),问:find(u) == find(v) ? 否(不在同一集合) 是(已经连通) ① 采纳这条边,累加权值 unite(u,v):合并两个集合 (这一步绝不会产生环 —— 正是并查集保证的) 丢弃这条边(什么都不做) 因为它会与已选边构成 由环性质:环上最大权边不属于任何 MST 已选边数 == n − 1 ? → 是则停止,否则继续下一条
图 9-4 Kruskal 的贪心循环与并查集判环(示例图 G 的边排序结果)

并查集:Kruskal 的心脏

并查集维护若干个不相交的集合,支持两种操作: find(x) 返回 x 所在集合的「代表元(树根)」, union(x,y) 把两个集合合并。两个优化是它的精髓:

两个优化一起用,单次操作的均摊复杂度是 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 的第一步是排序,这一步本身就是考点。排序后的边序列(权值相同时按端点编号排):

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)
按权值升序排列的边(权值写在方框上方,端点写在框内) (0,1) 2 ★ (1,2) 2 ★ (3,4) 2 ★ (0,2) 3 ✗环 (0,3) 3 ★ (2,6) 4 ★ (1,3) 5 ✗环 (3,5) 5 ★ (4,6) 5 ✗环 (1,5) 6 ✗环 (0,6) 11(未处理) ★ = 采纳(共 6 条,权值和 2+2+2+3+4+5 = 18)  ✗ = 丢弃(加入会形成环) 注意:选满 n−1 = 6 条边后即可停止,所以权值 11 的 (0,6) 根本没被判定;但即使判定也会因 (0,6) 成环被丢弃。
图 9-5 Kruskal 的边排序与取舍结果(示例图 G)
次序权值两端点所在集合(find 结果)是否同集合决策累计边数累计权值并查集合并
1(0,1)2{0} vs {1}采纳12{0,1}
2(1,2)2{0,1} vs {2}采纳24{0,1,2}
3(3,4)2{3} vs {4}采纳36{3,4}
4(0,2)3{0,1,2} vs {0,1,2}丢弃(成环 0–1–2–0)36不变
5(0,3)3{0,1,2} vs {3,4}采纳49{0,1,2,3,4}
6(2,6)4{0,1,2,3,4} vs {6}采纳513{0,1,2,3,4,6}
7(1,3)5{0,...} vs {0,...}丢弃(成环 1–2–6? 实为 1–0–3)513不变
8(3,5)5{0,1,2,3,4,6} vs {5}采纳618全部合并,结束
9(4,6)5已选满,无需处理618
10(1,5)6已选满,无需处理618
11(0,6)11已选满,无需处理618

第 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。更精确地说: 当 m 远小于 n² 时(稀疏图),Kruskal 的 O(m log m) 更快; 当 m 接近 n² 时(稠密图),Prim 的 O(n²) 更快,因为此时 m log n ≈ n² log n 反而更大。
另外两个常考的判断题:「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 算法流程与贪心前提

  1. 初始化dist[s] = 0,其余 dist[i] = ∞visited[] 全为 false;pre[] 全为 −1。
  2. 选点(贪心):在 visited = false 的顶点中,选 dist 最小的那个顶点 u。
  3. 确定:标记 visited[u] = true。此时 dist[u]永远定下来了,之后不再修改。
  4. 松弛:对 u 的每个未确定邻接点 v,执行 dist[v] = min(dist[v], dist[u] + w(u,v))
  5. 重复 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)。

考点:Dijkstra 的三个关键性质
  1. 每次确定一个顶点,共确定 n 次,已确定集合不断扩大。
  2. 已确定顶点的 dist 不再改变(这是它和 Bellman-Ford / SPFA 的最大区别,也是不能有负权的原因)。
  3. 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][·] 初始化时,别忘了 pre[] 朴素版常常直接用邻接矩阵的一行当作 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 和 3 的最短距离(图中有一条负权边) 0 源点 s 1 2 3 w = 1 w = 2 w = 4 w = −2 ① 第 1 轮:确定 0,松弛得 dist = [0, 1, 2, ∞] ② 第 2 轮:未确定顶点里 dist 最小的是 1(dist = 1),于是锁定 dist[1] = 1,并用它算出 dist[3] = 1 + 4 = 5 ③ 第 3 轮:确定 2(dist = 2),发现 2→1 权为 −2, 本可令 dist[1] = 2 − 2 = 0,但 1 已锁定,无效! ④ 结果:算法输出 dist[1] = 1、dist[3] = 5,而真实值是 dist[1] = 0、dist[3] = 4(走 0 → 2 → 1 → 3)——错了!
图 9-6 Dijkstra 的负权反例:顶点 1 被过早锁定,真正的更短路 0→2→1 与它推导出的 0→2→1→3 全都算错了

把上面这个反例完整地算一遍(四条有向边: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] 也带偏了 —— 一个被过早锁定的错误值,会沿着后续的松弛一路污染下去

为什么这个反例必须有 4 个顶点、且负边要「往回指」? 想让 Dijkstra 出错,必须制造「后处理的顶点能提供更短路」的局面:设顶点 x 先被取出并锁定, 而顶点 y 后取出,且 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 行,是「短小精悍」的典范。

它的适用条件:允许有负权边,但不允许有负环(有负环时结果无意义,不过我们可以顺便检测出来)。

k = 0 时:中转点集合为空,dist 就是邻接矩阵(只允许「一条边直达」) i\j 0 1 2 3 0 0 1 4 1 0 2 2 0 3 3 0 k = −1(不允许任何中转):矩阵里只有直连边的权值 状态转移:让第 k 个顶点「获得中转资格」 i j k dist[i][j](不用 k) dist[i][k] dist[k][j] dist[i][j] = min( dist[i][j], dist[i][k] + dist[k][j] ) 蓝色虚线 = 原来的方案(不经过 k);橙色折线 = 新方案(在 k 处中转一次) k 从 0 枚举到 n−1,每轮都让「可用的中转点」多一个,n 轮后所有组合都被考虑过 关键:k 是「阶段」,必须放在最外层循环(原因见 9.4.3)
图 9-7 Floyd 的动态规划状态与转移:逐步放开中转点 k

9.4.2 动态规划的思想:从 dp[k][i][j] 到二维滚动

先想一个「笨」但清晰的 DP。定义:

注意「只允许经过 ≤ k 的顶点中转」这句话的准确含义:路径的中间点只能从 {0,1,…,k} 里挑, 但起点 i 和终点 j 可以是任意顶点,而且路径的中间点允许重复经过编号更小的点。

考虑第 k 个中转点「用还是不用」,只有两种可能:

取两者较小值,得到状态转移方程:

边界(k = −1,即不允许任何中转):dp[i][j] = w(i,j),无边为 ∞,dp[i][i] = 0。 这正好就是邻接矩阵。

二维滚动(就地更新):注意 k 这一维只依赖 k−1,所以可以把这一维省掉, 直接在邻接矩阵上原地更新:

但「省掉一维」是有条件的:k 必须放在最外层。下一小节专门讲这个坑。

9.4.3 为什么 k 必须在最外层(最经典的易错点)

结论先给:把 i 或 j 放到最外层就是错的 正确写法是 for k → for i → for j。 写成 for i → for j → for kfor 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)会发生什么?

一个能当场验证的微型反例(请自己推一遍) 4 个顶点、3 条有向边:0→3 权 13→1 权 11→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(初始矩阵 = 邻接矩阵)

distj=0j=1j=2j=3j=4j=5j=6
i=0023311
i=120256
i=23204
i=335025
i=4205
i=5650
i=611450

k = 0(允许经过顶点 0 中转)——例如 dist[3][2] = min(∞, dist[3][0]+dist[0][2]) = 3+3 = 6

distj=0j=1j=2j=3j=4j=5j=6
i=0023311
i=12025613
i=232064
i=335602514
i=4205
i=5650
i=6111341450

k = 1(再允许顶点 1)——这一轮产生了 dist[0][5] = 2+6 = 8dist[5][6] = 6+13 = 19 等;

distj=0j=1j=2j=3j=4j=5j=6
i=00233811
i=12025613
i=2320684
i=335602514
i=4205
i=58685019
i=611134145190

k = 2(再允许顶点 2)——dist[0][6] 从 11 降到 3+4 = 7「直连不如绕路」在这里第一次体现

distj=0j=1j=2j=3j=4j=5j=6
i=0023387
i=1202566
i=2320684
i=335602510
i=4205
i=58685012
i=6764105120

k = 3(再允许顶点 3)——dist[0][4] = 3+2 = 5顶点 4 第一次变得可达

distj=0j=1j=2j=3j=4j=5j=6
i=00233587
i=12025766
i=23206884
i=335602510
i=45782075
i=586857012
i=6764105120

k = 4(再允许顶点 4)——dist[3][6] 从 10 降到 2+5 = 7dist[6][3] 对称更新;

distj=0j=1j=2j=3j=4j=5j=6
i=00233587
i=12025766
i=23206884
i=33560257
i=45782075
i=586857012
i=676475120

k = 5(再允许顶点 5)与 k = 6(再允许顶点 6)——矩阵不再变化,算法收敛:

最终 distj=0j=1j=2j=3j=4j=5j=6
i=00233587
i=12025766
i=23206884
i=33560257
i=45782075
i=586857012
i=676475120
两处可以用来验算的细节
  • 最终矩阵的第 0 行 = [0,2,3,3,5,8,7],与 9.3.3 里 Dijkstra 从 0 出发的结果 完全一致——两种算法互相印证,这是检查手推有没有算错的最好办法。
  • 矩阵关于主对角线对称dist[i][j] = dist[j][i]),因为示例图是无向图。 有向图就没有这个性质,手推时不要习惯性对称填写。

下面动画把 k = 0..6 的矩阵变化逐步演一遍,橙色格子是本轮被更新的元素:

考点:Floyd 的复杂度与适用范围 时间 O(n³),空间 O(n²)(就地滚动,不需要额外开 dp 数组)。 由于 n³ 的代价,Floyd 一般用于 n ≤ 500 的稠密图; n 很大又只需要单源时,宁可跑 n 次堆优化 Dijkstra(O(n·m log n),稀疏图上更快)。
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]=120, 2, 4, 12, 8
第 2 轮全部失败(没有任何一条边能松弛)⇒ 提前收敛0, 2, 4, 12, 8
第 3、4 轮同样无更新(代码会提前 break,不必真跑)0, 2, 4, 12, 8
负环检测再扫一遍所有边,若仍能松弛 ⇒ 有负环。本例不能 ⇒ 无负环0, 2, 4, 12, 8
关于「提前退出」与「n−1 轮」的关系 「最多 n−1 轮」是最坏情况的上界,实际往往远小于它 —— 只要某一轮没有任何更新,后面就不可能再有更新,可以直接退出。 但判断负环不能靠「是否提前退出」:必须老老实实跑完 n−1 轮(或检测到第 n 轮还能更新)。
另外注意:如果只需要判负环而不管最短路,可以把 dist[] 全部初始化为 0 (等价于加一个到所有点权为 0 的超级源点),这样即使负环与源点不连通也能检测出来。

下面动画逐轮演示 Bellman-Ford 的松弛过程,并给出负环的样子:

9.5.2 SPFA:队列优化的 Bellman-Ford

Bellman-Ford 有个明显的浪费:每一轮都把所有边扫一遍,但只有上一轮被更新过的顶点, 它的出边才可能引发新的更新。于是自然想到:用一个队列保存「刚刚被松弛过的顶点」, 每次取出一个顶点,只松弛它的出边。这就是 SPFA(Shortest Path Faster Algorithm), 由段凡丁在 1994 年提出。

SPFA 的实现要点:

#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;
}
诚实提醒:SPFA 的最坏复杂度是 O(nm),会被专门卡 SPFA 的「快」只是平均情况的经验结论,它的最坏时间复杂度仍是 O(nm), 与 Bellman-Ford 相同。出题人可以构造「网格图 + 精心设计的边权」让 SPFA 退化成 O(nm), 从而在 10⁵ 级别的数据上超时(这就是算法竞赛里著名的「SPFA 已死」梗)。
实践建议
  • 无负权 ⇒ 一律用堆优化 Dijkstra,稳定 O(m log n),不要用 SPFA。
  • 有负权但无负环 ⇒ 用 SPFA 或 Bellman-Ford;n 较小时直接用 Bellman-Ford 更稳。
  • 题目只要求判负环 ⇒ 常用「SPFA + 入队次数统计」或「SPFA + DFS 版判环」。
  • 负权图 + 大规模数据 ⇒ 考虑 Johnson 算法(先用 Bellman-Ford 重赋权,再跑 n 次 Dijkstra)。

9.5.3 三种最短路算法对比表

Bellman-Ford:每轮把「所有边」都松弛一遍 dist = [0, ∞, ∞, ∞, ∞] 第 1 轮:for 每条边 e: relax(e) dist = [0, 2, 4, 12, 8] ← 有更新 第 2 轮:for 每条边 e: relax(e) dist = [0, 2, 4, 12, 8] ← 无更新 ⇒ 收敛 第 n 轮:若仍能松弛 ⇒ 存在负环 每轮都是「全量扫描」,不挑不选,所以慢但稳 SPFA:只把「刚被更新过」的顶点放进队列 q = [0],inq[0] = true,cnt[0] = 1 出队 0 → 松弛邻居 1、2 ⇒ 1、2 入队 q = [1, 2] 出队 1 → 松弛 4 ⇒ 4 入队;出队 2 → 4 变短 ⇒ 4 再次入队 q = [4, 4] …(同一个点可能在队列里出现多次) 若某点入队次数 ≥ n ⇒ 存在负环 只处理「有希望」的点,平均快,但最坏仍是 O(nm) 队列优化 两者的共同点与分工 共同点:① 都不贪心、允许负权边;② 都靠「反复松弛」逼近答案;③ 都能判定负环;④ 最坏复杂度都是 O(nm) 分工:n 小或需要「确定的最坏复杂度」时用 Bellman-Ford;图随机、边权不刁钻时 SPFA 快得多; 但只要有负权边又想稳,比赛里的标准答案是 Johnson 算法(BF 重赋权 + n 次 Dijkstra),而不是 SPFA。
图 9-8 Bellman-Ford 与 SPFA 的对照:全量扫描 vs 队列优化
对比维度Dijkstra(堆优化)Floyd-WarshallBellman-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)

AOV 网的定义
  • 顶点表示活动(activity,一项任务 / 一门课 / 一个步骤);
  • 有向弧 <u,v> 表示「u 必须先于 v 完成」这种先后(前驱)关系;
  • 没有权值(我们只关心顺序,不关心耗时 —— 关心耗时的叫 AOE 网,见 9.7);
  • 图中不能有环:若 u 必须先于 v、v 又必须先于 u,这两件事就永远做不成。

拓扑排序(topological sort):把 AOV 网的所有顶点排成一个线性序列, 使得对图中任意一条弧 <u,v>,u 都排在 v 的前面。 这个序列称为拓扑序列。它的意义就是「一个可行的做事顺序」。

核心定理:有向图存在拓扑序 ⟺ 它是 DAG
  • 有环 ⇒ 无拓扑序:环上的 u₁→u₂→…→u₁ 要求 u₁ 在 u₁ 前面,矛盾。
  • 无环(DAG)⇒ 必有拓扑序:DAG 中一定存在入度为 0 的顶点 (否则从任意点一直往前找前驱,必然重复访问某个点,那就成了环), 把它拿掉后剩下的图仍是 DAG,归纳即可构造出完整序列。
  • 拓扑序一般不唯一:同时有多个入度为 0 的顶点时,先输出谁都可以。 例如示例中 1 与 2 都无前驱,先做 1 还是先做 2 都合法。
  • 只有 DAG 才有拓扑序,所以「用拓扑排序判断有向图是否有环」是最常用的判环手段之一。

9.6.2 Kahn 算法:不断摘掉入度为 0 的顶点

一句话本质

Kahn = 反复找入度为 0 的顶点(没有前驱、现在就能做)输出,并「删掉」它的所有出边

直觉极简单:入度为 0 的顶点意味着「没有前置任务」,可以立刻执行。 执行完之后,它指向的那些顶点就少了一个前置条件 —— 对应「把它的出边删掉,邻接点入度减 1」。 如果某个邻接点的入度因此变成 0,它就可以进入待办队列了。 当队列空了却还有顶点没输出时,说明剩下的顶点互相牵制,即存在环

算法步骤

  1. 统计每个顶点的入度 indeg[];把所有入度为 0 的顶点入队。
  2. 队列非空时:取出队首顶点 u,把 u 追加到输出序列。
  3. 遍历 u 的每条出边 <u,v>indeg[v]--;若减到 0,把 v 入队。
  4. 重复 2~3 直到队列为空。若输出序列的长度 = n,则是 DAG;否则图中存在环。

手推过程表

用一个 6 个顶点、7 条弧的 AOV 网做例子。为方便与 9.7 的关键路径对照, 这里保留每条弧的「持续时间」,拓扑排序本身只用其中的先后关系(忽略权值):

弧集:0→1(7)、0→2(5)、1→3(6)、2→3(4)、2→4(3)、3→5(6)、4→5(8)
7 5 6 4 3 6 8 0 源点 in=0 1 in=1 2 in=1 3 in=2 4 in=1 5 汇点 out=0 入度 indeg[] = [0, 1, 1, 2, 1, 2] (同一张网在 9.7 节还会用作 AOE 网)
图 9-9 用于拓扑排序与关键路径的 AOV / AOE 网(6 个顶点,7 条弧)
步骤队列 queue出队顶点操作入度数组 indeg[0..5]输出序列
初始[ 0 ]统计入度,只有 0 入度为 00, 1, 1, 2, 1, 2(空)
1[ 0 ] → [ ]0删出边 0→1、0→2,两者入度各减 1,均变成 0 ⇒ 都入队—, 0, 0, 2, 1, 20
2[ 1, 2 ] → [ 2 ]1删出边 1→3,indeg[3] 由 2 减到 1(不为 0,不入队)—, —, 0, 1, 1, 20, 1
3[ 2 ] → [ ]2删出边 2→3(indeg[3] 1→0 ⇒ 入队)、2→4(indeg[4] 1→0 ⇒ 入队)—, —, —, 0, 0, 20, 1, 2
4[ 3, 4 ] → [ 4 ]3删出边 3→5,indeg[5] 由 2 减到 1(不入队)—, —, —, —, 0, 10, 1, 2, 3
5[ 4 ] → [ ]4删出边 4→5,indeg[5] 由 1 减到 0 ⇒ 入队—, —, —, —, —, 00, 1, 2, 3, 4
6[ 5 ] → [ ]55 没有出边,直接输出—, —, —, —, —, —0, 1, 2, 3, 4, 5
结束输出长度 6 = n ⇒ 是 DAG,无环拓扑序:0 → 1 → 2 → 3 → 4 → 5
易错:队列里同时有多个点时的顺序 第 1 步之后队列里同时有 1 和 2。先出谁都能得到一个合法拓扑序
  • 先出 1:得到 0, 1, 2, 3, 4, 5(本例按「编号小的优先」实现)。
  • 先出 2:得到 0, 2, 1, 3, 4, 50, 2, 1, 4, 3, 5 等,同样合法。
所以问「拓扑序是什么」时,标准答案往往要写「不唯一,例如 ……」。 若题目要求「字典序最小的拓扑序」,把普通队列换成小根堆 priority_queue 即可(见下文)。

下面动画逐步骤演示 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 过程中遇到一个「正在访问中(灰色)」的顶点,说明存在环。

第 ① 步:从顶点 0 开始 DFS,一路深入到没有出边为止(橙色 = 正在递归栈上,color = 1) 0 1 3 5 2 4 递归栈(栈底 → 栈顶):0 → 1 → 3  访问顺序:dfs(0) → dfs(1) → dfs(3) → dfs(5) 虚线 = 尚未走的边 1→3;顶点 5 没有出边,马上开始回溯 第 ② 步:每次回溯(后序)时把顶点压栈,最后逆序输出就是拓扑序(绿色 = 已完成,color = 2) 0 1 3 5 2 4 后序(压栈顺序):5, 3, 1, 4, 2, 0 逆序输出(拓扑序):0 → 2 → 4 → 1 → 3 → 5 ✔ 每条弧都从前指向后
图 9-10 DFS 版拓扑排序:后序的逆序即拓扑序(绿色 = 已完成 color = 2)
DFS 版的遍历顺序与拓扑序的关系(看图 9-10 逐帧核对) 从 0 出发,按「编号小的邻接点优先」递归:
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),可见拓扑排序是它的地基。
把拓扑序理解成「DP 的阶段划分」 第 13 讲会讲到:动态规划的关键是「按什么顺序填表」。 在一张 DAG 上,拓扑序就是天然的填表顺序。 例如「DAG 上从源点到各点的最长路径」:ve[v] = max{ ve[u] + w(u,v) } (对所有入边取最大),按拓扑序从左到右算一遍即可。 这和第 13 讲的「树形 DP」「图上 DP」是同一套思想,而且它正是下面关键路径算法的第 ① 步。

9.7 关键路径:AOE 网与工程工期

9.7.1 AOE 网:弧表示活动

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) = 50, 7, 5, 0, 0, 0
处理 1(ve=7)ve[3] = max(0, 7+6) = 70, 7, 5, 7, 0, 0
处理 2(ve=5)ve[3] = max(7, 5+4=9) = 9取 max,被 2 顶上去了);ve[4] = max(0, 5+3) = 80, 7, 5, 9, 8, 0
处理 3(ve=9)ve[5] = max(0, 9+6) = 150, 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(汇点) = 1616, 16, 16, 16, 16, 16
处理 5(汇点)没有出边,vl[5] = ve[5] = 1616, 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) = 516, 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
发现问题了:vl[0] 算出 −3,这不合理! 源点的最迟发生时间当然是 0(工程从第 0 天开始),怎么会是 −3? 这说明上面这个网本身有问题:注意 ve[1] = 7vl[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 网:

修正后的弧集:0→1(7)、0→2(5)、1→3(6)、2→3(4)、2→4(3)、3→5(6)、4→5(8) ⇒ 改用下面这张网
7 5 6 4 3 6 8 0 ve=0 vl=0 1 ve=7 vl=7 2 ve=5 vl=8 3 ve=13 vl=13 4 ve=8 vl=11 5 ve=19 vl=19 绿色粗圈 = 关键路径上的事件(ve = vl);源点 0,汇点 5,关键路径 0 → 1 → 3 → 5,全长 7+6+6 = 19
图 9-11 自洽的 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→10→21→32→32→43→54→5
持续时间 w7564368

就是图 9-6 里的数据。下面直接给出它的完整四步手推结果(请你先自己算一遍,再对照):

① 正推求 ve(拓扑序 0 → 1 → 2 → 3 → 4 → 5):

步骤计算过程ve[0..5]
初始化ve[0] = 0,其余全部为 00, 0, 0, 0, 0, 0
v = 0ve[1] = 0+7 = 7;ve[2] = 0+5 = 50, 7, 5, 0, 0, 0
v = 1ve[3] = max(0, 7+6) = 130, 7, 5, 13, 0, 0
v = 2ve[3] = max(13, 5+4 = 9) = 13(9 < 13,不改);ve[4] = max(0, 5+3) = 80, 7, 5, 13, 8, 0
v = 3ve[5] = max(0, 13+6) = 190, 7, 5, 13, 8, 19
v = 4ve[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] = 1919, 19, 19, 19, 19, 19
v = 44→5(8):vl[4] = min(19, 19−8) = 1119, 19, 19, 19, 11, 19
v = 33→5(6):vl[3] = min(19, 19−6) = 1319, 19, 19, 13, 11, 19
v = 22→3(4) ⇒ 13−4 = 9;2→4(3) ⇒ 11−3 = 8;vl[2] = min(19, 9, 8) = 819, 19, 8, 13, 11, 19
v = 11→3(6):vl[1] = min(19, 13−6) = 719, 7, 8, 13, 11, 19
v = 00→1(7) ⇒ 7−7 = 0;0→2(5) ⇒ 8−5 = 3;vl[0] = min(19, 0, 3) = 0vl = 0, 7, 8, 13, 11, 19

校验:所有顶点都满足 vl(v) ≥ ve(v),且源点 vl(0) = 0 = ve(0) —— 网是自洽的

③ 求每条活动的 e 与 le(a) = ve(起点)l(a) = vl(终点) − w(a)):

活动时长 we = ve(起点)l = vl(终点) − w时间余量 l − e是否关键活动
a₀0→17ve(0) = 0vl(1) − 7 = 7 − 7 = 00★ 关键活动
a₁0→25ve(0) = 0vl(2) − 5 = 8 − 5 = 33否(可延后 3)
a₂1→36ve(1) = 7vl(3) − 6 = 13 − 6 = 70★ 关键活动
a₃2→34ve(2) = 5vl(3) − 4 = 13 − 4 = 94
a₄2→43ve(2) = 5vl(4) − 3 = 11 − 3 = 83
a₅3→56ve(3) = 13vl(5) − 6 = 19 − 6 = 130★ 关键活动
a₆4→58ve(4) = 8vl(5) − 8 = 19 − 8 = 113

④ 找出关键活动与关键路径

下面动画把四步完整演一遍,可以看到 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 四个关键结论

结论 1:关键路径长度 = 整个工程的最短完成时间 这句话看起来矛盾(「关键路径最长」与「工期最短」),其实说的是两件事: 关键路径是源点到汇点所有路径中最长的那条; 而由于所有活动可以尽量并行,工程的总工期恰好由这条最长的路径决定,所以它同时是「最短完成时间」。
一句话记:关键路径 = 最长路径;总工期 = 最长路径的长度 = 最短完成时间。
结论 2:缩短关键活动可能缩短总工期,但有极限 把关键活动 a₅(3→5) 的时长从 6 压到 3,总工期会从 19 变成 16(因为 ve[5] = 13 + 3 = 16), 但此时次长路径 0 → 2 → 4 → 5 的长度是 16 —— 两条路径一样长了。 再继续压缩 a₅,总工期就不再变化,因为瓶颈转移到了另一条路径上。
这就是工程上的「关键路径转移」:只压缩一条关键路径上的活动,效果是有限的; 必须同时压缩所有并列最长路径上的活动才能继续缩短工期。
另外:缩短非关键活动对总工期毫无影响(只要没缩到让它变成关键活动)。
结论 3:关键路径可能不唯一 如果存在两条(或多条)从源点到汇点的路径长度相同且都是最长,它们都是关键路径。 例如把上例中 a₂(1→3) 的时长改成 2,则 ve[3] = max(7+2, 5+4) = 9, 路径 0→1→3→50→2→3→5 长度都是 15 —— 两条关键路径
此时「缩短 a₂ 能否缩短工期」的答案是不能,因为另一条路还是那么长。
结论 4:关键路径就是源点到汇点的最长路径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 里那条最大边,已经是所有生成树里可能的最小「最大边」了。 但反过来不成立:瓶颈生成树不一定是 MST (它只关心最大的那条边,可能为了压住这个最大值而选了更长的总长度)。
证明思路(用割性质):设 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 次小生成树(简要)

次小生成树:权值和严格大于最小生成树的那些生成树中,权值最小的那棵。 求法有两大类:

对示例图 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。可是工程上第一个撞上的墙是规模

问题的根子在于:Dijkstra 完全不知道终点在哪一个方向。 它对所有顶点一视同仁,以起点为中心「一圈一圈」均匀向外扩散,直到把终点也圈进来才停手。 也就是说,为了找一条朝东走的路,它把西边、南边、北边的路也全算了一遍 —— 这些计算纯属浪费。 而路网恰恰有一个 Dijkstra 没有利用的宝贵性质:它是画在平面上的,结点有坐标, 「往哪个方向走大概能靠近终点」是能估出来的。

A*:给搜索装一个「指南针」

A*(读作 A-star)的改法非常简洁:给每个结点再多估一个数 h(v) = 「从 v 到终点大概还要走多远」,然后优先队列不再按 g 排序,改按 f = g + h 排序。 含义一目了然:g 是已经花掉的钱,h 是估计还要花的钱,两者相加就是「走这条路总共大概要花多少」。 于是搜索会优先去试探那些看起来最有希望的方向,而不是平均用力。

算法优先队列的排序依据行为
Dijkstrag(只看到已经走过的路)以起点为中心均匀向外扩张,像水波纹
A*f = g + h(还看剩余的路大概多长)朝终点方向被「拽」过去,像有磁铁吸引
A* 唯一的硬性前提:启发函数绝对不能「高估」 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 和编号两个比较键之后,队列顺序被唯一确定,扩展结点数才是一个可以写进文档、能被你复现的数字。 这类「把结果钉死」的做法在工程上非常常见:任何要对外报告的性能数字,都必须先消除这种不确定性。
同一张迷宫、同一条最短路(红点),两种搜索真正「看过」的格子数差了 4 倍多 放大显示左上角 26 x 26 区域;灰色墙、浅色格是没被扩展到的通路 Dijkstra:启发恒为 0,向四周均匀铺开 扩展 530 个结点,最短路 96 步 A*:曼哈顿距离牵引,朝终点方向收拢 扩展 125 个结点,最短路 96 步 Dijkstra 扩展 A* 扩展 没被扩展到的通路 最终最短路
图 9-13 A* 用启发函数把搜索「拽」向终点:Dijkstra(左)与 A*(右)扩展范围的对比(数据来自本节 C++ 程序实跑)

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的根本原因。

RIP 的「计数到无穷」:第 9.5 节的坏消息传播问题 设想 A—B—C 三个路由器直连成一条链,C 后面挂着一个网络 X。 正常时 B 认为「到 X 要 2 跳」,A 认为「要 3 跳」。现在 B 到 C 的链路断了
  • B 发现直连断了,于是去看别的邻居 —— A 说「我到 X 只要 3 跳」;
  • B 于是认为「我到 X 只要 4 跳」(它不知道 A 的 3 跳其实是经过 B 自己算出来的!);
  • A 下一轮听 B 说 4 跳,就更新成 5 跳;B 听 A 说 5 跳,又更新成 6 跳……
  • 两边互相喂错误信息,距离一跳一跳地涨上去,直到涨过协议规定的「无穷大」(RIP 里是 16 跳)才终于罢休。
这就是计数到无穷。它的根因是第 9.5 节讲过的: Bellman-Ford 是逐轮松弛的,坏消息每轮只能传播一跳,而且在环路里会被反复利用。
工程上的补救办法:水平分割(从某接口学到的路由,不再从该接口发回去)、 毒性逆转(干脆把它标成 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 + B1D1 = C1 * 2……单元格之间的引用关系天然是个 DAG。 Excel 每次重算都要重新做一次拓扑排序,保证「用到某个格子时它已经算好了」。 如果公式出现循环引用(A1 = B1 + 1B1 = A1 + 1), 它会直接弹窗报错 —— 那就是拓扑排序发现「队列空了但还有结点没输出」。
课程表排课与任务调度
「数据结构」要求先修「C 语言」,于是后者指向前者。 排课系统要给出一个满足全部先修约束的学期安排;约束自相矛盾(有环)时, 系统必须明确报错而不是硬排出一份不可能的课表。
CI 流水线的 stage 依赖
GitHub Actions / GitLab CI 里的 needs: 声明了 job 之间的依赖。 Runner 调度器按拓扑序启动 job,没有依赖关系的 job 并行跑, 这正是拓扑排序 + 并行的典型用法。
环检测:报错本身才是正确行为 拓扑排序最容易被初学者忽略的价值,是它能检测环。 判据一句话:Kahn 算法跑完之后,如果输出的顶点数小于 n,剩下的那些点就构成了环 (准确说,是环上的点以及依赖它们的点)。
为什么这个判据成立?因为入度 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 天开工完全没问题。 项目经理每天真正要盯的,就是那条关键路径上的十几个活动 —— 而不是全部三百个任务。

缩短工期该往哪使劲:只有压缩关键活动才有用 这是 CPM 最重要、也最常被考到的结论:
  • 压缩关键活动:可能缩短总工期 —— 但要注意,压缩到一定程度后, 另一条路径可能变成新的关键路径,此时继续压原来那条就完全无效了, 必须重新算一遍 ve / vl 找出新的关键路径。
  • 压缩非关键活动对总工期毫无影响,纯属浪费钱。 因为非关键活动本来就有机动时间,你把它做得再快,也要停下来等关键路径上的活动完工。
举个具体数字:某活动松弛时间是 5 天。你花加班费把它从 10 天压到 8 天,省下 2 天 —— 结果总工期一天都没少,因为这 2 天本来就在它 5 天的机动时间里面。 这 5 天的机动时间只要没被用光,压缩它就是往水里扔钱。
由此得到的工程准则:加人要加在关键活动上; 如果关键路径上压不动了,就想办法把任务从关键路径上挪走 (改成并行、或者改变任务之间的依赖关系),这才是真正的「管理」而不是「蛮干」。
这也解释了软件开发里那句名言「给已经延期的项目加人只会让它更延期」: 新人的沟通成本会引入新的依赖边,可能把原本不在关键路径上的活动拖成关键活动。

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) 主要花在给边排序上。翻译成工程语言就是:

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 依赖

表怎么看?重点不在背,而在体会每一列的现实约束

本节小结:三条能带走的工程心法
  1. 课本算法是零件,不是产品。 A* 只是 Dijkstra 加了一个启发函数(h ≡ 0 就退回去); 工程系统里真正上线的,是「A* + 双向搜索 + 层次化预处理」这样组合出来的东西。 学会一个算法的性质(可采纳性、收敛性、能否增量),比记住它的代码更重要。
  2. 约束决定了算法能不能用,而不是快不快。 负权把 Dijkstra 挡在门外、动态图让 Floyd 失效、依赖成环要报错而不是硬算 —— 这些「用不了」的判断,才是选型的第一步。先看约束,再看性能。
  3. 可量化的指标才叫工程。 本节 A* 与 Dijkstra 的 81 : 1578、125 : 530,都是同一份代码在同一张图上跑出来的真实数字。 工程上评价一个算法改动,从来不是「感觉快了」,而是「扩展结点数从多少降到多少、P99 延迟降了多少」。 这里用的正是第 01 讲的大 O 分析:它预测的只是「扩展量级会小一个档次」, 真正能写进技术评审文档的,仍然是实跑出来的绝对数字。

9.10 本章小结、易错点与自测题

9.10.1 一页速查表

第 09 讲知识地图:一张图看懂四组算法的关系 最小生成树 MST 全部顶点连通、总权最小 Prim O(n²) / O(m log n) Kruskal O(m log m) 割性质 · 环性质 · 并查集 最短路径 Dijkstra 单源 O(m log n) 无负权 Floyd 全源 O(n³) 可负权 BF / SPFA 可负权 · 可判负环 松弛 relaxation · 负环检测 有向无环图 DAG 拓扑排序 O(n + m) Kahn(入度队列)/ DFS 后序逆序 关键路径 ve / vl / e / l AOV 网 · AOE 网 · DAG 上 DP 都是 拓扑序 共同基础:图的存储结构(邻接矩阵 / 链式前向星)+ 第 01 讲的大 O 分析 + 第 03 讲的队列 / 栈 所有算法都离不开「遍历一个顶点的所有邻接点」这一动作,也都用「松弛」或「入度 / 出度」来推进 按「算法设计范式」归类(与第 13 讲呼应) 贪心:Prim(割性质)、Kruskal(环性质)、Dijkstra(每次锁定最小 dist) 动态规划:Floyd(放开中转点的阶段 DP)、关键路径 ve / vl(DAG 上的最长路 DP) 暴力 + 优化:Bellman-Ford(全量松弛)→ SPFA(队列优化);Kruskal 的并查集(路径压缩 + 按秩合并)
图 9-12 本章知识地图:四组算法的关系、共同基础与所属的算法设计范式
算法解决的问题核心思想时间复杂度限制
Prim最小生成树贪心长点:每次吞并离树最近的顶点朴素 O(n²) / 堆优化 O(m log n)只适用于无向连通图
Kruskal最小生成树贪心长边:排序 + 并查集判环O(m log m)同上;需要对边排序
Dijkstra单源最短路贪心:每轮锁定 dist 最小的点再松弛朴素 O(n²) / 堆优化 O(m log n)不能有负权边
Floyd全源最短路DP:逐步放开中转点 kO(n³)不能有负环;n 不宜大
Bellman-Ford单源最短路 + 判负环n−1 轮暴力松弛所有边O(nm)
SPFA单源最短路 + 判负环队列优化 BF,只扩展被更新过的点平均快,最坏 O(nm)容易被特殊数据卡
Kahn拓扑排序反复摘掉入度为 0 的顶点O(n + m)只适用于有向图
DFS 拓扑拓扑排序后序逆序O(n + m)深图可能爆栈
关键路径AOE 网工期分析拓扑序正推 ve + 逆序倒推 vlO(n + m)必须是 DAG

9.10.2 易错点清单

错误 1:把 Prim 的 lowcost 写成 Dijkstra 的 dist Prim: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。
错误 2:Floyd 的三重循环把 k 写到了里面 for i → for j → for k 是经典错误写法,会漏掉「需要连续经过多个中转点」的路径。 记住:k(中转点)永远在最外层。 自检方法:把三重循环连跑两遍,如果第二遍矩阵还会变小,说明写法错了。
错误 3:给 Dijkstra 用负权边 Dijkstra 一旦锁定了某个顶点的 dist 就不再修改,负权边可能让它「后悔莫及」。 题目里出现负权(例如「收益」「差值」这类可以递减的量)时,必须换 Bellman-Ford / SPFA。
错误 4:忘记处理重边与自环 邻接矩阵遇到重边应取 ming[u][v] = min(g[u][v], w), 直接赋值会把较小的边覆盖掉。自环(u = u)对 MST 和最短路都无意义,读入时直接丢弃。 Floyd 里若不初始化 dist[i][i] = 0,负环检测也会失效。
错误 5:把 INF 拿去参与加法导致溢出 INF = 0x3f3f3f3f(约 1.06×10⁹)时,INF + INF 仍在 int 范围内,是安全的; 但如果用 INT_MAX = 2.1×10⁹INF + w 就会溢出成负数, 导致「不可达」被误判成「可达且很短」。用 0x3f3f3f3f 并配合 memset 是最省心的做法。
错误 6:拓扑排序只看「有没有环」却忘了它也是 DP 的顺序 拓扑序不只是一个输出序列,它是DAG 上做动态规划的阶段顺序。 关键路径的 ve 就是「按拓扑序做最长路 DP」,第 13 讲的 DAG 上 DP 也完全一样。
错误 7:把 vl 算成负数还以为是算错了 如果出现 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
13, 5, ∞, 2, 5, ∞4(3,4) 权 22
松弛3, 5, ∞, —, 5, 5经 4 把 lowcost[6] 从 ∞ 降到 52
23, 5, ∞, —, 5, 50(3,0) 权 35
松弛—, 2, 3, —, 5, 5经 0 把 lowcost[1] 5→2、lowcost[2] ∞→35
3—, 2, 3, —, 5, 51(0,1) 权 27
松弛—, —, 2, —, 5, 5经 1 把 lowcost[2] 3→27
4—, —, 2, —, 5, 52(1,2) 权 29
松弛—, —, —, —, 5, 4经 2 把 lowcost[6] 5→49
5—, —, —, —, 5, 46(2,6) 权 413
6—, —, —, —, 5, —5(3,5) 权 518

边集 = {(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, ∞, ∞, ∞, ∞
123, 2, 0, ∞, ∞, ∞, 4(2,0)=3 (2,1)=2 (2,6)=4
21(dist=2)3, 2, 0, 7, ∞, 8, 4(1,3)=2+5=7 (1,5)=2+6=8 (1,0)=4>3 失败
30(dist=3)3, 2, 0, 6, ∞, 8, 4(0,3)=3+3=6(比 7 更小,更新) (0,6)=14>4 失败
46(dist=4)3, 2, 0, 6, 9, 8, 4(6,4)=4+5=9 其余已确定或更差
53(dist=6)3, 2, 0, 6, 8, 8, 4(3,4)=6+2=8(比 9 小,更新) (3,5)=11>8 失败
65(dist=8,与 4 并列,取编号小的 4 先?)3, 2, 0, 6, 8, 8, 4见下方说明
74(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):

0123
0014
102
203
30

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 = 3dist[0][3] = min(4, 1+dist[1][3]=∞) = 4 不变。

k = 2(用 2 中转):dist[1][3] = min(∞, dist[1][2]+dist[2][3]) = 2+3 = 5dist[0][3] = min(4, dist[0][2]+dist[2][3] = 3+3 = 6) = 4 不变。

k = 3(用 3 中转):dist[0][3] 已是 4,从 3 出发没有出边,全部不变。

最终矩阵:

0123
00134
1025
203
30

(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):

活动well − e关键?
0→1307−3 = 44
0→2404−4 = 00★ 是
1→3239−2 = 74
2→3549−5 = 40★ 是
2→46411−6 = 51
3→54913−4 = 90★ 是
4→521013−2 = 111

(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 上。

本章考点回顾
  1. Prim 手推表:lowcost / closest / 选中顶点 / 总权值,注意「不累加」。
  2. Kruskal 手推表:排序后的边序列 + 并查集集合归属 + 采纳/丢弃原因。
  3. Prim vs Kruskal:复杂度、适用图类型(稠密 / 稀疏)、是否需要并查集。
  4. Dijkstra 手推表:dist / visited / 选中顶点 / 被松弛的边;为什么不能有负权
  5. Floyddp[k][i][j] 的含义与转移方程,k 为什么必须在最外层,矩阵逐轮变化。
  6. 拓扑排序:AOV 网定义、Kahn 步骤、判环、「有拓扑序 ⟺ DAG」。
  7. 关键路径:ve / vl / e / l 四张表、关键活动判据、关键路径长度 = 最短工期。