第 4 章 图论
本章定位:图论在 NOIP 里不只出现在难题上(2020 年的 T1 排水系统是拓扑排序,2022 年的 T3 建造军营要先缩点);CSP-S 2026 的四道题里,T2、T3 是图,T4 是树。
对你来说,这一章的任务不是学新东西,而是把会的写对。CSP-S 2026 的 T2 地铁,最难的建模你想对了,却因为 Floyd 的循环顺序和 INF 各错一处,只得了 12 分;把这两处改对(再顺手关掉 cin 同步),官方数据上就是 100 分。T3 迁徙的暴力在图不连通时死循环,一分没拿到。本章标了【必会】的模板,要练到不看资料也写不错。
4.1 存图与遍历 【必会】
一句话:把“谁和谁相连”存下来,再从一个点出发,把能到的点都走一遍。 识别信号:题面出现“n 个点 m 条边”“道路”“连通”“能否到达”“最少经过几条边”。 复杂度:存图和遍历都是 O(n+m)。
存图只用一种写法:vector 邻接表。adj[u] 是一个 vector,里面放 u 的所有邻居;边上有长度时放 pair<int,int>(邻居,长度)。
// 存图 + BFS:用 vector 邻接表存无向图,BFS 求 1 号点到每个点最少要走几条边。
// 输入:n m,接着 m 行 u v(无向边,可能有重边、自环)。输出:n 个数,走不到的点输出 -1。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N]; // adj[u] 里存 u 的所有邻居
int dist[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v); // 无向边要存两次:u 能到 v,v 也能到 u
adj[v].push_back(u);
}
memset(dist, -1, sizeof(dist)); // -1 表示还没到过,同时充当“访问标记”
queue<int> q;
dist[1] = 0;
q.push(1);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (dist[v] != -1) continue; // 到过的点不再处理;第一次到达时的步数就是最少的
dist[v] = dist[u] + 1;
q.push(v);
}
}
for (int i = 1; i <= n; i++) cout << dist[i] << " \n"[i == n];
return 0;
}
BFS 从起点一层一层往外走,所以第一次到达某个点时经过的边数最少——这就是“每条边长度都是 1”时的最短路。只想知道“能不能到”,DFS 更短:
bool vis[N];
void dfs(int u) {
vis[u] = true;
for (int v : adj[u])
if (!vis[v]) dfs(v);
}
别人的代码里常见另一种存法,叫链式前向星:用数组模拟链表,速度略快,写法略绕。你不需要用它,但要看得懂:
const int N = 100005, M = 200005; // 无向图:M 要开到边数的两倍
int head[N], nxt[M], to[M], tot; // head[u]:u 的第一条边的编号;nxt[i]:和第 i 条边同起点的下一条边
void add(int u, int v) { // 加一条 u->v 的边。无向边要调用两次:add(u, v), add(v, u)
to[++tot] = v;
nxt[tot] = head[u];
head[u] = tot;
}
for (int i = head[u]; i; i = nxt[i]) { // 遍历 u 的所有出边
int v = to[i];
}
易错点
- 无向边只存了一个方向。
adj[u].push_back(v)和adj[v].push_back(u)必须成对出现。 - 点从 1 开始编号,数组开“最大点数 + 5”。用链式前向星时,无向图的边数组要开 2m。
- 图不保证连通。只从 1 号点搜一次会漏掉别的连通块;要处理所有点时写
for (int i = 1; i <= n; i++) if (!vis[i]) dfs(i);。 - 重边(两点之间不止一条边)、自环(自己连自己):题面没说“没有”,就当它有。BFS 和 DFS 靠访问标记,天然不怕;Floyd、Tarjan 各有讲究,见对应小节。
- BFS 的标记要在入队时打(模板里给
dist[v]赋值就是打标记)。等出队再打,同一个点会入队很多次。 - 多组数据时,每组开头清空用到的部分:
for (int i = 1; i <= n; i++) adj[i].clear();。不要memset整个大数组,组数一多就超时。 - 网格图不用建边:格子就是点,用
dx[4] = {-1, 1, 0, 0}、dy[4] = {0, 0, -1, 1}枚举四个邻居,记得判越界。
练一练:洛谷 P5318 查找文献、P3916 图的遍历。
4.2 Dijkstra(堆优化)【必会】
一句话:边的长度都不是负数时,求一个起点到所有点的最短距离。 识别信号:“最短路”“最少花费”“最早到达”,边有长度,点数在 10⁵ 这个级别。 复杂度:O((n+m) log m)。
做法:dist 记录每个点目前已知的最短距离。每一轮从“还没确定”的点里取出 dist 最小的那个,它的距离就确定了(边长非负,绕别的路不可能更短);再用它去更新邻居。“取出最小的”交给小根堆。
// Dijkstra(堆优化):边权非负的单源最短路。洛谷 P4779 的格式。
// 输入:n m s,接着 m 行 u v w(有向边 u->v,长度 w)。输出:s 到每个点的最短距离,走不到输出 -1(P4779 保证都能走到)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll, int> pli; // (距离, 点)。距离放前面,堆才会按距离比较
const int N = 100005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL; // 约 4.5e18,比任何真实距离都大;两个 INF 相加也不会超出 long long
vector<pair<int, int>> adj[N]; // adj[u] 里存 (v, w)
ll dist[N]; // 距离一律 long long
bool done[N]; // done[u]:u 的最短距离已经确定
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, s;
cin >> n >> m >> s;
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w});
}
memset(dist, 0x3f, sizeof(dist)); // 每个字节填 0x3f,得到的正好是上面的 INF
priority_queue<pli, vector<pli>, greater<pli>> pq; // 小根堆:堆顶是距离最小的
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
int u = pq.top().second;
pq.pop();
if (done[u]) continue; // 同一个点可能入堆多次,只处理距离最小的那一次
done[u] = true;
for (auto &e : adj[u]) {
int v = e.first, w = e.second;
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
for (int i = 1; i <= n; i++) cout << (dist[i] == INF ? -1 : dist[i]) << " \n"[i == n];
return 0;
}
易错点
dist用long long,INF 用0x3f3f3f3f3f3f3f3f。 估算方法:最长的路最多 n−1 条边,每条边最长 w,乘起来超过 2×10⁹ 就必须long long。n = 10⁵、w = 10⁹ 时能到 10¹⁴。拿不准就全用long long。- INF 必须比任何真实距离都大。
0x3f3f3f3f只有约 1.06×10⁹:地铁题一条边就能到 10⁹,两条边加起来已经超过它,真实的距离反而被当成“走不到”。 - 堆默认是大根堆。小根堆那一行
priority_queue<pli, vector<pli>, greater<pli>>要背下来;pair里距离放第一个。 if (done[u]) continue;不能漏。漏了答案还是对的,但同一个点会被反复展开,大数据超时。- 有负数边时 Dijkstra 的结果是错的,换 4.4 的 SPFA。
- 无向图每条边存两次;起点是题目给的 s,不一定是 1。
- 输出前判断
dist[i] == INF,按题目要求输出“走不到”。 - 不要写
#define ll unsigned long long。无符号数做减法会变成一个巨大的正数,a - b < 0永远不成立。固定写typedef long long ll;。
练一练:洛谷 P4779 单源最短路径(标准版)、P3371 单源最短路径(弱化版,走不到要输出 2³¹−1)、P1629 邮递员送信;本系统 E04。
4.3 Floyd 【必会】
一句话:三重循环,求出任意两点之间的最短距离。 识别信号:点数很少(n ≤ 500);要回答很多次“任意两点的距离”;或者后面的 DP 要用到所有点对的距离(NOIP2016 换教室、本系统 E14)。 复杂度:O(n³)。n = 500 时是 1.25×10⁸ 次,1 秒内能跑完;n = 1000 就不行了。
// Floyd:所有点对之间的最短路,点数不超过 500 时可用。洛谷 B3647 的格式。
// 输入:n m,接着 m 行 u v w(无向边,可能有重边)。
// 输出:n 行 n 列,第 i 行第 j 个数是 i 到 j 的最短距离,走不到输出 -1。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 505;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
ll d[N][N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) d[i][j] = (i == j) ? 0 : INF; // 自己到自己是 0,其余先当作走不到
for (int i = 1; i <= m; i++) {
int u, v;
ll w;
cin >> u >> v >> w;
d[u][v] = min(d[u][v], w); // 有重边时只留最短的一条,所以用 min 而不是直接赋值
d[v][u] = min(d[v][u], w);
}
for (int k = 1; k <= n; k++) // k 必须在最外层:它表示“现在允许用 1..k 号点中转”
for (int i = 1; i <= n; i++) {
if (d[i][k] == INF) continue; // i 走不到 k,拿 k 中转没有意义
for (int j = 1; j <= n; j++)
if (d[i][k] + d[k][j] < d[i][j]) d[i][j] = d[i][k] + d[k][j];
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cout << (d[i][j] == INF ? -1 : d[i][j]) << " \n"[j == n];
return 0;
}
为什么 k 必须在最外层
Floyd 其实是一个 DP。把“第 k 阶段的 d[i][j]”定义为:只允许拿 1 到 k 号点当中转站时,i 到 j 的最短距离。
- 第 0 阶段:不许中转,
d[i][j]就是直接相连的那条边。 - 第 k 阶段:比上一阶段多了一个可用的中转站 k。从 i 到 j,要么不经过 k(值不变),要么经过 k(
d[i][k] + d[k][j]),两者取小。
所以必须对所有的 (i, j) 都做完第 k 阶段,才能进入第 k+1 阶段。k 是阶段,阶段在最外层。把 k 放到最内层,每一对 (i, j) 只在轮到自己的那一刻更新一次,用到的 d[i][k]、d[k][j] 很多还是初始值;之后它们变小了,(i, j) 却再也没有机会重算。
这是你在 CSP-S 2026 T2 考场上写的(sp 是距离矩阵,它是对称的,所以 sp[j][k] 就是 sp[k][j]):
for(int i=1;i<=n;i++){
for(int j=i+1;j<=n;j++){
for(int k=1;k<=n;k++){ // 错:中转点 k 在最内层
sp[i][j]=min(sp[i][j],sp[i][k]+sp[j][k]);
sp[j][i]=sp[i][j];
}
}
}
只把 k 挪到最外层,别的一个字不动,就对了:
for(int k=1;k<=n;k++){ // 对:中转点 k 在最外层
for(int i=1;i<=n;i++){
for(int j=i+1;j<=n;j++){
sp[i][j]=min(sp[i][j],sp[i][k]+sp[j][k]);
sp[j][i]=sp[i][j];
}
}
}
一个 4 个点的例子就能看出差别。四个点连成一条链 1—4—3—2,每条边长 1,1 到 2 的距离应该是 3。错误写法第一个处理的就是 (1, 2):这时 sp[1][3] 和 sp[2][4] 都还是 INF,凑不出任何一条路;后来 sp[1][3] 被算成了 2,但 (1, 2) 已经轮过了。最终它说“1 到 2 走不到”。
记法:中转点在最外层。 写完 Floyd,第一件事是看最外层的循环变量是不是那个被当作中转的点。
易错点
- 循环顺序:k、i、j。
- 初始化:
d[i][i] = 0,其余是 INF;读边用min。有重边时直接赋值,后读入的长边会盖掉先读入的短边。 - INF 和 Dijkstra 一样,用
long long的0x3f3f3f3f3f3f3f3f。这个值是特意挑的:两个 INF 相加约 9.1×10¹⁸,还没超过long long的上限(约 9.2×10¹⁸),所以d[i][k] + d[k][j]不会溢出。拿LLONG_MAX当 INF,一加就溢出成负数,“走不到”变成了“距离是负的”。 - 无向图两个方向都要赋值,有向图只赋一个方向。
- 输出或使用前先判断是不是走不到。边权都不是负数时,比较
d[i][j] == INF就行;有负数边时,INF 加上一个负数会变小一点,要写成d[i][j] >= INF / 2(第 0 章 0.3)。 - n 超过 500 就不要用 Floyd 了,改成从每个起点各跑一次 Dijkstra。
- 只关心“能不能到”时,把取小和加法换成“或”和“与”:
reach[i][j] |= reach[i][k] && reach[k][j],循环顺序不变。
练一练:洛谷 B3647 【模板】Floyd、P1119 灾后重建(做完这题,就真正明白“k 是阶段”了)、P1850 换教室(先用 Floyd 求出所有点对的距离);本系统 E04(Floyd 能拿部分分)、E14。
4.4 Bellman-Ford / SPFA 与负环 【常考】
一句话:边的长度有负数时求单源最短路,并能判断有没有负环(绕一圈下来总长度是负数的环)。 识别信号:边权可以为负;“是否存在负环”。 复杂度:最坏 O(n·m)。
Bellman-Ford 的做法很朴素:把所有的边轮流“松弛”一遍(dist[v] = min(dist[v], dist[u] + w)),重复 n−1 轮。没有负环时,最短路最多 n−1 条边,所以 n−1 轮一定够。SPFA 是它的改进:只有距离刚刚变小的点,才值得用它的出边去更新别人,这些点用队列存起来。
// SPFA(用队列优化的 Bellman-Ford):边权可以是负数,还能发现负环。洛谷 P3385 的格式。
// 输入:T 组数据。每组先是 n m,接着 m 行 u v w:w>=0 表示双向边,w<0 表示只有 u->v 这一个方向。
// 输出:每组一行,从 1 号点出发能走到负环输出 YES,否则输出 NO。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2005;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
vector<pair<int, int>> adj[N];
ll dist[N];
int cnt[N]; // cnt[v]:当前记录的 1 到 v 的最短路上有几条边
bool inq[N]; // inq[v]:v 此刻是否在队列里
bool has_negative_cycle(int n) {
for (int i = 1; i <= n; i++) dist[i] = INF, cnt[i] = 0, inq[i] = false;
queue<int> q;
dist[1] = 0;
q.push(1);
inq[1] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
inq[u] = false;
for (auto &e : adj[u]) {
int v = e.first, w = e.second;
if (dist[u] + w >= dist[v]) continue;
dist[v] = dist[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) return true; // 没有负环时最短路最多 n-1 条边;到了 n 条说明在绕负环
if (!inq[v]) { // 已经在队列里就不用重复放
q.push(v);
inq[v] = true;
}
}
}
return false;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) adj[i].clear(); // 多组数据:上一组的边必须清掉
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w});
if (w >= 0) adj[v].push_back({u, w});
}
cout << (has_negative_cycle(n) ? "YES" : "NO") << '\n';
}
return 0;
}
易错点
- 边权都非负的图不要用 SPFA。 出题人会专门造数据把它卡到 O(n·m)。正权用 Dijkstra,有负权才用 SPFA。
- 判负环看的是“最短路上的边数达到了 n”,也就是模板里的
cnt。 - 这样只能发现从起点走得到的负环。要判断整张图有没有负环:一开始把所有点的
dist设为 0、全部入队(相当于 4.12 的超级源点)。 - 多组数据:
adj、dist、cnt、inq每组都要重置。 - P3385 的输入有个坑:w ≥ 0 是双向边,w < 0 是单向边。读题要读到这种细节。
练一练:洛谷 P3385 【模板】负环。
4.5 最短路计数 【常考】
一句话:求最短路的同时,数一数最短路有多少条。 识别信号:“有多少条最短路”“最短路是否唯一”“最短路恰好有两条的点对”。 复杂度:每条边长度为 1 时用 BFS,O(n+m);边有长度时套在 Dijkstra 上,O((n+m) log m)。
// 最短路计数:无权图上,1 号点到每个点有多少条不同的最短路。洛谷 P1144 的格式。
// 输入:n m,接着 m 行 u v(无向边,可能有重边、自环)。
// 输出:n 行,第 i 行是 1 到 i 的最短路条数对 100003 取模的结果,走不到输出 0。
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005, MOD = 100003;
vector<int> adj[N];
int dist[N], ways[N]; // ways[v]:1 到 v 的最短路条数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
memset(dist, -1, sizeof(dist));
queue<int> q;
dist[1] = 0;
ways[1] = 1; // 起点到自己算 1 条
q.push(1);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) { // 第一次到 v:最短距离确定,入队
dist[v] = dist[u] + 1;
q.push(v);
}
if (dist[v] == dist[u] + 1) // u 是 v 的“上一层”:经过 u 的最短路都能接上这条边到 v
ways[v] = (ways[v] + ways[u]) % MOD;
}
}
for (int i = 1; i <= n; i++) cout << ways[i] << '\n';
return 0;
}
原理:BFS 一层一层往外扩展。到 v 的最短路,最后一步一定是从“上一层”的某个邻居 u 走过来的,所以 ways[v] 等于所有这样的 u 的 ways[u] 之和。u 出队时,比它浅的点早已处理完,ways[u] 已经是最终值,可以放心累加。
边有长度(且都是正数)时,把同样的想法加进 Dijkstra 的松弛里:
if (dist[u] + w < dist[v]) { // 找到更短的路:条数整个换掉
dist[v] = dist[u] + w;
ways[v] = ways[u];
pq.push({dist[v], v});
} else if (dist[u] + w == dist[v]) { // 找到一样短的路:条数累加
ways[v] = (ways[v] + ways[u]) % MOD;
}
CSP-S 2026 T3 迁徙:暴力分就在这里
这题问的是“最短路恰好有两条的点对有多少”。最朴素的做法:从每个点各做一次上面的 BFS,数 ways 等于 2 的点,总共 O(n·(n+m))。这样写在官方数据上是 24 分(plan/csp2026-复盘/migrate_暴力24分.cpp,40 行)。只需要注意两点:
- 条数会很大,但只关心它是不是 2,所以超过 2 就封顶:
ways[v] = min(ways[v] + ways[u], 3)。不用取模,也不会溢出。 - 图可能不连通。走不到的点
ways是 0,自然不会被数进去。
考场上那份代码的问题不在算法,而在这个循环(ft[uu] 表示从 uu 出发一步最远能到哪个岛):
int uu=u,uuu=u,m=0;
while(uu<n){
uuu=uu;uu=ft[uu];m++;
for(int i=uuu+1;i<=uu;i++) dis[u][i]=m;
}
下一个岛太远、一步也跳不动时,ft[uu] == uu,uu 不再变化,循环永远不结束。这份代码在 25 个测试点上全部超时;用“三个岛,第三个岛够不着”的小数据就能复现。
写 while 之前问自己一句:如果目标永远到不了,这个循环会停吗? BFS 靠访问标记结束,图连不连通都会停,这也是暴力优先选它的原因。
易错点
- 起点
ways[s] = 1,别忘了。 - 重边算不算不同的路,要看题目怎么定义。P1144 里算(样例解释:4 和 5 之间有两条边,所以条数翻倍);P1608 里不算(经过的点的序列不同才算不同的路,重复的边要先去掉,只留最短的)。自环不影响最短路。
- 累加要取模,或者像上面那样封顶,否则溢出。
- 有长度为 0 的边时,这个办法不成立(0 长度的边连着的点分不出先后层次)。
练一练:洛谷 P1144 最短路计数、P1608 路径统计(边有长度;重复的边要去重)。
4.6 并查集 【必会】
一句话:维护若干个互不相交的集合,支持“合并两个集合”和“问两个元素是否在同一个集合”。 识别信号:“朋友的朋友是朋友”“是否连通”“有几个连通块”;只加边、不删边;Kruskal 里判断会不会成环。 复杂度:每次操作接近 O(1)。
// 并查集:合并两个集合,询问两个元素是否在同一个集合。洛谷 P3367 的格式。
// 输入:n m,接着 m 行 z x y:z=1 合并 x 和 y 所在的集合;z=2 询问 x 和 y 是否在同一集合,是输出 Y,否输出 N。
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
int fa[N], sz[N]; // fa[x]:x 的父亲,根的父亲是自己;sz[x]:以 x 为根的集合有多少个元素(只有根的值有意义)
int find(int x) { // 找 x 所在集合的根,顺手把路上的点直接挂到根下面(路径压缩)
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
void unite(int x, int y) {
x = find(x), y = find(y); // 合并的是两个根,不是 x 和 y 本身
if (x == y) return;
if (sz[x] < sz[y]) swap(x, y); // 小集合挂到大集合下面,树不会太深
fa[y] = x;
sz[x] += sz[y];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1; // 一开始每个元素自成一个集合
while (m--) {
int z, x, y;
cin >> z >> x >> y;
if (z == 1) unite(x, y);
else cout << (find(x) == find(y) ? "Y" : "N") << '\n';
}
return 0;
}
易错点
- 忘记初始化
fa[i] = i。多组数据每组都要重新初始化。 - 合并写成
fa[x] = y。必须合并两个根:fa[find(x)] = find(y)。 - 判断同一集合写成
fa[x] == fa[y]。必须比较find(x) == find(y)。 - 数连通块:数满足
find(i) == i的点;或者从 n 开始,每成功合并一次减 1。 - 并查集只会合并,不会拆开。遇到“删边”,常用的办法是把操作倒过来做,删边就变成了加边。
- 考场上忘了按大小合并没关系,只有路径压缩也够快;但
find里fa[x] = find(fa[x])的赋值不能省。
练一练:洛谷 P3367 【模板】并查集、P1551 亲戚、P3958 奶酪(NOIP2017);本系统 E08。
4.7 扩展域并查集 【常考】
一句话:除了“同类”,还要维护“异类”(敌人、对立阵营、取值相反)。 识别信号:两两之间的关系只有两种而且互斥——“u 和 v 相同 / 相反”“朋友 / 敌人”“x = y / x = 非 y”;要找出第一条和前面矛盾的信息。 复杂度:同并查集。
想法:把每个人 x 拆成两个点——x 代表“x 本身”,x+n 代表“x 的对立面”。两个点在同一个集合里,表示它们一定同类。
- 声明“u、v 同类”:合并 (u, v),合并 (u+n, v+n)。
- 声明“u、v 异类”:合并 (u, v+n),合并 (u+n, v)。
- 查询:u 和 v 在同一集合,是同类;u 和 v+n 在同一集合,是异类;都不是,现在还不知道。
- 矛盾:要声明同类时发现已经是异类,或者反过来。
// 扩展域并查集:维护“同类 / 异类”两种关系,并能发现矛盾。
// 输入:n m,接着 m 行 t u v:t=1 声称 u、v 同类;t=2 声称 u、v 异类;t=3 询问 u、v 的关系。
// 输出:对声明——与之前接受的声明不矛盾就接受,输出 YES;矛盾就丢弃,输出 NO。
// 对询问——输出 SAME(同类)、DIFF(异类)或 UNKNOWN(现在还推不出来)。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int fa[2 * N]; // 点 x 代表“x 本身”,点 x+n 代表“x 的对立面”。两个点在同一集合里,表示它们一定同类
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
void unite(int x, int y) { fa[find(x)] = find(y); }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= 2 * n; i++) fa[i] = i; // 注意是 2n 个点
while (m--) {
int t, u, v;
cin >> t >> u >> v;
bool same = find(u) == find(v); // 已经能推出 u、v 同类
bool diff = find(u) == find(v + n); // 已经能推出 u、v 异类(u 和“v 的对立面”同类)
if (t == 3) {
cout << (same ? "SAME" : diff ? "DIFF" : "UNKNOWN") << '\n';
} else if (t == 1) {
if (diff) {
cout << "NO\n";
continue;
}
unite(u, v), unite(u + n, v + n); // 本身和本身合并,对立面和对立面合并
cout << "YES\n";
} else {
if (same) {
cout << "NO\n";
continue;
}
unite(u, v + n), unite(u + n, v); // u 和 v 的对立面同类,反过来也一样
cout << "YES\n";
}
}
return 0;
}
另一种写法叫带权并查集【了解】:不拆点,而是给每个点多记一个 rel[x],表示 x 和它父亲的关系(0 同类,1 异类),路径压缩时顺便把关系接起来。两种写法能解决的问题一样,会一种就够。
int fa[N], rel[N]; // rel[x]:x 与它父亲的关系,0 同类,1 异类
int find(int x) {
if (fa[x] == x) return x;
int root = find(fa[x]); // 先让父亲直接挂到根下面,这时 rel[fa[x]] 已经是“父亲与根的关系”
rel[x] ^= rel[fa[x]]; // x 与根的关系 = x 与父亲的关系 异或 父亲与根的关系
return fa[x] = root;
}
// 声明 u、v 的关系是 want(0 同类,1 异类):
int ru = find(u), rv = find(v);
if (ru == rv) { /* 已有结论 rel[u] ^ rel[v],与 want 不同就是矛盾 */ }
else fa[ru] = rv, rel[ru] = rel[u] ^ rel[v] ^ want;
易错点
fa要开 2n,也要初始化到 2n。只初始化到 n 是最常见的错。- 每条声明要合并两对。只合并一对,会漏掉一半的推理。
- 先判断矛盾,再合并。
find(x) == find(x + n)表示“x 和自己相反”,是矛盾的标志。- 关系有三种(食物链:同类 / 吃 / 被吃)时拆成 3n 个点,思路相同。
练一练:洛谷 P1525 关押罪犯、P2024 食物链;本系统 E08。
4.8 最小生成树 Kruskal 【必会】
一句话:选 n−1 条边把 n 个点连通,使选中的边长度之和最小。 识别信号:“用最小的代价把所有点连起来”“修路 / 架线 / 铺管道”。有时要先转化一下才看得出来(见 4.12 的超级源点)。 复杂度:O(m log m),时间都花在排序上。
做法:边按长度从小到大考虑。两端还不连通就选它,已经连通就跳过。“连不连通”用并查集判断。
// Kruskal:最小生成树。洛谷 P3366 的格式。
// 输入:n m,接着 m 行 x y z(无向边,长度 z)。输出:最小生成树的边权之和;图不连通输出 orz。
#include <bits/stdc++.h>
using namespace std;
const int N = 5005, M = 200005;
struct Edge {
int u, v, w;
} e[M];
int fa[N];
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
bool cmp(const Edge &a, const Edge &b) { return a.w < b.w; }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) cin >> 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 total = 0;
int used = 0; // 已经选了几条边
for (int i = 1; i <= m && used < n - 1; i++) {
int x = find(e[i].u), y = find(e[i].v);
if (x == y) continue; // 两端已经连通,再加这条边会成环,跳过
fa[x] = y;
total += e[i].w;
used++;
}
if (used < n - 1) cout << "orz\n"; // 选不够 n-1 条边,说明图不连通
else cout << total << '\n';
return 0;
}
易错点
- 总和用
long long。 cmp里用<,不要用<=。sort的比较函数遇到相等的两项必须返回 false,否则可能直接 RE。- 图不连通时选不够 n−1 条边,按题目要求处理。
- 边数组按 m 开,不是按 n 开。
- “最大生成树”只要把排序反过来。“让最长的那条边尽量短”也是它:最小生成树上最长的边就是答案。
- 点是平面上的坐标、任意两点之间都有边时,边太多,Kruskal 又慢又占内存,要换 O(n²) 的 Prim。遇到再查,不必背。
练一练:洛谷 P3366 【模板】最小生成树、P2330 繁忙的都市、P1550 Watering Hole(要用超级源点);本系统 E10。
4.9 拓扑排序与 DAG 上的 DP 【必会】
一句话:给有向无环图(DAG)的点排一个顺序,使每条边都从前面的点指向后面的点;顺便能判断图里有没有环。 识别信号:“先修课”“依赖关系”“必须在……之前完成”;“水 / 消息顺着有向边往下游传”;有向图上的 DP 和计数。 复杂度:O(n+m)。
入度是指向这个点的边数。入度为 0 的点没有任何前置条件,可以最先处理;处理完一个点,就把它的出边“删掉”(后继的入度减 1),新冒出来的入度为 0 的点接着处理。
// 拓扑排序(Kahn 算法)+ DAG 上的 DP:判断有向图有没有环;没有环时,求最长的一条路径上有几个点。
// 输入:n m,接着 m 行 u v(有向边 u->v,表示 u 必须排在 v 前面)。输出:有环输出 -1,否则输出最长路径上的点数。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int indeg[N], f[N]; // indeg[v]:v 还有几个前驱没处理完;f[v]:以 v 结尾的最长路径上的点数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
indeg[v]++;
}
queue<int> q;
for (int i = 1; i <= n; i++)
if (indeg[i] == 0) q.push(i), f[i] = 1; // 没有前驱的点可以最先处理
vector<int> order; // 出队的顺序就是一个拓扑序
while (!q.empty()) {
int u = q.front();
q.pop();
order.push_back(u);
for (int v : adj[u]) {
f[v] = max(f[v], f[u] + 1); // u 出队时 f[u] 已经是最终值,可以放心用来更新后继
if (--indeg[v] == 0) q.push(v); // v 的前驱全部处理完了,轮到 v
}
}
if ((int)order.size() < n) { // 有点始终没能入队:它在环上,或者排在环的后面
cout << "-1\n";
return 0;
}
cout << *max_element(f + 1, f + n + 1) << '\n';
return 0;
}
DAG 上的 DP 都按这个顺序做:一个点出队时,它的所有前驱都已经出队,它的 DP 值已经是最终值。模板里的 f 求的是最长路;把取大换成加法,就是数路径条数(P4017);换成“把自己的量平分给各个后继”,就是 NOIP2020 T1 排水系统的主体。
易错点
indeg[v]++加在边的终点上。方向写反了全错。- 出队的点不足 n 个,说明有环。别忘了这个判断。
- 重边不用特殊处理:入度多加一次,之后也会多减一次。
- 要求“字典序最小的拓扑序”时,把
queue换成小根堆。 - 多组数据:
adj、indeg、f都要清。
练一练:洛谷 B3644 【模板】拓扑排序、P4017 最大食物链计数、P1113 杂务、P7113 排水系统(NOIP2020);本系统 E09。
4.10 二分图判定 【常考】
一句话:判断能不能把点分成两组,使每条边都连接不同组的两个点。 识别信号:“分成两队 / 两个阵营,有矛盾的两个人不能同队”;“图里有没有长度为奇数的环”。 复杂度:O(n+m)。
// 二分图判定:能不能把所有点染成黑白两色,使每条边的两端颜色不同。用 BFS 染色。
// 输入:n m,接着 m 行 u v(无向边)。输出:是二分图输出 YES,否则输出 NO。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int color[N]; // 0 表示还没染色,1 和 2 是两种颜色
bool paint(int s) { // 给 s 所在的连通块染色,发现矛盾返回 false
queue<int> q;
color[s] = 1;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (color[v] == color[u]) return false; // 一条边两端同色:图里有奇数长度的环
if (color[v] == 0) {
color[v] = 3 - color[u]; // 1 变 2,2 变 1
q.push(v);
}
}
}
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
bool ok = true;
for (int i = 1; i <= n && ok; i++) // 图可能不连通,每个连通块都要染一遍
if (color[i] == 0) ok = paint(i);
cout << (ok ? "YES" : "NO") << '\n';
return 0;
}
易错点
- 图不连通,每个连通块都要染。
- 有自环就一定不是二分图。模板先比较颜色再染色,能正确处理。
- 它和 4.7 的扩展域并查集解决的是同一类问题。边一次给全,用染色;边一条一条来、要找第一条出矛盾的,用并查集。
练一练:洛谷 P1330 封锁阳光大学、P1525 关押罪犯(另一种做法:二分答案 + 染色判定)。
4.11 Tarjan:缩点与割边
强连通分量缩点 【常考】
一句话:有向图里,把“互相都能走到”的点捏成一个大点。捏完之后图里没有环了(是 DAG),就能做拓扑排序和 DP。 识别信号:有向图,允许重复经过点;“互相可达”;要在有环的有向图上做 DP。 复杂度:O(n+m)。
// Tarjan 求强连通分量,缩点后在 DAG 上 DP。洛谷 P3387 的格式。
// 输入:n m,n 个点权,接着 m 行 u v(有向边)。
// 输出:选一条路径(点和边都可以重复走,但每个点的点权只算一次),点权之和最大是多少。
#include <bits/stdc++.h>
using namespace std;
const int N = 10005;
vector<int> adj[N], dag[N]; // dag:缩点之后的图,结点是分量编号
int a[N], dfn[N], low[N], timer; // dfn[u]:u 是第几个被访问的;low[u]:u 往下走能绕回到的最早的点的 dfn
int stk[N], top; // 栈里是“访问过、但还没归入任何分量”的点
bool in_stk[N];
int scc[N], scc_cnt; // scc[u]:u 属于第几个强连通分量
long long sum[N], f[N]; // sum[c]:分量 c 里的点权和;f[c]:从分量 c 出发能拿到的最大点权和
void tarjan(int u) {
dfn[u] = low[u] = ++timer;
stk[++top] = u, in_stk[u] = true;
for (int v : adj[u]) {
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stk[v]) { // v 还在栈里,说明从 u 能绕回 v
low[u] = min(low[u], dfn[v]);
}
}
if (low[u] != dfn[u]) return; // u 还能绕回更早的点,它不是分量的“入口”
scc_cnt++; // 栈里从 u 往上的所有点,恰好构成一个强连通分量
int v;
do {
v = stk[top--];
in_stk[v] = false;
scc[v] = scc_cnt;
sum[scc_cnt] += a[v];
} while (v != u);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) tarjan(i); // 图可能不连通,每个没访问过的点都要当一次起点
for (int u = 1; u <= n; u++)
for (int v : adj[u])
if (scc[u] != scc[v]) dag[scc[u]].push_back(scc[v]); // 跨分量的边留下,分量内部的边丢掉
long long ans = 0;
for (int c = 1; c <= scc_cnt; c++) { // 若有边 c->d,则 d 的编号一定比 c 小,所以从小到大算就是合法的 DP 顺序
f[c] = sum[c];
for (int d : dag[c]) f[c] = max(f[c], sum[c] + f[d]);
ans = max(ans, f[c]);
}
cout << ans << '\n';
return 0;
}
读这段代码的三个抓手:
dfn[u]是 u 第几个被访问。low[u]是从 u 往下走,能绕回到的、还在栈里的最早的点的dfn。low[u] == dfn[u]说明 u 往下怎么走都绕不回比自己更早的点,u 是它所在分量里最先被访问的。这时栈里 u 和它上面的点恰好就是这个分量。- 先被“收”出来的分量在 DAG 的下游。所以如果有边 c→d,一定有 d < c;按编号从小到大算,就是“先算后继,再算自己”。模板靠这一点省掉了一次拓扑排序。
易错点
else if (in_stk[v])这个条件不能丢。已经出栈的点属于别的分量,不能用它更新low。- 每个没访问过的点都要调用一次
tarjan。 - 缩点后的图会有重边。做 DP 没影响;要统计入度、出度时,记得它们被重复计了。
- 这个模板只用于有向图。无向图对应的概念是下面的边双。
练一练:洛谷 P3387 【模板】缩点、P2341 受欢迎的牛。
割边与边双连通分量 【了解】
一句话:无向图里,删掉之后会让图断开的边叫割边(桥)。把所有割边删掉,剩下的每一块叫一个边双连通分量;把每一块缩成一个点,原图就变成一棵树,树边正是那些割边。 识别信号:“删掉一条边之后还连通吗”“哪些边是必经的”;缩成树之后做树形 DP——NOIP2022 T3 建造军营就是这个结构。 复杂度:O(n+m)。
// Tarjan 求割边(桥):删掉这条边,图的连通块会变多。图里有重边、自环也能正确处理。
// 输入:n m,接着 m 行 u v(无向边)。
// 输出:第一行是割边的条数;之后每行一条割边 u v(u<v),按 u、v 从小到大排列。
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<pair<int, int>> adj[N]; // (邻居, 边的编号)。记编号是为了区分“来时走的那条边”和“另一条重边”
int dfn[N], low[N], timer; // low[u]:u 和它的子树不走来时的边,能到达的最早的点的 dfn
vector<pair<int, int>> bridges;
void tarjan(int u, int from_edge) { // from_edge:从父亲走到 u 用的那条边的编号
dfn[u] = low[u] = ++timer;
for (auto &e : adj[u]) {
int v = e.first, id = e.second;
if (id == from_edge) continue; // 不许沿着来时的那条边原路返回;重边的编号不同,可以走
if (!dfn[v]) {
tarjan(v, id);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) // v 那一侧无论如何绕不回 u 或更早的点:这条边是唯一的通道
bridges.push_back({min(u, v), max(u, v)});
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back({v, i});
adj[v].push_back({u, i});
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) tarjan(i, 0); // 图可能不连通
sort(bridges.begin(), bridges.end());
cout << (int)bridges.size() << '\n';
for (auto &b : bridges) cout << b.first << ' ' << b.second << '\n';
return 0;
}
易错点
- 割边的判定是
low[v] > dfn[u],严格大于。>=是割点的判定条件。 - 有重边时,“不走回头路”必须按边的编号判断。按父亲结点判断,会把两条重边当成一条,误判为割边。
- 求边双:做完这一遍,再 DFS 一遍,不走割边,每次搜到的连通块就是一个边双。
练一练:洛谷 P8436 【模板】边双连通分量;P8867 建造军营(NOIP2022,难,能看懂题解就行)。
4.12 建模技巧 【必会】
图论题难的往往不是算法,而是“图在哪儿”。读完题先回答两个问题:什么当结点?什么当边? 再根据数据范围选算法。
1. 让数量少的东西当结点。 CSP-S 2026 T2 地铁:车站很多,线路最多 500 条。把线路当结点,两条线路经过同一个车站就连一条边,边的长度是在这一站换乘的代价;500 个结点正好能跑 Floyd。你在考场上想到了这一步,这是那道题最难的部分。数据范围就是提示:哪个量小到能承受 n³,就考虑让它当结点。
2. 超级源点。
- 有很多个起点(“每个路口离最近的消防站多远”):新建一个 0 号点,向每个起点连一条长度为 0 的边,从 0 号点跑一次最短路。写代码时不用真的建点,把所有起点的
dist设成 0、一起放进堆里就行。 - “每个点可以花 c 自己解决,也可以花 w 连到别的点上”(打井、建电站):新建 0 号点,向每个点 i 连一条长度为 c_i 的边,问题就变成了 n+1 个点的最小生成树。见本系统 E10、洛谷 P1550。
3. 反向图。 要求“所有点到 1 号点的最短路”,不用跑 n 次:把每条边反过来建一张图,在反向图上从 1 出发跑一次,得到的就是原图上各点到 1 的距离。见本系统 E04、洛谷 P1629。
4. 把状态塞进结点(分层图)。
“有 k 次机会免费通过一条路”:结点不再是“在哪个路口”,而是“在哪个路口、已经用了几次机会”,距离数组变成 dist[u][j]。走一条边有两种走法:正常走(j 不变,加上边长);用一次机会(j 加 1,不加边长)。堆里放(距离,点,已用次数),其余和 Dijkstra 一样。见洛谷 P4568 飞行路线。
5. 费用在点上。 “经过城市 v 要交过路费 c_v”:把 c_v 加到所有进入 v 的边上,起点的费用单独加。更一般的做法是把 v 拆成“进 v”和“出 v”两个点,中间连一条长度为 c_v 的边。
6. 网格和“局面”。 迷宫、棋盘:格子是结点,相邻的格子之间有边,不用把边存下来。倒水、移动棋子这类题:一个“局面”是一个结点,“走一步”是一条边,BFS 求最少步数。
7. 倒着做。 并查集不会删边。把操作倒过来,删边变成加边。
4.13 本章易错点汇总 【必会】
| 错误 | 后果 | 怎么防 |
|---|---|---|
| Floyd 的中转点 k 不在最外层 | 很多点对的答案偏大,或被当成走不到 | 写完先看最外层的循环变量 |
INF 取 0x3f3f3f3f,而距离能超过 10⁹ |
真实距离被当成走不到 | dist 一律 long long,INF 一律 0x3f3f3f3f3f3f3f3f |
dist 用 int |
溢出成负数 | 同上;估算 (n−1) × 最长的边 |
#define ll unsigned long long |
减法和比较出错 | 只写 typedef long long ll; |
| 无向边只存了一个方向 | 答案错 | 两行 push_back 一起写 |
| 没考虑重边 | Floyd 被长边覆盖;割边误判 | Floyd 读边用 min;Tarjan 记边的编号 |
| 没考虑图不连通 | 漏掉别的连通块;循环停不下来 | 每个点都当一次起点;循环要有“走不动就退出” |
| 多组数据没清空 | 从第二组开始全错 | 每组开头清 adj,重新初始化 fa、dist、indeg |
| 边权非负却用了 SPFA | 被卡到超时 | 非负用 Dijkstra |
并查集合并、比较时没经过 find |
答案错 | 只用 find 的返回值做合并和比较 |
Dijkstra 用了大根堆,或 pair 里点在前 |
答案错或超时 | 背下小根堆那一行 |
| 把起点、根想当然地当成 1 | 答案错 | 读题时把起点圈出来 |
提交之前,用几个点的小图手算核对。至少这四种:一条链、一个环、两个互不相连的块、带重边和自环的图。
本章必会清单
- 能默写 vector 邻接表存图和 BFS(
graph_bfs.cpp) - 能默写 Dijkstra,小根堆那一行不用查(
dijkstra.cpp) - 能默写 Floyd,并能讲出 k 为什么在最外层(
floyd.cpp) - 能默写并查集,合并和查询都经过
find(dsu.cpp) - 能默写 Kruskal(
kruskal.cpp) - 能默写拓扑排序,并用它判环(
toposort.cpp) - 看到距离就用
long long,INF 写0x3f3f3f3f3f3f3f3f - 读完题先说出“什么当结点、什么当边”
- 写
while之前想过“到不了的时候会不会停” - 提交前用链、环、不连通、重边自环四种小图各测一遍