☰
地铁换乘算法解密:从图论建模到BFS与Dijkstra的工程落地
2026/10/8 20:26:48 网站建设 项目流程

有朋友在人民广场站问我去XX怎么走,我掏出手机,输入起点和终点,App不到一秒给出方案:哪条线、在哪个站换乘、全程多久。大多数人看一眼就跟着走了,但如果你是做技术的,你可能会像我一样好奇:就是这一下点击,屏幕上这条换乘方案到底是怎么算出来的?它背后是一个典型的地铁换乘算法。如果你正打算给自己的项目加一个地铁换乘模块,或者刚接触图论、BFS、Dijkstra这些名词,想搞懂它们在真实路网里怎么落地,这篇应该对你有用。我会从路网数据建模讲起,拆解最少换乘和最短时间两种核心查询,再聊换乘通道走行、首末班车这些绕不开的现实约束,最后说说性能和工程落地的细节。

1. 把人民广场“塞进”计算机:地铁路网的数据建模

1.1 一张图装下一张网:节点、边与权重

地铁换乘算法在动手写代码之前,第一件事不是查BFS的模板,而是把地铁网络抽象成计算机能理解的结构。我见过不少新手一上来就在"线路"上做文章,比如把2号线当成一个数组、把换乘站当成两个数组之间的交集。这样想问题不是不行,但一旦涉及换乘通道、不同线路交路运行的情况,代码会越写越乱。

更靠谱的做法是把整个网络抽象成一张带权图:每个车站是一个节点,车站与车站之间的区间是一条边,边的权重是列车跑完这段区间的时间,一般用秒做单位。为什么不能把一条线缩成一条"超边"?因为地铁是逐站停靠的。8号线从人民广场到大世界、老西门、陆家浜路,是一站一站开过去的。如果建模时把整条线当一个整体,就没法回答"从老西门上车到人民广场要多久"这种区间查询,更没法处理海量的中途站上下车需求。所以必须按站间区间切边,每个相邻车站对之间建一条边。

这种图和公路导航的图还有一个本质区别:公路导航依靠坐标和路网几何找路,而地铁规划在逻辑拓扑上做搜索。站点在地图上的经纬度,主要是用来渲染图标、估算直线距离的,真正决定路线的是站点之间的拓扑连接关系。把这两个层次分开,后续做可视化、做距离估算都会省很多事。

1.2 换乘站建模:站点合并与节点拆分

换乘站是建模时最容易翻车的地方。最简单的做法是:一个物理车站不管经过几条线路,共用同一个节点。图是干净了,搜索也快,但代价是换乘成本直接被清零——从1号线站台走到2号线站台的几百米、上楼下楼的几分钟,在模型里变成"瞬间移动"。这种模型跑出来的方案,看起来很优,走起来想骂人。尤其遇到需要出站换乘的车站,误差会被放大到完全不能用的程度。

正确的做法是"拆分节点":一个换乘站有几条线路,就拆成几个节点,分别代表"1号线人民广场站台""2号线人民广场站台""8号线人民广场站台",这些节点之间用"换乘边"连接,边的权重就是真实的换乘走行时间。这样换乘成本就进入到了算法视野里,路径规划才会给出合理建议。

建模方式换乘成本主要优点主要缺点
合并节点恒为0实现简单、图规模小忽略换乘时间;出站换乘被当成无缝换乘
拆分节点每条换乘边独立赋值与真实体验一致;可区分同台/通道/出站换乘节点数量增加;换乘走行数据需要持续维护

拆分之后还有一个容易忽略的进阶细节:换乘边建议做成有向的。有些换乘站从1号线换2号线要走五分钟,反方向因为有自动步道可能只要三分钟;有些通道在不同方向的开放时间还不一样。有向边能精确表达这些不对称性。凡是做过真实换乘数据标注的同行,应该都懂这个点。

注意:换乘边需要记录的字段,比想象中多——走行距离、预估时间、是否出站、是否同一张卡计费、是否有无障碍通道。前期建模留好扩展位,后期接数据时才不会返工。

1.3 可直接落地的路网数据结构

把上面的思路落成代码。下面是一个演示用的路网结构,站名借用上海真实站点,但时间和边关系做了简化,不表示真实运营数据。

# 站点表:id -> 站名 STATIONS = { 101: "人民广场", 102: "南京东路", # 2号线 103: "陆家嘴", # 2号线 201: "大世界", # 8号线 202: "老西门", # 8号线 301: "黄陂南路", # 1号线 } # 邻接表:station_id -> list of edges # edge = { # "to": 目标站点id, # "line": 所属线路,换乘边用 None, # "type": "travel" 运行边 / "transfer" 换乘边, # "cost": 时间成本(秒) # } GRAPH = { 101: [ {"to": 102, "line": "L2", "type": "travel", "cost": 150}, {"to": 201, "line": "L8", "type": "travel", "cost": 180}, {"to": 301, "line": "L1", "type": "travel", "cost": 140}, # 换乘边:101 拆出的3个节点之间的连接 {"to": 301, "line": None, "type": "transfer", "cost": 240}, ], 102: [ {"to": 101, "line": "L2", "type": "travel", "cost": 150}, {"to": 103, "line": "L2", "type": "travel", "cost": 180}, ], # 其余站点省略 }

这里的关键是每条边都带上了type字段。为什么要区分运行边和换乘边?因为不同的查询目标要用不同的字段:最少换乘查询只关心type == "transfer"的边有多少条;最短时间查询则需要累加cost。同一份图,两个算法各取所需,互不干扰。实际工程里,调度数据、首末班时间、发车间隔也都挂在边上,先把基础图跑通,再往后接这些复杂字段。

2. 最少换乘与最快到站:两种目标各自对应的算法

2.1 最少换乘查询:0-1 BFS的妙用

先看一个实际需求:用户只关心"换乘几次",不太在意总时间。这个诉求很真实——拎着行李箱、带着老人孩子、对路线不熟的人,宁可多坐一会儿,也想少倒腾一次。

最少换乘问题等价于:在图上找到一条从起点到终点的路径,使其中换乘边的数量最少。运行边的代价是0,换乘边的代价是1,这是一个典型的0-1权重最短路问题。直接套用普通BFS会踩坑,因为普通BFS按"层"扩展,一层代表一条边,但在这里一次扩展可能经过很多条运行边、也可能遇到一条换乘边,层数和换乘次数对不上。

解法是0-1 BFS:使用双端队列,运行边(权重0)到达的节点插到队首,换乘边(权重1)到达的节点插到队尾。这样队列里的距离天然保持非递减,每个节点第一次弹出时,它的距离就是最小换乘次数。

from collections import deque def min_transfers(graph, start, target): # dist 记录从 start 到每个站点的最少换乘次数 dist = {station: float("inf") for station in graph} dist[start] = 0 dq = deque([start]) while dq: u = dq.popleft() if u == target: return dist[u] for edge in graph[u]: v = edge["to"] # 换乘边 +1,运行边 +0 w = 1 if edge["type"] == "transfer" else 0 if dist[u] + w < dist[v]: dist[v] = dist[u] + w if w == 0: dq.appendleft(v) else: dq.append(v) return -1 # 不可达

这套逻辑跑在刚才那份GRAPH上,从101出发找103,最少换乘结果是0还是1,取决于图里101到103之间是否存在换乘边。复杂度是O(V+E),对几百个站点的地铁网来说是降维打击,快得没有存在感。

2.2 最短时间查询:带权图上的Dijkstra

换一个目标:用户要最快到站。这时边的权重就不能只看换乘次数了,运行时间、换乘走行时间都得加进来。问题变成标准的单源正权最短路,用Dijkstra解决。

Dijkstra的核心逻辑一句话:用优先队列按当前累计时间排序,每次弹出累计时间最小的节点做松弛;因为所有边权都是正数,一旦某个节点被弹出,它的最短时间不会再被更新。地铁网络里全是正权边,不存在负权环的困扰,Dijkstra就是最合适的工具,压根不用上Bellman-Ford。

import heapq def shortest_time(graph, start, target): dist = {station: float("inf") for station in graph} prev = {} dist[start] = 0 pq = [(0, start)] while pq: cur_cost, u = heapq.heappop(pq) if cur_cost > dist[u]: continue if u == target: break for edge in graph[u]: v = edge["to"] new_cost = cur_cost + edge["cost"] if new_cost < dist[v]: dist[v] = new_cost prev[v] = u heapq.heappush(pq, (new_cost, v)) # 重建路径 path = [] if dist[target] < float("inf"): node = target while node != start: path.append(node) node = prev[node] path.append(start) path.reverse() return dist[target], path

这里有一点值得注意:edge["cost"]对运行边是区间运行时间,对换乘边是走行时间。也就是说跑Dijkstra之前,换乘边的权重必须先标定好,否则算出来的"最短时间"只是"轨道上的最短时间",不是"人真正花掉的时间"。这个细节直接关系到下一节的工程话题。

2.3 同样到人民广场,两种算法的推演差异

为了把两个算法的区别讲透,我构造一个小型示意路网:一条环线L1和一条放射线L2。起点A在环线北侧,目标人民广场在环线西南方向,L2从环线上的B站直插人民广场。

方案一:全程沿L1绕大半个环,9站约36分钟,换乘0次,但绕路。方案二:L1坐2站到B,换乘L2后坐5站到人民广场,运行时间28分钟,再加上B站的换乘走行4分钟,总共32分钟。

BFS(0-1 BFS)会选方案一:换乘0次,这是它目标函数下的最优解。Dijkstra会选方案二:32分钟小于36分钟,尽管多了一次换乘。两个算法没有谁对谁错,只是各自回答的问题不一样。这也是我在实际项目里最喜欢向产品经理解释的一个结论:不要问"哪个算法更好",要先问"你要给用户推荐什么样的方案"。

放到真实路网里,这个差异经常以另一种方式呈现在App界面上:从郊区到市中心,有时0换乘的线路是沿环线绕一个大弧形,时间很长;换乘一次的放射线却能直接切进去,时间短得多。于是用户会看到两个标签,"少换乘"和"时间短",背后就是这两类算法的不同偏好。你的产品默认给哪个结果,决定了用户体感。

3. 换乘通道、末班车和用户偏好:真实世界如何改写“最优”

3.1 从同台换乘到出站换乘:换乘成本怎么标定

图论算法本身很干净,但真实世界的换乘成本是"脏"的。同样是换乘,不同场景的耗时天差地别。下表是我在项目里常用的参考值,具体数值因车站而异,但量级基本靠谱。

换乘类型参考走行时间典型场景
同台换乘1-3分钟对面站台下车直接上车
站厅换乘3-6分钟上下楼加站厅穿行
通道换乘6-12分钟超长换乘通道,如大型枢纽
出站换乘10分钟以上刷卡出站、重新进站

这些成本在建模时就写成换乘边的cost。如果换乘边长距离里还有自动步道、扶梯方向差异,就用有向边把两个方向分开标。别小看这几分钟的差异,它直接改变Dijkstra的排序结果。我见过一个真实案例:某站从A线换B线走通道要8分钟,反方向因为有平行扶梯只需4分钟。把两个方向都设成6分钟的"平均值",会让一半用户多走冤枉时间。

出站换乘是另一个大坑。它涉及刷卡出站再进站,可能产生额外费用,在部分票制下甚至算两段行程。給它标一个10分钟以上的成本还不够,最好在边上加一个"requires_exit"标记,前端渲染时明确提示"需出站换乘",避免用户以为是无缝换乘。

3.2 首末班车与发车间隔:静态图不够,得看时间

静态图解决不了"晚上十点半查路线还来不来得及"的问题。地铁换乘算法如果在真实产品里上线,必须回答动态时间约束。

轻量做法是三层检查:查询发起时,先拿当前时间过滤一遍所有线路,末班已经开走的线路直接标记不可用;然后对仍然可用的线路,把运行状态附加到边上,比如某方向末班已过就把对应区间边设成已禁用;最后再跑Dijkstra。这个方案实现快、缓存友好,适合大部分场景。

精细做法是把边权改成到达时间的函数,使用Time-Dependent Dijkstra。松弛一条边时,计算的是"当前到达时刻 + 等下一班车的候车时间 + 运行时间",而不是一个固定值。这么做更准确,但实现、测试、调参的复杂度都会上一个台阶,而且时刻表数据本身还需要持续维护。我的经验是:先做轻量版验证产品,用户反馈确实需要更精确的末班场景时,再上时间依赖模型。

发车间隔同样会影响结果。早高峰2号线间隔短,候车1-3分钟;平峰可能5-8分钟。把平均候车时间(发车间隔的一半)加进边的cost里,是很多产品在用的折中方案。这也是为什么同一段区间在不同时段会有不同"虚拟耗时"——不是列车跑慢了,是等车时间变了。

3.3 只给一条“最优”远远不够:备选路线的实用生成

真实用户的偏好远比"时间最短"复杂。怕换乘的人宁可多坐十分钟;赶时间的人愿意多换乘;天气不好时,全程走地下的人优先。一个成熟的地铁规划模块,至少要给出两三条有差异的备选路线。

最实用的生成方法不是上来就写Yen算法的K短路,而是多目标并行:同时跑一遍"最少换乘"和"最短时间",再按一个折中的权重(比如换乘一次等效增加5分钟)跑一遍。三条路线放一起,用户的诉求基本都能覆盖。

我也试过Yen算法求K短路,但踩了一个坑:K短路经常给出只在最后两站换了个入口、看起来几乎一模一样的路线,对用户来说就是重复推荐。解决方向是"路线多样性优化"——跑完第一条后,把这条路线经过的边权临时调高,再跑第二遍,迫使算法换路。对地铁这种几百节点的小图,跑两三次Dijkstra时间上完全没压力,却比维护一套K短路实现省心得多。

提示:备选路线之间最好有"可感知的差异"。判定标准可以是"共用站点的比例低于某个阈值"。如果App给用户弹三条几乎一样的方案,用户反而会觉得这个产品不用心。

4. 从一次查询到全城路网:性能优化与落地细节

4.1 城市级路网的性能真相:复杂度到底卡不卡

很多人一看到"路径规划"就搬出A*、双向Dijkstra、CH(Contraction Hierarchies),生怕性能扛不住。但请先看一眼数据规模:一线城市的地铁网络也就几百个站点、上千条区间边。用Python堆优化的Dijkstra跑一次单条查询,时间是毫秒级;就算每个请求从零开始算,一秒钟也能扛住几百个查询。性能从来不是地铁换乘模块的第一瓶颈。

真正需要上A*的场景,是地铁、公交、步行、骑行叠加在一起的联合路网,那个图动辄几万几十万个节点。如果只是在纯地铁路网里做换乘算法,我个人的建议是:先把BFS和Dijkstra用最教科书的方式写对,加好缓存,观察线上指标,再决定要不要加速。过早优化很容易变成自娱自乐。

万一你真要上A*,启发式函数按"当前站到目标站的直线距离除以地铁最高旅行速度"来估算时间下界。因为直线距离永远不会大于轨道距离,最高速度永远不会低于平均速度,这个启发式是admissible的,A*能保证返回最优解。

import math def heuristic(u, target): # 简化:用经纬度算直线距离 lat1, lon1 = STATION_COORDS[u] lat2, lon2 = STATION_COORDS[target] # 每度纬度约 110540 米,每度经度约 111320 米乘纬度余弦 dx = (lon2 - lon1) * 111320 * math.cos(math.radians((lat1 + lat2) / 2)) dy = (lat2 - lat1) * 110540 dist_meters = math.hypot(dx, dy) # 按约 60km/h(16.7m/s)旅行速度估算时间下界,单位秒 return dist_meters / 16.7

这段代码放在Dijkstra的优先队列里当排序键,就能让搜索方向明显偏向目标站点,减少无谓的扩散。但请记住,这是在"真的需要"的时候才做的事。

4.2 预处理、缓存与路网更新:把查询成本摊平

纯地铁路网那么小,查询耗时不是瓶颈,真正吃性能的是"每次查询都从数据库加载整张图"和"反复计算热门路线"。工程上的做法很朴素:

第一,站点ID全部整数化,邻接表在服务启动时加载进内存,展开成紧凑的数组结构,不要每次查询都查数据库。第二,热门OD对做结果缓存,尤其是各地到枢纽站(人民广场、虹桥站、机场站)这类方向,预计算一批结果扔进缓存,命中直接返回。第三,路网更新用版本号机制管理,新线开通后重新生成路网文件,发布新版本,缓存按版本失效。不要每次变化都全量重算,那是在浪费机器。

还有一个容易忽视的数据维护点:换乘边的走行时间不是一成不变的。某个通道加了自动步道、换乘口挪了位置,都会改变真实换乘时间。数据刷新频率至少做到季度级,并且要有反馈闭环——用户报错"走错了"就是个很好的数据修正信号。数据质量决定换乘算法体验,这句话真不是空话。

4.3 从站点序列到可读路线:最后一公里的信息渲染

算法输出是一串站点ID,比如101 → 102 → 201。用户不可能盯着节点序列坐地铁。真正交付给用户的,应该是"坐8号线,从人民广场上车,在大世界站换乘1号线"这种自然语言指引。

渲染层的工作流程是这样的:遍历路径上的边,把连续type == "travel"且line相同的边合并成一个乘车段,输出"乘坐XX号线,从XX站到XX站,共X站";遇到type == "transfer"的边,输出"在XX站换乘XX号线",并附上步行指引。这个逻辑本身不复杂,但它依赖图数据从一开始就带对了元信息——边上的line字段就是为渲染层准备的。

我在这上面栽过跟头。有一版实现把换乘边单独放一张表,但没有记录换乘后进入哪条线路,结果渲染层要显示换乘信息时还得反向查站点、猜线路,代码里全是临时补丁。所以强烈建议:邻接表从第一版设计开始就带上line、direction这些字段,宁可现在用不上,也别等到UI层需要时再补。

我做这个模块最大的教训是:算法要俯身服务真实世界,而不是让真实世界迁就算法。最早一个版本只把站点间轨道距离塞进边权,结果导出一条换乘方案,要乘客从站厅这头穿到那头、再绕回同一个站台,看起来时间最短,走起来像马拉松。后来把换乘通道步行距离、候车时间、楼层转换全部揉进权重,算法才给出像人话的方案。如果你也要写地铁换乘算法,我的建议很简单:先把数据理干净,线路、换乘通道、首末班车一张张表核对好,算法哪怕用最教科书的BFS加Dijkstra,也能跑出让人满意的体验。算法从来不是这类系统的瓶颈,数据和产品理解才是。

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

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

立即咨询