简介:ElevatorSimulation 是一套基于 Java 的摩天大楼电梯系统仿真项目,重点探讨单向、双向、观光、高速、消防等电梯类型,并实现先来先服务、最近楼层优先、最少行程时间、预调度及群控等多种调度算法。项目面向具备一定 Java 基础的开发者,适合用于理解多电梯协调、呼叫请求建模与算法性能对比。压缩包共 75 个文件,以 25 个 Java 源码文件为主体,涵盖电梯实体、呼叫对象、流量生成、调度器及大楼管理核心模块,并辅以 XML 配置、prefs 偏好设置、txt 说明等,总大小约 70KB,目录层级清晰,便于按类阅读。已有 402 人学习浏览。通过模拟流量输入与不同调度策略的替换,读者可直观观察各算法在等待时间和运行效率上的差别;其中 Selective Collective、SearchBased 等改进策略对理解电梯群控优化尤具参考价值。项目代码还涉及面向对象接口设计、数据结构与多种算法实践,既能作为课程设计参考,也可作为 Java 进阶项目练习,帮助提升大型仿真系统的拆分与编码能力。
1. ElevatorSimulation:电梯调度算法仿真的那点事
在《模拟城市》里规划过摩天楼的人,十有八九都被电梯流量卡过脖子。现实中高层建筑电梯系统更是个黑匣子——楼层一多,单梯运力不够,双梯又要考虑并联调度;早高峰打卡的人堆在厅门,电梯来回空跑,调度算法稍微笨一点,等梯时间就肉眼可见地涨。这个叫 ElevatorSimulation 的 Java 项目,就是把这套复杂系统掰开揉碎的仿真平台:它能模拟单梯、并联梯群、双层轿厢电梯,内置 FIFO、SSTF、SCAN、LOOK 以及带方向优先级的分层调度策略,通过离散事件仿真把每一趟运行拆成到达、开门、上下客、关门、移动这几个事件,输出平均等待时间、平均乘梯时间、系统吞吐量。适合正在做调度算法毕业设计的学生,或者想给楼宇控制系统选梯控策略的集成工程师。
2. 先把仿真模型拆开:离散事件驱动下的电梯物理与乘客逻辑
2.1 为什么电梯仿真必须用离散事件而不是时间片轮询
很多入门者第一反应是开一个 while(true) 循环,每秒更新一次所有电梯的位置。这个思路在 20 层以下的单体建筑里勉强能跑,但双梯并联、楼层上百的时候,时间片轮询会产生两个问题:一是仿真精度受限于时间步长,步长设小了性能崩,设大了事件被平均化——比如开门动作只需要 2.8 秒,你按 1 秒步长去刷新,误差就被放大了;二是乘客到达是一个连续随机过程,时间片刷新根本表达不了"第 42.7 秒来一个人"这种精确时刻。
ElevatorSimulation 的底层是离散事件队列(Discrete Event Queue),每个事件带一个时间戳,事件按时间排序,仿真时钟直接跳到下一个事件发生时刻,而不是逐秒推进。这意味着开门、关门、移动、乘客到达这些动作都作为独立事件插入队列。以某次 40 层双梯仿真为例,事件总数在 3 万次左右,如果换成 100ms 步长轮询,光空转检查就是 40 万次,性能差一个量级。
2.2 电梯物理模型:从额定速度到加减速曲线
电梯模型不是简单的"位置 = 速度 × 时间",真实电梯有加速度限制和加减速距离。ElevatorSimulation 中每台电梯配置如下参数:
public class ElevatorConfig { private int capacity = 12; // 额定载客量(人) private double maxSpeed = 3.5; // 额定速度(m/s) private double acceleration = 1.2; // 加速度(m/s²) private double deceleration = 1.2; // 减速度(m/s²) private double doorOpenTime = 2.8; // 开门时间(s) private double doorCloseTime = 2.2; // 关门时间(s) private double floorHeight = 3.2; // 层高(m) private int[] servedFloors; // 服务楼层集合,用于分区模式 }参数说明:capacity直接影响捎带算法能否在上下客高峰继续响应新事件;maxSpeed和acceleration共同决定了电梯从静止加速到匀速再减速停靠的行程时间。常见做法是计算单层运行时间时先判断是否达到最大速度——如果楼层间距小于加速距离加上减速距离,电梯根本跑不到额定速度就得减速,这叫短行程限速。仿真里如果没有短行程判断,就会出现 30 层电梯跑 1 层和跑 20 层耗时一样的不合理结果。
public double travelTime(int startFloor, int endFloor) { double distance = Math.abs(endFloor - startFloor) * floorHeight; double accelDist = maxSpeed * maxSpeed / (2 * acceleration); // 加速段距离 double totalAccelDecelDist = 2 * accelDist; if (distance <= totalAccelDecelDist) { // 短行程:纯加减速,到达不了最大速度 return 2 * Math.sqrt(distance / acceleration); } else { // 长行程:加速 + 匀速 + 减速 double cruiseDist = distance - totalAccelDecelDist; return maxSpeed / acceleration // 加速时间 + cruiseDist / maxSpeed // 匀速时间 + maxSpeed / deceleration; // 减速时间 } }逻辑说明:这段代码先算出加速到额定速度所需的距离accelDist,如果楼层间距小于两倍这个距离,就按纯加减速运动学公式算时间;否则拆成三段——加速、匀速、减速。这才是电梯调度仿真里最常见的"短行程陷阱",不处理这段,调度对比的结果会失真。
2.3 乘客到达模型:泊松到达和上下行比例
乘客到达不是均匀分布的。ElevatorSimulation 里提供了两种到达模式:均匀到达用于测试系统稳定态,泊松到达用于模拟早晚高峰的突发流量。泊松到达的关键参数是到达率 λ(人/分钟),相邻两个乘客的到达间隔服从指数分布。
public Passenger generatePassenger() { Random random = new Random(); // 指数分布采样,平均到达间隔为 1/lambda 分钟 double interval = -Math.log(1 - random.nextDouble()) / lambda; int currentFloor = random.nextInt(totalFloors); int destFloor; do { destFloor = random.nextInt(totalFloors); } while (destFloor == currentFloor); boolean upward = destFloor > currentFloor; return new Passenger(currentFloor, destFloor, now + interval, upward); }参数说明:nextDouble()取 [0,1) 均匀分布,-log(1-x)把均匀分布变换成指数分布,这是蒙特卡洛仿真的标准做法。do-while用来防止生成同层乘客——真实场景中没人会从 12 层进电梯又按 12 层。now + interval是乘客到达事件的绝对时间戳,它会被插入离散事件队列。
3. 从零搭一个 40 层双梯仿真:跑通 FIFO 和 SSTF
3.1 定义楼宇结构和服务基础参数
拿到 ElevatorSimulation 源码后,第一件事不是跑测试,而是打开SimulationConfig.java定义你的实验楼宇。以 40 层摩天楼为例,层高 3.2 米,电梯数量 2 台,载重 1350kg(约 18 人),额定速度 4m/s。仿真时长设 60 分钟,前面 5 分钟作为预热期不计入统计——这个预热很重要,否则开局电梯都在一楼傻等,前几分钟数据会把平均等待时间拉高。
SimulationConfig config = new SimulationConfig.Builder() .floors(40) // 总楼层数,不含地下层 .floorHeight(3.2) // 层高,单位米 .elevatorCount(2) // 电梯数量 .capacity(18) // 单梯载客 18 人 .maxSpeed(4.0) // 额定速度 4m/s .lambda(3.0) // 泊松到达率:平均每分钟 3 人 .simulationMinutes(60) // 仿真时长 60 分钟 .warmupMinutes(5) // 预热 5 分钟 .strategy("SSTF") // 先跑 SSTF 最短寻道时间优先 .build();3.2 跑通 FIFO 基线:先知道最笨的调度长什么样
FIFO(First In First Out)是电梯调度里最简单的策略——按乘客产生的先后顺序逐一向电梯派单。电梯当前没有任务就停在该楼层,来新乘客就按顺序走。这个策略实现起来只有几十行核心逻辑:
public class FifoScheduler implements ElevatorScheduler { private Queue<Passenger> requestQueue = new LinkedList<>(); @Override public void onPassengerArrive(Passenger p, List<Elevator> elevators) { // FIFO 策略不判断方向,直接进入全局队列 requestQueue.offer(p); dispatch(elevators); } private void dispatch(List<Elevator> elevators) { // 选择当前最空闲的电梯(任务数最少) Elevator target = elevators.stream() .min(Comparator.comparingInt(e -> e.getPendingTasks().size())) .orElse(null); if (target != null && !requestQueue.isEmpty()) { Passenger next = requestQueue.poll(); target.addTask(new Task(next.getFromFloor(), next.getToFloor())); } } }逻辑说明:FIFO 的核心数据结构就是一个先入先出队列,onPassengerArrive在乘客到达事件被处理时触发,dispatch从队列头部取一个任务分配给任务数最少的电梯。这里没有做任何方向合并和路径优化,是标准的"每响应一个人就派一趟"模型。
跑完 FIFO 基线后你会看到 40 层双梯下平均等待时间在 90 秒以上,峰值可能到 150 秒。这个数字很重要——它是后续所有调度策略的对比基准,没有这个基线,后面 SCAN 提升多少百分比就无从谈起。
3.3 SSTF 实现:贪心离当前层最近的请求
SSTF(Shortest Seek Time First)从磁盘调度算法搬过来,核心思想是每次选离电梯当前层最近的乘客请求。这段代码能跑通,但千万别急着下结论——SSTF 有个致命缺陷,下面避坑章节细说。
public class SstfScheduler implements ElevatorScheduler { @Override public void onPassengerArrive(Passenger p, List<Elevator> elevators) { // 谁离得近谁接单,不考虑方向 Elevator best = elevators.stream() .min(Comparator.comparingInt(e -> Math.abs(e.getCurrentFloor() - p.getFromFloor()))) .orElse(null); if (best != null) { best.addTask(new Task(p.getFromFloor(), p.getToFloor())); } } }逻辑说明:SSTF 用Math.abs(e.getCurrentFloor() - p.getFromFloor())计算电梯当前楼层到乘客所在楼层距离,选距离最小者。每来一个乘客事件就重新计算一次,所以它是一种全局贪心。跑完 SSTF 你会发现等待时间比 FIFO 低不少,但有一个严重问题——电梯会频繁掉头。
4. 方向优先与分层调度:SCAN/LOOK 在高楼里的真正形态
4.1 SCAN 电梯算法:把一栋楼变成一条磁盘磁道
SCAN 算法也叫电梯算法,电梯保持在一个方向上运行,直到该方向前方没有请求才掉头。相比 SSTF 的频繁掉头,SCAN 更像真实电梯——同一方向上顺路的请求可以被捎带处理。ElevatorSimulation 的 SCAN 实现维护每台电梯的运行状态(上行/下行/空闲),当乘客按下按钮,请求按方向进入上行集合或下行集合。
public class ScanScheduler implements ElevatorScheduler { private static final int UP = 1; private static final int DOWN = -1; private final Map<Integer, Integer> directionMap = new HashMap<>(); @Override public void onPassengerArrive(Passenger p, List<Elevator> elevators) { Elevator e = selectElevator(p, elevators); // 乘客请求按方向分组 if (p.isUpward()) { e.addUpRequest(p.getFromFloor(), p.getToFloor()); } else { e.addDownRequest(p.getFromFloor(), p.getToFloor()); } } private Elevator selectElevator(Passenger p, List<Elevator> elevators) { // 选一个当前运动方向和乘客请求方向最匹配的电梯 return elevators.stream() .filter(e -> e.getDirection() == 0 || e.getDirection() == directionMap.get(p.getFromFloor())) .min(Comparator.comparingInt(e -> Math.abs(e.getCurrentFloor() - p.getFromFloor()))) .orElseGet(() -> elevators.get(0)); } }逻辑说明:directionMap记录了每层楼当前存在的请求方向,selectElevator优先选中方向一致或空闲的电梯,避免把上行的乘客派给正在下行的电梯。运行中电梯会扫描当前方向上的所有请求,到达最高请求楼层后掉头。SCAN 的关键参数是"是否在响应完当前方向请求后立即掉头",真实电梯一般会加一小段空闲等待时间,避免刚掉头又来一个反向请求。
4.2 LOOK 变体:没有请求就掉头,减少空跑
LOOK 是 SCAN 的改良版——SCAN 不管前方有没有请求都跑到楼顶再掉头,LOOK 只跑到当前方向最后一个请求楼层就掉头。在高峰时段 SCAN 和 LOOK 差别不大,但平峰期 LOOK 能省掉大量空跑行程。ElevatorSimulation 里 LOOK 的判断核心如下:
public boolean shouldReverse(Elevator elevator) { int currentFloor = elevator.getCurrentFloor(); if (elevator.getDirection() == UP) { // 检查当前方向再无请求 for (int f = currentFloor + 1; f <= elevator.getMaxFloor(); f++) { if (elevator.hasUpRequestAt(f) || elevator.hasDownRequestAt(f)) { return false; } } return true; // 上方已无请求,掉头 } else { for (int f = currentFloor - 1; f >= 0; f--) { if (elevator.hasUpRequestAt(f) || elevator.hasDownRequestAt(f)) { return false; } } return true; } }逻辑说明:shouldReverse在每次停靠后判断,扫一遍当前方向前方楼层是否存在任何请求(不管是上行还是下行)。这比 SCAN 的固定到端掉头更灵活。注意判断条件里hasUpRequestAt和hasDownRequestAt都检查——因为下行的电梯停靠 10 层时,如果该层同时有下行乘客要去 3 层,这个请求顺路,同样应该被捎带。
4.3 分层调度:高层和低层分区,减少电梯串区
40 层以上的摩天楼,单靠 SCAN/LOOK 还不够——高峰期低层乘客频繁上下,会把一台电梯拖在低层区间,高层的乘客等半天没梯。分层调度把大楼切成多个垂直区域,每台电梯限定服务一组楼层。ElevatorSimulation 支持静态分区模式:
public void setupZones(List<Elevator> elevators, int totalFloors) { // 双梯 40 层:低区 1~20 层,高区 21~40 层 elevators.get(0).setServedFloors(IntStream.rangeClosed(1, 20).toArray()); elevators.get(1).setServedFloors(IntStream.rangeClosed(21, 40).toArray()); // 每台电梯的 serveFloors 数组在电梯模型中被检查 // 乘客的 fromFloor 不属于该梯服务区则不响应该跨区请求 }参数说明:双梯分区是最常规的配置——低区梯负责 1~20 层,高区梯负责 21~40 层。问题在于跨区乘客(比如 5 层到 35 层),实际建筑中靠换乘大堂解决。仿真里如果简化成"跨区请求直接拒绝",就会低估高层区的等待时间。比较好的做法是设置 1~2 个换乘层(比如 20 层和 21 层之间的转换大堂),ElevatorSimulation 里可以用换乘层 + 两级调度的方式模拟。
4.4 暴力枚举下的最优解:验证策略上界的试金石
ElevatorSimulation 里最有价值的不是 SCAN 或 LOOK 本身,而是一个暴力枚举调度器(BruteForceScheduler)——在乘客数小于某个阈值(比如 5 人)时,枚举所有可能的电梯分配方式,找出最优解。这个最优解是其他一切近似策略的上界参考。
public class BruteForceScheduler implements ElevatorScheduler { @Override public void onPassengerArrive(Passenger p, List<Elevator> elevators) { if (p.getTotalPending() > 5) { // 超过 5 人,放弃枚举,退化为 SCAN fallbackScheduler.onPassengerArrive(p, elevators); return; } // 枚举所有电梯分配组合,计算总等待时间 List<Elevator[]> assignments = enumerateAssignments(p, elevators); Elevator[] best = assignments.stream() .min(Comparator.comparingDouble(this::estimateTotalWait)) .orElse(...); assignPassengers(best, p); } }这段代码的价值在于:当你要对比 LOOK 和分层调度谁更优时,先跑暴力枚举拿到理论下界,然后看你的策略离下界还有多少差距。如果 LOOK 的平均等待时间接近枚举下界的 1.2 倍,说明策略已经不错;如果差了 2 倍以上,说明还有明显优化空间。暴力枚举只适用于小规模请求组合,电梯调度是 NP-hard 问题,请求超 5 人后枚举量会指数爆炸,所以仿真里通常只在低峰期启用。
5. 避坑指南:电梯调度仿真里最常见的六个翻车现场
5.1 电梯跑到楼层但乘客不见了
现象:电梯到达 15 层开门,结果没有乘客上轿厢,白白停靠一次。 原因:乘客和电梯的到达时间戳对不上。乘客到达事件在 100 秒生成,但电梯在 95 秒就已经扫描到该楼层并规划停靠,还没等乘客走到厅门,电梯已经完成上下客关门走了。 解决:电梯停靠前必须校验该楼层是否有在等待的乘客,而且要加"开门等待时间"——真实电梯开门后会等待 5~8 秒,给乘客反应时间。我在 ElevatorSimulation 里强制加上一个dwellTime参数(默认 5 秒),只有等待乘客清零或超时才关门。
public void onElevatorArrive(Elevator elevator, int floor) { List<Passenger> waiting = passengerQueue.getWaitingAt(floor, elevator.getDirection()); if (waiting.isEmpty()) { elevator.skipStop(floor); // 没人在等,直接跳过 return; } elevator.openDoor(); // dwellTime 内允许持续上客,超过 dwellTime 强制关门 scheduleEvent(new DoorCloseEvent(elevator, floor, now + dwellTime)); }5.2 电梯来回空跑,能耗报表惨不忍睹
现象:SSTF 策略下电梯刚向上走了两层,又来一个低层请求,电梯掉头向下,如此反复。 原因:SSTF 是纯贪心,没有考虑电梯当前运动方向与乘客请求方向的一致性。它本质上是磁盘寻道算法的移植,磁盘磁头移动成本均匀,但电梯有乘客体验问题——来回空跑既耗能又增加噪声。 解决:改用方向优先的 LOOK,或者在 SSTF 上叠加方向惩罚因子——反向请求的代价加权 1.8 倍再参与比较。这个 1.8 是经验值,我试过 1.5 和 2.0,1.8 在混合客流的仿真中等待时间和能耗的平衡点最好。
5.3 泊松到达随机种子不固定,对比实验没有说服力
现象:同一套配置跑 5 次,平均等待时间从 72 秒波动到 110 秒,策略对比无法得出结论。 原因:每次运行new Random()的种子不同,乘客到达序列完全不同。SCAN 和 LOOK 在不同随机序列上各有胜负,统计噪声盖过了算法差异。 解决:所有策略对比实验使用同一组预生成的乘客到达序列。ElevatorSimulation 支持从文件加载乘客到达时间表(CSV 格式:时间戳,起始楼层,目的楼层),跑 5 个策略共用这份 CSV。
// 生成一次乘客序列,保存到文件 PassengerSequenceGenerator gen = new PassengerSequenceGenerator(config); gen.generate().toCsv("passengers_high_peak.csv"); // 所有策略加载同一序列 SimulationBuilder builder = new SimulationBuilder() .loadPassengers("passengers_high_peak.csv");5.4 换乘层乘客堵在楼梯口不疏散
现象:分层调度模式下,换乘层(如 20 层)积压大量高区换乘客流,等待时间爆炸。 原因:低区电梯把乘客送到换乘层后直接返回低区,没考虑换乘乘客要再等高区电梯,而高区电梯的停靠策略没有优先响应换乘层请求。 解决:把换乘层标记为高优先级枢纽楼层。ElevatorSimulation 中我实现了一个HubFloorRequest机制——换乘层产生的新请求在调度器中有更高的权重,高区电梯到达换乘层时还应该清空所有下行换乘乘客,否则换乘层越积越多。
5.5 高峰期轿厢超载,但仿真里照单全收
现象:电梯额定载客 18 人,仿真中一台电梯拉了 30 人,而且数据还挺好看——因为载得越多效率越高。 原因:仿真的电梯模型没有做载重限制,乘客可以无限挤入,调度效率被人为抬高。 解决:在电梯模型中增加实时载重判断,轿厢满员时不对沿途请求响应。真实电梯还有个"满员直驶"模式——满载时跳过所有停靠请求,直到有乘客到达目的层释放载重。
public boolean canAcceptPassenger(int weight) { if (currentLoad + weight > ratedLoad) { // 满载不响应任何新请求,直达目的层 setExpressMode(true); return false; } return true; }5.6 超长仿真内存溢出,事件队列炸了
现象:仿真时长 8 小时、乘客到达率 λ=10 人/分钟时,堆内存飙到 2GB,GC 频繁。 原因:离散事件队列保留了大量已处理事件对象,仿真时钟推进后没有及时清理。乘客对象和事件对象在小堆配置下很容易撑满年轻代。 解决:每处理完一个事件,立即从队列移除并释放引用。另外对历史统计数据做滚动聚合,不要每个事件都保留完整历史,只保留每分钟聚合后的均值和分位数。
6. 用调参日志反推算法边界:仿真器里的三个验证习惯
6.1 第一次先跑暴力枚举,给后续策略画一条基准线
这可能是整个 ElevatorSimulation 里最值得做的实验——低峰期(λ=1 人/分钟)5 人以下请求组合切换到暴力枚举调度,记录平均等待时间作为理论下界。比如某个 20 层双梯场景下暴力枚举给出的平均等待时间是 18.7 秒,而 SCAN 实测 23.4 秒,LOOK 实测 21.9 秒——离下界的差距分别是 25% 和 17%。你拿这个数据去写论文或给甲方汇报,比干巴巴贴平均值有说服力得多。
6.2 调整扇区比例时观察换乘层积压,而不只看平均等待
双梯 40 层分区时,低区 1~20 层和高区 21~40 层看起来均匀,但如果你画一下 20 层换乘大厅的等待人数曲线就会发现,高峰期换乘层排队人数会持续增长。这时候正确的调参方式是提高高区电梯的调度权重,或者把分区改成交错式(1~15 + 16~40),再测一轮,对比换乘层最大积压人数。平均等待时间会撒谎,但换乘层积压不会。
6.3 把每分钟的等待时间分位数导出来,盯 P95 而不是均值
平均等待时间 30 秒不代表用户体验好——如果 5% 的乘客要等 2 分钟以上,投诉照样来。我在 ElevatorSimulation 里加了分位数统计输出,每次仿真结束生成 CSV,包含每分钟的平均等待、P50、P95 和最大等待时间。调参时盯 P95,因为 P95 才反映调度策略在突发流量下的表现。有一次 LOOK 的均值比 SCAN 低 5 秒,但 P95 高出 20 秒,说明 LOOK 在极端情况下对部分乘客极不友好,这个信息只看均值永远发现不了。
从那以后我每次做策略对比,强制自己先导出 P95 和换乘层积压曲线,再回头分析均值。这已经成了我跑电梯仿真的固定流程。仿真器的价值不止是跑出几个数字,更重要的是逼你直面极端情况下的系统行为——希望这套思路对你也有帮助。
本文还有配套的精品资源,点击获取