简介:这份全国交通出行咨询模拟系统C语言实现源码,面向数据结构课程设计与期末大作业场景,适合需要完成交通网络查询、最短路径规划等经典课题的本科生直接参考与复用。项目已获导师指导并通过评审,得分97分,围绕全国城市间交通出行咨询展开,完整演示了图结构存储、路径搜索与多方式换乘等核心算法的实际落地,既可作为课设报告配套代码,也能为同类系统设计提供可复用的模块化思路。压缩包共10个文件,具体为5个头文件、1个源文件、3个文本数据文件与1份说明文档;头文件按管理、用户、核心结构等职责拆分模块,主程序负责整体调度,文本数据提供城市、航班、列车基础信息,整体仅16KB,结构紧凑、无冗余文件,方便快速阅读与二次开发。目前已有308人学习下载,下载后可在本地直接编译运行,适合用于课程设计、期末大作业或数据结构综合训练。
1. 全国交通咨询模拟系统:本质上就是一张带权图加两段 Dijkstra
课程设计里,全国交通出行咨询模拟系统听起来像是个大工程,又是“全国”又是“咨询”,很容易让人以为要做地图、做导航。实际上它就是一个标准的数据结构图论题:把城市看成顶点、把交通线路看成带权边,然后回答“从 A 到 B 怎么走最近、怎么花钱最少、怎么换乘最少”。数据全是模拟的,不接真实地图 API,不需要经纬度,不需要路径渲染。我见过不少同学在这个题上卡住,卡的点不是算法看不懂,而是 C 语言怎么把菜单循环、文件读取、Dijkstra、路径回溯串成一个能演示的完整程序。这个题目适合已经选图论方向、想避开通讯录和图书管理这类烂大街选题的同学,也适合想认真练一次 C 语言工程组织能力的人。
2. 把全国交通建模成图:邻接矩阵选型与数据文件设计
2.1 三十个城市的规模,邻接矩阵比邻接表更稳
建全国交通网络,最常见的存储方案就是邻接矩阵和邻接表。很多教材默认“全国”顶点很多、边很稀疏,所以推荐邻接表,但课程设计这个场景要反过来想。
全国交通咨询模拟系统里的城市数量一般控制在 20 到 50 个以内,省会加主要地级市就够演示了。这个规模下,n×n 的矩阵也就 2500 个格子,三张权重表加起来不到一万个 int,内存完全无压力。邻接矩阵最大的好处是查边和改边都是 O(1) 复杂度,读文件填充权重时直接w[i][j] = value,不用维护链表节点,不用写头插法,出 bug 的概率低很多。邻接表的意义在于稀疏图省内存、遍历相邻顶点快,但 50 个顶点的图里,O(n²) 和 O(n+e) 的差距肉眼根本看不出来。
| 对比项 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 码量 | 少,直接二维数组 | 多,要写节点和链表操作 |
| 查边速度 | O(1),按下标取 | O(度),需要遍历链表 |
| 内存占用 | n² 个 int,n=50 时很小 | 较少,但n小优势不明显 |
| 适合场景 | 顶点少、图较稠密 | 顶点多、边稀疏 |
| 课程设计友好度 | 高,调试直观 | 低,链指针查起来费劲 |
我一般会直接用int **w动态申请一个 n×n 矩阵,或者干脆用静态二维数组。矩阵越界这类低级错误,在 50 的规模下也容易排查,valgrind 一跑就能定位,比链表节点丢指针好查得多。
2.2 三张权重矩阵与城市、线路数据文件的结构
系统要回答三种查询,就得准备三种权重:里程数、费用、耗时。常见做法是维护三张 n×n 矩阵,分别叫distMat、costMat、timeMat,读取不同交通方式的线路文件时,往对应的矩阵里填值。
数据文件分成两类。第一类是城市表,格式做两列:编号和城市名。第二类是线路表,每种交通方式一个文件,格式是“起点编号 终点编号 权重”,权重具体代表什么意义由该文件对应哪种权重矩阵决定。
cities.txt(示例) 0 北京 1 上海 2 广州 3 武汉 4 成都 roads.txt(公路线路,权重=公里数) 0 3 1100 1 3 800 3 4 1100 rails.txt(铁路线路,权重=票价,单位元) 0 1 553 0 3 520 1 3 270 flights.txt(航班线路,权重=飞行时间,单位分钟) 0 1 125 0 3 150 1 2 145这里有一个很容易误解的地方:公路存公里数、铁路存票价、航班存时间,那“最短里程、最低费用、最少时间”三种查询怎么统一权重体系?我建议不要把三种方式的数据混合进同一张矩阵,而是先汇总成三张 n×n 矩阵,然后查询时按需取用。
比如“最短里程”,把公路里程和铁路里程逐条对比,同一条城市对取较小值填入distMat,因为没人会在一个咨询系统里反复比较“公路 1100 公里 vs 铁路 1050 公里”这种细节,取最优即可。“最低费用”用costMat,由铁路票价和航班票价对比填充。“最短时间”用timeMat,公路时间按里程除以平均时速估算,航班直接用飞行时间。这样设计的好处是:Dijkstra 只认权重矩阵,查询时传不同矩阵进去就行,核心算法不用动。
2.3 loadData 源码骨架:把文件读进矩阵
下面这段是读取城市文件和线路文件的骨架代码,以roads.txt为例:
typedef struct { int id; char name[32]; } City; City cityTable[MAX_CITY]; int cityCount = 0; int **distMat; // 里程矩阵 int **costMat; // 费用矩阵 int **timeMat; // 时间矩阵 void loadRoads() { FILE *fp = fopen("roads.txt", "r"); if (fp == NULL) { printf("无法打开 roads.txt,请确认文件放在程序同目录\n"); return; } int u, v, w; while (fscanf(fp, "%d %d %d", &u, &v, &w) == 3) { if (w < distMat[u][v]) { // 若已有路线,取里程更短的 distMat[u][v] = distMat[v][u] = w; } } fclose(fp); }这段代码有个很实用的细节:fscanf返回值判断为== 3,确保每次读满三个字段才处理,能滤掉文件末尾的空行和格式错误行。w < distMat[u][v]的判断实现了“同一条线路有多条记录时取优”,这是真实数据里很常见的场景,写进文档里也是加分项。注意初始化矩阵时把所有值设为INF(一个很大但不溢出的数,比如1000000000),对角线设为 0。
3. Dijkstra 的工程化封装:一份代码同时查最短里程、最低费用和最少换乘
3.1 可复用的 Dijkstra 封装(含路径回溯)
查询功能的灵魂是 Dijkstra,但为了避免复制三份代码,我一般把它封装成一个通用的函数:传入顶点数、权重矩阵、起点、终点,返回最短权重值,并用path数组带回路径。
#define INF 1000000000 int dijkstra(int n, int **w, int src, int dst, int *path, int *pathLen) { int *dist = (int *)malloc(n * sizeof(int)); int *vis = (int *)calloc(n, sizeof(int)); int *pre = (int *)malloc(n * sizeof(int)); int i, j; for (i = 0; i < n; i++) { dist[i] = INF; pre[i] = -1; } dist[src] = 0; for (i = 0; i < n; i++) { int u = -1, minv = INF; for (j = 0; j < n; j++) { // 找当前未访问的最小距离点 if (!vis[j] && dist[j] < minv) { minv = dist[j]; u = j; } } if (u == -1) break; // 剩余点不可达 vis[u] = 1; for (j = 0; j < n; j++) { // 松弛操作 int nd = dist[u] + w[u][j]; if (!vis[j] && w[u][j] < INF && nd < dist[j]) { dist[j] = nd; pre[j] = u; } } } int ret = dist[dst]; // dst 不可达时 ret 仍为 INF int cnt = 0; for (j = dst; j != -1 && cnt < n; j = pre[j]) { path[cnt++] = j; // 先存的是 dst 到 src 的逆序 if (j == src) break; } *pathLen = cnt; free(dist); free(vis); free(pre); return ret; }参数说明:w是 n×n 权重矩阵,src、dst是城市编号,path是输出参数,调用方传入一个足够大的数组,函数运行完后path[]里保存的是从 dst 一路回溯 pre 数组得到的逆序路径,所以调用方打印时要从后往前读。pathLen返回路径长度,便于逆序打印。有一个实现细节值得注意:在松弛前先判断w[u][j] < INF,避免dist[u] + INF出现数值溢出导致结果变成负数,这个问题很隐蔽,后面避坑章会再展开。
3.2 三种查询的权重定义与调用方式
三个查询功能调用同一个 dijkstra 函数,区别只在于传入的矩阵不同。
void queryShortestDist() { int u = inputCityId(); int v = inputCityId(); int path[100], len = 0; int d = dijkstra(cityCount, distMat, u, v, path, &len); if (d >= INF) { printf("这两个城市之间暂时没有可通行线路\n"); return; } printf("最短里程: %d 公里\n", d); printPath(path, len); }queryLowestCost和queryShortestTime结构完全一样,只是把distMat换成costMat或timeMat。这就是矩阵选型的好处:核心算法一份代码,数据维度随便切。
“最少换乘”单独说。换乘次数不能在三种基础权重矩阵里直接跑 Dijkstra,否则你得到的是“经过的城市数量最少”,而不一定是“换乘次数最少”。我一般这样做:定义一张transferMat,如果两个城市之间有任意一种直达交通方式,就置为 1(代表一次直达),否则为 INF,然后跑 Dijkstra。路径经过的中间城市数量就是换乘次数。这个方案的前提是,系统中每条线路都是两城市点对点直达,不存在一条线路串起多个中间站;如果你的演示数据里有“一趟车经停五站”这种设定,这个近似就不成立了,需要把整条线路建模成一个超边。课程设计的数据规模通常不需要这么复杂,但想拿高分就要在报告中把这个边界说清楚。
4. 菜单驱动的交互循环:从“算法跑通”到“系统像样”
4.1 主菜单与 scanf 残留的处理
写 C 语言课程设计,控制台交互是标配。主循环的常见写法是do-while加switch,把各个查询函数挂在 case 分支上:
int main() { initMatrices(); loadCities(); loadRoads(); loadRails(); loadFlights(); int choice = -1; do { printf("\n===== 全国交通出行咨询系统 =====\n"); printf("1. 查询最短里程\n"); printf("2. 查询最低费用\n"); printf("3. 查询最少换乘\n"); printf("4. 查询某城市可直达的城市\n"); printf("0. 退出\n"); printf("请输入选项: "); scanf("%d", &choice); while (getchar() != '\n'); // 吃掉残留的回车符 switch (choice) { case 1: queryShortestDist(); break; case 2: queryLowestCost(); break; case 3: queryTransfer(); break; case 4: queryReachable(); break; case 0: printf("感谢使用,再见\n"); break; default: printf("无效选项,请重新输入\n"); } } while (choice != 0); return 0; }关键一行是while (getchar() != '\n');。scanf("%d")输入数字后,回车会残留在缓冲区,如果不吸掉,后续如果有getchar()或scanf("%c")读取输入,就会直接读到这个回车符,导致查询流程还没开始就结束了。这个坑几乎每届都有人踩,单独拿一节说是因为它真的会浪费调试时间。还有一点:选择0退出后立即返回0,不要在退出前释放矩阵内存,课程设计演示完直接关掉控制台就行,不用画蛇添足。
4.2 路径的正向打印与调试输出
path数组里存的是逆序路径,直接正着打印就反了。打印函数要按索引从后往前走:
void printPath(int *path, int len) { printf("路线: "); for (int i = len - 1; i >= 0; i--) { printf("%s", cityTable[path[i]].name); if (i > 0) printf(" -> "); } printf("\n"); }很多同学在这里犯迷糊,自己写的时候建议先画一张图:比如 0→3→4,path里存的是{4, 3, 0},len=3,打印循环从i=2开始输出cityTable[0],就是起点。为了调试方便,我还会在这个函数里加一个临时打印:把path数组内容先原样输出一遍,比如“path raw: 4 3 0”,确认路径回溯没错再正向打印。这种调试输出留在源码里不会扣分,答辩时还能说明你理解了路径存储方式。
4.3 查询直达城市的附加功能
菜单里的第 4 项“查询某城市可直达的城市”代码量不大,但演示效果很好,因为它是遍历邻接矩阵的行,实现了“直达查询”,和最短路径互补。核心代码就三行:
void queryReachable() { int u = inputCityId(); printf("从 %s 出发可直达:\n", cityTable[u].name); for (int v = 0; v < cityCount; v++) { if (u != v && distMat[u][v] < INF) { printf(" %s (%d 公里)\n", cityTable[v].name, distMat[u][v]); } } }这里的distMat[u][v] < INF判断体现了矩阵初始化的正确性:如果初始化漏了,distMat里全是随机值,直达查询会输出一屏垃圾城市,或者把不可达的城市也当成可达输出。所以初始化函数里两层循环把所有值赋为INF再写文件数据,这个顺序不能乱。
5. 课程设计高频坑排查:五个最容易翻车的地方
5.1 scanf 残留导致菜单“抽风”
现象:输入菜单选项后,程序没有等待输入起点城市,直接打印“请输入起点城市编号”然后立刻跳到“无效选项”,或者第二次循环时根本不进查询,直接退出。
原因:scanf("%d")只读走数字,把回车符留在输入缓冲区。下一次scanf("%d")遇到空白符会自动跳过,所以数字输入之间没事;但只要你用了getchar()或者scanf("%c"),这个回车就会被读走,变成城市编号的输入内容,逻辑自然错乱。
解决:每次scanf数字输入后立即加while (getchar() != '\n');清空缓冲。如果代码里多处需要输入,封装一个int readInt()函数统一处理,比到处写清缓冲更省心。
5.2 INT_MAX 做加法,最短路径变负数
现象:Dijkstra 松弛时dist[u] + w[u][j]算出来一个巨大的负数,程序认为这条路径比正数路径更短,最终输出一个负的里程或者一个不合理的路径。
原因:INT_MAX(约 21 亿)加上一个小权重直接溢出,变成负数,比较运算符被负数欺骗。
解决:不要用INT_MAX初始化距离,改用1000000000这类足够大又不会溢出的“伪无穷”。同时在松弛前判断w[u][j] < INF,这样dist[u] + w[u][j] < dist[j]只在权重有效时才会被执行。这个坑在数据结构题里写核心逻辑时就能发现,但放到完整系统里会被场景输入掩盖,等到演示那天翻车就晚了。
5.3 中文城市名乱码和 strcmp 比较失败
现象:程序读入的城市名打印出来是乱码,或者用户输入汉字查询时报“城市不存在”。
原因:C 语言源码里用中文字符串字面量,编译器保存的编码和文件读取编码不一致;控制台下输入的编码和代码里比较的编码不一致。Windows 控制台常见 GBK,Linux 终端常见 UTF-8,两头不对就乱码。
解决:城市名不要参与算法比较,城市表只用编号定位城市,城市名仅用于显示。inputCityId()接收的是整数编号,用户查询路径时也不会拿汉字去strcmp。如果演示环境乱码,最简单的方法是改用printf("0=北京 1=上海 ...")这种“编号+城市名”的提示方式,用户输入编号,程序内部只处理 int,乱码就变成纯显示问题,不影响逻辑。
5.4 图规模失控,查询卡到没法演示
现象:把“全国”理解成真的要把几十上百个城市全建出来,矩阵动态申请了 200×200 甚至更大的规模,loadData变得很慢,Dijkstra 也要算一会儿,演示时敲完查询要等好几秒才出结果。
原因:不是矩阵太大,是循环次数爆炸。200 个顶点的邻接矩阵初始化就要 4 万次赋值,Dijkstra 是 O(n²),一次查询 4 万次循环,看起来不多,但如果用了 Floyd-Warshall(O(n³)),200 的三次方是 800 万次,再加上三张矩阵,演示环境稍微差一点就会肉眼可见的卡。
解决:城市数量控制在 30 左右,Dijkstra 一次查询毫秒级返回,体验非常流畅。不要在课程设计里追求“真的全国”,要追求“结构完整、演示流畅”。如果想展示大数据量的能力,最多做到 60 个城市,30 以内最优。
5.5 “最少换乘”查出的是公里数最短的路线
现象:用户查询“从北京到成都最少换乘几次”,程序返回一条要换乘 5 次但总里程最短的路线,而实际上有另一条只换乘 1 次的路线被忽略了。
原因:transferMat没有建好,或者直接用了distMat跑 Dijkstra。“换乘次数”和“里程”是两种不同的代价,权重矩阵不同,结果自然不同。
解决:按 3.2 节的方案单独建transferMat,把“两城之间有任意直达方式”定义成权重 1,再跑 Dijkstra。同时测试用例要设计好:至少准备一条“里程长但换乘少”和一条“里程短但换乘多”的路线,演示时两个查询对照输出,反而能说明你的权重设计是有效的。
6. 答辩前再补三个升级点,让项目从“会跑”到“能讲”
6.1 加一个全矩阵 dump 功能,调试和展示都靠它
开发期间最容易遇到的问题是“图到底建对没有”,眼睛盯着 txt 文件没法确认。写一个dumpAllMatrices(),把三张权重矩阵按城市名格式化打印出来:
void dumpMatrix(const char *title, int **mat) { printf("===== %s =====\n", title); for (int i = 0; i < cityCount; i++) { for (int j = 0; j < cityCount; j++) { if (mat[i][j] >= INF) printf("%5s ", "INF"); else printf("%5d ", mat[i][j]); } printf("\n"); } }加载完数据后调用一次,手动对照 cities.txt 检查数据是否错位。答辩时如果老师问“数据怎么保证读入正确”,直接展示这张表,说明你做了数据自检,这个回答比空口解释有说服力得多。
6.2 把城市查询从“内置数字”改成“菜单选择”
inputCityId()让用户输入编号,新手演示容易输错。升级做法是:打印当前所有城市菜单,用户用上箭头或输入数字选中,程序内部仍只处理编号。这个改动不大,但操作体验会好很多,演示时不用背编号,老师也能直接上手。核心思路就是“显示层可以友好,内部逻辑不变”,这一句话讲出来,会比单纯说“我做的系统能跑”更有层次。
6.3 用同一个 dijkstra 函数把三次查询统一起来
答辩时最容易拿分的一点:我的三次查询共用了同一个dijkstra函数,区别只是传入不同的权重矩阵。这体现了函数封装和模块化的意识。建议在报告的数据结构说明部分画一张表,列出三张矩阵分别由哪些文件填充、服务哪种查询,然后写一句“三层查询共用同一算法,仅权重矩阵不同”。这个设计比单独写三个类似函数要简洁得多,也是课程设计质量的核心差异。
这学期我帮一个学弟排查这个题,最后发现他的问题就是INT_MAX溢出,改完的一瞬间他问了我一句“这个题是不是其实挺简单的”。我说对,就是一张图加两个函数,难的是你愿不愿意把每个环节的边界都想清楚。希望帮你把这道课程设计做成一个能讲清楚、敢演示、不翻车的项目,也希望你在做完之后回头看时,觉得这一段 C 代码写得值。
本文还有配套的精品资源,点击获取