6.1 图的基本概念用
本节比较三种表示:
- 邻接矩阵:为每一对顶点预留位置;
- 邻接表:只保存真实存在的邻接关系;
- 边集数组:把边逐条排列,不额外按顶点组织。
真正需要掌握的不是孤立地背复杂度,而是下面这条因果链:
图的规模与高频操作 → 存储方式 → 单次操作成本 → 整个算法的复杂度。
学习目标
完成本节后,你应该能够:
- 根据边集手写无向图与有向图的邻接矩阵、邻接表;
- 用 C++ 实现邻接矩阵与邻接表的增、删、查、遍历;
- 正确处理无向边、零权边、重边和自环的存储语义;
- 比较三种表示的空间、相邻查询、增删边与邻居遍历成本;
- 在邻接矩阵、邻接表和边集数组之间转换;
- 根据图的稠密度与操作模式选择存储;
- 解释为什么同一个 DFS/BFS 会因存储方式不同而具有不同复杂度。
本节使用的例图
继续使用 6.1 的简单无向图:
0 ───── 1 ───── 3
\ / |
\ / |
2 ────────── 4example-graph.txt它有
逻辑边不等于存储记录
无向边
邻接矩阵:把所有点对排成表
邻接矩阵为每一对顶点
例图的邻接矩阵为:
| 0 | 1 | 1 | 0 | 0 | |
| 1 | 0 | 1 | 1 | 0 | |
| 1 | 1 | 0 | 0 | 1 | |
| 0 | 1 | 0 | 0 | 1 | |
| 0 | 0 | 1 | 1 | 0 |
从矩阵可以直接读出三个结构:
A[1][3] = 1,所以顶点 与 相邻;- 第
行有三个1,所以 ; - 矩阵关于主对角线对称,因为无向边同时满足
A[u][v] = A[v][u] = 1。
有向图则不要求对称:第
带权矩阵怎样表示“没有边”
带权图不能简单地令 0 表示没有边,因为权重 std::optional:
#include <cstddef>
#include <optional>
#include <utility>
#include <vector>
using Vertex = std::size_t;
using Weight = long long;
class GraphMatrix {
public:
GraphMatrix(std::size_t n, bool directed)
: directed_(directed), matrix_(n, std::vector<std::optional<Weight>>(n)) {}
std::size_t size() const { return matrix_.size(); }
void addEdge(Vertex u, Vertex v, Weight weight = 1) {
matrix_.at(u).at(v) = weight;
if (!directed_) matrix_.at(v).at(u) = weight;
}
void removeEdge(Vertex u, Vertex v) {
matrix_.at(u).at(v).reset();
if (!directed_) matrix_.at(v).at(u).reset();
}
bool adjacent(Vertex u, Vertex v) const {
return matrix_.at(u).at(v).has_value();
}
std::optional<Weight> weight(Vertex u, Vertex v) const {
return matrix_.at(u).at(v);
}
std::vector<std::pair<Vertex, Weight>> neighbors(Vertex u) const {
const auto& row = matrix_.at(u);
std::vector<std::pair<Vertex, Weight>> result;
for (Vertex v = 0; v < row.size(); ++v)
if (row[v].has_value()) result.push_back({v, *row[v]});
return result;
}
private:
bool directed_;
std::vector<std::vector<std::optional<Weight>>> matrix_;
};adjacency-matrix.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
这份实现用 at 检查顶点下标;无向图添加和删除边时同步修改两个对称位置。若同一条边被再次添加,新权重会覆盖旧权重,因此它表达的是简单带权图,而不是保留全部平行边的多重图。
邻接矩阵与距离矩阵不是一回事
图的邻接矩阵中,对角线是否有值取决于是否真的存在自环;“没有边”应保持为空。Floyd 等最短路算法使用的距离矩阵通常把
邻接矩阵的成本
- 空间始终为
; - 查询、添加、删除指定边都是
; - 枚举一个顶点的邻居必须扫描整行,为
; - 枚举整张图的邻接关系需要扫描整个矩阵,为
。
无向矩阵只需保存上三角或下三角就能压缩接近一半空间,但换来的代价是更复杂的索引计算;渐近空间仍是
连续内存也是优势
矩阵不仅查询快,按行扫描还具有较好的缓存局部性。图较稠密或
邻接表:只保存真实存在的边
邻接表为每个顶点维护一个列表,只记录它实际连向哪些顶点。例图可以写成:
0: 1, 2
1: 0, 2, 3
2: 0, 1, 4
3: 1, 4
4: 2, 3adjacency-list.txt所有列表长度之和为
C++ 实现
下面使用动态数组保存每个顶点的邻接记录。代码假设输入没有重复边;若重复调用 addEdge(u,v),列表中也会出现重复记录。
#include <algorithm>
#include <cstddef>
#include <vector>
using Vertex = std::size_t;
using Weight = long long;
struct Edge {
Vertex to;
Weight weight;
};
class GraphList {
public:
GraphList(std::size_t n, bool directed)
: directed_(directed), adjacency_(n) {}
std::size_t size() const { return adjacency_.size(); }
void addEdge(Vertex u, Vertex v, Weight weight = 1) {
adjacency_.at(u).push_back({v, weight});
if (!directed_) adjacency_.at(v).push_back({u, weight});
}
bool adjacent(Vertex u, Vertex v) const {
const auto& edges = adjacency_.at(u);
return std::any_of(edges.begin(), edges.end(),
[v](const Edge& edge) { return edge.to == v; });
}
void removeEdge(Vertex u, Vertex v) {
eraseTo(u, v);
if (!directed_) eraseTo(v, u);
}
const std::vector<Edge>& neighbors(Vertex u) const {
return adjacency_.at(u);
}
private:
void eraseTo(Vertex u, Vertex v) {
auto& edges = adjacency_.at(u);
edges.erase(std::remove_if(edges.begin(), edges.end(),
[v](const Edge& edge) { return edge.to == v; }),
edges.end());
}
bool directed_;
std::vector<std::vector<Edge>> adjacency_;
};adjacency-list.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
邻接表的核心优势是:访问 neighbors(u) 时,拿到的全都是真实边,不必为不存在的点对付出扫描成本。
邻接表的成本
设
- 空间为
;无向图保存约 条记录,渐近量级不变; - 枚举
的邻居为 ; - 动态数组尾部添加边的摊还成本为
; - 查询或删除指定边最坏为
; - 扫描全图所有顶点和邻接记录为
。
无向图删除
WARN易错点 · 无向边只存一边
若添加无向边时只执行 adjacency[u].push_back(v),从
WARN易错点 · 邻居顺序不是图的性质
邻接表中边的插入顺序会影响 DFS/BFS 的访问顺序。若题目要求按顶点编号访问,应在建图后排序;排序全部邻接表的总成本可写为
简单上界为
查询很多,遍历也很多怎么办
动态数组邻接表擅长遍历,却不擅长反复判断指定边。可以把每个列表换成或配上哈希集合:
- 指定边查询的期望时间接近
; - 仍只保存真实边;
- 代价是更高的内存和常数、较差的缓存局部性,以及不稳定的遍历顺序。
工程中常采用“邻接数组作为单一事实来源 + 热点顶点附加哈希索引”的混合方案。此时必须明确谁负责更新,避免数组与索引内容不一致。
边集数组:按边逐条保存
还有一种极简表示:不按顶点组织邻居,只把每条边依次存下来。
#include <cstddef>
#include <vector>
struct EdgeRecord {
std::size_t from;
std::size_t to;
long long weight;
};
std::vector<EdgeRecord> edges = {
{0, 1, 2},
{0, 2, 5},
{1, 2, 1},
{1, 3, 4},
{2, 4, 3},
{3, 4, 2},
};edge-list.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
边集数组占
一张图可以有多个视图
邻接矩阵、邻接表和边集数组不是互斥的理论阵营。工程系统可以维护一种主存储,并按算法需要临时生成另一种视图;关键是把转换成本和同步责任算进去。
三种表示的复杂度对比
下表默认:邻接表使用动态数组,边集数组无额外索引。
| 操作 | 邻接矩阵 | 邻接表 | 边集数组 |
|---|---|---|---|
| 空间 | |||
判断 u → v | |||
枚举 u 的邻居 | |||
| 添加边 | 摊还 | 摊还 | |
| 删除指定边 | |||
| 扫描全部边/邻接关系 | |||
| 典型用途 | 稠密图、频繁点对查询 | 稀疏图、遍历与搜索 | 按边排序、全边松弛 |
复杂度表不是脱离实现的绝对真理。例如邻接表改用有序数组后可以二分查询,但插入与删除会搬移元素;改用哈希集合后查询期望
表示如何改变算法复杂度
以“访问每个顶点,并检查它的所有邻居”为例:
邻接矩阵
每访问一个顶点都要扫描长度为
邻接表
每个顶点只扫描自己的真实出边。把所有列表长度加起来:有向图为
O(·)复杂度 · DFS/BFS 必须注明图的表示
同样的 DFS/BFS 控制逻辑:
- 邻接矩阵实现为
; - 邻接表实现为
。
所以“DFS/BFS 是
这个差距在稀疏图上尤其明显。若
表示之间怎样转换
邻接表转邻接矩阵
- 分配
的空矩阵; - 枚举每个顶点
的每条邻接记录 ; - 设置
matrix[u][v] = w。
初始化矩阵已经需要
邻接矩阵转邻接表
必须检查矩阵的每个单元;遇到存在的边才加入列表,因此时间为
邻接表转边集数组
有向图直接输出每条邻接记录即可。对于不含自环的无向图,如果每条逻辑边恰好保存为 u < v 时输出,从而避免重复。若允许自环,u < v 会丢弃 edgeId,让同一条边的邻接记录共享该编号,转换时只输出尚未处理过的 edgeId。这样也能同时保留自环与平行边。扫描时间仍为
转换可能丢失语义
若矩阵每个单元只保存一个权重,多重图中的平行边在转成矩阵时会被覆盖。转换前必须决定保留最小权、最后一次权重、边数,还是完整权重列表。
怎样选择表示
不要只背“稠密矩阵、稀疏邻接表”。先回答三个问题:
- 规模与密度:
、 大约是多少? 是否接近 ? - 高频操作:主要是查询任意点对、枚举邻居,还是扫描全部边?
- 更新模式:图几乎只读,还是边频繁增删?是否要求稳定的邻居顺序?
| 场景 | 推荐起点 | 原因 |
|---|---|---|
| 边数接近 | 邻接矩阵 | 点对查询 |
| 大规模稀疏图,频繁 DFS/BFS | 邻接表 | 只存真实边,完整遍历 |
| 顶点很少,需要实现简单 | 邻接矩阵 | 代码直接,常数与维护成本低 |
| Kruskal、Bellman-Ford 等按边处理 | 边集数组 | 排序或全边扫描最自然 |
| 邻居要按编号稳定输出 | 排序后的邻接表 | 遍历顺序可复现 |
| 既常枚举邻居,又常查指定边 | 邻接表 + 哈希索引 | 用额外空间同时优化两类操作 |
| 静态超大图,强调紧凑与连续访问 | CSR 等压缩邻接结构 | 减少对象开销并改善缓存局部性 |
一个数量级判断
若
反过来,若
常见错误清单
WARN易错点 · 把零权边当成无边
带权图中 weight = 0 可能合法。应使用 optional、独立存在位或明确且不会与合法权值冲突的哨兵。
WARN易错点 · 混淆 $m$ 与邻接记录数
无向邻接表通常有
WARN易错点 · 用行和计算带权图的度
无权 0/1 矩阵的行和等于度;带权矩阵的行和是权重和,不是边数。计算度应统计“存在的单元个数”。允许自环时,还必须按图论定义处理自环对度的两次贡献。
WARN易错点 · 忘记输入是否允许重边
邻接表直接 push_back 会保留重复边;单值矩阵会覆盖旧边。两种实现对同一份含重边输入可能得到不同结果,必须先定义语义。
WARN易错点 · 只看密度,不看操作
稀疏图若要每秒进行大量任意点对查询,也可能需要哈希索引;稠密图若主要按边流式处理,也可能保留边集数组。表示选择最终服务于操作,而不是服务于标签。
小结
- 邻接矩阵用
空间换取指定边 查询。 - 邻接表用
空间保存真实边,适合邻居枚举与稀疏图遍历。 - 边集数组适合排序全部边或反复扫描全部边。
- 无向边在邻接表中通常保存两条记录,但逻辑边数仍为
。 - 算法复杂度依赖表示:邻接矩阵上的 DFS/BFS 是
,邻接表上是 。 - 选择表示时,应同时考虑规模、密度、高频操作、更新方式和顺序要求。
练习与自测
1. 从边集写出两种表示
对无向图
写出它的邻接矩阵与邻接表。
点击展开答案
邻接矩阵为:
| 0 | 1 | 0 | 1 | |
| 1 | 0 | 1 | 0 | |
| 0 | 1 | 0 | 0 | |
| 1 | 0 | 0 | 0 |
邻接表为 0: 1,3、1: 0,2、2: 1、3: 0。
2. 为什么矩阵不适合大规模稀疏图
有
点击展开答案
邻接矩阵需要
3. 解释遍历复杂度
为什么邻接表上的 BFS 是
点击展开答案
BFS 中每个顶点至多入队一次。邻接表只扫描该顶点真实存在的邻接记录,所有列表长度总和为
4. 选择合适的表示
分别为以下任务选择主要表示,并说明理由:
- 对
的稠密图运行 Floyd; - 对百万顶点道路网反复枚举邻居;
- 对全部边按权重排序后运行 Kruskal;
- 稀疏社交图中既要遍历好友,又要频繁判断两人是否直接认识。
点击展开答案
- 邻接矩阵:规模可控,Floyd 本身就使用距离矩阵;
- 邻接表或 CSR:道路网通常稀疏,只扫描真实道路;
- 边集数组:可直接按权重排序;
- 邻接表加哈希索引:邻接表负责遍历,哈希索引加速指定边查询。
5. 找出建图错误
下面代码想添加无向边,有什么问题?
void addEdge(int u, int v) {
graph[u].push_back(v);
}点击展开答案
它只保存了 u → v,没有保存 v → u。应再执行 graph[v].push_back(u)。否则从不同起点遍历时会得到不一致的可达关系,实际存成了有向图。
6. 思考多重图
若顶点
点击展开答案
没有唯一答案,取决于任务语义:最短路前处理可以只保存最小权
实践入口
完成概念学习后,可以先进入 Lab 06-T-02:图的存储结构选择题精练巩固表示与复杂度;再完成 Lab 06-E-02:连通三元组的最小度数,用邻接矩阵判断三个顶点是否两两相邻;最后完成 Lab 06-E-03:不邻接植花,用邻接表枚举真实邻居,并利用最大度数条件构造确定性的选花方案。
下一章继续学习 7.1 图的遍历:DFS 与 BFS,观察同一套遍历逻辑如何在不同图表示上产生不同复杂度。
参考资料
- 严蔚敏《数据结构》(C 语言版):图的存储结构;
- 王道《数据结构》考研复习指导:图的存储;
- 《算法导论》:第 22 章基本的图算法。