1. 从一道20分实验题说起:邻接矩阵建无向图到底在考什么
“6-1 采用邻接矩阵表示法创建无向图”,这个标题一看就是数据结构课程里图论章节的经典实验题。20分的分值说明它不是一个随便写两行就能过的水题,而是考察你对图的存储结构、矩阵操作、边界条件处理是否真正理解。很多同学拿到这道题的第一反应是“不就是开个二维数组填数吗”,结果一提交就发现各种问题:顶点数边数读入顺序搞反、无向图对称赋值漏了一半、顶点信息存储方式不对、输出格式差一个空格……这些坑我在带实验课和帮人看代码的过程中见得太多了。
这道题的核心任务很明确:给定一个无向图的顶点集合和边集合,用邻接矩阵把图存下来,并且能够正确输出这个矩阵。它解决的是“如何用计算机能理解的方式表示图结构”这个基础问题。适合正在学数据结构、准备实验课验收、或者想重新夯实图论基础的同学参考。不管你是刚接触图论的小白,还是已经写过邻接表想对比两种存储方式的老手,这篇内容都会把邻接矩阵建无向图的每个细节拆开讲透。
我见过太多人把这道题当成“填空题”来做,照着课本伪代码抄一遍就交,结果连为什么矩阵是对称的、顶点下标和矩阵下标怎么对应都说不清楚。这篇文章不会只给你一份能过的代码,而是把设计思路、参数计算、实操步骤、常见报错全部摊开,让你真正理解邻接矩阵这套东西是怎么运转的。下面从整体设计开始,一步步往细节里钻。
2. 邻接矩阵表示法的整体设计与选型逻辑
2.1 为什么无向图适合用邻接矩阵来存
图的存储方式主要有两种:邻接矩阵和邻接表。选哪种不是拍脑袋决定的,要看图的稠密程度和你要做的操作。邻接矩阵用一个二维数组matrix[i][j]来表示顶点 i 和顶点 j 之间是否有边。对于无向图来说,如果 i 和 j 之间有边,那么matrix[i][j]和matrix[j][i]都要置为 1,这就是所谓的对称性。
为什么无向图特别适合邻接矩阵?因为无向图的边没有方向,一条边连接两个顶点,在矩阵里天然就是关于主对角线对称的。你只需要处理一半的边,另一半对称填过去就行。这种对称性带来一个好处:判断任意两个顶点是否相邻,直接查matrix[i][j]就行,时间复杂度 O(1),不需要像邻接表那样遍历链表。
但邻接矩阵也有代价。如果图很稀疏,比如 1000 个顶点只有 5 条边,那矩阵里 99.9% 的位置都是 0,空间浪费严重。所以选型逻辑很简单:稠密图或者顶点数不多(比如这道题常见的 n ≤ 100)用邻接矩阵;稀疏图且顶点多,用邻接表。这道实验题明确要求用邻接矩阵,那我们就按矩阵的思路来设计。
2.2 顶点信息和边信息的存储结构设计
建图之前要先想清楚数据怎么组织。通常需要这几个部分:
- 顶点表:存每个顶点的名字或编号。可以用一个一维数组
vexs[n],每个元素是一个字符或者字符串。比如顶点是 A、B、C、D,就存成['A','B','C','D']。 - 邻接矩阵:二维数组
arcs[n][n],元素类型可以是 0/1 表示有无边,也可以是权值(带权图)。这道题是无向无权图,用 0 和 1 就够了。 - 顶点数和边数:两个整数
vexnum和arcnum,用来控制循环边界。
这里有个容易踩的坑:顶点下标和矩阵下标必须一一对应。如果你把顶点 A 存在vexs[0],那矩阵里 A 相关的边就必须在arcs[0][...]和arcs[...][0]。很多同学读入边的时候直接拿顶点字符去查下标,结果忘了写查找函数,或者查找函数写错导致下标越界。我建议单独写一个locateVex函数,输入顶点字符,返回它在vexs数组里的下标,这样逻辑清晰也不容易错。
2.3 无向图对称赋值的必要性分析
无向图的一条边 (u, v) 等价于 (v, u)。在邻接矩阵里,这意味着arcs[u][v] = 1和arcs[v][u] = 1必须同时成立。如果你只赋了一个方向,那这个矩阵表示的就是有向图,输出的时候就会露馅——矩阵不对称,验收老师一眼就能看出来。
为什么很多人会漏掉对称赋值?因为读入边的时候习惯性只写了一句arcs[u][v] = 1,脑子里想的是“我存了这条边”,但忘了无向图是双向的。正确的做法是读入一条边后,同时设置两个位置。这个操作本身不复杂,但它是无向图邻接矩阵最核心的特征,也是这道题区分“真懂”和“抄代码”的关键点。
另外要注意,如果图允许有自环(自己连自己),那arcs[i][i]也要处理。不过一般实验题里的无向图默认没有自环,顶点自己到自己不算边,所以主对角线保持 0 就行。如果题目有特殊说明,再按说明处理。
3. 核心细节拆解与实操要点
3.1 顶点和边的读入顺序与边界处理
读入顺序通常是:先读顶点数 n 和边数 e,然后读 n 个顶点信息,最后读 e 条边,每条边给两个顶点。这个顺序不能乱,因为建矩阵需要先知道顶点下标。
边界处理是很多人丢分的地方。比如:
- n 和 e 的范围是多少?如果 n 是 0 或者 e 是 0,程序能不能正常跑?空图也是图,矩阵全 0 就行,不能崩。
- 读入的顶点字符有没有重复?一般实验题保证不重复,但你自己写测试用例的时候要注意。
- 边的两个顶点如果相同怎么办?无向图里这算自环,看题目要求,通常忽略或者报错。
- 如果输入的边引用了不存在的顶点怎么办?
locateVex要能返回 -1,然后主逻辑里判断一下,避免数组越界。
我建议在locateVex里加一个找不到就返回 -1 的逻辑,然后在建边的时候检查返回值。虽然实验题数据一般规范,但养成这个习惯能帮你避开很多运行时错误。
3.2 矩阵初始化与内存布局选择
矩阵初始化就是把所有元素置 0。这一步看起来简单,但有两种写法:
- 静态数组:
int arcs[MAX][MAX];然后双重循环置 0。优点是简单,缺点是 MAX 固定,n 大了会爆栈。 - 动态分配:用
malloc或者new根据 n 来分配。优点是灵活,缺点是要记得释放,而且二级指针操作容易写错。
对于这道 20 分的实验题,n 通常不会太大(比如不超过 100),静态数组完全够用。但如果你想把代码写得更通用,动态分配是更好的选择。我个人的习惯是:实验题用静态数组快速过,项目里用动态分配或者vector<vector<int>>(C++)来避免固定大小限制。
内存布局上,邻接矩阵是按行优先存储的,arcs[i][j]访问第 i 行第 j 列。遍历的时候按行遍历对缓存更友好,输出矩阵的时候也是按行输出,所以这个顺序很自然。
3.3 顶点定位函数的实现细节
locateVex函数的作用是:给一个顶点字符,返回它在顶点数组里的下标。实现就是一个线性查找:
int locateVex(char vex) { for (int i = 0; i < vexnum; i++) { if (vexs[i] == vex) return i; } return -1; }这个函数虽然简单,但它是连接“顶点字符”和“矩阵下标”的桥梁。没有它,你就得在每次读边的时候手动去比对,代码又臭又长还容易错。把它单独抽出来,主逻辑就变成:
int u = locateVex(v1); int v = locateVex(v2); if (u != -1 && v != -1) { arcs[u][v] = 1; arcs[v][u] = 1; }清晰明了。注意这里u和v是下标,不是顶点字符本身。很多初学者会把这两个概念搞混,导致arcs['A']['B']这种写法,虽然 C 语言里字符可以当整数用,但语义上是错的,而且一旦顶点不是单个字符就会出问题。
3.4 输出格式的常见要求与陷阱
输出邻接矩阵通常就是按行打印,每行 n 个数字,数字之间用空格隔开。但这里有几个坑:
- 行末有没有多余空格?有些在线评测系统对行末空格敏感,多一个空格就判格式错误。稳妥的做法是每行最后一个数字后面不打印空格。
- 数字之间用几个空格?一般是一个,但有些题目要求用制表符或者固定宽度。看清楚题目要求。
- 矩阵外面要不要加边框或者行列标号?实验题一般不要,直接输出裸矩阵。如果题目有特殊格式要求,按题目来。
我见过一个同学因为行末多打了一个空格,提交了七次都是格式错误,最后把printf("%d ", arcs[i][j])改成判断是不是最后一个元素才过。这种细节虽然烦人,但实验题就是考你细不细。
4. 完整实操流程与核心代码实现
4.1 从零开始建图的步骤拆解
下面按顺序把建图流程走一遍。假设用 C 语言,顶点用字符表示,矩阵用静态二维数组。
第一步:定义数据结构和全局变量。
#include <stdio.h> #define MAXV 100 typedef struct { char vexs[MAXV]; // 顶点表 int arcs[MAXV][MAXV]; // 邻接矩阵 int vexnum, arcnum; // 顶点数和边数 } MGraph;这里用结构体把相关数据打包在一起,比散落的全局变量清晰得多。MAXV根据题目数据范围设定,一般 100 够用。
第二步:初始化矩阵。
void initMatrix(MGraph *G) { for (int i = 0; i < G->vexnum; i++) { for (int j = 0; j < G->vexnum; j++) { G->arcs[i][j] = 0; } } }注意循环边界是vexnum,不是MAXV。只初始化用到的部分,效率更高。
第三步:读入顶点信息。
for (int i = 0; i < G->vexnum; i++) { scanf(" %c", &G->vexs[i]); // 前面的空格跳过空白字符 }scanf(" %c")里的空格很重要,它能跳过换行和空格,避免读到空白字符。这个细节在连续读入多个字符时特别关键。
第四步:读入边并填充矩阵。
for (int k = 0; k < G->arcnum; k++) { char v1, v2; scanf(" %c %c", &v1, &v2); int u = locateVex(G, v1); int v = locateVex(G, v2); if (u != -1 && v != -1) { G->arcs[u][v] = 1; G->arcs[v][u] = 1; // 无向图对称赋值 } }这是整个建图的核心。每读一条边,找到两个顶点的下标,然后在矩阵里对称地置 1。
第五步:输出矩阵。
for (int i = 0; i < G->vexnum; i++) { for (int j = 0; j < G->vexnum; j++) { if (j > 0) printf(" "); printf("%d", G->arcs[i][j]); } printf("\n"); }用if (j > 0)控制空格,保证行末没有多余空格。
4.2 参数计算与下标映射的验证方法
建完图之后怎么验证对不对?最直接的方法是手动推一遍。假设输入:
4 4 A B C D A B A C B D C D顶点是 A、B、C、D,下标分别是 0、1、2、3。边是 AB、AC、BD、CD。那么矩阵应该是:
| A | B | C | D | |
|---|---|---|---|---|
| A | 0 | 1 | 1 | 0 |
| B | 1 | 0 | 0 | 1 |
| C | 1 | 0 | 0 | 1 |
| D | 0 | 1 | 1 | 0 |
你可以看到矩阵关于主对角线对称,每行 1 的个数等于该顶点的度。A 的度是 2,B 的度是 2,C 的度是 2,D 的度是 2。如果输出不符合这个规律,那肯定是哪里写错了。
验证的时候重点看三个地方:对称性、每行 1 的个数、主对角线是否为 0。这三个检查能覆盖 90% 的错误。
4.3 实操现场记录:一次完整的建图过程
我拿一个具体的例子把整个过程跑一遍,你可以跟着做。
输入数据:
5 6 1 2 3 4 5 1 2 1 4 2 3 3 4 3 5 4 5顶点是 1 到 5,共 5 个,下标 0 到 4。边有 6 条。
读入顶点后,vexs = ['1','2','3','4','5']。
初始化矩阵,5x5 全 0。
读第一条边 1-2:locateVex('1')返回 0,locateVex('2')返回 1,设置arcs[0][1] = 1和arcs[1][0] = 1。
读第二条边 1-4:下标 0 和 3,设置arcs[0][3] = 1和arcs[3][0] = 1。
读第三条边 2-3:下标 1 和 2,设置arcs[1][2] = 1和arcs[2][1] = 1。
读第四条边 3-4:下标 2 和 3,设置arcs[2][3] = 1和arcs[3][2] = 1。
读第五条边 3-5:下标 2 和 4,设置arcs[2][4] = 1和arcs[4][2] = 1。
读第六条边 4-5:下标 3 和 4,设置arcs[3][4] = 1和arcs[4][3] = 1。
最终矩阵:
0 1 0 1 0 1 0 1 0 0 0 1 0 1 1 1 0 1 0 1 0 0 1 1 0检查对称性:arcs[0][1]=1且arcs[1][0]=1,arcs[0][3]=1且arcs[3][0]=1,以此类推,全部对称。主对角线全是 0。每行 1 的个数:第一行 2 个,第二行 2 个,第三行 3 个,第四行 3 个,第五行 2 个。对应顶点的度分别是 2、2、3、3、2,加起来等于 12,正好是边数 6 的两倍。验证通过。
4.4 代码模块化与函数接口设计
把建图过程拆成几个函数,主函数就干净了:
int main() { MGraph G; scanf("%d %d", &G.vexnum, &G.arcnum); initMatrix(&G); readVexs(&G); readArcs(&G); printMatrix(&G); return 0; }每个函数只做一件事,调试的时候也容易定位问题。比如输出不对,那可能是readArcs里对称赋值漏了,或者printMatrix格式错了。模块化不是为了装样子,是为了让你在出问题的时候能快速缩小范围。
函数接口设计上,统一用MGraph *G指针传递,避免结构体拷贝。C 语言里结构体传值会复制整个数组,效率很低,而且修改不会影响原结构体。用指针是标准做法。
5. 常见问题与排查技巧实录
5.1 矩阵不对称:最常见也最致命的错误
现象:输出的矩阵关于主对角线不对称,比如arcs[0][1]=1但arcs[1][0]=0。
原因:读边的时候只写了一个方向的赋值,漏了对称的那一句。
排查:在readArcs里检查是不是只有arcs[u][v] = 1而没有arcs[v][u] = 1。或者输出矩阵后手动检查对称性。
解决:补上对称赋值。如果边有权值,两个方向都要赋同样的权值。
5.2 顶点定位失败导致下标越界
现象:程序崩溃,或者矩阵里出现了不该有的 1。
原因:locateVex返回 -1,但主逻辑没判断,直接用 -1 当数组下标,导致越界访问。
排查:在locateVex返回后加打印,看是不是 -1。或者用调试器单步跟踪。
解决:在readArcs里加判断,u != -1 && v != -1才赋值。同时检查顶点读入是否正确,有没有把换行符读进去。
5.3 输出格式错误:行末空格与换行
现象:本地跑着没问题,提交到评测系统就是格式错误。
原因:行末多打了空格,或者最后一行多打了换行。
排查:把输出重定向到文件,用文本编辑器打开,开启“显示空白字符”,看行末有没有多余空格。
解决:用if (j > 0) printf(" ");控制空格。最后一行换行一般没问题,但如果题目严格,可以判断i < n-1才打换行。
5.4 顶点读入时吞掉换行符
现象:顶点读入不全,或者边读入错位。
原因:scanf("%c", &ch)会读入换行符和空格。
排查:打印读入的顶点,看是不是多了空白字符。
解决:用scanf(" %c", &ch),前面的空格让scanf跳过所有空白字符。读字符串用%s也会自动跳过空白。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 矩阵不对称 | 漏了对称赋值 | 检查readArcs | 补上arcs[v][u] = 1 |
| 程序崩溃 | 下标越界 | 检查locateVex返回值 | 加 -1 判断 |
| 格式错误 | 行末多余空格 | 显示空白字符检查 | 用if (j > 0)控制 |
| 顶点读入错位 | 空白字符干扰 | 打印读入的顶点 | scanf(" %c")加空格 |
| 矩阵全 0 | 边没读进去 | 检查循环次数 | 确认arcnum读对 |
| 矩阵多出 1 | 顶点重复或定位错 | 检查vexs数组 | 确保顶点不重复 |
5.6 独家避坑技巧:先写测试用例再写代码
我自己的习惯是,拿到题目先不急着写代码,而是手动构造一个小测试用例,把预期输出写下来。比如 4 个顶点 3 条边,手动画出矩阵。然后写代码,跑这个用例,对比输出。如果一致,再换一个更复杂的用例。这样能快速发现逻辑错误,而不是等到提交了才发现问题。
另一个技巧是:在readArcs里加临时打印,每读一条边就输出u和v的值,看看定位对不对。调试完再删掉。虽然土,但管用。
6. 从邻接矩阵延伸出去的知识点
6.1 邻接矩阵与邻接表的对比选择
邻接矩阵建完了,不妨想想邻接表怎么建。邻接表用一个数组存顶点,每个顶点挂一个链表,链表里存相邻顶点的下标。对于无向图,每条边会在两个顶点的链表里各出现一次。
对比一下:
| 维度 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(n²) | O(n+e) |
| 判断两点是否相邻 | O(1) | O(度) |
| 遍历某点所有邻居 | O(n) | O(度) |
| 适合场景 | 稠密图 | 稀疏图 |
| 实现难度 | 低 | 中 |
这道题用邻接矩阵是因为题目要求,而且 n 不大。如果 n 到了 10000,边只有 100 条,那邻接矩阵开 10000x10000 的数组直接爆内存,必须用邻接表。所以选型要看数据规模,没有绝对的好坏。
6.2 基于邻接矩阵的深度优先遍历
建好矩阵之后,自然可以接着做遍历。深度优先遍历(DFS)的思路是:从某个顶点出发,访问它,然后递归访问它的第一个未访问邻居,直到走不下去再回溯。
用邻接矩阵实现 DFS 很直接:
int visited[MAXV]; void DFS(MGraph *G, int v) { visited[v] = 1; printf("%c ", G->vexs[v]); for (int j = 0; j < G->vexnum; j++) { if (G->arcs[v][j] == 1 && !visited[j]) { DFS(G, j); } } }遍历某点的邻居时,需要扫描矩阵的第 v 行,时间复杂度 O(n)。如果图很稀疏,这个扫描大部分是白费功夫,这也是邻接矩阵的一个缺点。但代码写起来确实简单,不容易出错。
6.3 基于邻接矩阵的广度优先遍历
广度优先遍历(BFS)用队列实现:从起点入队,出队时访问,然后把所有未访问的邻居入队。
void BFS(MGraph *G, int v) { int queue[MAXV], front = 0, rear = 0; int visited[MAXV] = {0}; visited[v] = 1; queue[rear++] = v; while (front < rear) { int u = queue[front++]; printf("%c ", G->vexs[u]); for (int j = 0; j < G->vexnum; j++) { if (G->arcs[u][j] == 1 && !visited[j]) { visited[j] = 1; queue[rear++] = j; } } } }BFS 和 DFS 的差别只在于用队列还是递归栈,但遍历顺序完全不同。BFS 按层扩展,适合找最短路径(无权图);DFS 一条路走到黑,适合找连通分量或者拓扑排序。
6.4 矩阵幂与路径数量的关系
邻接矩阵还有一个有意思的性质:矩阵的 k 次幂A^k中,A^k[i][j]表示从顶点 i 到顶点 j 长度为 k 的路径数量。这个性质在离散数学和图论里经常用到。
比如A^2[i][j]就是从 i 到 j 走两步的方案数。如果A^2[i][i] > 0,说明顶点 i 在一个三角形或者更长的环里。这个性质可以用来判断图的连通性、计算最短路径(通过矩阵乘法变体),虽然实际项目中不会真的去算矩阵幂,但理解它有助于你建立对邻接矩阵的直觉。
7. 实验课验收与代码提交的实战建议
7.1 验收时老师常问的问题
根据我的经验,实验课验收时老师不会只看你代码跑没跑通,还会问几个问题来确认你是不是真懂了。常见的问题包括:
- 为什么无向图的邻接矩阵是对称的?
- 如果图有自环,矩阵哪里会变?
- 邻接矩阵的空间复杂度是多少?什么情况下不适合用?
- 怎么判断两个顶点是否相邻?时间复杂度多少?
- 如果顶点数很大但边很少,你会怎么改进?
这些问题都不难,但如果你只是抄了代码没理解,很容易卡壳。建议建完图之后自己把这些问题过一遍,能用自己的话答上来。
7.2 代码风格与注释的加分项
实验报告里的代码,风格好能加分。几个建议:
- 变量命名要有意义,
vexnum、arcnum、locateVex这种一看就懂,别用a、b、c。 - 关键步骤加注释,比如“无向图对称赋值”这种,让老师一眼看到你懂。
- 函数拆分合理,别把所有逻辑塞在
main里。 - 输入输出格式严格按照题目要求,别自作主张加提示语。
我见过一个同学代码写得一般,但注释清楚、函数拆分合理,最后拿了满分。另一个同学代码能跑,但全挤在main里,变量叫x1、x2,被扣了分。实验题不只是考功能,也考工程习惯。
7.3 时间复杂度的口算方法
建图的时间复杂度:初始化矩阵 O(n²),读入顶点 O(n),读入边 O(e),每条边定位两个顶点 O(n),所以总的是 O(n² + e*n)。如果 n 不大,这个复杂度完全可接受。输出矩阵 O(n²)。
空间复杂度:矩阵 O(n²),顶点表 O(n),总共 O(n²)。这也是邻接矩阵的主要开销。
口算的时候抓住主要项,别被常数干扰。比如 O(n² + e*n) 在 e 远小于 n 的时候可以简化为 O(n²),因为 n² 占主导。
7.4 从实验题到工程实践的思维转变
实验题的数据是规范的,顶点不重复、边不越界、格式固定。但真实项目里,图数据可能来自文件、网络、数据库,顶点可能是字符串、整数、甚至自定义对象,边可能带权值、方向、时间戳。邻接矩阵在工程里用得少,因为真实图往往很稀疏,n 一大矩阵就爆了。
但邻接矩阵的思想很重要:用二维关系表示连接。这个思想在数据库的外键、社交网络的好友矩阵、推荐系统的共现矩阵里都能看到影子。学好邻接矩阵不只是为了过实验,而是建立“用矩阵表示关系”的思维方式。等你以后遇到图神经网络、PageRank、最短路径这些问题,会发现底层都是邻接矩阵在支撑。
8. 我个人在实际操作中的体会
带过几轮实验课之后,我发现这道题真正的难点不在代码,而在“想清楚”。很多同学一上来就敲键盘,结果读入顺序搞反、对称赋值漏掉、输出格式不对,改来改去浪费大量时间。我的建议是:先在纸上画一个小图,手动写出它的邻接矩阵,然后按这个矩阵去反推代码逻辑。这样你写代码的时候心里有数,每写一步都知道预期结果是什么。
另一个体会是,locateVex这个函数虽然简单,但它是整个建图流程的枢纽。把它写对、用好,后面的逻辑就顺了。我见过有人为了省事,直接在读边的时候用vexs数组硬编码下标,结果顶点顺序一变就全错。多写一个函数,换来的是代码的健壮性和可读性,这笔账怎么算都划算。
最后分享一个小技巧:如果你用的是 C++,可以用vector<vector<int>>代替静态二维数组,用map<char, int>代替locateVex的线性查找。这样代码更短、更安全,而且不用操心内存管理。但如果是 C 语言的实验课,还是老老实实用数组和循环,把基础打牢。工具可以换,思想不会变。