
简介基于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 个 intn 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清空visitedparent初始化为 -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((ne)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时能第一时间发现数据文件写坏了而不是等查询时得到空结果。把这条防御性逻辑加上管理端遇到坏文件会直接报警程序也不会用脏数据继续运行。本文还有配套的精品资源点击获取