第 41~44 节只教了四件事:怎么存一张图(邻接表)、怎么在图上走(DFS / BFS 与连通块)、怎么求最短路与最小生成树(Dijkstra / Floyd / Kruskal)、怎么把有向无环图排成一条线(拓扑排序 + DAG 上的 DP)。这一节不引入新算法:先用 P5318 把「建图 → 排序 → DFS → BFS」从空文件写到 AC,再把五张模板各拆一遍,最后 5 道洛谷黄题限时 95 分钟。
| 节 | 内容 | 看到这些字就上它 | 复杂度 |
|---|---|---|---|
| 41 | 图的存储与遍历 | 「连通块」「能不能到达」「遍历序」「哪些点互相可达」 | O(n+m) |
| 42 | 最短路:Dijkstra 与 Floyd | 「最短距离」「最少花费」;边权非负 → 堆优化 Dijkstra,n ≤ 400 要全源 → Floyd | O(m log n) / O(n³) |
| 43 | 最小生成树与并查集 | 「连通所有点花费最少」「最早何时全部连通」「两点是否连通」 | O(m log m) |
| 44 | 拓扑排序与 DAG 上的 DP | 「先修」「不能有环」「最长链」「方案数」 | O(n+m) |
P5318 是这四节的最小交集:一张有向图、两种遍历、一个排序要求。它不难,但把「存图 + 判重 + 输出格式」三件事一次考完,是回顾章最合适的起手题。
洛谷博客里一共有 n 篇文章(1 ≤ n ≤ 10⁵),编号 1 到 n;另有 m 条参考文献引用关系(1 ≤ m ≤ 10⁶)。若文章 X 的参考文献里有 Y,就记一条有向边 X → Y。
小 K 已经打开了 1 号文章。看完一篇就会去看它的全部参考文献(看过的不会再看)。要求分别用 DFS 和 BFS 把这个过程走一遍,输出访问到的文章编号。
同一篇文章的多篇参考文献,按编号从小到大依次看。
输入格式:第一行两个整数 n、m;接下来 m 行,每行两个整数 X、Y,表示 X 引用 Y。
输出格式:两行。第一行为 DFS 序,第二行为 BFS 序,编号之间用一个空格隔开。只输出从 1 号出发能到达的文章。
把题目翻译成图:文章是点,引用是有向边,P5318 问的就是从 1 号点出发的 DFS 序与 BFS 序。两处细节决定了这题能不能一次过:邻接表必须先排序(否则顺序取决于输入),以及 DFS 不能用朴素递归(n 到 10⁵,最坏深度也是 10⁵)。
下面五张卡不是新知识,是把本节要用的骨架各默写一遍。赛场上默写不出来的模板,等于没学过:先能一次写对,再谈限时。
| 组成 | 含义 / 格式要求 |
|---|---|
vector<int> g[MAXN] | 开 MAXN 个 vector,g[u] 里放 u 的全部出点。空间 O(n+m);1e5 个空 vector 约 2.4 MB,m = 10⁶ 条边再占 4 MB。 |
g[u].push_back(v) | 有向边只加一次;无向边加两次(g[u]→v 与 g[v]→u)。漏加一次的症状:连通块数变多、无向图最短路算不出来。 |
带权边 | 改成 vector<pair<int,int>> g[MAXN],存 (终点, 权值)。邻接矩阵在 n = 10⁵ 时要 4×10¹⁰ 字节,开不出来。 |
(int)g[u].size() | size() 返回无符号数。for (size_t i = size - 1; i >= 0; --i) 在空表上会变成 2⁶⁴−1 而死循环;本行先强转 int 再减一,空表时就是 −1,循环直接跳过。 |
for (int v : g[u]) | 遍历出边,O(deg(u))。整张图扫一遍是 O(n+m),不是 O(n²)。 |
要不要排序 | 题目要求「小编号优先」时必须对每个 g[u] 排序——邻接表里的顺序就是读入顺序。 |
vector<int> g[100005];
g[1].push_back(2);
g[1].push_back(3); // 无向图还要写 g[2].push_back(1) 之类
g[2].push_back(3);
for (int v : g[1]) cout << v << " "; // 2 3,按入表顺序,不自动有序
sort(g[1].begin(), g[1].end()); // 题目要求小编号优先时必须自己排无向图只加一条边:从 u 能到 v,从 v 回不到 u,连通块数直接翻倍。
1e5 个点用 int a[1005][1005] 这种矩阵存:n 一大就爆内存,稀疏图一律邻接表。
| 组成 | 含义 / 格式要求 |
|---|---|
vis 数组 | 判重的唯一依据。DFS 进点即标记,BFS 入队即标记;晚一步同一个点就会被处理两次。 |
递归 DFS | 代码最短。但深度等于路径长度:10⁵ 的链每帧按 48 字节算是 4.8 MB,离 8 MB 的栈上限只差一点,边界数据容易 RE。 |
显式栈 | st.push_back(u) 弹出访问、st.pop_back() 模拟递归。压孩子必须倒序,弹出顺序才和递归版一致。 |
queue BFS | 层序扩展。边权全为 1 时,第一次访问就是最少步数。 |
两遍之间 | memset(vis, 0, sizeof vis)。忘了清空,第二遍所有点都被判成「已访问」,第二行只剩起点一个 1。 |
复杂度 | 邻接表 O(n+m);同样的遍历换成邻接矩阵就是 O(n²) = 10¹⁰。 |
bool vis[MAXN];
void dfs(int u) { // 递归版:深度大时可能爆栈
vis[u] = 1;
cout << u << " ";
for (int v : g[u]) if (!vis[v]) dfs(v);
}
void bfs(int s) { // 队列版:层序
queue<int> q;
q.push(s); vis[s] = 1; // 入队即标记
while (!q.empty()) {
int u = q.front(); q.pop();
cout << u << " ";
for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
}
}BFS 改成「出队时标记」:同一个点被多个父亲反复入队,队列长度到 O(m),大数据 TLE。
显式栈正序压孩子:遍历顺序与递归不一致,题目要求固定顺序时直接 WA。
| 组成 | 含义 / 格式要求 |
|---|---|
pair 的第一维 | 必须是距离。pair 按字典序比较,第一维是距离时堆顶才是「距离最小的点」。两位写反、取用时又把 first 当距离,读到的其实是编号,输出一列毫无意义的数。 |
greater<...> | 小根堆,堆顶是当前距离最小的点。写成默认的大根堆:只要懒删除那行还在,答案仍然对(顺序错了会不断修正),但同一个点被反复松弛,常数大几倍;若又用 used 数组「弹出即定案」,就会把错的距离冻死 → WA。 |
dist 初始化 | fill(dist, dist + n + 1, INF);INF 取 LLONG_MAX / 2,保证 d + w 不会溢出成负数。 |
懒删除 | if (d > dist[u]) continue; 堆里同一个点可能有多份历史版本,取出的 d 比记录值大就丢弃。 |
松弛条件 | if (dist[v] > d + w)。相等时不更新,能少一次入堆。 |
适用前提 | 边权全部非负。有负权必须换 Bellman-Ford / SPFA,Dijkstra 会给错答案。 |
const long long INF = LLONG_MAX / 2;
long long dist[MAXN];
fill(dist, dist + n + 1, INF);
dist[s] = 0;
priority_queue<pair<long long,int>, vector<pair<long long,int>>,
greater<pair<long long,int>>> pq;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // 过期版本,丢掉
for (auto [v, w] : g[u])
if (dist[v] > d + w) { dist[v] = d + w; pq.push({dist[v], v}); }
}n ≤ 400 又要「任意两点间距离」时别写这个:Floyd 三重循环 O(n³) = 6.4×10⁷,三行就够。
dist 用 int 且 INF 取 0x7fffffff:d + w 溢出成负数,把不可达覆盖成负距离。
| 组成 | 含义 / 格式要求 |
|---|---|
fa[i] = i | 初始化,每个点自成一个集合。漏了这一步:fa 全 0,所有 find 都返回 0,连通图也会被判成「全部连通」,最后输出 orz。 |
find 路径压缩 | 把路径上的点直接挂到根,之后查询接近 O(1)。写成迭代是为了不受递归深度影响。 |
fa[find(a)] = find(b) | 合并:把 a 的根挂到 b 的根下。只把两个根连起来,写成 fa[a] = b 会把 a 底下的点全从原集合里拆走。 |
判断连通 | find(a) == find(b)。 |
计数 | Kruskal 里用 cnt 数已选边数,选满 n−1 就结束;最后 cnt < n−1 即图不连通。 |
复杂度 | 带路径压缩后单次接近 O(1)(反阿克曼函数),m = 2×10⁵ 时并查集部分耗时可忽略。 |
int fa[MAXN];
for (int i = 1; i <= n; ++i) fa[i] = i; // 必须初始化
int a = find(u), b = find(v);
if (a != b) { fa[a] = b; ++cnt; } // 不在同一集合才合并fa[u] = v 不取根:u 下面挂着的点全部脱离原集合,连通性判断随机出错,Kruskal 会加入成环的边,答案偏小。
只做路径压缩、不做按大小合并也能过;但两者都加上只是两行代码的事。
| 组成 | 含义 / 格式要求 |
|---|---|
indeg 数组 | 读边时 ++indeg[v](边 u → v)。写成出度就完全不是拓扑排序。 |
初始入队 | 入度为 0 的点没有前驱,可以直接处理,全部先入队。 |
--indeg[v] == 0 | 每处理一条边删掉一条入度,降到 0 才入队——保证 v 的所有前驱都处理完了。 |
判环 | 数出队次数 cnt,cnt == n 才无环;cnt < n 说明剩下的点互相成环,拓扑序不存在。 |
DAG 上的 DP | 按出队顺序更新 dp,就是 DAG 上正确的递推顺序:处理 u 时它的所有前驱都已算完。 |
复杂度 | O(n+m)。每个点入队一次、每条边被删一次。 |
queue<int> q;
int cnt = 0;
for (int i = 1; i <= n; ++i) if (indeg[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
++cnt;
for (int v : g[u])
if (--indeg[v] == 0) q.push(v); // 前驱全部处理完才入队
}
if (cnt < n) puts("有环,拓扑序不存在");写成 if (indeg[v] - 1 == 0):入度永远不减,队列第一次就空了,只输出一个点。
cnt 在入队时加:重边会让同一个点被数多次,判环失效。
① 存图。m 到 10⁶,邻接矩阵要 n² = 10¹⁰ 个格子,开不出来。用 vector<int> g[MAXN]:每条边只存一次,空间 O(n+m)。
② 定顺序。题目要求「参考文献按编号从小到大看」,而邻接表里的顺序就是读入顺序。所以遍历之前必须 for (i = 1..n) sort(g[i])。不排序时,DFS 序完全取决于输入顺序——样例恰好是升序给的,本地根本看不出错。
③ 判重。vis 数组。DFS 进点即标记,BFS 入队即标记。标记晚一步,同一个点会被处理两次,输出里就出现重复编号。
DFS 还要提前决定一件事:n 到 10⁵,递归深度最坏也是 10⁵,逼近 8 MB 的栈上限。所以这里用显式栈:弹出时访问,然后把出点倒序压栈——倒序是为了让编号最小的出点留在栈顶,弹出顺序才与递归版一致。这一行写反,遍历序就变了。
BFS 用 queue,入队即标记、出队即输出,得到的自然是「先近后远,同层按编号升序」。两遍遍历之间 memset(vis, 0, sizeof vis)。
复杂度:排序 Σ deg(u)·log deg(u) ≤ O(m log m),遍历 O(n+m),读入 O(m)。m = 10⁶ 这一级读入本身就是瓶颈:用 scanf 最稳,用 cin 必须关同步。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, m;
vector<int> g[MAXN]; // g[u]:u 引用的所有文献
bool vis[MAXN]; // 判重:每个点最多真正访问一次
// 按顺序输出,编号之间一个空格,行末只有换行
void printOrder(const vector<int> &ord) {
for (int i = 0; i < (int)ord.size(); ++i) {
if (i) putchar(' ');
printf("%d", ord[i]);
}
putchar('\n');
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < m; ++i) {
int x, y;
scanf("%d%d", &x, &y);
g[x].push_back(y); // 有向边 X -> Y,只加一条
}
// 题目要求小编号优先;邻接表里是读入顺序,必须自己排
for (int i = 1; i <= n; ++i) sort(g[i].begin(), g[i].end());
// ---------------- DFS:显式栈,避免 1e5 深度爆栈 ----------------
vector<int> ord, st;
st.push_back(1);
while (!st.empty()) {
int u = st.back(); st.pop_back();
if (vis[u]) continue; // 可能被多个点重复压栈,只处理第一次
vis[u] = 1;
ord.push_back(u);
// 倒序压栈:编号最小的出点最后压,弹出时最先访问,与递归 DFS 一致
for (int i = (int)g[u].size() - 1; i >= 0; --i) st.push_back(g[u][i]);
}
printOrder(ord);
// ---------------- BFS:队列,入队即标记 ----------------
memset(vis, 0, sizeof vis); // 两遍遍历之间必须清空
ord.clear();
queue<int> q;
q.push(1); vis[1] = 1;
while (!q.empty()) {
int u = q.front(); q.pop();
ord.push_back(u);
for (int v : g[u]) {
if (vis[v]) continue;
vis[v] = 1; // 入队立刻标记,否则同一点会重复进队
q.push(v);
}
}
printOrder(ord);
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
vector<int> g[MAXN]; | 邻接表。m ≤ 10⁶ 条边、n ≤ 10⁵ 个点,空间 10⁶ 个 int 加 10⁵ 个 vector 头,总共 6 MB 以内。 |
for (int i = 1; i <= n; ++i) sort(g[i].begin(), g[i].end()); | 题目要求「小编号优先」。样例的边恰好是按升序给的,所以删掉这一行本地看不出变化;把输入第 2、3 行对调后再删,DFS 序立刻变成 1 3 7 8 2 5 6 4 —— 顺序完全由输入决定。 |
if (vis[u]) continue; | 显式栈里同一个点可能被压进栈多次(每个前驱各压一次)。跳过已访问的,保证一个点只输出一次。 |
for (int i = (int)g[u].size() - 1; i >= 0; --i) st.push_back(g[u][i]); | 倒序压栈是这题唯一的技巧。正序压栈时栈顶是编号最大的出点,遍历序与递归版不一致 → WA。另外 size() 是无符号数,先强转 int 再减一:空表时 i = −1,循环直接跳过。 |
memset(vis, 0, sizeof vis); | 两遍遍历之间清空。忘了这一行,BFS 时所有出点都被判成「已访问」,第二行只剩一个 1。 |
q.push(1); vis[1] = 1; | 起点入队的同时标记。先 push 后标记也不会错,但两件事写在一起,就不会出现「进了队却没标记」。 |
if (vis[v]) continue; vis[v] = 1; q.push(v); | BFS 的核心:标记发生在入队时。改成出队时标记,同一个点会被多个父亲重复入队,队列长度从 n 涨到 O(m)。 |
printOrder(ord) | 把遍历序先存进 vector 再统一输出。这样能干净地控制「数字之间一个空格、行末只换行」,不用在每个 printf 里判断是不是第一个。 |
scanf("%d%d", &x, &y) | m = 10⁶ 时读入本身就是瓶颈。scanf 在这个量级比 cin 稳;若坚持用 cin,务必关同步。 |
| 题目里出现 | 第一反应 | 最容易写错的地方 |
|---|---|---|
| 连通块个数 / 能否互相到达 | DFS、BFS 或并查集,O(n+m) | 无向图只加了一条边 |
| 无权图最少几步 | BFS,距离数组兼作 visited | 在出队时标记 |
| 非负边权的最短路 | 堆优化 Dijkstra | 忘了 greater 或 pair 顺序写反 |
| n ≤ 400 的全源最短路 | Floyd 三重循环 | 中间点 k 没放最外层 |
| 连通所有点的最小花费 | Kruskal:排序 + 并查集 | fa 没初始化、合并不取根 |
| 有依赖关系的顺序 / 最长链 | 拓扑排序,再按出队顺序 DP | indeg 记成出度、忘判环 |
无向图只加一条边 → WA / 答案偏小。典型症状:n 个点本该 1 个连通块,程序数出 3 个;或者「任意两点可达」的题里有大量点输出 −1。检查 g[u].push_back(v) 有没有配一条 g[v].push_back(u)。
邻接表没排序就遍历 → WA。P5318 这类要求「小编号优先」的题,DFS 序会随输入顺序变。样例数据往往恰好是升序给的,本地全过、交上去第 3 个点就错。
BFS 在出队时判重 → TLE。小数据全对,大数据队列长度炸到 O(m),10⁶ 条边时内存和时间一起崩。判重必须放在入队那一刻。
Dijkstra 的堆方向写反 → TLE 或 WA。用默认大根堆、只靠懒删除时答案仍然是对的(顺序错了但会不断修正),代价是同一个点被反复松弛,P4779 的菊花数据能把它卡成 TLE;一旦再配上 used 数组「弹出即定案」,错的距离就会被冻死,大片数据 WA。三件事一起记:小根堆 greater、懒删除、别加 used。
INF 取成 0x7fffffff → WA。不可达点的 dist 是 2³¹−1,只要在它上面加一次正权就溢出成负数,「不可达」被改写成一条负距离,之后还会继续被松弛,输出里会出现 −2000000000 这种数。写 LLONG_MAX / 2 最稳。
并查集忘了 fa[i] = i → 连通图也输出 orz。fa 全局默认全 0,所有 find 都返回 0,每条边都被判成「成环」而跳过,cnt 始终是 0。
合并时写 fa[u] = v(不取根)→ WA 且答案偏小。树被拆断,连通性判断随机出错,Kruskal 会加入成环的边,最终答案比正确值小,但边数还是 n−1,很难自查。
答案用 int → 输出负数。P1629 的答案到 10¹⁰,P4017 的方案数是指数级(必须边算边模)。凡是「求和」「相乘」「方案数」,先估量级再定类型。
关掉页面,用 P5318 的样例从空文件敲一遍,提交到洛谷。
改一改①:把输入第 2、3 行对调(先 1 3 再 1 2),排序那行还在时输出不变;把排序那行注释掉,DFS 序变成 1 3 7 8 2 5 6 4,BFS 序变成 1 3 2 4 7 5 6 8 —— 这就是「没排序」的现场。
改一改②:把显式栈换成递归 DFS,再自己造一条 n = 10⁵ 的链(第 i 行输入 i i+1),盯着程序看它是正常结束还是 RE。8 MB 栈 ÷ 每帧几十字节 ≈ 10⁵ 帧,正好卡在边界上——这就是本节用显式栈的理由。
改一改③:把 BFS 的 vis[v] = 1 从入队移到出队,用 m = 10⁶ 的随机图跑一遍,看时间与队列长度怎么变。
| 题号 | 编号 | 主要考什么 | 最容易错在哪 |
|---|---|---|---|
| A | P3916 | 反向建图 + BFS:编号大的先覆盖 | 正着建图,语义整个反了 |
| B | P4779 | 堆优化 Dijkstra 模板 | 大根堆 + used 数组;pair 两位写反 |
| C | P1629 | 正反两张图各跑一次 Dijkstra | 只跑一次;ans 用 int |
| D | P3366 | Kruskal + 并查集 | fa 没初始化;合并不取根 |
| E | P4017 | 拓扑排序 + 路径计数 | 普及/提高- |
0~10 分钟:只读题不写代码。按「能立刻写」→「有思路」→「没思路」给 5 题排序,写下每题要用的模板。
10~25 分钟:A(图的遍历)。重点不是 BFS,而是「为什么从大到小扫、为什么建反图」这两问。
25~45 分钟:B(单源最短路)。模板题,要求一次写对:三个模板参数、懒删除、INF 的写法。
45~65 分钟:C(邮递员送信)。想清楚「回来」为什么等于反图上「出去」,这一题就只剩打模板。
65~80 分钟:D(最小生成树)。排序 + 并查集,重点是 fa 初始化和 orz 的判据。
80~90 分钟:E(最大食物链计数)。拓扑排序 + DP,重点是「链的起点/终点」怎么在度上体现。
90~95 分钟:只检查,不改算法。按下面的清单过一遍,每检查一项就打一个勾。
边的方向:有向图只加一条,无向图加两条,反图是否也建了?
vis:两遍遍历之间清空了吗?BFS 的标记在入队处吗?
最短路:dist 用 fill 初始化了吗?INF 够大且加法不溢出吗?小根堆写全了吗?
并查集:fa[i] = i 写了吗?合并的两个参数是 find 的返回值吗?
拓扑:indeg 记的是入度吗?环判了吗?
类型:求和 / 方案数是不是 long long 或边算边模?
输出:行末有没有多余空格?每个答案是否各自换行?
限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。
给出 N 个点、M 条边的有向图。对每个点 v,记 A(v) 为从 v 出发沿有向边走能到达的所有点(包括 v 自己)中编号最大的那个。求 A(1), A(2), …, A(N)。
输入格式:第一行两个整数 N、M(1 ≤ N, M ≤ 10⁵);接下来 M 行,每行两个整数 Uᵢ、Vᵢ,表示一条 Uᵢ → Vᵢ 的有向边。点用 1…N 编号。
输出格式:一行 N 个整数,依次为 A(1)…A(N),相邻两个之间一个空格。
样例输入4 3
1 2
2 4
4 3
样例输出4 4 3 4
样例解释:1 能到 2、4、3,最大编号 4;2 能到 4、3,A(2) = 4;3 只能到自己,A(3) = 3;4 能到 3,A(4) = 4。
先估暴力:每个点各跑一次遍历,最坏 N × (N+M) = 2×10¹⁰,1 秒不可能。所以必须让多个点共用同一次遍历。
如果 x 能走到 y,那么 A(x) ≥ A(y)。换句话说,答案大的点会「覆盖」答案小的点。什么样的扫描顺序能让最大的那个先说话?
把每条边的方向倒过来。原图上「谁能走到 i」,在反图上就是「从 i 能走到谁」——从 i 出发一次 BFS,就能把所有答案至少是 i 的点一次标记完。
暴力 O(N(N+M)) 必 TLE。突破口是:A(v) 一定是 v 能到达的点里编号最大的那个,而「能到达」在反图上就是「被到达」。
把每条边反向:原图 u → v 存成反图 v → u。这样从 i 在反图上能走到的每个 x,都满足「原图上 x 能走到 i」,于是 A(x) ≥ i。
从 N 往 1 枚举 i。若 ans[i] 还没被赋值,就从 i 在反图上 BFS,把沿途所有未赋值的点都标成 i。
正确性:设 x 被标成 i。① 反图上 i 到 x 有路 → 原图 x 到 i 有路 → A(x) ≥ i;② 任何 j > i 都在更早的轮次处理过,若 x 能到 j,反图上 j 就能到 x,x 早被标成 ≥ j 的值,矛盾。所以 A(x) 恰好是 i。
复杂度:每个点只被赋值一次、入队一次,每条反边最多扫一次,总 O(N+M),N = M = 10⁵ 时约 2×10⁵ 次操作。
细节:i 必须先令 ans[i] = i 再入队,否则没有出边的点会输出 0。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, m;
vector<int> rg[MAXN]; // 反图:原边 u -> v 在这里存成 v -> u
int ans[MAXN]; // ans[v]:v 能到达的最大编号;0 表示还没定
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < m; ++i) {
int u, v;
scanf("%d%d", &u, &v);
rg[v].push_back(u); // 唯一改动:反向建边
}
for (int i = n; i >= 1; --i) { // 从大到小扫:先被覆盖的答案就是最大的
if (ans[i]) continue; // 更大的点已经把它定过了
ans[i] = i; // 自己一定能到达自己
queue<int> q;
q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : rg[u]) {
if (ans[v]) continue;
ans[v] = i; // 反图 i 能到 v,等价于原图 v 能到 i
q.push(v);
}
}
}
for (int i = 1; i <= n; ++i) {
if (i > 1) putchar(' ');
printf("%d", ans[i]);
}
putchar('\n');
return 0;
}| 片段 | 作用 |
|---|---|
rg[v].push_back(u) | 反向建图的全部代价就是这一行:把 push 的下标换一下,后面的代码一行都不用改。 |
for (int i = n; i >= 1; --i) | 从大到小枚举。顺序反过来就没有「先被覆盖即最大」的性质,样例会输出 1 2 3 3 而不是 4 4 3 4。 |
if (ans[i]) continue; | 已经确定过的点不必再 BFS:它的答案在更早(更大)的那一轮就定下了。 |
ans[i] = i; | 自己一定能到达自己。漏了这一行,没有入边的孤立点答案是 0。 |
if (ans[v]) continue; ans[v] = i; | 只处理没赋过值的点。第一次覆盖它的 i 就是最大的可达编号,以后再被更小的 i 碰到也不用改。 |
if (i > 1) putchar(' '); | 行首不加空格,行末只换行。严格比对时多余空格会 WA。 |
给定 n 个点、m 条有向边的带非负权图,求起点 s 到每个点的最短距离。数据保证 s 能到达所有点。
输入格式:第一行三个正整数 n、m、s(1 ≤ n ≤ 10⁵,1 ≤ m ≤ 2×10⁵,s = 1);接下来 m 行,每行三个非负整数 uᵢ、vᵢ、wᵢ,表示一条 uᵢ → vᵢ、权值为 wᵢ 的有向边(0 ≤ wᵢ ≤ 10⁹,0 ≤ Σwᵢ ≤ 10⁹)。
输出格式:一行 n 个空格分隔的非负整数,第 i 个表示 s 到 i 的最短距离。
样例输入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
边权非负,所以某个点的最短路一旦被确定,就不会被更短的路径推翻。能不能利用这一点,规定一个「确定的顺序」,每轮只扩展一个点?
朴素做法每轮扫一遍数组找 dist 最小的未确定点,O(n²) = 10¹⁰。谁能在 O(log n) 内一直给你最小值?
同一个点可能被松弛多次,堆里会留着它的好几份历史版本。取出一个距离比当前 dist 还大的元素时,想清楚该拿它怎么办。
Dijkstra 把点分成「最短路已确定」和「未确定」两组。每轮从未确定集合里取 dist 最小的点 u,把 dist[u] 定为最终答案,再用 u 松弛它的所有出边。
为什么取最小就一定定案:任何到 u 的更短路径必然先经过某个未确定点 x,而 dist[x] ≥ dist[u];边权非负,绕过去只会更长。这与「u 是最小」矛盾。
朴素实现每轮 O(n) 找最小,总 O(n²),n = 10⁵ 时 10¹⁰,必 TLE。
把 (dist, 编号) 塞进小根堆,堆顶就是当前最小:每个点松弛成功就入堆一次,取出时若 d > dist[u] 说明是过期版本,直接丢弃。
复杂度 O((n+m) log n)。m = 2×10⁵ 时堆操作不超过 4×10⁵ 次,每次 log(4×10⁵) ≈ 19,总共约 8×10⁶ 次比较,1 秒内稳过。
类型:Σwᵢ ≤ 10⁹ 意味着最短路也在 10⁹ 量级,int 勉强够;但 d + w 在 INF 上做加法时用 int 容易溢出,一律 long long,不必再算边界。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
typedef long long ll;
typedef pair<ll, int> pli; // (距离, 编号)
int n, m, s;
vector<pair<int,int>> g[MAXN]; // (终点, 边权)
ll dist[MAXN];
int main() {
scanf("%d%d%d", &n, &m, &s);
for (int i = 0; i < m; ++i) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w}); // 有向边,只加一次
}
const ll INF = LLONG_MAX / 2; // 足够大且加 w 不会溢出
fill(dist, dist + n + 1, INF);
dist[s] = 0;
priority_queue<pli, vector<pli>, greater<pli>> pq; // 小根堆
pq.push({0, s});
while (!pq.empty()) {
pli cur = pq.top(); pq.pop();
ll d = cur.first;
int u = cur.second;
if (d > dist[u]) continue; // 过期版本:u 后来被更短的路径更新过
for (auto &e : g[u]) {
int v = e.first, w = e.second;
if (dist[v] > d + w) { // 松弛成功才入堆
dist[v] = d + w;
pq.push({dist[v], v});
}
}
}
for (int i = 1; i <= n; ++i) {
if (i > 1) putchar(' ');
printf("%lld", dist[i]);
}
putchar('\n');
return 0;
}| 片段 | 作用 |
|---|---|
typedef pair<ll,int> pli | 堆里存 (距离, 编号)。pair 按字典序比较,距离是第一关键字,所以堆顶永远是「距离最小的点」。 |
greater<pli> | 小根堆。写成默认的 priority_queue<pli> 就是大根堆:只靠懒删除时它仍然能算出正确答案(顺序错了,但会一直修正),代价是同一个点被反复松弛;一旦再配「弹出即定案」的 used 数组就必错。 |
if (d > dist[u]) continue; | 懒删除。不写这一句结果也对,但同一个点会被反复松弛,堆元素从 O(m) 变几倍,常数明显变大。 |
if (dist[v] > d + w) | 松弛条件。相等时不入堆,省一次操作,结果不变。 |
fill(dist, dist + n + 1, INF) | 区间是 [dist, dist+n+1),下标 1..n 全部覆盖。INF 取 LLONG_MAX/2,保证 d+w 不会溢出成负数。 |
dist[s] = 0; pq.push({0, s}); | 起点先定 0 再入堆。顺序反了,第一次取出的会是 INF。 |
used 数组「弹出即定案」、又用大根堆:过早点定案,错的距离被冻死,随机小图上错误率极高。本节代码不带 used,靠的是懒删除 + 小根堆。d > dist[u] 的过滤:结果仍然正确,但堆里每个历史版本都会重扫一遍出边,m = 2×10⁵ 时明显变慢。邮局在 1 号点,要给 2…n 号点各送一样东西。城里所有道路都是单行的,共 m 条。邮递员每次只能带一样东西,送完一件必须返回邮局,最后也要回到邮局。求送完所有东西的最少总时间。
输入格式:第一行两个整数 n、m(1 ≤ n ≤ 10³,1 ≤ m ≤ 10⁵);接下来 m 行,每行三个整数 u、v、w,表示 u 到 v 有一条耗时 w 的单向道路(1 ≤ w ≤ 10⁴)。输入保证任意两点互相可达。
输出格式:一行一个整数,表示最少总时间。
样例输入5 10
2 3 5
1 5 5
3 5 6
1 2 8
1 3 8
5 3 4
4 1 8
4 5 3
3 5 6
5 4 2
样例输出83
「送出去」和「回到邮局」是两段完全不同的路:去 i 是 1 → i,回来是 i → 1。单向图里这两件事不对称,不能用同一个距离。
1 → i 全部点只要跑一次 Dijkstra。可是 i → 1 有 n−1 个不同的起点,难道要跑 n−1 次?先数一下 n 和 m 的量级。
把每条边掉个头。在反图上从 1 出发的最短路,正好就是原图上「从某个点回到 1」的最短路——路径的每条边反向,长度不变。
总时间 = Σ( 1→i 的最短路 + i→1 的最短路 ),i = 2…n。两项分别算。
第一项:以 1 为源,在原图上跑一次 Dijkstra。
第二项:i → 1 的起点各不相同,但把每条边反向之后,「i → 1」就变成了「1 → i」。所以建一张反图,再从 1 跑一次 Dijkstra。
为什么等价:原图上一条 i → a → b → 1 的路径,把每条边反向就得到反图上 1 → b → a → i 的路径,边权逐一对应,长度完全一样,且这是双射。
复杂度:两次 Dijkstra,O((n+m) log n)。n = 10³、m = 10⁵ 时约 10⁵×10 次操作。若对每个 i 单独跑一次,是 10³ × 10⁵ = 10⁸ 量级以上,1 秒很难过。
类型:单条最短路 ≤ (n−1)×10⁴ ≈ 10⁷,int 够;但 n−1 条加起来到 10¹⁰,累加答案时必须 long long。样例:正向和 28,反向和 55,总共 83。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int INF = 0x3f3f3f3f; // 1.06e9,比任何真实最短路都大,加 w 也不溢出
int n, m;
vector<pair<int,int>> g[MAXN], rg[MAXN]; // (终点, 权值);g 正图,rg 反图
int d1[MAXN], d2[MAXN];
// 从 1 出发,在 gr 这张图上跑 Dijkstra,结果写进 d
void dijkstra(vector<pair<int,int>> gr[], int d[]) {
fill(d, d + n + 1, INF);
d[1] = 0;
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
pq.push({0, 1});
while (!pq.empty()) {
auto [du, u] = pq.top(); pq.pop();
if (du > d[u]) continue; // 过期版本
for (auto [v, w] : gr[u])
if (d[v] > du + w) {
d[v] = du + w;
pq.push({d[v], v});
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
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
rg[v].push_back({u, w}); // 反图:用来算「从 i 回 1」
}
dijkstra(g, d1); // d1[i] = 1 -> i
dijkstra(rg, d2); // d2[i] = i -> 1
long long ans = 0; // 10³ 条 10⁷ 的路,必须 long long
for (int i = 2; i <= n; ++i) ans += (long long)d1[i] + d2[i];
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
vector<pair<int,int>> g[MAXN], rg[MAXN] | 正图、反图各一份。n ≤ 10³、m ≤ 10⁵,两份邻接表合计 2×10⁵ 条边,约 1.6 MB。 |
rg[v].push_back({u, w}) | 全题唯一的新代码。原边 u→v 在反图里记成 v→u,权值不变。 |
dijkstra(g, d1) | 第一次:1 到每个点的最短路,即送出去的时间。 |
dijkstra(rg, d2) | 第二次:反图上 1 到每个点,等于原图上每个点回到 1,即返回的时间。 |
fill(d, d + n + 1, INF) | 每次调用前整体重置。函数里用的是全局 n,区间 [d, d+n+1)。 |
ans += (long long)d1[i] + d2[i] | 两项相加后累加。先转 long long 再相加,避免两数相加时溢出。 |
auto [du, u] = pq.top() | 结构化绑定,一次取出距离和编号。顺序必须与 push 时一致(距离在前)。 |
rg[u].push_back({v,w})):两次都算「送出去」,答案变成 2×28 = 56,样例一眼可见,但很值得警惕。给出 N 个点、M 条边的无向图,求它的最小生成树(选出 N−1 条边把所有点连成一片,且边权和最小)。如果图不连通,输出 orz。
输入格式:第一行两个整数 N、M(1 ≤ N ≤ 5000,1 ≤ M ≤ 2×10⁵);接下来 M 行,每行三个整数 Xᵢ、Yᵢ、Zᵢ(1 ≤ Zᵢ ≤ 10⁴),表示一条连接 Xᵢ、Yᵢ、长度为 Zᵢ 的无向边。
输出格式:连通则输出一个整数表示最小生成树的边权之和;不连通输出 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)、3-4(3),总权 2+2+3 = 7。
n 个点的树恰好 n−1 条边。要在 m 条边里挑 n−1 条,使总权最小且不出环。先想:按什么顺序挑边最不亏?
把边按长度从小到大看。一条边什么时候必须选?什么情况下它一定不能选?「不能选」的判据,说到底和「这两个点现在是否已经连通」有关。
什么结构能在接近 O(1) 的时间里回答「两点是否连通」,并且支持把两个集合合并成一个?
Kruskal:所有边按权值升序排序,依次尝试加入。若两端已经在同一连通块里就跳过(加入会成环),否则选中这条边并把两个连通块合并。选满 n−1 条即结束。
正确性(割性质):设 e 是当前剩下的边里权最小的,且两端分属不同连通块。任何一棵生成树都必须有某条边跨过这个割;把那条边换成 e,总权不会变大。所以 e 一定可以出现在某棵最小生成树中,删去这个子问题后归纳成立。
并查集负责「是否连通」与「合并」:路径压缩后单次接近 O(1)。
复杂度由排序决定:O(m log m)。m = 2×10⁵ 时约 3.6×10⁶ 次比较,并查集部分几乎不耗时。
判不连通:最后选中的边数 cnt < n−1,说明有的点始终连不进来,输出 orz。
边界:n = 1 时不需要任何边,cnt = 0 = n−1,输出 0;题目数据里 n ≥ 1。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 5005, MAXM = 200005;
int n, m;
struct Edge { int u, v, w; } e[MAXM];
int fa[MAXN];
int find(int x) { // 路径压缩,迭代写法
while (fa[x] != x) {
fa[x] = fa[fa[x]];
x = fa[x];
}
return x;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < m; ++i) scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w);
sort(e, e + m, [](const Edge &a, const Edge &b) { return a.w < b.w; });
for (int i = 1; i <= n; ++i) fa[i] = i; // 每个点自成一个集合,不能省
int cnt = 0;
long long sum = 0;
for (int i = 0; i < m && cnt < n - 1; ++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) printf("%lld\n", sum);
else puts("orz"); // 不连通,按题目要求输出
return 0;
}| 片段 | 作用 |
|---|---|
struct Edge { int u, v, w; } e[MAXM] | 把一条边打包。排序时 u、v 跟着一起搬,不用开三个平行数组。 |
sort(e, e + m, [](const Edge &a, const Edge &b) { return a.w < b.w; }) | 只按权值升序。权值相同的边谁先谁后不影响最终结果——Kruskal 对同权边的顺序不敏感。 |
for (int i = 1; i <= n; ++i) fa[i] = i; | 初始化。漏了这行,fa 全是 0,find 全部返回 0,每条边都被判成成环,最后输出 orz。 |
if (a == b) continue; | Kruskal 唯一的「拒绝」条件:两端已连通。这是保证不出环的全部机制。 |
fa[a] = b; | 合并两个根。写成 fa[e[i].u] = e[i].v 会把树拆断,连通性判断随机出错,答案偏小。 |
cnt < n - 1 | 选满 n−1 条边就够,后面的边不必再看。写在循环条件里,顺便防止无谓扫描。 |
puts("orz") | 不连通的输出。三个小写字母,不要写成 ORZ、orz!、0 或 −1。 |
fa[i] = i:连通图也输出 orz(所有 find 都返回 0,每条边都被跳过)。fa[e[i].u] = e[i].v:树被拆断,可能加入成环的边,答案偏小。给出一个食物网:n 个物种、m 条「吃与被吃」的关系。一条最大食物链,从不会捕食其他生物的生产者出发,到不会被其他生物捕食的消费者结束,链上每一步都是捕食关系。求这样的食物链条数,对 80112002 取模。
输入格式:第一行两个正整数 n、m(1 ≤ n ≤ 5×10³,1 ≤ m ≤ 5×10⁵);接下来 m 行,每行两个正整数 A、B,表示 B 吃 A(A 被 B 捕食)。数据保证不会出现环。
输出格式:一行一个整数,食物链条数模 80112002 的结果。
样例输入5 7
1 2
1 3
2 3
3 5
2 5
4 5
3 4
样例输出5
样例解释:5 条链分别是 1→2→3→5、1→2→5、1→3→5、1→2→3→4→5、1→3→4→5。1 是唯一的生产者(没有生物吃它),5 是唯一的顶级消费者(它不吃别的)。
先把方向定死:B 吃 A,就把「A 被 B 吃」画成 A → B。那么一条食物链就是这张有向图里的一段路径。生产者和顶级消费者,在这个方向上分别有什么特征?
直接枚举所有路径会重复、会漏。换一个量:以 u 结尾的链有几条?把它记成 dp[u],所有链都能按「最后一步从哪来」被唯一分解。
要让 dp 的递推一次算准,处理某个点时它所有的前驱必须都算完了。什么顺序能保证这件事?而这张图恰好没有环。
建图:B 吃 A,就加有向边 A → B。于是「生产者」= 入度为 0 的点(它不吃任何别的生物),「顶级消费者」= 出度为 0 的点(没有生物吃它)。食物链 = 从入度 0 走到出度 0 的一条路径。
状态:dp[u] = 以 u 结尾的食物链条数。初值:入度为 0 的点 dp = 1,它自己就是一条链的开头。
转移:按拓扑序处理,每遇到边 u → v,就 dp[v] += dp[u],随时对 80112002 取模。
答案:Σ dp[u],只累加出度为 0 的点——链必须在顶级消费者结束,中途的点不能计入。
为什么拓扑序就够:处理到 u 时,所有能到达 u 的点都已经把贡献加进了 dp[u],不会漏;u 只会被处理一次,不会重。
复杂度 O(n+m)。m = 5×10⁵、时限 1 秒,读入必须用 scanf;dp 的加法在 int 内完成(模数 8×10⁷,两数相加不超过 1.7×10⁸)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 5005;
const int MOD = 80112002;
int n, m;
vector<int> g[MAXN];
int indeg[MAXN], outdeg[MAXN]; // 入度 = 它吃几种;出度 = 几种吃它
int dp[MAXN]; // dp[u]:以 u 结尾的食物链条数
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < m; ++i) {
int a, b;
scanf("%d%d", &a, &b); // B 吃 A
g[a].push_back(b); // 链的方向:被吃者 -> 捕食者
++indeg[b];
++outdeg[a];
}
queue<int> q;
for (int i = 1; i <= n; ++i)
if (indeg[i] == 0) { // 生产者:链的起点
dp[i] = 1;
q.push(i);
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u]) {
dp[v] = (dp[v] + dp[u]) % MOD; // 所有以 u 结尾的链都能延长到 v
if (--indeg[v] == 0) q.push(v); // v 的全部前驱都处理完了
}
}
int ans = 0;
for (int i = 1; i <= n; ++i)
if (outdeg[i] == 0) // 顶级消费者:链的终点
ans = (ans + dp[i]) % MOD;
printf("%d\n", ans);
return 0;
}| 片段 | 作用 |
|---|---|
g[a].push_back(b) | 边的方向就是链的方向:被吃 → 吃。三处方向必须统一(建图、indeg、outdeg)。全反过来的话链首尾互换是一一对应,总数恰好不变;但混用(建图 A→B、入度却记在 A 上)就必错。 |
dp[i] = 1 | 每个入度为 0 的点自成一链的开头,方案数 1。 |
dp[v] = (dp[v] + dp[u]) % MOD | 按拓扑序做加法:以 u 结尾的每一条链,都能接一步到 v,所以 v 的方案数加上 u 的方案数。边算边模,避免溢出。 |
if (--indeg[v] == 0) q.push(v) | 入度降到 0 才入队,保证 v 的所有前驱都已处理完,dp[v] 一次算定,不会漏加。 |
if (outdeg[i] == 0) ans += dp[i] | 只有出度为 0 的点才是链的终点。把所有点的 dp 都加上,答案会大很多。 |
const int MOD = 80112002 | 模数不是 1e9+7。抄错一个数字就是 0 分。 |