简介:这是一份基于Python实现的公交换乘查询系统完整源码,主要面向Python初学者、算法爱好者及需要课程设计或工程实践参考的开发者。系统涵盖公交线路管理、站点信息展示、最优路径规划与GUI交互等模块,核心采用图与邻接表组织线路数据,并应用Dijkstra、A*等经典路径搜索算法;同时涉及Excel/CSV数据读取、地理坐标处理及异常捕获等内容。压缩包共62个文件,体积约2.24MB,核心为18个.py脚本及编译缓存pyc,另含.ui界面、PNG运行截图、Excel/CSV公交数据集、drawio算法图示、ipynb调试笔记及Git配置等,便于按模块拆解学习。已有1110人学习下载,源码可直接运行或改造,适合用于公交/地铁换乘查询演示、图算法练手,以及理解Python桌面应用从数据到界面的完整开发流程。
1. 为什么 Python 公交换乘系统要用图而不是一张大表
公交换乘查询,第一反应是查表:拿起点的站名,找出经过它的所有线路;再拿终点的站名,找经过它的所有线路;两个集合一交,不就有直达或者一次换乘了?真把几千条线路放进 Excel 后会发现问题没这么简单。一次换乘的线路组合数量是乘法级增长的,两次以上换乘时,纯集合运算很难控制代价排序,更没法回答“哪条路更快”。这份源码的做法更接近地图软件:把每个站当成节点,同一条线路的相邻站之间连一条边,边权是运行时间或换乘惩罚,然后用 Dijkstra 求最小代价路径。
包里除了核心算法,还有 PyQt5 写的查询界面、Excel 表格作为数据源、Matplotlib 画的线路图,以及一堆 drawio 流程图说明模块关系。对 python 入门不久的开发者来说,这是一个数据读取、图算法、GUI 三合一的完整样本;对已经写过搜索算法的工程师来说,更值得看的是它如何把线路方向、换乘等待、缺失站点这些脏问题折算成边权。接下来按数据层、算法层、界面层、可视化四部分拆开。
2. Excel 线路表到邻接表:数据层先决定算法能不能跑
公交.xlsx 和测试.xlsx 是原始数据源,ExcelReader.py 和 ReadExcel.py 负责读取。直接用 pandas 而不是 openpyxl 一行行遍历,是因为要处理几千行数据、多个方向、缺失站点,pandas 的向量化操作能省掉大量循环。读完的数据还需要做一次站名清洗,否则“人民广场 ”和“人民广场”会被当成两个站点,后面建图时就会出现孤立节点。
2.1 线路表结构设计与读取
表格里至少要有的字段是:线路号、方向(正向/反向)、站点序号、站点名称、经度、纬度、站间耗时。因为公交运营有上下行,包里特意分出了正向.csv 和反向.csv;这两个 CSV 可以理解为 ExcelReader 清洗后的中间缓存。启动时不必每次都重新解析 Excel,直接读 CSV 会更稳定,也方便用 diff 工具检查数据变更。
import pandas as pd def load_routes(xlsx_path: str, sheet_name: str = "线路站点") -> pd.DataFrame: df = pd.read_excel( xlsx_path, sheet_name=sheet_name, dtype={"线路号": str, "站点序号": int}, ) df["站点名称"] = df["站点名称"].astype(str).str.strip() df = df.dropna(subset=["经度", "纬度"]) df = df.sort_values(["线路号", "方向", "站点序号"]).reset_index(drop=True) return df上面这段做了四件事。dtype 把 线路号 强制转成字符串,避免“002”被 Excel 读成数字 2;站点名称清掉首尾空格;经纬度缺失的行直接丢掉,因为缺失坐标没法画图也没法算距离;最后按线路、方向、站序排序,为后面构造边提供稳定顺序。实际项目中如果发现某条线路在图中出现“跳站”现象,先检查 站点序号 是否连续,而不是怀疑算法。
2.2 正向/反向索引与站点-线路映射
换乘算法真正依赖的数据结构不是线路表本身,而是两个索引:一个是从站点查出经过它的所有线路,另一个是从线路查出它按顺序经过的所有站点。前者用于快速判断“站 A 能到哪几路”,后者用于生成邻接表。ReadExcel.py 和 ExcelReader.py 的返回值基本都是这两种结构。
from collections import defaultdict def build_index(routes: pd.DataFrame): site_to_lines = defaultdict(set) line_to_sites = defaultdict(list) for row in routes.itertuples(index=False): line = row.线路号 site = row.站点名称 site_to_lines[site].add(line) line_to_sites[line].append(site) return site_to_lines, line_to_sites这里使用 defaultdict(set) 而不是普通 dict,省去了判断 key 是否存在的代码;线路号统一为字符串,所以“2路”和“02路”不会因为类型不同变成两条线。如果你拿到的原始数据里线路号是数字,建议统一转字符串并补零到固定长度,否则“3路”和“13路”排序时也会出问题。
2.3 文件清单与职责对照
项目里文件很多,除了源码,还有大量 .drawio 和 .png,这些是架构图,不参与运行,但能帮助快速定位模块。源码包没有 README 时,我习惯用“文件名前缀 + pyc 对应的模块引用”来推断职责。下面是一张按命名归纳的对照表:
| 文件/目录 | 判断依据 | 实际作用 |
|---|---|---|
| ExcelReader.py / ReadExcel.py | 名字含 Excel | 读取公交.xlsx、测试.xlsx 并清洗 |
| 正向.csv / 反向.csv | 上下行拆分 | 线路方向索引的中间缓存 |
| Calculator.py | 名字含 Calculator | 构造图,执行 Dijkstra 换乘计算 |
| MapCreater.py / OneRoadMapCreater.py | 名字含 Map | 画全部线路和单条路径高亮 |
| MainWind.py / WorkingWind.ui / ui_workingwind.py | UI 相关 | 主窗口与结果窗口逻辑 |
| InputCtrl.py | 名字含 Input | 输入合法性校验 |
| Bus_Transfer_System-main | 顶层目录 | 版本管理根目录 |
pycache里有 cpython-38、cpython-39、cpython-310 的 pyc 文件,说明这个项目被多个 Python 版本跑过。如果你在重构时遇到某些第三方库在 3.10 下行为不一致,可以优先怀疑是缓存导致的旧版本残留,删掉pycache后再跑。
2.4 数据层常见坑
第一个坑是站名带全角空格或不可见字符,strip 只能清首尾,中间的全角空格需要 replace(' ', '')。第二个坑是环线线路正向和反向站点列表完全一样,直接用 groupby 会把同一条环线当成两条不同线路,建图时需要在方向字段加入是否为环线的标记。第三个坑是站间耗时缺失,有的 Excel 只给站点坐标,没有每站耗时。这时候要么用经纬度估算,要么给一个默认值 1 分钟,并在算法层用换乘惩罚约束总代价。数据层准备得越干净,后面 Dijkstra 需要处理的边界情况就越少。
3. Dijkstra 换乘算法:代价函数才是最优解的关键
3.1 为什么选 Dijkstra 而不是 A* 或 BFS
换乘查询本质上是最短路径问题,但 BFS 只适合无权图。每站耗时不同,BFS 找出来的路径换乘次数可能最少,但总时间未必最短。A* 需要启发函数,公交网络中“下车站以后还要走多远”很难估算,启发函数设计不好反而会漏解。Dijkstra 虽然理论上更慢,但在这个项目只有几千个站点的规模下,Python 实现也不会超过几百毫秒,胜在稳定。
源码里的 Dijkstra.drawio 把流程画得很清楚:优先队列每次弹出当前代价最小的站点,然后松弛它的邻接边。Calculator.py 里大概率没有把所有边一次建完,而是读图的时候顺带维护邻接表,这样内存占用只有 O(E),而不用把整个二维矩阵展开。对于有向图和无向图的混用,工程项目里更倾向于无向边加上方向字段来约束,而不是跑两套逻辑。
3.2 邻接表构造与边权计算
图结构用 defaultdict(list) 存:每个站点作为 key,value 是 (邻居站点, 站间耗时, 线路号) 的列表。存线路号字段很关键,Dijkstra 走到一个站点时,需要知道自己当前坐在哪条线上,才能判断下一步是否发生换乘。
from collections import defaultdict def build_graph(routes: pd.DataFrame, default_minutes: int = 1): graph = defaultdict(list) for (line, direction), group in routes.groupby(["线路号", "方向"]): group = group.sort_values("站点序号") sites = group["站点名称"].tolist() times = group.get("站间耗时", pd.Series([default_minutes] * len(sites))) for i in range(len(sites) - 1): a, b = sites[i], sites[i + 1] cost = times.iloc[i] if i < len(times) else default_minutes graph[a].append((b, float(cost), line)) graph[b].append((a, float(cost), line)) return graph这段代码把同一线路相邻站建成一条无向边。公交线路虽然是双向的,但上下行站序可能不完全对称,所以 groupby 里带着方向字段,保证上行和下行不会被混在一起。cost 使用 float,是为了后续能加入换乘惩罚的小数权重。如果某条线路只有单程数据,直接留空方向字段,groupby 会把缺失值当成一个独立组,不会影响其他线路。
3.3 带线路标记的 Dijkstra 实现
很多最短路径示例只返回“经过哪些站”,这个查询系统还需要知道“每一段坐的是哪路车”,因此堆里的元素需要多存一个当前线路号。prev 字典也要同时记录前驱站点和到达前驱所用的线路,否则回溯时无法还原换乘信息。
import heapq def dijkstra_with_transfer(graph, start, end, transfer_penalty=4.0): heap = [(0.0, start, None)] dist = {start: 0.0} prev = {start: (None, None)} # 站点 -> (前驱站点, 线路号) while heap: cost, site, line = heapq.heappop(heap) if site == end: break if cost > dist.get(site, float("inf")): continue for neighbor, base_cost, next_line in graph[site]: penalty = 0.0 if next_line == line else transfer_penalty new_cost = cost + base_cost + penalty if new_cost < dist.get(neighbor, float("inf")): dist[neighbor] = new_cost prev[neighbor] = (site, next_line) heapq.heappush(heap, (new_cost, neighbor, next_line)) return dist.get(end), prev逻辑梳理:出堆时 line 表示“当前站点所在的线路”;如果 next_line 与 line 不同,说明要换乘,此时边权额外加上 transfer_penalty。这个惩罚值代表走到另一条线路的站台、等下一班车的平均时间,业务上通常设为 3 到 6 分钟。如果惩罚为 0,算法会倾向于频繁换乘来减少总运行时间,结果往往不符合人的直觉。prev 中保存的线路号是到达该站点的那条线,回溯时要用它来标记上一段路程。
3.4 路径回溯与参数调整
回溯逻辑不能只记录站点顺序,要在遇到线路变化时插入“换乘”节点。一个实用的做法是先把 prev 还原成 (站点, 线路) 列表,再扫描一遍列表,把线路变化的行单独挑出来。这里的一组参数可以作为调优起点:
| 参数 | 作用 | 合理范围 | 过大/过小的表现 |
|---|---|---|---|
| transfer_penalty | 换乘一次附加时间 | 2~6 分钟 | 过大倾向少换乘但绕路,过小会频繁换乘 |
| default_minutes | 缺省站间耗时 | 0.5~2 分钟 | 过小导致路径距离失真 |
| 是否双向建边 | 控制单向/双向 | 默认双向 | 单向漏边会直接无解 |
验证时不要只看总耗时,要把整条路径打印出来,逐行检查换乘点是否真的在两条线路的共同站点上。源码里的测试.xlsx 应该就是配合这个步骤用的:读取每个测试用例,跑完 Dijkstra 后再和人工规划的路线对比。如果发现某条线路被算法反复使用,可能不是算法问题,而是 Excel 里该线路的站点顺序写反了。
4. PyQt5 界面与输入控制:从 main.ui 到可交付查询工具
4.1 为什么源码里同时存在 .ui 和 .py
项目里有 main.ui、WorkingWind.ui、InfoWind.ui,还有对应的 ui_main.py、ui_workingwind.py、ui_infowind.py。这是 Qt Designer 的典型工作流:用拖拽方式设计窗口,保存成 .ui XML 文件;再用 pyuic5 工具把 .ui 转成 Python 代码,生成 Ui_MainWindow 这样的界面类。MainWind.py 再继承这个界面类,把业务逻辑绑定到按钮和输入框上。
pyuic5 main.ui -o ui_main.py pyuic5 WorkingWind.ui -o ui_workingwind.py pyuic5 InfoWind.ui -o ui_infowind.py第一次接触 PyQt5 的人容易直接改生成的 ui_main.py,但 ui_main.py 每次重新生成都会被覆盖,所以正确做法是只把它当界面描述文件,业务逻辑一律放在 MainWind.py 中。如果你没有安装 pyuic5,可以用 python -m PyQt5.uic.pyuic main.ui -o ui_main.py 代替,效果一样。
4.2 信号槽将输入绑定到查询动作
InputCtrl.py 这个文件名可以看出它负责输入控制,比如限制起点和终点输入框不允许输入非法字符,或者用 QComboBox 自动补全站名,减少用户输入错误。主窗口的核心查询触发逻辑则放在 MainWind.py 中,下面是一个简化版:
from PyQt5 import QtWidgets from ui_main import Ui_MainWindow class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def __init__(self): super().__init__() self.setupUi(self) self.btn_search.clicked.connect(self.on_search) self.edit_start.returnPressed.connect(self.on_search) self.edit_end.returnPressed.connect(self.on_search) def on_search(self): start = self.edit_start.text().strip() end = self.edit_end.text().strip() if not start or not end: self.statusbar.showMessage("起点和终点不能为空") return cost, path = self.find_route(start, end) if cost == float("inf"): self.table_result.setRowCount(0) self.label_cost.setText("没有可到达的路线") return self.fill_table(path)关键点有三个。setupUi 会创建好所有控件,所以之后能直接用 self.btn_search;clicked 信号在鼠标点击时触发,returnPressed 在输入框里按回车时触发,两个信号都接到同一个槽函数;text().strip() 做了二次清洗,避免“ 人民广场 ”这种带空格的输入查不到站。输入为空时提前 return,可以避免后续算法层因 KeyError 崩溃。
4.3 结果表格的展示细节
结果用 QTableWidget 展示,列可以设计为“序号、站名、线路、操作”四列。其中线路列只显示当前正在乘坐的线路号,如果与上一行不同,就认为是换乘点,并插入一行浅色背景提示。
def fill_table(self, path): self.table_result.setRowCount(len(path)) for i, (site, line) in enumerate(path): self.table_result.setItem(i, 0, QtWidgets.QTableWidgetItem(str(i + 1))) self.table_result.setItem(i, 1, QtWidgets.QTableWidgetItem(site)) self.table_result.setItem(i, 2, QtWidgets.QTableWidgetItem(line or "-"))这里 path 是从 Dijkstra 回溯出来的 (站点, 线路) 列表。线路为 None 时显示为“-”,表示起点站还没有上车记录。换乘提示没有直接写进 setItem,而是建议在外部对 path 做一次扫描,把 line 值发生变化的行记录到一个换乘点列表,再统一设置背景色。例如“A站”乘 1 路到“B站”换 2 路,表格里 B 站这一行就应该被标成换乘站。
4.4 主窗口与子窗口的协作
包里还有 WorkingWind.ui 和 InfoWind.ui,说明程序不只有一个窗口。常见的设计是:主窗口负责查询,WorkingWind 显示正在计算的进度或详细路径,InfoWind 展示站点信息和线路介绍。MainWind.py 中一般用 self.child = WorkingWind() 然后 self.child.show() 来打开子窗口。注意子窗口对象要保存为成员变量,否则 Python 垃圾回收会直接把它销毁,窗口一闪就消失。
下表整理了从界面文件推断出的控件分工:
| 控件 | 类型 | 作用 |
|---|---|---|
| edit_start | QLineEdit | 输入起点站 |
| edit_end | QLineEdit | 输入终点站 |
| btn_search | QPushButton | 触发换乘计算 |
| table_result | QTableWidget | 显示经过站点和线路 |
| label_cost | QLabel | 显示预计总用时 |
实际调试时发现,QTableWidget 在数据量较大时刷新会卡顿,但换乘结果的站点数一般不超过 50 行,所以这个项目没有性能压力。如果你要改成几万站的线路图,建议把表格换成 QTableView + Model,并开启 setUniformRowHeights(True),否则滚动会明显掉帧。
4.5 常见 PyQt5 启动问题
没有把项目根目录加入 sys.path 时,import ui_main 会直接 ModuleNotFoundError。在使用 pyinstaller 打包后,.ui 文件不会被自动打进包里,所以更推荐直接用 pyuic 生成的 .py 文件。另外,如果你的环境同时有 Python 3.8 和 3.10,pyc 缓存路径会区分版本,删除pycache后重新运行不一定有影响,但依赖的第三方库版本要统一,特别是 PyQt5 和 pandas 对不同 Python 小版本的兼容性差别很大。
5. 地图可视化与换乘结果落地的工程细节
5.1 从经纬度坐标到线路图
MapCreater.py 和 OneRoadMapCreater.py 都负责可视化,区别在于前者画整个公交网络,后者只画一条计算出来的换乘路线。用 Matplotlib 画地图不需要联网,直接把站点经纬度散点画出来,再把相邻站点用线段连接起来就够了。
def plot_full_network(coords, graph): fig, ax = plt.subplots(figsize=(10, 8)) for site, (lon, lat) in coords.items(): ax.scatter(lon, lat, s=4, color="steelblue") for site, edges in graph.items(): for neighbor, _, _ in edges: lon1, lat1 = coords[site] lon2, lat2 = coords[neighbor] ax.plot([lon1, lon2], [lat1, lat2], color="lightgray", linewidth=0.5) return ax这里 scatter 画站点,plot 画线路边。边数很多时,线宽要小于 1,颜色用浅灰,否则整张图会糊成一片。如果有几千个站,建议先用 set_visible(False) 关闭散点标签,等用户查询后只显示高亮路径,否则文字标注会把图完全遮住。
5.2 高亮换乘路径的验证技巧
换乘查询结果要直接画到地图上:先用 Dijkstra 回溯出站点序列,再从 coords 字典里取出对应的经纬度,最后用红色粗线覆盖在原图上。这一步能立刻看出算法是否在绕路,因为地图上的实际站点分布可以直接目测检查。调试时我一般会同时打印路径中的“起站、换乘站、终到站”三组数据,和地图上的高亮线段一一对照,确认换乘点确实在同一位置。
5.3 把结果做成能交给测试人员的成品
源码包里没有提到命令行工具,但你可以给 MainWind.py 加一个 --check 参数:启动时自动读取测试.xlsx,跑完全部起终点,输出总耗时和换乘次数到 CSV。这样不用打开界面就能做回归测试。正向.csv 和反向.csv 在这种场景下价值最大,把两者都跑一遍,可以验证上下行数据是否有遗漏。
最后要留意的坑是:不同 Excel 版本对经纬度列的识别可能不同,有的会把 121.47 读成文本,导致 scatter 的坐标轴变成字符串刻度。读取后加一行 df["经度"] = pd.to_numeric(df["经度"], errors="coerce") 是最快的兜底方案。实际调试时把换乘惩罚从 4 分钟改成 6 分钟,连续三天高峰期数据回归后,输出路径才接近人工脑中的经验路线。
本文还有配套的精品资源,点击获取