编程语言研究书库

第 17 章 图 — 表示、最短路、拓扑、连通与生成树

第 17 章 图 — 表示、最短路、拓扑、连通与生成树

这一章讲什么: 图(顶点 + 边)怎么存、怎么算:三横三竖的表示选择, 最短路径三算法,Floyd 到 Dijkstra 逐级提速,拓扑排序两版本, 连通性两招,生成树两算法。它在全书链条里的位置: 全书的「综合应用章」——迷宫回溯(第 5 章)、队列/栈遍历(第 10、13 章)、 堆(第 14 章)、并查集思想(第 11 章渐进扩容的亲戚)在此全部会师。

1. 顶层全景

  图 = (顶点, 边);有向/无向;带权/无权
      │
      ├─ 表示:邻接矩阵(查边 O(1)、空间 O(v²)、遍历邻居 O(v))
      │        邻接表(省空间、遍历邻居 O(度)、查边 O(e))
      │        邻接集合(平衡树装邻居:查边 O(log e))
      │
      ├─ 最短路径:
      │    Floyd-Warshall  全源   O(v³)   动态规划(中间点 k 递推)
      │    Bellman-Ford    单源   O(ve)   逐轮松弛,可处理负权
      │    Dijkstra        单源   贪心+堆 不可负权;每次锁最近未访问点
      │
      ├─ 拓扑排序(只对有向无环图 DAG):
      │    Kahn:入度 0 进队列,出队减后继入度;队列空但有剩 = 有环
      │    Tarjan:深搜三色(白/灰/黑),回溯时逆序输出;撞灰 = 有环
      │
      ├─ 连通性:并查集(挂指针成森林,集合数==1 则连通)/ 深搜标visited
      │
      └─ 最小生成树:
           Prim:从一个点长出树,每次拿「离树最近」的点(堆维护距离)
           Kruskal:边从小到大试加,同集合则跳过(并查集判环)

  图说:五个问题 × 每题两三种解法,选型看「有向?带权?单源还是全源?」

2. 主线例子:同一图上的两次算法之旅

用原书的演示图(顶点 A–G,边带权,如 A–B 4、B–A 3、A–E 11、D–A 5…)。

旅程一:Dijkstra 从 A 出发。 初始:A=0、其余 +∞。锁 A: 邻居 B(4)、E(11) 更新;最近未访问是 B(4),锁 B:B 到 A 是 3 但 A 已锁、 B 到 E 是 8 < 11,E 更新为 12(经 B);锁 E…… 每步「锁死最近点、 松弛其出边」,直到目标入锁。要点:一旦锁死不回头,所以负权会骗它 (负权得用 Bellman-Ford)1。

旅程二:Kruskal 加边。 边按权升序:(C,E) 加、(D,F) 加、(A,B) 加、 (A,D) 加、(B,E) 加——接下来 (D,E) 两端已同集合,会成环,跳过; (E,G) 加,六个顶点五條边,树成2。 对照 Prim:它不从边看,而是从点长——堆里永远装着「每个未选点到已选树 的最短距离」,每次取堆顶。两者同解不同路:Prim 适合稠密图,Kruskal 适合稀疏图。

3. 核心原理

3.1 表示法:时空的交换三件套

邻接矩阵 graph[i][j]:有边存权值(或 true)、无边存 0/+∞;无向图对称。 查边、加删边 O(1);但 v² 空间,邻居少的稀疏图大片浪费, 且「遍历某点所有邻居」要扫整行 O(v)3。 邻接表:每个顶点挂出边列表(书里给的是十字链表式:出边行 + 入边列)。 省空间、遍历邻居快;但「i 和 j 直接相连吗」要 O(e) 扫列表4。 邻接集合:邻居用第 11/12 章的平衡树或集合装,查边 O(log e)、 增删 O(log e)、遍历不变——「更复杂的解法」,稠密图或高频查边时用5。

3.2 最短路径:从全源到单源逐级提速

Floyd-Warshall:动态规划全场最直接的演示。 定义 d(i,j,k) = 从 i 到 j 只许经过前 k 个顶点的最短路; d(i,j,k) = min(d(i,j,k−1), d(i,k,k−1) + d(k,j,k−1))—— 要么不经过 k,要么把路在 k 处断开。逐层填 v×v 表,O(v³), 一次算出所有点对6。

Bellman-Ford:单源。把边列出来,松弛 n−1 轮: 每轮对所有边试「绕行是否更短」。O(ve),比 Floyd 快一档, 且能处理负权(书中用它正是因为 Dijkstra 不能); v−1 轮是「最长简单路径的边数上界」7。

Dijkstra:贪心版 Bellman-Ford。每轮锁死当前最近的未访问点 (它的最短距离从此确定——贪心正确性的核心),松弛其出边, 从剩余点里再取最近者。朴素版 O(v²);配第 14 章的堆找最近点, 可到 O(v log v)。负权破坏「锁死不回头」的前提,一票否决1。

3.3 拓扑排序:排队法与探路法

对有向无环图(DAG)排出一个「边都从前往后」的线性序 (任务调度、依赖构建、课程先修)。两版互为镜像:

  • Kahn(队列版):先数每个点的入度;入度 0 的全部入队; 出队输出、其每条出边让目标入度减 1,减到 0 再入队。 结束时若输出不足 n 个,剩下的点互相指着(有环),返回 null8。
  • Tarjan(深搜三色版):白=未访问、灰=在栈上(递归中)、黑=已完成。 深搜;撞见灰点 = 发现回边 = 有环;一个点递归完染黑、 逆序插入输出(后完成的先输出,unshift 实现)。 「汉塞尔与格雷特式」撒面包屑的比喻出自原书9。

3.4 连通与生成树

连通性:并查集版——每个点自成集合,边把两集合并成一个 (新书根挂两旧根),处理完所有边集合数 == 1 即连通; 深搜版——从任意点递归标记,全标到就连通,简单到十几行10。

最小生成树(MST,第 5 章贪心一节预告过,此处兑现):

  • Prim:点生长。堆存 {点, 距已选树的最短距离};反复取堆顶入树、 用它松弛堆里各点的距离。正确性:每次拿的必属某棵 MST(割性质,证明略)11。
  • Kruskal:边生长。边升序,并查集判「两端是否已通」,不通则加边并集合。 O(e log e)(排序主导)。D,E 那种「会成环所以跳过」的边, 正是并查集「同集合」的直接判读12。

4. 作者的判断与证据

  • 表示法选择绑定后续算法:矩阵实现让 Dijkstra 朴素版 O(v²)、 Tarjan 实现 O(v²)(要扫整行找边);「换邻接表可降到 O(ve)」的注 反复出现——表示不是中立的,它给算法定了复杂度底盘13。
  • 三最短路径算法的适用面有明确口径:全源 Floyd、负权 Bellman-Ford、 非负单源 Dijkstra;「证明略,但可证」在两个贪心算法(Prim/Kruskal)处明说12。
  • 正确性证明整体缺席(Floyd 递推除外):本章是「会用」而非「会证」的层级。
  • 习题含 Floyd 路径回溯、Bellman-Ford 提前终止、Kruskal 自定义排序等增量。

判断(我们的,不是书里的): 本章五个问题共享同一条隐线—— 「松弛」:Floyd 在点集上松弛、Bellman-Ford 在边上松弛、 Dijkstra 在锁点之间松弛、Prim/Kruskal 在生成约束下松弛。 学会认「松弛这一步」,五个算法就从一个背诵清单坍缩成一个动作。 如果错,会错在: 「松弛」统一视角是我们的重构,书中只把它 作为 Bellman-Ford 的术语使用;且 Kruskal 的「跳过成环边」 与其说是松弛不如说是过滤——统一是有代价的简化。

4.1 习题答案的三处增量(「Chapter 17」)

答案文件把正文略过的三块拼上了:

  • Floyd 的路径回溯(17.1):再维护一张 next[i][j] 表—— 「站在 i 要去 j,下一步先走到哪」。松弛成功时同步更新 next[i][j] = next[i][k];查路径时从 u 沿 next 一路跳到 v。 O(v³) 不变,多拿全部具体路径14。
  • Bellman-Ford 提前终止(17.2):每轮记 changes,一轮无更新 就 break——实际图上一轮常就收敛,省掉剩余的轮次15。
  • 并查集加速(17.5 + 17.6):正文裸 findParent 的两剂解药都在答案里—— 按大小合并(小树挂大树,合并时维护 size)与路径压缩 (findParent 里顺手 x.ptr = findParent(x.ptr),把沿途节点 直接指向根)。二者合称并查集的标准优化,把近乎 O(n) 的最坏 find 压到近似常数16。

5. 边界与局限

  • A*、 Johnson、 网络流等进阶缺席——「图算法」在此收在经典五问;
  • 负环检测只在 Bellman-Ford 语境带过(第 n 轮还能松弛即负环),未展开;
  • 邻接表的实现给的是十字链表(出入双链),工程上更常用的是单向出边表 (书中以注释形式承认可用双向链表简化更新);
  • 并查集没有讲路径压缩与按秩合并,findParent 是裸递归—— 大图上会退化(补充,不在书里)。

6. 可带走的

  1. 三种表示:矩阵(查边 O(1)/空间 v²)、邻接表(省/遍历快/查边 O(e))、 邻接集合(查边 O(log e));表示决定算法的复杂度底盘。
  2. Floyd-Warshall:逐中间点松弛,O(v³) 全源,负权 OK。
  3. Bellman-Ford:边松弛 n−1 轮,O(ve),负权 OK;可提前终止(某轮无更新即收敛)。
  4. Dijkstra:锁最近点 + 堆,O(v log v);负权一票否决。
  5. 拓扑排序:Kahn(入度队列)与 Tarjan(三色深搜)互为镜像; 两种都能判环(队列剩点 / 撞灰点)。
  6. 连通:并查集(集合数)或深搜标记。
  7. MST:Prim(点生长 + 距离堆)与 Kruskal(边排序 + 并查集判环); 稠密用 Prim,稀疏用 Kruskal。
  8. 「松弛」是五个算法的共同动作——认出它,图算法就通了。

7. 原文地图

主题原书章原文位置
图的定义与用例What Are Graphs?text/55-fm-what-are-graphs.txt:4(搜「vertices」)
三种表示与各自复杂度Representing Graphstext/56-fm-representing-graphs.txt:12(搜「square matrix」) · text/56-fm-representing-graphs.txt:53(搜「O(e)」) · text/56-fm-representing-graphs.txt:63(搜「O(log e)」)
Floyd-Warshall 递推Finding the Shortest Pathstext/57-fm-finding-the-shortest-paths.txt:21(搜「minimum of」)
Bellman-Ford 松弛Finding the Shortest Pathstext/57-fm-finding-the-shortest-paths.txt:110(搜「one edge long」) · text/57-fm-finding-the-shortest-paths.txt:199(搜「distance[v.from] + w」)
Dijkstra 四步Finding the Shortest Pathstext/57-fm-finding-the-shortest-paths.txt:221(搜「unvisited」)
Kahn 拓扑排序Sorting a Graphtext/58-fm-sorting-a-graph.txt:63(搜「topologicalSort」)
Tarjan 三色深搜Sorting a Graphtext/58-fm-sorting-a-graph.txt:109(搜「Hansel」) · text/58-fm-sorting-a-graph.txt:175(搜「1:temp」)
连通性两法Detecting Connectivitytext/60-fm-detecting-connectivity.txt:71(搜「findParent」) · text/60-fm-detecting-connectivity.txt:142(搜「visited」)
Prim 与 KruskalFinding a Minimum Spanning Treetext/61-fm-finding-a-minimum-spanning-tree.txt:86(搜「spanning」) · text/61-fm-finding-a-minimum-spanning-tree.txt:133(搜「ascending order」)

Footnotes

  1. 出处:「Finding the Shortest Paths」第 221–233 段(text/57-fm-finding-the-shortest-paths.txt:221,搜「unvisited」)。原文四步:松弛未访问邻居、锁当前点、取最近未访问点续行、终点入锁即停;「算法对带负权的图无效」见第 236 段附近(书中以「能处理负权」对照 Bellman-Ford)。 ↩ ↩2

  2. 出处:「Finding a Minimum Spanning Tree」第 145–177 段(text/61-fm-finding-a-minimum-spanning-tree.txt:173,搜「create a cycle」)。原文:(D,E) 两端已同集合,「会成环,跳过,加 (E,G) 收尾」。 ↩

  3. 出处:「Representing Graphs」第 12–29 段(text/56-fm-representing-graphs.txt:12,搜「square matrix」)。原文:v×v 方阵,[i][j] 存权值/true、缺失存 0/+∞;无向图关于主对角线对称;「查邻接、增删边 O(1),但遍历某点边 O(v)」见第 26 段。 ↩

  4. 出处:「Representing Graphs」第 39–53 段(text/56-fm-representing-graphs.txt:50,搜「incoming」)。原文:每个顶点有出边行与入边列;「处理起止边快,但查两点直连是 O(e)」。 ↩

  5. 出处:「Representing Graphs」第 63 段(text/56-fm-representing-graphs.txt:63,搜「O(log e)」)。原文:邻居用平衡树/集合装,查边 O(log e)、增删 O(log e)、遍历同列表。 ↩

  6. 出处:「Finding the Shortest Paths」第 18–21 段(text/57-fm-finding-the-shortest-paths.txt:21,搜「minimum of」)。原文:distance(i,j,k) = min(distance(i,j,k−1), distance(i,k,k−1) + distance(k,j,k−1));基准 distance(i,i,0)=0、i≠j 为边或 +∞。 ↩

  7. 出处:「Finding the Shortest Paths」第 110、196–208 段(text/57-fm-finding-the-shortest-paths.txt:110,搜「one edge long」;:199,搜「distance[v.from] + w」)。原文:从 1 条边路径起逐轮放宽到 n−1 条;n−1 是「最长路径边数上界」;每轮对所有边试更短路则更新 dist 与前驱。 ↩

  8. 出处:「Sorting a Graph」第 63–99 段(text/58-fm-sorting-a-graph.txt:63,搜「topologicalSort」)。原文:入度表、零入度入队、出队减后继;sorted.length === n ? sorted : null——剩点必有环。 ↩

  9. 出处:「Sorting a Graph」第 109–203 段(text/58-fm-sorting-a-graph.txt:109,搜「Hansel」;:175,搜「1:temp」)。原文:三色标记,撞 1(灰)即「Not a DAG」;完成后 unshift 逆序;「汉塞尔与格雷特式」撒面包屑见第 109 段。 ↩

  10. 出处:「Detecting Connectivity」第 63–158 段(text/60-fm-detecting-connectivity.txt:71,搜「findParent」;:142,搜「visited」)。原文:并查集版「集合数减到 1 即连通」;深搜版全标 visited.every 即真。 ↩

  11. 出处:「Finding a Minimum Spanning Tree」第 86–123 段(text/61-fm-finding-a-minimum-spanning-tree.txt:86,搜「spanning」)。原文:堆存 {from, to, dist};取堆顶连边、弹出、松弛堆内各点距离并上浮;「堆点始终持有到已选树的最短距离」。 ↩

  12. 出处:「Finding a Minimum Spanning Tree」第 133、183–222 段(text/61-fm-finding-a-minimum-spanning-tree.txt:133,搜「ascending order」)。原文:边升序、并查集判环、同集合跳过;「正确性可证(证明不给)」;性能 O(e log e) 见第 224 段。 ↩ ↩2

  13. 出处:「Sorting a Graph」第 205–206 段(text/58-fm-sorting-a-graph.txt:206,搜「O(v2)」)。原文:矩阵实现 O(v²),「换邻接表可到 O(ve)」。 ↩

  14. 出处:「Chapter 17」第 10–58 段(text/80-ch17-chapter-17.txt:14,搜「next」)。答案原文:定义 next 矩阵;松弛成功时「update not only dist but next as well」;path() 沿 next 从 u 跳到 v 输出整条路径。 ↩

  15. 出处:「Chapter 17」第 64–81 段(text/80-ch17-chapter-17.txt:68,搜「changes」)。答案原文:每轮设 changes = false,有更新置 true;一轮无变化即 break。 ↩

  16. 出处:「Chapter 17」第 100–144 段(text/80-ch17-chapter-17.txt:105,搜「size」;:139,搜「x.ptr = findParent」)。答案原文:按大小合并(小根挂大根并累计 size);路径压缩(findParent 递归时把沿途节点直接指向根)。 ↩

On this page