邻接矩阵 vs 邻接表:数据结构课设实战指南(附完整C++代码)
在计算机科学的学习中,图论是一个既迷人又充满挑战的领域。作为数据结构课程设计的重要组成部分,图的存储与遍历算法往往让学生们既兴奋又困惑。本文将带你深入理解两种最基础的图存储结构——邻接矩阵和邻接表,并通过完整的C++实现,帮助你彻底掌握这一关键知识点。
1. 图存储结构的选择困境
当我们第一次接触图的存储时,常常会面临一个基本问题:为什么需要两种不同的存储方式?答案在于它们各自独特的优势和适用场景。
邻接矩阵使用一个二维数组来表示图中顶点之间的连接关系。对于包含n个顶点的图,我们需要一个n×n的矩阵。矩阵中的每个元素表示对应顶点之间是否存在边(对于无权图)或边的权重(对于带权图)。
#define MAXV 100 // 最大顶点数
typedef struct {
int edges[MAXV][MAXV]; // 邻接矩阵
int n, e; // 顶点数和边数
} MatGraph;
邻接表则采用了一种更节省空间的方式。它为每个顶点维护一个链表,链表中存储与该顶点直接相连的所有邻接顶点。
typedef struct ANode {
int adjvex; // 邻接点编号
struct ANode* nextarc; // 指向下一个边结点的指针
int weight; // 边权重
} ArcNode;
typedef struct VNode {
ArcNode* firstarc; // 指向第一条边的指针
} VNode;
typedef struct {
VNode adjlist[MAXV]; // 邻接表数组
int n, e; // 顶点数和边数
} AdjGraph;
存储效率对比表:
| 特性 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(n²) | O(n+e) |
| 查询边存在 | O(1) | O(degree(v)) |
| 遍历所有邻接点 | O(n) | O(degree(v)) |
| 添加边 | O(1) | O(1)或O(log n) |
| 适合图类型 | 稠密图 | 稀疏图 |
提示:在实际应用中,当图的边数接近顶点数的平方时(e≈n²),邻接矩阵更为高效;而对于稀疏图(e≪n²),邻接表通常是更好的选择。
2. 邻接矩阵的完整实现
让我们从邻接矩阵开始,逐步构建一个完整的图存储实现。邻接矩阵特别适合表示稠密图,因为它的结构简单直观,且能快速判断任意两个顶点之间是否存在边。
创建邻接矩阵的核心函数:
void CreateMat(MatGraph &g, int A[MAXV][MAXV], int n, int e) {
g.n = n; g.e = e;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
g.edges[i][j] = A[i][j]; // 直接复制输入矩阵
}
}
}
邻接矩阵的输出函数:
void DispMat(const MatGraph &g) {
for (int i = 0; i < g.n; i++) {
for (int j = 0; j < g.n; j++) {
if (g.edges[i][j] != INF) {
printf("%4d", g.edges[i][j]);
} else {
printf("%4s", "∞");
}
}
printf("\n");
}
}
在实际应用中,我们需要注意几个关键点:
- 内存管理:邻接矩阵的大小在编译时就需要确定(通过MAXV),这可能导致内存浪费或不足
- 初始化顺序:确保先设置顶点数和边数,再填充矩阵
- 无穷大表示:使用INF(如32767)表示不存在的边,但要确保它不会与实际权重冲突
3. 邻接表的深度解析
邻接表的实现相对复杂,但它在处理稀疏图时表现出色。让我们深入探讨其实现细节。
创建邻接表的核心函数:
void CreateAdj(AdjGraph* &G, int A[MAXV][MAXV], int n, int e) {
G = (AdjGraph*)malloc(sizeof(AdjGraph));
if (!G) { /* 错误处理 */ }
G->n = n; G->e = e;
for (int i = 0; i < n; i++) {
G->adjlist[i].firstarc = NULL;
}
for (int i = 0; i < n; i++) {
for (int j = n-1; j >= 0; j--) { // 逆序插入,保持原始顺序
if (A[i][j] != 0 && A[i][j] != INF) {
ArcNode* p = (ArcNode*)malloc(sizeof(ArcNode));
if (!p) { /* 错误处理 */ }
p->adjvex = j;
p->weight = A[i][j];
p->nextarc = G->adjlist[i].firstarc;
G->adjlist[i].firstarc = p;
}
}
}
}
邻接表的输出函数:
void DispAdj(const AdjGraph* G) {
for (int i = 0; i < G->n; i++) {
printf("%3d: ", i);
ArcNode* p = G->adjlist[i].firstarc;
while (p) {
printf("%3d[%d]->", p->adjvex, p->weight);
p = p->nextarc;
}
printf("∧\n");
}
}
内存释放函数:
void DestroyAdj(AdjGraph* &G) {
for (int i = 0; i < G->n; i++) {
ArcNode* p = G->adjlist[i].firstarc;
while (p) {
ArcNode* tmp = p;
p = p->nextarc;
free(tmp);
}
}
free(G);
G = NULL; // 避免悬空指针
}
注意:邻接表的内存管理尤为重要,特别是对于动态分配的结构。确保每个malloc都有对应的free,防止内存泄漏。
4. 图的遍历算法实现
掌握了存储结构后,我们来看看如何在它们基础上实现图的遍历算法。我们将重点讨论深度优先搜索(DFS)和广度优先搜索(BFS)。
4.1 递归DFS实现
int visited[MAXV] = {0}; // 访问标记数组
void DFS(AdjGraph* G, int v) {
printf("%3d", v);
visited[v] = 1;
ArcNode* p = G->adjlist[v].firstarc;
while (p) {
if (!visited[p->adjvex]) {
DFS(G, p->adjvex);
}
p = p->nextarc;
}
}
4.2 非递归DFS实现
void DFS1(AdjGraph* G, int v) {
int St[MAXV], top = -1;
memset(visited, 0, sizeof(visited));
printf("%3d", v);
visited[v] = 1;
St[++top] = v;
while (top != -1) {
int x = St[top];
ArcNode* p = G->adjlist[x].firstarc;
while (p) {
if (!visited[p->adjvex]) {
printf("%3d", p->adjvex);
visited[p->adjvex] = 1;
St[++top] = p->adjvex;
break;
}
p = p->nextarc;
}
if (!p) top--;
}
}
4.3 BFS实现
void BFS(AdjGraph* G, int v) {
int queue[MAXV], front = 0, rear = 0;
memset(visited, 0, sizeof(visited));
printf("%3d", v);
visited[v] = 1;
rear = (rear + 1) % MAXV;
queue[rear] = v;
while (front != rear) {
front = (front + 1) % MAXV;
int w = queue[front];
ArcNode* p = G->adjlist[w].firstarc;
while (p) {
if (!visited[p->adjvex]) {
printf("%3d", p->adjvex);
visited[p->adjvex] = 1;
rear = (rear + 1) % MAXV;
queue[rear] = p->adjvex;
}
p = p->nextarc;
}
}
printf("\n");
}
遍历算法对比分析:
- 递归DFS:实现简单直观,但可能面临栈溢出风险,特别是对于深度很大的图
- 非递归DFS:使用显式栈避免了递归的潜在问题,适合大规模图
- BFS:使用队列实现,适合寻找最短路径(无权图)或层次遍历
5. 实战应用与调试技巧
在实际课程设计中,除了正确实现算法外,调试和优化也是关键环节。以下是一些实用技巧:
常见问题排查清单:
- 内存泄漏:确保每个malloc都有对应的free
- 指针错误:特别注意二级指针的使用场景
- 数组越界:检查所有数组访问是否在合法范围内
- 初始化问题:确保所有数据结构在使用前正确初始化
- 边界条件:测试空图、单顶点图等特殊情况
性能优化建议:
- 对于静态图(结构不变),考虑将邻接表转换为更紧凑的存储形式
- 使用位矩阵(bit matrix)表示无权图的邻接矩阵,节省空间
- 对于频繁的边查询操作,邻接矩阵可能更高效
- 考虑缓存友好性,优化遍历顺序
代码组织建议:
# 推荐的项目结构
graph_algorithm/
├── include/
│ ├── graph.h # 结构定义和函数声明
│ └── traversal.h
├── src/
│ ├── graph.cpp # 邻接矩阵/表实现
│ └── traversal.cpp # 遍历算法实现
└── main.cpp # 测试代码
提示:在实际项目中,避免直接包含.cpp文件,而是采用标准的头文件包含方式,这有助于模块化和代码重用。
6. 扩展思考与实际案例
理解了基础实现后,我们可以进一步探讨一些高级主题和应用场景。
动态图处理: 当图结构需要频繁变动时,邻接表的优势更加明显。添加或删除边在邻接表中通常是O(1)操作(不考虑排序),而在邻接矩阵中则是O(1)。
加权图与网络流: 对于带权图,两种存储结构都能很好地表示边权重。在网络流算法中,邻接表通常更受欢迎,因为它能高效地遍历邻接边。
并行计算考量: 邻接矩阵由于其规整的结构,更适合并行化处理。许多图算法(如矩阵乘法式的图算法)可以利用这一特性。
实际应用场景:
- 社交网络分析(邻接表更优)
- 路径规划与导航(根据具体情况选择)
- 编译器依赖关系分析
- 网络拓扑结构表示
在完成课程设计时,不妨尝试将这些高级主题纳入考虑,这将大大提升你的项目质量和技术深度。
&spm=1001.2101.3001.5002&articleId=159871111&d=1&t=3&u=15a7bbc2ec3946b18e93024fa489fbc6)
2827

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



