ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++与Qt QML实现地铁公交换乘系统:图建模与Dijkstra算法实战

C++与Qt QML实现地铁公交换乘系统:图建模与Dijkstra算法实战 简介面向高校数据结构课程设计与实训任务这份工程包提供基于Qt QML开发的地铁公交换乘系统Demo适合正在做课程设计、大作业或毕业设计的学生参考复刻。项目涵盖换乘线路规划、站点数据管理、换乘方案输出等核心模块可将课堂中的图结构、最短路径算法落到实际界面中代码结构清晰便于二次开发。压缩包共52个文件以cpp、h源码为主搭配qml界面文件、xml数据配置及ttf字体资源整体约30.49MB各类文件分工明确方便按需查阅。工程附有说明文档下载后可先阅读再运行既能直接复现演示效果也能替换数据或扩展站点流程用于其他交通场景。目前已有92人学习浏览对初学Qt与数据结构综合项目的人来说是份门槛适中的起步资料。1. 数据结构课程设计选“地铁公交换乘系统”时真正要交的是什么很多人的数据结构课程设计把题目选成地铁公交换乘系统因为这个题目天然包含完整链路站点和线路能练图存储最短路径能练 Dijkstra最后还能用 Qt QML 做一个看得见的界面。但课程设计/实训/大作业里真正拉开差距的往往不是界面多花哨而是“图怎么建、换乘代价怎么设、算法结果怎么送到前端”这三件事有没有讲清楚。下面按这条线写先用 C 把地铁公交网络建模成图并完成路径计算再通过 Qt QML 与 C 混合编程把结果做成可交互 demo最后补上验证、发布和排错时最常踩的几个坑。适合一个人独立完成也能撑住答辩时的追问。2. 把地铁公交网络建模成图邻接表与换乘代价设计换乘查询拆开看是一件事在带权图上求给定起点到终点的最优路径。直接拿线路表存数据也行但每次查询都要遍历全部站点和线路回答不了“跨线怎么走”的问题。把网络建成图之后最短时间、最少换乘这两个课设里最常见的查询都能统一成同一个算法问题。2.1 为什么用无向带权图而不是直接把线路存成表地铁和公交线路本质是站点之间的连通关系。相邻两个站之间有列车运行是同一条线路内的可达关系不同线路之间通过同一个物理站台换乘也是可达关系。这两种关系都可以抽象成边区别只是边上的代价不同。把线路存成关系表适合展示“这条线经过哪些站”但换乘查询问的是“从任意站到任意站怎么走”这是一个典型的最短路径问题图的邻接表恰好是最合适的存储形式。地铁、公交可以在一个模型里共存地铁站和公交站都是顶点站内换乘或步行可达的两个站之间加一条虚拟边。课设阶段把边近似为双向、耗时非负即可这样可以直接使用 Dijkstra。这里有个容易被问到的基础点为什么不需要像 A* 那样估计到终点距离因为站点规模通常只有几十到两三百个节点Dijkstra 朴素实现的复杂度足够可控而且它的结果是最优解答辩时更容易把原理讲清楚。顺带一提图的最短路径在数据结构高频核心知识点面试里几乎是必考内容做完这个课设相当于把邻接表、堆优化、路径回溯全部过了一遍。提示不要在一开始加入“上下行首末班时间”“拥挤度”这类运营细节。课设 demo 的第一版只保留两个代价维度——乘坐时间与换乘惩罚模型简洁答辩反而好讲。2.2 用邻接表存站点与边C 结构这样定义邻接矩阵和邻接表是这个课设里首先要做的一次选型。多数城市线网是稀疏图邻接表在存储和遍历上都有优势实现也不复杂。两种存储结构的取舍可以整理成一张表对比项邻接矩阵邻接表存储空间站点数平方站点数加边数判断两点是否相邻O(1)遍历邻接点平均 O(度数)遍历某点的全部邻接点扫一整行只扫邻接链适用场景稠密图稀疏图地铁线路网更贴近这种站点和边的 C 结构可以这样定义这也是后续 Dijkstra 直接使用的数据结构struct Station { int id; // 站点编号作为数组下标 QString name; // 站点名界面显示用 QVectorint lineIds; // 经过该站的线路集合用于判断换乘 }; struct Edge { int to; // 另一端站点编号 int time; // 乘坐耗时或换乘步行耗时单位分钟 int lineId; // 该边所属线路-1 表示换乘虚拟边 bool isTransfer; // true 表示这是一条换乘边 }; // 邻接表本体下标为起点站点编号 QVectorQVectorEdge graph;建图时同一条线路内相邻两站互相插入普通边同一个站点的不同线路之间插入一条 isTransfer 为 true 的虚拟边。虚拟边的 time 就是换乘消耗后面算法里还会叠加换乘惩罚参数。插入边时记得对称插入保证无向图的遍历不丢方向。2.3 最短路径与最少换乘Dijkstra 和换乘惩罚权重带权图求最短路课设里统一用 Dijkstra。标准做法是创建 dist 数组记录起点到各节点的最小总耗时parent 数组记录前驱节点用于回溯完整路径再反复从“未确定最优的节点”里选一个最短者做松弛。下面是去掉堆优化的直观版本便于答辩时对着代码讲原理QVectorint dijkstra(int start, int end) { const int INF 1e9; QVectorint dist(graph.size(), INF); QVectorint parent(graph.size(), -1); QVectorbool done(graph.size(), false); dist[start] 0; for (int cnt 0; cnt graph.size(); cnt) { int u -1; for (int i 0; i graph.size(); i) { if (!done[i] (u -1 || dist[i] dist[u])) u i; // 找出当前距离最小的未确定节点 } if (u -1 || u end) break; // 终点已确定直接结束 done[u] true; for (const Edge e : graph[u]) { int w e.time; if (e.isTransfer) w transferPenalty; // 换乘边额外加惩罚 if (dist[u] w dist[e.to]) { dist[e.to] dist[u] w; parent[e.to] u; // 记录前驱回溯路径用 } } } QVectorint path; for (int v end; v ! -1; v parent[v]) path.prepend(v); return path; }这段代码的核心是松弛条件dist[u] w 小于旧值时才更新。换乘惩罚在这里体现普通乘坐边只算 e.time换乘边额外叠加 transferPenalty。这个参数直接决定系统偏好“少换乘”还是“总时间短”。用一个简单例子说明假设从 S 到 T 有两套方案1 号线直达耗时 25 分钟另一条路线坐 2 号线 10 分钟、换乘 3 号线再坐 5 分钟总乘坐时间 15 分钟。若换乘惩罚为 0查询结果会选换乘方案若惩罚设成 10 分钟换乘方案变成 25 分钟与直达并发再大一点就直接选直达。路径方案乘坐时间换乘次数惩罚0惩罚101 号线直达25 分钟025 分钟25 分钟2 号线转 3 号线15 分钟115 分钟25 分钟所以“最少换乘优先”并不是另一个算法而是同一个 Dijkstra 配合一个偏大的惩罚值。需要注意的是换乘惩罚不是模拟“等车时间”而是告诉算法“换乘一次代价很高”。课设里建议设成 5 到 8 分钟既保留换乘省时间的路径又避免结果出现连续换乘的怪路线。如果想让界面同时提供“最快”“少换乘”两种模式只需把 transferPenalty 做成运行时参数查询时按按钮传不同值进去算法代码不用改。提示parent 回溯得到的路径是站点编号序列后续在 C 侧还要把连续同线路的站点合并成乘车段并在线路变化处标记为换乘点。这一步放在算法层而不是 QML 层做界面代码会干净很多。3. Qt QML 与 C 混合编程把算法结果送上前端demo 的界面层用 Qt QML 而不是 Widgets原因很直接QML 的声明式布局写界面快换乘提醒这类动效用动画组件就能做Canvas 也能画简化线网图。算法、图数据、站点名列表这类重计算的东西放在 C 侧通过 Qt 提供的交互机制暴露给 QML。这是 qml 与 c 混合编程的标准姿势也是课设答辩时的加分结构。3.1 为什么用 QML 做课设界面算法留在 CQML 适合做界面C 适合做数据与逻辑但很多课设写到最后变成两套代码互相看不懂。问题通常出在 QML 直接操作后端数据结构或者 C 进程式地把界面当成输出设备。这个课设只需要一条清晰的分界C 负责“回答查询”QML 负责“把答案画出来”。站点列表是 C 提供的查询结果是 C 算好的结构化数据QML 只做展示和交互。这样分工以后答辩时被问“用户输入怎么处理”“数据流怎么走”可以一路讲到底。QML 侧拿到的数据是类型化的。C 返回 QVariantList里面每一项是 QVariantMap到 QML 里自动变成 JavaScript 数组和普通对象。不要在这一层把结构拆散也不要让 QML 去解析逗号拼接的字符串否则后面做换乘高亮、动画都困难。数据通道设计得干净界面表现反而省事。3.2 暴露给 QML 的 RouteModelQ_PROPERTY 与 Q_INVOKABLE在 C 侧先写一个 RouteModel 类继承 QObject集中暴露查询接口和状态属性。下面是头文件的骨架class RouteModel : public QObject { Q_OBJECT Q_PROPERTY(QString statusText READ statusText NOTIFY statusChanged) public: explicit RouteModel(QObject *parent nullptr); Q_INVOKABLE QVariantList stationNames(); // 站点名列表 Q_INVOKABLE QVariantList query(int fromId, int toId); // 查询换乘路线 signals: void routeChanged(); void statusChanged(); };这段代码里 Q_PROPERTY 把 statusText 暴露成 QML 可读属性QML 里可以直接显示“查询完成”“无此路线”这类状态文本Q_INVOKABLE 让 QML 可以把方法当成普通函数调用。query 的返回类型是 QVariantListQML 调用时直接拿数组用不需要再把结果转成字符串。实现 query 时内部先调 dijkstra再把站点序列合并成换乘步骤。用一个辅助方法把“第几站上车、坐几号线、到哪站下车、这里是否换乘”装进 QVariantMapQVariantList RouteModel::query(int fromId, int toId) { QVariantList steps; QVectorint path dijkstra(fromId, toId); if (path.size() 2) return steps; int i 0; while (i 1 path.size()) { int line lineOfEdge(path[i], path[i 1]); // 当前乘坐线路 int j i; while (j 1 path.size() lineOfEdge(path[j], path[j 1]) line) j; // 连续同线路的站点合并为一段 QVariantMap seg; seg[from] stations[path[i]].name; seg[to] stations[path[j]].name; seg[line] line; seg[stops] j - i; // 这一段经过的站数 steps.append(seg); i j; if (i 1 path.size()) { // 还没到终点说明要换乘 QVariantMap trans; trans[name] stations[path[i]].name; trans[line] line; trans[isTransfer] true; // 界面高亮和动画都靠这个字段 steps.append(trans); } } emit routeChanged(); return steps; }这段合并逻辑的核心是 lineOfEdge它根据边两端站点和所属线路判断当前坐的是哪条线。连续几段都同线就归成一个乘车段线路一变就插入换乘记录。stops 字段类似提示“乘坐 2 号线经过 4 站到人民广场换乘 3 号线”。这样一个查询结果就是一条干净的乘车路线而不是一长串站点。3.3 qmlRegisterType 与 setContextProperty两种接线方式C 模型定义好之后要把 RouteModel 送进 QML 运行时。常见做法有两种main.cpp 里分别是这样写int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); // 方式一注册成 QML 类型页面里自行创建实例 qmlRegisterTypeRouteModel(MetroModel, 1, 0, RouteModel); QQmlApplicationEngine engine; engine.loadFromModule(MetroApp, Main); return app.exec(); }方式二是把对象直接挂到根上下文QQmlApplicationEngine engine; RouteModel routeModel; engine.rootContext()-setContextProperty(routeModel, routeModel); engine.loadFromModule(MetroApp, Main);两种方式各有取舍课设里建议优先用 qmlRegisterType接线方式特点适用场景qmlRegisterTypeQML 里可以创建多个实例生命周期由 QML 管理想在一个界面里复用多个模型时setContextProperty全局对象QML 任意位置直接用只有一个模型想少写几行 import 时用 setContextProperty 要特别注意生命周期routeModel 必须比 engine 活得更久。main 函数结束时先销毁 engine再销毁 routeModel顺序反过来很可能在退出时出现 qt 崩溃。qmlRegisterType 方式下模型实例在 QML 里创建生命周期跟着 QML 引擎走少一类问题。无论是哪种方式QML 里调用方法时传入的站点 id建议直接用 ComboBox 的 currentIndex 对应当前选中项少做一层名称到 id 的换算。4. 实现地铁公交换乘查询界面站点选择、路径展示与换乘提醒动画界面不需要一开始就做完整。这个课设的最小可用界面是三个控件两个下拉框选起点终点一个按钮触发查询下面用列表显示乘车步骤。先把这条路打通再往上加动画和效果。4.1 主界面布局ComboBox 选站、Button 查询、ListView 出结果QML 主界面的结构可以用 Column 纵向排列查询区和结果区上下分层。一个能直接运行的骨架import QtQuick 2.15 import QtQuick.Controls 2.15 import MetroModel 1.0 ApplicationWindow { width: 480; height: 720; visible: true title: 地铁公交换乘系统 RouteModel { id: model } // QML 侧创建的模型实例 Column { anchors.fill: parent padding: 16 spacing: 12 Row { spacing: 8 ComboBox { id: fromBox; model: model.stationNames() } ComboBox { id: toBox; model: model.stationNames() } } Button { text: 查询路线 onClicked: { resultView.model model.query(fromBox.currentIndex, toBox.currentIndex); } } ListView { id: resultView width: parent.width height: parent.height - 120 clip: true delegate: resultDelegate } } }这段代码可以直接跑起来看效果几个关键点值得说明。ComboBox 的 model 来自 C 暴露的 stationNames()它返回的是字符串数组下拉框会显示站名。Button 的 onClicked 里调用 model.query返回值赋给 ListView 的 modelListView 会自动重建每个 delegate不需要手动刷新列表。这里把返回数组直接当 model 用是 QML 里处理小规模结果最简单的方式如果结果集很大后续再换成 ListModel 做增量追加。另外可以把每个 QML 控件在这个系统里的职责概括成一张对照表答辩时按这张表讲结构。控件在这个系统里的用途ComboBox起点与终点选择数据来自 C 方法Button触发查询把输入传给 RouteModel.queryListView delegate渲染乘车段与换乘提醒数据绑定自动刷新Canvas可选画拓扑线网图绘制线路折线与换乘点4.2 路径展示区分“乘坐”和“换乘”两种步骤查询结果里有两类条目普通乘车段和换乘提示。数据在 C 端已经区分好了QML 的 delegate 只需要按字段渲染。给 ListView 配一个 delegate里面对 isTransfer 字段做判断Component { id: resultDelegate Rectangle { required property var modelData width: parent.width height: modelData.isTransfer ? 52 : 40 color: modelData.isTransfer ? #FFF3E0 : transparent radius: 6 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: modelData.isTransfer ? 换乘在 modelData.name 转 modelData.line 号线 : modelData.from 上车 → modelData.to 下车 modelData.stops 站乘坐 modelData.line 号线 } } }这里用 modelData 读取每个数组元素isTransfer 决定换乘条目的背景色和文案。普通乘车段显示起止站点、经过站数和线路号换乘条目只显示站点名和要转到的线路。这样视觉上换乘点一目了然答辩时可以指着界面讲“换乘点是从数据里带出来的不是写死的”。实际跑通这个骨架后会发现换乘条目的可读性比预想的重要。演示时最好让用户一眼看出“在哪里换、换几号线”所以换乘条目建议统一用暖色背景并加粗线路号。这些视觉规则在 QML 里只改一处 delegate 就能全局生效。4.3 换乘点高亮与动画提示换乘提醒是演示环节最容易出效果的地方正好用到 qml 动画。先别急着做复杂转场一个最实用的动效是“换乘条目出现时轻微闪烁提示”。换乘 Rectangle 在显示时播放一次透明度动画Rectangle { id: transferItem color: #FFF3E0 opacity: 1.0 onVisibleChanged: { if (visible) noticeAnimation.restart(); } NumberAnimation { id: noticeAnimation target: transferItem property: opacity from: 0.2; to: 1.0 duration: 500 } }onVisibleChanged 保证每次换乘条目进入视图时才触发动画避免列表一直闪烁。duration 500 毫秒是提示类动画比较舒服的节奏太快没存在感太慢拖累查询体验。如果希望更明显可以把 from 改成 0.0让条目整体淡入。提示课设界面不用刻意追求复杂动效。换乘高亮、淡入提示、按钮按下反馈这三级效果已经足够撑起演示观感也比复杂的路径绘制动画更容易在答辩现场讲清楚。如果想再进一步可以用 Canvas 画一张简化线网拓扑图把站点画成圆点、线路画成折线查询结果用不同颜色描出路径。这项加分工作不需要接入网络地图服务QML Canvas 绘制折线、圆点、文字都内置支持逻辑也不复杂把站点坐标存在 C 侧通过 Q_INVOKABLE 方法暴露给 QML。5. 验证算法、发布 demo 与排错的三个实用技巧课设交出去之前有三个步骤不能省算法结果要对得上手工计算程序换台机器要能跑起来换乘信息要能一眼看明白。5.1 用手工算例验证 Dijkstra 的结果拿一个不超过 10 站的简化线网把每条边的耗时标在纸上手工算一遍最短路径。在 C 的 query 开头加一行日志输出每个站点第一次确定最优值时的时间for (int v : path) { qDebug() stations[v].name dist[v]; }把日志和手算结果逐行对比。常见的不一致原因有三个换乘边重复插入导致出现两条完全相同的边无向边只插入了一侧parent 在松弛失败时被错误更新。这三种问题靠读代码很难一眼发现手工对拍反而最快。答辩时能拿出这张手写算例和程序输出对照比任何口头说明都有说服力。5.2 发布时带走 QML 模块qml 编译错误与平台插件缺失课程设计经常换机器演示发布后的程序最容易在“别人电脑上”出问题。表现形式通常是窗口白屏、控制台报 qml 模块导入失败或者提示找不到平台插件。若运行时出现 qt_qpa_platform_plugin_path 相关提示说明 plugins 下的 platforms 目录没有跟随程序一起放置。Windows 下发布 demo最省事的是用 Qt 自带的 windeployqtwindeployqt --qmldir . --release release/MetroDemo.exe--qmldir 参数指定项目 QML 源码目录工具会扫描 import 语句把 QtQuick、QtQuick.Controls 等模块依赖一并拷出。漏掉这个参数最常见的 qml 编译错误就是“module QtQuick.Controls is not installed”原因并不是没安装而是发布目录里缺少对应的 QML 模块文件。发布之后把整个 release 目录压成一个 zip就是课程设计可以直接提交的交付物。5.3 让 isTransfer 成为演示效果的支点路径搜索结果里保留 isTransfer 字段界面层的换乘高亮、动画提示、线路颜色全部以它为准。演示时准备一组对比参数把换乘惩罚从 0 调到 8 再调到大值观察换乘点位置的变化把两组结果并排截进实验报告。这样图建模、Dijkstra、QML 交互就串成了一条能从头讲到尾的逻辑链isTransfer 字段是这条链上界面层和数据层的连接点也是答辩追问时最容易说明白的地方。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进