简介:基于C语言实现的公交线路查询与管理系统,面向C语言学习者和课程设计者,解决了站点信息维护、公交线路推荐与换乘方案规划等问题。压缩包共7个文件,包含1个cpp源码、5个txt测试数据与1份docx说明文档,整体仅15KB,内容精简便于快速查阅。目前已有935人学习,适合作为数据结构与图算法综合应用的参考。源码演示了数组/链表管理站点数据,并通过邻接矩阵或邻接表构建线路图,配合Dijkstra或Floyd-Warshall算法实现最优路径推荐;测试数据覆盖直达、换乘、边界条件与异常输入等场景。配套docx文档梳理系统设计与函数模块划分,对照cpp源码和txt测试数据即可复现功能,有助于巩固C语言工程实现、算法调试和交互设计能力。
1. 公交线路查询与管理系统,这个课程设计为何值得认真写
公交线路查询与管理系统这个题目,表面是一个 C 语言课程设计,实际是把指针、链表、文件读写、图算法四块硬骨头一次性揉进同一个工程。很多人把邻接表和菜单循环分别写熟过,但真正要合成一个能编译、能演示、能通过验收的系统时,问题会集中爆发:scanf 吃回车、strtok 改坏原串、删除线路后内存泄漏、换乘算法只对单条线路有效。这篇文章顺着一个可编译的 C 语言工程,把数据建模、命令交互、文件持久化和排错路径铺开讲,适合正在写课程设计、准备期末项目、想补 C 语言基础的人照着改。
2. 先建模再编码:公交数据的核心结构与读写
2.1 把公交网络看成一张无权图,是最容易理解的做法
公交线路查询与管理系统里的数据,有两个天然维度:线路和站点。一条线路是一个有序站点序列;一个站点可能被多条线路经过;换乘的本质是从站点 A 到站点 B 找一条由若干相邻站点组成的路径。这个结构就是图论里的无向图,或按单行线做成有向图:节点是站点,边是相邻两个站点可以通过某条线路直达。如果不关心距离,全图边权可以看作 1,最少换乘问题就等价于无权图上的最短路径问题。
选型上有两种常见方案:邻接矩阵和邻接表。邻接矩阵用二维 int 数组,写起来直观,查两点是否相邻是 O(1),但城市级数据动辄上千站,矩阵空间是站点数的平方,稀疏图会浪费大量内存。邻接表只给每个站点挂一个可直达邻居的链表,内存随边数增长,遍历邻居也足够快。这个题目数据量小,两种都能跑,但课程设计里我会选邻接表,既体现对稀疏图的理解,也给后面扩展地铁、公交混排留下余地。
对比一下两种结构在核心操作上的差异:
| 指标 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 判定两站相邻 | O(1),查二维数组 | O(度),遍历链表 |
| 遍历一个站所有邻居 | O(站点数) | O(出度) |
| 内存占用 | n^2 个 int | n + 2e 个节点 |
| 插入/删除一条边 | O(1) 改矩阵 | O(度) 找到位置 |
提示:很多教程直接拿《数据结构》里的邻接表代码改工种,容易忽略保存线路名、站点顺序这些公交领域属性,所以下面的结构体设计会把图和线路信息拆开维护。
2.2 线路、站点、邻接表的结构体设计
我一般会把数据分成三层:线路层、站点层、图论层。线路层用单链表存所有公交线路,每条线挂一串站点;站点层用定长数组维护站点名到编号的映射;图论层用邻接表存从某站能直达哪些站。这样查询线路时不碰图,查询换乘时才遍历边。
#define MAX_STATION_NUM 512 #define MAX_STATION_NAME 32 #define MAX_ROUTE_ID 16 #define MAX_ROUTE_NAME 32 typedef struct Stop { char name[MAX_STATION_NAME]; struct Stop *next; } Stop; typedef struct Route { char route_id[MAX_ROUTE_ID]; char name[MAX_ROUTE_NAME]; Stop *stop_head; /* 线路上第一个站点 */ struct Route *next; } Route; typedef struct AdjEdge { int to; /* 相邻站点编号 */ int route_id_index; /* 通过哪条线路到达 */ struct AdjEdge *next; } AdjEdge; typedef struct Station { char name[MAX_STATION_NAME]; AdjEdge *neighbor_head; /* 从本站出发的邻接边 */ } Station;这里的关键是route_id_index。换乘查询不只是判断能否到达,还要告诉用户坐哪路车、在哪站换。如果边只记录to,路径回溯后无法给出车次;加上线路编号后,BFS 回溯时能同时输出乘车方案。Stop用链表而不是数组,是因为增删站点只需要改指针,不用做大量元素搬移,正好练习指针和动态内存管理,这也是翁恺 C 语言练习题里反复出现的结构体与链表套路。
另一个细节是定长数组name[MAX_STATION_NAME]。用char *动态分配看起来更省内存,但每次赋值都要 malloc 和 free,容易漏。课程设计阶段定长数组能大幅减少指针错误,站点名字符串最大长度一般不超过 32 字节,足够覆盖中文站名和英文缩写。这里牺牲的是内存,换来的是strcmp、strcpy这类 C 语言字符串函数用起来更安全。
2.3 文件读写:把静态线路数据变成可复用资产
没有文件读写的管理系统,每次启动都得手动录入数据,既没法演示,也没法保存改动。课程设计评分里数据是否会保存通常是一个明确加分点。常见做法是用纯文本文件存,因为可以用记事本直接检查,出错好排查。我建议每行一条线路,字段之间用逗号分隔:
B1, 1路公交, 火车站, 人民公园, 文化宫, 汽车总站 B2, 2路公交, 汽车总站, 软件园, 会展中心读文件时第一个坑是feof。新手常写成while (!feof(fp)) { fscanf(...); },这会导致最后一条记录被读两次,因为feof要等读操作越过文件尾之后才置位。安全写法是把fgets作为循环条件,每次读一行再解析。
#include <stdio.h> #include <string.h> #include <stdlib.h> static void loadRoutes(Route **route_list, const char *filepath) { FILE *fp = fopen(filepath, "r"); if (fp == NULL) { perror("打开线路文件失败"); return; } char line[512]; while (fgets(line, sizeof(line), fp) != NULL) { if (line[0] == '#' || line[0] == '\n') continue; Route *r = (Route *)calloc(1, sizeof(Route)); char *token = strtok(line, ","); if (token != NULL) snprintf(r->route_id, sizeof(r->route_id), "%s", token); token = strtok(NULL, ","); if (token != NULL) snprintf(r->name, sizeof(r->name), "%s", token); Stop **tail = &r->stop_head; while ((token = strtok(NULL, ",\n")) != NULL) { Stop *s = (Stop *)calloc(1, sizeof(Stop)); snprintf(s->name, sizeof(s->name), "%s", token); *tail = s; tail = &s->next; } r->next = *route_list; *route_list = r; } fclose(fp); }逻辑说明:fgets一次读一整行,避免fscanf("%s")遇到空格就断的问题;strtok用逗号和换行作为分隔符,顺序切割出线路号、线路名、各站点名;snprintf限制拷贝长度,防止过长的线路名覆盖结构体后面的字段。token = strtok(NULL, ",\n")第二次及以后的调用如果传入 NULL,会从断点继续切,这是 C 语言指针教材里常考的状态保持设计。参数里filepath指向外部文件,这样测试时可以换不同数据文件,不用改代码重新编译。
注意:
strtok会修改传入字符串,所以你必须传可写的字符数组line,不能传字符串常量。这是 C 语言内存管理里最常见的越界写入来源之一。
保存文件与之对称,用fopen(filepath, "w")后逐条fprintf。要注意fopen成功与否一定要判断,否则文件目录不存在时程序会在下一次写入时静默丢数据。文件读写操作代码里,这句判断决定了你的系统是“能跑”还是“能抗错”。
3. 查询与管理的实现:线路检索、站点检索与最少换乘
3.1 按线路号查询全线的站点顺序
查询功能的交互通常是一个数字菜单:输入 1 按线路查询、输入 2 按站点查询、输入 3 查换乘方案、输入 4 管理线路、0 退出。按线路查询最简单,遍历线路链表,用strcmp比对route_id,命中的线路再遍历它的Stop链表。
void queryRoute(Route *route_list, const char *key) { for (Route *r = route_list; r != NULL; r = r->next) { if (strcmp(r->route_id, key) == 0) { printf("线路 %s: %s\n", r->route_id, r->name); int seq = 1; for (Stop *s = r->stop_head; s != NULL; s = s->next) { printf(" %d. %s\n", seq++, s->name); } return; } } printf("未找到线路 %s\n", key); }strcmp是 C 语言字符串函数里的高频函数,返回值 0 表示相等;key由用户输入,如果直接读入会带换行符,要用sscanf(line, "%s", key)先去掉\n,否则永远匹配不上。这里还有一个隐藏问题:如果线路号区分大小写,用strcasecmp会更好,但strcasecmp不是标准 C 库函数,跨平台时要自己判断。
3.2 按站点查询所有经过该站的线路
按站点查询是线路查询的倒置:遍历每一条线路的每一个站点,碰到同名站点就打印当前线路。这个功能看起来没有技术含量,却要留意一个问题——同一个站名在不同数据文件里可能出现“文化宫”和“文化宫东”这种互相包含的干扰,用strstr做模糊匹配容易把无关线路也匹配进去。课程设计里应该明确规则:默认完全相等,想要模糊匹配时再做二次确认。
void queryStation(Route *route_list, const char *station) { int hit = 0; for (Route *r = route_list; r != NULL; r = r->next) { for (Stop *s = r->stop_head; s != NULL; s = s->next) { if (strcmp(s->name, station) == 0) { printf("站点 %s 经过线路: %s (%s)\n", station, r->route_id, r->name); hit = 1; break; } } } if (!hit) printf("没有线路经过 %s\n", station); }代码里命中一条线路后立刻break,避免同一个线路上同名站点出现两次时重复打印。这个双循环是 O(线路数 × 每线站点数),站点规模 500 以下完全够用,比先建倒排索引更直观。如果你想让查询更快,可以另建一个站点名到线路编号列表的索引,但这会让新增线路时的维护成本升高,属于课程设计里的选做优化。
3.3 最少换乘:把 BFS 从教材写法变成可回溯路径
换乘查询是整个系统最核心的功能。最少换乘意味着不追求耗时最短,只追求少换车。在无权图里用广度优先搜索 BFS,天然保证第一次访问到终点时深度最小,这个深度就是经历过的乘坐段数,段数减一是换乘次数。不要在这里用深度优先,DFS 找到的第一条路径很可能不是最少换乘。
下面的代码假设已经通过buildGraph()把各线路相邻站点连成了邻接表。station_index是站点在全局数组里的下标;parent数组记录到达某站之前的那一站。BFS 结束后从终点沿parent回溯,再逆序输出就是完整路径。
int bfsMinTransfer(Station *stations, int station_num, int start, int target, int *path_out, int *transfer_count) { int queue[MAX_STATION_NUM]; int head = 0, tail = 0; int visited[MAX_STATION_NUM]; int parent[MAX_STATION_NUM]; int line_chosen[MAX_STATION_NUM]; memset(visited, 0, sizeof(visited)); for (int i = 0; i < MAX_STATION_NUM; i++) parent[i] = -1; visited[start] = 1; queue[tail++] = start; while (head < tail) { int cur = queue[head++]; if (cur == target) break; for (AdjEdge *e = stations[cur].neighbor_head; e != NULL; e = e->next) { if (!visited[e->to]) { visited[e->to] = 1; parent[e->to] = cur; line_chosen[e->to] = e->route_id_index; queue[tail++] = e->to; } } } if (!visited[target]) return 0; int stack[MAX_STATION_NUM], top = 0; for (int v = target; v != -1; v = parent[v]) stack[top++] = v; int legs = 0; for (int i = top - 1; i > 0; i--) { path_out[legs * 2] = stack[i]; path_out[legs * 2 + 1] = stack[i - 1]; legs++; } *transfer_count = legs - 1; if (*transfer_count < 0) *transfer_count = 0; return legs; }参数说明:stations是全局站点数组;start和target是站点编号;path_out是长度为 2 × 最大乘坐段数的输出数组,里面交替存相邻站点;*transfer_count最后保存换乘次数。memset清空visited,parent初始化为 -1,表示还没有路径。BFS 的队列是简单数组,出队时head++,入队时tail++,不用指针操作,既减少代码量也避免malloc的释放负担。line_chosen数组记录进入某站时乘坐的线路编号,方案输出时可以据此显示“在 B 站从 1 路换到 2 路”。
提示:BFS 队列数组如果开得太小,数据溢出会写坏相邻结构体,典型表现是程序在查询到一半时突然崩溃。建议把队列容量定义成站点数的两倍,或者入队时检查
tail < MAX_QUEUE。
3.4 管理功能:添加、删除线路与内存正确释放
管理端负责增删线路和修改线路名。删除线路的难点不在链表删除,而在内存释放:要把每个站点的Stop节点逐个free,再把Route节点自己free,同时还要从邻接表里删掉与这条线相关的边。很多人的代码在删除后再次查询时“运气好能跑”,但用valgrind一检查就是访问已释放内存。
| 管理功能 | 数据结构操作 | 必须做的内存处理 |
|---|---|---|
| 添加线路 | 在Route链表头插入新节点 | 为新节点和每个Stop分配空间 |
| 删除线路 | 从Route链表摘除节点 | 释放Stop链表全部节点,再释放Route |
| 修改线路名 | 覆盖r->name | 先检查字符串长度,避免越界 |
void removeRoute(Route **route_list, const char *route_id) { Route **p = route_list; while (*p != NULL && strcmp((*p)->route_id, route_id) != 0) p = &(*p)->next; if (*p == NULL) return; Route *del = *p; Stop *s = del->stop_head; while (s != NULL) { Stop *next = s->next; free(s); s = next; } *p = del->next; free(del); printf("线路 %s 已删除\n", route_id); }Route **p是二级指针,作用是直接修改链表头指针;遍历时p = &(*p)->next保存上一个节点的next地址,这样删除操作不需要单独处理头节点情况,是比 dummy 头节点更轻量的链表删除写法。释放Stop时先存next再free,否则下一步会访问已释放的指针,这是 C 语言指针课里最经典的悬空指针问题。菜单循环部分,用while (1) + switch就够,但一定不要用裸scanf("%d", &opt)去读菜单,因为用户输入“3 回车”之后残留的换行符会被下一个scanf("%c")当成有效输入。正确做法是fgets(buf, sizeof(buf), stdin); sscanf(buf, "%d", &opt);,让sscanf从字符串里读出整数。
4. 编译、验证与排错:从 VSCode 到命令行的收尾
4.1 用 VSCode 配置 C 语言环境并快速编译
这个项目对编译器没有强依赖,GCC 和 MSVC 都能跑,但推荐在 VSCode 里使用 C/C++ 扩展加 MinGW-w64,或者直接用 Linux 自带的 GCC。VSCode 配置 C 语言环境的关键是tasks.json不要写死在单个源文件上,用${workspaceFolder}/*.c配合 gcc 一次性编译,后面新增文件不用改配置。
{ "version": "2.0.0", "tasks": [ { "label": "build bus system", "type": "shell", "command": "gcc", "args": [ "-Wall", "-g", "${workspaceFolder}/*.c", "-o", "${workspaceFolder}/bus_system" ], "group": "build" } ] }-Wall开启所有常见警告,-g生成调试信息,方便在断点里查parent数组。命令行手动验证时,在项目根目录执行gcc -Wall -g main.c bus.c fileio.c -o bus_system也可以。args里的通配符只匹配一层目录,如果项目结构拆成src/和include/,你需要额外加-I参数。编译过程里最常见的告警是“隐式声明函数”,根源往往是忘写函数原型,把所有extern声明集中放一个bus.h里,能一次消掉大半告警。
4.2 用一份最小测试数据验证三件事
无论代码写得多散,运行后第一步永远是用已知答案的样例验证功能正确性。下面是一份可以被程序直接读取的最小数据:
B1, 1路, A, B, C B2, 2路, D, B, E B3, 3路, C, F对应的三个测试是:查询线路 B2 应输出 D-B-E;查询站点 B 应显示 1 路和 2 路;查询从 A 到 F 的最少换乘方案应该是 A-B-C-F,在 B 从 1 路下车换 2 路坐一站到 C,再换 3 路到 F。输出格式我建议统一成:
方案: A -> B (1路) B -> C (2路) C -> F (3路) 换乘次数: 2如果程序输出一次换乘甚至报“不可达”,优先怀疑buildGraph里是否给双向边各插入了一个邻接表节点。单向边会让从 B 到 C 有路、从 C 到 B 没路,换乘结果对方向敏感。还要检查line_chosen数组是不是在 BFS 出队时才赋值,那样会出现部分站点记录的是上一段的线路号,最终输出错误车次。
4.3 内存与指针的排错清单
这个题目失分最重的不是算法,而是运行时崩溃和内存泄漏。把常见问题列一张表,每一条都可以直接对着查:
| 症状 | 根因 | 修复方向 |
|---|---|---|
| 菜单输入一次后跳两次 | scanf 残留换行符 | 改用 fgets + sscanf |
| 查询“未找到”但数据存在 | 线路号带换行或空格 | 读入后统一去空白 |
| 程序退出时卡死 | 释放后重新访问对象 | 删除后置空外部指针 |
| 文件打开失败 | 工作目录不对 | 检查 perror 输出 |
| BFS 结果错误 | 队列溢出或建图缺少双向边 | 加大队列并检查 buildGraph |
在 Windows 上运行 main 返回前如果看到“Stack around variable was corrupted”,多半是局部数组越界,比如queue下标写到了MAX_STATION_NUM之外。把queue改为动态malloc并将上限设为station_num * 2,退出前free,能同时缓解栈碎片问题。如果非要选出整段代码里最容易错的位置,我会说是line_chosen:它只在发现未访问节点时赋值,一旦图里有环,后到达的节点可能覆盖之前记录的线路,导致回溯时报出错误车次。针对环的安全做法是只在第一次访问节点时写入,并在回溯时用访问顺序编号辅助判断。
5. 换乘算法进阶与健壮性技巧
5.1 从最少换乘升级到最短时间路径
BFS 解决不了边权不等的场景。如果线路数据里加入了“A 站到 B 站行驶 5 分钟”,最少换乘方案可能绕远,而用户更关心的往往是总共多久。这时把无权图改为带权图,用 Dijkstra 算法求最短时间:
int dist[MAX_STATION_NUM]; int used[MAX_STATION_NUM]; dist[start] = 0; for (int step = 0; step < station_num; step++) { int u = -1; for (int i = 0; i < station_num; i++) { if (!used[i] && (u == -1 || dist[i] < dist[u])) u = i; } if (u == -1) break; used[u] = 1; for (AdjEdge *e = stations[u].neighbor_head; e != NULL; e = e->next) { if (dist[u] + e->min_time < dist[e->to]) { dist[e->to] = dist[u] + e->min_time; } } }这版 O(n^2) 的 Dijkstra 在小数据量题目里足够。站点数上千后,把找最小距离节点的循环换成小顶堆,复杂度会降到 O((n+e)log n)。别忘记把换乘等待时间也作为边权加进min_time,否则算法会认为换乘是瞬时完成的。最少换乘和最短时间两套逻辑可以同时保留,菜单里让用户选一个查询维度。
5.2 校验数据完整性的两个小技巧
课程设计验收前,用脚本校验数据文件是性价比最高的一步。写一个简单的 C 程序或直接用 grep 检查同名站点是否被两条线路同时使用,可以暴露输入错误。也可以用下面这段命令在不同测试文件上跑回归:
./bus_system < test1_in.txt > test1_out.txt diff test1_out.txt test1_expected.txt && echo PASSdiff返回 0 时输出 PASS,适合批量验证。最后一个技巧是给文件读入模块增加返回值,用枚举区分“文件不存在”“格式错误”“数据正常”,这样管理端调用loadRoutes时能第一时间发现数据文件写坏了,而不是等查询时得到空结果。把这条防御性逻辑加上,管理端遇到坏文件会直接报警,程序也不会用脏数据继续运行。
本文还有配套的精品资源,点击获取