news 2026/10/14 16:20:33

基于MFC的南京地铁查询系统:Dijkstra与BFS算法实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于MFC的南京地铁查询系统:Dijkstra与BFS算法实现解析

简介:这套基于MFC的南京地铁查询系统是一份完整的Visual C++ 6.0工程,面向正在学习MFC框架或需要开发桌面信息查询系统的学生和开发者。系统实现了地铁线路与站点数据的存储、查询和可视化展示,并提供了可直接运行的exe程序,方便查看实际运行效果。整个压缩包包含43个文件,大小仅2.44MB,其中8个头文件和7个CPP文件构成主要源码,配合Dlg、Prop等类完成界面交互与查询逻辑;bmp、ico、rc等资源用于界面美化;dsp、dsw、clw等工程配置便于直接编译调试;readme和txt则提供必要的说明。目前已有962人学习下载。通过阅读完整源码,能够清晰理解MFC的CWinApp程序入口、CDialog对话框设计、消息映射与事件处理,以及数据读取和列表展示等关键步骤;同时,项目中的地铁图位图展示和路线查询逻辑也可为其他地理信息查询类软件提供可复用的设计参考。

1. 基于MFC的南京地铁查询系统:为什么一个课程设计值得认真做

很多人第一次看到“基于MFC的南京地铁查询系统”这个题目,第一反应是“又是MFC,老掉牙了”。但真把需求拆开,你会发现它恰好覆盖了MFC里最值得练的三件事:文档数据怎么进内存、图算法怎么在UI线程里跑、自绘控件怎么画得又快又对。这篇文章要拆的就是这样一套完整的系统:用VS + MFC对话框搭出查询窗口,把南京地铁的线路站点数据读进邻接表,再用Dijkstra算最少站数、用BFS算最少换乘,最后把最优路线画在自绘区里。新手能照着把环境跑起来,老手可以重点看编码、算法引擎和重绘这三类坑。

2. 工程结构:先把“查询系统”拆成三层再动手

2.1 为什么选MFC对话框而不是单文档视图

常见做法是新建一个MFC对话框程序,而不是用单文档视图(SDI)。原因很简单:地铁查询系统的主界面就是“两个下拉框 + 一个按钮 + 一个结果列表”,没有复杂的文档概念。对话框的DoModal循环天然适合这种交互,生成菜单、工具栏那些代码都是噪音。

但有个地方要用到视图框架的能力:自绘线路图。对话框里放一个自定义的Picture Control,或者直接重写对话框的OnPaint,让它在指定区域绘图。如果你把绘图区域封装成一个继承自CWnd的控件类,那么对话框和单文档的差别就不大了。我的建议是:对话框为主体,自绘控件单独拆成类,方便以后如果要做成大窗口,直接换成CScrollView。

2.2 三层结构:数据层、算法层、界面层

别把CSV解析、Dijkstra、OnPaint全塞在对话框类里,否则一个文件能写到三千行。拆三层:

  • 数据层 MetroData:负责读文件、存站点、建邻接表、提供查询接口。
  • 算法层 RouteFinder:接受MetroData的图结构,返回最短路径或最少换乘结果。
  • 界面层 CMetroQueryDlg:只负责收集用户输入、调用算法层、刷新列表和绘图。

这样做的最大好处是调试时可以单独给RouteFinder写控制台测试,不用启动界面。MFC工程里加一个命令行入口不是不行,但我一般直接加一个“自检”按钮,把几条已知路径的结果弹出来。

2.3 初始化文件与界面骨架代码

先看数据加载。南京地铁站点数据我建议存成一个CSV,格式如下,每行一条记录:

line_id,station_name,order 1,迈皋桥,1 1,红山动物园,2 1,南京站,3 ... 2,油坊桥,1 2,雨润大街,2

读取时需要注意:如果用MFC的CStdioFile的ReadString按Unicode读UTF-8文件会乱码。下面这段代码先把文件按UTF-8字节读进来,再手动转成宽字符,最后用CString存站名:

bool MetroData::LoadFromFile(const CString& path) { std::ifstream in(CT2A(path.GetString())); if (!in.is_open()) return false; std::string utf8Line; while (std::getline(in, utf8Line)) { if (utf8Line.empty()) continue; // 计算UTF-8字节串转成宽字符所需长度 int wLen = MultiByteToWideChar(CP_UTF8, 0, utf8Line.c_str(), (int)utf8Line.size(), nullptr, 0); if (wLen <= 0) continue; std::wstring wBuf(wLen, L'\0'); MultiByteToWideChar(CP_UTF8, 0, utf8Line.c_str(), (int)utf8Line.size(), &wBuf[0], wLen); CString line(wBuf.c_str()); int pos1 = line.Find(_T(',')); int pos2 = line.Find(_T(','), pos1 + 1); if (pos1 < 0 || pos2 < 0) continue; int lineId = _ttoi(line.Left(pos1)); CString station = line.Mid(pos1 + 1, pos2 - pos1 - 1); int order = _ttoi(line.Mid(pos2 + 1)); m_stations.push_back(StationRecord{ lineId, station, order }); } BuildGraph(); return true; }

逻辑说明:先用std::ifstream按二进制方式读UTF-8文本,避免CStdioFile默认按本地代码页读取;然后用MultiByteToWideChar把每行UTF-8转成UTF-16。这样在Unicode项目中CString拿到的就是正确的中文站名。要注意find查找分隔符的结果是相对位置,截取站名时Mid的长度要算对,否则最后一个中文字符会被吞掉。

参数说明:CP_UTF8指源字符串编码;wBuf是转换目标缓冲区;MultiByteToWideChar的第五个参数传-1表示字符串以\0结尾,但这里显式传长度更规范,因为getline的结果里没结尾符。转换成功后wBuf末尾会有\0,CString构造会自动处理。

2.4 BuildGraph:从站点表到邻接表

读取完成后,必须把“线路-站点-顺序”转换成适合寻路的邻接表。我一般用站点名做全局ID:

void MetroData::BuildGraph() { // 先给每个站点分配ID int id = 0; for (auto& rec : m_stations) { if (m_stationId.find(rec.station) == m_stationId.end()) { m_stationId[rec.station] = id++; m_idToName.push_back(rec.station); } } m_graph.assign(m_idToName.size(), {}); m_stationLines.assign(m_idToName.size(), {}); // 按线路分组,然后组内相邻站建边 std::map<int, std::vector<StationRecord>> byLine; for (auto& rec : m_stations) byLine[rec.lineId].push_back(rec); for (auto& kv : byLine) { auto& vec = kv.second; std::sort(vec.begin(), vec.end(), [](const StationRecord& a, const StationRecord& b) { return a.order < b.order; }); for (size_t i = 0; i + 1 < vec.size(); i++) { int u = m_stationId[vec[i].station]; int v = m_stationId[vec[i + 1].station]; m_graph[u].push_back({ v, kv.first }); m_graph[v].push_back({ u, kv.first }); } // 记录每个站经过的所有线路 for (auto& rec : vec) m_stationLines[m_stationId[rec.station]].push_back(kv.first); } }

逻辑说明:m_stationId是map,把中文站名映射到连续整数ID,m_idToName用于反查站名。m_graph是邻接表,边结构里除了目标站点ID,还存这条边属于哪条线路,这样在结果输出时能告诉用户“乘坐1号线从A到B”。m_stationLines后面算最少换乘要用。

参数说明:m_graph[u].push_back({v, kv.first})里的kv.first是线路ID。这里用vector存邻接表,站点数量不超过300,内存占用无所谓,但排序按order能保证同一条线上顺序正确。如果数据文件里线路ID重复或order有乱序,排序这步很重要。

3. 数据是黑匣子:先谈编码、再谈结构

3.1 为什么地铁查询系统要纠结CSV编码

MFC工程在VS 2019/2022上默认字符集是Unicode,CString是UTF-16宽字符。而大多数数据文件来源是Excel另存的CSV,Excel在国内保存默认是GBK,从网页复制出来的可能是UTF-8。如果直接ifstream读进std::string再赋给CString,中文字符串全部变成问号。这个坑我在第二节代码里已经绕过去了:统一要求数据文件是UTF-8,读取时用MultiByteToWideChar转成宽字符。

有人问:那用CStdioFile的typeText模式读Unicode编码的文件不就行了吗?CStdioFile在Unicode项目里确实读UTF-16文件没问题,但要求文件带BOM(FF FE)。问题是很多GIS或爬虫导出的CSV是UTF-8 no BOM,打开就是乱码。所以我的习惯是:数据文件保存为UTF-8(有BOM更稳),程序里用字节流读取后手动转码。这样不管VS工程是ANSI还是Unicode都能兼容。

3.2 数据结构:为什么要建双向边和换乘站表

上面的BuildGraph里我建的是双向边,因为地铁可以坐反向。换乘站不需要特殊标记,它的特点是在邻接表里同时出现多条线路的边。比如新街口站在1号线和2号线都有记录,那么它的邻接节点既包括1号线的相邻站,也包括2号线的相邻站,Dijkstra跑的时候自然能“换乘”。

但要注意:如果单纯用Dijkstra找最少站数,换乘次数不会被惩罚。一个从A到B的方案可能是“坐1号线2站到换乘站,再坐2号线1站”共3站,另一个方案“坐3号线直通”共4站,最少站数会选择前者。如果你的需求是“优先换乘少”,那不能只用Dijkstra,要改算法。

3.3 查询接口:数据层对外屏蔽细节

数据层给界面层提供三个接口就够了:

// 获取所有站名,用于下拉框 std::vector<CString> GetAllStations() const { return m_idToName; } // 获取某站的ID,不存在返回-1 int GetStationId(const CString& name) const { auto it = m_stationId.find(name); return it == m_stationId.end() ? -1 : it->second; } // 获取某站经过的线路列表 std::vector<int> GetLinesOfStation(int stationId) const { return m_stationLines[stationId]; }

界面层拿到站名填充CComboBox,用户选完站后调GetStationId,然后交给算法层。这样的好处是后续如果要换数据库,只需要改MetroData内部实现,RouteFinder和界面类不用动。

4. 换乘算法:最少站数和最少换乘是两套逻辑

4.1 最少站数:Dijkstra的等权实现

因为地铁每坐一站代价是1,理论上BFS也能求最少站数。但Dijkstra写出来以后可以扩展“换乘惩罚权重”,所以我还是选择Dijkstra。实现时用优先队列做小顶堆:

struct RouteNode { int stationId; int dist; bool operator<(const RouteNode& other) const { return dist > other.dist; // 注意方向 } }; void RouteFinder::ShortestPath(int start, int end, std::vector<int>& outPath, int& outDist) { const int INF = 1e9; int n = (int)_graph.size(); std::vector<int> dist(n, INF); std::vector<int> prev(n, -1); std::priority_queue<RouteNode> pq; dist[start] = 0; pq.push({ start, 0 }); while (!pq.empty()) { RouteNode cur = pq.top(); pq.pop(); if (cur.dist != dist[cur.stationId]) continue; // 跳过过期节点 if (cur.stationId == end) break; for (const auto& edge : _graph[cur.stationId]) { int nd = cur.dist + 1; if (nd < dist[edge.to]) { dist[edge.to] = nd; prev[edge.to] = cur.stationId; pq.push({ edge.to, nd }); } } } outDist = dist[end]; // 回溯路径 outPath.clear(); for (int v = end; v != -1; v = prev[v]) { outPath.push_back(v); if (v == start) break; } std::reverse(outPath.begin(), outPath.end()); }

逻辑说明:RouteNode重载了小于号,但内容写的是dist > other.dist,这样priority_queue的默认大顶堆会被反转成小顶堆,每次弹出当前距离最小的节点。cur.dist != dist[cur.stationId]这个判断是核心,因为同一个节点可能被多次入队,只有最新最短的记录才需要处理。

参数说明:INF设置成1e9对站点数量300以内的图足够。prev数组记录路径前驱,回溯时从终点逆推,最后reverse得到从起点到终点的顺序。outPath里的元素是站点ID,界面层再用m_idToName转成站名显示。

4.2 最少换乘:把“线路”当节点做BFS

最少换乘问题的图不是站点图,而是“线路图”。线路是节点,换乘站是边:如果某站同时属于线路1和线路2,那么线路1和线路2之间连一条边。从起点站所在的线路出发,BFS逐层扩散,直到碰到终点站所在线路,BFS的层数就是换乘次数。但这样只能算出换乘次数,不能直接给站点路径。我一般这样实现:

int RouteFinder::MinTransfer(int start, int end, std::vector<std::vector<int>>& outRoutes) { if (start == end) return 0; auto& startLines = _stationLines[start]; auto& endLines = _stationLines[end]; // 线路邻接表:lineAdj[a] = {b, transferStationId} // transferStationId是能让a、b两条线换乘的站点ID std::vector<std::vector<LineEdge>> lineAdj; BuildLineAdj(lineAdj); std::queue<int> q; std::vector<int> dist(lineAdj.size(), -1); std::vector<int> prevLine(lineAdj.size(), -1); std::vector<CString> transferInfo; // 调试用 // 起点站的所有线路作为初始层 for (int line : startLines) { dist[line] = 0; q.push(line); } int targetLine = -1; while (!q.empty()) { int curLine = q.front(); q.pop(); // 如果当前线路包含终点站,结束 for (int line : endLines) if (line == curLine) { targetLine = curLine; break; } if (targetLine != -1) break; for (const auto& e : lineAdj[curLine]) { if (dist[e.toLine] == -1) { dist[e.toLine] = dist[curLine] + 1; prevLine[e.toLine] = curLine; q.push(e.toLine); } } } int transfers = targetLine == -1 ? -1 : dist[targetLine]; // 回溯线路路径 std::vector<int> linePath; for (int l = targetLine; l != -1; l = prevLine[l]) { linePath.push_back(l); if (l == startLines[0]) break; } std::reverse(linePath.begin(), linePath.end()); // 把线路路径转成站点路径(这里只给线路列表) outRoutes.push_back(linePath); return transfers; }

逻辑说明:BuildLineAdj内部遍历每个站点,把该站点所属的所有线路两两连边。这是个简化版实现,实际问题里还需要考虑同一条线路有两个换乘站的情况,但对于课程设计这个量级,每个换乘站只记录一条边就够了。

参数说明:dist初始化为-1表示未访问,这天然充当了visited数组。prevLine记录线路前驱,最后回溯得到的是线路序列,比如[1号线, 2号线],界面层再根据1号线和2号线的换乘站生成“坐1号线→新街口换2号线”这样的文字提示。

4.3 两种算法如何结合

我的习惯是界面层给两个单选按钮:“最少站数”和“最少换乘”。最少站数直接调Dijkstra,最少换乘调BFS线路图。如果用户要求在“换乘不超过1次的情况下站数最少”,则需要在Dijkstra代价里加重换乘惩罚,比如每次换乘加5站代价,然后统一跑Dijkstra。实现细节在下一节展开,但核心思路就是改边权。

5. 界面实现:让用户看见线路图和数据结果

5.1 自绘控件:映射坐标到屏幕

线路图自绘不建议用静态图片。用GDI画线,数据变化时重绘即可。我封装了一个MetroMapView类继承CWnd,在对话框里把它作为一个自定义控件放置。

核心是把站点真实坐标映射到控件客户区:

CPoint MetroMapView::MapToScreen(int stationId) { double minX = _minLng, maxX = _maxLng; double minY = _minLat, maxY = _maxLat; double sx = (_coordX[stationId] - minX) / (maxX - minX); double sy = 1.0 - (_coordY[stationId] - minY) / (maxY - minY); CRect rc; GetClientRect(&rc); int px = (int)(rc.left + sx * (rc.Width() - 40) + 20); int py = (int)(rc.top + sy * (rc.Height() - 40) + 20); return CPoint(px, py); }

逻辑说明:这里假设每个站点数据文件里有经纬度坐标字段。如果没有真实坐标,可以退化为按order把站点排成直线布局。映射公式里的1.0 -是为了把纬度从南到北翻转成屏幕的从下到上。

参数说明:减40留边距是为了避免站点圆点画到控件边缘。坐标归一化后乘可用宽度,再加边距偏移。_coordX和_coordY是double数组,在数据加载时填充。

5.2 OnDraw:画线、画站点、画当前路线

void MetroMapView::OnPaint() { CPaintDC dc(this); dc.SetBkMode(TRANSPARENT); // 画所有线路底图 for (const auto& line : _lines) { CPen pen(PS_SOLID, 2, line.color); CPen* pOld = dc.SelectObject(&pen); for (size_t i = 0; i + 1 < line.stations.size(); i++) { CPoint p1 = MapToScreen(line.stations[i]); CPoint p2 = MapToScreen(line.stations[i + 1]); dc.MoveTo(p1); dc.LineTo(p2); } dc.SelectObject(pOld); } // 画站点圆 for (int id = 0; id < (int)_coords.size(); id++) { CPoint pt = MapToScreen(id); dc.Ellipse(pt.x - 3, pt.y - 3, pt.x + 3, pt.y + 3); } }

逻辑说明:CPen设置线宽2像素,颜色来自线路配置表。画站点时用Ellipse画小圆,正常站半径3像素,换乘站可以半径5像素并画成空心。如果要高亮查询出的路径,可以维护一个vector highlightPath,在OnPaint里先画普通线,再画一条粗红线段重描路径。

参数说明:dc.SelectObject返回旧画笔,画完立即恢复,否则画笔泄漏导致内存增长。DC本身由CPaintDC析构时释放,不用手动DeleteObject。

5.3 重绘策略:尽量用InvalidateRect而不是Invalidate

用户点“查询”后,路径结果出现在CListCtrl里,同时线路图上高亮路径。不要调用Invalidate()无条件全量重绘,那会闪烁。正确做法是只失效路径所在区域:

CRect rcInvalid = m_mapView.GetHighlightArea(); m_mapView.InvalidateRect(&rcInvalid);

逻辑说明:GetHighlightArea返回高亮路径包围盒向外扩10像素的矩形。InvalidateRect只标记这个区域为无效,WM_PAINT时通过裁剪区只重绘这一块,效率提升明显。如果只是刷新CListCtrl,不需要重绘地图。

6. 避坑:常见问题与调试技巧

6.1 三个必踩的坑

坑一:数据文件乱码。现象:下拉框里站名全是问号。原因:CSV是UTF-8编码,而工程Unicode下直接用CStdioFile读成了UTF-16。解决:用第二节的MultiByteToWideChar转换流程,或者统一把CSV另存为Unicode编码再读。

坑二:CListCtrl插入速度慢。现象:一次性插入几百条路径点,窗口卡住几秒。原因:每调一次InsertItem都会触发控件重绘。解决:批量插入前调SetRedraw(FALSE),插入完成后再SetRedraw(TRUE)并Invalidate(),或使用虚拟列表风格设置LVS_OWNERDATA。

坑三:Dijkstra路径绕圈。现象:明明有直达车,算法输出的路径却来回换乘。原因:优先队列里比较运算符方向写反,导致每次弹出的是最大距离节点,或者prev数组在松弛时覆盖了正确前驱。解决:按4.1节代码写operator<,并加cur.dist != dist[cur.stationId]的过期判断。我遇到过最隐蔽的是前后两条线路共用同一站点ID但没去重,导致邻接表里出现自环边。

6.2 验证算法结果的土办法

动手前先准备一个三条线的极简数据:1号线“火车站-广场-大学城”,2号线“广场-机场”,3号线“机场-景区”。手动验证最少站数:火车站到机场应返回“火车站→广场→机场”,2站。最少换乘:火车站到景区应返回“坐1号线到广场,换2号线到机场,换3号线到景区”,换乘2次。在代码里加一个自检按钮,直接断言这些预期值,输出“通过/失败”即可。

6.3 一个绘画性能技巧

自绘地铁图如果线路多,OnPaint里Pen创建销毁会拖慢帧率。我的做法是把所有普通线路一次性画到一个内存DC位图上,查询高亮时才覆盖红色粗线。MoveTo和LineTo只发生在重绘时,平时拖动窗口不会闪烁。从那以后,我每次做自绘控件都会强制先画底图缓存,再处理动态高亮,查询响应时间从肉眼可见的卡顿降到几乎无感。这个习惯让我少走很多弯路,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 16:20:21

上班久了人为什么会变麻木?心理机制与恢复方法

不知道你有没有过这种瞬间&#xff1a;早上推开工位所在的那栋大楼&#xff0c;电梯上行&#xff0c;你盯着楼层数字跳动&#xff0c;脑子里什么都没想&#xff1b;坐到椅子上&#xff0c;打开电脑&#xff0c;登录聊天工具&#xff0c;回复消息&#xff0c;处理邮件&#xff0…

作者头像 李华
网站建设 2026/10/14 16:19:49

蓝牙模块功耗优化实战:从功耗模型到制造端能效兜底

去年接的一个环境监测模块项目&#xff0c;到现在印象都很深。用的是低功耗蓝牙方案&#xff0c;两节AA电池&#xff0c;标称续航两年。样机阶段测出来的电流曲线相当漂亮&#xff0c;广播电流、连接电流、休眠电流每一项都在预算内&#xff0c;客户看了也满意。结果小批量出去…

作者头像 李华
网站建设 2026/10/14 16:19:20

OpenClaw 整合包上手,本地离线运行保障文档数据安全

&#x1f4d6; 前言 本文面向 Windows 系统用户&#xff0c;系统梳理 OpenClaw 的标准化部署流程。全程无需输入任何命令行&#xff0c;所有操作均依托可视化向导完成&#xff0c;即便是零基础用户也能独立走完整套部署。文中同时汇总了高频报错的对应解决方案&#xff0c;力求…

作者头像 李华
网站建设 2026/10/14 16:18:55

基于微信小程序的社区团购系统:SSM后端下单链路与库存防超卖实战

简介&#xff1a;这是一套面向计算机相关专业学生与项目实战学习者的微信小程序社区团购系统毕业设计源码&#xff0c;采用微信小程序前端搭配SSM&#xff08;Spring、SpringMVC、MyBatis&#xff09;后端架构&#xff0c;覆盖需求分析、系统设计、编码实现到测试的完整开发流程…

作者头像 李华
网站建设 2026/10/14 16:18:54

Apache Tomcat 8.5.16 zip版:解压部署、配置与避坑全攻略

简介&#xff1a;Apache Tomcat v8.5.16 是一款成熟稳定的 Java Web 应用服务器&#xff0c;常被开发者和运维工程师用来托管 Servlet、JSP 等动态站点&#xff0c;并为后续生产环境部署打下基础。压缩包是接近官方发布形态的完整目录&#xff0c;共 637 个文件、约 9.53MB&…

作者头像 李华