☰
地铁公交换乘课设全解析:从图建模到Dijkstra算法与Qt QML实现
2026/10/22 9:13:09 网站建设 项目流程

简介:一套基于Qt QML实现的地铁公交换乘系统演示项目,适合数据结构课程设计、期末大作业、工程实训及初期项目立项等场景。项目核心围绕站点、线路与换乘方案的数据结构设计展开,包含完整的算法演示与可视化交互界面,可帮助学习者理解图结构、最短路径及换乘规划的实际落地方式。压缩包共52个文件,约30.49MB,以C++源码(12个cpp、10个头文件)、QML界面文件、XML数据配置、字体资源及工程配置文件为主,同时附有说明文档,目录结构清晰,便于直接打开工程运行或按模块二次开发。项目代码均经过测试运行,功能正常,支持复现复刻;已有92人学习下载。对于课程设计报告撰写,也可参考其中的模块划分与实现思路,并在此基础上扩展更多地铁线路或优化换乘策略。

1. 把「地铁公交换乘 demo」做成数据结构课设,为什么它总被老师点名

数据结构课程设计最容易被点名的题目,地铁公交换乘系统绝对排前三。它看起来只是一个查询工具——输入起点站和终点站,输出换乘方案,但背后几乎把数据结构课里的核心内容都串了一遍:图怎么存、最短路径怎么算、时间与换乘次数怎么折成一个可比较的权重、极端数据下算法会不会崩。再加上一个 Qt QML 写的界面,整套交付物就是一个能跑、能演示、能讲半小时的完整课设 demo。

这篇笔记面向正在做课设、实训或大作业的同学,也包括想拿这套题目去参加校内竞赛的团队。我不会把内容写成教材式的原理堆砌,而是按「先建立图模型 → 选算法与参数 → 用 Qt QML 落地界面 → 排查经典翻车点 → 准备答辩验证」这条真实做项目的路径,把每一步的选型理由、核心代码、参数设置和踩坑记录讲清楚。读完之后,你心里应该有一套可以直接动手复现的方案,而不是一堆概念的碎片。

2. 先想清楚再敲代码:图的建模、边权折算与算法选型

2.1 把换乘系统画成一张图:节点、边和权重怎么定

很多同学拿到题目第一反应是「站点就是节点,线路就是边」,这个直觉对了一半。如果直接把站名建图,会出现一个非常基础的问题:同一个站,在两条线路的交点处,到底是算一个节点还是两个节点。比如某个换乘站同时经过 L1、L2 两条线,物理上它是一个站台,但在图论建模里,如果不把「跨线换乘」单独转化成一条边,后面所有换乘方案都算不干净。

常见做法是把每一个「线路-站点」对当成一个节点,也就是同一个物理站名在 L1 上有一个节点,在 L2 上有另一个节点。两个节点之间用一条「换乘边」连接,权重就是换乘代价。这样做的好处是,站点与站点之间到底是「坐一站」还是「换一条线」,在图结构里被区分得一清二楚,Dijkstra 跑起来不会出现「在同一线路里靠换乘边穿越」的假路径。

演示数据的规模不建议一上来就搞真实城市线网。课设 demo 用 4 到 6 条线路、80 到 150 站就足够撑起复杂度,也足够展示算法的全貌。节点边权建议这样定:相邻两个站的「行程边」权重用运行分钟数,通常地铁区间 2 到 4 分钟;「换乘边」权重 = 站内走行时间 + 候车惩罚,走行时间一般取 4 到 6 分钟,候车惩罚取 2 到 3 分钟。把换乘代价设成有意识的「惩罚值」,是后面调整最少换乘还是最短时间的关键旋钮。

2.2 Dijkstra 还是 Floyd:课设规模下怎么选

最短路径算法在课设里最常用的是 Dijkstra 和 Floyd,很多人纠结到底选哪个。我的结论很简单:如果你的界面是「用户输入起点和终点,系统现场算一次」,就选 Dijkstra;如果你的系统要预先算好所有站点之间的最短路径,并且站点规模只有一两百,Floyd 也能接受,但意义不大。

Dijkstra 的单次查询复杂度是 O(E log V),E 是边数,V 是节点数。换乘场景下每个站平均也就两三条边,E 约等于 2V 到 4V,非常稀疏,跑一次查询在毫秒级。Floyd 是 O(V^3),100 个节点是 100 万次,200 个节点是 800 万次,看起来也能秒出,但这只是预处理,而且地铁的时刻表、等车时间经常变,换乘边权重一旦调整,整个 Floyd 表要重算,非常不划算。

Dijkstra 还有个隐藏优点:它天然支持「目标函数有副作用」。比如你可以在换乘边加惩罚值,让算法倾向少换乘;也可以把惩罚调低,让它倾向省时间。Floyd 虽然也能做,但它预计算的方式让「换乘次数上限」这类约束很难动态加。所以课设项目我建议直接上 Dijkstra + 优先队列,代码量不大,复杂度推导也好讲,答辩时老师问「为什么不用 Floyd」,你能说出「稀疏图、在线查询、权重可变」这三个理由,这题就答稳了一半。

2.3 Qt QML 在这个项目里承担什么角色

Qt QML 在这里不是核心算法的载体,而是交互层和展示层。很多课设采用 C++ 写算法配 QWidget 界面,也能过,但 QML 有明显的两个好处:一是界面代码量少,一个 ListView 加两个输入框加一个按钮就能构成完整交互;二是控件样式可配置性强,换乘结果的卡片式展示比 QWidget 的表格好看很多,答辩演示时观感更符合「信息可视化」的要求。

架构上我建议 C++ 持有图数据和 Dijkstra 算法,QML 只负责渲染和输入采集。C++ 侧通过 Q_INVOKABLE 暴露查询接口,QML 调用后拿回字符串列表或者一个 QVariantList 来填充 ListView。不要图省事把 Dijkstra 用 JavaScript 重写在 QML 里——数据量小的时候没问题,但你失去了 C++ 侧调试和性能展示的机会,答辩时老师问「复杂度是多少」你只能含糊其辞,很亏。

整个工程的根目录建议分成 data、core、ui 三个模块,core 里放 GraphBuilder 和 PathFinder 两个类,ui 里放 main.qml 和查询面板组件。这样代码结构一眼就能看出是一个有分层意识的课设,而不是所有逻辑堆在一个 main.cpp 里。

3. 从 CSV 数据到 QML 界面:直接能抄的核心实现

3.1 静态数据准备:站点表与线路表的 CSV 格式

课设 demo 不需要上数据库,SQLite 虽然 Qt 直接支持,但你要为它编写建表语句和读写层,徒增工作量。常见做法是准备两个 CSV 文件,一个描述站点,一个描述线路区间。CSV 的好处是可以用 Excel 编辑,答辩时可以现场改数据演示算法对变化的响应,这也是课设里很加分的操作。

站点表建议四列:station_id 为唯一编号,station_name 为站名,line_id 为所在线路,is_transfer 标记该站是否为换乘站。线路表建议五列:line_id、station_order、station_id、next_station_id、travel_minutes。注意线路表里我放的是相邻站关系,而不是整个线路字符串,这样和图的邻接表直接对应,解析代码可以写得很短。

下面是我建议的 demo 数据格式,站名全部用虚构的演示数据,你可以直接复制改成自己的线网:

# station.csv station_id,station_name,line_id,is_transfer S01,云翠,L1,0 S02,明湖,L1,1 S03,长桥,L2,0 S04,虹桥,L2,1 S05,望山,L3,1 S06,科技路,L2,0 S07,创业园,L3,0 S08,凤凰里,L1,0 S09,东渡,L2,0 S10,星河,L3,0 S11,半月湾,L1,1 S12,松风,L2,0 S13,北宸,L3,1 S14,南谷,L1,0
# line_edges.csv line_id,station_order,station_id,next_station_id,travel_minutes L1,1,S01,S02,3 L1,2,S02,S08,4 L1,3,S08,S11,3 L1,4,S11,S14,2 L2,1,S03,S04,3 L2,2,S04,S06,2 L2,3,S06,S09,4 L2,4,S09,S12,3 L2,5,S12,S02,5 L3,1,S05,S10,3 L3,2,S10,S07,2 L3,3,S07,S13,4 L3,4,S13,S02,5

这段 CSV 的解析逻辑不复杂:station.csv 里的每一行对应图里的一个节点,line_edges.csv 里的每一行对应一条有向边,权重就是 travel_minutes。还要注意一个细节——我故意让 L2 和 L3 的终点都指向 S02,这样 S02 在演示数据里是三条线的交汇处,换乘方案丰富,测试算法时最容易看出效果。数据设计本身也是课设的一个得分点,老师如果看到测试数据覆盖了同站换乘、两线换乘、三线交汇,印象分会明显不同。

3.2 用 C++ 封装图结构:邻接表与 Dijkstra 主体

前端界面可以后做,但图结构和算法必须先把函数签名定下来。我这里给一个最小可跑的 C++ 实现,采用邻接表存储,优先队列优化 Dijkstra。类名和接口尽量简单——GraphHolder 负责建图,PathFinder 负责查询,两者都不依赖任何 UI 类,方便在纯控制台工程里先调试通过。

// graph_builder.h struct StopNode { QString stationId; QString stationName; QString lineId; bool isTransfer; }; struct Edge { int toNode; // 目标节点下标 int weightMinutes; // 行程或换乘的分钟权重 int lineId; // -1 表示换乘边 }; class GraphHolder { public: bool loadStations(const QString& stationCsvPath); bool loadEdges(const QString& edgeCsvPath); int addTransferEdges(int walkMinutes, int waitPenalty); const QVector<QVector<Edge>>& adjacency() const { return adj_; } QString stopDescription(int nodeIdx) const; private: QVector<QVector<Edge>> adj_; QVector<StopNode> stops_; };
// path_finder.cpp 核心段落 struct Candidate { int node; int cost; }; bool operator<(const Candidate& a, const Candidate& b) { return a.cost > b.cost; } QVector<int> PathFinder::shortestPath(int fromNode, int toNode) { const int INF = 1e9; QVector<int> dist(adj_.size(), INF); QVector<int> prev(adj_.size(), -1); std::priority_queue<Candidate> pq; dist[fromNode] = 0; pq.push({fromNode, 0}); while (!pq.empty()) { Candidate cur = pq.top(); pq.pop(); if (cur.cost != dist[cur.node]) continue; // 跳过失效的旧记录 if (cur.node == toNode) break; for (const Edge& e : adj_[cur.node]) { int nd = cur.cost + e.weightMinutes; if (nd < dist[e.toNode]) { dist[e.toNode] = nd; prev[e.toNode] = cur.node; pq.push({e.toNode, nd}); } } } return rebuildPath(fromNode, toNode, prev); }

这段代码里的dist数组就是「当前已知从起点到每个节点的最短用时」,prev记录前驱节点,用于最后回溯路径。优先队列里比较器用>是因为 std::priority_queue 默认是大顶堆,我们要让花费小的先出队。这种写法比每次遍历所有节点找最小值快一截,也是算法复杂度能讲到 O(E log V) 的关键。INF 一定要设得比所有可能路径总和大得多,课设数据里所有边的权重都不超过 10 分钟,路径长度也不会超过几百分钟,INF 取 1e9 足够。

这里有个很容易忽略但答辩常被追问的点:addTransferEdges的参数walkMinutes和waitPenalty。换乘边的目标节点不是任意站,而是同一个物理站在另一条线上的节点。所以建换乘边前必须先把「同站名不同线路」的节点找出来,再成对添加。你在 CSV 里看到的 S02 对应 L1、L2、L3 三个节点,这三者之间要补三条「换乘边」,权重就是走路时间加候车惩罚。

3.3 QML 侧交互:ListView 加 TextField 的查询界面

QML 渲染层我见过很多课设写成一个大文件躺在那里,几百行全堆进去。更克制的方式是拆成三个小组件:目的地输入行、查询按钮、结果列表。下面这个 main.qml 是去掉修饰后的骨架,把核心交互留清楚,样式你可以按自己的审美补。

import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 ApplicationWindow { visible: true width: 480 height: 700 title: qsTr("地铁公交换乘 Demo") ColumnLayout { anchors.fill: parent anchors.margins: 16 RowLayout { TextField { id: fromInput; placeholderText: qsTr("起点站,如 云翠") } TextField { id: toInput; placeholderText: qsTr("终点站,如 松风") } } Button { text: qsTr("查询换乘") onClicked: { var result = pathFinder.queryByNames(fromInput.text.trim(), toInput.text.trim()); resultModel.clear(); for (var i = 0; i < result.length; i++) { resultModel.append({ stepText: result[i] }); } } } ListView { id: resultList Layout.fillWidth: true Layout.fillHeight: true model: resultModel delegate: Rectangle { width: resultList.width height: 44 color: index % 2 === 0 ? "#FAFAFA" : "#F0F0F0" Text { anchors.centerIn: parent text: stepText font.pixelSize: 15 } } } ListModel { id: resultModel } } }

这段 QML 的精髓在onClicked里:QML 侧不做任何算法逻辑,只把两个输入框的文本传给 C++ 的queryByNames方法,拿回的result是一个字符串数组,每一项是一行换乘描述,比如「从 云翠 乘坐 L1 到 明湖,3 分钟」或者「在 明湖 换乘 L2」。resultModel.clear()和append的组合是 ListView 最常见的刷新模式,只要查询结果不大,性能完全没问题。

queryByNames要求 C++ 侧实现「站名→节点下标」的映射。比较稳的做法是在加载 CSV 时建立QHash<QString, QVector<int>>,键是站名,值是该站名对应的所有节点下标。用户输入站名后,如果这个站是换乘站,多个节点都匹配,一般取第一个就行,复杂度也很低。在queryByNames里还要处理两种边界:起点等于终点、站名不存在,分别返回「已在同一站」和「站点不存在」的提示,而不是让 Dijkstra 去跑一个无效输入。

3.4 算法与界面怎么接:从 QML 调 C++ 的三种姿势

从 QML 调用 C++,课设里常见的有三种做法。第一种是把算法对象设置成 QML 上下文属性,QML 里直接用pathFinder.queryByNames(...),这是最推荐的方式,代码直观、QML 侧不需要关心对象生命周期。第二种是用 QML 的注册类型机制,把类注册给 QML 引擎动态创建,适合需要多个实例的场景。第三种是纯 JavaScript 重写算法,完全不碰 C++,我强烈不建议。

第一种子类型做法,在 main.cpp 里大概就这几行:

#include <QGuiApplication> #include <QQmlApplicationEngine> #include <QQmlContext> #include "path_finder.h" int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); PathFinder finder; bool ok = finder.loadData(":/data/station.csv", ":/data/line_edges.csv"); if (!ok) return -1; QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("pathFinder", &finder); engine.load(QUrl(QStringLiteral("qrc:/ui/main.qml"))); return app.exec(); }

loadData内部会依次加载站点表、线路表、构建邻接表、添加换乘边,全部完成之后,QML 侧拿到的是一个完全就绪的查询器。setContextProperty把finder挂到 QML 全局,QML 里的pathFinder指的就是它。注意finder是栈上对象,必须保证在 engine 销毁之前有效,只要 main 函数里engine还活着就没问题。还要在 PathFinder 类声明queryByNames时用Q_INVOKABLE宏标记,或者把类声明为 QObject 子类并用Q_PROPERTY暴露属性,否则 QML 里调不到这个函数。

这套接法有个隐藏好处:调试定位问题时可以先用一个纯控制台的 main.cpp 把算法输出打出来,确认正确后再切到 QML 工程。数据加载、换乘边构建这些操作本身和界面完全解耦,后面第 4 章讲的排查手段,大部分也都在 C++ 这一层完成,不用反复切界面重新查询。

4. demo 必翻车现场:换乘系统的 5 个经典课设坑与排查

4.1 坑:输入站名带不带“站”字,结果完全不一样

现象是用户输入“云翠站”查询有结果,换成“云翠”就提示站点不存在;或者反过来。原因在于 CSV 里站名是「云翠」,但界面输入时不少人习惯带一个“站”后缀。这个坑看起来小,答辩现场一旦被老师随手试试就会露馅,因为老师会直接敲「长桥站」来测。

解决方式是在queryByNames入口处做统一归一化:把输入的站名先 trim 掉首尾空格,再移除末尾的“站”字,再去站名哈希表里查。如果你后续要支持别名查询,比如「东渡」和「东渡站」都映射到同一个站,这一层归一化就是唯一的改动点,不需要动图和算法。

QString normalizeStopName(const QString& raw) { QString name = raw.trimmed(); if (name.endsWith("站")) { name.chop(1); } return name; }

4.2 坑:Dijkstra 永远算不出最少换乘

现象是你的结果总在 3 次换乘以上,即使手动看线路图只需要 1 次。原因多半是你把相邻站边的权重设为「站数」,也就是 1,而换乘边权重也设成 1,算法当然会通过频繁换乘来绕路,因为换乘边便宜。这和真实需求完全相反——用户宁愿多坐几站也不愿意多换一次。

解决办法是把换乘边权重设为「走行时间 + 候车惩罚」。走行时间 4 分钟是站内步行,候车惩罚 2 到 3 分钟模拟等待下一班车。这样换乘一次的成本是 7 分钟左右,而多坐两站地铁通常只要 4 到 6 分钟,算法会在「多加两站」和「换乘一次」之间做真实权衡。如果你想要「最少换乘优先」模式,就把惩罚值调大到 15 分钟;想要「最短耗时优先」就把惩罚调到 3 分钟。把这个参数暴露到 QML 的切换按钮上,就是第 5 章说的进阶亮点。

4.3 坑:同站换乘边重复添加,内存膨胀

现象是程序跑着跑着内存涨得很快,或者查询结果里出现同一条换乘路径被列出两三次。原因多是建换乘边时没有去重——同一个站有两个节点,A 到 B 加了一条换乘边,B 到 A 又加了一条,如果你循环遍历所有节点对,就很容易重复添加。

解决方式是在addTransferEdges里用一个集合记录已经处理过的节点对。我一般用一个QSet<QPair<int,int>>,添加前先查一下,重复的直接跳过。还有一种更简单的方法:只遍历有序对,即只添加节点下标小的到下标大的方向,然后反向边在 Dijkstra 里通过遍历邻接表自然可达。不过这个技巧要建立在你的图是双向图的基础上,地铁线路本来就是双向运行,所以通常没问题。

4.4 坑:查“不可达”路线时程序卡死

现象是输入的两个站点之间没有连通的线网路径时,查询按钮点了没反应,甚至界面假死。原因不是 Dijkstra 死循环,而是它正常跑完后返回空路径,但 QML 侧没有处理空结果,或者rebuildPath在prev数组里找不到前驱导致越界。

解决方式是明确 Dijkstra 的结束条件。当优先队列被清空且还没有访问到终点时,直接返回空列表;queryByNames里对空结果返回 QML 友好的提示文本,比如「两个站点当前不可达」。同时检查prev回溯逻辑,如果prev[current] == -1且当前不是起点,就不应该进入路径重建,直接返回空。这个分支一定要单独写测试用例,因为课设数据里很难出现不可达,但老师只要把 CSV 删一行就立刻触发。

4.5 坑:QML 结果列表在换乘方案较长时滚动卡顿

现象是换乘方案超过 10 行后,快速拖动列表有明显掉帧。原因多出在delegate里使用了复杂的样式计算,或者每次查询都对 ListModel 做大量 clear/append 操作。课设规模下 20 行以内其实不该卡,如果卡了,大概率是你在 QML 里做了不该做的事,比如在 delegate 里同步做字符串分割、正则匹配这类重活。

解决方法是把格式拼装全部放到 C++ 侧的queryByNames返回阶段完成,QML 拿到的数组项直接就是最终要显示的字符串,delegate 里只做文本渲染。另一个思路是不用 ListModel,改用QStringListModel绑定到 C++ 侧,查询时一次setStringList整批替换,省掉逐条 append 的开销。两种方式选一种即可,我更推荐后者,因为代码更短,排查更简单。

5. 把课设从“能交”做成“能答辩”:验证数据与讲解切入点

5.1 自测用例:用 6 组查询把算法边界全部踩完

课设最怕的不是功能少,而是功能看起来有,但边界一测就翻车。我给这套 demo 数据设计了 6 个必测查询用例,覆盖了同线直达、一次换乘、两次换乘、换乘最优、不可达和非法输入全部场景。建议你把这 6 组用例写成一个 check list,在提交前全部跑一遍。

第一组「云翠 → 南谷」,期望结果是沿 L1 直接 4 站到达,不需要换乘;第二组「半月湾 → 松风」,期望结果是沿 L1 到明湖后换乘 L2 或 L3;第三组「北宸 → 凤凰里」,期望结果是经星河转 L2 到明湖再转 L1,这是最长的换乘链;第四组「长桥 → 科技路」验证 L2 上相邻站直达;第五组「北宸 → 东渡」验证换乘边权重是否让算法选择了更合理的线路;第六组给一个完全虚构的站名如「量子园」,验证提示文本是否友好。

每一组用例跑完之后,用 qDebug 或控制台输出「站名 + 线路 + 分钟数」的路径描述,人工对照期望结果。这个环节看起来笨,但它能帮你发现绝大多数建图错误。比如 4.1 讲的站名后缀问题,如果直接在用例里输入「云翠站」而不是「云翠」,第一次就会暴露。认真做好这 6 组,答辩时你说「我测试过全部边界」,比空口说「程序能跑」有说服力得多。

5.2 答辩时从哪些参数切入讲,老师最容易顺着追问

答辩时间通常只有五到十分钟,与其把代码从头讲一遍,不如选三个「设计决策点」来讲。第一个是「换乘惩罚值」怎么定,这是整份代码里最体现你理解深度的位置——你能说出 4 分钟走行加 3 分钟候车的依据,而不是随手写了个 5,老师就会觉得你是认真调过的。第二个是「为什么站点按线路拆分成多个节点」,这是建模层的核心决策。第三个是「为什么有向图而不是无向图」,可以引出「地铁上下行可能有不同运行时间」这个扩展场景。

老师大概率会追问「如果换乘等待时间改成动态的,比如工作日和节假日不同,你要改哪里」,这时候你能指到addTransferEdges的waitPenalty参数和 CSV 的travel_minutes字段,就已经证明你的架构没把写死了。如果追问「地铁加了一条新线路,你要改哪些文件」,你只需要回答「在 CSV 里追加站点和边记录,其余不动」,这一句话就是模块化设计最好的自证。

5.3 一个不加功能也能加分的验证技巧:现场改数据重跑

答辩现场最稳的演示手段不是背台词,而是当场改数据。在 station.csv 里把 L1 和 L2 交汇的换乘站增加一个,或者在 line_edges.csv 里把某段 travel_minutes 从 3 改成 9,让程序重新加载后展示查询结果的变化。这个动作直观地说明你的系统是「数据驱动」的,是一个可以被真实运维持续维护的结构,而不仅仅是一次性的作业。

我的一般习惯是把这个「重载数据」的按钮直接做到 QML 界面里,绑定到一个 C++ 侧的reloadData()方法,它内部关闭已有图数据、重新 load CSV、重新建换乘边。这样老师提要求时你只需要点一下按钮再查询,全过程不会超过十秒。要注意 reload 时必须先把邻接表和节点数组清空,否则旧数据残留会和新数据混在一起,这也是常见的现场翻车点。

5.3 这一节其实是从「能交」迈向「能做演示」的关键一步:功能没有增加,但演示的灵活度完全不同。答辩结束时可以提一句自己做的最大决定——把换乘惩罚设计成可配置,而不是写死在算法里,如果你觉得这种思路对你有用,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询