基于Qt QML的地铁换乘系统:数据结构与Dijkstra算法实践 📅 发布时间:2026/9/13 16:29:46 👁 浏览次数: 简介这是一份面向数据结构课程设计、实训与大作业场景的地铁公交换乘系统Demo基于Qt QML开发核心解决换乘线路规划、站点数据组织与可视化交互等问题适合高校学生用于课程项目或初期立项参考。资源包共52个文件压缩后约30.49MB主要包含C源码cpp/h、QML界面描述、XML站点与线路数据、字体资源及工程配置文件源码结构相对完整便于按模块阅读和二次开发。目前已有92人学习/下载。项目代码经过测试运行功能可正常复现内部附有说明文档与目录结构梳理可以帮助使用者快速搭建起相同的地铁换乘演示程序即使基础一般也可在现成算法框架上修改数据或界面扩展到公交/多模式换乘等更多功能。该资源对课程设计报告撰写也有参考价值适合需要快速落地演示项目的学习者。1. 为什么一个换乘 demo 值得拆开看一条地铁线停运 20 分钟换乘通道从进站到上站台要走 7 分钟——这些在导航软件里都是“默认规则”但在数据结构课程设计里它们需要被建模成数据结构里的权值和状态。这个基于 Qt QML 和 C 写的地铁公交换乘系统 demo核心只做一件事给定站点库和线路库的 XML 数据规划出从 A 到 B 的换乘方案并把它画在可交互的路网上。它把图论、Qt 的 Model/View 体系、QML 的 Canvas 绘制三个知识块串到了一起正好是数据结构课设里头比较少见、写出来比较能打的题目。适合正在找课设方向的学生也适合想快速上手 QML 与 C 混合编程、看看数据如何从 XML 一路流到界面的开发者。2. 数据层先站稳DStation、DLine 与 XML 解析的工程化处理这个项目里所有算法和界面都建立在站点、线路两类数据之上先把这两块数据模型弄干净后面整个项目都不会乱。资源包里的 dstation.h、dline.h、dtransmap.h 正好划分了这条边界。2.1 站点和线路结构体怎么设计才不返工2.1.1 站点实体id、name、pos、lineIds从资源包的 dstation.h 可以看到站点的实体字段非常精简但每个字段都是为后面算法准备的// dstation.h #pragma once #include QString #include QPointF #include QList // 地铁/公交站点实体 class DStation { public: int id -1; // 全局唯一站点编号 QString name; // 站点名如 西直门 QPointF pos; // 画布坐标QML 绘制时直接用 QListint lineIds; // 经过该站的线路 id 列表 };这里有几个选择需要解释。pos 不用两个 int 而用 QPointF是因为 Canvas 绘制时坐标可能带有小数QPointF 省得每次绘制都做类型转换lineIds 用 QList 而不是 QSet是因为后面要按固定顺序遍历QSet 的遍历顺序不保证。id 从 1 开始、-1 表示无效这个约定会在 Dijkstra 前驱回溯时用到判断路径是否完整时就看 prev 数组里有没有 -1。2.1.2 线路实体把绘制颜色直接放进数据模型dline.h 里对线路的建模也值得注意// dline.h #pragma once #include QString #include QVector class DLine { public: int id -1; // 线路 id如 1 QString name; // 显示名如 1号线 QString color; // #e3002a 格式的颜色字符串 QVectorint stationIds; // 按运行方向排序的站点 id 序列 };color 用字符串而不是 QColor是因为这个字段最终要交给 QML Canvas 的 strokeStyle。虽然 QColor 也能从 C 传到 QML但字符串十六进制值可以直接赋值中间不用再转。stationIds 的顺序即线路真实走向构建邻接表时按相邻关系成对加边如果这里少了站或者顺序乱了换乘方案会直接跑偏。2.2 QXmlStreamReader 顺序解析站库与线路库的读取方式资源包里有 stadb.xml、db0.xml、db1.xml、transdb.xml 几个数据文件站点和线路的实体就存在里面。读取时我一般用 QXmlStreamReader 顺序解析不用 QDomDocument。原因在规模站库到几千个节点时QDomDocument 要把整棵树先建到内存里再查询内存会多占好几倍QXmlStreamReader 是流式的读完一个节点就能丢内存占用基本恒定。// dchkdata.cpp 解析骨骼片段 bool DTransMap::load(const QString filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() cannot open filePath; return false; } QXmlStreamReader xml(file); while (!xml.atEnd() !xml.hasError()) { xml.readNext(); if (xml.isStartElement() xml.name() QLatin1String(station)) { QXmlStreamAttributes attr xml.attributes(); DStation s; s.id attr.value(id).toInt(); s.name attr.value(name).toString(); s.pos QPointF(attr.value(x).toDouble(), attr.value(y).toDouble()); // 线路列表存成 “1,2,5” 这样的逗号分隔属性 const auto lineParts attr.value(lines).split(,); for (const auto lp : lineParts) { s.lineIds.append(lp.toInt()); } m_stations.insert(s.id, s); } else if (xml.isStartElement() xml.name() QLatin1String(line)) { DLine l; QXmlStreamAttributes attr xml.attributes(); l.id attr.value(id).toInt(); l.name attr.value(name).toString(); l.color attr.value(color).toString(); const auto stParts attr.value(stations).split(,); for (const auto sp : stParts) { l.stationIds.append(sp.toInt()); } m_lines.append(l); } } if (xml.hasError()) { qWarning() XML parse error: xml.errorString(); return false; } buildGraph(); return true; }解析时的常见坑是属性缺失。QXmlStreamAttributes 在属性不存在时返回空的 Attributevalue() 是空字符串toInt() 是 0。如果 XML 里某个 station 节点漏写了 id后面的逻辑会把 id0 当成有效节点。我一般会在解析完加一轮校验站点 id 必须在 1 以上线路里引用的站点必须真实存在不满足的直接跳过。三种解析方式的取舍可以简单对比解析方式内存占用随机访问适用规模QXmlStreamReader低流式不支持几千节点起步的站库QDomDocument高全树支持百级小配置QJsonDocument中支持数据量再大建议换 JSON2.3 把 C 数据交给 QML 的两种方式main.cpp 里只做了两件事加载 XML 数据然后启动 QML 引擎。数据对象怎么传过去直接影响 QML 侧所有页面怎么引用它。// main.cpp 核心片段 #include QGuiApplication #include QQmlApplicationEngine #include QQmlContext #include dtransmap.h int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); // 方式一注册 QML 类型适合需要多实例时 qmlRegisterTypeDTransMap(TransferModel, 1, 0, DTransMap); // 方式二上下文属性本 demo 用这种地图只有一个实例 DTransMap transMap; if (!transMap.load(:/data/stadb.xml)) { return -1; } QQmlApplicationEngine engine; engine.rootContext()-setContextProperty(transMap, transMap); engine.load(QUrl(QStringLiteral(qrc:/main.qml))); return app.exec(); }两种方式不能混用同一个对象。setContextProperty 要求 transMap 的生命周期覆盖 engine 的生命周期如果 transMap 是局部对象、engine 先析构没问题反过来 transMap 先析构就会让 QML 里的引用悬空。qmlRegisterType 是懒创建适合 QML 里需要动态 new 的场景但构造参数不好传本例的地图数据由 C 预先加载所以用上下文属性最省事。3. 换乘搜索不是套 Dijkstra 模板换乘惩罚与图构建细节换乘查询是这个课设的核心算法它不能直接套最短路径模板。原因是轨道交通换乘的体验损耗在图上没有体现出来。3.1 从线路数据到邻接表构图的关键几步DLine 的 stationIds 是按方向排好序的构建邻接表时直接成对加边。整个过程放在 DTransMap::buildGraph和 XML 解析分离方便单独测试。// dtransmap.h 中与图相关的成员 struct Edge { int to; // 目标站点 id int lineId; // 所属线路换乘判断时用 int weight; // 运行时间分钟 }; class DTransMap : public QObject { Q_OBJECT public: Q_INVOKABLE QVariantList filterStations(const QString kw) const; Q_INVOKABLE QStringList planRoute(int start, int end, int penalty 6); private: QHashint, DStation m_stations; QListDLine m_lines; QVectorQVectorEdge m_graph; // 邻接表 QHashint, int m_indexOfStation; // 站点 id - 邻接表下标 };前面解析完 XML 后马上调用 buildGraph// dtransmap.cpp 构建邻接表 void DTransMap::buildGraph() { // 站点 id 是非连续编码时先映射到连续下标 m_indexOfStation.clear(); int idx 0; for (auto it m_stations.cbegin(); it ! m_stations.cend(); it) { m_indexOfStation.insert(it.key(), idx); } const int n m_stations.size(); m_graph.assign(n, {}); // 每条线路内部相邻站之间加双向边 for (const DLine line : m_lines) { for (int i 0; i line.stationIds.size() - 1; i) { int u m_indexOfStation.value(line.stationIds.at(i), -1); int v m_indexOfStation.value(line.stationIds.at(i 1), -1); if (u -1 || v -1) continue; // 脏数据 const int w 2; // 相邻两站按 2 分钟算 m_graph[u].push_back({v, line.id, w}); m_graph[v].push_back({u, line.id, w}); } } }两步走的原因站点 id 可能从 100 号开始编、中间有空洞直接用 id 当邻接表下标会浪费大量内存映射到连续下标后邻接表大小正好等于站点数。双向加边是必须的漏了返回方向就会出现“只能从 A 到 B、不能从 B 到 A”的问题。边权 w 在这里统一取 2 分钟真实数据可以按区间距离或运行时长细分这个值反正是可以后期再调。3.2 换乘惩罚的 Dijkstra多一维状态与关键松弛逻辑纯按时间最短来搜换乘次数会明显偏多。原因是“同站换乘”在图上是同一个节点算法不会为换乘时的步行和等车时间计价。正确做法是在松弛时判断是否换了线路如果换了就在原有代价上追加 penaltyMinutes。// dtransmap.cpp 带换乘惩罚的时间优先搜索 QStringList DTransMap::planRoute(int start, int end, int penaltyMinutes) { const int s m_indexOfStation.value(start, -1); const int e m_indexOfStation.value(end, -1); if (s -1 || e -1) return {}; const int n m_stations.size(); QVectorint dist(n, INT_MAX); QVectorint prev(n, -1); // 前驱节点下标 QVectorint reachLine(n, -1); // 到达某节点时乘坐的线路 id // 小顶堆存三元信息累计代价、节点下标、当前线路 using State QPairint, QPairint, int; auto cmp [](const State a, const State b) { return a.first b.first; }; std::priority_queueState, std::vectorState, decltype(cmp) pq(cmp); dist[s] 0; pq.push({0, {s, -1}}); while (!pq.empty()) { const int cost pq.top().first; const int u pq.top().second.first; const int curLine pq.top().second.second; pq.pop(); if (cost dist[u]) continue; // 惰性删除过期状态 if (u e) break; for (const Edge ed : m_graph[u]) { // 关键换乘才加惩罚同线续乘只加运行时间 const int extra (curLine ! -1 ed.lineId ! curLine) ? penaltyMinutes : 0; const int nd cost ed.weight extra; if (nd dist[ed.to]) { dist[ed.to] nd; prev[ed.to] u; reachLine[ed.to] ed.lineId; pq.push({nd, {ed.to, ed.lineId}}); } } } if (dist[e] INT_MAX) return {}; // 不可达 return buildPlanText(s, e, prev, reachLine); }这段代码有两个细节要解释。一是优先队列里多放了 curLine因为换乘判断需要知道“当前所在线路”但到达同一站点可能有多条线路状态空间因此比普通 Dijkstra 多了一维。二是 extra 的计算放在边松弛里不修改图结构换乘惩罚变成运行时参数想试 4 分钟还是 8 分钟直接改函数入参就行。换乘策略的取舍有一个通用参照策略算法结果特征适用场景最少换乘BFS 或边权为 1 的最短路换乘少但可能绕路时间不敏感最短时间普通 Dijkstra时间短但换乘多赶时间且熟悉换乘时间换乘惩罚带惩罚 Dijkstra时间和换乘综合最优默认推荐3.3 路径回溯把节点序列拼成换乘方案prev 和 reachLine 两个数组配合使用能从终点一路倒推出起点然后按线路合并成乘车段。// dtransmap.cpp 回溯并生成乘车文案 QStringList DTransMap::buildPlanText(int s, int e, const QVectorint prev, const QVectorint reachLine) const { if (s e) return {QStringLiteral(已在目的地)}; // 倒推节点下标 QVectorint path; for (int v e; v ! -1; v prev[v]) path.prepend(v); if (path.size() 2) return {}; // 不可达 QStringList segments; int segStart path.first(); int curLine reachLine[path.at(1)]; // 同一线路连续经过的站合并成一个乘车段 for (int i 1; i path.size(); i) { if (reachLine[path.at(i)] ! curLine) { segments buildSegment(segStart, path.at(i - 1), curLine); segStart path.at(i - 1); curLine reachLine[path.at(i)]; } } segments buildSegment(segStart, path.back(), curLine); return segments; }这里用path.at(1)取第一段线路是因为 path[0] 是起点站起点的 reachLine 没有意义真正需要读取的是离开起点后进入的线路。buildSegment 内部通过 m_stations 取站名、通过 m_lines 取线路名拼成“从 XX 乘坐 X 号线到 XX共 N 站”这样的句子SelectView.qml 直接按行展示。4. QML 视图层拼装从站点选择到路网绘制数据层和算法层在 C 侧完成后QML 只负责交互与展示。这个 demo 的几个 QML 文件各管一段流程分工很清晰。4.1 页面骨架StackView 组织三步流程main.qml 是整个界面的入口用 StackView 管理页面切换。选择起点、选择终点、看结果三步是线性的StackView 的 push/pop 天然契合不用自己维护页面状态。// main.qml 骨架 import QtQuick 2.15 import QtQuick.Controls 2.15 ApplicationWindow { id: root width: 1024 height: 768 visible: true title: qsTr(地铁公交换乘系统) StackView { id: pageStack anchors.fill: parent initialItem: staselPage } // 页面组件用 Component 包一层延迟实例化 Component { id: staselPage Stasel { } } Component { id: selectViewPage SelectView { } } Component { id: roadsViewPage RoadsView { } } }各 QML 文件与数据接口的对应关系可以整理成一张表方便后面加页面时照着填QML 文件职责依赖的 C 接口main.qml页面容器与导航无Stasel.qml起终点选择filterStations()SelectView.qml换乘方案展示routeSegments 属性RoadsView.qml路网绘制allLines()、allStations()、linePoints()4.2 StaselTextField ListView 的实时过滤选择站点选择页的核心是搜索框和站点列表。搜索框每敲一个字都从 C 侧拿一次过滤结果并把 ListView 的 model 重新赋值。// Stasel.qml 片段 import QtQuick 2.15 import QtQuick.Controls 2.15 Item { id: root signal stationSelected(int id) // 把选中的站点 id 抛给外层 ColumnLayout { anchors.fill: parent anchors.margins: 12 TextField { id: searchBox placeholderText: qsTr(输入站点名过滤) onTextChanged: { // transMap 是 C 侧 setContextProperty 挂进来的对象 stationList.model transMap.filterStations(searchBox.text); } } ListView { id: stationList Layout.fillWidth: true Layout.fillHeight: true clip: true // model 是 QVariantList每个元素是 {id, name} 的 map delegate: ItemDelegate { text: modelData.name width: ListView.view.width onClicked: root.stationSelected(modelData.id) } } } }配合过滤的 C 方法是 filterStations通过 Q_INVOKABLE 暴露给 QML。它遍历整个站点表按关键字做包含匹配返回的 QVariantList 可以直接作为 ListView 的 model。整个过程没有任何中间状态输入一个关键字输出一个列表语义非常直接。// dtransmap.cpp QVariantList DTransMap::filterStations(const QString keyword) const { QVariantList result; for (auto it m_stations.cbegin(); it ! m_stations.cend(); it) { if (keyword.isEmpty() || it.value().name.contains(keyword, Qt::CaseInsensitive)) { QVariantMap item; item.insert(QStringLiteral(id), it.key()); item.insert(QStringLiteral(name), it.value().name); result.append(item); } } return result; }这个方法有个性能边界每次按键都全表扫描。站点在几千个量级时完全没问题如果再涨一个数量级需要改成按拼音首字母索引或者在 C 侧缓存索引结果。对课程设计来说全表扫描反而是更直观的示范。4.3 RoadsViewCanvas 路网绘制的三件事路网页用 Canvas 把线路和站点画出来。代码本身不复杂但有三件事必须处理好否则画面不是缺线就是盖站点。// RoadsView.qml 核心绘制片段 Canvas { id: roadCanvas anchors.fill: parent // 页面显示时重绘一次窗口尺寸变化也触发 onPaint: { var ctx getContext(2d); ctx.clearRect(0, 0, width, height); // 第一层所有线路 var lines transMap.allLines(); // QVariantList of {id, name, color} ctx.lineWidth 3; for (var i 0; i lines.length; i) { var pts transMap.linePoints(lines[i].id); // QVariantList of QPointF if (pts.length 2) continue; ctx.strokeStyle lines[i].color; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (var j 1; j pts.length; j) { ctx.lineTo(pts[j].x, pts[j].y); } ctx.stroke(); } // 第二层站点圆点压在线上 var sts transMap.allStations(); ctx.fillStyle #ffffff; ctx.strokeStyle #444444; ctx.lineWidth 1; for (var k 0; k sts.length; k) { ctx.beginPath(); ctx.arc(sts[k].pos.x, sts[k].pos.y, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } }三件事分别是先画线再画站否则圆点会被线盖住站点的 pos 直接在绘制时当像素坐标用不要再乘缩放系数XML 生成数据时就该按目标画布尺寸来定坐标重绘时机用 onPaint 触发窗口 resize 时 Canvas 会自动重绘不用手工干预但如果 QML 侧修改了站点数据需要手动调用 requestPaint()。4.4 SelectView把换乘方案变成可读的乘车步骤选完起终点后C 的 planRoute 返回 QStringList每行是一个乘车段。SelectView 的 ListView 直接把这个列表绑上去// SelectView.qml 片段 import QtQuick 2.15 import QtQuick.Controls 2.15 ListView { id: routeView // routeSegments 是 C 侧 Q_PROPERTY(QStringList) model: routeSegments delegate: ItemDelegate { text: modelData // 每一行形如“从 西直门 乘坐 2号线 到 鼓楼大街共 3 站” } }C 侧要提供 routeSegments 属性并在方案计算完成后发出通知// dtransmap.h Q_PROPERTY(QStringList routeSegments READ routeSegments NOTIFY routeChanged)这样 QML 侧只要在起点、终点确定后调用一次 planRoute 并触发 routeChanged列表就会自动刷新。数据从算法输出到界面展示没有经过中间对象链路是最短的。5. 同一张图站点可达性与封站重规划换乘查询只是这套图结构的一种用法。把 m_graph 拿出来还能做两件非常实用的扩展站点可达性分析和封站后的动态重规划。5.1 用 BFS 快速算连通域判断两个站之间是否连通不需要跑 Dijkstra一层 BFS 就够了。// 可达性判断 bool DTransMap::isReachable(int from, int to) const { const int s m_indexOfStation.value(from, -1); const int t m_indexOfStation.value(to, -1); if (s -1 || t -1) return false; QQueueint q; QVectorbool visited(m_stations.size(), false); visited[s] true; q.enqueue(s); while (!q.isEmpty()) { int u q.dequeue(); if (u t) return true; for (const Edge ed : m_graph[u]) { if (!visited[ed.to]) { visited[ed.to] true; q.enqueue(ed.to); } } } return false; }BFS 的时间复杂度是 O(VE)跑一次比 Dijkstra 更快适合在起点变化时做前置检查把不可达的情况拦截在搜索之前。如果还想知道整个网络分成几个连通块就做一次全图遍历给每个连通块编号结果可以直接显示在 RoadsView 上换乘方案里也可以提前标注“该站点当前不可达”。5.2 封站模拟不删边只加一个阻塞集合运营中常见的问题是某站临时封闭。直接在图上删边、改边会破坏原图最轻量的做法是把阻塞站点传给搜索函数松弛时跳过。// 对 planRoute 加一个可选参数阻塞站点集合 QStringList planRoute(int start, int end, int penaltyMinutes 6, const QSetint blocked {}); // 搜索循环内部 for (const Edge ed : m_graph[u]) { if (blocked.contains(ed.to)) continue; // 封闭站直接跳过 // ... 其余逻辑不变 }这种做法比图结构上删边更可控阻塞站点变化时只需重新构造 blocked 集合再跑一次不需要 rebuildGraph。把 previous 的路线缓存和这次结果对比就能得到“哪些站需要绕行”的运营提示。整个扩展只加了不到十行代码却把课设从“能查换乘”提升到“能模拟运营中断”而且 BFS 连通性检查和带阻塞的 Dijkstra 共用同一份邻接表不破坏原有数据结构答辩时可以顺着这条线多讲五分钟。本文还有配套的精品资源点击获取