尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python公交换乘系统:Dijkstra算法与PyQt5可视化实战

Python公交换乘系统:Dijkstra算法与PyQt5可视化实战 简介这是一份基于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_namesheet_name, dtype{线路号: str, 站点序号: int}, ) df[站点名称] df[站点名称].astype(str).str.strip() df df.dropna(subset[经度, 纬度]) df df.sort_values([线路号, 方向, 站点序号]).reset_index(dropTrue) 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(indexFalse): 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.pyUI 相关主窗口与结果窗口逻辑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) 存每个站点作为 keyvalue 是 (邻居站点, 站间耗时, 线路号) 的列表。存线路号字段很关键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_penalty4.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_searchclicked 信号在鼠标点击时触发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_startQLineEdit输入起点站edit_endQLineEdit输入终点站btn_searchQPushButton触发换乘计算table_resultQTableWidget显示经过站点和线路label_costQLabel显示预计总用时实际调试时发现QTableWidget 在数据量较大时刷新会卡顿但换乘结果的站点数一般不超过 50 行所以这个项目没有性能压力。如果你要改成几万站的线路图建议把表格换成 QTableView Model并开启 setUniformRowHeights(True)否则滚动会明显掉帧。4.5 常见 PyQt5 启动问题没有把项目根目录加入 sys.path 时import ui_main 会直接 ModuleNotFoundError。在使用 pyinstaller 打包后.ui 文件不会被自动打进包里所以更推荐直接用 pyuic 生成的 .py 文件。另外如果你的环境同时有 Python 3.8 和 3.10pyc 缓存路径会区分版本删除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, s4, colorsteelblue) for site, edges in graph.items(): for neighbor, _, _ in edges: lon1, lat1 coords[site] lon2, lat2 coords[neighbor] ax.plot([lon1, lon2], [lat1, lat2], colorlightgray, linewidth0.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[经度], errorscoerce) 是最快的兜底方案。实际调试时把换乘惩罚从 4 分钟改成 6 分钟连续三天高峰期数据回归后输出路径才接近人工脑中的经验路线。本文还有配套的精品资源点击获取
返回列表