题单 · 习题解析

洛谷 B3643 图的存储

本章模板题:★★ 邻接矩阵的一行天生是升序 ⇒ 第二问白送、一次 sort 都不用;★★★ 题面那句「无重边无自环」对 vector 版是噪声(0)、对扫矩阵那版是命门(247/247 一个不差);★ 抓获率的主语是 n(139 → 224 → 290 → 300)

原题:洛谷 B3643出自 第 29 章 图的存储:三种存法的对比与选型 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 B3643,日期见页头。两边不一致时信原站。

题目描述

给定一个 n 个顶点 m 条边的无向图。请以邻接矩阵和邻接表的形式输出这一张图。

输入格式

第一行输入两个正整数 nm,表示图的顶点数和边数。

第二行开始,往后 m 行,每行输入两个以空格隔开的正整数 u, v,表示 u, v 顶点之间有一条边直接相连。

输出格式

首先输出 nn 列的矩阵,以空格隔开每一行之间的数表示邻接矩阵。 第 i 行第 j 列的数为 1 则表示顶点 i, j 之间有一条边直接相连;若为 0 则表示没有直接相连的边。

再往后输出 n 行。第 i 行首先先输出一个整数 dᵢ,表示这个顶点的度数, 再按照从小到大的顺序,依次输出与顶点 i 直接相连的所有顶点。

说明/提示

样例的图如图所示:

B3643 样例的图:5 个顶点 5 条边

数据保证,对于所有数据,1 ≤ n ≤ 10001 ≤ m ≤ 10⁵且图无重边无自环

输入输出样例

输入

5 5
1 2
2 3
3 5
1 3
3 4

输出

0 1 1 0 0
1 0 1 0 0
1 1 0 1 1
0 0 1 0 0
0 0 1 0 0
2 2 3
2 1 3
4 1 2 4 5
1 3
1 3

前五行是邻接矩阵,后五行是邻接表:2 2 3 意思是「1 号点度数为 2,邻居是 2 和 3」。

⚠ 注意 3 号点那一行是 4 1 2 4 5 —— 邻居是升序的, 而输入里和 3 相连的边依次是 2 3 / 3 5 / 1 3 / 3 4(进来的顺序是 2、5、1、4)。 这组样例把本页三个错法全挡住了,下一步就从这儿开始。

1★ 第一版:vector 存图,邻居直接输出 —— 官方样例当场打回来

b3643Order.cpp✗ 第一版:邻居按输入顺序输出
// ✗ B3643 第一版:vector 存图,邻居**按输入顺序**输出。
//
// 这是绝大多数人真实的第一版 —— 三行建图、两个循环输出,看着毫无破绽。
// 而它错在题面那半句话上:「**再按照从小到大的顺序**,依次输出与顶点 i 直接相连的所有顶点」。
//
// vector 里邻居的顺序 = **边在输入里出现的顺序**,和「从小到大」没有任何关系。
// ⚠ 官方样例挡不住它(那组样例里每个点的邻居恰好就是升序进来的)—— 见解析页第 ① 步。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1);
vector<vector<int>> a(n + 1, vector<int>(n + 1, 0));
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
a[u][v] = a[v][u] = 1;
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cout << a[i][j] << (j == n ? '\n' : ' ');
for (int i = 1; i <= n; i++) {
cout << g[i].size();
for (int v : g[i]) cout << ' ' << v; // ★ 没排序:这里是输入顺序
cout << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它打出来的第三行邻接表是 4 2 5 1 4,而答案要的是 4 1 2 4 5

★ 顺序不是「存法的属性」,是「你没管它」的后果

vector 里邻居的顺序 = 边在输入里出现的顺序。它和「从小到大」没有任何关系, 只是在小数据上经常碰巧一样

⇒ 这就是本章那句话的第一个现场: 你选的存法,决定了哪些问题是白送的、哪些还得自己再做一步。

2第二版:换成链式前向星 —— 错得不一样,但还是错

b3643Star.cpp✗ 前向星:邻居倒序出来
// ✗ B3643 第二版:链式前向星,邻居**倒序**出来。
//
// 换成本章第 4 步那份前向星,第一版那个「顺序不对」的毛病不但没好,还换了个方向:
// `add()` 把新边挂到链子**最前面** ⇒ 遍历 head 链拿到的邻居是**加边顺序的倒序**。
//
// ★ 这一版存在的意义,就是把「顺序错」这件事和「哪一种存法」拆开:
// 它和第一版**都是 WA,可是打出来的两行数字不一样** —— 顺序不是存法的属性,
// 是「你没管它」的后果。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<int> head(n + 1, -1), to(2 * m), nxt(2 * m);
vector<vector<int>> a(n + 1, vector<int>(n + 1, 0));
int cnt = 0;
auto add = [&](int u, int v) { to[cnt] = v; nxt[cnt] = head[u]; head[u] = cnt++; };
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
add(u, v);
add(v, u);
a[u][v] = a[v][u] = 1;
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cout << a[i][j] << (j == n ? '\n' : ' ');
for (int i = 1; i <= n; i++) {
int deg = 0;
for (int e = head[i]; e != -1; e = nxt[e]) deg++;
cout << deg;
for (int e = head[i]; e != -1; e = nxt[e]) cout << ' ' << to[e]; // ★ 倒序
cout << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

第三行变成 4 4 1 5 2正文第 4 步那句话在这儿兑现了: add() 把新边挂在链子最前面,所以遍历 head 链拿到的邻居是加边顺序的倒序

⚠ 两版都错,可打出来的数字不一样 —— 这件事值得盯一眼

同一句「顺序不对」,vector 版给的是输入序、前向星给的是输入序的倒序

⇒ 所以「我的邻接表顺序对不对」这个问题,离开存法是没法回答的。 本页第 ④ 步会给出第三种答案:邻接矩阵给的是升序——白送的。

3★ 补一行 sort —— 这一版就已经能 AC 了

b3643Sort.cpp★ 这一版就能 AC
// ★ B3643 第三版:vector 存图 + 每个点的邻居 sort 一遍 —— **这一版就已经能 AC 了**。
//
// 顺着第一版的病往下改,一行就够:输出前把 g[i] 排一遍。
// 复杂度 O(n² + m log m)(n² 是输出矩阵本身要的,躲不掉)。
//
// ⚠ 别被「还能更快」吓住:顶格 n = 1000、m = 10⁵,
// 排序那部分只有 2m log(2m) ≈ 4 × 10⁶ 次比较,而输出矩阵本身就有 10⁶ 个数字。
// **排序不是这道题的瓶颈,输出才是。**(解析页第 ④ 步量了这件事。)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1);
vector<vector<char>> a(n + 1, vector<char>(n + 1, 0));
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
a[u][v] = a[v][u] = 1;
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cout << (int)a[i][j] << (j == n ? '\n' : ' ');
for (int i = 1; i <= n; i++) {
sort(g[i].begin(), g[i].end()); // ★ 补上的就是这一行
cout << g[i].size();
for (int v : g[i]) cout << ' ' << v;
cout << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 别被「还能更快」吓住

顶格 n = 1000m = 10⁵:排序那部分约 2m log(2m) = 3 521 928 次比较, 而输出矩阵本身就有 10⁶ 个数字要打。排序从来不是这道题的瓶颈 (第 ⑥ 步给出实测:三种正确写法顶格都是 0.03~0.04 秒,时限 2 秒)。

⇒ 写到这一步就该交。下面两步是为了把这一章那句话讲透,不是为了「更优」。

4★★ 正解:一次 sort 都不用 —— 因为第一问的答案里就藏着第二问要的顺序

题目让我们输出邻接矩阵。而邻接矩阵的第 i 行,天生就是「i 的所有邻居按编号从小到大」: 从左往右扫 a[i][1..n],遇到 1 就打印列号,出来的顺序必然升序

b3643.cpp★★ 两问共用一张矩阵、一个扫描方向
// ★★ B3643 正解:一次 sort 都不用 —— 因为**第一问的答案里就藏着第二问要的顺序**。
//
// 题目让我们输出邻接矩阵。而邻接矩阵的第 i 行,天生就是「i 的所有邻居**按编号从小到大**」:
// 从左到右扫一遍 a[i][1..n],遇到 1 就打印列号,出来的顺序**必然是升序的**。
//
// for (int j = 1; j <= n; j++) if (a[i][j]) { deg++; nb.push_back(j); }
//
// ⇒ 两问用同一张矩阵、同一个扫描方向,`sort` 一次都不用,vector 邻接表也不用建。
//
// ★★ 这是第 29 章那句话最干净的一次现场:**存法没有绝对的好坏,只有配不配得上你要问的问题。**
// 题目问「按编号升序列出邻居」—— 邻接矩阵是**唯一一种天然就按这个顺序存着**的存法。
// (前向星倒序、vector 输入序,两个都得额外做一步。)
//
// 复杂度 O(n² + m),顶格 n = 1000 就是 10⁶ 次扫描 —— 和「输出矩阵」这件事本来就要花的一样多。
// 内存:`vector<char>` 的 n² 矩阵顶格 1 MB(题面给 256 MB)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<char>> a(n + 1, vector<char>(n + 1, 0));
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
a[u][v] = a[v][u] = 1; // 题面保证无重边无自环
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) cout << (int)a[i][j] << (j == n ? '\n' : ' ');
for (int i = 1; i <= n; i++) {
int deg = 0;
for (int j = 1; j <= n; j++) deg += a[i][j];
cout << deg;
for (int j = 1; j <= n; j++) if (a[i][j]) cout << ' ' << j; // ★ 天然升序
cout << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 这是第 29 章那句话最干净的一次现场

存法没有绝对的好坏,只有配不配得上你要问的问题。

这道题问的是「按编号升序列出邻居」,而邻接矩阵是唯一一种天然就按这个顺序存着的存法

存法 邻居出来的顺序 要多做一步吗
vector 输入序 要(sort
链式前向星 输入序的倒序 要(见第 ⑤ 步,钱一样多)
★ 邻接矩阵 升序 不用 —— 而且矩阵本来就要输出

⇒ 复杂度 O(n² + m),顶格 10⁶ 次扫描 —— 和「把矩阵打出来」这件事本来就要花的一样多。 第二问是白送的。

5⚠ 顺带回答一句:前向星的倒序能不能治?能,但钱和 sort 一样多

b3643Rev.cpp⚠ 先把边排好,再倒着加

前向星倒序的根子在「新边挂链头」。那就先把 2m 条有向边按 (u, v) 排一遍,再从最后一条往回加 —— 链头自然就是编号最小的邻居。它能 AC(三版顶格输出逐字节相同), 但排序的量从「每个点各排一次」挪成了「所有边一起排一次」,总量一样是 2m 个数

⇒ ★ 「倒序」不是治不了,是治它要付的钱和直接 sort 一样多。真正白送的只有扫矩阵那条。

6★★★ 正解敢这么写,全靠题面那句「无重边无自环」—— 称一称它

题面最后那句「且图无重边无自环」看着像背景。可正文第 9 步刚说过: bool/char 矩阵记不住「有几条边」——重边一进来就被合并了。 而本页正解的度数正是靠数矩阵那一行里有几个 1 数出来的。

⇒ 所以造一档违反题面的数据(允许重边和自环),看两个写法的行为变没变:

300 轮,允许重边 + 自环
真的「脏」(有重边或自环)的轮数 247
vector 版(push_back 两次)度数错的轮数 0
扫矩阵那版度数错的轮数 247
★★★ 一个不差 —— 而这句话同时干了两件事

247 ≡ 247只要输入是脏的,扫矩阵那版就一定错(重边被合并、自环少算一度)。 这是本书第若干次量到「满足触发条件的轮数 ≡ 真被抓的轮数」,而这一次它是能证明的: 脏一条边,度数和就少算至少 1。

⇒ ★★ 于是按第 12 章那套三分法(情报 / 命门 / 噪声),同一句「无重边无自环」:

  • vector + sort 那一版是噪声0 → 0,它本来就按边算度数);
  • 扫矩阵那一版是命门0 → 247)。

★★ 「这句约束重不重要」不是题目的属性,是「题目 × 你写的那一版」的属性 —— 这和第 28 章 P1171 那次是同一个形状,两轮之内出现两次 ⇒ 它是个可复用的问法。

7★ 三个错法各被抓多少 —— 而抓获率的主语是 n

对拍的参照物就是第 ③ 步那份 vector + sort(和正解选法完全不同:一个排序、一个扫矩阵行)。

300 轮(n 随机 2~8)
正解(扫矩阵)≡ vector + sort 不一致 0 轮
邻居不排序(第一版) 185
前向星倒序(第二版) 182
无向边只存一遍 300

只拧 n,别的都不动:

n 3 5 20 100
不排序被抓 139 224 290 300
前向星倒序被抓 138 226 289 300
⚠ 于是「样例把三个错法全挡住了」这件事要说得准一点

本书连着好几轮量到一条规律:官方样例是个「一测就死」的过滤器 —— 它挡住的都是「每一组都错」的错法,放过的都是「偶尔才错」的。

这一页看着像个反例:不排序在默认档只错 185/300(六成),可样例照样把它打了回来。 ⚠ 但把 n 拧上去就清楚了:n = 100 时它是精确的 300 —— 它本来就是「每组都错」型,只是默认档 n ≤ 8 里有大把点的度数 ≤ 1, 那种点根本问不出「顺序」这个问题

⇒ ★★ 抓获率低不一定是 bug 偶发,也可能是你的数据太小、问不出那个问题。 (同一个动作在第 7 章 P1102第 13 章 P1596 上都出现过 —— 先拧一个旋钮,看它到底控制着什么。)

★ 而自检也顺手做了:把边(u, v) 排好再喂进去(这时邻接表天然升序), 「不排序」当场变成精确的 0,而「前向星倒序」仍被抓 211 —— ⇒ 那个 0 是「这一档结构上抓不到」,不是这段代码没在跑。

b3643OneWay.cpp✗ 无向边只存了一遍(300/300)

8⚠ 规模:三道三十秒就能算完的算术题,答案全是「够」

顶格 n = 1000m = 10⁵
邻接矩阵格子数 10⁶
⇒ 光矩阵那部分的输出 2 000 000 字节(1.91 MB)
实际总输出(含邻接表) 2 782 687 字节(2.65 MB)
矩阵内存:int / char 3.81 MB / 0.95 MB(题面给 256 MB
vector 版排序的比较次数 2m log(2m) = 3 521 928

秒表(A 机 · WSL2 · i5-13500H · nproc 8 · 2026-08-30,独占):

顶格耗时 时限
正解(扫矩阵) 0.03 秒 2 秒
vector + sort 0.03 秒
前向星(排边后倒着加) 0.04 秒
⚠ 连同步都不关cout 0.05 秒
★ 又一次否定结论:这道题没有「输出瓶颈」

2.65 MB 的输出听着不少,可时限是 2 秒,而实测三版都在 0.04 秒以内 —— ios::sync_with_stdio(false) 都可以不写(0.05 秒 vs 0.03 秒)。

★ 单独把「打 10⁶ 个 0/1」这件事拎出来量,倍数其实不小(同机、写 /dev/null):

打 10⁶ 个 0/1 毫秒
fprintf 30
ostringstream(≈ 关了同步的 cout 20
自己拼一个 string、一次 fwrite 1

⇒ 这正是第 19 章 P1803 那条的又一次现场: 四种读写方式的倍数跨题几乎不变,变的是绝对时间,而分数线画在绝对时间上。 30 毫秒对 2 秒的时限来说什么都不是; 换成 P2367 那种「2 × 10⁷ 个数 / 1 秒」,同样的倍数就是 AC 和 TLE 的分界。

9度量程序和生成器

b3643Count.cpp度量程序(本页所有数字都出自它)
b3643Gen.cpp数据生成器

10一页纸

★★ 关键的一步 题目要升序邻居 —— 而邻接矩阵的一行天生就是升序,第二问白送
★ 第一版 vector 直接输出 = 输入序;官方样例当场打回(第三行 4 2 5 1 4
第二版 前向星 = 输入序的倒序4 4 1 5 2)—— 同一句「顺序错」,两版数字不同
★ 哪一版能 AC 补一行 sort 就够了(顶格 0.03 秒 / 时限 2 秒)
⚠ 治倒序的钱 先排 2m 条边再倒着加 —— 和直接 sort 一样多,白送的只有扫矩阵
★★★ 题面那句保证 「无重边无自环」对 vector 版是噪声(0 → 0)、对扫矩阵版是命门(0 → 247
★ 抓获率的主语 不排序:n = 3 / 5 / 20 / 100 ⇒ 139 / 224 / 290 / 300(自检档是精确的 0)
规模 顶格输出 2 782 687 字节、矩阵 char 表 0.95 MB —— 三道算术题答案全是「够」