首页 / 05 数据结构 / 图论基础
CSP-J / CSP-S 图论专题

图论基础
超详细通关讲义

从「图是什么」一路讲到 Tarjan。9 章递进、39 道编程题题题给完整代码 + 逐段解析。
每个算法都配可运行模板与逐步推演,看懂一遍就能默写。

9 章正文
3 种存图方式
7 大算法模板
39 道编程题
全部 附完整代码与逐段解析
导读

先搞清楚:图论到底在学什么

这一节不写代码,只帮你建立全局地图。看完你就知道后面 9 章分别解决什么问题、CSP-J 会怎么考。

🗺️一句话理解「图」

图 = 一堆点 + 连接这些点的边。点表示「对象」,边表示「关系」。地铁线路图、微信好友、课程先修关系、地图上的城市公路,本质都是图。

前面你学的数组、链表、栈、队列、树,都是「有严格层次或顺序」的结构;而图最自由——任何两个点之间都可以相连,还可以有方向、有长度。正因为自由,它才能描述真实世界里绝大多数「关系型」问题,也正因此,图论是 CSP-J/S 的必考板块。

核心把实际问题抽象成图,是图论题的第一步也是最重要的一步。题目不会告诉你「这是图」,你要自己判断:什么是点?什么是边?边有没有方向?边权是什么?
1 2 3 4 无向图:边没有箭头,(u,v) 与 (v,u) 是同一条边 1 2 3 有向图:边有箭头,u→v 与 v→u 是两条不同的边
左:无向图(双向通行) 右:有向图(单行道)
🏙️ 地铁图

站点 = 点,相邻两站 = 边,两站距离 = 边权。求「最少坐几站」就是无权最短路,求「最短里程」就是带权最短路。

📚 先修课程

课程 = 点,「学 B 之前必须先学 A」= 有向边 A→B。判断能不能安排学习顺序,就是拓扑排序。

🛣️ 修路预算

村庄 = 点,可修公路 = 带权边。要用最少的总花费让所有村连通,就是最小生成树。

📋本讲义知识地图(9 章,由易到难)
章节核心内容难度CSP-J 考法
第 1 章图的基本概念:有向/无向、度、路径、环、连通、完全图、二分图入门初赛概念题、握手定理计算
第 2 章三种存储:邻接矩阵、vector 邻接表、链式前向星入门初赛复杂度/结构选择、复赛建图
第 3 章DFS / BFS 遍历、连通块、网格搜索、二分图染色基础复赛 T1/T2 搜索题、连通块计数
第 4 章拓扑排序(Kahn)、判环、字典序最小、DAG 上 DP基础依赖关系、判环、DAG 最长路
第 5 章Floyd 多源最短路、三重循环顺序陷阱基础n ≤ 500 的任意两点最短路
第 6 章Dijkstra 朴素 + 堆优化、为什么怕负边重点单源最短路主力算法
第 7 章Bellman-Ford、SPFA、负环判定、分层图重点负权边、判负环、状态分层
第 8 章最小生成树:Kruskal(并查集)、Prim重点修路/连通代价最小
第 9 章进阶选学:Tarjan 强连通分量、割点与桥、次小生成树、二分图匹配提高CSP-S / NOIP 衔接
建议第 1~3 章是地基,必须彻底搞懂(尤其「度」和「遍历」);第 5~8 章是四大算法模板,考试时基本靠默写 + 小变形。第 9 章是给学有余力的同学准备的上限拓展,CSP-J 不要求,但 CSP-S 必考。
🧭学图论最容易踩的 5 个坑(先打个预防针)
  1. 忘记初始化:邻接矩阵要全部置 INF、dis[i][i]=0;链式前向星要 memset(head,-1,sizeof head)。漏一步就是全 WA。
  2. 无向图只加了一条边:add(u,v) 之后必须 add(v,u)。这个错误极其常见。
  3. BFS 入队时才标记:应该在入队时标记 vis,而不是出队时,否则同一个点会重复入队、内存爆炸。
  4. Dijkstra 用在负权图上:贪心前提被破坏,答案会错,必须换 SPFA / Bellman-Ford。
  5. Floyd 的 k 写在内层:k 必须是最外层循环,否则 DP 的阶段顺序错了,结果错误(第 5 章会详细解释为什么)。
第 1 章

图的基本概念入门 · 必背

这一章全是「名词解释」,但初赛选择题几乎年年考。概念不清,后面所有算法都会学得含糊。

1.1图的形式化定义:G = (V, E)

一个图用二元组 G = (V, E) 表示:

  • V(Vertex,点集):非空集合,里面每个元素叫顶点(结点、节点),一般编号 1..n。
  • E(Edge,边集):V 中两点之间关系的集合,每个元素叫一条边,一般有 m 条。

例如右图:V = {1,2,3,4},E = {(1,2),(2,3),(1,3),(3,4)},记作 n = 4 个点、m = 4 条边。

必背后面所有算法的时间复杂度,都用 n(点数) 和 m(边数) 表示。看到 O(n+m) 就知道是「每个点每条边各访问一次」的遍历类算法。
1.2无向图 vs 有向图:边到底有没有方向?
无向图(Undirected)
  • 边写作 (u, v),是无序二元组
  • (u,v) 和 (v,u) 是同一条边
  • u、v 都叫这条边的端点
  • 现实例子:朋友关系、双向公路、无向管道
  • 存储时必须双向加边
有向图(Directed)
  • 边写作 u → v,是有序二元组
  • u 叫起点(弧尾),v 叫终点(弧头)
  • u 是 v 的直接前驱,v 是 u 的直接后继
  • 现实例子:单行道、微博关注、课程先修
  • u→v 与 v→u 是两条不同的边
易错题目说「无向边」但没说是无向图?混合图(既有有向边又有无向边)也可能出现(例如第 7 章的「最优贸易」)。每读一条边都要看清方向,无向边要拆成两条有向边存。
1.3度:一个点「连了几条边」

无向图中,顶点 v 的度 deg(v) = 与 v 相连的边的条数。自环会让度 +2(因为它的两个端点都是 v)。

有向图中,度要分两种:

  • 入度 indeg(v):以 v 为终点的边数(有几条边指向我)
  • 出度 outdeg(v):以 v 为起点的边数(我从几条边出去)
  • 有向图里 deg(v) = indeg(v) + outdeg(v)
1 2 3 4 5 以点 2 为例:入度 = 1(1→2),出度 = 1(2→4),度 = 2
握手定理无向图中所有顶点的度数之和 = 边数的 2 倍:
Σ deg(v) = 2m(因为每条边被它的两个端点各数了一次)。
推论 1:度数为奇数的顶点一定有偶数个。
推论 2:有向图中 Σ 入度 = Σ 出度 = m。
怎么考「一个图有 10 个点、每个点的度都是 3,问有几条边?」→ 3×10 = 2m → m = 15。
反过来问「n 个点每个点度都是 3,可能吗?」→ 由推论 1,奇数度的点必须有偶数个,所以 n 必须是偶数。
🔍 手算演示:数一数每个点的度
1看顶点 1:两条出边 1→2、1→3,没有入边。outdeg(1) = 2,indeg(1) = 0,deg(1) = 2
2看顶点 2:1→2 进来,2→4 出去。indeg(2) = 1,outdeg(2) = 1,deg(2) = 2
3看顶点 3:1→3 进来,3→4 出去。indeg(3) = 1,outdeg(3) = 1,deg(3) = 2
4看顶点 4:2→4、3→4 进来,4→5 出去。indeg(4) = 2,outdeg(4) = 1,deg(4) = 3
5看顶点 5:4→5 进来,没有出边。indeg(5) = 1,outdeg(5) = 0,deg(5) = 1
✓核对:Σ入度 = 0+1+1+2+1 = 5,Σ出度 = 2+1+1+1+0 = 5,都等于边数 5;Σ度 = 2+2+2+3+1 = 10 = 2×5 ✓(有向图的度总和仍等于 2m)
点击「播放」自动演示,或「单步」自己控制节奏
1.4路径、简单路径、回路、环
名词定义直白理解
路径边的序列 e₁,e₂,…,eₖ,且相邻两条边首尾相接,可简写为 v₀→v₁→…→vₖ从起点一步步走到终点的一条「走法」
简单路径路径上的点两两不同(不重复经过同一个点)不走回头路
回路起点 = 终点的路径(v₀ = vₖ)绕了一圈回到原点
环除起点=终点外,其余点都只出现一次的回路最简单的「圈」,中间不重复
辨析「回路」不一定是「环」:a→b→c→b→a 是回路,但中间 b 出现了两次,不是环。
在无向图里,一条边来回走两遍 a→b→a 算回路,但通常不把它当作有意义的环(长度 2 的环在有向图中要求两条方向相反的边)。
1.5连通性:图是不是「连在一起」

无向图:连通 & 连通分量

若 u 到 v 存在路径,称 u 与 v 连通。若图中任意两点都连通,称该图是连通图;否则叫非连通图。

非连通图可以拆成若干个「极大的连通子图」,每个叫一个连通分量(连通块)。「极大」的意思是:再加任何一个点进来就不再连通了。

a b c d e f g h i i 3 个连通分量:{a,b,c}、{d,e,f,g,h}、{i}
注意孤立的点 i 自己也算一个连通分量

有向图:可达 & 强连通

有向图中若存在 u→…→v 的路径,称 u 可达 v(记 u ⇝ v)。注意可达是单向的:u 能到 v,不代表 v 能到 u。

若图中任意两个点互相可达,称该图强连通。有向图的「极大强连通子图」叫强连通分量(SCC),第 9 章会讲 Tarjan 求它。

无向图连通

只要「能走过去」即可,方向无所谓。求连通块个数用 DFS/BFS 扫一遍(第 3 章)。

有向图强连通

要求「来回都能走」。例如 a↔b↔c 构成环,这 3 个点就在同一个 SCC 里。

1.6几张「有名字的特殊图」
完全图

任意两点之间都有边。n 个点的无向完全图边数:

m = n(n−1)/2

有向完全图(任意两点双向都有边):m = n(n−1)。

稀疏图 / 稠密图

边数远小于 n² 的叫稀疏图(如 m ≈ n);边数接近 n² 的叫稠密图。

没有严格界限,作用是帮你选算法:稀疏图用 O(m log m) 的堆优化 Dijkstra / Kruskal;稠密图可用 O(n²) 的朴素 Dijkstra / Prim。

简单图

无自环、无重边的图。自环是 u=u 的边;重边是两个端点都相同的多条边。

无向图中 (u,v) 与 (v,u) 算重边;有向图中 u→v 与 v→u 不算重边。

二分图

能把点集分成 X、Y 两部分,使每条边的两个端点分别落在不同部分。

等价判定:图中不存在奇环(长度为奇数的环)。可用 DFS/BFS 黑白染色判定(第 3 章)。

读题题目若没有特别说明,输入可能含自环和重边!求最短路时,重边要取最小值;自环一般无影响(除非题目另有含义)。做图论题第一件事:看数据范围 + 看有没有说「简单图」。
1.7例题精讲:概念也能算得很爽
例 1握手定理

一个无向图有 12 条边,其中 5 个点的度都是 4,其余点的度都是 2,问一共有多少个点?

思路:设总点数为 n。所有点的度数之和 = 2m = 24。

查看解析

握手定理:Σdeg = 2m = 2×12 = 24。

设总点数 n,则其余点有 (n−5) 个,度数和为:5×4 + (n−5)×2 = 2n + 10。

解 2n + 10 = 24,得 n = 7。

答案:7 个点。

检验:5×4 + 2×2 = 24 = 2×12 ✓

例 2完全图边数

n 个点的无向连通图,最多有多少条边?最少有多少条边?

「最多」对应完全图;「最少」还要保证连通。

查看解析

最多:任意两点都有边,共 C(n,2) = n(n−1)/2 条。

最少:连通只需 n−1 条边(构成一棵树)。少一条就不连通了。

答案:最多 n(n−1)/2,最少 n−1。

由此可得一个常用结论:n 个点的连通图,边数 m 满足 n−1 ≤ m ≤ n(n−1)/2。

例 3CSP-S 2021 初赛 · 第 7 题(改编)

G 是一个非连通简单无向图(无自环无重边),共有 36 条边,则该图至少有( )个点。

A. 8  B. 9  C. 10  D. 11

查看解析

思路:要让点数最少,就要把边「塞得最密」——先让一个连通分量尽量接近完全图,再加至少 1 个孤立点保证「非连通」。

设主连通分量有 k 个点,则最多 k(k−1)/2 条边,要求 k(k−1)/2 ≥ 36。

k = 9 时:9×8/2 = 36,刚好;k = 8 时只有 28 条,不够。

但只有 9 个点时,36 条边意味着它是完全图 K₉,此时图是连通的,不满足「非连通」。

所以必须再加 1 个孤立点:9 + 1 = 10。

答案:C(10 个点)。

例 4CSP-S 2022 初赛 · 第 8 题

强连通图的性质不包括( )

A. 每个顶点的度数至少为 1  B. 任意两个顶点之间都有边相连
C. 任意两个顶点之间都有路径相连  D. 每个顶点至少都连有一条边

查看解析

强连通 ⇒ 任意两点互相可达 ⇒ 不存在孤立点,因此 A、D 正确;C 正是强连通的定义。

但强连通不要求两点之间直接有边。反例:4 个点顺时针连成一个环 1→2→3→4→1,它是强连通的,却没有对角线(1 和 3 之间没有边)。

答案:B。

记住这个反例:环是强连通但不是完全图。

✍️第 1 章编程题(3 道,全部要写代码)

本章概念看起来「只能背」,但初赛的计算题、复赛的建图题都建立在这些定义上。下面 3 道题把「度」「简单图」「完全图」变成可以直接运行的代码——写一遍比背十遍牢。

P1-1

度数统计与握手定理验算

入门
输入 n m + m 条边输出 度数 / 总和 / YES|NOn,m ≤ 10⁵

读入 n 个点、m 条无向边(点编号 1..n,可能存在重边与自环)。要求输出:① 每个点的度数;② 所有点的度数之和;③ 若度数之和等于 2m 输出 YES,否则 NO。

输入

第一行 n m;接下来 m 行每行两个整数 u v,表示一条无向边。

输出

第一行 n 个整数;第二行一个整数;第三行 YES 或 NO。

样例
输入:
4 4
1 2
2 3
1 3
3 3

输出:
2 2 4 0
8
YES

为什么 3 号点是 4 度:它连着 1、2 各一次(2 度),还有一个自环 3-3。自环给该点贡献 2 度,因为从 3 出发沿这条边走到 3,出和入各算一次。

思路:一遍扫描累加度数,自环特殊处理加 2。因为每条边贡献 2 度,所以总和必然是偶数,直接和 2m 比较即可验证握手定理。

C++P1-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int deg[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        if (u == v) deg[u] += 2;      // 自环:对同一个点贡献 2 度
        else { deg[u]++; deg[v]++; }  // 普通边:两端各 +1
    }
    long long sum = 0;
    for (int i = 1; i <= n; i++) { printf("%d ", deg[i]); sum += deg[i]; }
    printf("\n%lld\n", sum);
    puts(sum == 2LL * m ? "YES" : "NO");
    return 0;
}
代码逐段解析
L3-4
为什么数组开在全局:int deg[100005] 约 400KB,放 main 里会占栈空间(Windows 默认栈只有 1~2MB),n 再大一点就 爆栈。全局变量在静态区,空间大得多,且默认全 0,不用手动清零。
L5-6
读入顺序别搞反:scanf("%d%d", &n, &m) 对应题面的 n m,其中 m 是边数,控制后面的循环次数。这是最容易被自己写反的一处。
L9
自环为什么要 +2:握手定理里「度」= 与该点关联的边数,自环算一条边但有两个端点都在 u 上,所以给 u 加 2。如果不特判,自环会让总和变成奇数,握手定理反而「检验失败」——那是代码错,不是图错。
L10
无向边加两次:deg[u]++ 和 deg[v]++ 都要写。只写一行是最常见的 bug,表现为「所有度数都偏小、总和 = m 而不是 2m」。
L12
sum 用 long long:n、m 到 10⁵ 时度数和最大约 2×10⁵,int 够用;但养成习惯——凡是「求和」一律 long long,比赛的坑往往就在这种地方。
L15
puts(条件 ? "YES" : "NO") 是三目运算符的常见写法,比 if-else 短一半。注意 2LL * m 里的 LL——它把 m 提升为 long long 再相乘,防止大数溢出。
举一反三把「无向边」改成「有向边 u→v」,就变成出度 +1、入度 +1 的统计:outdeg[u]++; indeg[v]++;,且不再有自环加 2 的问题(自环给出度、入度各 +1)。第 4 章拓扑排序的入度就是这么算的。
P1-2

判断是不是简单图

基础
输入 n m + m 条边输出 YES / NO + 原因n ≤ 1000,m ≤ 10⁵

简单图指:没有自环(两端相同的边),也没有重边(同一对点之间出现多次)。给定一个无向图,判断它是否是简单图。若不是,输出第一个发现的违规原因(自环 或 重边)。

样例
输入:
3 4
1 2
2 3
1 2
3 1

输出:
NO 重边

说明:1-2 出现了两次(注意 1 2 与 2 1 是同一条边)

思路:n ≤ 1000,可以用 bool g[1005][1005] 记住每条边是否出现过。读一条边就检查:两端相同 → 自环;已经标记过 → 重边。边读边判,发现即退出。

C++P1-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
bool g[N][N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        if (u == v) { puts("NO 自环"); return 0; }
        if (g[u][v]) { puts("NO 重边"); return 0; }
        g[u][v] = g[v][u] = true;   // 无向图:两个方向都标记
    }
    puts("YES");
    return 0;
}
代码逐段解析
L4
为什么这里可以用二维数组:n ≤ 1000 → 1005×1005 ≈ 1.01×10⁶ 个 bool,只占 1MB。如果 n 是 10⁵,就该换 set<pair<int,int>> 或哈希表了。数据结构的选择永远由数据范围决定。
L8
边读边判:不需要把边存下来再检查,读到第 i 条就地判断。这样额外空间只有 g 数组,时间复杂度 O(m)。
L9
先判自环:自环时 u == v,如果先查 g[u][v] 也成立(因为上一轮可能已标记),会把「自环」误报成「重边」。判断顺序要按「更特殊的先查」。
L10
重边靠「对称标记」识别:g[u][v] 为真说明这条边来过。注意输入给的是 1 2 还是 2 1 都算同一条边,所以下一步必须两个方向都置真。
L11
g[u][v] = g[v][u] = true; 是链式赋值的写法,等价于两条独立语句。无向图的矩阵永远关于主对角线对称,这是检查自己代码有没有写漏的好办法。
L13
提前 return 0:一旦判定不是简单图就直接结束程序,不再读剩下的输入。这种写法在「判断类」题目里很常见,注意此时不必把剩余的输入读完。
易错点如果只写 g[u][v] = true 而不写 g[v][u],那么输入 1 2 后再输入 2 1 会被判成「不是重边」——因为查的是 g[2][1],还是 false。
P1-3

完全图与补图的边数

基础
输入 n m输出 完全图边数 / 补图边数n ≤ 2×10⁹

n 个点的完全图指任意两点之间都有一条边,边数为 n(n−1)/2。给定 n 和 m 条已有的边(保证是简单图),求:① 完全图的边数;② 补图的边数(补图 = 完全图去掉这些边后剩下的边)。

样例
输入:
4 3

输出:
6
3

思路:公式直接算。all = n*(n-1)/2,comp = all - m。唯一的难点在溢出:n = 2×10⁹ 时 n(n−1) ≈ 4×10¹⁸,已经接近 long long 的上限 9.2×10¹⁸,用 int 会直接变负数。

C++P1-3 参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long n; long long m;
    scanf("%lld%lld", &n, &m);
    long long all = n * (n - 1) / 2;    // 先乘后除
    long long comp = all - m;
    printf("%lld\n%lld\n", all, comp);
    return 0;
}
代码逐段解析
L4-5
n 和 m 都声明为 long long。哪怕 m 实际很小,只要它参与 all - m 这类运算,混用 int 就可能在中途被截断。题目给了 2×10⁹ 这种范围,就一个 int 都别留。
L6
为什么写 n * (n-1) / 2 而不是 n/2 * (n-1):C++ 的整数除法会向下取整。n 是奇数时 n/2 会丢掉 0.5,结果偏小;而 n 与 n−1 中必有一个偶数,所以先乘后除一定整除,结果精确。
L6
溢出边界算一遍:n = 2×10⁹ 时 n(n−1) = 4×10¹⁸ < 9.22×10¹⁸(long long 上限),安全。但如果题目把 n 开到 10¹⁰,就需要 __int128 或高精度了。
L7
补图边数的推导:完全图包含「所有可能的边」,已有的 m 条是它的子集,所以补图 = 全集 − 已有。这个思路在「补图最短路」「反图」类题目里反复出现。
L8
%lld 与 long long 必须配对:用 %d 打印 long long 是未定义行为,通常表现为输出一个莫名其妙的数。这是新手调试时最抓狂的 bug 之一。
延伸完全图 Kₙ 的边数是图论里出现频率最高的公式之一,必须条件反射:n(n−1)/2。有向完全图则是 n(n−1)(每条无向边变成两条有向边)。
第 2 章

图的三种存储方式入门 · 必须动手写

算法写得对不对,一半取决于图存得对不对。三种方式各有适用场景,竞赛里最常用的是「vector 邻接表」和「链式前向星」。

2.0先明确:存图到底要存什么?

无论用哪种结构,目标都是支持两个操作:

  1. 建边:读入 u、v、w,把这条边记录下来。
  2. 访问邻居:给定点 u,快速枚举「从 u 出发的所有边」以及它们的终点、权值。

第 2 个操作是所有图算法的核心——DFS、BFS、Dijkstra、拓扑排序、Prim,全都在反复做「枚举 u 的邻居」。

选择口诀n 小(≤ 5000)图稠密 → 邻接矩阵;n 大图稀疏 → 邻接表 / 链式前向星。不确定时,无脑用 vector 邻接表(好写不易错);被卡常数或被出题人针对时,换链式前向星。
2.1邻接矩阵:用二维数组「查表」

开一个 n×n 的二维数组 a[i][j],表示 i 到 j 有没有边(或边权是多少):

类型a[i][j] 的含义
无权图a[i][j] = 1 表示有边,0 表示无边
带权图a[i][j] = w 表示边权;无边记 INF;a[i][i] = 0
1 2 3 4 无向图 G(4 点 4 边) 邻接矩阵 A 12 34 12 34 0 1 1 0 1 0 0 1 1 0 0 1 0 1 1 0
无向图的邻接矩阵一定沿主对角线对称(a[i][j] = a[j][i])
C++邻接矩阵:建图 + 遍历
const int N = 1010;
const int INF = 0x3f3f3f3f;
int a[N][N];           // a[i][j] = 边权,无边为 INF,自己到自己为 0

int main() {
    int n, m;
    cin >> n >> m;
    // 1) 初始化:先全部置为“不可达”
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            a[i][j] = (i == j ? 0 : INF);

    // 2) 读入 m 条边
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        a[u][v] = min(a[u][v], w);   // 有重边时保留最小的
        a[v][u] = min(a[v][u], w);   // 无向图:反向也要存!
    }

    // 3) 枚举 u 的所有邻居
    int u = 1;
    for (int v = 1; v <= n; v++)
        if (a[u][v] != INF && u != v)
            cout << "邻居 " << v << " 边权 " << a[u][v] << endl;
    return 0;
}
优点
  • 查边 / 改边 O(1):a[u][v] 直接访问
  • 代码极简,不易写错
  • Floyd 算法天然就用它
缺点
  • 空间 O(n²):n = 10⁵ 时直接爆内存(10¹⁰ 个 int)
  • 枚举一个点的邻居要扫 n 个位置,O(n),稀疏图极浪费
边界n ≤ 5000 时 n² = 2500 万,用 int 约 100MB,已经很危险;n ≤ 2000 才比较安全。所以邻接矩阵只适合 n 小的题(比如 Floyd 题 n ≤ 500)。
2.2vector 邻接表:每个点挂一个「邻居清单」

给每个点开一个 vector,里面装它所有的出边。用 pair<int,int> 同时存「终点」和「边权」。

1 2 3 4 g[1] 2 3 g[2] 4 g[3] 4 g[4] (空)
g[u] 里只存 u 真正连出去的边,空间只和边数有关
C++vector 邻接表:带权图(最常用写法)
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;

vector<pair<int,int>> g[N];   // g[u] = {(终点 v, 边权 w), ...}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});         // 有向边 u→v
        g[v].push_back({u, w});         // 如果是无向图,加这一行
    }
    // 遍历 u 的所有邻居
    int u = 1;
    for (auto &e : g[u]) {
        int v = e.first, w = e.second;
        cout << u << " -> " << v << " , w = " << w << endl;
    }
    return 0;
}
C++无权图可以更省:直接存终点
vector<int> g[N];
// 建边
g[u].push_back(v);
g[v].push_back(u);   // 无向图
// 遍历
for (int v : g[u]) cout << v << " ";
优点空间 O(n + m),枚举邻居的代价正比于真实边数 O(deg(u)),写法直观不易错,是竞赛首选。
注意vector 的 push_back 会动态扩容。数据量大时建议先 g[u].reserve(deg[u]),或者直接用链式前向星。
2.3链式前向星:用数组模拟链表(竞赛高频)

思路:把所有边存在一个数组里,再用一个 head 数组记录「每个点的第一条边在哪」。每条边再记一个 nxt,指向「同起点的下一条边」,形成一条链表。

数组含义
head[u]以 u 为起点的第一条边在 Edge 数组中的下标(没有则为 −1)
Edge[i].to第 i 条边的终点
Edge[i].w第 i 条边的权值
Edge[i].nxt第 i 条边同起点的下一条边的下标(没有则为 −1)
C++链式前向星:标准模板(背下来)
const int N = 1e5 + 5;        // 点数
const int M = 2e5 + 5;        // 边数(无向图要开 2 倍!)
struct Edge {
    int to, nxt, w;
} e[M];
int head[N], cnt = 0;

void add(int u, int v, int w) {
    e[++cnt].to  = v;          // 这条边指向 v
    e[cnt].w     = w;          // 边权
    e[cnt].nxt   = head[u];    // 接在 u 原来的第一条边前面
    head[u]      = cnt;        // u 的第一条边变成新加的这条
}

int main() {
    memset(head, -1, sizeof(head));   // -1 表示“没有边”
    int n, m;  cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w;  cin >> u >> v >> w;
        add(u, v, w);
        add(v, u, w);          // 无向图才需要
    }
    // 遍历 u 的所有出边
    for (int i = head[u]; i != -1; i = e[i].nxt) {
        int v = e[i].to, w = e[i].w;
    }
}
代码逐行解析
L1-2
N 是点数、M 是边数,两个都要 +5 留余量。无向图 M 必须开 2m——因为每条边要 add 两次。这是链式前向星最高频的 RE 原因。
L3-5
把 to/nxt/w 装进结构体,等价于开三个平行数组 to[M]、nxt[M]、w[M]。结构体的好处是「一条边的信息连着放」,缓存友好;缺点是不同写法(结构体 / 三数组)不能混用,抄模板时要整套抄。
L6
head[u] 存的是「u 的链表头那条边的编号」,不是邻居编号,也不是边数。cnt 从 0 开始,每 add 一次先自增再使用,所以边编号是 1..m,0 号位置永远空着——这样才能用 −1 当「没有边」的哨兵。
L9-10
先写数据:to 和 w。这两句顺序无所谓,但必须在改 head 之前完成。
L11-12
这两句的顺序绝对不能反。先 e[cnt].nxt = head[u](新结点接上旧链表),再 head[u] = cnt(头指针前移)。如果先改 head,旧链表头就丢了,整条链断掉——这是最经典的指针操作失误。
L16
memset(head, -1, sizeof(head)) 必须写,且必须在任何 add 之前。漏了 head 全是 0,遍历时会把 0 号边当成真实边读,输出垃圾数据。
L21
add(v, u, w) 是无向图的标志。有向图删掉这一行即可;但如果删了却按无向图理解题意,答案会系统性偏大(很多点变得不可达)。
L24-25
遍历三要素:起点 i = head[u]、终止 i != -1、步进 i = e[i].nxt。漏掉任何一项都会死循环或漏边。注意这里是 i 是边编号,取终点要写 e[i].to 而不是 to[i](除非你用的是三数组写法)。
L24
遍历顺序与输入顺序相反。因为每次都是头插。需要正序时,要么改用 vector 邻接表,要么把边倒着存,要么遍历后反转。
为什么是倒序?每次 add 都把新边插在链表头部(e[cnt].nxt = head[u]; head[u] = cnt;),所以遍历时边的顺序和输入顺序相反。大多数题不在意顺序;若要求「按输入顺序」输出,用 vector 或把边倒着存。

手把手:3 个点、4 条边,看数组怎么变

依次加入:① 1→2(权 5) ② 1→3(权 7) ③ 2→3(权 4) ④ 1→4(权 2)

🔧 链式前向星建边过程
0初始化:head[1..4] = -1,cnt = 0。此时没有任何边。
1add(1,2,5):++cnt = 1;e[1] = {to=2, nxt=head[1]=−1, w=5};head[1] = 1。
含义:点 1 的第一条边是 1 号边,它后面没有别的边了。
2add(1,3,7):++cnt = 2;e[2] = {to=3, nxt=head[1]=1, w=7};head[1] = 2。
含义:点 1 的第一条边变成 2 号,2 号边之后接 1 号边。
3add(2,3,4):++cnt = 3;e[3] = {to=3, nxt=head[2]=−1, w=4};head[2] = 3。
点 2 的链表只有这一条边。
4add(1,4,2):++cnt = 4;e[4] = {to=4, nxt=head[1]=2, w=2};head[1] = 4。
点 1 的链表变成:4 号 → 2 号 → 1 号。
✓遍历点 1:i = head[1] = 4 → 输出 4;i = e[4].nxt = 2 → 输出 3;i = e[2].nxt = 1 → 输出 2;i = e[1].nxt = −1 结束。
得到邻居序列 4、3、2(与输入顺序 2、3、4 相反)。
点击「播放」自动演示,或「单步」自己控制节奏
head[1] 4 边4: to=4 边2: to=3 边1: to=2 −1 点 1 的边链表(head[1]=4): nxt 把同起点的边串起来,插入永远在表头 → 遍历顺序与输入相反
head 是「链表头」,nxt 是「下一跳」,−1 表示走到尽头
小技巧for (int i = head[u]; ~i; i = e[i].nxt) 里的 ~i 等价于 i != -1(−1 的二进制全是 1,取反为 0,循环结束)。很多模板这么写,看到别慌。初学建议写 i != -1 更清楚。
最常见的 RE无向图忘记把边数组开到 2m 大小 → 越界写坏内存 → 本地能跑、提交 RE。记住:无向图 add 两次,M 要乘 2。
2.4三种方式横向对比 & 怎么选
对比项邻接矩阵vector 邻接表链式前向星
空间O(n²)O(n + m)O(n + m)
查边 u→vO(1)O(deg(u))O(deg(u))
枚举邻居O(n)O(deg(u))O(deg(u))
建图O(n² + m)O(n + m)O(n + m)
适合稠密图、n 小、Floyd通用、好写大数据、卡常数
无向图注意对称赋值两次 push_backM 开 2 倍
决策流程 ① n ≤ 500 且要任意两点距离 → 邻接矩阵(配 Floyd)
② 一般情况 → vector 邻接表(推荐,不易写错)
③ n、m ≥ 10⁵ 且被卡时间/内存 → 链式前向星
④ 要按输入顺序遍历边 → vector(链式前向星是倒序)
✍️第 2 章编程题(3 道,全部要写代码)

存图方式光看书没用,必须亲手写三遍:矩阵读进来转邻接表、前向星建完再遍历、邻接表去重排序。这三个动作覆盖了复赛 90% 的建图代码。

P2-1

邻接矩阵 → 邻接表

入门
输入 n + n×n 矩阵输出 每点的邻居(升序)n ≤ 1000

读入一个有向图的邻接矩阵(a[i][j] = 1 表示有一条 i→j 的边),把它转换成邻接表输出。要求每个点的邻居按编号升序排列。

样例
输入:
4
0 1 1 0
1 0 0 1
1 0 0 0
0 1 0 0

输出:
1: 2 3
2: 1 4
3: 1
4: 2

思路:双重循环扫矩阵,遇到 1 就把列号 j 推进 e[i]。因为内层 j 是从小到大扫的,push 的顺序天然升序,不需要额外排序。

C++P2-1 参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; scanf("%d", &n);
    vector<vector<int>> e(n + 1);        // 下标 1..n
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) {
            int x; scanf("%d", &x);
            if (x) e[i].push_back(j);
        }
    for (int i = 1; i <= n; i++) {
        printf("%d:", i);
        for (int j = 0; j < (int)e[i].size(); j++) printf(" %d", e[i][j]);
        printf("\n");
    }
    return 0;
}
代码逐段解析
L5
vector<vector<int>> e(n+1) 开了 n+1 个空 vector,下标 0 浪费掉不用,这样点编号 1..n 能直接当前标。「多开一个」是所有竞赛代码的通用习惯——后面 e[n] 才是最后一个点,不会越界。
L6-7
双重循环的顺序很重要:外层 i 是行(起点),内层 j 是列(终点)。如果写反了,读入的数据会错位——矩阵是按行存储的,输入流只能顺序读。
L9
if (x) 而不是 if (x == 1):非 0 即为真。题目若用边权矩阵(0 表示无边、正数表示权值),这个写法同样适用,只是要把 e 换成存 pair<int,int>。
L13
(int)e[i].size() 的强制转换:size() 返回的是无符号类型 size_t,与 int j 比较时会触发有符号/无符号警告,某些编译器会报错。养成加 (int) 的习惯。
L13-15
输出格式的小技巧:先打印 "%d:",再循环打印 " %d"(前面带空格),最后补一个换行。这样不会出现行末多余空格,也不会漏掉冒号——是刷题时最省心的写法。
举一反三如果矩阵是无向图(对称矩阵),同一段代码依然正确:a[i][j] 和 a[j][i] 都是 1,两边都会各自 push,正好得到双向邻接表。
P2-2

链式前向星:建图 + 遍历出边

基础
输入 n m + m 条有向边输出 每点的出边 (v,w)n,m ≤ 2×10⁵

用链式前向星存一个有向带权图(m 条边,每条 u v w),然后按链式遍历的顺序输出每个点的所有出边,格式 u: (v1,w1) (v2,w2) ...。

样例
输入:
4 5
1 2 5
1 3 7
2 4 2
3 4 9
1 4 1

输出:
1: (4,1) (3,7) (2,5)
2: (4,2)
3: (4,9)
4:

注意输出是逆序的:1 号点先加了 2、再 3、再 4,但头插法让最后加的排在最前面。这不是 bug,而是链式前向星的固有特性——遍历顺序与加边顺序相反。很多题(比如求最小字典序路径)需要正序,那时就要额外排序或改用邻接表。

C++P2-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005, M = 200005;
int head[N], nxt[M], to[M], wt[M], tot;

void add(int u, int v, int w) {
    to[++tot] = v; wt[tot] = w;
    nxt[tot] = head[u]; head[u] = tot;
}

int main() {
    memset(head, -1, sizeof head);      // 必须!-1 表示链表结束
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        add(u, v, w);                  // 有向图:只加一条
    }
    for (int u = 1; u <= n; u++) {
        printf("%d:", u);
        for (int i = head[u]; i != -1; i = nxt[i])
            printf(" (%d,%d)", to[i], wt[i]);
        printf("\n");
    }
    return 0;
}
代码逐段解析
L4
五个数组各管什么:head[u] 存「u 的最后一条出边的编号」;to[i] 存第 i 条边的终点;wt[i] 存权值;nxt[i] 存「同起点的上一条边编号」;tot 是边的计数器。把一条边想象成链表结点,head 就是链表头。
L7-8
头插法的三步顺序不能乱:①写 to 和 wt(写数据);②nxt[tot] = head[u](新结点指向原链表头);③head[u] = tot(更新头指针)。如果先改 head 再取 nxt,就会丢掉整条旧链表。
L12
memset(head, -1, sizeof head) 是整个模板里最容易漏的一行。漏了会怎样?head 全是 0,遍历时 i = 0 不是 −1,于是把编号 0 当成一条边去读 to[0],输出一堆垃圾甚至死循环。
L18
循环条件是 i != -1,不是 i > 0:因为边编号从 1 开始,0 号边不存在,用 −1 作哨兵才能正确终止。写成 i > 0 恰好也对,但若以后改成 0-index 就会出错,统一用 != -1 更稳。
L4
M 开多大:这里是有向图,m 条边只需 M = m。如果改成无向图,每条边要 add 两次,M 必须开 2m——这是最经典的 RE 来源,没有之一。
一个记忆锚点把 head 想成「一串挂钩」,每次 add 都是把新牌子挂在最外面。所以遍历时看到的是「最后挂上去的」最先出现——这就是逆序的由来。
P2-3

邻接表去重 + 排序

基础
输入 n m + m 条无向边输出 去重后的邻居表n,m ≤ 2×10⁵

读入一个无向图,其中可能有重边和自环。请对每个点去重(自己到自己的自环也去掉),并把邻居按编号升序输出。

样例
输入:
4 7
1 2
2 1
1 3
1 2
3 4
2 3
4 4

输出:
1: 2 3
2: 1 3
3: 1 2 4
4: 3

思路:仍然用 vector 邻接表,建完图后对每个点执行 sort + unique(先去重再排序,或者先排序再去重——后者才是标准写法,因为 unique 只能消除相邻的重复元素)。

C++P2-3 参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m; scanf("%d%d", &n, &m);
    vector<vector<int>> e(n + 1);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        if (u == v) continue;          // 自环直接丢掉
        e[u].push_back(v);
        e[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) {
        sort(e[i].begin(), e[i].end());                       // 先排序
        e[i].erase(unique(e[i].begin(), e[i].end()), e[i].end()); // 再去重
        printf("%d:", i);
        for (int j = 0; j < (int)e[i].size(); j++) printf(" %d", e[i][j]);
        printf("\n");
    }
    return 0;
}
代码逐段解析
L8
自环在 push 前就丢掉:if (u == v) continue;。如果放到后面去重,也能去掉(因为只 push 了一次),但要多跑一轮 unique。能在源头拦掉就别留到后面处理。
L9-10
无向边双向 push:e[u] 加 v、e[v] 加 u。这也是为什么重边会「双倍出现」——一次 1 2 会在 1 的表里塞一个 2,一次 2 1 又塞一个。
L13
必须先 sort 再 unique。unique 的作用是「把相邻的重复元素移到末尾」,它不排序。对 2 1 3 2 用 unique 什么都不会删,因为两个 2 不相邻。
L14
erase(unique(...), end()) 是固定搭配:unique 返回「去重后新区间的尾后迭代器」,erase 把从这里到 end() 的垃圾元素真正删掉。两句要连在一起写,缺一不可。
L13-14
复杂度:每个点的表长是它的度数,总长度 2m。所有点排序合计 O(m log m),去重 O(m)。总复杂度 O(m log m),n、m = 2×10⁵ 完全跑得动。
这题为什么实用真实题目经常给「可能有重边」的数据。如果不加重边处理,最短路答案照样对(松弛时会自动取小),但计数类、DFS 枚举类题目会因为重复边而重复计算,直接 WA。「建图时顺手去重」是个好习惯。
第 3 章

图的遍历:DFS 与 BFS基础 · 复赛高频

遍历是图论的「万能钥匙」:连通块、判环、最短路、拓扑排序、强连通分量,全都建立在遍历之上。

3.1DFS 深度优先:一条路走到黑

思想:从起点出发,沿着一条路一直往前走,走不动了就回退到上一个还能走别的路的点,继续深入。这正好对应递归。

  1. 标记起点已访问;
  2. 枚举起点的每个邻居:若未访问,则从该邻居继续 DFS;
  3. 所有邻居都处理完,返回上一层(回溯)。
C++DFS 递归模板(邻接表)
int vis[N];

void dfs(int u) {
    vis[u] = 1;              // 一进来就标记,防止绕圈
    cout << u << " ";        // 访问 u(按题目要求做事)
    for (int v : g[u]) {
        if (!vis[v]) dfs(v); // 没访问过才继续深入
    }
}

int main() {
    // ...
    for (int i = 1; i <= n; i++)
        if (!vis[i]) dfs(i);   // 非连通图:每个未访问点都起一次 DFS
}
1 2 3 4 5 6 7 从 1 出发:1 → 2 → 4 → 6 → 回退 → 3 → 7 → 回退 → 5
绿色为已访问。注意 4 和 6 走完之后是「回退」到 2,再去走 3
▶ DFS 遍历全过程(对照上图)
1dfs(1):标记 1 已访问,输出 1。邻居有 2、3,先选 2。
2dfs(2):标记 2,输出 2。邻居 1(已访问)、4、5,先选 4。
3dfs(4):标记 4,输出 4。邻居 2(已访问)、6 → 继续。
4dfs(6):标记 6,输出 6。6 的邻居 4 已访问 → 无路可走,返回到 4。
5回到 4:邻居都访问过了,返回到 2。
6回到 2:还剩邻居 5 未访问 → dfs(5),输出 5;5 的邻居 3 未访问 → dfs(3)。
7dfs(3):输出 3;邻居 7 未访问 → dfs(7),输出 7。
✓7 无路可走,一路回退到 1;1 的邻居也都访问完了 → 结束。
DFS 序列:1 2 4 6 5 3 7(不唯一,取决于邻居枚举顺序)。
点击「播放」自动演示,或「单步」自己控制节奏
两个必须记住的点 ① vis 要在进入时标记,不能等到「准备离开」才标记,否则会重复访问、死循环;
② 非连通图要外层循环,对每个未访问点都发起一次 DFS,否则漏掉其他连通块。
爆栈警告DFS 递归深度可达 n。n ≥ 10⁵ 且图是一条长链时,递归层数 10⁵,可能栈溢出(本地能跑,评测机 RE)。链状图/网格图优先用 BFS,或把 DFS 改成手写栈。
3.2BFS 广度优先:一层一层往外扩散

思想:从起点出发,先把「距离为 1」的全部访问完,再访问「距离为 2」的,像水波一样一层层扩散。用队列实现。

C++BFS 模板(含无权图最短路)
int vis[N], dist[N];
queue<int> q;

void bfs(int s) {
    vis[s] = 1;
    dist[s] = 0;
    q.push(s);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : g[u]) {
            if (vis[v]) continue;
            vis[v] = 1;            // 关键:入队时就标记!
            dist[v] = dist[u] + 1; // 无权图:边数就是距离
            q.push(v);
        }
    }
}
新手最大坑把 vis[v] = 1 写在「出队时」而不是「入队时」:同一个点可能被多个邻居重复入队,队列膨胀到 O(m),甚至死循环。入队即标记,一遍就够。
1 2 3 4 5 6 第 0 层 第 1 层 第 2 层 第 3 层 BFS 序列:1 → 2 → 3 → 4 → 5 → 6(同层顺序可换)
BFS 天然按「边数」分层,所以能直接求无权图最短路
▶ BFS 队列变化全过程
0初始:vis[1]=1,dist[1]=0,队列 = {1}
1取队首 1 出队;邻居 2、3 未访问 → 标记、dist=1、入队。输出 1。队列 = {2, 3}
2取队首 2 出队;邻居 4 未访问 → dist[4]=2、入队。输出 2。队列 = {3, 4}
3取队首 3 出队;邻居 1、4 都已访问 → 跳过。输出 3。队列 = {4}
4取队首 4 出队;邻居 5、6 未访问 → dist=3、入队。输出 4。队列 = {5, 6}
55 出队(无新邻居)、6 出队(无新邻居)。输出 5 6,队列为空,结束。
✓BFS 序列:1 2 3 4 5 6;距离 dist:0,1,1,2,3,3 —— 正好是从 1 出发的最少边数。
点击「播放」自动演示,或「单步」自己控制节奏
BFS 的两大用途 ① 求无权图最短路(或边权全为 1 的图):第一次访问到某点时,距离一定最短;
② 分层/扩散类问题:如「腐烂的橘子」「迷宫最少步数」「多源扩散」。
3.3经典应用①:连通块计数(网格图)

题目(Oil Deposits):一块 n×m 的矩形区域,@ 表示有油,* 表示没有。若两个 @ 在水平、垂直或对角方向相邻(8 个方向),它们属于同一油藏。问一共有多少块油藏。n, m ≤ 100。

建模把每个 @ 格子当成一个点,相邻(8 方向)的两个 @ 之间连边。问题就变成:求无向图的连通块个数。
C++8 方向 DFS 求连通块
int n, m;
char c[105][105];
int dx[8] = {-1,-1,-1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1,-1, 1,-1, 0, 1};

void dfs(int x, int y) {
    c[x][y] = '*';                 // 标记为已访问(直接改成石头,省一个 vis 数组)
    for (int k = 0; k < 8; k++) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx >= 0 && nx < n && ny >= 0 && ny < m && c[nx][ny] == '@')
            dfs(nx, ny);
    }
}

int main() {
    while (cin >> n >> m && n) {       // 多组数据,n=0 结束
        for (int i = 0; i < n; i++) cin >> c[i];
        int ans = 0;
        for (int i = 0; i < n; i++)
            for (int j = 0; j < m; j++)
                if (c[i][j] == '@') {  // 发现一个新的连通块
                    ans++;
                    dfs(i, j);         // 把整块都标记掉
                }
        cout << ans << "\n";
    }
    return 0;
}
通用套路「求连通块个数」永远是同一个模板:
外层扫所有点 → 遇到未访问的 → 计数器 +1 → 从它 DFS/BFS 把整个块标记掉。
四方向改 dx/dy 为 4 个,八方向用 8 个,这是唯一区别。
3.4经典应用②:反向建图(一个能救命的思维转换)

题目(洛谷 P3916 图的遍历):给出 n 个点、m 条边的有向图,对每个点 v,求从 v 出发能到达的编号最大的点 A(v)。1 ≤ n, m ≤ 10⁵。

暴力想法

对每个点各做一次 DFS,求能到达的最大编号。复杂度 O(n(n+m)),n = 10⁵ 时约 10¹⁰,直接 TLE。

反向思维

「v 能到 u」等价于「在反图中 u 能到 v」。那么从编号大的点出发,在反图上能走到的所有点,答案都是这个大编号。

算法 ① 建图时把边反向:读入 u→v,实际存 v→u;
② 从 n 递减到 1 枚举起点 i,若 i 还没被访问过,就从 i 做一次 DFS,把沿途所有点的答案都记为 i;
③ 每个点最多被访问一次,总复杂度 O(n + m)。
C++P3916 反向建图解法
vector<int> g[N];
int ans[N];

void dfs(int u, int root) {
    if (ans[u]) return;       // 已经被更大的编号“认领”过了
    ans[u] = root;            // 当前起点 root 能到 u,答案就是 root
    for (int v : g[u]) dfs(v, root);
}

int main() {
    int n, m; cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        g[v].push_back(u);    // 反向建图:原图 u→v,反图 v→u
    }
    for (int i = n; i >= 1; i--)  // 从大到小
        if (!ans[i]) dfs(i, i);
    for (int i = 1; i <= n; i++) cout << ans[i] << " ";
    return 0;
}
原图: 1 2 5 反图: 1 2 5 从 5 出发在反图上 DFS,能走到 2、1 → A(1)=A(2)=A(5)=5
「谁能到达我」反过来就是「我能到达谁」——这是图论里最常用的转换之一
同类题「求每个点能被哪些点到达」「求一条路径使终点编号最大」——看到「反向可达」四个字,就想反向建图。
3.5经典应用③:无权图最短路(BFS 的真正主场)

题:n×m 的迷宫,. 可走、# 墙壁,从起点 S 走到终点 T,每步上下左右移动一格,求最少步数。

把每个可走格子当点,相邻格子连边(边权都是 1)。边权相同的图,BFS 第一次到达就是最短。

C++迷宫最短路 BFS(含路径长度)
struct Node { int x, y; };
int n, m, dist[1005][1005];
char mp[1005][1005];
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int bfs(int sx, int sy, int tx, int ty) {
    memset(dist, -1, sizeof(dist));   // -1 表示未访问
    queue<Node> q;
    q.push({sx, sy});
    dist[sx][sy] = 0;
    while (!q.empty()) {
        Node u = q.front(); q.pop();
        if (u.x == tx && u.y == ty) return dist[u.x][u.y];
        for (int k = 0; k < 4; k++) {
            int nx = u.x + dx[k], ny = u.y + dy[k];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
            if (mp[nx][ny] == '#' || dist[nx][ny] != -1) continue;
            dist[nx][ny] = dist[u.x][u.y] + 1;
            q.push({nx, ny});
        }
    }
    return -1;   // 走不到
}
为什么 BFS 能求最短路?因为 BFS 是按距离从小到大扩展的:所有距离为 k 的点处理完,才会处理距离为 k+1 的点。所以第一次碰到目标点时,用的步数一定是最少的。
对比如果边权不全是 1(比如每步耗时不同),BFS 就失效了,要用 Dijkstra(第 6 章)。这是初学者最常混淆的一点。
3.6经典应用④:二分图判定(黑白染色)

判定标准:一个图是二分图 ⇔ 可以用两种颜色给点染色,使每条边的两端颜色都不同 ⇔ 图中没有奇环。

做法:从每个未染色的点出发 BFS(或 DFS),把它染成 1,邻居染成 2,邻居的邻居染成 1……如果发现某条边的两端颜色相同,立即判定「不是二分图」。

C++BFS 染色判定二分图
vector<int> g[N];
int col[N];     // 0=未染色, 1=颜色A, 2=颜色B

bool check(int n) {
    for (int s = 1; s <= n; s++) {
        if (col[s]) continue;          // 每个连通块都要检查
        queue<int> q;
        q.push(s);
        col[s] = 1;
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (int v : g[u]) {
                if (!col[v]) {
                    col[v] = 3 - col[u];   // 1 变 2,2 变 1
                    q.push(v);
                } else if (col[v] == col[u]) {
                    return false;          // 相邻同色 → 不是二分图
                }
            }
        }
    }
    return true;
}
为什么非连通图要循环?二分图要求每个连通块都能正确染色。外层循环保证每个连通块都起了个染色的种子,漏掉就会误判。
3.7DFS vs BFS:什么时候用哪个?
对比项DFS(深度优先)BFS(广度优先)
实现递归 / 栈队列
访问顺序一条路走到底,再回退按距离一层层扩散
空间O(递归深度),最坏 O(n)O(队列宽度),最坏 O(n)
能否求最短路不能(除非枚举所有路径)能(仅限无权/等权图)
擅长连通块、判环、拓扑、回溯搜索、Tarjan最短路、分层、多源扩散、二分图染色
风险深图爆栈宽图队列占内存
一句话选择问「最少几步 / 最短距离」→ BFS;问「能不能 / 有多少种 / 连通性」→ DFS(或 BFS 都行)。
✍️第 3 章编程题(3 道,全部要写代码)

遍历是图论里唯一「必须靠手感」的部分。下面 3 道题从「数连通块」到「网格搜索」再到「BFS 最短路」,把 DFS 与 BFS 的两套模板各自用一遍。

P3-1

连通块计数与每块大小

入门
输入 n m + m 条无向边输出 块数 + 各块点数n,m ≤ 10⁵

给定 n 个点、m 条无向边(可能不连通)。求:① 连通块个数;② 每个连通块包含几个点,按升序输出。

样例
输入:
6 4
1 2
2 3
4 5
5 6

输出:
2
3 3

思路:外层循环扫每个点,遇到未访问的就说明发现了一个新块 → BFS 把整块染色,同时统计点数。外层循环的次数就是块数。

C++P3-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> e[N];
bool vis[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);
        e[v].push_back(u);          // 无向图:双向加
    }

    vector<int> sz;
    for (int i = 1; i <= n; i++) {
        if (vis[i]) continue;       // 已属于某个块,跳过
        int cnt = 0;                // 本块的点数
        queue<int> q;
        q.push(i); vis[i] = true;   // 入队时就标记!
        while (!q.empty()) {
            int u = q.front(); q.pop();
            cnt++;
            for (int j = 0; j < (int)e[u].size(); j++) {
                int v = e[u][j];
                if (!vis[v]) { vis[v] = true; q.push(v); }
            }
        }
        sz.push_back(cnt);
    }

    sort(sz.begin(), sz.end());
    printf("%d\n", (int)sz.size());
    for (int i = 0; i < (int)sz.size(); i++) printf("%d ", sz[i]);
    return 0;
}
代码逐段解析
L4-5
vector<int> e[N] 是全局的「数组套 vector」,等价于二维动态数组。它比 int e[N][N] 省得多:有边的位置才占空间,总容量 2m 而不是 n²。
L11-12
无向边必须加两次。只加一次的话,从 u 能到 v、从 v 回不到 u,BFS 会把同一块拆成两个不对称的部分,答案必错。
L17
if (vis[i]) continue; 是块计数的核心:只有「从没被任何 BFS 碰过」的点才代表一个新块。这一句同时起到了「计数」和「避免重复遍历」两个作用。
L21
vis[i] = true 必须在入队时写,不能挪到出队时。否则同一个点会被它的多个邻居重复入队,队列膨胀到 O(m),最坏直接把内存吃光(第 3 章讲过的经典坑)。
L23-27
cnt++ 放在出队处:每个点入队一次、出队一次,所以在出队时统计不会重复也不会漏。这是「统计块内点数」最标准的写法。
L33-34
先排序再输出:题目要求升序,而 BFS 的发现顺序是按点编号来的,块大小并不有序。sort 一下最省事。
换成 DFS 也可以把 BFS 的三行换成 void dfs(int u){ cnt++; vis[u]=true; for(int v:e[u]) if(!vis[v]) dfs(v); } 即可。但要小心:n = 10⁵ 的链状图递归深度也是 10⁵,会爆栈。数据大时优先 BFS。
P3-2

网格最大连通区域面积

基础
输入 n m + 字符网格输出 最大面积n,m ≤ 1000

n×m 的网格,# 表示陆地、. 表示水。上下左右相邻的陆地属于同一片区域。求最大区域的格子数。

样例
输入:
5 5
.....
.##..
.###.
..#..
.....

输出:
6

思路:网格题不要真的建图——把格子坐标当成点,四个方向就是四条边,「相邻」关系在遍历时现场算出来即可。这就是所谓「隐式图」。

C++P3-2 参考代码(隐式图 BFS)
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int n, m, ans;
char g[N][N];
bool vis[N][N];
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%s", g[i] + 1);

    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++) {
            if (g[i][j] != '#' || vis[i][j]) continue;
            int cnt = 0;
            queue<pair<int,int>> q;
            q.push({i, j}); vis[i][j] = true;
            while (!q.empty()) {
                int x = q.front().first, y = q.front().second;
                q.pop(); cnt++;
                for (int k = 0; k < 4; k++) {
                    int nx = x + dx[k], ny = y + dy[k];
                    if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界
                    if (g[nx][ny] != '#' || vis[nx][ny]) continue;         // 非陆地/已访问
                    vis[nx][ny] = true;
                    q.push({nx, ny});
                }
            }
            ans = max(ans, cnt);
        }

    printf("%d\n", ans);
    return 0;
}
代码逐段解析
L5-8
方向数组是网格题的标准装备。dx[k] 与 dy[k] 一一配对:(+1,0) 下、( −1,0) 上、(0,+1) 右、(0,−1) 左。如果题目允许走斜线,再加 4 组 (±1,±1) 变 8 个方向。
L12
scanf("%s", g[i] + 1) 是读字符网格的技巧:g[i]+1 是第 i 行第 1 列的地址(跳过下标 0),这样字符就存在 g[i][1]..g[i][m],与 1-based 的循环一致。%s 会自动在末尾补 \0,不会串行。
L16
起点筛选条件 g[i][j] != '#' || vis[i][j]:非陆地跳过、已访问也跳过。两个条件用 || 合并,任一成立就 continue,比嵌套 if 清爽。
L19
queue<pair<int,int>> 存坐标。C++11 起可以用花括号 q.push({i, j}) 直接构造 pair,不必写 make_pair。
L26
越界判断必须放在访问 g[nx][ny] 之前。C++ 不检查数组越界,先读再判会让程序读到别的内存(可能崩溃、也可能算出诡异答案)。「先判边界,再判内容」是网格题的铁律。
L27
一个 continue 挡掉两个条件:非陆地或已访问。注意「已访问」这一条不能省,否则两块连在一起时会互相"踢皮球",在两人之间反复入队直到内存耗尽。
L34
答案用 max 滚起来,不需要把每块面积都存进数组再排序。题目只要最大值,能省的内存就省。
递归版本慎用这道题写成 DFS 很简洁,但 n = m = 1000 时最坏路径长度 10⁶,递归栈必然溢出(Windows 默认约 1~2MB,每层几十字节也扛不住)。网格搜索统一用 BFS,这是无数人用 RE 换来的经验。
P3-3

无权图单源最短路(BFS)

进阶
输入 n m s + m 条有向边输出 到各点的最少边数n,m ≤ 2×10⁵

给定有向图(边权全为 1),求起点 s 到每个点的最少经过几条边。不可达输出 -1。

样例
输入:
6 5 1
1 2
1 3
2 4
3 4
4 5

输出:
0 1 1 2 3 -1

思路:BFS 按「层」扩展,第 1 层是距离 1 的点,第 2 层是距离 2 的点……所以点第一次被访问时的层数就是最短距离。用一个 dis 数组记录层数即可,不需要任何松弛操作。

C++P3-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> e[N];
int dis[N];         // -1 表示未访问

int main() {
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);              // 有向图:只加一条
    }

    memset(dis, -1, sizeof dis);        // 用 -1 同时表示「未访问」
    dis[s] = 0;
    queue<int> q; q.push(s);

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i];
            if (dis[v] != -1) continue;  // 访问过就不再更新
            dis[v] = dis[u] + 1;         // 层数 = 上一层 +1
            q.push(v);
        }
    }

    for (int i = 1; i <= n; i++) printf("%d ", dis[i]);
    return 0;
}
代码逐段解析
L6
用 dis = -1 代替 vis 数组:一个数组同时表达「有没有访问过」和「距离多少」。判断条件是 dis[v] != -1,赋值就是 dis[u] + 1。能合并的状态就合并,代码更短也更快。
L11
有向图只加一条。这题和 P3-1 的最大区别就在这里:无向图加两行,有向图加一行。写错方向会让答案整体偏小(把单行道当成了双向)。
L14-15
起点距离 0,且必须在入队前设好。如果先 push 再设 dis,第一轮扩展时 dis[s] 还是 −1,会算出 dis[邻居] = 0,全盘错位。
L21
「访问过就不再更新」是 BFS 正确性的关键:BFS 保证先被访问的点距离更小,所以一个点第一次被碰到时就已经是最短距离,之后任何路径都不会更优。同样一句 continue 在 Dijkstra 里是 if(vis[u]),在 SPFA 里则没有——三者别混。
L22
dis[v] = dis[u] + 1:dijkstra 里是加边权 w,BFS 里边权恒为 1,所以加 1。如果把 BFS 写成 dis[v] = dis[u] + w 并允许 w ≠ 1,那就是错的——队列不按距离排序,必须先弹完近的才能保证正确。
L27
不可达的点 dis 仍是 −1,直接打印就符合题目要求。如果题目要求输出很大的数(比如 2³¹−1),就把初值改成那个数,但判断条件要跟着改。
这道题是分水岭它把「BFS」和「最短路」连了起来。理解了这一题,你就能明白为什么第 6 章的 Dijkstra 要「每次取最小的」——因为 BFS 的队列天然按距离有序,而带权图里普通队列不再有序,于是用堆把顺序找回来。Dijkstra 本质就是「带权版的 BFS」。
第 4 章

拓扑排序:把依赖关系排成一条线基础 · 常考

「先学 A 才能学 B」「先编译模块 A 才能编译 B」——只要题目里出现「先后顺序」,大概率就是拓扑排序。

4.1DAG 与 AOV 网:有向无环图

DAG(Directed Acyclic Graph,有向无环图):没有回路的有向图。

如果把「任务」当点,「任务 u 必须在 v 之前完成」当有向边 u→v,得到的图叫 AOV 网(Activity On Vertex)。它必须是 DAG,否则说明任务互相依赖、无法开始。

拓扑排序:把 DAG 的所有点排成一个线性序列,使得对每条边 u→v,u 都排在 v 前面。简单说:不破坏先后关系地把图「拉直」。

注意:拓扑序通常不唯一。比如「1→3、2→3」,那么 1 2 3 和 2 1 3 都是合法的拓扑序。

1 2 3 4 5 6 边:1→2、1→3、1→4、2→4、3→4、4→6、5→6 合法拓扑序之一:1 2 3 4 5 6
入度为 0 的点(1、5)可以最先做;每做完一个,就「解锁」它的后继
4.2Kahn 算法:入度 + 队列(最好写)
核心思想 「入度为 0」意味着没有前置任务,现在就能做。做完它,就把它指向的点的入度减 1(相当于「我这项前置完成了」);谁减到 0,谁就解锁入队。
重复这个过程,直到队列为空。
  1. 统计每个点的入度 indeg[v];
  2. 把所有入度为 0 的点入队;
  3. 循环:取队首 u 加入答案 → 枚举 u 的每条出边 u→v,indeg[v]--,若变成 0 则入队;
  4. 循环结束后,若答案里的点数 < n,说明图中有环(存在无法解锁的点)。
C++Kahn 拓扑排序模板(背下来)
const int N = 1e5 + 5;
vector<int> g[N];
int indeg[N];          // 入度
vector<int> topo;      // 拓扑序

bool kahn(int n) {
    queue<int> q;
    for (int i = 1; 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 v : g[u]) {
            if (--indeg[v] == 0)        // 一个前置完成后入度减 1
                q.push(v);              // 所有前置都完成 → 解锁
        }
    }
    return (int)topo.size() == n;       // false 说明有环
}

int main() {
    int n, m; cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        g[u].push_back(v);
        indeg[v]++;                     // 建边时统计入度
    }
    if (!kahn(n)) cout << "Error!" << endl;
    else for (int x : topo) cout << x << " ";
    return 0;
}
代码逐行解析
L1-4
四个成员各司其职:g 存邻接表(有向边 u→v)、indeg 存入度、topo 存结果。topo 声明成全局是为了让 kahn 函数能往里写、主函数能读出来。
L7-9
初始化队列放「所有入度为 0 的点」。这里用 for 扫一遍全图,而不是只放 1 号点——图可能分成多个互不连通的部分,只放一个会漏。
L14
入队的含义是「前置任务已全部完成,可以开始做了」。这个物理含义能帮你理解后面所有变形(字典序、限时、带权)。
L18-20
--indeg[v] == 0 是「删边」的具体动作:u 完成后,u→v 这条依赖被解除,v 的入度减 1。减到 0 说明 v 的所有前置都完成了,可以入队。先减再判,顺序不能反。
L23
用 topo.size() == n 判环,不要用「队列空了」或「有没有入度 0 的点」。只有「排出来的点数等于总点数」才等价于「无环」。返回 false 时说明有环,环上的点永远出不来。
L30-31
indeg[v]++ 必须在建边时同步完成。若忘了这一句,所有点入度都是 0,一开场全部入队,输出的「拓扑序」只是 1..n 的编号顺序,完全不满足依赖。
L33-34
调用后先判返回值再输出。有环时 topo 里只有一部分点,直接打印会得到一串「看起来正常」但不完整的序列,非常容易被忽略。
复杂度
O(n + m):每个点入队出队各一次,每条边在「u 出队时」被访问一次。这是拓扑排序最优的复杂度,没有更快的方法。
▶ Kahn 算法全过程(对照上图)
1统计入度:indeg[1]=0、indeg[2]=1、indeg[3]=1、indeg[4]=3、indeg[5]=0、indeg[6]=2。
入度为 0 的点入队:队列 = {1, 5}
21 出队,加入答案。枚举出边:2 的入度 1→0,3 的入度 1→0,4 的入度 3→2。
2、3 入队。答案 = [1],队列 = {5, 2, 3}
35 出队,加入答案;5→6 使 indeg[6] 从 2 变 1,还不为 0,不入队。
答案 = [1, 5],队列 = {2, 3}
42 出队,加入答案;2→4 使 indeg[4] 从 2 变 1,不入队。
答案 = [1, 5, 2],队列 = {3}
53 出队,加入答案;3→4 使 indeg[4] 从 1 变 0 → 4 入队。
答案 = [1, 5, 2, 3],队列 = {4}
64 出队,加入答案;4→6 使 indeg[6] 从 1 变 0 → 6 入队。
答案 = [1, 5, 2, 3, 4],队列 = {6}
✓6 出队,加入答案。队列为空,答案长度 = 6 = n → 拓扑排序成功。
拓扑序:1 5 2 3 4 6(不唯一)
点击「播放」自动演示,或「单步」自己控制节奏
复杂度每个点入队出队一次、每条边被枚举一次 → O(n + m),非常快。
4.3判环:拓扑排序的副产品

如果图中存在环,环上的点互相等待,入度永远减不到 0,永远不会入队。所以:

判环结论 拓扑排序结束后,若进入答案的点数 < n,则图中有环(有向图);反之是 DAG。
这是判断「有向图是否有环」最常用的方法,复杂度 O(n+m)。
注意这个结论只适用于有向图。无向图判环要用 DFS(记录父节点,遇到访问过的非父节点即有环)或并查集。
4.4字典序最小的拓扑序:把队列换成小根堆

题:输出拓扑序,若有多解输出字典序最小的那个(即尽量让小编号在前)。

关键:队列是先进先出,无法保证「当前可选点中最小的先出」。把 queue 换成小根堆 priority_queue<int, vector<int>, greater<int>>:每次取当前所有入度为 0 的点里编号最小的。

C++字典序最小拓扑序
priority_queue<int, vector<int>, greater<int>> q;   // 小根堆

bool topoMin(int n) {
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);
    int cnt = 0;
    while (!q.empty()) {
        int u = q.top(); q.pop();
        ans[++cnt] = u;
        for (int v : g[u])
            if (--indeg[v] == 0) q.push(v);
    }
    return cnt == n;
}
为什么这样对?在任意时刻,所有入度为 0 的点都可以立即选,为了让字典序最小,当然先选编号最小的那个。选它不会破坏任何依赖关系,所以贪心正确。
常见错误「反向建图 + 大根堆 + 倒序输出」是另一种经典套路(求字典序最小的逆序方案),但对「正序字典序最小」不适用。要正序最小,就用正图 + 小根堆。
4.5进阶应用:DAG 上的 DP(最长路 / 方案数)

DAG 有一个极好的性质:按拓扑序处理时,所有前驱都已经算完了。所以在拓扑序上做 DP,不需要记忆化,也不会重复计算。

模板 设 f[u] 表示「以 u 结尾的最长路径长度」:
f[v] = max(f[v], f[u] + 1)(每经过一条边长度 +1)
按拓扑序枚举 u,用 u 去更新它的所有后继 v,一遍扫完即可,O(n+m)。
C++DAG 最长路(按拓扑序转移)
// 假设已经求出 topo 数组
for (int i = 1; i <= n; i++) f[i] = 1;      // 每个点自己至少长度为 1
for (int u : topo) {
    for (int v : g[u]) {
        f[v] = max(f[v], f[u] + 1);
    }
}
int ans = 0;
for (int i = 1; i <= n; i++) ans = max(ans, f[i]);
例题建模题 · 世界杯

有 N 支球队,每两队之间比赛一场。每支球队给出一个「对手顺序」,要求比赛按这个顺序进行;每天每队最多打一场,求最少需要多少天。

这是 PDF 里的经典例题,难点全在「怎么建成图」。

查看解析

建模:把每一场比赛(而不是球队)当成一个点。共有 N(N−1)/2 场比赛。

若某支球队的对手顺序是 a → b → c,说明「这场 (i,a) 必须在 (i,b) 之前打」,于是从 (i,a) 向 (i,b) 连一条有向边。

建完图后,同一支球队的比赛被串成了一条链,约束全部变成有向边。因为顺序不会自相矛盾,图一定是 DAG。

求什么:最少天数 = DAG 上最长路径的边数 + 1。用上面的拓扑 DP 求最长路即可。

复杂度:点数 O(N²),边数 O(N²),总复杂度 O(N²)。N ≤ 1000 时约 10⁶,可行。

关键一步:把「比赛」抽象成点,而不是把球队当点。这种「以边/事件为点」的建模在图论里很常见。

✍️第 4 章编程题(3 道,全部要写代码)

拓扑排序的模板只有 20 行,但它能解决的题非常多。下面 3 道题分别对应「判环」「字典序」「DAG 上 DP」三种典型考法。

P4-1

课程表:能否修完所有课

入门
输入 n m + m 条依赖输出 YES / NOn,m ≤ 2×10⁵

共有 n 门课,给出 m 条先修关系 u v(学 v 之前必须先学 u)。判断是否存在一种顺序,能把这 n 门课全部学完。

样例
输入:
4 4
1 2
2 3
3 4
4 2

输出:
NO

说明:2→3→4→2 构成环,这 3 门课互相依赖,永远学不完

思路:跑一遍 Kahn 算法,统计成功弹出的点数 tot。若 tot == n 说明无环,否则环上那些点的入度永远减不到 0。

C++P4-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> e[N];
int indeg[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);
        indeg[v]++;                     // 建图时顺便统计入度
    }

    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);   // 所有「没有前置」的点先入队

    int tot = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        tot++;
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i];
            if (--indeg[v] == 0) q.push(v);   // 前置全部完成,可以学了
        }
    }

    puts(tot == n ? "YES" : "NO");
    return 0;
}
代码逐段解析
L9-11
建图 + 入度一起做:e[u].push_back(v) 记边,indeg[v]++ 记入度。入度不能事后单独算——那样要再扫一遍所有边,白费一次 O(m)。
L15-16
初始队列放的是所有入度为 0 的点,而不是只有 1 号点。图可能由多个互不相连的部分组成,只放一个点会漏掉其他部分,tot 永远小于 n,误判成有环。
L21
tot 数的是「成功弹出的点数」,不是边数、也不是轮数。它就是被排进拓扑序的点个数,最终与 n 比较。
L25
if (--indeg[v] == 0) 把「减一」和「判断」合成一句。这里必须先减再判,顺序不能反;写成 if(indeg[v]-1==0) indeg[v]--; 虽然也对,但两句容易写漏一句。
L25
为什么减完是 0 才能入队:入度表示「还有几个前置没完成」。减到 0 意味着 所有前置都完成了,这一刻才可以开始学它。这就是拓扑排序的物理含义。
L29
判环只需一句比较。如果图里有环,环上的点入度互相"供养",永远减不到 0,它们不会入队,tot < n——这就是拓扑排序判环的全部原理。
不要用「有没有点入度为 0」来判环这种想法是错的:一个图可以完全无环但初始没有入度 0 的点吗?不可能(DAG 一定有入度 0 的点);但反过来,有入度 0 的点也不代表无环。唯一可靠的判据是 tot == n。
P4-2

字典序最小的拓扑序

基础
输入 n m + m 条有向边输出 字典序最小的拓扑序n,m ≤ 2×10⁵

给定一个 DAG,输出字典序最小的拓扑序。即:在所有合法顺序里,第一个数尽量小;第一个数相同时,第二个数尽量小……以此类推。

样例
输入:
5 4
1 3
2 3
3 4
3 5

输出:
1 2 3 4 5

说明:1 和 2 都无前置,但 1 < 2 所以先出 1;剩下的 2 也满足条件,接着出 2

关键改动只有一行:把普通 queue 换成小根堆 priority_queue<int, vector<int>, greater<int>>。这样每次取出的都是「当前可选的、编号最小的」点,贪心结果就是字典序最小。

C++P4-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> e[N];
int indeg[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v); indeg[v]++;
    }

    // 小根堆:每次取编号最小的可选项
    priority_queue<int, vector<int>, greater<int> > q;
    for (int i = 1; i <= n; i++) if (indeg[i] == 0) q.push(i);

    while (!q.empty()) {
        int u = q.top(); q.pop();
        printf("%d ", u);
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i];
            if (--indeg[v] == 0) q.push(v);
        }
    }
    return 0;
}
代码逐段解析
L14
小根堆的三个模板参数:元素类型 int、底层容器 vector<int>、比较器 greater<int>。greater 表示「大的排后面」,于是堆顶最小。大于号后面必须空一格再写 > >,否则 >> 会被当成右移运算符——C++98 时代的经典坑(C++11 起已允许连写)。
L15
初始把全部入度 0 的点压进堆。堆会自动把它们按编号排好,不需要手动 sort。
L18-19
top() + pop() 取最小值,与普通队列的 front() 不同。堆没有 front,用错会编译失败。
L23
弹出后立刻输出,而不是收集到数组里再输出。拓扑序只要边算边打就够了,省一个数组。
L14
复杂度:每次堆操作 O(log n),总共 n 次入堆、n 次出堆,加上遍历 m 条边,合计 O((n+m) log n)。比普通队列的 O(n+m) 慢一点,但数据规模 2×10⁵ 完全够。
为什么贪心是对的(简单说)当前所有入度 0 的点,谁先谁后都不违反任何约束。既然都合法,要让字典序最小,当然选编号最小的那个。选完以后图的状态变了,但「在当前可选项里挑最小」这个逻辑依然成立——所以每步都取最小就能得到全局最优。
P4-3

工程最短完工时间(DAG 最长路)

进阶
输入 n m + 点权 + m 条依赖输出 最短完工时间n ≤ 10⁴,m ≤ 10⁵

有 n 项工程,第 i 项耗时 t[i]。给出 m 条依赖 u v(u 完成后 v 才能开始)。工程可以并行进行,求整个项目的最短完工时间。

样例
输入:
5 4
3 2 4 1 5
1 3
2 3
3 4
3 5

输出:
12

说明:1(3) 与 2(2) 并行;3 要等两者都完成,从时刻 3 开始跑到 7;
      4 从 7 跑到 8,5 从 7 跑到 12。最长链 1→3→5 = 3+4+5 = 12

思路:答案是「最长的那条依赖链」,即 DAG 上的最长路。设 f[v] 为「以 v 结尾的最长耗时」,转移为 f[v] = max(f[v], f[u] + t[v])。必须按拓扑序递推,才能保证算 f[v] 时所有前驱已经算好。

C++P4-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> e[N];
int indeg[N], t[N], f[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &t[i]);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v); indeg[v]++;
    }

    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) { f[i] = t[i]; q.push(i); }   // 无前置的,自己就是起点

    int ans = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        ans = max(ans, f[u]);
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i];
            f[v] = max(f[v], f[u] + t[v]);   // 所有前驱里取最大
            if (--indeg[v] == 0) q.push(v);
        }
    }

    printf("%d\n", ans);
    return 0;
}
代码逐段解析
L7
三个数组分工:t[i] 点权(耗时)、f[i] DP 值(以 i 结尾的最长耗时)、indeg[i] 入度。DP 值必须开在全局并初始化为 0,否则 max 会读到随机值。
L17-18
入度 0 的点 f[i] = t[i]:它没有前置,可以从时刻 0 直接开工,所以最早完成时间就是自己的耗时。这一步是 DP 的边界条件,漏了全盘皆 0。
L22
ans = max(ans, f[u]) 在出队时更新,而不是最后输出 f[n]。因为最长链未必结束在编号最大的点上——这是本题最容易错的一处。
L26
f[v] = max(f[v], f[u] + t[v]):v 的前驱可能有很多个,v 必须等最慢的那个完成,所以取 max 而不是 min。求最短路用 min,求工期/最长路用 max,一字之差题意完全不同。
L26
为什么加的是 t[v] 而不是 t[u]:f[u] 已经包含 u 自己的耗时,我们正在算 v 的完成时刻,所以再加 v 的耗时。如果题目是边权(u→v 耗时 w),就改成 f[u] + w。
L27
DP 更新必须放在「入度减到 0 之前」。因为 f[v] 需要所有前驱都贡献过一次才能定下来,而 v 只有在入度归零时才入队——先更新再判入队,正好保证这一点。
这一类题的通用套路DAG 上的 DP = 拓扑排序 + 按序递推。
· 最长路 / 关键路径:f[v] = max(f[u] + w)
· 路径条数:cnt[v] = Σ cnt[u]
· 最小字典序路径:拓扑序 + 贪心
只要看到「有依赖关系」又要求「某种最优值」,就先想拓扑 + DP,几乎不会错。
第 5 章

Floyd:任意两点之间的最短路基础 · 代码最短的算法

Floyd 只有 5 行,却是图论里性价比最高的算法:一次跑完,得到所有点对的距离。n ≤ 500 的多源最短路题,基本就是它。

5.1它解决什么问题?

多源最短路(全源最短路):给定一张带权图,求任意两点 i、j 之间的最短距离。

如果对每个点各跑一次 Dijkstra,复杂度 O(n·m log m),稠密图上很慢。Floyd 直接用 O(n³) 一次算完所有点对,而且允许负权边(但不能有负环)。

适用条件n ≤ 500(O(n³) = 1.25×10⁸,勉强能过);n ≤ 300 更稳。n 上千就必须改用「每个点跑一次 Dijkstra」。
5.2核心思想:动态规划「允许经过哪些中转点」

定义 f[k][i][j] = 从 i 到 j,且只允许经过编号 1..k 的点作为中转时的最短距离。

考虑第 k 个点用不用:

  • 不用 k 中转:f[k−1][i][j]
  • 用 k 中转:f[k−1][i][k] + f[k−1][k][j](i 先到 k,再从 k 到 j)

取较小值:

f[k][i][j] = min( f[k−1][i][j] , f[k−1][i][k] + f[k−1][k][j] )

因为第 k 层只依赖第 k−1 层,可以把 k 这一维滚掉,直接在二维数组上原地更新,得到最终的三重循环。

C++Floyd 标准模板(5 行核心)
const int N = 505;
const int INF = 0x3f3f3f3f;
int dis[N][N];

void floyd(int n) {
    for (int k = 1; k <= n; k++)          // 阶段:允许经过 1..k 中转
        for (int i = 1; i <= n; i++)      // 起点
            for (int j = 1; j <= n; j++)  // 终点
                if (dis[i][k] != INF && dis[k][j] != INF)
                    dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
}
为什么加 if 判断?INF + INF 在 int 里会溢出成负数,可能导致错误更新。加一句判空更安全;也可以用 long long + 更大的 INF。
5.3初始化:90% 的 Floyd 错误都出在这一步
C++正确的初始化顺序
const int INF = 0x3f3f3f3f;   // 约 1.06e9,两个相加不爆 int
int dis[N][N];

int main() {
    int n, m; cin >> n >> m;

    // 1) 先把所有点对置为“不可达”,自己到自己为 0
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            dis[i][j] = (i == j ? 0 : INF);

    // 2) 再读边(注意重边取 min!)
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        dis[u][v] = min(dis[u][v], w);
        dis[v][u] = min(dis[v][u], w);   // 无向图
    }

    floyd(n);
}
三个必错点
  • 忘记 dis[i][i] = 0 → 自己到自己变成 INF
  • 重边直接赋值 dis[u][v] = w → 后面的大权值覆盖了小权值
  • 用 memset(dis, 0x3f, ...) 后忘记把对角线清零
INF 怎么选
  • 0x3f3f3f3f ≈ 1.06×10⁹,两两相加 ≈ 2.1×10⁹,仍在 int 范围内
  • 不要用 INT_MAX(2.1×10⁹),加一下就溢出
  • 边权很大时(≥10⁹)改用 long long 和 1e18
5.4深度辨析:k 为什么必须在最外层?

这是 Floyd 最容易被问、也最容易写错的地方。看看把 k 放到最内层会发生什么:

C++❌ 错误写法(k 在最内层)
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= n; j++)
        for (int k = 1; k <= n; k++)
            dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
从 DP 的「阶段」理解 Floyd 是一个区间 DP,它的阶段是 k(「允许使用哪些中转点」),i、j 是状态。
DP 的铁律:外层循环必须是阶段,内层才是状态和转移。

当 k 在最外层时,进入第 k 轮之前,dis[i][j] 已经是「只用 1..k−1 中转」的最优解,所以 dis[i][k]、dis[k][j] 也都是这一阶段的正确值,转移成立。

当 k 在最内层时,处理 (i,j) 这一对时,dis[i][k] 或 dis[k][j] 可能还没有被本轮(甚至后面几轮)优化过,用的是「半成品」数据,结果可能偏大。

记忆口诀「k 在最外,i j 在里;k 若进内,答案必错。」写 Floyd 前先默念一遍。
顺便:为什么 i、j 的顺序可以互换?

因为 dis[i][j] 与 dis[j][i] 相互独立(各自用自己的状态转移),i、j 谁在外层不影响第 k 阶段的完整性。但 k 必须在最外层。

5.5手算演示:看矩阵一步步变小

图:4 个点,边 1-2(3)、1-3(10)、2-3(4)、2-4(1)、3-4(2)。初始矩阵如下(INF 用 ∞ 表示):

初始(只允许直接边)
1234
10310∞
23041
310402
4∞120

1-3 有直接边,权值 10,但显然绕 2 更短(3+4=7),Floyd 会把它优化掉。

k = 2 之后(允许经过 2)
1234
1033+4=73+1=4
23041
34+3=7402
41+3=4120

1→3 变成 7(1→2→3),1→4 变成 4(1→2→4)。

k = 3 之后(允许经过 3)
1234
10377+2=9?
23041
37402
44120

1→4 尝试 1→3→4 = 7+2 = 9,但已经是 4(更小),所以保持不变。这说明 Floyd 只会「变小」,不会「变坏」。

最终答案1 到 4 的最短路是 1→2→4,长度 4;1 到 3 是 1→2→3,长度 7。所有点对一次算完。
5.6两个高频变式
变式 1:传递闭包(只问「能否到达」)

把「最小距离」换成「布尔值」,min 换成 or,+ 换成 and:

C++
for (int k = 1; k <= n; k++)
  for (int i = 1; i <= n; i++)
    for (int j = 1; j <= n; j++)
      if (reach[i][k] && reach[k][j])
        reach[i][j] = 1;

用来求「两点间是否连通」「图的连通性」等。

变式 2:最小环

枚举中转点 k 时,先用「只经过 1..k−1」的路径拼出一个经过 k 的环:

C++
for (int k = 1; k <= n; k++) {
  for (int i = 1; i < k; i++)
    for (int j = i + 1; j < k; j++)
      ans = min(ans, dis[i][j] + w[i][k] + w[k][j]);
  // 再执行正常的 Floyd 转移
}

关键:先算环,再更新 dis,否则会用同一条边两次。

✍️第 5 章编程题(3 道,全部要写代码)

Floyd 的代码只有 5 行,但「循环顺序」「初始化」「INF 选择」三个坑能让人 WA 一整场。下面 3 道题分别练最短路、传递闭包、最小环。

P5-1

多组询问的任意两点最短路

入门
输入 n m + m 条边 + q 次询问输出 每次询问的最短路n ≤ 300,q ≤ 10⁴

n 个点、m 条无向带权边(权值为正),有 q 次询问,每次给 u v,求 u 到 v 的最短距离,不可达输出 -1。

样例
输入:
4 4
1 2 3
2 3 4
1 3 10
3 4 2
3
1 3
1 4
2 4

输出:
7
9
6

为什么选 Floyd:n ≤ 300 → n³ = 2.7×10⁷,一秒内轻松跑完;而「多次询问任意两点」正是 Floyd 的主场——预处理一次,之后每次询问 O(1)。若改用 Dijkstra 跑 n 次,是 O(n·m log m),在稠密图上反而更慢。

C++P5-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 305, INF = 0x3f3f3f3f;
int dis[N][N];

int main() {
    int n, m; scanf("%d%d", &n, &m);

    memset(dis, 0x3f, sizeof dis);           // ① 全部置 INF
    for (int i = 1; i <= n; i++) dis[i][i] = 0;   // ② 自己到自己为 0

    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        dis[u][v] = min(dis[u][v], w);       // ③ 重边取最小
        dis[v][u] = min(dis[v][u], w);       // 无向图双向
    }

    for (int k = 1; k <= n; k++)             // k 必须在最外层!
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                if (dis[i][k] + dis[k][j] < dis[i][j])
                    dis[i][j] = dis[i][k] + dis[k][j];

    int q; scanf("%d", &q);
    while (q--) {
        int u, v; scanf("%d%d", &u, &v);
        printf("%d\n", dis[u][v] == INF ? -1 : dis[u][v]);
    }
    return 0;
}
代码逐段解析
L8
memset(dis, 0x3f, sizeof dis) 把每个字节填成 0x3f,一个 int 四个字节就变成 0x3f3f3f3f = 1061109567。它有两个好处:足够大(约 10⁹)、且两个相加不会超过 int 上限(2×10⁹ < 2.1×10⁹),所以 dis[i][k] + dis[k][j] 不会溢出。
L9
dis[i][i] = 0 绝不能漏。漏了会怎样?自己到自己变成 INF,于是任何经过自己中转的路径都不会被采用,甚至 dis[i][k] + dis[k][i] 这种「走一圈回来」的更新会把矩阵改得面目全非。
L13-14
重边要取 min,不能直接赋值。同一对点之间可能输入多条边,直接 dis[u][v] = w 会让最后输入的那条生效,而不是最小的那条。
L17
k 在最外层是 Floyd 的正确性前提。这里的 k 不是「循环变量」,而是 DP 的阶段:dis[k][i][j] 表示「只允许经过编号 ≤ k 的点时 i 到 j 的最短距离」。算第 k 阶段必须先有完整的第 k−1 阶段,所以 k 只能放最外层。
L20-21
松弛式 dis[i][k] + dis[k][j] < dis[i][j] 的物理含义:从 i 先到 k、再从 k 到 j,若比现在更短就更新。这就是「绕一下 k 会不会更划算」。
L26
输出时判 == INF。注意只能判等号,不能判 >= INF——因为 INF 被赋值过之后不会再变,用等号最精确。
性能小优化内层可以用 int d = dis[i][k]; 先把值取出来,避免每次访问二维数组:if (d + dis[k][j] < dis[i][j]) dis[i][j] = d + dis[k][j];。n = 300 时差别不大,n = 500 时能快 20% 左右。另外若 d == INF 可直接 continue 跳过整行。
P5-2

传递闭包:谁能到达谁

基础
输入 n m + m 条有向边输出 每点可达的点数n ≤ 300

给定有向图,对每个点 i 求「从 i 出发能到达多少个不同的点」(不含 i 自己)。

样例
输入:
4 3
1 2
2 3
4 3

输出:
2 1 0 1

说明:1 能到 {2,3};2 能到 {3};3 谁也到不了;4 能到 {3}

思路:把「距离」换成「能否到达」,Floyd 就能算传递闭包:r[i][j] = true 表示 i 能到 j,转移式变成 r[i][j] |= r[i][k] & r[k][j]。n 只有 300,O(n³) = 2.7×10⁷,完全够。

C++P5-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 305;
bool r[N][N];       // r[i][j]:i 能否到达 j

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        r[u][v] = true;                 // 有向边
    }

    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            if (r[i][k])                // 剪枝:i 到不了 k 就不用试
                for (int j = 1; j <= n; j++)
                    if (r[k][j]) r[i][j] = true;

    for (int i = 1; i <= n; i++) {
        int cnt = 0;
        for (int j = 1; j <= n; j++)
            if (j != i && r[i][j]) cnt++;
        printf("%d ", cnt);
    }
    return 0;
}
代码逐段解析
L4
用 bool 二维数组,只关心「通不通」不关心「多远」。空间 n² = 9×10⁴ 字节,几乎不占内存。
L9
只标记 r[u][v] = true,不给对角线置真。这题的「可达」不含自己,如果给 r[i][i] 置真,最后统计时要额外减去。两种做法都对,关键是想清楚自己要哪一种。
L12-13
k 依然在最外层,理由和对角线一样:这是 DP 的阶段,不是普通循环。
L14
if (r[i][k]) 是重要剪枝:i 都到不了 k,就别指望「i→k→j」了,直接跳过整个内层循环。在稀疏的可达图上这一句能省掉 80% 以上的无效循环。
L16
只写 r[i][j] = true,不需要 |=。因为一旦为真就永远为真,直接赋值即可。(若用整型数组则要写 r[i][j] |= r[i][k] & r[k][j]。)
L20
j != i 排除自己。如果题目改成「含自己」,把这个条件删掉即可——先看清题目要求再写统计。
数据更大怎么办n = 2000 时 O(n³) = 8×10⁹ 就超时了。此时用 bitset<2000> r[2000],转移写成 if (r[i][k]) r[i] |= r[k];——一次操作处理 64 位,复杂度降到 O(n³/64) ≈ 1.25×10⁸,就能过了。这是「传递闭包」的标准优化写法。
P5-3

最小环

进阶
输入 n m + m 条无向边输出 最小环长度n ≤ 300

求无向带权图中权值和最小的环。环至少要包含 3 个点(重边构成的 2 点环按题目约定通常不算)。若没有环输出 -1。

样例
输入:
4 5
1 2 1
2 3 1
3 1 1
3 4 1
4 1 5

输出:
3

说明:1-2-3-1 权值和 1+1+1 = 3;1-3-4-1 是 1+1+5 = 7。取 3

思路(Floyd 的经典副产品):在 Floyd 进行到第 k 轮之前,dis[i][j] 表示「只经过编号 < k 的点」时 i 到 j 的最短路。此时选一条 i→k→j 的路径再接上 dis[i][j],就构成一个以 k 为最大编号点的环,长度为 dis[i][j] + g[i][k] + g[k][j]。把所有 k、i、j 都枚举一遍就能拿到最小环。

C++P5-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 305, INF = 0x3f3f3f3f;
int g[N][N], dis[N][N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    memset(g, 0x3f, sizeof g);
    for (int i = 1; i <= n; i++) g[i][i] = 0;

    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u][v] = min(g[u][v], w);
        g[v][u] = min(g[v][u], w);
    }
    memcpy(dis, g, sizeof g);            // dis 是 g 的副本,会不断被优化

    int ans = INF;
    for (int k = 1; k <= n; k++) {
        // 第一步:用「不经过 k」的最短路 + 两条到 k 的边,拼出环
        for (int i = 1; i < k; i++)
            for (int j = i + 1; j < k; j++)
                if (g[i][k] != INF && g[k][j] != INF && dis[i][j] != INF)
                    ans = min(ans, dis[i][j] + g[i][k] + g[k][j]);

        // 第二步:再让 k 参与最短路更新
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                if (dis[i][k] + dis[k][j] < dis[i][j])
                    dis[i][j] = dis[i][k] + dis[k][j];
    }

    printf("%d\n", ans == INF ? -1 : ans);
    return 0;
}
代码逐段解析
L7-8
两个矩阵各司其职:g 存原始边(永不被修改,用来取 g[i][k] 这条真实边),dis 存最短路(会被 Floyd 不断优化)。如果只用一个矩阵,取 g[i][k] 时可能拿到一条「绕路」的路径而不是直连边,环就拼错了。
L15
memcpy(dis, g, sizeof g) 复制整个二维数组。不能写 dis = g(数组不能赋值),也不能只复制有效区域(用 sizeof 最简单不易错)。
L19-22
必须「先算环,再更新 dis」。若顺序反了,dis[i][j] 已经可能经过了 k,再拼 i→k→j 就会重复经过同一个点,拼出来的不是简单环。
L20-21
i < k、j < k 的循环界:我们约定「k 是环上编号最大的点」,所以另外两个点只能小于 k。同时 j 从 i+1 开始,避免同一个环被算两次(i→j 和 j→i)。
L22
三段式 dis[i][j] + g[i][k] + g[k][j]:中间一段是 i 到 j 的最短路(不经过 k),两头各接一条真实边。三处都要判 != INF,否则 INF 相加会溢出成负数,反而被当成最小值选中。
L33
ans == INF 说明一个环都没找到。无环图(比如一棵树)就会走到这里,按题目要求输出 −1。
这题的价值它展示了 Floyd 的一个通用用法:算法中间状态本身就携带信息。第 k 轮的 dis 表示「只允许前 k−1 个点作中转」,这个「阶段性」正好可以用来构造「以 k 为最大点」的结构。类似技巧还有「Floyd 求最小环」「Floyd 求恰好 k 条边的最短路(矩阵快速幂)」。
第 6 章

Dijkstra:单源最短路的主力重点 · 必默写

Floyd 求「任意两点」,但复杂度 O(n³) 只扛得住 n ≤ 500。真实题目动辄 n = 10⁵,这时必须换 Dijkstra:只从一个起点出发,O(m log m) 跑完。它是 CSP-J/S 复赛出现频率最高的图论算法,没有之一。

6.1单源最短路:先明确「源」是谁

单源最短路(Single Source Shortest Path, SSSP):给定一个起点 s(叫「源点」),求 s 到每一个其他点的最短距离。

算法解决什么复杂度负权边典型数据范围
Floyd任意两点O(n³)可以n ≤ 500
Dijkstra 朴素单源O(n²)不行n ≤ 5000,稠密图
Dijkstra 堆优化单源O(m log m)不行n ≤ 10⁵,稀疏图
Bellman-Ford单源O(nm)可以n·m ≤ 10⁷
SPFA单源 + 判负环平均 O(km),最坏 O(nm)可以随机数据
选型口诀边权全非负 → 优先 Dijkstra;有负权边 → 只能 Bellman-Ford / SPFA;n ≤ 500 且要全部点对 → Floyd。三条线记住,比赛时 90% 的最短路题都能一眼定算法。
注意「最短」在这里指边权之和最小,不是边数最少。如果边权全是 1,那 Dijkstra 和 BFS 结果一样——此时直接用 BFS 更快(O(n+m) 且不用堆),别杀鸡用牛刀。
6.2贪心思想:把点分成「已确定」和「未确定」两堆

Dijkstra 的核心只有一个信念:

核心信念在所有还没确定的点里,dis 值最小的那个点,它的最短路已经确定了,不可能再被别的路径改小。

为什么敢这么说?因为边权非负。假设当前 dis[u] 是所有未确定点里最小的。如果还存在一条更短的路径能从 s 到 u,那它必须先经过某个未确定的点 x,而 dis[x] ≥ dis[u],再加上从 x 到 u 的边权 w ≥ 0,总长 ≥ dis[u],不可能更短。矛盾。

所以每个点一旦被「选中」,它的 dis 就永久固定,可以放心拿它去更新别人。这正是贪心 + 松弛(relax) 的组合。

S 集合(已确定最短路) s a b T 集合(还没确定) c d e 选 dis 最小的拉进来 已确定的点,永远不会再被改小
Dijkstra 每轮:从 T 里取出 dis 最小的点 → 加入 S → 用它松弛 T 中的邻居
6.3朴素实现:邻接矩阵 + 两重循环 O(n²)

先看慢但最好懂的版本。适合 n ≤ 5000 的稠密图(边数接近 n²)。

C++朴素 Dijkstra(邻接矩阵,O(n²))
#include <bits/stdc++.h>
using namespace std;
const int N = 5005;
const int INF = 0x3f3f3f3f;
int n, m, s;
int g[N][N];    // 邻接矩阵,g[u][v] = 边权,无边为 INF
int dis[N];     // dis[i]:s 到 i 的当前最短距离
bool vis[N];    // vis[i]:i 是否已进入 S 集合

void dijkstra(int s) {
    memset(dis, 0x3f, sizeof dis);   // 全部置为 0x3f3f3f3f
    dis[s] = 0;                      // 起点到自己是 0,千万别漏!

    for (int i = 1; i <= n; i++) {    // 循环 n 轮,每轮确定一个点
        // 第 1 步:在未确定的点里找 dis 最小的那个 u
        int u = -1;
        for (int j = 1; j <= n; j++)
            if (!vis[j] && (u == -1 || dis[j] < dis[u])) u = j;

        if (u == -1 || dis[u] == INF) break;  // 剩下的点都不可达,提前结束
        vis[u] = true;               // 第 2 步:u 的最短路确定了,加入 S

        // 第 3 步:用 u 松弛所有邻居
        for (int v = 1; v <= n; v++) {
            if (g[u][v] != INF && dis[u] + g[u][v] < dis[v])
                dis[v] = dis[u] + g[u][v];
        }
    }
}

int main() {
    scanf("%d%d%d", &n, &m, &s);
    memset(g, 0x3f, sizeof g);       // 矩阵初始化成 INF
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u][v] = min(g[u][v], w);   // 有重边时取最小的那条
        g[v][u] = min(g[v][u], w);   // 无向图才要这一行!有向图删掉
    }
    dijkstra(s);
    for (int i = 1; i <= n; i++)
        printf("%d ", dis[i] == INF ? -1 : dis[i]);
    return 0;
}
代码逐行解析
L3-4
两个常量要先定:N 是点上限,INF 取 0x3f3f3f3f(约 1.06×10⁹)。为什么不用 1e9?因为 memset 只能按字节填充,只有「每字节 0x3f」才能用 memset 一次性刷出这个值。
L6-8
三个全局数组:g 邻接矩阵(n² 个 int,n=5000 时约 100MB,这已是邻接矩阵的上限)、dis 当前最短距离、vis 是否已确定。全局数组默认全 0,所以 dis 必须手动刷 INF。
L11-12
初始化两件事,缺一不可:memset(dis, 0x3f) 把所有点设为「不可达」,dis[s] = 0 把起点设为 0。第二句写成 dis[1] = 0 是最常见的错——起点不是 1 号点时全错。
L14
外层循环 n 轮,每轮确定一个点。理论上少于 n 轮也可能结束(剩下的都不可达),所以循环内部有提前 break。
L16-18
找最小:u == -1 || 这个短路条件很关键。第一轮 u 是 −1,直接取 j;之后才有 dis[u] 可比较。写成 dis[j] < dis[u] 而不加保护,第一次就会访问 dis[-1],读越界内存。
L18
!vis[j] 不能省。省了的话已确定的点会一直被选中,算法在几个点之间原地打转,永远出不来。
L20
提前退出:dis[u] == INF 说明剩下的点全都不可达,继续跑没有意义。这一句能省时间,也能避免 INF + w 溢出。
L21
vis[u] = true 就是「把 u 放进 S 集合」,代表它的最短路已永久确定。整个算法的贪心就体现在这一句。
L24-26
松弛两个条件都要判:g[u][v] != INF(确实有边)且 dis[u]+g[u][v] < dis[v](绕 u 更近)。只判后者时,无边的情况会算出 INF + INF 溢出成负数,把 dis 改成一个荒谬的小值。
复杂度
O(n²):外层 n 轮,内层找最小 O(n) + 松弛 O(n)。与边数无关,所以边很密(m ≈ n²)时它反而比堆优化快(常数小)。
✅ 四个必须记住的细节
  • dis[s] = 0:漏了就全 INF,一题全 WA
  • dis 整体先置 INF:memset 0x3f 恰好是 0x3f3f3f3f
  • 重边取 min:g[u][v] = min(g[u][v], w),直接赋值会取到后输入的那条
  • vis[u] 判定:只有没确定的点才参与「找最小」
❌ 三个高频错误
  • 把 dis[s]=0 写成 dis[1]=0(起点不是 1 就错)
  • 有向图也写了 g[v][u] 反向边,答案偏小
  • dis[u] == INF 不判断,INF + w 溢出成负数

复杂度:外层 n 轮,内层两次 n——找最小 O(n)、松弛 O(n),合计 O(n²)。与边数无关,所以稠密图(m ≈ n²)非常划算;但稀疏图(m ≈ n)就白白浪费了。

6.4手算一遍:五个点跑完 Dijkstra

下面这张无向图,求 1 号点到所有点的最短距离。请拿纸笔跟着推一遍,这是理解 Dijkstra 最快的方式。

1 2 3 4 5 2 5 1 6 2 8 3
起点 1;边权如图。所有边权非负 → 可以用 Dijkstra
Dijkstra 逐轮推演
0
初始化 dis = [0, ∞, ∞, ∞, ∞],S = { }
1
选 1(dis=0)→ S={1}。松弛:dis[2]=0+2=2,dis[3]=0+5=5。 dis = [0, 2, 5, ∞, ∞]
2
选 2(dis=2,最小)→ S={1,2}。松弛:2→3 得 2+1=3 < 5,dis[3] 改为 3;2→4 得 2+6=8。 dis = [0, 2, 3, 8, ∞]
3
选 3(dis=3)→ S={1,2,3}。松弛:3→4 得 3+2=5 < 8,dis[4] 改为 5;3→5 得 3+8=11。 dis = [0, 2, 3, 5, 11]
4
选 4(dis=5)→ S={1,2,3,4}。松弛:4→5 得 5+3=8 < 11,dis[5] 改为 8。 dis = [0, 2, 3, 5, 8]
5
选 5(dis=8)→ S 满。没有可松弛的点了。
最终答案:1→2 = 2,1→3 = 3,1→4 = 5,1→5 = 8
✓
回看 dis[3] 经历了 5 → 3,dis[5] 经历了 11 → 8。每次「发现更短就走它」就是松弛操作,整个 Dijkstra 就是不断松弛直到无法更优。
点击「播放」自动演示,或「单步」自己控制节奏
轮次dis[1]dis[2]dis[3]dis[4]dis[5]本轮选中
初始0∞∞∞∞—
1025∞∞1
20238∞2
30235113
4023584
5023585

绿色 = 已确定(进 S),粉色 = 本轮被松弛改小的值,黄色 = 本轮选中的点。

6.5堆优化:把「找最小」从 O(n) 降到 O(log m)

朴素版最耗时的部分是「在未确定的点里扫一遍找最小」,每轮 O(n),总共 O(n²)。但如果我们用一个小根堆(优先队列)把候选点按 dis 排序,取最小就变成 O(1),插入 O(log m)。

配套必须换成 邻接表——因为松弛时只需要遍历真实存在的边,不再扫 n 个不存在的点。总复杂度 O(m log m),n = 10⁵、m = 2×10⁵ 轻松跑过。

C++堆优化 Dijkstra(邻接表 + priority_queue,O(m log m))
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
const int INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];      // e[u] 存 u 的所有出边

int dis[N];
bool vis[N];          // vis[u]:u 是否已经从堆里「正式取出」过

void dijkstra(int s, int n) {
    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;

    // 小根堆:pair 默认先比 first,所以必须把 dis 放在 first
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> q;
    q.push({0, s});                 // {距离, 点编号}

    while (!q.empty()) {
        int u = q.top().second;
        q.pop();

        if (vis[u]) continue;       // 关键!同一个点可能入堆多次,旧的直接扔掉
        vis[u] = true;

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                q.push({dis[v], v});   // 不删旧值,直接塞新的
            }
        }
    }
}
代码逐行解析
L6-7
换成邻接表的原因:朴素版松弛时要扫 n 个点(大部分没边),浪费;邻接表只遍历真实存在的边,总遍历量从 O(n²) 降到 O(m)。vector<Edge> e[N] 就是「N 个可变长列表」,写法比前向星简单,速度略慢但够用。
L10
vis 在堆优化里的语义变了:不再是「在 S 集合里」,而是「这个点已经正式处理过了」。因为堆里会有重复记录,需要这个标记来跳过旧的。
L17
小根堆的三个模板参数:pair<int,int>、底层 vector、比较器 greater。pair 默认先比 first,所以必须把距离放 first、点编号放 second,写反了就变成按编号排序,堆彻底失效。
L18
只把起点压进去。与 SPFA 不同,Dijkstra 的堆里只放「已经算出距离」的点,不需要预先放所有点。
L21-22
取 second 当点编号,因为 first 是距离。q.top() 取堆顶(最小值),q.pop() 之后再访问 top() 就已经是下一个了——这两句顺序不能反。
L24
if (vis[u]) continue; 是整个堆优化版本的灵魂。同一个点可能被 push 多次(每次变优都塞一条),旧的那条弹出来时必须扔掉。删掉这一句,代码不但变慢,还可能用过期距离去松弛邻居,答案出错。
L27-28
遍历邻接表:e[u].size() 要转 (int) 避免无符号比较警告;e[u][i].to 和 .w 分别取终点和边权。
L29-31
松弛成功后 push 新距离,注意压的是 dis[v](刚更新后的值),不是 dis[u]+w 的表达式——虽然数值相同,但读 dis[v] 更不容易写错。
L17 vs L24
为什么有 vis 还需要「不删旧值」:堆不支持删除任意元素,所以采用「懒惰删除」——旧值留在堆里,靠 vis 在弹出时过滤。这是标准做法,总入堆次数 ≤ m,复杂度 O(m log m)。
为什么可以不删旧值堆里可能同时存在 {8, 3} 和 {5, 3} 两条记录。因为是小根堆,{5,3} 一定先弹出,此时 vis[3]=true;等 {8,3} 弹出来时被 if (vis[u]) continue; 直接跳过。所以 vis 判重不是可选项,是必须的——去掉它,算法不仅变慢,还可能把已经确定的值改错。
💡 小根堆的两种写法
  • priority_queue<pair<int,int>, vector<...>, greater<...>>
  • 或者存负距离:q.push({-dis[v], v}),用默认大根堆
  • 自定义结构体要重载 operator<,注意方向是反的
⚠️ 数据范围一大就要 long long
  • 边权 ≤ 10⁹、n ≤ 10⁵ 时,最短路最长可达 10¹⁴
  • int 上限约 2.1×10⁹,会溢出成负数
  • 此时 dis 换 long long,INF 换 0x3f3f3f3f3f3f3f3f
6.6为什么 Dijkstra 容不下负权边?

6.2 的证明用了一句话:「从 x 到 u 的边权 w ≥ 0」。如果把这条前提拿掉,整个贪心就塌了。看下面这个反例。

A B C D 1 2 -3 1 5
有向图,起点 A。C→B 是一条权值 −3 的负权边

正确答案:A→B 走 A→C→B = 2 + (−3) = −1,比直接走 A→B 的 1 更短;A→D 走 A→C→B→D = −1 + 1 = 0,比 A→B→D 的 2、A→C→D 的 7 都短。

Dijkstra 会怎么算:

  1. 初始化 dis[A]=0,dis[B]=1,dis[C]=2
  2. 未确定点里最小的是 B(dis=1),于是 vis[B]=true,B 被永久确定成 1
  3. 用 B 松弛:dis[D] = 1+1 = 2
  4. 选 C(dis=2),尝试松弛 B:2+(−3) = −1 < 1,但 B 已经确定,不会再改
  5. 用 C 松弛 D:2+5 = 7,不比 2 更小

最终 Dijkstra 给出 dis[B]=1、dis[D]=2,全部错。根因就是第 2 步:它在「还不知道 C 的情况」时就把 B 定死了。

结论只要图里有负权边,Dijkstra 就是错的,没有任何补救办法。必须换第 7 章的 Bellman-Ford 或 SPFA。判断依据只有一条:读题时看清楚边权是否可能为负(有些题会写「费用可能为负」「收益」这类暗示)。
那负权边常见吗纯路程、时间类问题不会有负权。但「赚取收益」「折返有补贴」「差分约束」这类建模会出现负权,CSP-S 与 NOIP 中不少题就靠这一点区分算法选择。
6.7例题精讲①:洛谷 P4779【模板】单源最短路径(标准版)
题意给定 n 个点、m 条有向边(边权非负),求起点 s 到每个点的最短距离。n ≤ 10⁵,m ≤ 2×10⁵。输出 s 到 1..n 的距离,不可达输出 2³¹−1。

思路:n 高达 10⁵,Floyd 和朴素 Dijkstra 都会 TLE,必须用堆优化 + 邻接表(或链式前向星)。

C++P4779 参考代码(链式前向星 + 堆优化)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005, M = 200005;
const int INF = 0x3f3f3f3f;

int head[N], nxt[M], to[M], wt[M], tot;
int n, m, s, dis[N];
bool done[N];

void add(int u, int v, int c) {
    to[++tot] = v; wt[tot] = c; nxt[tot] = head[u]; head[u] = tot;
}

int main() {
    memset(head, -1, sizeof head);
    scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v, c; scanf("%d%d%d", &u, &v, &c);
        add(u, v, c);                     // 有向图:只加一条!
    }

    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> q;
    q.push({0, s});

    while (!q.empty()) {
        int d = q.top().first, u = q.top().second;
        q.pop();
        if (done[u]) continue;
        done[u] = true;
        for (int i = head[u]; i != -1; i = nxt[i]) {
            int v = to[i];
            if (d + wt[i] < dis[v]) {
                dis[v] = d + wt[i];
                q.push({dis[v], v});
            }
        }
    }

    for (int i = 1; i <= n; i++)
        printf("%d ", dis[i]);
    return 0;
}
样例输入 / 输出
输入:
4 6 1
1 2 2
2 3 2
2 4 1
1 3 5
3 4 3
1 4 4

输出:
0 2 4 3

核对:1→3 最短路是 1→2→3 = 4(比直达的 5 短);1→4 是 1→2→4 = 3(比直达的 4 短)。可见「直达不一定最短」,这正是松弛存在的意义。

坑本题输出用 %d 且按题意不可达保留 0x3f3f3f3f(部分版本要求输出 2³¹−1 = 2147483647,与 0x3f3f3f3f 数值相同,天然吻合)。如果边权开到 10⁹,一定换 long long。
6.8例题精讲②:多源最短路(改编自「应急救援」)
题意某地区有 n 个村庄、m 条双向公路(边权为通行时间)。其中有 k 个村庄建有急救站。求每个村庄到最近急救站的最短时间。n ≤ 10⁵,m ≤ 2×10⁵,k ≤ n。

暴力想法:从每个急救站各跑一次 Dijkstra,复杂度 O(k·m log m),k 接近 n 时直接爆炸。

正解:建一个「超级源点」。想象一个虚拟点 0,它到每个急救站都有一条权值为 0 的边。那么「0 到 v 的最短路」就等于「离 v 最近的急救站到 v 的距离」。实现上甚至不用真的建点 0——把 k 个急救站同时压进堆,dis 全部初始化为 0即可。

C++多源 Dijkstra(一堆源点同时入堆)
// 与单源完全一样,只改初始化部分
memset(dis, 0x3f, sizeof dis);

priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> q;

for (int i = 1; i <= k; i++) {
    int x; scanf("%d", &x);   // 读入一个急救站编号
    dis[x] = 0;               // 源点自己到自己是 0
    q.push({0, x});           // 全部先塞进堆
}

while (!q.empty()) {
    int u = q.top().second; q.pop();
    if (vis[u]) continue;
    vis[u] = true;
    for (int i = 0; i < (int)e[u].size(); i++) {
        int v = e[u][i].to, w = e[u][i].w;
        if (dis[u] + w < dis[v]) {
            dis[v] = dis[u] + w;
            q.push({dis[v], v});
        }
    }
}
// 结束后 dis[i] 就是 i 到最近急救站的时间
手算核对(3 个村庄,2 条路 1-2(4)、2-3(7),2 号有急救站)
初始:dis[1]=∞, dis[2]=0, dis[3]=∞;堆 = {(0,2)}
弹 2 → dis[1]=4, dis[3]=7
弹 1(4)→ 无更优
弹 3(7)→ 无更优
结果:dis[1]=4, dis[2]=0, dis[3]=7
举一反三「多源最短路」是所有「最近设施」类问题的通用解法:求每个点到最近医院/消防站/学校/仓库的距离,全部这样写。反过来,如果求「所有点到某点的距离」,则是反向建图跑单源(第 3 章 P3916 用过同样的技巧)。
✍️第 6 章编程题(3 道,全部要写代码)

Dijkstra 是复赛出现频率最高的图论算法。下面 3 道题覆盖了「模板题」「多源改造」「计数变形」三种考法,建议每道都手写一遍到不看模板为止。

P6-1

单源最短路(long long 版)

入门
输入 n m s + m 条有向边输出 各点最短路n ≤ 10⁵,w ≤ 10⁹

给定 n 个点、m 条有向边,边权为正整数,求起点 s 到每个点的最短距离。不可达输出 -1。

样例
输入:
4 6 1
1 2 2
2 3 2
2 4 1
1 3 5
3 4 3
1 4 4

输出:
0 2 4 3

这道题的唯一难点是溢出:边权可达 10⁹,路径上最多 10⁵ 条边,最短路最长可达 10¹⁴,int 上限只有 2.1×10⁹,会在中途变成负数。所以距离数组、堆元素、INF 全部要用 long long。

C++P6-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;

struct Edge { int to; long long w; };
vector<Edge> e[N];

long long dis[N];

int main() {
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v; long long w;
        scanf("%d%d%lld", &u, &v, &w);
        e[u].push_back({v, w});        // 有向图:只加一条
    }

    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;

    typedef pair<long long,int> PLI;
    priority_queue<PLI, vector<PLI>, greater<PLI> > q;
    q.push({0, s});

    while (!q.empty()) {
        PLI cur = q.top(); q.pop();
        long long d = cur.first; int u = cur.second;
        if (d > dis[u]) continue;       // 懒惰删除:过期的直接扔

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to; long long w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                q.push({dis[v], v});
            }
        }
    }

    for (int i = 1; i <= n; i++)
        printf("%lld ", dis[i] == INF ? -1 : dis[i]);
    return 0;
}
代码逐段解析
L5
0x3f3f3f3f3f3f3f3fLL 是 long long 版的 INF,约 4.6×10¹⁸。它同样是「每字节 0x3f」的产物,所以 memset(dis, 0x3f, ...) 能直接填出这个值。别写 1e18——那样 memset 就不管用了。
L7-8
结构体里的 w 也必须是 long long。如果只把 dis 改成 long long、w 还是 int,读入 10⁹ 没问题,但 dis[u] + w 里 w 会先参与提升,问题不大;真正致命的是把 w 读进 int 变量时若超过 2.1×10⁹ 就越界了。
L22-23
typedef pair<long long,int> PLI 简化类型名。堆的模板参数写全了太长,用 typedef 之后 priority_queue<PLI, vector<PLI>, greater<PLI>> 清爽很多。
L27-28
取堆顶后立刻把 first 放进 d、second 放进 u,不要反复写 q.top().first。注意 d 是 long long,若写成 int 就白改了。
L29
if (d > dis[u]) continue; 是懒惰删除:同一个点可能入堆多次(每次变优都塞一条),堆里会有 (8,3) 和 (5,3)。弹出 (5,3) 时 dis[3] 已是 5,正常处理;之后弹出 (8,3) 时 8 > 5,直接扔掉。它等价于 if(vis[u]) continue;,但不需要额外开 vis 数组。
L33-36
松弛三行不能变形:判断 → 更新 dis → 压入堆。顺序错或漏掉 push,算法就停在那里不动了。注意这里压的是新距离 dis[v],不是旧的。
L41
%lld 与 long long 配对,并且用 == INF 判不可达。三个 long long 相关的细节(INF、堆元素、输出格式)任何一个漏了,都会得到莫名其妙的负数输出。
三处必须同时改成长整型① dis 数组;② 堆的 pair 第一个元素;③ INF 常量。只改其中一两个,错误会以「输出负数」的形式出现,而且很难一眼看出。看到边权 10⁹ 就直接全套 long long,不要省。
P6-2

多源最短路:到最近医院的时间

基础
输入 n m k + 边 + k 个医院输出 各村庄到最近医院n,m ≤ 2×10⁵

n 个村庄、m 条双向公路(边权为通行时间),其中 k 个村庄建有医院。求每个村庄到最近医院的最短时间。

样例
输入:
5 4 2
1 2 7
2 3 5
3 4 1
4 5 3
2 4

输出:
7 0 1 0 3

说明:3 号村离 2 号医院 5、离 4 号医院 1,取 1

暴力思路:从每个医院各跑一次 Dijkstra,O(k·m log m),k 接近 n 时直接爆炸。
正解:想象一个「超级源点 0」,它到每个医院都有一条权值为 0 的边。那么「0 到 v 的最短路」正好就是「离 v 最近的医院到 v 的距离」。实现上不用真的建点 0——把 k 个医院同时压进堆、dis 全设 0 即可。

C++P6-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;

struct Edge { int to; long long w; };
vector<Edge> e[N];
long long dis[N];

int main() {
    int n, m, k; scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i <= m; i++) {
        int u, v; long long w;
        scanf("%d%d%lld", &u, &v, &w);
        e[u].push_back({v, w});
        e[v].push_back({u, w});        // 双向公路
    }

    memset(dis, 0x3f, sizeof dis);
    typedef pair<long long,int> PLI;
    priority_queue<PLI, vector<PLI>, greater<PLI> > q;

    for (int i = 1; i <= k; i++) {
        int x; scanf("%d", &x);
        dis[x] = 0;                    // 医院自己距离 0
        q.push({0, x});                // 全部先入堆
    }

    while (!q.empty()) {
        PLI cur = q.top(); q.pop();
        long long d = cur.first; int u = cur.second;
        if (d > dis[u]) continue;
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to; long long w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                q.push({dis[v], v});
            }
        }
    }

    for (int i = 1; i <= n; i++) printf("%lld ", dis[i]);
    return 0;
}
代码逐段解析
L14
无向图加两行,这是它和 P6-1 的第一处差别。公路是双向的,只加一行会让一半的村庄"到不了"。
L23-27
核心改动只有三行:dis[x] = 0、q.push({0, x})、以及循环 k 次。后面的 Dijkstra 主体一个字都不用改——这就是「最小改动解决新问题」的典型。
L26
为什么不用真的建超级源点:虚拟点 0 的出边权值全是 0,等价于「初始就有 k 个点距离为 0」。把它们直接塞进堆,效果完全一样,还省了 k 条边的空间。这种技巧叫「多源初始化」。
L33
循环内不用区分「这个点是医院还是普通村」:医院 dis 为 0,是全场最小的,一定最先出堆,之后普通点按距离依次扩展。算法自动完成了「从所有医院同时向外扩散」的效果。
L43
最后直接输出 dis[i],不需要判 INF——题目保证每个村庄都至少能到一个医院。若不能保证,就要加 dis[i]==INF?-1:dis[i]。
这一类题的识别信号题目里出现「每个点到最近的 XX」——最近医院、最近加油站、最近学校、最近仓库——立刻想多源最短路。
反向情形是「所有点到某个点」:那就反向建图跑一次单源(第 3 章 P3916 用过)。
两者是镜像的,别搞混。
P6-3

最短路计数

进阶
输入 n m + m 条无向边输出 1→n 的最短路条数n,m ≤ 2×10⁵,答案 mod 1e9+7

求从 1 号点到 n 号点有多少条不同的最短路径。边权均为 1,答案对 1000000007 取模。

样例
输入:
4 5
1 2 1
2 4 1
1 3 1
3 4 1
1 4 3

输出:
2

说明:1-2-4 与 1-3-4 长度都是 2,是最短的,共 2 条

思路:在松弛时多维护一个 cnt[]。遇到严格更短的路径,cnt[v] = cnt[u](旧路径全作废);遇到长度相等的路径,cnt[v] += cnt[u](累加方案数)。

C++P6-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005, MOD = 1000000007, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];
int dis[N], cnt[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        e[u].push_back({v, w});
        e[v].push_back({u, w});
    }

    memset(dis, 0x3f, sizeof dis);
    dis[1] = 0;
    cnt[1] = 1;                       // 起点到自己有 1 条(空路径)

    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>> > q;
    q.push({0, 1});

    while (!q.empty()) {
        pair<int,int> cur = q.top(); q.pop();
        int d = cur.first, u = cur.second;
        if (d > dis[u]) continue;

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u];              // 更短了,方案数重置
                q.push({dis[v], v});
            } else if (dis[u] + w == dis[v]) {
                cnt[v] = (cnt[v] + cnt[u]) % MOD;   // 一样短,累加
            }
        }
    }

    printf("%d\n", dis[n] == INF ? 0 : cnt[n]);
    return 0;
}
代码逐段解析
L4
这里特意不用 long long:边权是 1、n ≤ 2×10⁵,最短路最大 2×10⁵,int 完全够。数据范围决定类型,不要无脑 long long(会变慢)。
L20
cnt[1] = 1 是 DP 的边界:从 1 到 1 有 1 条「空路径」。如果写成 0,后面所有累加都是 0,最终答案永远是 0——这是本题第一个必错点。
L27
这里没有 vis 数组,只有懒惰删除。因为「相等距离」时还要继续累加计数,若像 P6-1 那样一旦出堆就标记 vis 并跳过,会漏掉后半段相等的方案。计数题的 Dijkstra 不能加 vis。
L31-34
严格更短:cnt[v] = cnt[u](赋值,不是累加)。因为之前算出的所有路径都比现在这条长,全部作废,方案数直接继承 u 的。
L35-36
长度相等:cnt[v] += cnt[u]。两条不同路径都达到同样的最短长度,所以方案数相加。这句是本题的核心,用 else if 而不是独立的 if,避免前后矛盾。
L41
不可达时输出 0(0 条路径),而不是 −1。看清题目要求再写特判——计数类题目里「0 条」和「距离 −1」是两件事。
为什么不能换成 SPFA 做计数SPFA 里一个点可能反复出队,cnt 会被重复累加(同一批方案被算多次)。要处理这种情况必须按拓扑序传播,非常麻烦。计数一律用 Dijkstra——按距离从小到大处理,天然保证每个状态只被最终确定一次。
🧠本章小结
要点必须记住的内容
适用条件单源、边权全部非负。有负权 → 换 SPFA / Bellman-Ford
贪心核心未确定点中 dis 最小的那个,最短路已确定,可永久固定
松弛if (dis[u]+w < dis[v]) dis[v] = dis[u]+w; 一切最短路的统一动作
两套模板朴素 O(n²)(邻接矩阵,稠密图)/堆优化 O(m log m)(邻接表,稀疏图)
必写四句dis[s]=0、dis 置 INF、if(vis[u]) continue、堆按距离排序
常见变体多源(全部入堆)、反向建图、加维度做分层图
下一步Dijkstra 唯一的天敌是负权边。第 7 章我们就专门解决它:Bellman-Ford 怎么做到允许负权、SPFA 怎么用队列加速、以及怎么用 SPFA 判断负环。
第 7 章

负权边 · Bellman-Ford 与 SPFA重点 · 易错

Dijkstra 死在负权边上。这一章的两个算法专门对付「边权可能为负」的图,并且顺带解决一个更难的问题:图里有没有负环。最后用「分层图」把最短路技巧推到能解带限制的题。

7.1负权边从哪来?

纯「路程、时间、花费」不会为负。但下面这些建模会出现负权:

💰 收益与亏损

走一条商路赚 5,走另一条亏 3。把「赚」记作负权、「亏」记作正权,求总成本最小。

🔁 返程补贴

A→B 收费 10,B→A 返 4。反向边权值就是 −4。

📐 差分约束

x_v − x_u ≥ w 这类不等式组,可转成 u→v 边权 −w 的最短路问题。

第一步永远是判断读完题先问自己一句:边权会不会为负?题目里出现「收益」「补贴」「亏损」「可以倒赚」「不等式组」就必须警惕。这一步判断错了,后面代码写得再漂亮也是 0 分。
7.2Bellman-Ford:暴力但可靠的「全边松弛」

核心洞察只有一句:

关键性质如果图中没有负环,那么任意两点间的最短路一定是简单路径(不重复经过同一个点),因此最多只含 n−1 条边。

于是算法极其朴素:做 n−1 轮,每轮把所有边都拿来试一次松弛。第 1 轮结束后,最多 1 条边的最短路全部正确;第 2 轮结束后,最多 2 条边的全部正确……第 n−1 轮结束后,所有最短路都正确。

第 1 轮后:所有「1 条边」的最短路已正确 第 2 轮后:所有「≤ 2 条边」的最短路已正确 第 n−1 轮后:所有最短路已正确 s a s a b s a b c …
每轮最多让「正确的最短路边数」+1,所以 n−1 轮足以覆盖全部
C++Bellman-Ford(边表存储,O(nm))
#include <bits/stdc++.h>
using namespace std;
const int N = 2005, M = 6005, INF = 0x3f3f3f3f;

struct Edge { int u, v, w; } E[M];   // 只需要「边表」,不用邻接结构

int n, m, s, dis[N];

bool bellman(int s) {                 // 返回 true 表示存在负环
    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;

    for (int i = 1; i < n; i++) {      // 最多 n-1 轮
        bool updated = false;
        for (int j = 1; j <= m; j++) {
            int u = E[j].u, v = E[j].v, w = E[j].w;
            if (dis[u] != INF && dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                updated = true;
            }
        }
        if (!updated) break;           // 一轮下来没有任何变化 → 已经收敛,提前退出
    }

    // 第 n 轮:如果还能松弛,说明存在负环
    for (int j = 1; j <= m; j++) {
        int u = E[j].u, v = E[j].v, w = E[j].w;
        if (dis[u] != INF && dis[u] + w < dis[v]) return true;
    }
    return false;
}

int main() {
    scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        scanf("%d%d%d", &E[i].u, &E[i].v, &E[i].w);
        // 无向图要把每条边拆成两条:E[++m] = {v,u,w}; 记得 m 先翻倍
    }
    if (bellman(s)) puts("存在从 s 可达的负环");
    else for (int i = 1; i <= n; i++) printf("%d ", dis[i]);
    return 0;
}
✅ 优点
  • 允许负权边,这是它存在的唯一理由
  • 能顺带判负环(多做一轮)
  • 实现简单,不需要邻接表,边表即可
  • 支持「最多经过 k 条边」的限边最短路
❌ 缺点
  • O(nm),n = 10⁴、m = 10⁵ 时约 10⁹,必 TLE
  • 每轮都傻傻扫全部边,大部分是无效松弛
  • 实际比赛里通常被 SPFA 取代(但要知道它的原理)
7.3判负环:为什么「第 n 轮还能松弛」就是有负环

没有负环时,n−1 轮之后所有最短路都定下来了,第 n 轮不可能再有任何边能松弛成功。反过来,如果第 n 轮还能松弛——说明存在一条至少含 n 条边且比现有答案更短的路径。

n 个点的简单路径最多 n−1 条边。含 n 条边且还在变短,唯一解释就是路径里绕了环,而且这个环的权值之和是负数。由于每绕一圈都能让距离更小,这个点的最短路实际上不存在(可以无限变小)。

负环的后果只要从源点能走到负环,环上及环下游的点最短路都变成 −∞(可以无限绕圈变小)。很多题目正是要求你判定这种情况并特判输出。
s 2 A B C 1 -4 1 A→B→C→A 权值和 = 1 + (−4) + 1 = −2 < 0,是负环
从 s 到 A 后,绕一圈距离反而减少 2,绕 k 圈就减少 2k,永无下界
7.4SPFA:只松弛「刚刚变短了」的点

Bellman-Ford 的一大半工作是无用功:某个点 dis 没变,用它的边去松弛别人也不会变。于是有人想:只有 dis 刚刚被改小的点,才值得拿它的出边去松弛邻居。用队列维护这些「值得扩散」的点,就是 SPFA(Shortest Path Faster Algorithm)。

它的形状和 BFS 几乎一样,只是「一个点可以多次入队」。

C++SPFA 求最短路(邻接表 + 队列)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];
int dis[N];
bool inq[N];       // inq[u]:u 是否已在队列里(防止重复入队)

void spfa(int s) {
    memset(dis, 0x3f, sizeof dis);
    memset(inq, 0, sizeof inq);
    dis[s] = 0;

    queue<int> q;
    q.push(s);
    inq[s] = true;

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;            // 出队后必须清标记,允许它以后再次入队

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                if (!inq[v]) {     // 已在队里就不重复塞
                    q.push(v);
                    inq[v] = true;
                }
            }
        }
    }
}
代码逐行解析
L5-9
四个成员:邻接表 e、距离 dis、inq(是否在队列中)。注意这里没有 vis——SPFA 允许一个点反复入队,不存在「永久确定」的概念。
L11-13
初始化三句:dis 刷 INF、inq 全清 0、dis[s] = 0。第二句容易被忽略——如果 inq 残留 true,起点可能永远不会再入队。
L15-17
起点入队并标记。这三行的顺序是:push 队列 → 设 inq。inq 必须与「在队列里」严格同步,多设或少设都会导致队列膨胀或漏扩展。
L21
inq[u] = false 是 SPFA 最不能漏的一行。出队就清标记,这样它以后被更短的路径发现时还能再进来。漏了这句,SPFA 退化成「每个点只扩展一次」的 BFS,负权带来的二次优化全部丢失,答案错。
L25-26
松弛式与 Dijkstra 完全一样:dis[u] + w < dis[v] 就更新。区别在于「谁来算」——Dijkstra 用堆保证按距离顺序,SPFA 用队列反复试。
L27-30
只有不在队里才入队。一个点可能被多个邻居连续松弛,若每次都 push,队列长度会爆炸(最坏 O(m) 级别),内存和时间都受不了。inq 就是用来做这个去重的。
L19
循环结束意味着「再也松弛不动了」,此时 dis 就是最终答案。这也顺带说明:若图里有可达负环,这个循环永远不会结束——所以判负环必须额外计数(见 7.5)。
复杂度
平均 O(km),最坏 O(nm)。k 是「每个点平均入队次数」,随机数据下 2~3。出题人若专门构造菊花图或网格图,会把你卡到最坏情况。
⏱ 复杂度到底多少
  • 随机数据:约 O(km),k 是常数(2~3),飞快
  • 最坏情况:O(nm),和 Bellman-Ford 一样
  • 出题人可以专门构造「菊花图 + 网格图」把你卡成 O(nm)
🚫 为什么比赛里常被针对
  • NOI 系列题目常明确写「本题数据可能卡掉 SPFA」
  • 非负权图请老老实实用 Dijkstra,别赌
  • SPFA 只在「有负权」时才有存在价值
两个小优化① SLF:入队时若 dis[v] < dis[队首] 就插到队头(用 deque),常数更小。② LLL:把 dis[v] 与队内平均值比较。两者都能提速,但改变不了最坏复杂度,别指望靠它们过恶意数据。
7.5SPFA 判负环:数一数「最短路边数」

SPFA 判负环比 Bellman-Ford 优雅得多。维护 cnt[v] 表示「当前这条最短路用了几条边」:每次用 u 松弛 v 成功时,cnt[v] = cnt[u] + 1。一旦 cnt[v] ≥ n,说明最短路已经用了 n 条边——n 个点不可能有 n 条边的简单路径,必然绕了负环。

C++SPFA 判负环(返回 true 表示有负环)
int cnt[N];
bool inq[N];

bool spfa_neg(int n) {
    memset(dis, 0x3f, sizeof dis);
    memset(inq, 0, sizeof inq);
    memset(cnt, 0, sizeof cnt);

    queue<int> q;
    // 重要:把所有点都先入队并把 dis 置 0
    // 这样才能检测「不与起点连通」的负环
    for (int i = 1; i <= n; i++) {
        dis[i] = 0;
        q.push(i);
        inq[i] = true;
    }

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n) return true;    // 负环!
                if (!inq[v]) { q.push(v); inq[v] = true; }
            }
        }
    }
    return false;
}
三个必须注意的点 ① 如果题目问的是「从 s 出发能到达的负环」,就只把 s 入队、dis[s]=0,其余保持 INF;
② 如果问「图里是否存在负环」(不管连通性),必须像上面那样所有点 dis 置 0 全入队;
③ cnt 一定要在松弛成功时更新,不能放在入队处,否则统计的不是路径边数。
另一个判据还可以数「入队次数」:某点入队超过 n 次就一定绕了负环。两种写法都对,但边数计数(cnt)更稳定——入队次数会受重复入队影响,边界容易差一。
7.6分层图:把「限制」变成「新的一维」

有一类题长这样:「你有 k 次免费通行的机会,求最小花费」。这时候单靠 dis[u] 不够用了——同一个点,用掉 0 次免费和用掉 1 次免费,是两个不同的状态。

做法:把状态写成二维 dis[u][j],表示「到达 u,已经用掉 j 次免费机会」的最小花费。整张图实际上变成了 (k+1) 层,每层是原图的一份拷贝,层与层之间用「免费边」连接——这就是分层图。

j = 0(没用免费) 1 2 3 5 5 j = 1(已用 1 次) 1 2 3 5 5 权 0(免费用在这条边) 权 0 答案 = min( dis[3][0], dis[3][1] ):用不用免费机会都可能更优
每层是原图的一份拷贝;「免费通行」就是从第 j 层跳到第 j+1 层的 0 权边
C++分层图最短路(k 次免费通行)
#include <bits/stdc++.h>
using namespace std;
const int N = 10005, K = 12, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];

struct Node {
    int d, u, j;
    bool operator<(const Node& o) const { return d > o.d; }  // 反过来写 = 小根堆
};

int dis[N][K];   // dis[u][j]:到 u,已用 j 次免费
bool vis[N][K];
int n, m, k, s, t;

int solve() {
    memset(dis, 0x3f, sizeof dis);
    dis[s][0] = 0;

    priority_queue<Node> q;
    q.push({0, s, 0});

    while (!q.empty()) {
        Node cur = q.top(); q.pop();
        int u = cur.u, j = cur.j;
        if (cur.d > dis[u][j]) continue;   // 懒惰删除

        for (int i = 0; i <(int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;

            // 转移 1:正常走,付费
            if (dis[u][j] + w < dis[v][j]) {
                dis[v][j] = dis[u][j] + w;
                q.push({dis[v][j], v, j});
            }
            // 转移 2:这一条免费走,层数 +1
            if (j < k && dis[u][j] < dis[v][j + 1]) {
                dis[v][j + 1] = dis[u][j];
                q.push({dis[v][j + 1], v, j + 1});
            }
        }
    }

    int ans = INF;
    for (int j = 0; j <= k; j++) ans = min(ans, dis[t][j]);
    return ans;
}
为什么正确把 (u, j) 当成新图的顶点后,新图的边权依然全部非负(0 也非负),所以 Dijkstra 的正确性原封不动搬过来。这也是分层图最常用的实现方式。
还能怎么用分层图的本质是「给状态加维度」。同类题型:① 走到障碍上要付代价(代价有上限);② 图里有两类边,某些边只能走奇数/偶数次;③ 需要区分「当前朝向」「剩余能量」「已收集钥匙的二进制集合」。看到「有次数限制 / 有阶段状态」就想到加维度。
7.7例题精讲:P3385【模板】负环
题意多组数据。每组给定 n 个点、m 条有向边,判断是否存在从顶点 1 出发能到达的负环。n ≤ 2000,m ≤ 3000。

思路:只把 1 号点入队,dis[1]=0,其余保持 INF。这样能保证检测到的负环一定是从 1 可达的。用 cnt[v] ≥ n 判定。

C++P3385 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 2005, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];
int dis[N], cnt[N];
bool inq[N];

bool hasNegCycle(int n) {
    memset(dis, 0x3f, sizeof dis);
    memset(cnt, 0, sizeof cnt);
    memset(inq, 0, sizeof inq);

    queue<int> q;
    dis[1] = 0;
    q.push(1);
    inq[1] = true;

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;
        for (int i = 0; i <(int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n) return true;
                if (!inq[v]) { q.push(v); inq[v] = true; }
            }
        }
    }
    return false;
}

int main() {
    int T; scanf("%d", &T);
    while (T--) {
        int n, m; scanf("%d%d", &n, &m);
        for (int i = 1; i <= n; i++) e[i].clear();
        for (int i = 1; i <= m; i++) {
            int u, v, w; scanf("%d%d%d", &u, &v, &w);
            e[u].push_back({v, w});
            // 注意:本题不同版本对建边约定的描述略有差别,
            // 有的版本规定 w >= 0 要额外加一条反向边 v->u(权 w)。
            // 一律以题目原文为准,别照抄记忆里的模板。
        }
        puts(hasNegCycle(n) ? "YE5" : "N0");
    }
    return 0;
}
这套模板题最爱坑人的两点 ① 输出是 YE5(数字 5)和 N0(数字 0),不是 YES / NO;
② 多组数据,邻接表与所有数组必须清空,否则上一组的数据会污染下一组。
✍️第 7 章编程题(3 道,全部要写代码)

负权题的关键不在代码,而在判断:读题时能不能意识到「这里有负数」。下面 3 道题分别练 SPFA 求最短路、SPFA 判负环、分层图建模。

P7-1

带负权边的单源最短路

基础
输入 n m s + m 条有向边输出 各点最短路n,m ≤ 2×10⁵,保证无负环

给定有向图,边权可以为负(数据保证不存在从 s 可达的负环),求 s 到各点的最短距离,不可达输出 1000000000。

样例
输入:
4 5 1
1 2 2
2 3 -1
1 3 4
3 4 2
2 4 10

输出:
0 2 1 3

说明:1→3 走 1→2→3 = 2+(−1) = 1,比直达的 4 更短

为什么不能用 Dijkstra:它假设「当前最小的 dis 已经最终确定」,而负权边会在后面把已确定的点改小(第 6 章 6.6 已给过反例)。所以必须换成 SPFA——允许一个点反复入队、反复变优。

C++P7-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];
int dis[N];
bool inq[N];              // 是否在队列中

int main() {
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        e[u].push_back({v, w});
    }

    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;

    queue<int> q;
    q.push(s); inq[s] = true;

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;                 // 出队后清标记,允许再次入队

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                if (!inq[v]) {          // 已在队里就不重复塞
                    q.push(v);
                    inq[v] = true;
                }
            }
        }
    }

    for (int i = 1; i <= n; i++)
        printf("%d ", dis[i] == INF ? 1000000000 : dis[i]);
    return 0;
}
代码逐段解析
L8
inq 和 vis 是两回事:vis(Dijkstra 里用)表示「已永久确定」,一旦为真不再改;inq 只表示「此刻在队列里」,出队就清掉,允许下次再进。把两者混用是 SPFA 写错的第一大原因。
L20-21
只把起点入队。SPFA 不需要预先把所有点塞进去——它的语义是「从已变优的点出发扩散」,没变优过的点没有扩散价值。
L25
inq[u] = false 必须在出队后立刻写。如果漏了,一个点出队后永远不会再入队,算法就退化成「只走一遍 BFS」,负权边带来的二次优化全部丢失。
L29
if (!inq[v]) 防止队列膨胀:一个点可能被多个邻居同时松弛,若每次都 push,队列里会出现大量重复。加上判断后,同一时刻每个点最多在队列里出现一次。
L28-33
整段连起来看:只有当 dis[v] 真的变小了才继续——这就是「松弛」的本质。Bellman-Ford 是盲扫所有边,SPFA 只扫「刚变短的点」的出边,所以平均快得多。
L41
不可达输出 10⁹ 而非 −1,因为本题有负权,−1 可能是一个合法的最短距离。题目要求的「不可达」标记必须选一个不可能出现的数,这个判断在负权题里尤其重要。
SPFA 什么时候会慢最坏 O(nm)。出题人可以构造「菊花图 + 网格」把你卡到 10⁹ 级别。如果题目没有负权,一定用 Dijkstra,别赌 SPFA 的随机性能。
P7-2

判断是否存在负环

进阶
输入 n m + m 条有向边输出 YES / NOn ≤ 2000,m ≤ 5000

给定有向图,判断图中是否存在负环(环上权值之和为负)。要求检测整张图的负环,不限定从某个点可达。

样例
输入:
4 4
1 2 1
2 3 -2
3 4 1
4 2 -1

输出:
YES

说明:2→3→4→2 权值和 = (−2)+1+(−1) = −2 < 0

思路:SPFA 时维护 cnt[v] 表示「当前最短路用了几条边」,每次松弛成功就 cnt[v] = cnt[u] + 1。一旦 cnt[v] >= n,说明这条最短路用了 n 条边——n 个点不可能有 n 条边的简单路径,必然绕了负环。

关键细节:要检测「整张图」,必须把所有点的 dis 置 0 并全部入队。这样相当于加了一个超级源点,任何连通块里的负环都会被触发。

C++P7-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 2005;

struct Edge { int to, w; };
vector<Edge> e[N];
int dis[N], cnt[N];
bool inq[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        e[u].push_back({v, w});
    }

    queue<int> q;
    for (int i = 1; i <= n; i++) {      // 所有点都入队!
        dis[i] = 0;                    // 距离都设 0
        q.push(i);
        inq[i] = true;
    }

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;         // 路径边数 +1
                if (cnt[v] >= n) { puts("YES"); return 0; }
                if (!inq[v]) { q.push(v); inq[v] = true; }
            }
        }
    }
    puts("NO");
    return 0;
}
代码逐段解析
L18-22
全部入队 + dis 全 0 是这道题的核心技巧。它等价于「建一个超级源点 0,向每个点连一条权 0 的边」。这样一来,任何点周围的负环都会因为「绕一圈还能更小」而被反复触发。
L18-22
如果只把 1 号点入队,那答案就变成「从 1 出发可达的负环」,与题目要求不同。很多模板题在这点上表述不同(洛谷 P3385 就是从 1 出发),一定要按题目原文判断。
L30-31
cnt[v] = cnt[u] + 1 放在「松弛成功」内部,不能放在入队处。因为 cnt 记的是路径边数,只有真的沿着这条边延伸了才 +1;若放在入队处会重复计数。
L32
cnt[v] >= n 就判定负环并直接输出。为什么是 n 而不是 n−1?n 个点的简单路径最多 n−1 条边,达到 n 条必然有重复点 = 有环,且环权和为负。
L32
找到就 return 0 立刻结束,不需要把队列跑空。判环类题目都是「发现即退出」,能省大量时间。
L36
循环自然结束说明没有负环,输出 NO。这里不要用 cnt 的最大值去比较,直接看有没有触发过判定就行。
两种判据别混用边数法(本题):cnt[v] = cnt[u]+1; if(cnt[v] >= n) 有负环,稳定可靠。
入队次数法:某点入队超过 n 次即判负环。也正确,但边界容易差一(是 > n 还是 >= n),且受重复入队影响。推荐用边数法。
P7-3

分层图:一次免费通行

挑战
输入 n m k + m 条双向边输出 最小总花费n ≤ 10⁴,m ≤ 5×10⁴,k ≤ 10

n 个城市、m 条双向道路,每条有通行费。你有 k 次「免单」机会,可以让某条路的费用变 0(每条路最多用一次机会)。求从 1 到 n 的最小总花费。

样例(k = 1)
输入:
4 4 1
1 2 5
2 4 5
1 3 1
3 4 10

输出:
1

说明:走 1→3→4,把 3-4 的 10 免掉,只付 1

为什么普通最短路不行:同一个城市,「用掉 0 次机会」和「用掉 1 次机会」是两个不同的状态,到达它们的代价不同。一维的 dis[u] 表达不了这种区别,必须扩成二维 dis[u][j]。

建模:把图想成 k+1 层,第 j 层表示「已用 j 次机会」。同一层内正常走(付钱);从第 j 层到第 j+1 层有一条权值为 0 的边(用掉一次机会)。然后在 2n 个「点」上跑 Dijkstra。

C++P7-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 10005, K = 12, INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> e[N];

struct Node {                     // 堆元素:距离 + 点 + 已用次数
    int d, u, j;
    bool operator<(const Node& o) const { return d > o.d; }  // 反向 = 小根堆
};

int dis[N][K];

int main() {
    int n, m, k; scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        e[u].push_back({v, w});
        e[v].push_back({u, w});
    }

    memset(dis, 0x3f, sizeof dis);
    dis[1][0] = 0;

    priority_queue<Node> q;
    q.push({0, 1, 0});

    while (!q.empty()) {
        Node cur = q.top(); q.pop();
        int u = cur.u, j = cur.j;
        if (cur.d > dis[u][j]) continue;      // 懒惰删除

        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i].to, w = e[u][i].w;

            // 转移 1:正常付费走
            if (dis[u][j] + w < dis[v][j]) {
                dis[v][j] = dis[u][j] + w;
                q.push({dis[v][j], v, j});
            }
            // 转移 2:用掉一次机会,跳到上一层
            if (j < k && dis[u][j] < dis[v][j + 1]) {
                dis[v][j + 1] = dis[u][j];
                q.push({dis[v][j + 1], v, j + 1});
            }
        }
    }

    int ans = INF;
    for (int j = 0; j <= k; j++) ans = min(ans, dis[n][j]);
    printf("%d\n", ans);
    return 0;
}
代码逐段解析
L9-12
自定义结构体重载 operator<:return d > o.d; 看着像「大根堆」,其实因为 STL 堆默认用 less(即 a < b 为真时 b 优先),把比较反过来就得到小根堆。这是最反直觉、也最容易记错的一处。
L14
dis[N][K] 是二维状态:第一维点编号、第二维「已用次数」。K 只需开到 k+1(k ≤ 10,开 12 留余量)。数组第二维一定要按题目给的 k 上限来开,开小了就是越界。
L27
dis[1][0] = 0:起点、还没用任何机会,花费 0。注意不能写成 dis[1][j] = 0 对每个 j 都置 0——那等于「免费次数凭空出现」,答案会偏小。
L36
cur.d > dis[u][j] 是二维版的懒惰删除,两个下标都要带上。写成 dis[u] 编译不过,写成 dis[u][0] 逻辑就错了。
L41-44
转移 1(付费)更新同一层:dis[v][j],j 不变。这就是普通的沿边松弛。
L46-49
转移 2(免费)跳到下一层:dis[v][j+1] = dis[u][j],注意右边不加 w,因为这条免费。同时要判 j < k,否则会越界写坏内存。
L54-55
答案是 min(dis[n][0..k]),不是 dis[n][k]。因为机会不一定都要用掉——可能用 0 次就已经最优(比如开始就在终点),也可能用 1 次比用满 k 次更好。
分层图的本质「给状态加维度」。只要题目出现次数限制、阶段状态、剩余资源,就把那一维加进 dis 数组,然后在新图上跑 Dijkstra。
同类题:① k 次免费(本题);② 走到障碍上付费;③ 有向边可反向一次;④ 收集钥匙的二进制状态(那就要 dis[u][1<<m])。
🧠本章小结
算法复杂度核心 / 用途
Bellman-FordO(nm)n−1 轮全边松弛;多做一轮可判负环。允许负权,实现最简。
SPFA平均 O(km),最坏 O(nm)队列版 BF,只扩散「刚变短」的点;cnt[v] ≥ n 判负环。
分层图O(k·m log(km))给状态加一维,用 Dijkstra 解「有次数限制」的最短路。
一句话记住非负权用 Dijkstra,有负权用 SPFA,要判环数边数。下一章换个问题:不求最短路径,而是求「把所有点连起来的最小代价」——最小生成树。
第 8 章

最小生成树:Kruskal 与 Prim重点 · 高频

前两章问「怎么走最快」,这一章换一个问题:要让所有点都连通,最少要花多少钱?。答案就是最小生成树。它是「修路 / 铺管道 / 架电网 / 组网」类问题的标准解法,也是并查集最经典的应用场景。

8.1生成树与最小生成树

先明确三个概念:

  • 生成树(Spanning Tree):包含图中全部 n 个点、且恰好 n−1 条边、并且连通无环的子图。它是一棵「撑满整张图」的树。
  • 最小生成树(MST):所有生成树中,边权之和最小的那一棵。
  • 注意:MST 只对连通图才有定义(也常推广为「最小生成森林」)。图不连通时只能得到每块的最小生成树。
为什么一定是 n−1 条边n 个点的连通图最少需要 n−1 条边;再多一条就必然成环,删掉环上任意一条边仍连通且总权不会更大(若边权非负还会更小)。所以最优解一定是 n−1 条边、无环,即一棵树。
原图(6 点 10 边) 1 2 3 4 5 它的最小生成树(5 条边) 1 2 3 4 5
MST 是原图的一个子图:保留全部点,只挑 n−1 条边,且必须连通
✅ MST 的三个性质(可直接用)
  • 环性质:任意一个环上,权值最大的边一定不在 MST 中(若权值唯一)
  • 割性质:任意一个割中,权值最小的边一定在 MST 中
  • 唯一性:所有边权互不相同时,MST 唯一;有相等边权时可能有多棵
⚠️ 容易误解的地方
  • MST 不是「任意两点间距离最短」,它只保证总权最小
  • MST 上两点路径 ≠ 原图最短路(可能绕远)
  • 图不连通时不存在 MST,只能求「最小生成森林」
8.2前置必备:并查集(Union-Find)

Kruskal 的核心动作是:「这条边的两个端点,是不是已经在同一块里了?」是 → 加了会成环,跳过;否 → 加进去并把两块合并。这个「判断连通 + 合并」正是并查集的看家本领。

C++并查集(路径压缩,近 O(1))
int fa[N];

void init(int n) {
    for (int i = 1; i <= n; i++) fa[i] = i;   // 初始每人自成一个集合
}

int find(int x) {
    if (fa[x] == x) return x;
    return fa[x] = find(fa[x]);   // 路径压缩:顺手把路上的点都挂到根上
}

// 合并 a、b 所在集合;若本来就在一起,返回 false
bool unite(int a, int b) {
    a = find(a); b = find(b);
    if (a == b) return false;
    fa[a] = b;                    // 把 a 的根挂到 b 的根下
    return true;
}

// 判断是否连通:find(a) == find(b)
路径压缩那一行不能省fa[x] = find(fa[x]) 里的赋值是关键。如果只写 return find(fa[x]),树会退化成一条长链,单次查询 O(n),Kruskal 直接 TLE。加上压缩后,均摊复杂度近似 O(α(n)) ≈ O(1)。
压缩前:一条长链,查 5 要爬 4 层 1 2 3 4 5 压缩后:全部直挂根,O(1) 查到 1 2 3 4 5
路径压缩:一次 find 之后,路径上所有点都直接指向根
8.3Kruskal:把所有边排个序,从小到大贪心加

Kruskal 的思路短到一句话就能说完:

算法把所有边按权值从小到大排序,依次尝试每一条边:若它两端还不在同一连通块,就选它并合并两块;否则跳过。选够 n−1 条边即可停止。
C++Kruskal 完整模板(O(m log m))
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 200005;

struct Edge { int u, v; long long w; } E[M];
int fa[N];

bool cmp(const Edge& a, const Edge& b) { return a.w < b.w; }

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++)
        scanf("%d%d%lld", &E[i].u, &E[i].v, &E[i].w);

    sort(E + 1, E + m + 1, cmp);        // 第 1 步:按边权升序

    for (int i = 1; i <= n; i++) fa[i] = i;   // 并查集初始化

    long long sum = 0;
    int cnt = 0;                         // 已选边数

    for (int i = 1; i <= m; i++) {        // 第 2 步:依次尝试
        int a = find(E[i].u), b = find(E[i].v);
        if (a == b) continue;            // 已成环,跳过
        fa[a] = b;                       // 选它!合并两块
        sum += E[i].w;
        cnt++;
        if (cnt == n - 1) break;         // 第 3 步:够了就停
    }

    if (cnt < n - 1) puts("orz");        // 图不连通
    else printf("%lld\n", sum);
    return 0;
}
代码逐行解析
L5
边表:只存 u、v、w 三个字段。Kruskal 不需要邻接结构,因为它是「按边权全局排序后逐条判断」,不需要「找某个点的邻居」。
L6
fa[N] 是并查集的父指针数组。fa[i] = i 表示 i 自成一个集合;find 一路向上找到根,根相同的两个点就在同一连通块。
L8
cmp 决定排序方向:a.w < b.w 是升序(最小生成树),改成 > 就是最大生成树。整个算法只有这一处方向,其余代码完全对称。
L10
路径压缩的一行写法:fa[x] = find(fa[x]) 的赋值是整个表达式的一部分——它在递归返回时把路上的点都直接挂到根上。只写 return find(fa[x]) 不算错,但树会退化成链,单次查询 O(n),大数据必 TLE。
L17
sort(E+1, E+m+1, cmp) 左闭右开:下标 1 到 m 参与排序,所以右端写 m+1。写成 E+m 会漏掉最后一条边。
L19
并查集初始化必须在排序后、循环前。这一句漏了整个算法就废了:fa 全是 0,find 认为所有点同根,第一条边就判成环,一条都选不上。
L25-26
a == b 表示两端已连通,加这条边会成环,跳过。自环(u==v)在这里天然被判掉;重边中较大的那条也会因为「已连通」被跳掉,所以两者都不用特判。
L27-29
合并 + 累加 + 计数三件事必须一起做。fa[a] = b 是「把 a 的根挂到 b 的根下」,注意合并的是根不是原结点;sum += E[i].w 用 long long;cnt++ 用于判断是否够 n−1 条。
L30
选够 n−1 条立即 break。n 个点连通至少需要 n−1 条边,够了就停,后面的边再小也不看(它们必然成环)。
L33
cnt < n-1 判不连通。n 个点至少要 n−1 条边才能连成一体,不够说明有孤立块。不判这一点,不连通的数据会输出一个偏小的"正常"答案,是最隐蔽的 WA。
复杂度
O(m log m):排序是瓶颈,并查集近似 O(1)。只跟边数有关,所以稀疏图(m ≈ n)非常划算——这也是比赛里默认选 Kruskal 的原因。

复杂度:排序 O(m log m) 是瓶颈,并查集近似 O(1),总 O(m log m)。适合稀疏图(m 不太大),也是实际比赛中最常用的写法——因为代码短、不容易写错。

正确性怎么来的用到 8.1 的割性质。设当前最小边 e=(u,v),若某棵 MST 里不含 e,把 u 所在块与其余点看成一个割,MST 中必有一条跨这个割的边 f,且 w(f) ≥ w(e)。把 f 换成 e,仍是生成树且总权不增——所以一定存在包含 e 的 MST。贪心每一步都不亏,全局最优。
8.4手算演示:6 个点、10 条边

边与权值:1-2(6)、1-3(1)、1-4(5)、2-3(5)、2-5(3)、3-4(5)、3-5(6)、3-6(4)、4-6(2)、5-6(6)。

Kruskal 逐边判定
0
排序 1-3(1) → 4-6(2) → 2-5(3) → 3-6(4) → 1-4(5) → 2-3(5) → 3-4(5) → 1-2(6) → 3-5(6) → 5-6(6)
1
1-3(1) 1 与 3 分属不同集合 → 选。sum = 1,{1,3}
2
4-6(2) 4 与 6 不同 → 选。sum = 3,{1,3}、{4,6}
3
2-5(3) 2 与 5 不同 → 选。sum = 6,{1,3}、{4,6}、{2,5}
4
3-6(4) 3 在 {1,3}、6 在 {4,6},不同 → 选。sum = 10,合并成 {1,3,4,6}
5
1-4(5) 1 和 4 都在 {1,3,4,6} → 跳过(会成环)
6
2-3(5) 2 在 {2,5}、3 在 {1,3,4,6} → 选。sum = 15,全部点连通,cnt = 5 = n−1,停止
✓
结果 MST 边 = {1-3, 4-6, 2-5, 3-6, 2-3},总权 = 1+2+3+4+5 = 15
点击「播放」自动演示,或「单步」自己控制节奏

用 Prim 从 1 号点出发验证:依次选入 3(1)、6(4)、4(2)、2(5)、5(3),合计 1+4+2+5+3 = 15,两种算法结果一致。

8.5Prim:从「一棵树」长成「一棵大树」

Prim 换了视角:维护一个「已加入树的点集」,每轮从连接树内与树外的边里选权值最小的那条,把对应点拉进来。n−1 轮后树就长满了。

C++Prim 朴素版(邻接矩阵,O(n²),适合稠密图)
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, INF = 0x3f3f3f3f;

int n, m;
int g[N][N];
int dis[N];      // 注意!Prim 的 dis[v] = v 到「已选点集」的最小边权
bool vis[N];     // 不是「到起点的距离」,别和 Dijkstra 混!

int prim() {
    memset(dis, 0x3f, sizeof dis);
    memset(vis, 0, sizeof vis);
    dis[1] = 0;

    long long sum = 0;
    for (int i = 1; i <= n; i++) {
        int u = -1;
        for (int j = 1; j <= n; j++)
            if (!vis[j] && (u == -1 || dis[j] < dis[u])) u = j;

        if (dis[u] == INF) return -1;   // 图不连通
        vis[u] = true;
        sum += dis[u];                  // 把这条入树边计入答案

        for (int v = 1; v <= n; v++)
            if (!vis[v] && g[u][v] < dis[v]) dis[v] = g[u][v];
            // 关键区别:这里不是 dis[u] + g[u][v],而是直接取边权!
    }
    return (int)sum;
}
Prim 和 Dijkstra 长得几乎一样,但有一处必须分清 Dijkstra:dis[v] = dis[u] + g[u][v],累加的是「到起点的整条路径」。
Prim:dis[v] = g[u][v],只取「这一条入树边」的权值。
写错这一行,Prim 就变成了「从 1 出发的最短路」,答案完全错。考试里这是最容易失分的一处细节。

复杂度:外层 n 轮,内层找最小 O(n) + 更新 O(n),共 O(n²),与边数无关 → 稠密图(m ≈ n²)首选。用堆优化可到 O(m log m),但常数比 Kruskal 大,实战中稀疏图大家更爱 Kruskal。

已在树里的点 1 3 6 树外的点 2 4 5 2 两条候选边权 5 和 2 → 选 2,把 4 号点拉进树
Prim 每轮只在「跨界边」里挑最小的一条(与 Kruskal 的割性质是同一个道理)
8.6Kruskal vs Prim:怎么选
对比项KruskalPrim
核心思想按边权从小到大加边,用并查集防环从一个点出发,每次拉最近的点入树
复杂度O(m log m)朴素 O(n²);堆优化 O(m log m)
适合图型稀疏图(m ≈ n ~ n log n)稠密图(m ≈ n²)
是否需要邻接结构不需要,边表就够朴素版用邻接矩阵最方便
代码难度很短,但要会并查集很像 Dijkstra,容易与最短路混淆
处理不连通天然支持,最后判 cnt < n−1需要额外判 dis == INF
推荐场景比赛默认选择(尤其 m ≤ 2×10⁵)n ≤ 5000 且边很密时更快
一句话选型边少用 Kruskal,点少边多用 Prim。拿不准就写 Kruskal——它的复杂度只跟边数有关,而且代码短、不容易错。
8.7例题精讲①:洛谷 P3366【模板】最小生成树
题意n 个点、m 条无向边,求最小生成树的边权之和。n ≤ 5000,m ≤ 2×10⁵。若图不连通,输出 orz。

直接套 8.3 的模板即可。几个必须处理的细节:

  • 重边:Kruskal 排序后自然只取最小的那条,不用特判。
  • 自环(u == v):find(u) == find(v) 恒成立,自动跳过,也不用特判。
  • 负权边:MST 允许负权,Kruskal 会把它们全部选上(排在最前面),不用特殊处理。
  • 不连通:循环结束后若 cnt < n-1,说明有孤立块 → 输出 orz。
样例(4 点 5 边)
输入:
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3

输出:
7

选边:1-2(2) → 1-3(2) → 1-4(3),合计 2+2+3 = 7

注意 3-4 也是 3,选它同样得 7 —— 说明边权有相等值时 MST 可能不唯一,但总权一定相同。题目只问总权,所以不影响答案。

8.8例题精讲②:已有道路的「村村通」
题意n 个村庄,其中已经有 k 条路修好了(保证这些路不会成环)。现在要再修一些路让所有村庄连通,给出可修的 m 条路及其花费,求最小总花费。

关键技巧:把已修好的路的代价设为 0。因为「已经连通」意味着这几条边可以免费使用。把这些边权改成 0 一起丢进 Kruskal,算法会自动优先选它们(0 是最小的),相当于先把已有的连通块合并好,再花钱补缺口。

C++把「已有边」权值置 0
int main() {
    int n, k, m;
    scanf("%d%d", &n, &k);          // k 条已修好的路
    for (int i = 1; i <= n; i++) fa[i] = i;

    int tot = 0;
    for (int i = 1; i <= k; i++) {
        int u, v; scanf("%d%d", &u, &v);
        E[++tot] = {u, v, 0};        // 关键:代价记 0
    }
    scanf("%d", &m);
    for (int i = 1; i <= m; i++) {
        int u, v; long long w;
        scanf("%d%d%lld", &u, &v, &w);
        E[++tot] = {u, v, w};
    }

    sort(E + 1, E + tot + 1, cmp);
    long long sum = 0; int cnt = 0;
    for (int i = 1; i <= tot; i++) {
        int a = find(E[i].u), b = find(E[i].v);
        if (a == b) continue;
        fa[a] = b; sum += E[i].w; cnt++;
        if (cnt == n - 1) break;
    }
    if (cnt < n - 1) puts("无法连通");
    else printf("%lld\n", sum);
    return 0;
}
举一反三「已有 / 免费 / 必选」的边,通用处理是把权值设成 0 或 −INF:
· 已有道路 → 权值 0(免费使用)
· 必须选的边 → 先用并查集合并,或权值设 −INF 保证必被选中
· 禁用的边 → 直接不加进边表
这三招覆盖了 MST 题 80% 的变形。
✍️第 8 章编程题(3 道,全部要写代码)

MST 的代码短,但细节多:排序方向、并查集初始化、连通性判断、long long。下面 3 道题把「标准最小」「已有边」「最大生成树」都走一遍。

P8-1

最小生成树(模板 + 不连通判定)

入门
输入 n m + m 条无向边输出 总权 / orzn ≤ 5000,m ≤ 2×10⁵,w ≤ 10⁹

n 个点、m 条无向边(可能有重边、自环、负权)。求最小生成树的边权之和;若图不连通,输出 orz。

样例
输入:
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3

输出:
7

选边:1-2(2)、1-3(2)、1-4(3),合计 7

思路:Kruskal——边按权升序排序,依次尝试,用并查集判断两端是否已连通。选够 n−1 条即停;循环结束还没选够就是图不连通。

C++P8-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 200005;

struct Edge { int u, v; long long w; } E[M];
int fa[N];

bool cmp(const Edge& a, const Edge& b) { return a.w < b.w; }

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++)
        scanf("%d%d%lld", &E[i].u, &E[i].v, &E[i].w);

    sort(E + 1, E + m + 1, cmp);          // ① 按边权升序

    for (int i = 1; i <= n; i++) fa[i] = i;   // ② 并查集初始化

    long long sum = 0;
    int cnt = 0;
    for (int i = 1; i <= m; i++) {
        int a = find(E[i].u), b = find(E[i].v);
        if (a == b) continue;              // ③ 已成环,跳过
        fa[a] = b;                         // ④ 合并
        sum += E[i].w;
        cnt++;
        if (cnt == n - 1) break;           // ⑤ 够了就停
    }

    if (cnt < n - 1) puts("orz");
    else printf("%lld\n", sum);
    return 0;
}
代码逐段解析
L8
cmp 必须写成 a.w < b.w(升序)。写成大于号就变成最大生成树了——这是 P8-3 才会用到。排序方向错了,答案会完全反过来。
L10
路径压缩的一行写法:fa[x] == x ? x : fa[x] = find(fa[x])。赋值 fa[x] = ... 是必须的,它把路径上的点直接挂到根,省掉后续查询的遍历。只写 return find(fa[x]) 会退化成链。
L19
sort(E+1, E+m+1, cmp) 左闭右开:从下标 1 排到下标 m(含),所以右端点是 m+1。写成 E+m 会漏掉最后一条边,某些数据下答案偏大。
L21
并查集初始化必须在排序之后、循环之前。fa[i] = i 表示每个点自成一个集合。漏了这一句,fa 全是 0,find 会把所有点当成同一个集合,一条边都选不上。
L26-27
a == b 说明两端已在同一连通块,加这条边会形成环,直接跳过。自环(u == v)在这里天然被跳过,不用特判;重边中较大的那条也会因为「已成环」被跳掉。
L28-30
合并 + 累加 + 计数三件事必须一起做。漏了 cnt++ 就永远不会 break,会把所有能选的边都选完(但因为成环判断,结果恰好还是 n−1 条——只是白跑);漏了 sum += 则答案恒为 0。
L36
cnt < n-1 判不连通:n 个点至少要 n−1 条边才能连通。少于这个数说明有的点根本没边可连。这一步不能省,否则不连通时会输出一个「看起来正常」的偏小答案。
为什么 sum 要用 long longn = 5000、w = 10⁹ → 总权最大 5×10¹²,远超 int 上限 2.1×10⁹。MST 的答案几乎总是要 long long,因为边数和边权相乘很容易爆 int。
P8-2

村村通:已有道路免费

基础
输入 n k + k 条已通路 + m 条可修路输出 最小花费n ≤ 5000,m ≤ 2×10⁵

n 个村庄,其中 k 条路已经修好(可以免费通行)。另有 m 条路允许修,各有造价。求让所有村庄连通的最小总造价。

样例
输入:
5 2
1 2
3 4
3
1 3 5
2 3 6
4 5 2

输出:
7

说明:已通 1-2、3-4 免费;再修 4-5(2) 和 1-3(5),总花费 2+5 = 7

关键技巧:把已修好的路的造价设为 0。因为「已经能走」等价于「这条边的代价是 0」。把它们和普通边一起丢进 Kruskal,算法会优先选它们(0 是最小的),相当于先把已有的连通块合并好,再花钱补缺口。

C++P8-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 400005;

struct Edge { int u, v; long long w; } E[M];
int fa[N], tot;

bool cmp(const Edge& a, const Edge& b) { return a.w < b.w; }
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

int main() {
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) fa[i] = i;

    for (int i = 1; i <= k; i++) {
        int u, v; scanf("%d%d", &u, &v);
        E[++tot] = {u, v, 0};            // 已修好:代价 0
    }

    int m; scanf("%d", &m);
    for (int i = 1; i <= m; i++) {
        int u, v; long long w;
        scanf("%d%d%lld", &u, &v, &w);
        E[++tot] = {u, v, w};
    }

    sort(E + 1, E + tot + 1, cmp);

    long long sum = 0;
    int cnt = 0;
    for (int i = 1; i <= tot; i++) {
        int a = find(E[i].u), b = find(E[i].v);
        if (a == b) continue;
        fa[a] = b;
        sum += E[i].w;
        if (++cnt == n - 1) break;
    }

    if (cnt < n - 1) puts("无法连通");
    else printf("%lld\n", sum);
    return 0;
}
代码逐段解析
L5-6
用 tot 而不是 m 当边数。因为边表里混合了「已有边」和「可修边」两类,总数是 k+m。把两种来源合并到一个数组里统一排序,是这类题最省事的写法。
L9-10
并查集初始化提到最前面(读入已有边之前)。因为后面可能要在读入时就合并,提前初始化更稳妥,也符合「先准备好工具再干活」的习惯。
L14
E[++tot] = {u, v, 0}:C++11 的花括号初始化,等价于逐个赋值。写成 E[tot] = ... 而不先 ++tot 会覆盖上一条边。
L14
为什么是 0 而不是负数:代价 0 表示「不花钱就能用」,排序后一定排在最前面、一定被选中。如果用 −1,虽然也会被选,但 sum 会变成负数,最后还要加回来——多余且易错。0 是语义最干净的选择。
L23
可修边用 long long w 读入,再存进结构体。若这里用 int 读 10⁹ 再加上 sum,累加过程会溢出。
L27
sort(E+1, E+tot+1, cmp) 排的是全部 k+m 条边,权重 0 的已有边自然跑到最前面,先被处理。
L36
if (++cnt == n-1) break; 把「自增」和「判断」写在一句里。因为 cnt 只在这里用,合并更紧凑;注意必须是前自增 ++cnt,cnt++ 会先比较后加导致差一。
三招通吃 MST 变形① 已有 / 免费的边 → 权值设 0;② 必须选的边 → 权值设 -INF,或用并查集预先合并;③ 禁用的边 → 干脆不加进边表。
这三招能解决 80% 的 MST 应用题。
P8-3

最大生成树

基础
输入 n m + m 条无向边输出 最大总权n ≤ 5000,m ≤ 2×10⁵

求边权之和最大的生成树(仍要求 n−1 条边、连通、无环)。若图不连通输出 -1。

样例
输入:
4 4
1 2 5
2 3 4
3 4 3
1 4 10

输出:
19

选边:1-4(10)、1-2(5)、2-3(4),合计 19

改动只有一处:把比较函数从 a.w < b.w 改成 a.w > b.w,让边从大到小排序。Kruskal 的贪心逻辑完全对称——「每次选当前能选的最大边」得到的就是最大生成树。

另一种等价写法:把所有边权取负,跑最小生成树,最后答案取负。两种都对,但直接改排序更直观。

C++P8-3 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 200005;

struct Edge { int u, v; long long w; } E[M];
int fa[N];

bool cmp(const Edge& a, const Edge& b) { return a.w > b.w; }   // 降序!

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++)
        scanf("%d%d%lld", &E[i].u, &E[i].v, &E[i].w);

    sort(E + 1, E + m + 1, cmp);
    for (int i = 1; i <= n; i++) fa[i] = i;

    long long sum = 0;
    int cnt = 0;
    for (int i = 1; i <= m; i++) {
        int a = find(E[i].u), b = find(E[i].v);
        if (a == b) continue;
        fa[a] = b; sum += E[i].w; cnt++;
        if (cnt == n - 1) break;
    }

    printf("%lld\n", cnt == n - 1 ? sum : -1);
    return 0;
}
代码逐段解析
L7
a.w > b.w 就是全部区别。C++ 的 sort 按「比较函数返回真」的顺序排列,所以返回 a.w > b.w 时大的排前面。把这一行和 P8-1 对比着记,两个模板就都掌握了。
L13
边权用 long long 读。最大生成树的答案可能比最小生成树更大(因为专挑大边),更容易溢出。
L20-24
循环体一个字都不用改——「成环就跳过、否则合并」的逻辑与最小生成树完全相同。贪心的方向由排序决定,而不是由循环决定。这是 Kruskal 最优雅的地方。
L26
输出用三目把两种结果合并:连通就输出 sum,否则 −1。比 if-else 短,也不容易漏掉 else 分支。
为什么贪心在「最大」时也成立把每条边的权值取负,原图的「最大生成树」就是新图的「最小生成树」——因为最大化 Σw 等价于最小化 Σ(−w)。而最小生成树的 Kruskal 已被证明正确,所以最大生成树也一定正确。这类「取负转化」是很常用的证明与实现手段。
🧠本章小结
要点内容
MST 定义n 个点、n−1 条边、连通无环、总权最小
Kruskal边排序 → 并查集判环 → 选够 n−1 条。O(m log m),稀疏图首选
Prim点集扩张,每次取最小跨界边。O(n²),稠密图首选
最易错Prim 的 dis[v] = g[u][v] 不是 dis[u]+g[u][v]
三大变形已有边权置 0、必选边权置 −INF、禁用边不入表
理论武器割性质(最小跨界边必在 MST)、环性质(最大环边必不在 MST)
进度提示到这里,CSP-J 要求的图论内容已经全部学完。第 9 章是给 CSP-S 衔接的进阶内容(Tarjan、割点与桥、二分图匹配),CSP-J 不考,但学完你对「图」的理解会上一个台阶。
第 9 章

进阶选学:Tarjan · 割点与桥 · 二分图匹配提高 · CSP-J 不要求

这一章是给学有余力的同学准备的「上限拓展」。CSP-J 不会考,但 CSP-S / NOIP 里它们是常客,而且思路都非常漂亮——一次 DFS 就能算出整张图的结构信息。看不懂可以先跳过,等打完 CSP-J 再回来。

9.1强连通分量(SCC)与「缩点」

在有向图里,「连通」要分强弱:

  • 弱连通:把有向边当成无向边(忽略方向)后连通。
  • 强连通:u 能到 v,v 也能到 u。两个点必须双向可达。
  • 强连通分量(SCC):极大的强连通子图。一个点自己也算一个 SCC。
为什么要在乎 SCC同一个 SCC 里的点互相都能到达。很多题里这意味着「进了这个环就可以随便走,代价可以共享」。把每个 SCC 压成一个点(叫缩点),原图就变成一个 DAG,于是可以套拓扑排序 + DP 解最长路、方案数、必经点等问题。
SCC ① 1 2 3 SCC ② 4 5 3 → 4
左边 {1,2,3} 互相可达 = 一个 SCC;右边 {4,5} 也是。缩点后变成 A → B 的 DAG

Tarjan 算法只用一次 DFS。它给每个点记两个值:

  • dfn[u]:时间戳,第几个被访问到。
  • low[u]:从 u 出发、只经过 DFS 栈里的点,能回溯到的最小 dfn。

当 low[u] == dfn[u] 时,说明 u 是这个 SCC 里「最靠上」的点,此时把栈里从 u 到栈顶的所有点弹出,它们就构成一个 SCC。

C++Tarjan 求强连通分量(链式前向星)
int dfn[N], low[N], timer;
int stk[N], top, scc[N], sccCnt;
bool inStk[N];

void tarjan(int u) {
    dfn[u] = low[u] = ++timer;      // 打时间戳
    stk[++top] = u; inStk[u] = true; // 入栈

    for (int i = head[u]; i != -1; i = nxt[i]) {
        int v = to[i];
        if (!dfn[v]) {              // 树边:先递归
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (inStk[v]) {      // 后向边 / 横叉边:只在栈内才有意义
            low[u] = min(low[u], dfn[v]);
        }
    }

    if (low[u] == dfn[u]) {         // u 是本 SCC 的根
        sccCnt++;
        while (true) {
            int v = stk[top--];
            inStk[v] = false;
            scc[v] = sccCnt;        // 标记所属 SCC
            if (v == u) break;
        }
    }
}
// 主函数里对所有未访问点调用 tarjan(i),注意图可能不连通
三处最容易写错 ① else if (inStk[v]) low[u] = min(low[u], dfn[v]) —— 用 dfn 不是 low[v];
② 主函数必须 遍历所有点 调用 tarjan,否则漏掉不连通的块;
③ 弹栈时要 先标记再判断 v == u,顺序反了会漏掉根自己。
C++缩点:把 SCC 压成点,重建成 DAG
// 1. 先跑一遍 tarjan,得到 scc[u](每个点属于哪个分量)
for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i);

// 2. 把点权累加到分量上
for (int i = 1; i <= n; i++) w[scc[i]] += a[i];

// 3. 遍历所有边,若两端不属于同一分量,就加一条新边
for (int u = 1; u <= n; u++)
    for (int i = head[u]; i != -1; i = nxt[i]) {
        int v = to[i];
        if (scc[u] != scc[v]) add2(scc[u], scc[v]);
    }
// 4. 新图一定是 DAG,可以直接拓扑排序 + DP
典型应用「每个点有点权,可以重复走,求从起点出发能获得的最大权值和」——因为能重复走,一个 SCC 里的权可以全部吃干净,缩点后变成 DAG 上求「最大权路径」,拓扑 + DP 一遍即可。这就是 NOIP 经典题「最优贸易 / 抢掠计划」的解法骨架。
9.2割点与桥:图上最脆弱的部位

在无向连通图里:

  • 割点:删掉这个点(及与之相连的所有边)后,图不再连通的点。
  • 桥:删掉这条边后,图不再连通的边。

它们描述的是「网络的瓶颈」:一个通信网里,割点就是「一旦瘫痪就全网分区」的关键节点。

1 2 3 4 5 桥 桥 删掉 4 号点 → {1,2,3} 与 {5} 分离,所以 4 是割点 删掉 4-5 这条边 → 5 变成孤立点,所以它是桥
三角形内部的边都不是桥(删掉还有另一条路绕过去);4 的度数是 2,删掉就断链
C++Tarjan 求割点(无向图)
int dfn[N], low[N], timer;
bool cut[N];        // cut[u] = true 表示 u 是割点

void dfs(int u, int fa) {
    dfn[u] = low[u] = ++timer;
    int child = 0;              // u 在 DFS 树里的孩子数

    for (int i = head[u]; i != -1; i = nxt[i]) {
        int v = to[i];
        if (v == fa) continue;  // 无向图不能走回父亲(有重边时要改成判边编号)
        if (!dfn[v]) {
            child++;
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            // 非根节点:只要有一个孩子回不到 u 的祖先,u 就是割点
            if (fa != 0 && low[v] >= dfn[u]) cut[u] = true;
        } else {
            low[u] = min(low[u], dfn[v]);
        }
    }
    // 根节点单独判:有两棵以上子树才是割点
    if (fa == 0 && child >= 2) cut[u] = true;
}

// 主函数:对每个未访问点调用 dfs(i, 0)
割点与桥的判据只差一个等号 割点:low[v] >= dfn[u](v 顶多回到 u 自己,删掉 u 就上不去了)
桥:low[v] > dfn[u](v 连 u 都回不到,删掉这条边必然断开)
记住这个「等号差别」,两条判据就不会混。
重边陷阱用 if (v == fa) continue; 跳过父亲,在有重边时会出错:u 和 v 之间有两条边,走第一条到 v 后,第二条本应让 v 的 low 变小,但被这句直接跳过了,导致误判成桥。
正确做法:记录边的编号,跳过「来时的那一条边」而不是「父亲那个点」——if (i == (from ^ 1)) continue;(链式前向星建边时成对编号,^1 就是反向边)。
9.3二分图最大匹配:匈牙利算法

匹配:在二分图里选若干条边,要求任意两条边不共用端点。边的数量最多能达到多少,就是最大匹配。

典型场景:n 个学生、m 个任务,每个学生只能做自己能做的任务,每个任务只能派给一个人,最多能安排多少个任务?

核心思想:能抢就抢,抢不到就让对方去「换一个」从左部每个点出发尝试找配对。如果看中的右部点已经被别人占了,就递归地请那个人换一个别的——只要他能换成功,当前位置就让给你。这个「请对方腾位置」的过程叫增广路。
A B C 学生(左部) ① ② ③ 任务(右部) 已匹配 已匹配 A-①、B-② 已成对;C 只能选 ②,但 ② 已被 B 占
C 请求 ②:看 B 能不能换去 ③(B 可以)→ B 让位,C 拿到 ②,匹配数从 2 变 3
C++匈牙利算法(DFS 找增广路,O(nm))
#include <bits/stdc++.h>
using namespace std;

vector<int> g[N];   // g[u]:左部点 u 可连的右部点
int match[N];       // match[v]:右部点 v 目前配给了哪个左部点(0 = 未匹配)
bool vis[N];        // vis[v]:本轮 DFS 中右部点 v 是否已被尝试过

bool dfs(int u) {
    for (int i = 0; i <(int)g[u].size(); i++) {
        int v = g[u][i];
        if (vis[v]) continue;      // 本轮已经试过,别再绕回来(防死循环)
        vis[v] = true;
        // v 没被占,或占它的人能换到别处 → 让出来
        if (match[v] == 0 || dfs(match[v])) {
            match[v] = u;
            return true;
        }
    }
    return false;
}

int main() {
    int n, m, e; scanf("%d%d%d", &n, &m, &e);
    for (int i = 1; i <= e; i++) {
        int u, v; scanf("%d%d", &u, &v);
        g[u].push_back(v);
    }

    int ans = 0;
    for (int u = 1; u <= n; u++) {
        memset(vis, 0, sizeof vis);   // 每个左部点重新清空!
        if (dfs(u)) ans++;
    }
    printf("%d\n", ans);
    return 0;
}
两个必错点 ① vis 必须在每个左部点开始前清空,且在 dfs 内部只对右部点标记——它表示「这一轮增广里,这个右部点已经被考虑过了」,避免无限递归;
② 不能在找到一条边就 return,必须遍历完所有候选,否则会漏掉可行的增广路,答案偏小。
二分图的两个基本结论① 二分图的充要条件是不含奇环(第 3 章用染色法判过);② 二分图最大匹配 = 最小点覆盖(König 定理),也等于「最大独立集 = n − 最大匹配」。这三个数互相换算,是初赛选择题的常客。
9.4再加一招:最短路计数与次小生成树

两个常与前面算法搭配出现的小技巧,各给一句核心。

📊 最短路计数

求「从 s 到 t 有多少条最短路」。在松弛时多维护一个 cnt[]:

· 若 dis[u]+w < dis[v]:dis[v]=dis[u]+w; cnt[v]=cnt[u];
· 若 dis[u]+w == dis[v]:cnt[v]=(cnt[v]+cnt[u])%MOD;

用 Dijkstra 时必须按 dis 从小到大传播,否则计数会被重复累加。所以别用 SPFA 做计数。

🌲 次小生成树

先求出一棵 MST,然后枚举每一条非树边 (u,v,w):把它加进树会形成一个环,环上权值最大的那条树边 f 可以被替换掉,新树权 = MST总权 + w − w(f)。

取所有替换方案中最小且严格大于 MST 的那个,就是次小生成树。环上最大边用树上倍增(LCA)或暴力求均可。

这两个技巧的共同点都不需要新算法,只是在原算法的松弛 / 选边过程中多维护一个信息。竞赛里大量「最短路变形」题都是这个套路:多记一个 cnt、多记一个最大值、多记一个前驱。与其背新算法,不如把模板改造成「可扩展」的形态。
✍️第 9 章编程题(2 道,选做)

这两题是 CSP-S 难度的入门,CSP-J 不会考。但如果你已经能独立写出前 8 章的题,这两道正好用来检验「能不能读懂别人的算法框架并改写成自己的代码」。

P9-1

缩点 + DAG 最大权路径

挑战
输入 n m + 点权 + m 条有向边输出 最大权值和n ≤ 10⁴,m ≤ 10⁵

有 n 个点,第 i 个点有权值 a[i](可能为负)。m 条有向边,允许重复经过同一个点,但同一个点的权值只算一次。求从 1 号点出发,能获得的最大权值和。

样例
输入:
4 4
1 2 3 4
1 2
2 1
2 3
3 4

输出:
10

说明:1 与 2 互相可达(一个 SCC),能全吃掉 = 1+2 = 3;
      再走 3(3) → 4(4),总计 3+3+4 = 10

思路(三步走):

  1. Tarjan 求 SCC:把互相可达的点压成一个分量,分量权值 = 内部所有点权之和。
  2. 缩点重建图:跨分量的边变成新边,得到的图一定是 DAG。
  3. 拓扑序上 DP:f[v] = max(f[v], f[u] + sw[v]),只从 1 号分量出发传播。
C++P9-1 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 10005, INF = 0x3f3f3f3f;

vector<int> e[N], g[N];              // e 原图,g 缩点后的 DAG
int dfn[N], low[N], timer;
int stk[N], top, scc[N], sccCnt;
bool inStk[N];

int a[N], sw[N], indeg[N], f[N];

void tarjan(int u) {
    dfn[u] = low[u] = ++timer;
    stk[++top] = u; inStk[u] = true;
    for (int i = 0; i < (int)e[u].size(); i++) {
        int v = e[u][i];
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (inStk[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (low[u] == dfn[u]) {
        sccCnt++;
        while (true) {
            int v = stk[top--];
            inStk[v] = false;
            scc[v] = sccCnt;
            if (v == u) break;
        }
    }
}

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);
    }

    for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i);   // ① 缩点

    for (int i = 1; i <= n; i++) sw[scc[i]] += a[i];        // ② 合并点权
    for (int u = 1; u <= n; u++)
        for (int i = 0; i < (int)e[u].size(); i++) {
            int v = e[u][i];
            if (scc[u] != scc[v]) {
                g[scc[u]].push_back(scc[v]);               // ③ 建新图
                indeg[scc[v]]++;
            }
        }

    memset(f, 0xc0, sizeof f);          // -INF
    f[scc[1]] = sw[scc[1]];

    queue<int> q;
    for (int c = 1; c <= sccCnt; c++) if (indeg[c] == 0) q.push(c);

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < (int)g[u].size(); i++) {
            int v = g[u][i];
            if (f[u] != -INF) f[v] = max(f[v], f[u] + sw[v]);   // ④ DP
            if (--indeg[v] == 0) q.push(v);
        }
    }

    int ans = -INF;
    for (int c = 1; c <= sccCnt; c++) ans = max(ans, f[c]);
    printf("%d\n", ans);
    return 0;
}
代码逐段解析
L14-16
Tarjan 的三件套:打时间戳、入栈、标记在栈。这三步的顺序不能变——先赋 dfn/low,再压栈,再标 inStk。
L19-24
两种边要分开处理:!dfn[v] 是树边(继续递归,用 low[v] 更新);inStk[v] 是回边(用 dfn[v] 更新)。回边只能用 dfn,用 low[v] 会算错。
L26-26
low[u] == dfn[u] 说明 u 是分量的根,此时栈里从 top 到 u 的所有点构成一个 SCC。这个判断必须放在递归返回之后,不能提前。
L28-34
弹栈顺序:先取 stk[top--]、再清 inStk、再赋 scc,最后判断是否等于 u。如果先判断再弹,u 自己会漏掉。这是最典型的「顺序写错就少一个点」。
L47
主函数必须遍历所有点调用 tarjan。图可能不连通,只调 tarjan(1) 会漏掉其他块,那些点的 scc 还是 0,后面全部出错。
L49
点权合并到分量上:sw[scc[i]] += a[i]。同一个 SCC 内的点互相可达,所以它们的权值可以全部吃到——这就是「缩点」在这类题里的物理意义。
L52-56
只在 scc[u] != scc[v] 时加新边。同分量内部的边全部丢弃(它们已经"内化"成点权了)。注意这里可能会产生重边,但拓扑排序与 DP 都不受影响,不用去重。
L58
memset(f, 0xc0, sizeof f) 是置 −INF 的常用写法(0xc0c0c0c0 ≈ −1.06×10⁹)。不能用 0x3f(那是正无穷),也不能用 −1(权值可能真的是 −1)。
L59
起点只设 f[scc[1]],其余保持 −INF。这样 DP 时只有从 1 号分量出发的路径才会被传播,从别的分量起步的路径不会污染答案。
L66
if (f[u] != -INF) 判断是否可达。不可达的分量 f 是 −INF,若直接加 sw[v] 会溢出成更小的负数,甚至变成正数。这一句是负权 DP 的安全阀。
L71-72
答案是所有分量的最大值,不是 f[scc[n]]。因为最优路径可能停在任何一个分量上。若题目要求「必须到 n 号点」,才改成输出 f[scc[n]]。
为什么不能直接在原图上跑最长路因为原图有环,最长路在环上可以无限绕(如果权值为正则无穷大)。缩点把环「压扁」成一个点后,图变成 DAG,最长路才有定义。「有环 → 缩点 → DAG 上 DP」是处理环形依赖的标准套路。
P9-2

割点计数

挑战
输入 n m + m 条无向边输出 割点个数n ≤ 2×10⁴,m ≤ 10⁵

给定无向图(可能不连通),求割点的个数。割点指:删掉这个点(及与之相连的所有边)后,图的连通块数量会增加的顶点。

样例
输入:
4 3
1 2
2 3
3 4

输出:
2

说明:这是一条链 1-2-3-4。删掉 2 → {1} 与 {3,4} 分离;删掉 3 同理。
      1 和 4 是端点,删掉它们只会少一个点,不算割点

思路:Tarjan 求 dfn/low。对非根节点 u,若存在孩子 v 满足 low[v] >= dfn[u],则 u 是割点(v 及其子树回不到 u 的祖先,只能"卡"在 u 这里)。根节点单独判:孩子数 ≥ 2 才是割点。

C++P9-2 参考代码
#include <bits/stdc++.h>
using namespace std;
const int N = 20005;

vector<int> e[N];
int dfn[N], low[N], timer;
bool cut[N];

void dfs(int u, int fa) {
    dfn[u] = low[u] = ++timer;
    int child = 0;                       // DFS 树上的孩子数

    for (int i = 0; i < (int)e[u].size(); i++) {
        int v = e[u][i];
        if (v == fa) continue;           // 不走回父亲

        if (!dfn[v]) {
            child++;
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if (fa != 0 && low[v] >= dfn[u]) cut[u] = true;
        } else {
            low[u] = min(low[u], dfn[v]);
        }
    }

    if (fa == 0 && child >= 2) cut[u] = true;   // 根的特殊判定
}

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);
        e[v].push_back(u);
    }

    for (int i = 1; i <= n; i++)
        if (!dfn[i]) dfs(i, 0);          // 0 表示没有父亲

    int ans = 0;
    for (int i = 1; i <= n; i++) if (cut[i]) ans++;
    printf("%d\n", ans);
    return 0;
}
代码逐段解析
L9
fa 用 0 表示「没有父亲」,因为点编号从 1 开始,0 一定不是合法点。这样就能用 fa == 0 判断当前是否是 DFS 树的根。
L10
child 只统计 DFS 树上的孩子(即 !dfn[v] 的那条分支),不包括「已访问的祖先或兄弟」。这个数只用于根节点的判定。
L14
if (v == fa) continue; 跳过父节点,否则会在父子之间来回递归、死循环。但这一句在有重边时会出错(见下方警示)。
L19
low[v] >= dfn[u] 是割点的判据:v 最多只能回到 u 自己,回不到 u 的祖先。所以删掉 u 之后,v 那棵子树就与外界失联了。注意是 大于等于。
L19
fa != 0 限定「非根」:根节点不适用这个判据,因为根没有祖先,low[v] >= dfn[root] 几乎永远成立。根要用 child >= 2 单独判。
L21-22
已访问的点用 dfn[v] 更新 low,不是 low[v]。这与 Tarjan 求 SCC 完全一致——都是「回边取 dfn」。
L25
根节点判定放在循环外:等所有孩子都访问完,child 才是最终值。放在循环里判断会因为计数不全而误判。
L36-37
主函数遍历所有未访问点。图不连通时,每个连通块都要各自跑一次 DFS,否则漏算。注意每个块的根都用 fa = 0 调用。
重边陷阱(务必知道)如果 u 和 v 之间有两条边,从 u 走第一条到 v 后,第二条边本应让 low[v] 变小;但 if (v == fa) continue; 会把它当成「回到父亲」直接跳过,导致误判成割点/桥。
正确做法:链式前向星建边时成对编号,改成跳过「来时的边」——if (i == (from ^ 1)) continue;,其中 from 是走到 u 时用的那条边的编号。
割点与桥只差一个等号割点:low[v] >= dfn[u];桥:low[v] > dfn[u]。
把这道题的 >= 改成 >,并把标记从点换成「这条边的编号」,就得到求桥的代码。两道题可以一起背。
🧠本章小结
内容核心判据 / 复杂度
Tarjan 求 SCClow[u]==dfn[u] 则弹栈成组;O(n+m)
缩点同一 SCC 压成一点,得 DAG,可拓扑 + DP
割点非根:low[v] >= dfn[u];根:子树 ≥ 2
桥low[v] > dfn[u](注意重边要用边编号跳过)
匈牙利匹配DFS 找增广路,「请对方让位」;O(nm)
最短路计数松弛时同步维护 cnt,只在严格更短/相等时分别处理
次小生成树枚举非树边,替换环上最大树边
学习建议这一章不用背代码,先记住「什么问题该想到什么结构」:
· 能互相到达 → SCC · 删掉就断 → 割点 / 桥 · 一一配对 → 二分图匹配
有了这个条件反射,等真正需要时再回来抄模板,效率最高。
附录

编程实战卷与刷题路线全部为编程题

13 道编程题,分三档。先自己做,做完再点开折叠的参考答案对照——13 份代码全部用 g++ 编译、用样例与边界数据实测通过,可直接照抄提交。答案前面那句「点睛」讲的是这道题最容易踩的坑,建议先看它再读代码。

📕 答案默认折叠。先自己写完再看——直接读答案会让你产生「我会了」的错觉。
A 组基础关(5 题,对应第 1~4 章)

这一组的目的是「把模板写熟」。如果某题卡住了,说明对应章节的模板还没背下来,回去重抄三遍。

A-1

度序列判定

基础
输入 n + n 个度数输出 YES / NOn ≤ 10⁵

给出 n 个点的度数序列,判断它能否构成一个无向图(不要求简单图)。只需用到握手定理。

输入

第一行 n,第二行 n 个整数表示各点度数。

输出

能构成输出 YES,否则 NO。

思路与代码要点
  • 握手定理:Σ度 = 2m 必须是偶数。奇数直接 NO。
  • 求和时用 long long,n 到 10⁵、度数可能很大。
  • 只需判断奇偶性:puts(sum % 2 == 0 ? "YES" : "NO");
  • 若题目要求「简单图」,还要用 Havel-Hakimi 逐次削度,那是另一套算法。
点击展开参考答案(完整可提交代码)

握手定理的逆命题不成立——度数全为偶数并不保证能构成简单图;但本题明确写了「不要求简单图」,所以只判奇偶就是完整答案。

C++A-1 参考答案
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; scanf("%d", &n);
    long long sum = 0;                 // n 到 1e5,度数可能很大,求和一律 long long
    for (int i = 1; i <= n; i++) {
        long long d; scanf("%lld", &d);
        sum += d;
    }
    puts(sum % 2 == 0 ? "YES" : "NO"); // 握手定理:Σ度 = 2m 必为偶数
    return 0;
}

O(n) 时间、O(1) 额外空间。注意用 long long 求和,不必真的建图。

A-2

有向图入度出度统计

基础
输入 n m + m 条有向边输出 每点入度出度n,m ≤ 2×10⁵

对每个点输出 出度 入度,并额外输出一行「入度为 0 的点数」与「出度为 0 的点数」。

思路与代码要点
  • 两个数组 outdeg[]、indeg[],读边时 outdeg[u]++、indeg[v]++。
  • 不需要建图——只统计度数的话,边表就够,省内存也省时间。
  • 入度 0 的点在拓扑排序里就是「可以立刻开始」的点,这个统计很有用。
  • 自环(u == v)会给出度、入度各 +1,不需要特殊处理。
点击展开参考答案(完整可提交代码)

关键认识:统计度数根本不需要邻接结构。边读进来就地加一,省下建图的整段代码,也省下 O(n+m) 的内存。

C++A-2 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
int indeg[N], outdeg[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        outdeg[u]++;                   // u 出去一条
        indeg[v]++;                    // v 进来一条
    }
    int zi = 0, zo = 0;
    for (int i = 1; i <= n; i++) {
        printf("%d %d\n", outdeg[i], indeg[i]);
        if (indeg[i] == 0) zi++;
        if (outdeg[i] == 0) zo++;
    }
    printf("%d %d\n", zi, zo);
    return 0;
}

O(n+m) 时间、O(n+m) 空间(两个计数数组)。自环给入度、出度各 +1,无需特判。

A-3

邻接表转邻接矩阵

基础
输入 n m + m 条有向边输出 n×n 的 0/1 矩阵n ≤ 1000

读入一个用边表描述的有向图,输出它的邻接矩阵。

思路与代码要点
  • 开 bool g[1005][1005](约 1MB),读边时 g[u][v] = true。
  • 有向图只标一个方向;无向图要对称标两个位置。
  • 输出时用 printf("%d ", (int)g[i][j]),bool 直接打印是 1/0,但加强制转换更保险。
  • 这题是 P2-1 的反向操作,两题一起做能彻底搞清两种存储的对应关系。
点击展开参考答案(完整可提交代码)

矩阵版的有效代码只有 6 行。真正要记住的是容量上限:1000² 个 bool ≈ 1MB 很安全,100000² 就是 10GB,直接崩。

C++A-3 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
bool g[N][N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        g[u][v] = true;                // 有向图只标一个方向
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++)
            printf("%d%c", (int)g[i][j], j == n ? '\n' : ' ');
    }
    return 0;
}

O(n²) 时间与空间。n ≤ 1000 是邻接矩阵的实用边界。

A-4

连通块数量与最大块

基础
输入 n m + m 条无向边输出 块数 + 最大块点数n,m ≤ 2×10⁵

求连通块个数,以及最大的那个连通块包含几个点。

思路与代码要点
  • 就是 P3-1 的简化版:外层扫未访问点 + BFS 统计 cnt。
  • 最大值可以在 BFS 内部直接 mx = max(mx, cnt),不用存数组。
  • 入队即标记,不要写成出队时标记。
  • 图可能完全不连通(m = 0),此时答案是 n 个块、每块 1 个点。
点击展开参考答案(完整可提交代码)

「入队即标记」在这里千万不能省:若等出队才标记,同一个点会被多个邻居重复塞进队列,队列长度爆炸、内存超限。

C++A-4 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> e[N];
bool vis[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v); e[v].push_back(u);   // 无向边两头都要加
    }
    int blocks = 0, mx = 0;
    for (int s = 1; s <= n; s++) {
        if (vis[s]) continue;
        blocks++;
        int cnt = 0;
        queue<int> q; q.push(s); vis[s] = true;   // 入队即标记
        while (!q.empty()) {
            int u = q.front(); q.pop(); cnt++;
            for (int v : e[u])
                if (!vis[v]) { vis[v] = true; q.push(v); }
        }
        mx = max(mx, cnt);
    }
    printf("%d %d\n", blocks, mx);
    return 0;
}

O(n+m)。每个点最多进队一次,每条边被看两次(无向图两头各一次)。

A-5

判断能否完成所有课程

基础
输入 n m + m 条依赖输出 YES / NOn,m ≤ 2×10⁵

n 门课,m 条先修关系 u v(学 v 前必须学 u)。判断能否修完全部课程。

思路与代码要点
  • 等价于「有向图是否无环」→ Kahn 拓扑排序,判 tot == n。
  • 初始队列要放所有入度为 0 的点。
  • 别用「有没有入度 0 的点」判环,那是不充分的。
  • 若要求输出具体顺序,直接在出队时打印即可(字典序最小则换小根堆)。
点击展开参考答案(完整可提交代码)

判环的正确姿势是数点数:只有「排出来的点数 = n」才等价于无环。看队列空不空、看有没有入度 0 的点,都是不充分的。

C++A-5 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> e[N];
int indeg[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        e[u].push_back(v);
        indeg[v]++;
    }
    queue<int> q;
    for (int i = 1; i <= n; i++) if (indeg[i] == 0) q.push(i);
    int tot = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop(); tot++;
        for (int v : e[u]) if (--indeg[v] == 0) q.push(v);
    }
    puts(tot == n ? "YES" : "NO");
    return 0;
}

O(n+m)。有环时环上的点入度永远减不到 0,tot 必然小于 n。

B 组提高关(5 题,对应第 5~8 章)
B-1

多组询问的任意两点最短路

提高
输入 n m + 边 + q 次询问输出 每次的最短路n ≤ 300,q ≤ 10⁴

无向带权图,q 次询问 u 到 v 的最短距离,不可达输出 −1。

思路与代码要点
  • n ≤ 300 → Floyd,O(n³) = 2.7×10⁷ 完全够;预处理一次,询问 O(1)。
  • 三件套:memset(dis, 0x3f)、dis[i][i] = 0、重边取 min。
  • k 必须在最外层,这是 DP 阶段而不是循环变量。
  • 若 n = 10⁵ 就只能 Dijkstra 跑 n 次或改题意,说明这题的选择依据是数据范围。
点击展开参考答案(完整可提交代码)

三重循环里 k 是「允许经过的中间点编号 ≤ k」,它是 DP 的阶段,所以必须在最外层;写在内层就退化成错误的贪心。

C++B-1 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 305, INF = 0x3f3f3f3f;
int dis[N][N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    memset(dis, 0x3f, sizeof(dis));            // 全部先设为不可达
    for (int i = 1; i <= n; i++) dis[i][i] = 0;
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        dis[u][v] = min(dis[u][v], w);         // 重边取较小值
        dis[v][u] = min(dis[v][u], w);         // 无向图对称
    }
    for (int k = 1; k <= n; k++)               // k 必须在最外层
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                if (dis[i][k] + dis[k][j] < dis[i][j])
                    dis[i][j] = dis[i][k] + dis[k][j];
    int q; scanf("%d", &q);
    while (q--) {
        int u, v; scanf("%d%d", &u, &v);
        printf("%d\n", dis[u][v] >= INF ? -1 : dis[u][v]);
    }
    return 0;
}

O(n³ + q)。n=300 时约 2.7×10⁷ 次运算,稳过;预处理一次,每次询问 O(1)。

B-2

单源最短路(边权非负)

提高
输入 n m s + m 条边输出 各点最短路n,m ≤ 2×10⁵,w ≤ 10⁹

标准堆优化 Dijkstra。不可达输出 −1。

思路与代码要点
  • 邻接表 + priority_queue<pair<long long,int>, vector<...>, greater<...>>。
  • 三处 long long:dis 数组、堆元素、INF(0x3f3f3f3f3f3f3f3fLL)。
  • 弹出后必须 if (d > dis[u]) continue;,否则重复扩展会 TLE。
  • 无向图边数组开 2m——这是最常见的 RE 原因。
点击展开参考答案(完整可提交代码)

三处 long long 缺一不可:dis 数组、堆里的 pair、INF 常量。最容易漏的是堆元素——写成 int 会在 w ≤ 10⁹、路径又长时溢出成负数。

C++B-2 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 200005, M = 400005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
int head[N], nxt[M], to[M], cntE;
ll w[M], dis[N];

void add(int u, int v, ll c) {
    to[++cntE] = v; w[cntE] = c;
    nxt[cntE] = head[u]; head[u] = cntE;
}

int main() {
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v; ll c; scanf("%d%d%lld", &u, &v, &c);
        add(u, v, c);                          // 无向图:反向再 add 一次
        add(v, u, c);
    }
    memset(dis, 0x3f, sizeof(dis));
    dis[s] = 0;
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    pq.push({0, s});
    while (!pq.empty()) {
        pair<ll,int> pr = pq.top(); pq.pop();
        ll d = pr.first; int u = pr.second;
        if (d > dis[u]) continue;              // 过期的旧记录,扔掉
        for (int i = head[u]; i; i = nxt[i]) {
            int v = to[i];
            if (d + w[i] < dis[v]) {
                dis[v] = d + w[i];
                pq.push({dis[v], v});
            }
        }
    }
    for (int i = 1; i <= n; i++)
        printf("%lld%c", dis[i] >= INF / 2 ? -1LL : dis[i], i == n ? '\n' : ' ');
    return 0;
}

O(m log m)。每条边最多触发一次入堆,堆大小 O(m)。

B-3

带负权的最短路

提高
输入 n m s + m 条边(含负权)输出 各点最短路n,m ≤ 2×10⁵,无负环

边权可能为负,数据保证从 s 不可达负环。求 s 到各点的最短路。

思路与代码要点
  • SPFA:队列 + inq 标记,出队即清标记。
  • 不能用 Dijkstra,即使没有负环也会错(见 6.6 反例)。
  • 不可达的判定要选一个「不可能出现的值」,比如 10⁹,不能用 −1(−1 可能是合法距离)。
  • 若题目数据专门卡 SPFA(明确写着「可能卡掉 SPFA」),就换 Bellman-Ford 或考虑题目是否另有结构。
点击展开参考答案(完整可提交代码)

inq 与队列必须严格同步:入队置 true、出队置 false。漏掉出队清零,一个点只会被扩展一次,负权带来的二次优化全部丢失,答案偏大。

C++B-3 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 200005, M = 400005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
int head[N], nxt[M], to[M], cntE;
ll w[M], dis[N];
bool inq[N];

void add(int u, int v, ll c) {
    to[++cntE] = v; w[cntE] = c;
    nxt[cntE] = head[u]; head[u] = cntE;
}

int main() {
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v; ll c; scanf("%d%d%lld", &u, &v, &c);
        add(u, v, c);                          // 有向边,只加一次
    }
    for (int i = 1; i <= n; i++) dis[i] = INF;
    queue<int> q;
    dis[s] = 0; q.push(s); inq[s] = true;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        inq[u] = false;                        // 出队清标记,之后还能再进来
        for (int i = head[u]; i; i = nxt[i]) {
            int v = to[i];
            if (dis[u] + w[i] < dis[v]) {
                dis[v] = dis[u] + w[i];
                if (!inq[v]) { inq[v] = true; q.push(v); }
            }
        }
    }
    for (int i = 1; i <= n; i++)
        printf("%lld%c", dis[i] >= INF / 2 ? 1000000000000000000LL : dis[i], i == n ? '\n' : ' ');
    return 0;
}

平均 O(km)、最坏 O(nm)。随机数据 k≈2~3;本题无负环,所以循环一定终止。

B-4

最小生成树

提高
输入 n m + m 条无向边输出 总权 / orzn ≤ 5000,m ≤ 2×10⁵

求最小生成树的边权之和,不连通输出 orz。

思路与代码要点
  • Kruskal:边升序排序 + 并查集判环,选够 n−1 条即停。
  • sort(E+1, E+m+1, cmp) 注意右端点是 m+1(左闭右开)。
  • 并查集要初始化 fa[i] = i,路径压缩写成 fa[x] = find(fa[x])。
  • 最后判 cnt < n-1,否则不连通时会输出偏小答案。
点击展开参考答案(完整可提交代码)

判不连通必须在循环结束后看 cnt,不能在循环里提前返回。否则不连通的数据会输出一个「看起来正常但偏小」的答案——这是最隐蔽的 WA。

C++B-4 参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 200005;
struct Edge { int u, v, w; } e[M];
int fa[N];

bool cmp(const Edge &a, const Edge &b) { return a.w < b.w; }

int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w);
    sort(e + 1, e + m + 1, cmp);               // 左闭右开,右端写 m+1
    for (int i = 1; i <= n; i++) fa[i] = i;    // 并查集初始化
    long long sum = 0; int cnt = 0;
    for (int i = 1; i <= m; i++) {
        int a = find(e[i].u), b = find(e[i].v);
        if (a == b) continue;                  // 已成环,跳过
        fa[a] = b;
        sum += e[i].w; cnt++;
        if (cnt == n - 1) break;               // 够 n-1 条即停
    }
    if (cnt < n - 1) puts("orz");              // 不连通
    else printf("%lld\n", sum);
    return 0;
}

O(m log m)。排序是瓶颈,并查集带路径压缩近似 O(1)。

B-5

工程最短完工时间

提高
输入 n m + 点权 + 依赖输出 最短完工时间n ≤ 10⁴,m ≤ 10⁵

每项工程有耗时,给出依赖关系,工程可并行。求整个项目的最短完工时间。

思路与代码要点
  • DAG 上最长路:f[v] = max(f[v], f[u] + t[v]),必须按拓扑序。
  • 入度 0 的点 f[i] = t[i],这是 DP 的边界。
  • 答案是 max(f[i]),不是 f[n]。
  • 求「最晚开始时间」就是反图 + 倒推,是同一套思路的镜像。
点击展开参考答案(完整可提交代码)

把 f[i] 全部初始化为 t[i] 是一行漂亮的写法:对入度 0 的点它就是边界,对其余点会被后面的 max 自然覆盖。

C++B-5 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 10005;
vector<int> e[N];
int indeg[N], t[N];
ll f[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) { scanf("%d", &t[i]); f[i] = t[i]; }
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);       // u 完成后 v 才能开始
        e[u].push_back(v); indeg[v]++;
    }
    queue<int> q;
    for (int i = 1; i <= n; i++) if (indeg[i] == 0) q.push(i);
    int tot = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop(); tot++;
        for (int v : e[u]) {
            f[v] = max(f[v], f[u] + t[v]);     // 前驱全部完成才能开始
            if (--indeg[v] == 0) q.push(v);
        }
    }
    ll ans = 0;
    for (int i = 1; i <= n; i++) ans = max(ans, f[i]);   // 不是 f[n]
    printf("%lld\n", ans);
    return 0;
}

O(n+m)。拓扑序上做一次最长路 DP,答案是 max(f[i]) 而不是 f[n]。

C 组挑战关(3 题,选做)
C-1

最短路计数

挑战
输入 n m + m 条边输出 方案数 mod 1e9+7n,m ≤ 2×10⁵

求 1 到 n 的最短路径条数。

思路与代码要点
  • Dijkstra 里多维护 cnt[]:更短则赋值,相等则累加。
  • cnt[1] = 1 是必须的边界(空路径算一条)。
  • 不能加 vis 判重,否则会漏掉相等距离的后续累加。
  • 别用 SPFA 做计数——重复出队会导致同一批方案被重复累加。
点击展开参考答案(完整可提交代码)

分两步最稳:先 Dijkstra 定距离,再按距离升序做 DP。边权为正时「最短路图」一定是 DAG,按距离排序后每条边只会被算一次,不会重复也不会漏。

C++C-1 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 200005, M = 400005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL, MOD = 1000000007;
int head[N], nxt[M], to[M], cntE;
ll w[M], dis[N], cnt[N];

void add(int u, int v, ll c) {
    to[++cntE] = v; w[cntE] = c;
    nxt[cntE] = head[u]; head[u] = cntE;
}

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v; ll c; scanf("%d%d%lld", &u, &v, &c);
        add(u, v, c); add(v, u, c);            // 无向带权图
    }
    // 第一步:Dijkstra 求出真正的距离
    memset(dis, 0x3f, sizeof(dis)); dis[1] = 0;
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    pq.push({0, 1});
    while (!pq.empty()) {
        pair<ll,int> pr = pq.top(); pq.pop();
        ll d = pr.first; int u = pr.second;
        if (d > dis[u]) continue;
        for (int i = head[u]; i; i = nxt[i]) {
            int v = to[i];
            if (d + w[i] < dis[v]) { dis[v] = d + w[i]; pq.push({dis[v], v}); }
        }
    }
    if (dis[n] >= INF / 2) { puts("0"); return 0; }
    // 第二步:按距离从小到大做 DP,最短路径图一定是 DAG
    vector<int> ord;
    for (int i = 1; i <= n; i++) if (dis[i] < INF / 2) ord.push_back(i);
    sort(ord.begin(), ord.end(), [](int a, int b) { return dis[a] < dis[b]; });
    cnt[1] = 1;                                // 边界:到起点本身有 1 条空路径
    for (int u : ord) {
        if (!cnt[u]) continue;
        for (int i = head[u]; i; i = nxt[i]) {
            int v = to[i];
            if (dis[u] + w[i] == dis[v])        // 这条边属于某条最短路
                cnt[v] = (cnt[v] + cnt[u]) % MOD;
        }
    }
    printf("%lld\n", cnt[n]);
    return 0;
}

O(m log m + n log n)。cnt[1] = 1 是必须的边界(空路径算一条);不可达输出 0。

C-2

k 次免费通行的最短路

挑战
输入 n m k + m 条双向边输出 最小花费n ≤ 10⁴,k ≤ 10

每条边有通行费,有 k 次机会把某条边的费用变 0,求 1 到 n 的最小花费。

思路与代码要点
  • 分层图:dis[u][j] 表示到 u、已用 j 次机会。
  • 两种转移:付费走同层 dis[v][j] = dis[u][j] + w;免费跳层 dis[v][j+1] = dis[u][j]。
  • 跳层要判 j < k,否则越界。
  • 答案是 min(dis[n][0..k]),不是 dis[n][k]。
点击展开参考答案(完整可提交代码)

分层图的本质是把「状态」当成点:元组 (u, j) 就是一个新点,两种转移分别是「同层付费」和「跳层免费」。想通这一点,代码只是普通 Dijkstra。

C++C-2 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 10005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
struct Edge { int to; ll w; };
vector<Edge> e[N];
ll dis[N][11];                                  // k ≤ 10,开 11 层

int main() {
    int n, m, k; scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i <= m; i++) {
        int u, v; ll w; scanf("%d%d%lld", &u, &v, &w);
        e[u].push_back({v, w});
        e[v].push_back({u, w});                 // 双向边
    }
    for (int i = 1; i <= n; i++)
        for (int j = 0; j <= k; j++) dis[i][j] = INF;
    dis[1][0] = 0;
    // 状态压成一个整数:id = 点号 * (k+1) + 已用次数
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    pq.push({0, 1 * (k + 1) + 0});
    while (!pq.empty()) {
        pair<ll,int> pr = pq.top(); pq.pop();
        ll d = pr.first; int id = pr.second;
        int u = id / (k + 1), j = id % (k + 1);
        if (d > dis[u][j]) continue;
        for (Edge ed : e[u]) {
            if (d + ed.w < dis[ed.to][j]) {     // 付费:留在同一层
                dis[ed.to][j] = d + ed.w;
                pq.push({dis[ed.to][j], ed.to * (k + 1) + j});
            }
            if (j < k && d < dis[ed.to][j + 1]) {   // 免费:跳到下一层
                dis[ed.to][j + 1] = d;
                pq.push({d, ed.to * (k + 1) + j + 1});
            }
        }
    }
    ll ans = INF;
    for (int j = 0; j <= k; j++) ans = min(ans, dis[n][j]);   // 机会可以不用完
    printf("%lld\n", ans);
    return 0;
}

O(km log(km)),状态数 n(k+1)。答案是 min(dis[n][0..k])——机会可以不用完。

C-3

缩点求最大权路径

挑战
输入 n m + 点权 + m 条有向边输出 最大权值和n ≤ 10⁴

点有点权,可重复经过点但权值只算一次,求从 1 出发能获得的最大权值和。

思路与代码要点
  • 三步:Tarjan 求 SCC → 合并点权 + 重建 DAG → 拓扑 DP。
  • Tarjan 里「回边用 dfn[v]」不能写错成 low[v]。
  • 弹栈时先标记再判断 v == u,顺序反了会漏掉根。
  • DP 起点只设 f[scc[1]],其余 −INF;传递时判 f[u] != -INF。
点击展开参考答案(完整可提交代码)

缩点后必须重建图,直接在原图上跑拓扑 DP 会被环卡死(环上的点入度永远减不到 0)。重建时只需处理 scc[u] != scc[v] 的边。

C++C-3 参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 10005, M = 100005;
int head[N], nxt[M], to[M], cntE;
int dfn[N], low[N], stk[N], top, timer_, scc[N], scnt;
bool inStk[N];
ll val[N], sw[N];                               // sw:缩点后的点权

void add(int u, int v) {
    to[++cntE] = v; nxt[cntE] = head[u]; head[u] = cntE;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++timer_;
    stk[++top] = u; inStk[u] = true;
    for (int i = head[u]; i; i = nxt[i]) {
        int v = to[i];
        if (!dfn[v]) { tarjan(v); low[u] = min(low[u], low[v]); }
        else if (inStk[v]) low[u] = min(low[u], dfn[v]);   // 回边用 dfn[v]
    }
    if (low[u] == dfn[u]) {
        scnt++; int v;
        do {
            v = stk[top--]; inStk[v] = false;
            scc[v] = scnt; sw[scnt] += val[v];
        } while (v != u);
    }
}

vector<int> g[N];
int indeg[N];
ll f[N];

int main() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%lld", &val[i]);
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        add(u, v);
    }
    for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i);     // 图可能不连通

    for (int u = 1; u <= n; u++)                              // 重建 DAG
        for (int i = head[u]; i; i = nxt[i]) {
            int v = to[i];
            if (scc[u] != scc[v]) { g[scc[u]].push_back(scc[v]); indeg[scc[v]]++; }
        }
    queue<int> q;
    for (int i = 1; i <= scnt; i++) { f[i] = -1; if (!indeg[i]) q.push(i); }
    f[scc[1]] = sw[scc[1]];                                   // 只从 1 号所在的点出发
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : g[u]) {
            if (f[u] > 0) f[v] = max(f[v], f[u] + sw[v]);
            if (--indeg[v] == 0) q.push(v);
        }
    }
    ll ans = 0;
    for (int i = 1; i <= scnt; i++) ans = max(ans, f[i]);
    printf("%lld\n", ans);
    return 0;
}

O(n+m)。Tarjan 线性 + 一次拓扑 DP;递归深度 ≤ n,n=10⁴ 安全。

📚刷题路线:按顺序做完这 12 题

每道题都亲手写完再提交,不要看题解抄。同一道题隔一周再写一遍,能独立写出来才算真会。

阶段题目练什么
第一周
建图与遍历
P3916 图的遍历反向建图 + DFS
P5318 查找文献图的基本遍历 + 排序输出
P4017 最大食物链计数DAG 上 DP + 拓扑排序
第二周
最短路
P3371 单源最短路径(弱化版)Dijkstra 入门
P4779 单源最短路径(标准版)堆优化 + 链式前向星
P3385 负环SPFA 判负环
第三周
生成树与 DP
P3366 最小生成树Kruskal + 并查集
P1113 杂务DAG 最长路 / 关键路径
P1525 关押罪犯二分 + 二分图判定(提高)
第四周
进阶选做
P3387 缩点Tarjan SCC + 缩点 + DAG DP
P3388 割点Tarjan 求割点
P2055 假期的宿舍二分图最大匹配(提高)
刷题方法① 每题先手算小样例,写出过程再写代码;② 提交前自己造 3 组边界数据(n=1、不连通、重边);③ WA 时先判断是「算法选错」还是「实现写错」——前者回去重读章节,后者打表对比;④ 把错因写在代码注释里,一周后回看。
🎯一页纸记住图论
问题算法复杂度触发关键词 / 条件
遍历 / 连通块DFS、BFSO(n+m)「能否到达」「几个块」
无权最短路BFSO(n+m)边权全为 1、网格迷宫
任意两点最短路FloydO(n³)n ≤ 500、要全部点对
单源最短路(非负)DijkstraO(m log m)边权非负、n 大
单源最短路(有负权)SPFA / Bellman-Ford平均 O(km)「收益」「补贴」「可能为负」
判负环SPFA + cntO(nm)「是否可能无限小」
依赖关系 / 判环拓扑排序O(n+m)「先修」「顺序」「能否完成」
DAG 最长路 / 计数拓扑 + DPO(n+m)「关键路径」「方案数」
连通全部点、总代价最小Kruskal / PrimO(m log m) / O(n²)「修路」「铺管道」「最少花费」
二分图判定染色(BFS/DFS)O(n+m)「分成两组」「不能相邻」
互相可达 / 环上信息Tarjan SCC + 缩点O(n+m)「能回来」「可重复走」
网络瓶颈割点 / 桥O(n+m)「删掉就断」「关键节点」
一一配对匈牙利O(nm)「最多能配几对」
带次数限制的最短路分层图O(k·m log)「k 次免费」「有状态」
最后三句话 ① 先建模,再选算法。90% 的失分不是算法写错,是没看出「什么是点、什么是边」。
② 先算复杂度,再动手写。n、m 的大小直接决定你该用哪个算法,读题时就把范围圈出来。
③ 模板要默写到闭眼能写。图的存储、Dijkstra、Kruskal、拓扑排序这四段,考试时不该占用你的思考时间。

本讲义参考王晓鹏《图论基础》(NOI 南京站培训)的知识框架,结合 NOI 大纲入门级要求与 CSP-J/S 历年考查方式编写。
例题与代码均经手算核对,若有疏漏欢迎指出。