1. 从生活场景理解图:为什么我们需要不同的存储方式
想象一下你正在规划一次城市旅行。城市里的各个景点就是“顶点”,连接景点的道路就是“边”。现在,你需要一张地图来记录这些信息。一种最直观的方法,就是画一个巨大的表格,列出所有景点,然后标记任意两个景点之间是否有路直达。比如,表格里“故宫”行和“天坛”列交叉的格子如果写“1”,就表示有路;写“0”就表示没路。这种“大表格”的思路,就是邻接矩阵。
听起来很完美,对吧?但问题来了。北京有成千上万个地点,绝大多数地点之间并没有直接的道路相连。你的那张大表格里,将会充斥着无数的“0”,真正有用的“1”却寥寥无几。为了记录那几条有用的路,你却不得不维护一个极其庞大的、几乎全是空白的表格,这无疑是巨大的空间浪费。这就好比为了记住通讯录里10个朋友的电话,你买了一本能记录10万人的空白电话本,然后把朋友写在最前面,后面全是空白页。
于是,更聪明的方法出现了:邻接表。它不再维护那个大表格,而是为每个景点单独准备一个小本子。比如“故宫”的小本子上,只记录和它直接相连的景点:“天安门”、“景山”、“北海”。其他不相干的景点一概不记。这样一来,你只需要准备和景点数量相当的小本子,每个本子只记录真正有用的信息,空间利用率瞬间飙升。
这两种方法,邻接矩阵和邻接表,就是图论世界里最核心的两种存储结构。它们没有绝对的好坏,只有适合与不适合。今天,我就结合自己这些年做算法优化和系统开发的经验,带你彻底搞懂它们,让你在面对任何图相关问题时,都能像老手一样,瞬间选出最趁手的那把“武器”。
2. 邻接矩阵:稠密图的“重型坦克”
2.1 核心思想与结构:一张无所不包的关系网
邻接矩阵的核心,就是用空间换取极致的查询速度。它假设你的图里“可能”存在任何边,所以提前为所有“顶点对”都预留好位置。
对于一个有 n 个顶点的图,邻接矩阵就是一个 n * n 的二维数组 matrix。matrix[i][j] 的值,就代表了顶点 i 到顶点 j 的关系。
- 对于无权图,通常用
1表示有边,0表示无边。 - 对于带权图(比如道路长度),这个位置就直接存储权重值(如
3.5),而用一个特殊值(如INF无穷大)来表示没有直接边。
这里有个关键点:无向图的邻接矩阵一定是对称的。因为如果 i 到 j 有路,j 到 i 也一定有路,所以 matrix[i][j] 必须等于 matrix[j][i]。而有向图则不一定,这反映了方向的单向性。
我举个带权有向图的例子,这样更贴近实际工程场景。假设我们有4个顶点(0,1,2,3),边和权重如下:0->1 (2.5),1->2 (1.0),2->3 (3.2)。那么它的邻接矩阵如下(用 INF 代表无穷大,即无边):
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 2.5 | INF | INF |
| 1 | INF | 0 | 1.0 | INF |
| 2 | INF | INF | 0 | 3.2 |
| 3 | INF | INF | INF | 0 |
你看,这个矩阵一目了然地展示了整个图的全貌。对角线上的0表示自己到自己的距离为0(或者在某些场景下表示无自环)。
2.2 性能剖析:优势与代价都极其明显
我们来量化一下邻接矩阵的性能,这是做技术选型的根本依据。
空间复杂度:O(n²)。 这是它最被人诟病的一点。假设我们用8字节的 double 存储权重,一个1万个顶点(n=10000)的图,矩阵就需要 8 * 10000 * 10000 ≈ 800MB 内存!即使对于无权图,用1字节的 bool 存储,也需要约100MB。所以,邻接矩阵只适用于顶点规模不大(通常n在几千以内)的图。
时间复杂度(关键操作):
- 查询边 (i, j) 是否存在:O(1)。直接访问
matrix[i][j],这是它最大的魅力。在需要频繁进行“两点是否连通”判断的场景下,这个优势是压倒性的。 - 遍历顶点 i 的所有邻居:O(n)。你必须扫描第
i行的所有n个元素,即使顶点i只有两三个邻居。在稀疏图中,这非常低效。 - 添加/删除一条边:O(1)。直接修改矩阵中的一个值即可。
- 添加一个顶点:灾难性的 O(n²)。因为你需要重新分配一个
(n+1)*(n+1)的新矩阵,并把旧数据拷贝过去。所以,邻接矩阵极度不适合动态增长的图。
优点总结:
- 闪电般的边查询:O(1) 的访问速度,无人能及。
- 适合线性代数运算:矩阵本身就是数学对象,可以直接进行矩阵乘法、求特征值等操作。例如,计算
A^k可以得到长度为k的路径数量。很多图谱分析(如谱聚类)都直接基于邻接矩阵。 - 实现简单直观:逻辑非常直白,不易出错。
缺点总结:
- 空间杀手:稀疏图下空间浪费严重。
- 邻居遍历慢:在稀疏图中需要做大量无效扫描。
- 动态性差:增删顶点成本极高。
2.3 工程实现与高级技巧:避开那些“坑”
在实际写代码时,实现方式直接影响性能。我踩过不少坑,这里分享几个关键点。
首先,绝对不要使用 vector<vector<T>> 来表示矩阵! 这会导致大量不连续的小内存块分配,缓存命中率极差,性能严重下降。正确的做法是使用单一大数组模拟二维索引。
class AdjacencyMatrix {
private:
size_t n;
std::vector<double> data; // 一维连续数组
static constexpr double INF = std::numeric_limits<double>::infinity();
public:
AdjacencyMatrix(size_t num_vertices) : n(num_vertices), data(n * n, INF) {
// 初始化对角线为0(自己到自己的距离)
for (size_t i = 0; i < n; ++i) {
at(i, i) = 0.0;
}
}
// 访问元素
double& at(size_t i, size_t j) { return data[i * n + j]; }
const double& at(size_t i, size_t j) const { return data[i * n + j]; }
// 判断边是否存在
bool hasEdge(size_t i, size_t j) const { return at(i, j) != INF; }
// 遍历顶点i的邻居(虽然低效,但有时需要)
std::vector<size_t> getNeighbors(size_t i) const {
std::vector<size_t> neighbors;
for (size_t j = 0; j < n; ++j) {
if (i != j && hasEdge(i, j)) {
neighbors.push_back(j);
}
}
return neighbors;
}
};
其次,针对特殊场景的优化:
- 无向无权图:利用对称性,只存储上三角或下三角矩阵,可以节省近一半空间,但访问时需要一点索引转换。
- 位矩阵 (Bit Matrix):如果图是无权图,只关心边是否存在,可以用
std::bitset或vector<uint64_t>来存储。每个边只占1个比特,能极大压缩空间。更妙的是,你可以用位运算(AND, OR, POPCOUNT)来极速计算公共邻居数量,这在社交网络分析或回环检测中非常有用。 - 与高性能库结合:如果你需要进行矩阵分解或复杂的线性代数运算,可以直接使用
Eigen::MatrixXd或Armadillo库的矩阵类型作为邻接矩阵,它们经过高度优化,并能无缝衔接后续的数学计算。
3. 邻接表:稀疏图的“灵动刺客”
3.1 核心思想与结构:按需分配,精准记录
邻接表的设计哲学是“绝不多浪费一分空间”。它只为每个顶点维护一个列表,这个列表里只存放该顶点的直接邻居。
最常见的实现是 vector<vector<int>>,外层 vector 的索引对应顶点编号,内层的 vector 存储该顶点的所有邻居顶点编号。对于带权图,内层可以存储 pair<邻居编号, 权重>。
还是用刚才那个4个顶点的例子,用邻接表(出边列表)表示如下:
顶点0 -> [1]
顶点1 -> [2]
顶点2 -> [3]
顶点3 -> []
如果是无向图,则需要在两个顶点的列表中都添加对方:
顶点0 -> [1]
顶点1 -> [0, 2]
顶点2 -> [1, 3]
顶点3 -> [2]
这种结构天然就反映了图的稀疏性。有多少边,才记录多少信息。
3.2 性能剖析:空间大师与遍历高手
空间复杂度:O(n + m)。其中 n 是顶点数,m 是边数。这才是稀疏图该有的样子!假设一个图有100万个顶点,500万条边,使用压缩的表示法(如CSR),可能只需要几十MB内存,这与邻接矩阵的TB级需求形成天壤之别。
时间复杂度(关键操作):
- 查询边 (i, j) 是否存在:O(deg(i))。需要遍历顶点
i的邻居列表,直到找到j或遍历完。deg(i)是顶点i的度(邻居数)。在稀疏图中,这通常很小,但最坏情况下也可能是 O(n)。如果你需要频繁的边查询,可以考虑用unordered_set代替vector存储邻居,获得平均 O(1) 的查询,但会牺牲一些遍历速度和内存。 - 遍历顶点 i 的所有邻居:O(deg(i))。这是邻接表的看家本领,效率与顶点的实际连接数成正比,没有一丝浪费。像BFS、DFS、Dijkstra这类需要频繁遍历邻居的算法,在邻接表上如鱼得水。
- 添加一条边:平均 O(1)。在对应顶点的列表末尾
push_back即可。 - 删除一条边:O(deg(i))。需要遍历列表找到它。如果不在乎邻居顺序,可以用“交换-弹出”技巧实现 O(1) 删除:把要删除的元素和列表末尾元素交换,然后
pop_back。 - 添加一个顶点:O(1)。直接在
vector末尾push_back一个空列表。
优点总结:
- 空间效率之王:稀疏图下的存储开销近乎最优。
- 遍历邻居极快:直接操作邻居列表,没有冗余。
- 动态性极佳:增删顶点和边都非常高效。
- 易于扩展:可以轻松地在边信息上附加权重、时间戳、置信度等任意属性,非常适合SLAM、推荐系统等复杂场景。
缺点总结:
- 边查询慢:无法像矩阵那样随机访问。
- 内存可能碎片化:如果使用
vector<vector<T>>,大量的小vector会导致内存碎片,影响性能。可以通过预分配 (reserve) 或使用更紧凑的格式(如CSR)来缓解。 - 不适合极端稠密图:当边数接近
n²时,每个顶点对应的列表都会很长,管理这么多小列表的开销可能会超过一个大矩阵。
3.3 工程实现变体:从简单到工业级
邻接表的实现方式非常灵活,这里我介绍几种不同场景下的选择。
1. 基础快速开发版 (vector<vector<int>>)
// 最简单,适用于原型或小规模图
int n = 100;
std::vector<std::vector<int>> adj(n);
void addUndirectedEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u);
}
// 遍历u的邻居
for (int neighbor : adj[u]) {
// 处理邻居
}
注意:务必在知道大概度数时使用 adj[u].reserve(expected_degree) 预分配,避免 push_back 多次扩容。
2. 带权图与属性版 在机器人SLAM或者图优化中,一条边可能附带很多信息(权重、信息矩阵、时间戳)。
struct Edge {
int target_vertex;
double weight;
Eigen::Matrix2d information_matrix; // 信息矩阵(用于优化)
uint64_t timestamp;
};
std::vector<std::vector<Edge>> adj;
这种结构将数据与拓扑紧密绑定,后端优化时非常方便。
3. 工业级高性能版:压缩稀疏行 (CSR) 对于大规模静态图或需要频繁进行并行计算、GPU加速的场景,CSR格式是黄金标准。它使用三个数组:
offsets: 长度为n+1,offsets[i]到offsets[i+1]-1是顶点i的邻居在destinations数组中的范围。destinations: 按顺序存储所有邻居顶点ID。weights(可选): 与destinations一一对应的权重。
// 构建CSR格式的邻接表
std::vector<int> offsets(n + 1, 0);
std::vector<int> destinations;
std::vector<double> weights;
// 第一步:统计每个顶点的出度
for (const auto& edge : all_edges) {
offsets[edge.u + 1]++; // 假设edge.u是起点
}
// 第二步:计算前缀和,得到offsets
for (size_t i = 1; i <= n; ++i) {
offsets[i] += offsets[i - 1];
}
// 第三步:填充destinations和weights
destinations.resize(offsets.back());
weights.resize(offsets.back());
std::vector<int> current_pos = offsets;
for (const auto& edge : all_edges) {
int pos = current_pos[edge.u]++;
destinations[pos] = edge.v;
weights[pos] = edge.w;
}
CSR的优点在于内存绝对连续,对缓存极其友好,也便于整体拷贝到GPU进行并行计算。缺点是构建好后难以动态增删。
4. 终极对决:如何根据场景做出正确选择
光知道原理不够,关键是要会用。下面这个表格是我多年经验总结出的决策指南:
| 特性维度 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(n²) | O(n + m) |
| 边查询速度 | O(1) (极快) | O(deg(i)) (可能较慢,可用哈希优化) |
| 遍历邻居速度 | O(n) (慢,尤其稀疏图) | O(deg(i)) (极快) |
| 增删顶点 | 极难 (O(n²)) | 容易 (O(1)) |
| 增删边 | 容易 (O(1)) | 较易 (O(deg(i))) |
| 适用图类型 | 稠密图 (边数 m ≈ n²) | 稀疏图 (边数 m << n²) |
| 算法适配 | Floyd-Warshall, 矩阵幂运算,谱方法 | BFS, DFS, Dijkstra, A*, 大多数图遍历和优化算法 |
| 工程场景 | 小规模稠密相似度计算、GPU并行矩阵运算、理论分析 | 社交网络、路径规划、SLAM因子图、网络拓扑 |
4.1 实战场景深度解析
场景一:社交网络分析(千万级用户) 这绝对是邻接表的主场。微信好友关系、微博关注关系都是典型的稀疏图——每个人平均只有几百个连接,相对于十亿级用户总量,稀疏得不能再稀疏。你需要频繁进行“查找共同好友”(遍历邻居)、“推荐可能认识的人”(BFS扩展),邻接表在空间和遍历效率上完胜。如果用邻接矩阵,内存早就爆了。
场景二:实时路径规划(如高德、谷歌地图) 城市道路网是一个稀疏图,但每个路口(顶点)的连接数很有限(通常2-4个)。Dijkstra或A*算法需要反复从当前路口访问其所有相邻路口。邻接表的O(deg(i))遍历效率至关重要。我做过测试,在百万级路网数据上,使用CSR格式的邻接表相比邻接矩阵,查询速度有数量级的提升。
场景三:SLAM(同步定位与地图构建)中的因子图 这是机器人领域的核心。因子图是一个二分图,包含机器人位姿顶点和观测因子顶点。这个图随着机器人移动动态增长,但每个新的位姿顶点只与之前少数几个位姿以及新观测因子相连,异常稀疏。邻接表(或专门设计的因子图库如gtsam内部的表示)是唯一选择,因为它需要高效地动态添加顶点和边,并遍历连接来构建优化问题。
场景四:小规模图像相似度计算 假设你有1000张图片,需要计算两两之间的相似度(例如用于聚类)。这是一个完全图(稠密图),因为理论上每两张图片都可以计算相似度。此时,一个1000*1000的邻接矩阵(存储相似度分数)非常合适。你可以用GPU并行计算填充这个矩阵,然后基于它进行谱聚类等操作,邻接矩阵的O(1)访问和矩阵运算优势得以充分发挥。
场景五:游戏中的网格地图
很多策略游戏使用网格地图,每个格子是一个顶点,与上下左右四个格子相连。这看似规则,但地图可能很大(1000x1000就是百万顶点)。虽然每个顶点度数固定为4(稀疏),但有时使用隐式的“邻接表”更简单——直接编写一个函数 getNeighbors(x, y),根据坐标返回四个相邻坐标,而无需在内存中显式存储任何边列表。这本质上是邻接表的思想,但实现更轻量。
5. 进阶话题与性能调优经验谈
选对了结构只是第一步,真正让程序飞起来还需要一些“黑魔法”。
对于邻接矩阵:
- 缓存友好访问:坚持使用一维数组。遍历时,尽量按行顺序访问,这是现代CPU缓存预取机制最喜欢的方式。
- 位矩阵的妙用:在需要快速进行大量集合交并操作时(例如:“这两个用户的共同好友数”),用
vector<uint64_t>表示行,使用_mm_popcnt_u64等指令进行位计数,速度可以提升百倍。
对于邻接表:
- 预分配是美德:如果你能预估每个顶点的大致度数,一定要用
reserve()。这避免了动态扩容带来的内存重分配和数据拷贝,对性能影响巨大。 - 小心
vector<bool>:C++标准库的vector<bool>是特化版本,一个元素占1比特,但访问和迭代有诸多限制和性能陷阱。如果需要位图,直接用std::bitset(大小编译期固定)或boost::dynamic_bitset。 - 动态图的删除优化:如果不需要保持邻居顺序,实现“交换-弹出”删除法,可以保证删除操作是O(1)。
- 并发访问:在多线程环境下,如果读多写少,可以为每个顶点的邻居列表配一把细粒度锁(
std::shared_mutex),而不是锁住整个图。如果写入频繁,则需要更复杂的无锁数据结构或事务机制。
我记得有一次优化一个图分析服务,最初使用 vector<vector<int>>,在数据量增大后性能急剧下降。通过剖析发现,大量时间花在了内存分配和缓存缺失上。后来将其重构为CSR格式,不仅内存占用减少了40%,而且关键路径的查询延迟降低了70%。这个案例深刻地告诉我,数据结构的底层内存布局,在极端性能要求下,往往比算法本身的大O复杂度更重要。
最后,记住没有银弹。邻接矩阵和邻接表是工具,你的任务是根据图的特点(稠密/稀疏)、操作的类型(查询多还是遍历多)、以及系统的约束(内存、实时性)来挑选和优化工具。刚开始可能会选错,这很正常。多思考、多测试、多 profiling,你自然会培养出那种一眼看穿问题本质、直击最佳解决方案的直觉。
421



被折叠的 条评论
为什么被折叠?



