1. 引言
图(Graph)是计算机科学中一种非常重要的非线性数据结构,它由顶点(Vertex)和连接顶点的边(Edge)组成。无论是社交网络中的好友关系、地图导航中的道路网络,还是计算机网络中的路由拓扑,都可以抽象为图来进行建模和分析。而如何高效地存储和表示图,直接决定了相关算法的性能与实现的复杂度。本文将围绕图的存储结构展开,重点深入剖析邻接矩阵(Adjacency Matrix)这一经典存储方式,从定义、数学表示、代码实现、复杂度分析到实际应用场景,为读者提供一份系统而实用的技术参考。
2. 图的基本概念与分类
在深入讨论存储结构之前,有必要先厘清图的基本概念。图 G 可以形式化地定义为一个二元组 G = (V, E),其中 V 是顶点的有限集合,E 是边的有限集合。每条边连接两个顶点,表示它们之间存在某种关系。
根据边的特征,图可以划分为以下几种常见类型:
- 无向图(Undirected Graph):边没有方向,顶点 u 和 v 之间的边 (u, v) 与 (v, u) 表示同一条边,例如朋友关系。
- 有向图(Directed Graph):边具有方向,顶点 u 指向 v 的边记为 <u, v>,与 <v, u> 是两条不同的边,例如网页之间的超链接关系。
- 带权图(Weighted Graph):边带有权值,权值可以表示距离、成本、时间等度量,例如城市之间的公路里程。
- 稀疏图与稠密图:根据边数相对于顶点数的比例,边数较少的图称为稀疏图,边数接近顶点数平方的图称为稠密图。这一分类对存储结构的选择至关重要。
此外,还有完全图(任意两个顶点之间都有边)、连通图(任意两个顶点之间都存在路径)等概念。理解这些分类,有助于我们针对不同场景选择最合适的存储方式。
3. 邻接矩阵的定义与数学表示
邻接矩阵是图的一种顺序存储结构,它使用一个二维数组来记录图中任意两个顶点之间是否存在边(以及边的权值)。
假设图 G 包含 n 个顶点,则邻接矩阵是一个 n × n 的方阵 A,其中元素 Aij 的定义如下:
- 对于无权图:Aij = 1 表示顶点 i 到顶点 j 之间存在边;Aij = 0 表示不存在边。
- 对于带权图:Aij = w 表示顶点 i 到顶点 j 之间的边权值为 w;当不存在边时,通常用无穷大(∞)或一个特殊值(如 0 或 -1)表示。
数学上,邻接矩阵可以简洁地表示为:
text
A[i][j] = 1(或权值 w),若存在从顶点 i 到顶点 j 的边;
A[i][j] = 0(或 ∞),若不存在从顶点 i 到顶点 j 的边。
对于无向图,由于边 (u, v) 与 (v, u) 等价,其邻接矩阵必然是一个对称矩阵,即 Aij = Aji。这一对称性既是无向图邻接矩阵的重要特征,也可以用于存储优化(例如只存储上三角或下三角部分)。
下面以一个包含 4 个顶点的无向图为例进行说明。假设顶点集合为 V = {0, 1, 2, 3},边集合为 E = {(0,1), (0,2), (1,2), (2,3)},则其邻接矩阵为:
text
0 1 2 3
0 0 1 1 0
1 1 0 1 0
2 1 1 0 1
3 0 0 1 0
可以看到,矩阵关于主对角线对称,且对角线元素通常为 0(表示顶点自身没有边)。
4. 邻接矩阵的具体实现方法
邻接矩阵的实现思路非常直观:使用一个二维数组存储顶点之间的关系,同时使用一个一维数组(或列表)存储顶点本身的信息。下面分别针对无向图、有向图和带权图给出具体的实现方式。
4.1 无向图的邻接矩阵实现
无向图的邻接矩阵是对称的,插入一条边时需要同时更新 Aij 和 Aji。以下是使用 C 语言的实现示例:
c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_VERTICES 100
typedef struct {
int n; // 顶点数
int matrix[MAX_VERTICES][MAX_VERTICES]; // 邻接矩阵
} UndirectedGraph;
// 初始化图
void initGraph(UndirectedGraph *g, int n) {
g->n = n;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
g->matrix[i][j] = 0;
}
}
}
// 添加边(无向图需对称更新)
void addEdge(UndirectedGraph *g, int u, int v) {
if (u >= 0 && u < g->n && v >= 0 && v < g->n) {
g->matrix[u][v] = 1;
g->matrix[v][u] = 1;
}
}
// 判断两个顶点之间是否存在边
bool hasEdge(UndirectedGraph *g, int u, int v) {
return g->matrix[u][v] == 1;
}
// 打印邻接矩阵
void printGraph(UndirectedGraph *g) {
for (int i = 0; i < g->n; i++) {
for (int j = 0; j < g->n; j++) {
printf("%d ", g->matrix[i][j]);
}
printf("\n");
}
}
int main() {
UndirectedGraph g;
initGraph(&g, 4);
addEdge(&g, 0, 1);
addEdge(&g, 0, 2);
addEdge(&g, 1, 2);
addEdge(&g, 2, 3);
printGraph(&g);
return 0;
}
4.2 有向图的邻接矩阵实现
有向图的邻接矩阵不再对称,插入边 <u, v> 时只需更新 Auv。以下是使用 C 语言的实现示例:
c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define MAX_VERTICES 100
typedef struct {
int n; // 顶点数
int matrix[MAX_VERTICES][MAX_VERTICES]; // 邻接矩阵
} DirectedGraph;
// 初始化图
void initGraph(DirectedGraph *g, int n) {
g->n = n;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
g->matrix[i][j] = 0;
}
}
}
// 添加有向边 u -> v
void addEdge(DirectedGraph *g, int u, int v) {
if (u >= 0 && u < g->n && v >= 0 && v < g->n) {
g->matrix[u][v] = 1;
}
}
// 判断是否存在 u -> v 的边
bool hasEdge(DirectedGraph *g, int u, int v) {
return g->matrix[u][v] == 1;
}
// 打印邻接矩阵
void printGraph(DirectedGraph *g) {
for (int i = 0; i < g->n; i++) {
for (int j = 0; j < g->n; j++) {
printf("%d ", g->matrix[i][j]);
}
printf("\n");
}
}
int main() {
DirectedGraph g;
initGraph(&g, 4);
addEdge(&g, 0, 1);
addEdge(&g, 0, 2);
addEdge(&g, 1, 2);
addEdge(&g, 2, 3);
printGraph(&g);
return 0;
}
4.3 带权图的邻接矩阵实现
带权图的邻接矩阵中,矩阵元素存储的是边的权值。当两个顶点之间不存在边时,通常使用一个足够大的数(如 INT_MAX)表示无穷大。以下是使用 C 语言的实现示例:
c
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#define MAX_VERTICES 100
typedef struct {
int n; // 顶点数
int matrix[MAX_VERTICES][MAX_VERTICES]; // 邻接矩阵
} WeightedGraph;
// 初始化图,对角线为 0,其余为无穷大
void initGraph(WeightedGraph *g, int n) {
g->n = n;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i == j) {
g->matrix[i][j] = 0;
} else {
g->matrix[i][j] = INT_MAX;
}
}
}
}
// 添加带权边(无向图示例,对称更新)
void addEdge(WeightedGraph *g, int u, int v, int weight) {
if (u >= 0 && u < g->n && v >= 0 && v < g->n) {
g->matrix[u][v] = weight;
g->matrix[v][u] = weight;
}
}
// 获取两个顶点之间的边权值
int getWeight(WeightedGraph *g, int u, int v) {
return g->matrix[u][v];
}
// 打印邻接矩阵
void printGraph(WeightedGraph *g) {
for (int i = 0; i < g->n; i++) {
for (int j = 0; j < g->n; j++) {
if (g->matrix[i][j] == INT_MAX) {
printf("INF ");
} else {
printf("%d ", g->matrix[i][j]);
}
}
printf("\n");
}
}
int main() {
// 示例:4 个顶点的带权无向图
WeightedGraph g;
initGraph(&g, 4);
addEdge(&g, 0, 1, 5);
addEdge(&g, 0, 2, 3);
addEdge(&g, 1, 2, 2);
addEdge(&g, 2, 3, 7);
printGraph(&g);
return 0;
}
在上述实现中,对角线元素设为 0,表示顶点到自身的距离为 0;不存在的边用无穷大表示,这样在后续执行最短路径等算法时可以直接使用矩阵进行运算。
5. 邻接矩阵的时间复杂度与空间复杂度分析
邻接矩阵的复杂度分析是理解其适用场景的关键。下面从时间复杂度和空间复杂度两个维度进行剖析。
5.1 时间复杂度
- 判断两个顶点之间是否存在边:直接访问 Aij,时间复杂度为 O(1)。这是邻接矩阵最大的优势。
- 添加或删除一条边:直接修改 Aij(无向图还需修改 Aji),时间复杂度为 O(1)。
- 遍历某个顶点的所有邻接顶点:需要扫描矩阵中对应的一整行,时间复杂度为 O(n)。
- 遍历整张图的所有边:需要扫描整个矩阵,时间复杂度为 O(n²)。
5.2 空间复杂度
邻接矩阵需要存储 n × n 个元素,因此空间复杂度为 O(n²)。无论图是稀疏还是稠密,这一空间开销都是固定的。对于顶点数 n 较大的稀疏图(例如 n = 10000 而边数只有 20000),邻接矩阵将浪费大量空间,此时邻接表等链式存储结构更具优势。
下表总结了邻接矩阵在不同操作上的复杂度表现:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 判断边是否存在 | O(1) | 直接访问矩阵元素 |
| 添加 / 删除边 | O(1) | 直接修改矩阵元素 |
| 遍历某顶点的邻接点 | O(n) | 扫描对应行 |
| 遍历所有边 | O(n²) | 扫描整个矩阵 |
| 空间复杂度 | O(n²) | 固定开销,与边数无关 |
6. 邻接矩阵的优缺点对比
任何存储结构都有其适用边界,邻接矩阵也不例外。下面从优点和缺点两个角度进行系统对比。
6.1 优点
- 实现简单直观:二维数组的语义与图的数学定义高度一致,代码实现和理解成本低。
- 判断边是否存在非常高效:O(1) 的时间复杂度使得频繁查询边关系的场景(如 Floyd 最短路径算法)表现优异。
- 便于矩阵运算:邻接矩阵天然支持线性代数运算,例如计算图的传递闭包、统计长度为 k 的路径数量等,可以直接借助矩阵乘法实现。
- 适合稠密图:当边数接近 n² 时,邻接矩阵的空间利用率高,且没有链表带来的指针开销。
6.2 缺点
- 空间浪费严重:对于稀疏图,大量矩阵元素为 0 或无穷大,存储利用率低。
- 扩展性差:顶点数 n 在初始化时即固定,动态增加顶点需要重建整个矩阵,成本较高。
- 遍历邻接点效率低:即使某个顶点的邻接点很少,也需要扫描整行,时间复杂度为 O(n)。
- 无向图存在冗余存储:对称矩阵导致一半空间被重复存储(可通过压缩存储优化,但会增加实现复杂度)。
7. 邻接矩阵的实际应用场景
尽管邻接矩阵存在空间开销大的缺点,但在许多实际场景中,它依然是首选甚至唯一可行的存储方案。
7.1 图论算法中的矩阵运算
Floyd-Warshall 算法(多源最短路径)是邻接矩阵最经典的应用之一。该算法需要频繁查询和更新任意两个顶点之间的最短距离,邻接矩阵 O(1) 的随机访问能力使其成为天然适配的数据结构。此外,计算图的传递闭包(Warshall 算法)、统计路径数量等操作也依赖矩阵乘法,邻接矩阵可以直接参与运算。
7.2 稠密图场景
当图的边数接近顶点数的平方时(如完全图、竞赛图),邻接矩阵的空间利用率接近 100%,此时它比邻接表更节省内存(邻接表需要额外的指针存储)。例如在社交网络分析中,小规模群体内的强连接关系图往往接近稠密图,使用邻接矩阵可以获得更好的性能。
7.3 小规模图与教学演示
对于顶点数较少(如 n ≤ 100)的图,邻接矩阵的空间开销完全可以接受,且实现简单、易于调试。在算法教学、竞赛入门和原型验证阶段,邻接矩阵是最常用的选择。
7.4 图神经网络中的邻接矩阵
在深度学习领域,图神经网络(GNN)通常以邻接矩阵作为图的标准化输入表示。虽然大规模图会使用稀疏矩阵存储,但邻接矩阵的数学形式(配合度矩阵、拉普拉斯矩阵)是图卷积等操作的理论基础。例如,图卷积网络(GCN)的层间传播公式就建立在邻接矩阵的归一化运算之上。
8. 邻接矩阵与邻接表的适用场景比较
邻接表(Adjacency List)是另一种主流的图存储结构,它使用一个数组存储所有顶点,每个顶点对应一个链表(或动态数组),链表中存储该顶点的所有邻接顶点。下面从多个维度对两种结构进行对比。
| 对比维度 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(n²),固定开销 | O(n + e),与边数成正比 |
| 判断边是否存在 | O(1),直接访问 | O(degree),需遍历链表 |
| 遍历某顶点的邻接点 | O(n),扫描整行 | O(degree),只遍历该顶点的链表 |
| 添加 / 删除边 | O(1) | O(degree)(需查找链表) |
| 适合图类型 | 稠密图 | 稀疏图 |
| 实现复杂度 | 简单 | 相对复杂 |
| 典型算法 | Floyd、传递闭包 | DFS、BFS、Dijkstra、Kruskal |
从对比中可以看出,两种结构各有千秋,选择的关键在于图的稀疏程度和核心操作类型:
- 如果图是稠密图 ,或者算法需要频繁判断任意两个顶点之间是否存在边(如 Floyd 算法),应优先选择邻接矩阵。
- 如果图是稀疏图 ,或者算法需要频繁遍历某个顶点的所有邻接点(如 DFS、BFS、Dijkstra),应优先选择邻接表。
- 如果图的规模较小(如 n ≤ 100),两种结构均可,但邻接矩阵实现更简单、调试更方便。
- 如果图需要动态增删顶点,邻接表更灵活;邻接矩阵在顶点数变化时需要重建整个矩阵,成本较高。
9. 总结
本文系统介绍了图的存储结构,并重点剖析了邻接矩阵这一经典方案。我们从图的基本概念与分类出发,明确了无向图、有向图、带权图以及稀疏图与稠密图的区别;随后给出了邻接矩阵的定义与数学表示,并通过具体示例展示了其对称性等特征。
在实现层面,我们分别使用 C++、Java 和 Python 演示了无向图、有向图与带权图的邻接矩阵实现方式。复杂度分析表明,邻接矩阵在判断边是否存在、添加或删除边等操作上具有 O(1) 的优异性能,但空间开销固定为 O(n²),更适合稠密图场景。
通过优缺点对比和实际应用场景分析可以看出,邻接矩阵在 Floyd 算法、传递闭包计算、图神经网络等场景中具有不可替代的优势;而邻接表则在稀疏图、频繁遍历邻接点等场景中表现更佳。实际开发中,应根据图的规模、稀疏程度以及核心操作类型,权衡选择最合适的存储结构。
希望本文能帮助读者深入理解邻接矩阵的原理与实现,并在实际项目中做出合理的技术选型。