NOIP 算法笔记全部章节
本章目录

第 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 之前想过“到不了的时候会不会停”
  • 提交前用链、环、不连通、重边自环四种小图各测一遍