2026/8/27 7:19:03

电梯换乘最短时间建模:Dijkstra算法与状态节点图实战

电梯换乘最短时间建模:Dijkstra算法与状态节点图实战 1. 从电梯换乘到最短时间一个经典建模问题的核心拆解最近在整理一些经典的算法竞赛题目又翻到了Uva 10801 “Lift Hopping”这道题。题目名字听起来有点抽象但说白了就是一个关于“电梯换乘”的建模问题。想象一下你在一栋有100层高的大楼里楼里有好几部电梯每部电梯有自己的运行速度并且只停靠特定的楼层。你现在在0楼目标是尽快到达第K楼。你可以在电梯运行的任意楼层下电梯然后步行到同一楼层的另一部电梯去换乘。步行时间忽略不计但电梯的运行时间取决于它的速度和停靠的楼层间隔。这听起来是不是很像我们每天在写字楼或者酒店里会遇到的情况只不过题目把它抽象成了一个数学模型。很多人第一次看到这个题可能会觉得“这不就是个最短路径问题吗用Dijkstra算法不就行了” 道理是这个道理但真动手去建这个模你会发现里面有不少细节需要仔细推敲比如状态怎么定义边权时间怎么计算特别是“平行时间”这个思路以及用“桶”来记录电梯停靠信息的小技巧都是让代码既高效又清晰的关键。今天我就结合自己多次实现和教学的经验把这个问题的完整思考路径和实现细节拆解清楚重点聊聊如何把生活场景转化为一个严谨的图论模型以及如何用优先队列优化的Dijkstra算法高效求解。2. 问题本质与建模核心将电梯网络抽象为图我们首先得把题目描述的场景翻译成算法能理解的语言——图Graph。2.1 图的节点定义不仅仅是楼层最直观的想法是把每个楼层当作图的一个节点。如果这么简单问题就太容易了。这里的关键在于同一楼层对于不同的电梯而言意义可能不同。假设你在5楼有一部电梯A停靠5楼另一部电梯B也停靠5楼。如果你在电梯A里到达5楼你可以选择走出电梯A即结束A上的旅程然后在这个5楼的“公共区域”换乘到电梯B。因此图中的一个节点不能仅仅是“第i层”而应该是“在电梯e上位于第i层”这样一个二元组(elevator_id, floor)。更进一步的简化是我们可以给每个“物理楼层”赋予多个“逻辑节点”每个节点对应一部能到达该楼层的电梯。但更常见的、编码更简洁的做法是使用一个全局唯一的节点ID。一个广泛采用的建模方法是将每个“状态”定义为一个节点状态由“所在的楼层”和“乘坐的电梯”共同决定。但是这里有一个特殊状态当你在某个楼层但还没有进入任何电梯即处于“换乘点”或“起点”时你乘坐的电梯可以记为-1或一个特殊值。然而这种表示在实现时稍显繁琐。更优雅且高效的做法是将每个“物理楼层”都看作一个节点。然后我们巧妙地利用边来隐含“电梯”和“换乘”信息。具体来说电梯边对于同一部电梯它停靠的任意两个楼层之间存在一条双向边。边的权重就是这部电梯从一层运行到另一层所需要的时间。计算方法是abs(floor_a - floor_b) * elevator_speed[e]。注意如果电梯是每层都停那这就是一个完全图边数会爆炸。但题目通常给出的是电梯的停靠楼层列表因此我们只需要在列表中相邻的两个停靠楼层之间建边即可。换乘边对于同一个物理楼层如果有多部电梯停靠那么从这些电梯的“状态”切换到该楼层的“换乘状态”需要时间吗题目说“步行时间忽略不计”所以换乘本身的时间成本为0。但在我们的“单层节点”模型里如何体现换乘呢我们可以在同一个物理楼层节点上连接所有停靠该楼层的电梯边。当你通过一条电梯边到达某个楼层节点时你就自动处于该楼层的“换乘点”可以免费0时间成本切换到从这个楼层出发的任何其他电梯边。这实际上意味着一个楼层节点连接了多部电梯的“线路”。但这种“单层节点”模型在计算时间时有个问题从5楼到10楼坐电梯A需要5 * speed_A时间。如果我在10楼下电梯然后立刻换乘电梯B去15楼需要5 * speed_B时间。这里在10楼换乘没有额外时间。模型是成立的。然而题目还有一个关键条件每次进入一部电梯包括起点第一次进入需要等待60秒。这个“进入成本”在我们的模型里必须体现。因此节点仅仅定义为“楼层”就不够了因为“进入电梯”这个动作发生在从某个楼层节点通过某条电梯边离开时。我们需要把“进入成本”附加在边上而不是节点上。经过以上分析一个更精准的模型浮出水面节点每个物理楼层0到100都是一个节点。此外我们还需要一个虚拟的“起点”节点吗其实不必我们可以把起点0层视为一个普通节点。边电梯运行边对于电梯e在其停靠楼层列表[f1, f2, ..., fk]中对于每一对相邻楼层(fi, fj)创建一条双向边fi - fj。这条边的权重是abs(fi - fj) * speed[e]。电梯进入边这才是处理60秒等待时间的关键。我们不能简单地把60秒加在电梯运行边上因为从同一个楼层换乘到不同的电梯这60秒只应计算一次换乘时而从起点第一次进入电梯也要计算一次。一个巧妙的处理方法是在算法初始化时将所有从起点0层出发通过电梯边到达其他节点的“初始距离”设置为电梯运行时间 60。而对于后续的换乘当我们在节点u楼层时如果我们想通过电梯e的边前往节点v那么这条路径的总时间应该是dist[u] 电梯运行时间(u-v) 60。这里的60就是换乘进入新电梯的代价。但是仔细想想如果我们在节点u已经是乘坐电梯e到达的那么从u继续乘坐电梯e前往v不应该再支付60秒因为我没有换电梯。所以60秒的代价只发生在“切换电梯”或者“从起点首次进入电梯”的时刻。这就要求我们的图模型或者算法状态必须能识别“当前所在的电梯”。这引出了最经典和正确的建模方法状态节点 (楼层, 电梯ID)。如果当前没在电梯里如在起点或换乘点电梯ID可以设为-1或一个特殊值或者单独处理。2.2 经典建模状态节点图让我们采用最清晰的建模方式节点定义为一个二元组(floor, elevator_id)其中elevator_id表示当前乘坐的电梯编号。特别地定义节点(start_floor, -1)表示在起点楼层且未上电梯的状态。边电梯运行边对于节点(f1, e)如果电梯e也停靠f2并且f1和f2在电梯e的停靠列表中相邻那么存在一条到节点(f2, e)的边权重为abs(f1 - f2) * speed[e]。这条边表示在同一部电梯内移动不产生换乘成本。换乘边对于节点(f, e1)如果存在另一部电梯e2 (e2 ! e1)也停靠楼层f那么存在一条到节点(f, e2)的边权重为60。这条边表示在楼层f下电梯e1然后换乘到电梯e2。注意从(f, -1)起点状态到(f, e)的边权重也是60表示从楼层f首次进入电梯e。起点初始化我们的起始状态是(0, -1)。目标状态是所有电梯ID为任意值但楼层为K的节点即(K, *)。因为只要到达K层无论乘坐哪部电梯都算到达。这个模型完美地区分了运行时间和换乘等待时间是解决此题最准确的图模型。接下来我们的任务就是在这样一个可能规模较大的图上跑一遍单源最短路径算法求从(0, -1)到任意(K, *)的最短时间。3. 算法选择与优化Dijkstra与优先队列一旦建立了图模型求解单源最短路径就是自然而然的选择。在所有的最短路算法中Dijkstra算法是针对非负权图的经典且高效的算法。本题中边权时间和等待秒数均为正数因此Dijkstra算法完全适用。3.1 为什么是Dijkstra简单回顾一下Dijkstra算法的核心思想是贪心每次从未确定最短距离的节点集合中选取一个距离源点最近的节点认为它的当前距离就是最终的最短距离然后用它来松弛更新其邻居节点的距离。对于普通的实现使用邻接矩阵复杂度是O(V²)使用邻接表是O(V²)如果每次线性扫描找最小距离节点。对于本题节点数最多可能是 (楼层数101 * 电梯数n)n最大为5楼层100所以节点数最多约500个。O(V²)约25万次操作在现代计算机上勉强可以但绝非最优。3.2 优先队列优化从O(V²)到O(E log V)优先队列通常用最小堆实现优化是Dijkstra算法的标准提速手段。其核心是我们不再需要每次线性扫描所有未确定节点来寻找最小值而是用一个优先队列小顶堆来动态维护所有“距离被更新过且未最终确定”的节点。每次从堆顶取出距离最小的节点如果这个节点已经被处理过距离已确定则跳过否则用它来松弛邻居。如果邻居的距离被更新就将邻居及其新距离放入优先队列。这样每个节点最多入队一次实际上可能多次但每次被取出时如果已确定则跳过每次入队和出队操作是O(log V)。总的时间复杂度可以优化到O((VE) log V)对于稀疏图E远小于V²非常高效。在我们的建模中每个电梯在其停靠楼层间建立的边是线性的换乘边也只在同楼层不同电梯间存在所以图是稀疏的优先队列优化效果显著。实现细节数据结构使用priority_queueC或heapqPython。队列元素通常为(当前距离, 节点标识)。注意C的priority_queue默认是大顶堆所以需要传入greater比较函数或者存储负距离。距离数组dist[floor][elev_id]初始化为无穷大。dist[0][-1] 0。节点标识为了便于在优先队列和距离数组中索引我们需要将二维状态(floor, elev_id)映射成一个一维的整数ID。一个简单的方法是node_id floor * (E1) (elev_id1)其中E是电梯总数elev_id从-1到E-1。这样可以将所有状态线性存储。3.3 “平行时间”思路对Dijkstra过程的另一种理解所谓“平行时间”思路并不是一种新的算法而是对Dijkstra算法执行过程的一种形象化理解有助于我们思考状态转移。我们可以想象时间在流逝。在时间t0时只有起点(0, -1)是“活跃”的。当时间到达60秒时所有从起点0层可以进入的电梯即停靠0层的电梯其对应的状态(0, e)就变得“可达”了代价是60秒进入等待。此时这些状态被加入优先队列。从这些(0, e)状态开始电梯e开始向上或向下运行。比如电梯e的速度是5秒/层那么4秒后总时间64秒状态(4, e)可能被达到如果电梯停靠4层。同时其他电梯也在自己的线路上运行。Dijkstra的优先队列就像一个“事件调度器”总是处理当前“已知最早发生”的事件即距离最小的节点。这个事件可能是“在时间T1到达了楼层f1在电梯e1上”。处理这个事件时我们会做两件事继续乘坐当前电梯生成新事件“在时间T1 Δt 到达楼层f2仍在电梯e1上”放入队列。换乘生成新事件“在时间T1 60 到达楼层f1但在电梯e2上”放入队列。这种“平行推进”的感觉就是“平行时间”思路。它强调所有可能的路径是在时间线上并行探索的而Dijkstra算法保证了我们总是按照时间顺序距离顺序来处理这些事件从而第一次处理到目标楼层节点时所用的时间就是最短时间。这个理解对于后续调试和验证算法正确性很有帮助。4. 关键实现技巧“桶”记录与邻接关系构建建模和算法思路清晰后实现环节的挑战主要在于如何高效地构建这个图。特别是“换乘边”的建立需要快速知道“哪些电梯停靠了某个给定的楼层”。如果每次需要时都去遍历所有电梯的停靠列表时间复杂度会很高。这里就需要用到“桶”Bucket或者说“倒排索引”的思想。4.1 “桶”数据结构的设计我们创建一个数组或列表floors_to_elevators其下标是楼层号0-100值是一个列表存储所有停靠该楼层的电梯ID。# Python示例 floors_to_elevators [[] for _ in range(101)] # 假设楼层0-100 for elev_id, stops in enumerate(elevator_stops_list): for floor in stops: floors_to_elevators[floor].append(elev_id)这样对于任意楼层ffloors_to_elevators[f]立刻给出了所有停靠此楼层的电梯。构建这个结构的时间复杂度是 O(总停靠站数)查询是 O(1)。4.2 利用“桶”构建换乘边在Dijkstra算法的松弛过程中当我们在状态(f, e1)时如果需要尝试换乘我们不再需要遍历所有电梯而是直接查询floors_to_elevators[f]。current_state (current_floor, current_elev) current_time dist[current_floor][current_elev] # 操作1继续乘坐当前电梯 (如果 current_elev ! -1) if current_elev ! -1: # 获取当前电梯的停靠列表 stops elevator_stops[current_elev] # 找到当前楼层在停靠列表中的索引然后向相邻楼层移动 idx stops.index(current_floor) # 向上一个停靠站移动 if idx 0: prev_floor stops[idx-1] travel_time abs(current_floor - prev_floor) * speed[current_elev] new_time current_time travel_time # 松弛操作: if new_time dist[prev_floor][current_elev]: update... # 向下一个停靠站移动 if idx len(stops)-1: next_floor stops[idx1] travel_time abs(current_floor - next_floor) * speed[current_elev] new_time current_time travel_time # 松弛操作... # 操作2换乘到其他电梯 (包括从-1状态首次进入) # 获取所有停靠当前楼层的电梯 for next_elev in floors_to_elevators[current_floor]: if next_elev current_elev: continue # 同一部电梯不需要换乘边 transfer_time 60 new_time current_time transfer_time # 松弛操作: if new_time dist[current_floor][next_elev]: update...通过“桶”我们高效地处理了换乘逻辑。注意对于起点状态(0, -1)current_elev -1此时“操作1”不执行只执行“操作2”这正好对应了从起点首次进入电梯需要60秒等待。4.3 邻接表构建的取舍在上面的代码中我采用了“隐式建边”的方式即在Dijkstra的松弛步骤中根据当前状态动态计算可能的下一状态和边权而不是预先建立一个完整的邻接表。这是因为我们的边规则相对规整电梯内移动、同层换乘动态计算比存储一个可能很大的邻接表更节省内存代码也更清晰。对于电梯运行边我们只需要存储每部电梯的有序停靠列表和速度。当处理状态(f, e)时在电梯e的停靠列表中找到f的位置就能立刻知道相邻的停靠楼层并计算出边权。这种“用时计算”的方式结合“桶”记录换乘关系是解决此类问题非常典型的空间换时间或者说用计算换清晰度的策略。5. 完整解题流程与代码框架将以上所有部分串联起来我们可以梳理出完整的解题步骤。5.1 输入处理与数据结构初始化首先读取输入。输入格式通常是第一行是N目标楼层K和电梯数量n。随后n行每行给出电梯的速度和停靠楼层列表。import sys import heapq def solve(): data sys.stdin.read().strip().split() if not data: return it iter(data) K int(next(it)) n int(next(it)) speeds [] stops [] floors_to_elevators [[] for _ in range(101)] # 桶 for elev_id in range(n): speed int(next(it)) speeds.append(speed) # 读取该电梯的停靠楼层列表直到行尾实际处理需根据输入格式调整这里假设一行内读完 # 例如输入可能是“15 0 1 2 3 4 5”表示速度15停靠0,1,2,3,4,5层 # 这里用循环读取直到遇到换行或文件结束简化起见假设我们知道个数或遇到特定分隔符。 # 更健壮的做法是按行读取。 stop_list [] # ... 解析停靠楼层添加到stop_list ... for floor in stop_list: if 0 floor 100: # 边界检查 floors_to_elevators[floor].append(elev_id) stops.append(sorted(stop_list)) # 确保停靠列表有序注意输入格式可能是每行一个电梯的信息速度后面跟着若干个停靠楼层用空格分隔。需要妥善处理每行的结束。有时输入中楼层可能超过100根据题目说明处理或忽略。5.2 Dijkstra算法实现优先队列优化初始化距离数组和优先队列。节点状态用(floor, elev_id)表示elev_id为 -1 到 n-1。INF 10**9 # dist[floor][elev_id1]将elev_id偏移1以处理-1的情况 dist [[INF] * (n1) for _ in range(101)] # 状态映射elev_id -1 存储在索引0, elev_id0存储在索引1, 以此类推 START_ELEV_IDX 0 # 对应 elev_id -1 pq [] # 优先队列元素 (time, floor, elev_idx) dist[0][START_ELEV_IDX] 0 heapq.heappush(pq, (0, 0, START_ELEV_IDX)) while pq: current_time, current_floor, elev_idx heapq.heappop(pq) # 如果取出的时间大于当前记录的距离说明是旧数据跳过 if current_time dist[current_floor][elev_idx]: continue # 如果到达目标楼层可以提前结束因为Dijkstra第一次取出目标节点即是最短 if current_floor K: # 注意我们需要的是所有电梯状态中到达K层的最小值不一定第一次pop的就是。 # 更稳妥的是继续运行直到队列为空或者记录最小值。 pass current_elev_id elev_idx - 1 # 转换回原始电梯ID-1, 0, 1, ... # 操作1继续乘坐当前电梯如果当前在电梯上 if current_elev_id ! -1: stop_list stops[current_elev_id] speed speeds[current_elev_id] # 找到当前楼层在停靠列表中的索引 try: idx stop_list.index(current_floor) except ValueError: # 理论上不应该发生因为状态(current_floor, current_elev_id)意味着该电梯停靠该层 continue # 向左向下列表中的前一个停靠站移动 if idx 0: prev_floor stop_list[idx-1] travel_time abs(current_floor - prev_floor) * speed new_time current_time travel_time if new_time dist[prev_floor][elev_idx]: # elev_idx 不变因为电梯没换 dist[prev_floor][elev_idx] new_time heapq.heappush(pq, (new_time, prev_floor, elev_idx)) # 向右向下列表中的后一个停靠站移动 if idx len(stop_list) - 1: next_floor stop_list[idx1] travel_time abs(current_floor - next_floor) * speed new_time current_time travel_time if new_time dist[next_floor][elev_idx]: dist[next_floor][elev_idx] new_time heapq.heappush(pq, (new_time, next_floor, elev_idx)) # 操作2换乘包括从起点进入电梯 # 遍历所有停靠当前楼层的电梯 for next_elev_id in floors_to_elevators[current_floor]: next_elev_idx next_elev_id 1 # 转换为dist数组的索引 if next_elev_idx elev_idx: continue # 同一部电梯无需换乘边 transfer_time 60 new_time current_time transfer_time if new_time dist[current_floor][next_elev_idx]: dist[current_floor][next_elev_idx] new_time heapq.heappush(pq, (new_time, current_floor, next_elev_idx))5.3 答案提取与输出算法结束后dist[K][*]中存储的就是从起点到目标楼层K且处于不同电梯状态下的最短时间。我们需要的是所有状态中的最小值。注意状态(K, -1)即到达K层但没在电梯里也是有效的其时间可能比在某些电梯里更短虽然题目要求是到达K层无论是否在电梯内。ans min(dist[K]) # 取dist[K]列表中所有值的最小值 if ans INF: print(IMPOSSIBLE) else: print(ans)5.4 边界情况与测试起点电梯如果没有任何电梯停靠0层那么除了起点状态(0, -1)其他所有状态都不可达最终答案可能是IMPOSSIBLE。目标楼层同样如果没有任何电梯停靠K层那么答案也是IMPOSSIBLE。我们的算法中floors_to_elevators[K]为空意味着没有状态能通过换乘边到达(K, *)但有可能通过电梯运行边直接到达如果一部电梯的终点是K层那么它可以到达(K, e)。所以IMPOSSIBLE的判断标准是min(dist[K]) INF。电梯速度为零题目通常保证速度为正。同一楼层多次出现在电梯停靠列表需要去重或者我们的算法能处理index()方法会返回第一个索引但计算相邻楼层时可能出错。最好在读取输入后对每个电梯的停靠列表进行排序和去重。6. 从Uva10801到更广泛的建模思维解完这道题我们收获的不仅仅是一个AC的代码更重要的是一种将复杂约束条件转化为图论模型的思维方法。这种“状态节点”的建模技巧在很多问题中都有应用比如分层图在处理“有K次机会可以免去某条边的代价”这类问题时可以将状态定义为(节点, 已使用机会次数)。多维度状态像经典的“迷宫带钥匙”问题状态是(位置, 手中钥匙的集合)。时间维度有些问题中边权或节点可用性与时间相关可以将时间也作为状态的一维。对于Uva10801我们通过(楼层, 电梯ID)这个状态清晰地将“换乘等待60秒”这个条件编码进了图中换乘边的权重为60。而“桶”记录技巧则是优化稀疏图邻接关系查询的常用手段。在实际编写代码时还有一些小技巧提前终止在优先队列中弹出节点时如果该节点楼层等于K可以记录当前时间。由于Dijkstra的性质第一次弹出目标节点注意是任意(K, *)时的时间不一定是最小的吗不对Dijkstra保证从源点到某个具体节点的最短距离在第一次从队列中取出时确定。但我们的目标是所有(K, *)节点中的最小值。因此更安全的做法是让算法跑完然后取dist[K]的最小值。当然也可以在弹出节点时如果节点楼层是K就用其时间更新一个全局最小值ans但最终答案仍需等队列清空或ans不再被更新时才能确定因为后面可能弹出时间更小的(K, *)状态。简单起见跑完再取最小值最稳妥。状态压缩如果电梯数量很多二维数组dist[floor][elev]可能很大。但本题限制很松无需担心。调试可以打印出floors_to_elevators桶的内容以及算法运行过程中队列的状态来验证建图和松弛过程是否正确。最后这道题是一个非常好的综合练习它考察了问题抽象、图建模、最短路径算法及其优化以及一些实用的编程技巧。理解其精髓对于解决其他复杂的动态规划或搜索问题也大有裨益。在数学建模竞赛中这类将现实调度、路径规划问题转化为图论或网络流模型的思想更是至关重要。下次当你再遇到带有复杂状态转移和代价计算的问题时不妨先问问自己能不能定义一组状态并建立状态之间的转移关系如果能那么很可能就能用Dijkstra、BFS或DP来解决了。