邻接表你已经会了 —— 第 27 章那一小节就在用:
vector<vector<int>> son(n + 1);
son[k].push_back(l);所以这一章不重新介绍它,而是回答下一个、也更实用的问题:
邻接矩阵、链式前向星、
vector版,三种存法各要多少内存、扫一遍要多久?该选哪个?
★ 这一章的关键一步不是某个技巧,而是一种态度:
选型是「算」出来的,不是背出来的。
「稠密用矩阵、稀疏用表」这句口诀你大概听过。这一章要做的是
把它背后那三个公式和两张实测表摆出来 —— 之后你不用记口诀,
拿到题面看一眼 n 和 m,当场就能算。
1 这一章拿来练手的两个问题
给一张无向图(
n个点、m条边,可能有自环,也可能有重边),求: ① 每个点的度数(约定:一个自环给这个点贡献 2 度); ② 去重之后有多少条不同的边。
它们是最基础的「建图正确性检查」,而且故意挑成一对:
- ① 关心重边的条数(多一条重边,两端各多 1 度);
- ② 关心重边有没有被合并。
邻接矩阵天生擅长回答 ②(它本来就是去重的),天生答不好 ① (只存 0/1 的话重边就没了)—— 第 9 步会把这件事讲透。
存法的好坏,取决于你打算问什么。 这一章从头到尾都在说这一句。
2 手算一遍:5 个点 7 条边,故意带上自环和重边
5 7
1 2 ┐
2 3 │
1 2 ├ 注意 1-2 出现了两次(重边)
3 3 │ 3-3 是自环
4 5 │ 4-5 和 5-4 是同一条边(也是重边)
5 4 │
1 5 ┘
- 度数: 1 号 = 两条 1-2 + 一条 1-5 = 3; 2 号 = 两条 1-2 + 一条 2-3 = 3; 3 号 = 一条 2-3 + 自环算 2 度 = 3; 4 号 = 4-5 和 5-4 两条 = 2; 5 号 = 那两条 + 1-5 = 3。
- 不同的边:
{1,2}、{2,3}、{3,3}、{4,5}、{1,5}一共 5 条。
3 3 3 2 3 / 5 —— 这组数后面每一步都会回来验。
点「运行 ▶」看结果
★ 标准答案完全绕开了「图」这个数据结构(第 20 章那条规矩)。 这一章尤其需要这一点:三种存法彼此之间很容易「一起错」—— 比如三份都忘了把无向边存两遍。所以标准答案必须来自另一个方向。
3 存法一:vector 版 —— 最好写的那种
点「运行 ▶」看结果
vector<vector<int>> g(n + 1);
g[u].push_back(v);
g[v].push_back(u); // ★ 无向图要存两遍
第 27 章那棵树只存了单向(son[k].push_back(l))——
因为那里的边自带方向(上司 → 下属),而且只需要从上往下走。
这一章是无向图:从 u 能走到 v,从 v 也能走到 u,所以必须存两遍。
建图前先问一句:这张图是有向的还是无向的。 这是建图最常见的错误,没有之一 —— 第 8 步会看到它的样子。
4 存法二:链式前向星 —— 用数组手写的链表
点「运行 ▶」看结果
int head[N]; // head[u] = u 的第一条出边的编号,-1 = 没有
int to[2*M], nxt[2*M]; // to[e] 这条边指向谁;nxt[e] 同一个点的下一条边
int cnt = 0;
void add(int u, int v) {
to[cnt] = v;
nxt[cnt] = head[u]; // ★ 新边挂到链子最前面
head[u] = cnt++;
}
遍历 u 的邻居:for (int e = head[u]; e != -1; e = nxt[e])。
它本质上就是「用数组手写的链表」:head 是每个点的链头,nxt 是 next 指针。
好处是一次性开好、零动态分配,而且内存可以精确算到字节。
边编号从 0 开始 → head 初值 -1,循环条件 e != -1 ← 本章用这种
边编号从 1 开始 → head 初值 0,循环条件 e写成「编号从 0 开始 + head 初值 0」就出事了: 「没有出边」和「有一条编号为 0 的边」分不出来。第 8 步有这个错误版本。
挑一种,然后一辈子都这么写。
5 存法三:邻接矩阵 —— 查询 O(1),代价是 n²
点「运行 ▶」看结果
三份都跑出 3 3 3 2 3 / 5。(check:viz 拿 300 组随机图验过这件事:
排序去重的暴力、三种存法、动画,五方给出同一个答案。)
⚠ 注意矩阵存的是条数(int),不是 bool —— 否则重边就没了。第 9 步细说。
6 动画:同一条边,在三种存法里各落在哪
边一条一条加进来,你能同时看到它落在矩阵的哪两格、前向星的哪两条有向边、
vector 的哪两个格子。下拉框里那三个错误版本,第 8、9 步会逐个讲。
现在看这一章的 ★:
| 存法 | 扫一遍整张图要看多少格 |
|---|---|
| 邻接矩阵 | n²(每个点都要扫一整行,哪怕它一个邻居都没有) |
| 两种表 | 2m(每条无向边被两端各看一次) |
默认那张图上是 25 格 vs 14 格。数字不大,但请把边删掉几条再看一遍: 右边会变小,左边纹丝不动。
这就是「稀疏图别用矩阵」的全部理由 —— 不是玄学,是这两个计数器。
7 ★ 先把账算出来:三个公式
点「运行 ▶」看结果
n 个点、m 条无向边:
| 存法 | 内存(字节) | 扫一遍 |
|---|---|---|
| 邻接矩阵 | 4(n+1)² | n² |
| 链式前向星 | 4(n+1) + 16m | 2m |
vector 版 | 24(n+1) + 8m | 2m |
- 矩阵存
int,一格 4 字节,(n+1)²格; - 前向星每条有向边要
to和nxt两个int(8 字节),无向图有2m条有向边 →16m; - ★
vector版有个很多人想不到的开销:每个vector<int>的壳是 24 字节(三个指针)。n = 100 万时,光壳就是 24 MB —— 一条边都还没存。
跑出来的表(./cost):
图的样子 n m 矩阵内存 前向星内存 vector内存 矩阵扫 表扫
-------- -------- ---------- ---------- ---------- ---------- --------- ---------
稀疏 1000 2000 3.8 MB 35.2 KB 39.1 KB 100 万 4000
稀疏 100000 200000 37.3 GB 3.4 MB 3.8 MB 100.0 亿 40 万
稀疏 1000000 2000000 3725.3 GB 34.3 MB 38.1 MB 10000.0 亿 400 万
稠密 1000 500000 3.8 MB 7.6 MB 3.8 MB 100 万 100 万
稠密 3000 4500000 34.4 MB 68.7 MB 34.4 MB 900 万 900 万
看第二行:n = 10 万的稀疏图,矩阵要 37 GB —— 这不是「慢」,是根本开不出来。
再看最后两行:稠密图上矩阵反而更省(表要为每条边多存 to 和 nxt 两个 int)。
8 实测:算出来的账对不对
光有公式不算数,量一遍。
这三份代码的第二问要用一个 set 去重,而那个 set 在稠密图上能占几十 MB ——
比存法本身还大。第一次量的时候我就栽在这儿:
量出来的「vector 版占 50 MB」,其实五分之四是那个 set。
所以三份代码都加了一个 deg 参数(./vec deg),只算度数、跳过第二问。
下面两张表都是这么量的。
量一个东西之前,先确认你量的就是它。 (第 22 章那条「动手优化前先测一遍是谁慢」的同款教训,这次栽在内存上。)
稀疏图 —— ./genBig 8000(n = 8000, m = 16000,固定种子),
/usr/bin/time -v 量峰值内存(本机进程底噪约 3.9 MB,要减掉):
| 存法 | 实测峰值 | 减去底噪 | 公式算出来的 | 耗时 |
|---|---|---|---|---|
| 邻接矩阵 | 248.3 MB | 244.4 MB | 244.2 MB | 0.24 秒 |
| 链式前向星 | 4.1 MB | 0.25 MB | 0.27 MB | 0.00 秒 |
vector 版 | 4.3 MB | 0.46 MB | 0.31 MB | 0.00 秒 |
算出来的和量出来的对得上(矩阵那一项 244.4 vs 244.2,误差不到千分之一)。
vector 那行比公式多一点,是因为它成倍扩容,容量常常大于实际长度。
把点数翻一倍(./genBig 16000):
| 存法 | 实测峰值 | 耗时 |
|---|---|---|
| 邻接矩阵 | 1004 MB | 1.20 秒 |
| 链式前向星 | 4.3 MB | 0.00 秒 |
vector 版 | 4.8 MB | 0.00 秒 |
★ 点数翻倍,矩阵内存翻四倍、耗时翻五倍;两种表纹丝不动。
稠密图 —— ./genBig 2000 1000000(n = 2000, m = 100 万):
| 存法 | 实测峰值 | 减去底噪 | 公式算出来的 | 耗时 |
|---|---|---|---|---|
| 邻接矩阵 | 19.1 MB | 15.2 MB | 15.3 MB | 0.47 秒 |
| 链式前向星 | 18.8 MB | 15.0 MB | 15.3 MB | 0.49 秒 |
vector 版 | 14.9 MB | 11.1 MB | 7.7 MB | 0.11 秒 |
局面完全反过来了:稀疏图上矩阵多花 50 倍内存,稠密图上它反而不吃亏。
0.49 秒 vs 0.11 秒 —— 而两者的内存差不多。
原因是缓存:前向星遍历时顺着 nxt 在数组里跳来跳去,
而 vector 的每个 g[u] 是连续的一段。边一多,跳跃就变成了大量缓存未命中。
「前向星常数最小」是个流传很广的说法,在稠密图上它不成立。 这正好又印证了这一章的主题:别背口诀,去量。 (当然前向星也有它的主场:不用动态分配,内存可以精确预估,不会有扩容抖动。)
9 ★ 存法本身会丢信息 —— 这不是打字错误
点「运行 ▶」看结果
跑出来 2 2 3 1 2(正确答案是 3 3 3 2 3)。
它每一行代码都完全按作者的意思在运行。没有写反的判断,没有差一,没有顺序错。
问题出在存法本身:bool 只能记住「有没有」,记不住「有几条」。
重边一进来就被合并了 —— 丢掉的那部分,之后再怎么写代码都找不回来。
选存法就是在选「你打算记住什么、打算忘掉什么」。
反过来说:如果题目问的是「u 和 v 之间有没有边」,
bool 矩阵不但没错,还是最省内存、最快的选择(一格 1 字节,查询 O(1))。
存法没有绝对的好坏,只有配不配得上你要问的问题。
10 另外四种错法
点「运行 ▶」看结果
跑出来 3 1 1 1 1 —— 度数直接少一半。 这是建图最常见的错误,而且它在有向图的题上是对的(第 27 章那棵树就是)。
11 ★ 对拍:一个「顺手」的写法,一次掩盖三个 bug
300 轮实测,五个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 无向图只存一遍 | 300 / 300 | 第 1 轮 |
| 前向星 head 初值写成 0 | 300 / 300 | 第 1 轮 |
| 没去重就数不同的边 | 251 / 300 | 第 1 轮 |
| 自环只算 1 度 | 206 / 300 | 第 1 轮 |
| 邻接矩阵只存 bool | 187 / 300 | 第 1 轮 |
gen.cpp 带了三个档位(./gen 种子 档位)。种子固定 1..300:
| 档位 | 改了什么 | 只存一遍 | head 初值 | 没去重 | 自环 1 度 | bool 矩阵 |
|---|---|---|---|---|---|---|
| 0(最初) | 简单图:不造自环,也不造重边 | 300 | 300 | 0 | 0 | 0 |
| 1 | 允许自环 | 300 | 300 | 226 | 226 | 0 |
| 2(在用) | 也允许重边 | 300 | 300 | 251 | 206 | 187 |
最初那一版一次性掩盖了三个 bug。 而它的写法一点都不奇怪 —— 「去掉自环、去掉重边」几乎是每个人写图生成器时的条件反射, 因为「图」在直觉里就该长那样。可题目常常是允许它们的。
★ 连着前两章,这已经是同一个毛病的第三张脸:
| 章 | 生成器里那个「顺手」的写法 | 于是哪个 bug 隐身了 |
|---|---|---|
| 27 | 顺手让 1 号点当根 | 「没找根,直接从 1 号 DFS」 0 / 300 |
| 28 | 顺手把距离矩阵镜像一下 | 「方向写反」 0 / 300 |
| 29 | 顺手去掉自环和重边 | 一口气三个 0 / 300 |
三次的根因是同一句话:
生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。
12 这一章可以带走的四样东西
【1】三个公式,比任何口诀都好用。
邻接矩阵 内存 4(n+1)² 扫一遍 n²
链式前向星 内存 4(n+1) + 16m 扫一遍 2m
vector 版 内存 24(n+1) + 8m 扫一遍 2m拿到题面看一眼 n 和 m,当场算 —— 不用背「稠密用矩阵、稀疏用表」。
(顺带记住 vector 那 24 字节的壳,和矩阵的硬上限:n 超过三千,矩阵就别想了。)
【2】选存法,就是在选「打算记住什么、打算忘掉什么」。
bool 矩阵忘掉重边 —— 如果题目问「有没有边」,它是最优解;
如果题目问「有几条边」,它从一开始就答不了。这和代码写得对不对无关。
【3】别背口诀,去量。
「前向星常数最小」在稠密图上就不成立(0.49 秒 vs vector 的 0.11 秒,因为缓存)。
而且量之前先确认你量的就是它 —— 我第一次把一个去重用的 set 也量进去了。
【4】生成器里的「顺手」写法,是这三章连续踩到的同一个坑。 顺手让 1 号当根、顺手镜像矩阵、顺手去掉自环和重边 —— 每一次都悄悄给数据加了一条题目里没有的性质,每一次都让真 bug 拿到 0 / 300。 写生成器前先问:题目到底允不允许?我是不是替它做了主?
第 30 章:图上的 DFS 与 BFS、连通性。
好消息:第 13、14 章那两份代码原封不动就能用。
唯一变的是「邻居是谁」—— 从「上下左右四个方向」换成「for (int v : g[u])」。
下一章要做的就是让你亲眼确认这件事,而不是把搜索重新学一遍。 (所以这一章的三种存法,下一章立刻就要用上了。)
13 自测
- 洛谷 B3643 图的存储 —— 本章的模板题:同时输出邻接矩阵和邻接表。写完对着本章三份代码检查一遍
- 洛谷 P5318 【深基18.例3】查找文献 —— ★ 建图 + DFS/BFS,而且要求邻居按编号从小到大访问 —— 正好逼你想清楚「前向星的邻居是倒序出来的」这件事
- 洛谷 P3916 图的遍历 —— ★ 反向建图的经典入门题。它会让你真正理解「有向图存一遍 vs 存两遍」的区别
- 洛谷 P1113 杂务 —— 建图 + 拓扑序 DP(第 31 章的预习)。这题的边是有向的,正好和本章的无向图对照着写
- 洛谷 P2853 [USACO06DEC] Cow Picnic —— 多次 DFS,n 和 m 都不大 —— 适合拿三种存法各写一遍,实测比一比
- 洛谷 P1330 封锁阳光大学 —— 进阶:二分图判定。它的数据里有重边和自环的坑,正好呼应本章第 11 步