简介:这份《数据结构课程设计-校园导航系统》文档面向计算机相关专业学生及需要完成图结构课程设计的读者,围绕校园景点查询与任意两点间最短路径问题展开。内容以C语言实现为主线,涵盖图的邻接矩阵存储、景点与道路信息结构体设计、Floyd算法求最短路径、系统调试测试及时间复杂度分析等关键环节,并给出源程序清单与执行结果。压缩包内共1个doc文件,大小约187KB,按任务书、详细设计、调试与测试、总结、致谢和参考文献组织,可作为课程设计报告模板与算法实现参考。已有67人学习,适合希望对照完整报告结构、梳理图算法实现思路并查漏补缺的读者。
1. 校园导航系统:一份课程设计里藏着图论落地的完整骨架
十来个景点、二十几条路,游客站在校门口问一句「去图书馆怎么走最近」,背后就是一张带权无向图和一次全源最短路计算。这份《数据结构课程设计-校园导航系统》要解决的就是这类问题:把校园平面图抽象成图,景点是顶点,道路是带权边,然后提供两个最朴素也最实用的能力——查景点信息、查任意两点间的最短路径。它适合正在做数据结构课程设计、想把邻接矩阵和 Floyd 算法真正跑通一遍的开发者,也适合想拿一个干净的小型 C 项目练手图结构建模的人。代码量不大,但「建模—存储—算法—交互」这条链路是完整的,拆明白这一套,后面换成地铁导航、景区导览、仓库拣货路径,骨架不用重写。
2. 技术选型与数据结构建模:为什么是邻接矩阵加 Floyd
2.1 三个核心结构体撑起整个模型
这个项目没有用什么框架,纯 C 语言加标准库,编译依赖只有stdio.h、string.h、malloc.h。选型上有一个很明确的判断:校园景点数量在十几个这个量级,属于典型的小规模稠密图,那么存储结构用邻接矩阵比邻接表更划算——判边存在性是 O(1),Floyd 算法本身也天然吃矩阵。要是换成上千个节点的稀疏图,邻接矩阵的 O(n²) 空间就该翻车了,那时候得上邻接表配 Dijkstra 加堆优化。
模型被拆成三层,每层一个结构体,职责划得很清楚:
#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]; // 边权矩阵 int numOfEdge; // 边的条数 } AdjMGraph; // 建图用的边描述,避免在 main 里手写矩阵 typedef struct { int row; int col; int weight; } RowColWeight;DataType是业务层,SeqList是顶点容器,AdjMGraph是图的整体。这么分层的好处是查询景点信息时只需要扫vertices,完全不碰边矩阵;算路径时只吃Edge,跟简介文本无关。MaxWeight = 1000这个常量是个关键约定:矩阵里凡是 1000 的位置都视为「没有直接道路」,Floyd 的松弛判断依赖它做加法时不会误判成一条真实可走的短边。
RowColWeight这个中间结构容易被忽略,但它解决了一个很实际的痛点。二十八条边如果直接在main里写G.Edge[0][1] = 20; G.Edge[1][0] = 20;这样一对对赋值,写起来啰嗦,还极容易漏掉对称项。用{row, col, weight}三元组数组描述,建图函数里统一处理双向赋值,出错面小很多。
2.2 建图函数:把三元组数组灌进矩阵
建图这段代码原报告里没贴全(作者说「书上已经出现过」),但逻辑很直白,按这个场景的常见做法补出来大概是这样:
void Creat(AdjMGraph *G, DataType v[], RowColWeight rcw[], int n, int e) { int i, j; // 1. 顶点表初始化 for (i = 0; i < n; i++) { G->vertices.list[i] = v[i]; } G->vertices.size = n; G->numOfEdge = e; // 2. 边矩阵先全部置为「不通」 for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { G->Edge[i][j] = MaxWeight; } } // 3. 按三元组写入边权,注意无向图要对称赋值 for (i = 0; i < e; i++) { G->Edge[rcw[i].row][rcw[i].col] = rcw[i].weight; G->Edge[rcw[i].col][rcw[i].row] = rcw[i].weight; } }四个参数各管一件事:v[]是景点数组,rcw[]是边数组,n是景点数,e是边数。第 2 步的初始化不能省——C 的二维数组不会自动填 1000,不初始化就是一堆随机值,Floyd 跑出来的结果会非常玄学,这是新手最容易栽的第一个坑。第 3 步的双向赋值对应无向图语义,只写一半的话,从 A 到 B 有路、从 B 到 A 没路,问路功能直接就废了。
调用侧的样子,从原程序里能看清数据规模:
int n = 10, e = 28; // 10 个景点,28 条边 Creat(&G, a, rcw, n, e);十个景点、二十八条边,平均每个节点度数 5.6,是稠密图,邻接矩阵的空间利用率其实不低,选型站得住。
2.3 景点信息查询:一次线性扫描的取舍
信息查询的实现是全篇最简单的部分,但它的复杂度取舍值得说一句:
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; } } // 循环走完还没 break,说明没找到 if (i == G.vertices.size) { printf("您所查询的景点不在我们所提供的范围内!\n\n"); } }入口参数是整张图加一个景点名字符串,出口没有返回值,直接打印。时间复杂度 O(n),空间 O(1)。这里有个很常见的写法技巧:for循环变量i提到循环外声明,循环结束后用i == size判断是否命中,省掉一个flag变量。这个模式在 C 里很常见,但要小心:如果中途break,i停在命中位置;如果自然结束,i等于size。逻辑是对的,前提是循环条件严格写成i < size。
字符串输入用scanf("%s", sceneryname),这就埋了一个隐患——景点名里但凡有空格,输入会被截断。校园景点名一般没空格,所以这个项目里侥幸没出问题;换成「学生第二食堂」这种长名字,char name[20]也够用,但如果你要扩成英文名或加描述性后缀,长度得重新算。
3. 最短路径核心链路:Floyd 三重循环与路径回溯
3.1 Floyd 算法本体:十九行代码的全部内容
全篇技术含量最高的就是这个函数,十九行,没有一句废话:
void Floyd(int cost[][MaxVertices], int n, int weight[][MaxVertices], int path[][MaxVertices]) { int i, j, k; // 初始化:weight 拷贝原边权,path 全置 -1 表示「直达」 for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { weight[i][j] = cost[i][j]; path[i][j] = -1; } } // 三重循环做松弛,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是输出的最短距离矩阵,path是输出的中转点矩阵,n是顶点数。三个输出矩阵都是MaxVertices × MaxVertices的定长二维数组,传参时退化成指针,所以必须显式带上第二维长度MaxVertices——这是 C 语言二维数组传参的硬性要求,漏了编译不过。
path矩阵的语义要嚼透:path[i][j] = -1表示 i 到 j 的最短路是直达的,中间不经过任何点;path[i][j] = k表示 i 到 j 的最短路要经过 k。这个「记中转点」的存法比记完整路径省空间,代价是回溯时要递归或迭代展开。
复杂度 O(n³),n=10 的时候是一千次内层判断,可以忽略不计。但如果景点扩到 200 个,就是八百万次,每次查询都重跑一遍 Floyd 就很不划算了。合格的改法是:建图后跑一次 Floyd,把weight和path缓存成全局或挂在图结构上,查询时直接查表。原程序的写法是在Path里每次查询都调Floyd,对课程设计来说无所谓,对真实系统来说是明显的性能债,二次开发时第一个该改的就是这里。
注意:
weight[i][k] + weight[k][j]相加时,如果两个都是MaxWeight = 1000,结果是 2000,不会溢出int,也不会小于任何真实边权(真实边权最大也就几十),所以不用额外判!= MaxWeight。但如果你的无穷大常量取成了INT_MAX,这里就会整型溢出成负数,松弛判断直接失效。无穷大的取值必须小于INT_MAX / 2。
3.2 路径回溯:那段让作者调试半天的逻辑
距离算出来容易,把「A 经 B 经 C 到 D」这条链还原成人类可读的一行,才是真正麻烦的地方。原程序里的Path函数就是干这个的:
void Path(AdjMGraph G, char sceneryname[], char sceneryname1[]) { int i, j = -1, k = -1, n, m, count = 0; n = G.vertices.size; int weight[MaxVertices][MaxVertices], path[MaxVertices][MaxVertices]; int value[MaxVertices]; // 暂存中转点序列 // 1. 名称 -> 下标 for (i = 0; i < n; i++) { if (strcmp(G.vertices.list[i].name, sceneryname) == 0) j = i; if (strcmp(G.vertices.list[i].name, sceneryname1) == 0) k = i; } // 2. 跑一次 Floyd Floyd(G.Edge, n, weight, path); m = path[j][k]; printf("最短路径长度:%d\n", weight[j][k]); // 3. 直达的情况 if (m == -1) { printf("%s ——> %s\n", sceneryname, sceneryname1); } else { // 4. 回溯:不断找当前区间里的中转点,收窄区间 while (m != -1) { value[count++] = m; if (j < k) k = m; else j = m; m = path[j][k]; } // 5. 按方向输出 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); } }逐段解释。第 1 步把两个景点名映射成矩阵下标j和k,这里用的是「扫一遍线性表比对字符串」,和前文的Information同构。注意j、k初始化为 -1,如果用户输错名字,两者保持 -1,后面path[-1][k]就是数组越界——这是个真实存在的健壮性缺口,原程序没处理,我在下面避坑章节还会提。
第 4 步是核心。m = path[j][k]拿到 j 到 k 之间的一个中转点,把它记进value,然后把区间收窄:如果j < k,说明中转点在 k 这一侧之前,新的右端点变成m;否则左端点变成m。再取新区间的中转点,循环到m == -1(区间内直达)为止。这样收下来的value数组,顺序是从外往里,所以第 5 步输出时要看方向决定正着遍历还是倒着遍历。
作者在报告里专门写了一段:正序输出正常,逆序输出出问题,最后定位到循环方向。这就是典型的「数据是对的,渲染顺序错了」。血泪经验是——回溯出来的中间点序列天然是有方向的,别指望一个 for 循环通吃两个方向,先想清楚value里存的是从起点侧往中间收还是反过来,再决定循环怎么写。
3.3 输入校验与调试:几个该补没补的地方
Path里j、k可能为 -1 的风险,随手补两行就能堵住:
if (j == -1 || k == -1) { printf("输入的景点名称有误,请检查后重试。\n"); return; } if (j == k) { printf("起点和终点相同,距离为 0。\n"); return; }另外scanf("%s%s", sceneryname, sceneryname1)没限制宽度,名字超过 19 字符就会写穿name[20]。正确写法是scanf("%19s%19s", ...)。这类问题在课程设计里通常不会暴露,因为测试数据都是造好的;但一旦用户手滑多打几个字符,程序行为就不可预测了,可能悄无声息地改掉边上数据,表现为「同一个查询,结果时对时错」——这种 bug 最难查,因为现象不像崩溃那么显眼。
调试阶段建议开一个临时的打印函数,把weight矩阵整个打出来核对:
void DumpMatrix(int m[][MaxVertices], int n) { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) printf("%5d", m[i][j]); printf("\n"); } }建图完之后打一次,确认对角线是 0(如果初始化为 0)、对称位置相等、不通的位置都是 1000。Floyd 跑完再打一次,检查weight[i][j] == weight[j][i](无向图必对称),以及三角不等式是否满足。这三条检查几乎是免费的,能拦掉八成的数据层错误,比在输出结果上瞎猜高效得多。
4. 编译运行与工程组织:六个头文件怎么串起来
4.1 文件划分与依赖顺序
原报告列了六个文件,各管一摊:
| 文件 | 职责 | 被谁包含 |
|---|---|---|
school.cpp | 主文件,构造数据、调Creat、进菜单 | 无 |
SeqList.h | 顺序表结构与基本操作 | AdjMGraph.h |
AdjMGraph.h | 图结构体定义、图基本操作 | AdjMGraphCreat.h |
AdjMGraphCreat.h | Creat建图函数 | school.cpp |
Floyd.h | Floyd 算法实现 | school.cpp |
Inquiry.h | Information与Path两个查询函数 | school.cpp |
Operation.h | 菜单、Information1、Path1包装 | school.cpp |
这里有个依赖顺序的坑:Inquiry.h里的函数签名用到了AdjMGraph类型,所以它必须排在AdjMGraph.h之后被包含;Operation.h里又调用了Inquiry.h的函数和全局变量G,所以它必须放在最后。原程序的包含顺序正是这么排的:
#include <stdio.h> #include <string.h> #include <malloc.h> #define MaxSize 20 #define MaxVertices 20 #define MaxWeight 1000 #include "Floyd.h" #include "AdjMGraphCreat.h" #include "Inquiry.h" AdjMGraph G; // 全局图对象,供各模块共享 #include "Operation.h" // 依赖 G,必须放最后注意AdjMGraph G;这行的位置——它夹在两组 include 之间,因为它必须在AdjMGraph.h(被AdjMGraphCreat.h间接引入)之后声明,又要在Operation.h(使用了G)之前存在。这种夹心写法能跑,但非常脆:你一旦调整 include 顺序就会连环报错。更稳的做法是给AdjMGraph.h加 include guard,把G改成extern声明放在头文件、定义放在school.cpp。
4.2 编译命令与运行流程
纯 C 代码,没有图形库依赖,任何主流编译器都能过。用 g++ 编译(文件后缀是.cpp,虽然代码全是 C 风格):
# GCC g++ -o school school.cpp -Wall ./school # 或者用 Clang clang++ -std=c++11 -o school school.cpp ./school-Wall一定要加。这类老代码里常见「函数隐式声明」「变量未初始化」的隐患,开警告能提前揪出来。如果用 MSVC,注意.cpp后缀下 MSVC 会按 C++ 规则编译,DataType a[] = { ... }里的中文字符串如果要按 C 处理可能编码告警,加/utf-8参数就好。
跑起来的交互流程是两屏菜单:
****************菜单********************** 1,相关信息查询 2,最短路径查询 ******************************************* 请选择您想进行的操作: 2 请输入你要查询的两景点的名称: 图书馆 操场 最短路径长度:50 从 图书馆 到 操场 的最短路径为: 图书馆——>博学楼——>操场菜单的递归实现值得一提。menu()在每个操作处理完之后又调menu(),形成无限循环;想退出只能关窗口。这在课程设计里很常见,也确实是偷懒——正确做法是把menu包进while(1),加一个「0 退出」分支。递归调用会一直压栈,虽然实际使用中压不了几层,但一旦用户连查几十次,理论上会栈溢出。翻车场景很小概率,但改起来就是两行:
void menu(void) { int n; while (1) { puts("1. 信息查询 2. 最短路径 0. 退出"); scanf("%d", &n); if (n == 0) break; if (n == 1) Information1(); else if (n == 2) Path1(); } }改完之后把Information1和Path1末尾的menu()递归调用删掉,流程就对了。
4.3 数据录入:那十行初始化的写法问题
main里的景点初始化用的是一段中英引号混用的代码,原报告里是这样的:
DataType a[] = {{"学生第二食堂",10,"学生的主要就餐点"}, {"男生宿舍",11,"一共有八栋"}, ...};如果直接从文档复制,里面的引号很可能是中文全角引号“ ”,编译器会报一堆莫名其妙的错。作者在总结里专门提了「全角半角也得注意」,指的就是这个。硬核教训:从 Word 或网页里往编辑器粘贴代码,先粘到一个纯文本文件里再打开检查,或者干脆手打一遍,能省下找不出错在哪的半小时。
边数组rcw[]有二十八项,全部是成对出现的,比如{0,1,20}和{1,0,20}。如果哪天要加一条路,必须两条一起加,否则就成了有向图。这也是为什么Creat里做的是对称赋值——理论上你只用写一半的三元组,函数会补全另一半。原程序的数组写全了,属于冗余但无害;真做二次开发时,把e改成 14,三元组只写单向的,效果一样,还少一半出错机会。
5. 避坑与排查:五个真踩过的坑
5.1 矩阵没初始化,距离算出来是乱数
现象:同样的输入,最短路径一会儿对一会儿不对,甚至出现负数。原因:AdjMGraph是局部或栈上的变量时,Edge数组里全是垃圾值;对垃圾值做min松弛,结果自然失控。解决:Creat里第一件事就是把整个矩阵填成MaxWeight,对角线填 0(自己到自己距离为 0,否则weight[i][i]会被算成绕一圈的路)。这条必须写死,不要指望calloc或全局变量。
5.2 无穷大取值过大导致整型溢出
现象:明明没路的两点,算出来一个很大的负数距离,接着被当成合法路径输出。原因:MaxWeight取成了INT_MAX一类的值,weight[i][k] + weight[k][j]相加溢出。解决:无穷大取INT_MAX / 4以内的值,1000对这个项目足够;同时建图时所有「不相邻」一律填这个值,不要让矩阵里出现两种「不通」的表示。
5.3 逆序输出路径顺序反了
现象:查询 A 到 D 显示正常,查 D 到 A 时中间点顺序颠倒,路径显示成D——>B——>C——>A但实际拓扑是反的。原因:回溯path时用j < k判断收窄方向,输出时的遍历方向没跟着同步。解决:把回溯和渲染解耦——先一路回溯到起点,把完整节点序列从终点往起点压进数组,最后统一倒序打印。这样无论哪个方向查询,渲染逻辑只有一条。我一般会写个RebuildPath(int path[][MaxVertices], int i, int j)单独测试,输入固定的path矩阵看输出对不对,比连着 Floyd 一起调快得多。
5.4 景点名输错导致数组越界
现象:输入一个不存在的景点名,程序偶尔崩溃,偶尔输出一段乱码路径。原因:j或k保持 -1,path[j][k]用 -1 做下标。解决:映射完成后立刻校验j == -1 || k == -1并直接 return;同时把strcmp的比较改成不区分大小写、去首尾空格,减少用户输入失误。查询失败的提示语也要写清楚,别只说「找不到」,要告诉用户当前有哪些景点可选。
5.5 头文件互相包含导致重复定义
现象:加了 include guard 之后反而编译报「multiple definition」,或者去掉 guard 之后报「redefinition of struct」。原因:结构体定义放在了头文件里,多个.cpp各自 include 一次就有了多份定义;全局变量AdjMGraph G;写在头文件里更是灾难。解决:头文件只放类型声明和函数原型,函数实现放.c,全局变量在头文件里写extern AdjMGraph G;、在某个.cpp里写一次定义。课程设计规模小,全塞头文件也能过,但你要往上加功能,这个坑迟早撞上。
6. 二次开发:从十景点 Demo 到能用的小系统
这个项目最大的价值不是它现在能跑,而是它的结构留了扩展口。想把课程设计改造成一个真能用的小导览系统,按下面的顺序动手最省力。
第一步,把「每次查询都跑 Floyd」改成「建图后跑一次」。把weight和path两个矩阵提升为图结构的成员或者全局静态变量,Creat完成后立即调一次Floyd,Path里只做名称映射和回溯。这一改,查询响应从 O(n³) 降到 O(n) 加一次路径展开。用 200 个节点压测一下,差别肉眼可见。验证方法:在Floyd前后打时间戳,或者干脆在Path里加个静态计数器,看每次查询是否还在重复计算。
第二步,把硬编码数据外置成配置文件。现在景点和边写在main里,改一个景点要重新编译。抽一个简单的文本格式,比如每行名称 代号 简介一个景点,起点 终点 距离一条边,写个LoadFromFile函数读进来。这一步做完,非程序员也能维护地图数据。下面是我常用的配置解析骨架:
// 顶点文件:name code intro int LoadVertices(const char *file, DataType v[], int *n) { FILE *fp = fopen(file, "r"); if (!fp) return -1; // 文件打不开,交给调用方报错 int cnt = 0; while (fscanf(fp, "%19s %d %49s", v[cnt].name, &v[cnt].code, v[cnt].introduction) == 3) { cnt++; if (cnt >= MaxVertices) break; // 防止越界 } fclose(fp); *n = cnt; return 0; }fscanf的返回值是成功匹配的项数,等于 3 才算读到一条完整记录,这个判断必须写,否则文件末尾的空行会让最后一条数据重复入库。宽度限制%19s、%49s对应结构体的数组长度,差一位都是缓冲区溢出。
第三步,做路径的多解与偏好。现在只输出「最短距离」,但实际导览里,用户可能想要「少走楼梯」「路过食堂」这类偏好。改造路径是把Edge的权值从单一距离换成复合代价,比如weight = distance + stair_penalty * 5 + food_bonus * (-3),Floyd 一行不改就支持了。副作用是路径可能不再唯一,你需要在松弛时记录「相等距离下的另一条路」,代码量不大,但要想清楚相等时取哪条——我一般按「中转点编号小的优先」,结果稳定、可复现。
第四步,加个最简图形界面。不用上 Qt,用 EasyX 或者直接生成 SVG 文件就够了。把所有顶点的坐标存进DataType,再多一个x, y字段,问路时把路径点连成折线输出成 SVG,浏览器打开就能看。这一步的收益是把「一串名字加箭头」变成「一眼看懂的图」,答辩演示效果好,也确实是导览系统该有的样子。
改造过程中有个验证习惯值得保留:每改一处,先把矩阵 dump 出来看一遍。我第一版加配置文件加载时,就是因为没做这个检查,n被算成了 0,Creat里循环一次没进,整个图是空的,但程序不崩,查询任何路径都返回 1000 加一条直达——现象极其迷惑,查了半小时才发现是文件路径写错、fopen返回 NULL 却被当成功处理了。从那以后我每次加载数据后都强制走一遍「顶点数 > 0、边数 > 0、矩阵对称」的断言检查,宁可多打几行日志。希望这些坑你一个都不用踩,直接用上干净的骨架。
本文还有配套的精品资源,点击获取