简介:这份《数据结构课程设计-校园导航系统》Word文档面向计算机相关专业学生与数据结构初学者,围绕校园景点信息查询和任意两点间最短路径求解,给出可直接参考的课程设计报告范本。文档以无向图建模校园平面图,采用邻接矩阵存储景点与道路信息,结合Floyd算法实现不少于10个景点间的路径查找,并比较Dijkstra、Bellman-Ford等算法的适用场景,配有数据结构定义、算法设计思想、调试测试与时间复杂度分析。压缩包仅含1个doc文件,大小约187KB,内容覆盖任务书、详细设计、测试结果讨论、源程序清单与执行结果、C程序设计总结、致谢及参考文献,结构完整,便于按报告章节模仿写作和复盘实现思路。已有67人学习,适合需要完成同类图论课程设计、补齐文档与代码说明的学生参考。
1. 校园导航系统:从 10 个景点和 28 条路开始把图论课设做实
校园导航系统这道课设,看着像“画个地图、跑个最短路”就完事,实际交作业时最容易翻车的地方在于:景点数据一多,路径回溯就乱;查询方向一反,输出顺序就颠倒。这套方案要解决的就是把《数据结构》里抽象的图和最短路径,做成一个能录入 10 个以上景点、支持任意两点问路和信息查询的完整可运行程序。它适合正在赶数据结构课程设计的学生,也适合想拿一份能直接改参数复用的 C 语言工程骨架的从业者。整套程序按邻接矩阵存图、顺序表存景点、Floyd 算全源最短路径来搭,顶点与边的信息一次建模到位,后面加景点、改路长都不用动算法主体。
2. 需求拆解与选型:为什么是邻接矩阵加 Floyd 而不是省事的邻接表
先把题目要求摊开看。任务书里有三条硬性约束:景点不少于 10 个;两个场所之间可以有不同的路,路长也可能不同;要找出任意场所到另一场所的最佳路径。前两条决定了这是个带权无向图,第三条决定了它问的是“全源最短路径”,而不只是“单源最短路”。这三个条件基本就把数据结构和算法的路给堵死了,选型空间其实很小,但恰恰是这一步选错,后面全是补丁。
2.1 存储结构:邻接矩阵、顺序表与边的三元组
图用邻接矩阵存,理由不是“书上这么写”,而是查询模式决定的。这个系统的高频操作是“任意两点问路”,一次查询就要读大量顶点对之间的距离关系,邻接矩阵的Edge[i][j]是 O(1) 随机访问,天然贴合;反观邻接表,每取一条边都要顺着链表走,做全源最短路时反复遍历邻接点,代码量和常数都上去了。校园景点规模在 10 到 20 之间,矩阵的 O(n²) 空间开销完全不值一提,这就是典型的“图小、查询密”场景,用矩阵。
顶点信息单独用顺序表存,因为景点有名称、代号、简介三段异构数据,还要支持按名称扫描查找。把“图的结构”和“点的属性”拆成两个结构体,是这份方案里最值得抄的一个设计决策:图负责连通关系和权重,顺序表负责业务信息,两者通过数组下标隐式对应,第 i 个顶点就是顺序表第 i 个元素。
#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; // 建图用的边三元组:批量录入 (行, 列, 权值) typedef struct { int row; int col; int weight; } RowColWeight;这里几个参数的作用要说清楚。MaxWeight取 1000 是个经验值,只要大于所有真实路径长度之和就行,它的作用是让 Floyd 在松弛时不会把“不连通”误判成“一条很长的路”;如果你学校景点多、路又长,把它调到 9999 也无妨。MaxVertices和MaxSize保持一致,是为了让顶点下标和顺序表下标严格对齐,省掉一层映射,代价是扩景点时要同时改两个宏,这个取舍在课设规模下完全划算。
RowColWeight这个三元组结构是建图的关键设计。它把“哪两个点之间有路、路多长”从矩阵里剥离出来,写成一张扁平的边表。这样做的好处很直接:改地图时你只需要编辑这张边表,不用去碰矩阵的初始化逻辑,加一条路就是加一行{i, j, w},删一条路就是删一行。28 条边对应 28 行,无向图要正反各写一次,这一点后面避坑章节会专门说。
2.2 算法选型:Floyd 的三重循环与 path 矩阵的回溯设计
题目要“任意场所到任意场所”,这就是全源最短路。Dijkstra 单源跑 n 次也能得到同样结果,复杂度都是 O(n³),但代码要写 n 次初始化、n 次选点,还得配一个优先队列或者手动找最小值,对课设来说纯属自找麻烦。Floyd 三重循环、十几行代码、不需要任何辅助数据结构,正确性还特别好验证,是这个场景下最省事也最不容易错的选择。至于 Bellman-Ford,那是给负权边准备的,校园路长不可能为负,用不上。
Floyd 的核心是松弛:如果“i 经 k 到 j”比“当前已知的 i 到 j”更短,就更新距离,并把 k 记下来。这个“记下来”的动作就是path矩阵存在的全部意义——只算距离不给路径,用户问完路你还是答不出“怎么走”。
void Floyd(int cost[][MaxVertices], int n, int weight[][MaxVertices], int path[][MaxVertices]) { int i, j, k; // 初始化:距离矩阵复制边权,path 全部置 -1 表示直达、无中间点 for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { weight[i][j] = cost[i][j]; path[i][j] = -1; } } // n 次递推:以 k 为中转点尝试松弛 for (k = 0; k < n; k++) { for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { if (weight[i][j] > weight[i][k] + weight[k][j]) { weight[i][j] = weight[i][k] + weight[k][j]; path[i][j] = k; // 记录中转点,供回溯还原路径 } } } } }三个参数各有分工:cost是原始邻接矩阵,只读不改;weight是输出用的最短距离矩阵,查询时直接weight[j][k]取值;path是路径还原表,path[i][j] = k表示 i 到 j 的最短路会经过 k,等于 -1 表示两点直达或者压根没有中间点。三层循环的顺序不能乱,k 必须放最外层,这是 Floyd 能成立的前提——每一轮 k 都意味着“允许经过编号不超过 k 的点作为中转”,k 在内层就会算错,这是课设里非常典型的一个错误。
path初始化为 -1 而不是 0,是个容易忽略的细节。如果初始化成 0,回溯时无法区分“直达”和“经过 0 号顶点”,会把本来直达的路径错误地拆开。用 -1 做哨兵,语义就干净了:-1 就是“到此为止,没有中间点了”。
3. 模块实现:查询、问路与路径还原的三个关键函数
代码按头文件拆成六块:SeqList.h管顺序表、AdjMGraph.h管图的基本操作、AdjMGraphCreat.h负责建图、Floyd.h放算法、Inquiry.h管两类查询、Operation.h管菜单与交互,主文件school.cpp只负责准备数据和启动菜单。这个拆法不是为了好看,而是为了让“改地图”和“改算法”这两件事互不干扰——地图数据全在主文件的数据区,算法逻辑全在头文件里,答辩被问到哪块都能单独讲。
3.1 景点信息查询:顺序表扫描与未命中的兜底
信息查询走的是最朴素的线性扫描:从表头到表尾,逐个strcmp比对名称,命中就打印名称、代号、简介三行,没命中就给出明确提示。这段逻辑简单到没什么可优化的,但有两个地方决定了它好不好用。
void Information(AdjMGraph G, char scenery[]) { int i; for (i = 0; i < G.vertices.size; i++) { if (strcmp(G.vertices.list[i].name, scenery) == 0) { printf("您输入的景点的名称是: %s\n", G.vertices.list[i].name); printf("您输入的景点的代码为: %d\n", G.vertices.list[i].code); printf("您输入的景点的相关信息有: %s\n\n", G.vertices.list[i].introduction); break; // 命中即退出,避免同名景点重复输出 } } if (i == G.vertices.size) { // 循环走完仍未命中 printf("您所查询的景点不在我们所提供的范围内!\n\n"); } }第一个点是break的位置。命中后立刻跳出,不继续扫,因为景点名在逻辑上应当唯一,继续扫只会浪费一次遍历;但如果你的数据里真出现了重名,这个break就会让后一个景点永远查不到,所以录入阶段必须保证名称唯一,这一点在验收时要专门测。
第二个点是循环结束后靠i == G.vertices.size判断未命中,而不是在循环内设标志位。这种写法依赖 for 循环的变量作用域——循环正常结束 i 就等于 size,提前 break 则 i 小于 size。它省一个变量,但可读性略差,答辩时被问到“怎么判断没找到”要能答上来。时间复杂度 O(n),n 是景点数,20 个点最坏也就比 20 次,性能上没有任何压力。
3.2 最短路径查询:起终点定位与 path 矩阵回溯
问路功能是整个系统里唯一有技术含量的部分,它的流程是:读两个景点名 → 在顺序表里定位它们的下标 j 和 k → 跑一遍 Floyd → 从path[j][k]开始回溯,把中转点一个个挖出来 → 按正确顺序打印。
void Path(AdjMGraph G, char sceneryname[], char sceneryname1[]) { int i, j, k, n, m, count = 0; n = G.vertices.size; int weight[MaxVertices][MaxVertices], path[MaxVertices][MaxVertices]; int value[MaxVertices]; // 暂存回溯出的中转点下标 // 第一步:按名称定位起点 j、终点 k for (i = 0; i < G.vertices.size; i++) { if (strcmp(G.vertices.list[i].name, sceneryname) == 0) j = i; if (strcmp(G.vertices.list[i].name, sceneryname1) == 0) k = i; } Floyd(G.Edge, n, weight, path); m = path[j][k]; printf("最短路径为: %d\n", weight[j][k]); if (m == -1) { // 直达,无中转点 printf("从 %s 点到 %s 景点的最短路径为:\n", sceneryname, sceneryname1); printf("%s ——>%s\n", sceneryname, sceneryname1); } else { // 第二步:沿 path 反向挖出所有中转点 while (m != -1) { value[count] = m; if (j < k) k = m; else j = m; // 判断往哪半段继续找 m = path[j][k]; count++; } // 第三步:按起终点相对大小决定正序或逆序输出 printf("从 %s 点到 %s 景点的最短路径为:\n", sceneryname, sceneryname1); printf("%s——>", sceneryname); if (j < k) { for (i = count - 1; i >= 0; i--) printf("%s——>", G.vertices.list[value[i]].name); } else { for (i = 0; i < count; i++) printf("%s——>", G.vertices.list[value[i]].name); } printf("%s\n", sceneryname1); } }这段代码里最值得讲的是回溯为什么会有“正序/逆序”两套输出。path记录的中转点是从两端往中间收敛的:先拿到最靠近起点的分割点,再分别往两段继续找,所以value数组里存下的顺序跟实际行走顺序并不一致,起点一侧要到后面才被找到。原料里就栽在这一点上——正序查询(起点下标小于终点)时恰好顺序对得上,逆向查询就颠倒了,这就是那份报告里提到的“只能正序输出”的坑。判断j < k并在两种情况下分别正序、逆序打印,本质上是在补上这段回溯顺序的差异。更稳妥的写法是用一个栈把中转点压进去再弹出,或者干脆把value存完后统一按起终点关系重排,课设规模下两种写法都行,关键是你要说得清为什么这么写。
还有一个隐含风险:如果 j 和 k 定位失败(用户输错景点名),这两个变量就是未初始化的野值,path[j][k]直接越界。稳妥的做法是在定位循环里加标志位,任一未命中就打印提示并直接返回,这一点在后面避坑章节会展开。
3.3 建图与菜单:把边表灌进矩阵、把交互串成闭环
建图这段逻辑的价值在于把“边表”翻译成“矩阵”。给定 n 个顶点和 e 条边,先把矩阵整体填成MaxWeight(自己到自己填 0),再逐条读三元组把权值写进去,同时累加边数。无向图的边要在[row][col]和[col][row]两个位置都写。
void Creat(AdjMGraph *G, DataType v[], RowColWeight e[], int n, int edgeNum) { int i, j; // 顶点表:整段拷贝景点信息 for (i = 0; i < n; i++) G->vertices.list[i] = v[i]; G->vertices.size = n; // 矩阵初始化:对角线 0,其余视为不连通 for (i = 0; i < n; i++) for (j = 0; j < n; j++) G->Edge[i][j] = (i == j) ? 0 : MaxWeight; // 逐条写入边权,无向图双向赋值 G->numOfEdge = 0; for (i = 0; i < edgeNum; i++) { G->Edge[e[i].row][e[i].col] = e[i].weight; G->Edge[e[i].col][e[i].row] = e[i].weight; G->numOfEdge++; } }主函数里把 10 个景点和 28 条边写成两个静态数组,调一次Creat就完成了整个图的构建,之后进menu()循环接受用户选择,1 走信息查询、2 走问路查询,查完再回菜单。这个“数据静态声明 + 一次建图 + 菜单循环”的结构,好处是主函数极短、数据极醒目,改地图只需要动数组,不用碰任何逻辑代码。
int main() { // 10 个景点,下标 0~9 与顺序表位置一一对应 DataType a[] = { {"学生第二食堂", 10, "学生的主要就餐点"}, {"男生宿舍", 11, "一共有八栋"}, {"操场", 12, "最新的塑胶跑道和人工草坪"}, {"物理楼", 13, "拥有各种实验器材"}, {"博学楼", 14, "学生上课集中地"}, {"草地", 15, "休闲娱乐的好地方"}, {"老教", 16, "学校最早的教学楼"}, {"女生宿舍", 17, "一共七栋"}, {"小门", 18, "可以通往半汤镇"}, {"图书馆", 19, "藏书50万册"} }; // 28 条有向边描述,无向路正反各写一次 RowColWeight rcw[] = { {0,1,20},{0,2,30},{0,3,35},{1,0,20},{1,3,20}, {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; Creat(&G, a, rcw, n, e); // 构造图 menu(); // 进入交互菜单 return 0; }数据区里有几个细节值得在报告里点一句:下标 0 到 9 跟顺序表位置严格对应,{0,1,20}就是 0 号点到 1 号点 20 米;边表里{0,1,20}和{1,0,20}成对出现,这是无向图的写法约定;{9,6,5}这种单向出现的行,就意味着那条路缺失了反向记录,一旦漏写,反方向查询就会得到MaxWeight,看起来像“不连通”。这套数据本身就是测试用例的原料,答辩演示直接从里面挑起终点即可。
4. 避坑与排查:这类课设最容易翻车的五个点
这份方案跑通之前,踩过的坑基本都集中在“矩阵初始化、无向边、回溯顺序、越界、数据一致”这五处,它们有个共同特点——编译能过、程序能跑,但结果是错的,属于典型的玄学 bug,不看数据根本找不出来。下面按“现象 → 原因 → 解决”逐条过。
4.1 现象:查询任意两点都显示路径极长或干脆不通
原因几乎总是矩阵初始化不完整。Creat里只把对角线置 0、其余置MaxWeight,如果漏了这一步,或者用malloc分配后没清零,矩阵里就是随机垃圾值,Floyd 会把这些垃圾当成真实路长参与松弛。另一种情况是MaxWeight设得太小,小于某条真实路径的总长,松弛时就会出现“绕远反而更短”的荒唐结果。
解决方式是双保险:建图时显式双重循环填MaxWeight,不依赖任何默认值;同时保证MaxWeight大于所有真实边长之和,10 个点、单边最长 35 的情况下取 1000 绰绰有余。排查手段是在Creat结束后加一段调试打印,把整个矩阵打出来,肉眼确认非对角线是不是全是 1000,这一步花两分钟,能省掉两小时的怀疑人生。
4.2 现象:正向查询路径正确,反向查询路径乱序或漏点
这就是原料里记录的那个坑,原因在path回溯的方向性。中转点是从两端向中间收敛挖出来的,存进value的顺序跟实际行走方向相反。起点下标小于终点时,逆序打印恰好对;起点下标大于终点时,还按逆序打印就整个颠倒了。
解决办法是按起终点大小关系分两套输出,或者改用栈结构统一处理。更彻底的做法是放弃“边挖边判断”,改成标准递归回溯:写一个printPath(i, j),先判path[i][j] == -1则直接打印 j,否则printPath(i, k)再打印 j,递归天然给出正序,根本不需要判断方向。这个写法的代码量反而更少,答辩时也更经得起追问。
4.3 现象:A 到 B 通,B 到 A 不通
无向图写成了有向图。Creat里如果只写G->Edge[row][col] = weight,漏掉[col][row],矩阵就是上三角有值、下三角全是MaxWeight。表现出来就是单向可达,用户换个顺序问路直接得到 1000。
这个问题在建图函数里就该一次性堵死:无向图必须双向赋值,这一点在Creat里已经写进去了。排查方法是遍历矩阵,检查Edge[i][j] == Edge[j][i]是否恒成立,写个十行的小循环就能扫出所有不对称的边。另外,边表里成对出现的记录也要人工核对一遍,{6,9,5}和{9,6,5}缺一个,建图代码再正确也补不回来。
4.4 现象:输入一个不存在的景点名,程序崩溃或输出天文数字
起终点定位失败,j或k保持未初始化的野值,随后path[j][k]直接越界读写。C 语言不会报错,运气好读到垃圾数,运气不好段错误。
解决方式是在定位循环里加命中标记,任一没找到就打印“景点名称输入有误”并直接 return,绝不带着野值往下走。Information函数里的未命中判断是同样的道理。另外,scanf("%s", sceneryname)本身不限制长度,输入超过 20 个字符会溢出,稳妥写法是scanf("%19s", ...),把宽度限死在缓冲区大小之内。这两处防护加起来不到十行,但能挡掉答辩现场最尴尬的两次崩溃。
4.5 现象:改了景点名之后,问路功能说什么都查不到
典型的数据不一致。景点名称同时存在于顺序表和边表的下标语义中,但边表只记下标不记名字。如果你在顶点数组里改了名字、调整了顺序,却没有同步调整边表里的行列号,那么“名字能找到、下标对不上”,查询必然失败。
处理原则是:顶点数组的下标就是唯一事实来源,改名字可以随便改,改顺序必须连带核对边表。我一般会在数据区顶部写一行注释,标明“下标 0~9 对应的景点顺序,改动需同步边表”,并在改完后跑一遍全点对的自测——任意两点都查一次,看有没有出现 1000,能挡住绝大多数顺序错位。
5. 验收与答辩:测试用例怎么设计、复杂度怎么讲清
验收这块,老师看的不是你程序能跑,而是你能不能证明它是对的。所以测试用例不能只演示一条漂亮路径,得覆盖边界和异常。我会准备一张用例表,至少包含五类:直达路径、多中转路径、反向查询同一条路、起终点相同、以及输入不存在的景点名。每一类都记录输入、期望输出、实际输出三列,截图贴进报告。
| 用例类型 | 输入示例 | 期望结果 | 检查要点 |
|---|---|---|---|
| 直达 | 学生第二食堂 → 男生宿舍 | 距离 20,无中转点 | path 为 -1 分支 |
| 多中转 | 学生第二食堂 → 图书馆 | 距离与路径链完整 | 回溯点顺序正确 |
| 反向查询 | 图书馆 → 学生第二食堂 | 与上一条路径镜像一致 | 正逆序输出对称 |
| 同点查询 | 操场 → 操场 | 距离 0,路径只有一个点 | 对角线值为 0 |
| 异常输入 | 不存在的景点名 | 明确提示,不崩溃 | 越界防护生效 |
复杂度这块,报告里必须写清楚两个操作分别是什么量级。信息查询扫一遍顺序表,时间 O(n)、空间 O(n);最短路径查询跑 Floyd 三重循环,时间 O(n³),额外开了两个 n×n 的weight和path矩阵,空间是 O(n²) 而非 O(1)——原料里写空间 O(1) 是不准确的,答辩被追问会很难看。正确的说法是:Floyd 本身在原地更新,但为了保留原矩阵和还原路径,额外开了两张表,所以空间 O(n²)。n 取 20 时矩阵才 400 个 int,完全可接受,这个解释反而显得你想清楚了。
还有一点值得主动讲:为什么每次查询都重跑一遍 Floyd,而不是建图时算一次存起来。诚实答案是课设规模下重算一次也就 8000 次内层操作,毫秒级,没必要做缓存;如果景点扩到几百个,就该在建图后预计算一次,把结果矩阵常驻内存,查询直接查表。这种“知道当前方案的边界在哪”的表述,比强行吹复杂度更得分。
演示顺序也有讲究:先跑一条直达,证明基础功能;再跑一条多中转,证明回溯;然后故意反向查一次,证明方向处理;最后输一个错名字,证明健壮性。四步走完,功能覆盖面基本齐全,老师也没什么可追问的了。源码我一般会按头文件分块贴进附录,主文件全贴,其余头文件只贴关键函数,并在每块前面加一行说明这个文件管什么。
从那以后我每次交这类带路径还原的课设,都会先把“反向查一遍”写进自测清单第一条——正序对不算对,逆序也对才叫对。希望帮到你。
本文还有配套的精品资源,点击获取