简介:这份文档是面向计算机相关专业学生的《数据结构》课程设计参考报告,主题为校园导航系统,适合正在完成图论类课程设计或需要理解最短路径算法落地实现的学习者。压缩包内共1个doc文件,约187KB,内容按任务书、详细设计、调试与测试、时间复杂度分析、源程序清单与执行结果、C程序设计总结、致谢及参考文献组织,结构接近正式课程设计报告规范,可直接对照章节框架梳理写作思路。资源已有67人学习,说明其在同类课设中具备一定参考价值。文档具体给出了采用邻接矩阵存储的无向图设计,景点用顺序表保存名称、代号与简介,边保存路径长度,并选用Floyd算法求解任意两景点间的最短路径,同时说明信息查询、路径输出由正序改为完整路径等调试过程,配有测试用例分析与复杂度讨论。读者可据此理解图结构选型、算法比较、模块划分及报告撰写要点,并参考源程序清单完成自己的实现与排错。
1. 邻接矩阵与三重循环:一张校园地图为什么能被压成两个二维数组
校园导航系统这个题目看着朴素:十来个景点,问两点之间怎么走最近。直觉上,找路应该像人问路一样,一段段试探、走错了退回来再试。但数据结构课程设计给出的答案恰恰相反——先别找路,先把一张 n×n 的邻接矩阵摊在内存里,然后让 Floyd 对着它原地涂改 n 轮,改完矩阵里剩下的数字就是任意两点间的最短距离。整个过程没有队列、没有栈、没有回溯,只有三重 for 循环和一个二维 path 数组。最短路径这件看起来需要"搜索"的事,被压成了纯数组改写,这正是它值得拆开看的地方。
2. 邻接矩阵:稠密小图宁可浪费空间,也要换回 O(1) 的判边
2.1 三个结构体拼出的存储模型:顶点表、边矩阵、边计数
这套代码把"校园"拆成了两层:一层是顶点表,装景点本身的属性(叫什么、编号多少、有什么介绍);另一层是边矩阵,只装景点之间的连通关系和路长。两层之间靠下标对齐——顶点表第 3 个元素,就是边矩阵第 3 行第 3 列对应的那个景点。这种"属性与拓扑分离"的写法,是后面所有查询能写得极短的前提。
#define MaxSize 20 /* 顶点表最大容量 */ #define MaxVertices 20 /* 邻接矩阵最大阶数 */ #define MaxWeight 1000 /* 表示“不连通”的假无穷 */ /* 顶点:景点自身的属性 */ typedef struct { char name[20]; /* 景点名称,同时充当查询的键 */ int code; /* 景点代号,纯展示用 */ char introduction[50]; /* 景点简介 */ } DataType; /* 顺序表:一段连续数组 + 当前有效长度 */ typedef struct { DataType list[MaxSize]; int size; } SeqList; /* 图:顶点表 + 边权矩阵 + 边计数 */ typedef struct { SeqList vertices; /* 顶点集合 */ int Edge[MaxVertices][MaxVertices]; /* 边权矩阵,MaxWeight 表示不通 */ int numOfEdge; /* 边数,主要给打印和校验用 */ } AdjMGraph;值得琢磨的是size和MaxSize的区别:MaxSize是编译期定下的天花板,size是运行时的实际景点数。后面 Floyd 里所有循环上界都写n = G.vertices.size,而不是MaxSize——这一点如果写错,矩阵里未被初始化的那部分内存(值是随机的)会被当成真实边参与运算,算出来的最短路会莫名其妙地短。这不是理论风险,是实际调过的血泪经验。
为什么用邻接矩阵而不是邻接表?因为题目规模写死了:10 个景点。10×10 的 int 矩阵才 400 字节,随便浪费。换来的好处是判边只要Edge[i][j] != MaxWeight一次访存,取权值也是一次访存,而且 Floyd 的三重循环里全是Edge[i][k] + Edge[k][j]这种随机访问,连续内存的缓存命中率远高于链表跳转。邻接表只在稀疏大图(上万个顶点、几十万条边)才有优势,校园平面图完全不沾边。
2.2 MaxWeight 的 1000 是一个"假无穷",它会在加法里露馅
代码里用 1000 表示"这两点之间没有直接的路"。这是个纯粹的工程妥协——C 语言没有真正的无穷,用INT_MAX又会在MaxWeight + MaxWeight时整数溢出变成负数,那样 Floyd 会认为"绕一圈反而更近",把不存在的路写进矩阵。1000 的好处是加两次才 2000,离int上限远得很,不会溢出。
但它埋了一个隐患:1000 同时也是真实路长的合法取值。这套代码里所有边权是 5 到 35 之间的整数,看起来离 1000 很远,安全。可一旦有人把边权改成"米",两公里外的两点路长就是 2000,那么这条真实存在的路会被当成"不通",导航直接把目的地判为不可达。常见的处理方式是让假无穷至少取"全图最长简单路径长度的两倍",或者干脆在松弛时加一道显式判断:
if (weight[i][k] != MaxWeight && weight[k][j] != MaxWeight) { int cand = weight[i][k] + weight[k][j]; /* 两端都可达才相加 */ if (cand < weight[i][j]) { weight[i][j] = cand; path[i][j] = k; /* 记录是谁把这条路改短的 */ } }原版代码没有这道判断,靠的是"1000 + 5 = 1005 > 1000,所以不会更新"这个巧合。巧合能 work,但它依赖"真实权值严格小于假无穷"这个没写进注释的隐含约定。写出这种代码不算错,只是下一个接手的人一定会在这里翻车。
2.3 对称赋值:无向图的 28 条边为什么在代码里写了 28 行
建图部分的输入是一串三元组{row, col, weight},对应"从 row 号景点到 col 号景点有一条长为 weight 的路"。校园里的路是双向的,所以每一条真实道路必须在数组里出现两次:一次{2,3,15},一次{3,2,15}。
/* 每条无向道路写两遍:正反各一条 */ RowColWeight rcw[] = { {0,1,20}, {0,2,30}, {0,3,35}, {1,0,20}, {1,3,20}, /* 1-0 与 0-1 同一条路 */ {2,0,30}, {2,3,15}, {2,4,30}, {3,0,35}, {3,1,20}, {3,2,15}, {3,4,30}, {4,2,30}, {4,3,30}, {4,5,10}, {5,4,10}, {5,6,35}, {5,8,8}, {6,5,35}, {6,7,20}, {6,8,25}, {6,9,5}, {7,6,20}, {7,8,10}, {8,5,8}, {8,6,25}, {8,7,10}, {9,6,5} }; int n = 10, e = 28; /* 10 个景点,28 条有向边即 14 条真实道路 */数一下就会发现,28 条有向边正好是 14 条无向道路的两倍。这种手工写对称对的做法极容易漏——漏一条反向边,图就悄悄变成了有向图:从 A 走到 B 查得出路径,从 B 回 A 却告诉你"到不了"。程序不报错,测试也未必覆盖到那个方向,等到验收演示时随手点了一对正好漏掉的景点,就是当场出问题。
更稳妥的写法是只录入一半,让建图函数自己补反向边:
/* 只录 {i<j} 的一半,另一条边由构造过程自动补全 */ for (k = 0; k < e; k++) { G->Edge[rcw[k].row][rcw[k].col] = rcw[k].weight; G->Edge[rcw[k].col][rcw[k].row] = rcw[k].weight; /* 无向图,对称赋值 */ G->numOfEdge++; /* 补完是双倍,需除以 2 */ }代价是numOfEdge需要额外校正。这就是典型的工程设计取舍:用"录入量减半"换"计数需要修正"。在课程设计这种规模下,两种做法都能跑通,但前者把正确性交给了人的记性,后者交给了代码。
3. Floyd 的三重循环:为什么 k 必须钉在最外层
3.1 状态定义:把"经过哪些点"换成"允许经过前 k 个点"
Floyd 最难讲清的不是代码,是它凭什么敢用三重循环一遍算完。它没有枚举路径,也没有做任何搜索,它做的是动态规划,状态定义得非常巧妙:
设d(k)[i][j]表示"从 i 到 j、且中途只允许经过编号 0 到 k-1 这些顶点作为中转"时的最短长度。当 k = 0 时,不允许任何中转,d(0)就是邻接矩阵本身;当 k = n 时,允许所有顶点中转,d(n)就是最终答案。
状态转移只有一句话:从 i 到 j,要么压根不用第 k 号顶点当跳板,要么用一次。用的话,路径必然是"i 到 k"加"k 到 j",而这两段的中转点只能来自前 k-1 个:
d(k+1)[i][j] = min( d(k)[i][j], d(k)[i][k] + d(k)[k][j] )这就是三重循环的来历。k 在最外层,不是因为写起来顺手,而是因为第 k+1 轮必须建立在第 k 轮的结果之上——k 是"阶段",i 和 j 只是"状态"。把 k 放进内层,等于把阶段打乱,DP 的前提就没了。
还有一个常被忽略的细节:为什么这份代码能"原地"更新,不需要两份矩阵轮流用?因为第 k 轮里,weight[i][k]和weight[k][j]这两个值在更新前后其实不会变。以weight[i][k]为例,它的候选值是weight[i][k] + weight[k][k],而weight[k][k]恒为 0,所以更新公式退化成"自己和自己比较",永远不生效。这两个值在第 k 轮是不动点,原地更新才安全。如果有人手贱把weight[k][k]初始成MaxWeight而不是 0,这个不动点性质就崩了,结果会变得没法解释。
3.2 把 k 挪到内层会得到什么:一个不报错但算错的程序
假设有人觉得"三重循环不就是三层嘛",写成了for i / for j / for k。程序照常编译、照常运行、照常输出一个数字,只是这个数字可能是错的,而且错得没有规律。
用一条链举例:4 个顶点 0-1-2-3 依次相连,每条边长 1,0 和 3 之间没有直达路。按i → j → k的顺序跑一趟:
i=0, j=3 时: k=0: 无改善 k=1: 需要 weight[1][3],此刻它还是“不通”,加不起来 k=2: 需要 weight[0][2],此刻它也是“不通”,加不起来 → weight[0][3] 保持“不通” i=1, j=3 时: k=2: weight[1][3] = weight[1][2] + weight[2][3] = 2 ← 这里才第一次算出来 i=2, j=3 时: k=1: weight[2][3] = weight[2][1] + weight[1][3] = 3 i=0 的那一轮已经过去了,再也不会回来重算 weight[0][3]结果是:0 到 3 明明走得通,长度 3,程序却告诉你"不可达"。因为外层 i 走到后半段时修好的值,没法回馈给前面已经算完的 i。要让它收敛,只能在外面再套 n 趟循环反复扫,复杂度从 O(n³) 涨到 O(n⁴),这就是把阶段顺序搞乱的代价。
这类错误是典型的黑匣子:没有编译错误,没有运行时崩溃,输出格式也完全正常,只有拿纸笔手算一遍才能发现数字不对。我一般建议在 Floyd 之后加一段自检——随机挑几对顶点,用朴素 DFS 枚举所有简单路径求最小值,跟矩阵里的结果对一遍。n=10 时 DFS 也就几百次递归,作为一次性验证完全够用。
3.3 weight 和 path 必须同时更新:一张记长度,一张记"是谁把它改小的"
完整实现只有二十来行,但两张表的初始化方式不一样,这一点经常被抄漏:
void Floyd(int cost[][MaxVertices], int n, int weight[][MaxVertices], int path[][MaxVertices]) { int i, j, k; /* 第一张表:拷贝邻接矩阵;第二张表:全部置 -1,表示“这一段直达” */ for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { weight[i][j] = cost[i][j]; path[i][j] = -1; } } /* k 是阶段:本轮只允许 0..k 号顶点充当跳板 */ for (k = 0; k < n; k++) { for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { if (weight[i][k] + weight[k][j] < weight[i][j]) { weight[i][j] = weight[i][k] + weight[k][j]; /* 长度变短 */ path[i][j] = k; /* 记下跳板 */ } } } } }weight是答案本身,path是答案的"来路"。两者必须在同一次判断里一起改:只要weight[i][j]因为经过 k 而变小,path[i][j]就必须记成 k,否则回溯出来的路线跟显示的里程对不上。这里没有"先算长度再补路径"的两步走方案——路径信息在松弛过程中会被覆盖多次,只有跟着一起记才留得住最后一次生效的那个跳板。
空间上是 O(n²)。n=10 时两张表加起来不到 1KB,代价可以忽略;但如果哪天景点扩到 1000 个,两张 1000×1000 的 int 表就是 8MB,而且 Floyd 的 10⁹ 次内层运算在单机上已经跑不完了。
4. path 表的语义与回溯:为什么逆序输出要写一个 if(j<k)
4.1 path[i][j] = k 记的不是前驱,而是"最后一次让路变短的中间点"
很多人第一眼会把path[i][j] = k理解成"i 的下一个点是 k",也就是前驱/后继那种链式结构。不是。它记录的是"i 到 j 的最短路在最后一次被改短时,是用哪个顶点当跳板拆开的"。
这个语义带来一个很强的性质:整条最短路可以被递归地拆成两半。path[i][j] = k意味着真实路线是"i 到 k 的最短路"接上"k 到 j 的最短路",而这两段各自的跳板分别记在path[i][k]和path[k][j]里。于是不需要存整条路径,也不需要前驱数组,只要 n² 个整数,就能把任意两点的完整路线还原出来。
path[i][j] = -1的含义也随之明确:这一段没有被任何跳板拆开过,i 和 j 直接相连,中间没有别的景点。
4.2 标准回溯:递归写三行,迭代用显式栈
按上面的递归定义直接翻译,代码短得不像话:
/* i 到 j 之间的中间点,按从 i 到 j 的顺序打印 */ void PrintMid(int path[][MaxVertices], const SeqList *V, int i, int j) { int k = path[i][j]; if (k == -1) return; /* 直达,没有中间点 */ PrintMid(path, V, i, k); /* 先输出 i..k 之间的中间点 */ printf("%s——>", V->list[k].name); /* 再输出跳板本身 */ PrintMid(path, V, k, j); /* 最后输出 k..j 之间的中间点 */ }注意参数里path[][MaxVertices]的第二维不能省。C 语言传二维数组时必须让编译器知道每行多宽,否则path[i][j]的地址算不出来。这是个纯粹的语法细节,但初学时十有八九会在这里卡一次。
递归深度最坏是 n(链状路径),n=10 时毫无压力。如果不想用递归,就自己开一个栈把"先左后中再右"的访问顺序压进去:
void PrintMidIter(int path[][MaxVertices], const SeqList *V, int i, int j) { int stack[MaxVertices * 2], top = 0; int l = i, r = j; while (1) { int k = path[l][r]; if (k == -1) break; /* 这一段直达,收敛 */ stack[top++] = k; /* 记下跳板 */ r = k; /* 继续往左半边拆 */ } /* 此时栈里是从右到左的跳板序列,倒着弹就是从左到右 */ while (top > 0) { printf("%s——>", V->list[stack[--top]].name); } }这段迭代写法只往左半边拆,拆到底之后再倒序输出。它能成立的前提是:跳板序列沿路径方向单调递减——也就是拆出来的 k 一定小于当前的 r。对某些图这是对的,对另一些图就不对了。
4.3 原代码的 if(j<k) 为什么会在编号不单调的路径上翻车
原版Path函数用的是迭代回溯,但它在输出方向的处理上加了一个判断:每次拆出跳板后,比较当前两端的下标j和k,如果j < k就把右端收缩到跳板,否则收缩左端,最后根据这个比较结果决定正序还是逆序打印。
这个判断的隐含假设是"路径中间点的编号是从小到大排列的"。校园地图上恰好大部分路线符合这个直觉(景点编号大致按地理位置排),所以测试时一直是好的。但只要有一条路的编号不单调,输出就会静默地少站。
构造一个最小反例:4 个顶点,边只有 0-2 长 1、2-1 长 1、1-3 长 1、2-3 长 3,其余不通。跑完 Floyd 后:
weight[0][3] = 3 path[0][3] = 2 ← k=2 那一轮把它改短 weight[2][3] = 2 path[2][3] = 1 ← k=1 那一轮把它改短 weight[0][2] = 1 path[0][2] = -1 ← 直达,无跳板真实路线是0 → 2 → 1 → 3,编号 0、2、1、3,中间的 2 和 1 是递减的。原版代码走一遍:起点 0、终点 3,因为 0 < 3,收缩右端到 2,记下跳板 2;接着看path[0][2],是 -1,循环结束。此时只剩一个中间点,打印出来是0 → 2 → 3——顶掉了顶点 1。
诡异的地方在于:里程显示 3,是对的;终点也对;只有中间少了一站。用户照着这条路走,走到 2 号景点之后会发现 3 号景点并不挨着它,中间还隔着一个。这种 bug 不会在编译期、运行期、甚至格式检查里暴露,只有拿真实地图对照才能发现。修法就是换成 4.2 的递归回溯,让路径结构自己决定输出顺序,而不是靠下标大小猜。
5. 避坑与排查:这套课设代码里最容易翻车的五处
5.1 数据录入阶段:三个静默越界与编码坑
第一条:scanf("%s")不限制长度。现象是输入一个稍长的景点名之后,程序要么直接崩,要么下一次查询返回一堆乱码。原因是name只有 20 字节,scanf读到空白为止,不检查边界,多出来的字符直接盖掉结构体里紧挨着的code和introduction。解决方式很土但有效:写成scanf("%19s", buf),留一个字节给结束符\0。血泪经验是——任何往定长数组里读字符串的地方,宽度都得手写。
第二条:源文件编码和控制台代码页不一致。现象是菜单和景点名全变成问号或方块,strcmp永远返回非零,查询任何景点都提示"不在范围内"。原因不在代码,在环境:源文件存成一种编码,控制台按另一种编码解释,两边看到的字节序列不一样,比较自然失败。解决方式是让源文件编码和控制台代码页对齐,或者干脆把景点名换成纯 ASCII 的拼音缩写先跑通逻辑,最后再换回中文。
第三条:手写对称边漏了反向。现象是 A 到 B 有结果、B 到 A 提示不可达。原因是RowColWeight数组里只写了{3,4,30}没写{4,3,30},图在有向与无向之间反复横跳。解决办法是只录一半、在建图函数里自动补对称,或者在建图完成后加一段自检:
/* 建图后自检:无向图的邻接矩阵必须对称 */ for (i = 0; i < n; i++) for (j = 0; j < n; j++) if (G.Edge[i][j] != G.Edge[j][i]) printf("警告:%d-%d 边不对称,权值 %d vs %d\n", i, j, G.Edge[i][j], G.Edge[j][i]);二十行代码,能在建图那一秒就把问题揪出来,比后面调试半小时强。
5.2 算法执行阶段:两个不崩溃只算错的坑
第四条:假无穷与真实权值区间重叠。现象是明明地图上有路,导航却说"两点不连通",或者给出一条绕得离谱的路线。原因是MaxWeight设成 1000,而某些真实路长超过 1000,被当成了"不通"。诊断方法很直接:把最终weight矩阵整个打印出来,看有没有哪个格子恰好等于MaxWeight但你确信它应该连通。解决方式是抬高假无穷(至少取全图最长可能路径的两倍),或者在松弛前加显式的可达性判断。
第五条:路径回溯依赖下标单调。现象是里程数字正确、起终点正确,但中间少了几站,或者顺序颠倒。原因就是 4.3 分析的那个if(j<k)。诊断方法是拿纸笔对一对:从输出里相邻两站,去邻接矩阵查它们之间是不是真有边;如果没有,说明中间丢了顶点。解决方式是换成递归回溯,让path的树形结构自己决定顺序。
5.3 排查顺序:先看矩阵,再看 path,最后看输出
调这类程序有个固定顺序,照着走能省掉大量猜测。第一步,把Edge矩阵整个打印出来,确认顶点数、边数、对称性、连通分量都对——这一步能拦掉八成问题,因为大部分错误其实出在数据录入而不是算法。第二步,把weight和path两张表打印出来,挑几对手算得出的顶点对,验证weight的数值和path的跳板是否自洽。第三步才是看最终的路线字符串。
顺序不能反。先看输出字符串,你只能看到"少了一站"这个现象,看不出是录入错了、松弛错了、还是回溯错了;先看矩阵,问题的位置是自明的。我后来养成了一个习惯:任何图算法的第一次运行,都先把邻接矩阵和结果矩阵完整打印出来,肉眼扫一遍再谈输出格式。
6. 设计取舍:为什么课设选 Floyd 而不是 Dijkstra
单源最短路有 Dijkstra,能处理负权有 Bellman-Ford,全源最短路才是 Floyd 的地盘。这三者的取舍在课堂上一句话带过,实际写起来差别很大:
| 算法 | 时间复杂度 | 空间 | 适用前提 | 在这套课设里的表现 |
|---|---|---|---|---|
| Floyd | O(n³) | O(n²) | 无负权回路,允许负权边 | n=10 时约 1000 次内层运算,一次算完全部点对 |
| Dijkstra(朴素) | O(n²) 单源 | O(n) | 边权非负 | 每次查询重跑,问 10 次就是 10 倍开销 |
| Bellman-Ford | O(n·e) 单源 | O(n) | 允许负权,能检测负圈 | 校园路长不可能为负,能力过剩 |
把数字代进去就明白了:n=10,Floyd 的内层判断总共执行 1000 次,一次运行把 100 个点对全算完,之后任何查询都是 O(1) 查表。Dijkstra 单次查询 O(n²)=100,看起来更快,但它只解决一个起点;用户如果连续问"从食堂到图书馆""从食堂到操场""从宿舍到教学楼",起点一变就得重跑。课程设计的交互是菜单式的,谁也不知道下一条查询从哪出发,预计算全源反而是最省事的选择。
真正的取舍点在扩展性上。如果景点扩到 200 个,Floyd 是 8×10⁶ 次内层运算,仍然是毫秒级;扩到 2000 个就是 8×10⁹,单机跑不动,而 Dijkstra 加堆优化单次查询只要 O(e log n),这时候就该换算法了。校园导航这个题目的规模被死死钉在 10 到 20 之间,所以 Floyd 的立方复杂度压根不是问题,代码短、无边界情况、一次算完全部——这三条加起来,比"理论最优复杂度"重要得多。
验证方法我也建议固定下来:随机抽 20 对顶点,用 DFS 枚举所有简单路径求最小值,跟weight矩阵对一遍;再抽 5 对,把path回溯出的完整路线打印出来,逐段去Edge矩阵核对每一条边是否真实存在、权值相加是否等于显示里程。前者验数值,后者验路径,两个都过才算真的对。
从那以后我每次写最短路,不管多简单的图,都强制先把邻接矩阵打印出来扫一眼,再让程序输出任何结果——因为这套代码里最贵的教训不是算法写错,是算法没错、数据错了,而输出看起来一切正常。希望帮到你。
本文还有配套的精品资源,点击获取