阶段 6 · 图论 · 第 29 章

图的存储:三种存法的对比与选型

邻接表你在第 27 章已经用过了。这一章要回答的是下一个问题 —— 三种存法各要多少内存、扫一遍要多久,到底该选哪个。全部用实测的数字说话,不背口诀。

例题:度数统计 · 三种存图方式 建议用时:100 分钟
这一章不教新东西,它教「怎么选」

邻接表你已经会了 —— 第 27 章那一小节就在用:

vector<vector<int>> son(n + 1);
son[k].push_back(l);

所以这一章不重新介绍它,而是回答下一个、也更实用的问题:

邻接矩阵、链式前向星、vector 版,三种存法各要多少内存、扫一遍要多久?该选哪个?

★ 这一章的关键一步不是某个技巧,而是一种态度:

选型是「算」出来的,不是背出来的。

「稠密用矩阵、稀疏用表」这句口诀你大概听过。这一章要做的是 把它背后那三个公式和两张实测表摆出来 —— 之后你不用记口诀, 拿到题面看一眼 nm,当场就能算。

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 —— 这组数后面每一步都会回来验。

brute.cpp标准答案:根本不建图,排序去重
输入(stdin)
输出
点「运行 ▶」看结果

★ 标准答案完全绕开了「图」这个数据结构(第 20 章那条规矩)。 这一章尤其需要这一点:三种存法彼此之间很容易「一起错」—— 比如三份都忘了把无向边存两遍。所以标准答案必须来自另一个方向。

3 存法一:vector 版 —— 最好写的那种

vec.cppvector<vector<int>>
输入(stdin)
输出
点「运行 ▶」看结果
vector<vector<int>> g(n + 1);
g[u].push_back(v);
g[v].push_back(u);        // ★ 无向图要存两遍
⚠ 和第 27 章那棵树的区别,现在必须说清楚

第 27 章那棵树只存了单向son[k].push_back(l))—— 因为那里的边自带方向(上司 → 下属),而且只需要从上往下走。

这一章是无向图:从 u 能走到 v,从 v 也能走到 u,所以必须存两遍

建图前先问一句:这张图是有向的还是无向的。 这是建图最常见的错误,没有之一 —— 第 8 步会看到它的样子。

4 存法二:链式前向星 —— 用数组手写的链表

star.cpp链式前向星
输入(stdin)
输出
点「运行 ▶」看结果
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 指针。 好处是一次性开好、零动态分配,而且内存可以精确算到字节

⚠ head 的初值:两种流派,但不能混着用
边编号从 0 开始 → head 初值 -1,循环条件 e != -1     ← 本章用这种
边编号从 1 开始 → head 初值  0,循环条件 e

写成「编号从 0 开始 + head 初值 0」就出事了: 「没有出边」和「有一条编号为 0 的边」分不出来。第 8 步有这个错误版本。

挑一种,然后一辈子都这么写。

5 存法三:邻接矩阵 —— 查询 O(1),代价是 n²

matrix.cpp邻接矩阵(存条数,不是 bool)
输入(stdin)
输出
点「运行 ▶」看结果

三份都跑出 3 3 3 2 3 / 5。(check:viz 拿 300 组随机图验过这件事: 排序去重的暴力、三种存法、动画,五方给出同一个答案。)

⚠ 注意矩阵存的是条数int),不是 bool —— 否则重边就没了。第 9 步细说。

6 动画:同一条边,在三种存法里各落在哪

同一条边,在三种存法里各落在哪
度数 3 3 3 2 3
第 1 / 9 步
邻接矩阵 g[u][v]
1
2
3
4
5
1
0
0
0
0
0
2
0
0
0
0
0
3
0
0
0
0
0
4
0
0
0
0
0
5
0
0
0
0
0
6 × 6 格,和边数无关
链式前向星
head
-1
-1
-1
-1
-1
边号
to
nxt
head 初值 −1;新边永远挂到链子最前面,所以邻居是倒序出来的
vector<vector<int>>
g[1]
(空)
度 0
g[2]
(空)
度 0
g[3]
(空)
度 0
g[4]
(空)
度 0
g[5]
(空)
度 0
每行长度就是这个点的度数 —— 这是三种存法里最好读的一种
5 个点、7 条无向边。三种存法从空开始,边一条一条加进来 —— 请盯住同一条边在三个结构里分别落在哪。

边一条一条加进来,你能同时看到它落在矩阵的哪两格、前向星的哪两条有向边、 vector 的哪两个格子。下拉框里那三个错误版本,第 8、9 步会逐个讲。

现在看这一章的 ★:

★ 扫一遍整张图:矩阵看 n² 格,表看 2m 格
矩阵 25 格 · 表 14 格
第 1 / 7 步
邻接矩阵:整行都要看
1
0
1
0
0
1
2
1
0
1
0
0
3
0
1
1
0
0
4
0
0
0
0
1
5
1
0
0
1
0
邻接表:只看真正的邻居
1
2
2
5
2
1
3
1
3
2
3
3
4
5
5
5
4
4
1
矩阵一共看了几格
0
表一共看了几格
0
差几倍
三种存法的内存(算出来的)
矩阵 144 B · 前向星 136 B · vector 200 B
左边每扫一个点,整行 5 格都要看一遍 —— 哪怕这个点一个邻居都没有。右边只看它真正的邻居。 两个计数器最后停在 n² = 252m = 14。 把边删掉几条再看一遍:右边会变小,左边纹丝不动 —— 矩阵的代价和边数毫无关系,这就是「稀疏图别用矩阵」的全部理由。
要做的事:数出每个点的度数。两边都得把每个点的邻居过一遍 —— 区别只在「过一遍」要看多少格。矩阵每个点扫一整行 5 格,表只扫它真正的邻居。
★ 一句话结论:矩阵的代价和边数毫无关系
存法扫一遍整张图要看多少格
邻接矩阵(每个点都要扫一整行,哪怕它一个邻居都没有)
两种表2m(每条无向边被两端各看一次)

默认那张图上是 25 格 vs 14 格。数字不大,但请把边删掉几条再看一遍: 右边会变小,左边纹丝不动。

这就是「稀疏图别用矩阵」的全部理由 —— 不是玄学,是这两个计数器。

7 ★ 先把账算出来:三个公式

cost.cpp三种存法的内存和扫描代价
输入(stdin)
输出
点「运行 ▶」看结果

n 个点、m 条无向边:

存法内存(字节)扫一遍
邻接矩阵4(n+1)²
链式前向星4(n+1) + 16m2m
vector24(n+1) + 8m2m
  • 矩阵存 int,一格 4 字节,(n+1)² 格;
  • 前向星每条有向边要 tonxt 两个 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 —— 这不是「慢」,是根本开不出来。 再看最后两行:稠密图上矩阵反而更省(表要为每条边多存 tonxt 两个 int)。

8 实测:算出来的账对不对

光有公式不算数,量一遍。

⚠ 量之前先确认「你量的就是它」

这三份代码的第二问要用一个 set 去重,而那个 set 在稠密图上能占几十 MB —— 比存法本身还大。第一次量的时候我就栽在这儿: 量出来的「vector 版占 50 MB」,其实五分之四是那个 set

所以三份代码都加了一个 deg 参数(./vec deg),只算度数、跳过第二问。 下面两张表都是这么量的。

量一个东西之前,先确认你量的就是它。 (第 22 章那条「动手优化前先测一遍是谁慢」的同款教训,这次栽在内存上。)

稀疏图 —— ./genBig 8000n = 8000, m = 16000,固定种子), /usr/bin/time -v 量峰值内存(本机进程底噪约 3.9 MB,要减掉):

存法实测峰值减去底噪公式算出来的耗时
邻接矩阵248.3 MB244.4 MB244.2 MB0.24 秒
链式前向星4.1 MB0.25 MB0.27 MB0.00 秒
vector4.3 MB0.46 MB0.31 MB0.00 秒

算出来的和量出来的对得上(矩阵那一项 244.4 vs 244.2,误差不到千分之一)。 vector 那行比公式多一点,是因为它成倍扩容,容量常常大于实际长度。

把点数翻一倍(./genBig 16000):

存法实测峰值耗时
邻接矩阵1004 MB1.20 秒
链式前向星4.3 MB0.00 秒
vector4.8 MB0.00 秒

点数翻倍,矩阵内存翻四倍、耗时翻五倍;两种表纹丝不动。

稠密图 —— ./genBig 2000 1000000n = 2000, m = 100 万):

存法实测峰值减去底噪公式算出来的耗时
邻接矩阵19.1 MB15.2 MB15.3 MB0.47 秒
链式前向星18.8 MB15.0 MB15.3 MB0.49 秒
vector14.9 MB11.1 MB7.7 MB0.11 秒

局面完全反过来了:稀疏图上矩阵多花 50 倍内存,稠密图上它反而不吃亏。

★ 一个意外的发现:稠密图上前向星比 vector 慢 4 倍

0.49 秒 vs 0.11 秒 —— 而两者的内存差不多。

原因是缓存:前向星遍历时顺着 nxt 在数组里跳来跳去, 而 vector 的每个 g[u]连续的一段。边一多,跳跃就变成了大量缓存未命中。

「前向星常数最小」是个流传很广的说法,在稠密图上它不成立。 这正好又印证了这一章的主题:别背口诀,去量。 (当然前向星也有它的主场:不用动态分配,内存可以精确预估,不会有扩容抖动。)

同题对比:邻接矩阵 vs 链式前向星
先跑 4000,再改成 8000、16000。⚠ 变的是点数 —— 矩阵是 n²,每翻一倍它就慢四倍、内存涨四倍。别超过 16000(再大内存就爆了)。
邻接矩阵
链式前向星

9 ★ 存法本身会丢信息 —— 这不是打字错误

wrongBool.cpp✗ 邻接矩阵只存「有没有」
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 2 2 3 1 2(正确答案是 3 3 3 2 3)。

★ 请注意它和前面所有错误版本的区别

每一行代码都完全按作者的意思在运行。没有写反的判断,没有差一,没有顺序错。

问题出在存法本身bool 只能记住「有没有」,记不住「有几条」。 重边一进来就被合并了 —— 丢掉的那部分,之后再怎么写代码都找不回来。

选存法就是在选「你打算记住什么、打算忘掉什么」。

反过来说:如果题目问的是「uv 之间有没有边」, bool 矩阵不但没错,还是最省内存、最快的选择(一格 1 字节,查询 O(1))。

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

10 另外四种错法

wrongOneWay.cpp✗ 无向图只存了一遍
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 3 1 1 1 1 —— 度数直接少一半。 这是建图最常见的错误,而且它在有向图的题上是对的(第 27 章那棵树就是)。

wrongSelf.cpp✗ 自环只算 1 度
wrongDedup.cpp✗ 数不同的边时忘了去重
wrongHead.cpp✗ 前向星的 head 初值写成 0

11 ★ 对拍:一个「顺手」的写法,一次掩盖三个 bug

对拍器
★ 这个生成器的灵魂是「造得出自环和重边」。绝大多数人写图生成器时会顺手把它们过滤掉 —— 而这一章有三个 bug 全躲在那两样东西后面。

300 轮实测,五个错误版本:

故意写错的地方被抓第几轮
无向图只存一遍300 / 300第 1 轮
前向星 head 初值写成 0300 / 300第 1 轮
没去重就数不同的边251 / 300第 1 轮
自环只算 1 度206 / 300第 1 轮
邻接矩阵只存 bool187 / 300第 1 轮
★ 生成器改了两次,而「最初」那一版一次漏掉三个 bug

gen.cpp 带了三个档位(./gen 种子 档位)。种子固定 1..300:

档位改了什么只存一遍head 初值没去重自环 1 度bool 矩阵
0(最初)简单图:不造自环,也不造重边300300000
1允许自环3003002262260
2(在用)也允许重边300300251206187

最初那一版一次性掩盖了三个 bug。 而它的写法一点都不奇怪 —— 「去掉自环、去掉重边」几乎是每个人写图生成器时的条件反射, 因为「图」在直觉里就该长那样。可题目常常是允许它们的

★ 连着前两章,这已经是同一个毛病的第三张脸

生成器里那个「顺手」的写法于是哪个 bug 隐身了
27顺手让 1 号点当根「没找根,直接从 1 号 DFS」 0 / 300
28顺手把距离矩阵镜像一下「方向写反」 0 / 300
29顺手去掉自环和重边一口气三个 0 / 300

三次的根因是同一句话:

生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。

gen.cpp(带三个档位的生成器)两次改动都能重跑
genSimple.cpp(永远只造简单图)演示用:反面教材

12 这一章可以带走的四样东西

★ 关键的一步

【1】三个公式,比任何口诀都好用。

邻接矩阵      内存 4(n+1)²        扫一遍 n²
链式前向星    内存 4(n+1) + 16m   扫一遍 2m
vector 版     内存 24(n+1) + 8m   扫一遍 2m

拿到题面看一眼 nm,当场算 —— 不用背「稠密用矩阵、稀疏用表」。 (顺带记住 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 自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)