刷编程题库的时候,很多人都会遇到“模拟电梯”这类题目。题目编号可能叫《113. 模拟电梯7》,也可能叫“电梯调度模拟”“电梯运行仿真”,但不管名字怎么变,核心考察点几乎一致:能不能用代码描述一台电梯从启动、加速、停靠、开关门到响应新请求的完整状态变化。
这类题看起来亲切,因为谁每天都会坐电梯;真动手写起来又容易卡住,因为电梯的运行不是简单的“按楼层排序”,它涉及方向、状态、请求优先级和边界条件。很多初学者把电梯写成一个冒泡排序,或者写出一段在最高层和最低层之间反复横跳的死循环代码,根本原因不是语法不熟,而是没有先在逻辑层把“电梯模型”讲清楚。
这篇文章不打算只给一份代码,而是以“模拟电梯”题为切入点,完整拆解电梯模拟器的设计过程。你会看到:
- 电梯问题的核心难点到底是什么;
- 如何用面向对象思维设计电梯模型;
- 一套基于“电梯算法”的调度逻辑怎么写;
- 完整的 Java 代码实现和运行效果;
- 常见的死循环、请求丢失、边界错乱问题如何排查。
如果你正在准备机试、课程设计,或者想把状态机建模能力练扎实,这篇内容建议收藏,跟着从头写一遍。
1. 这道题到底想考什么
“模拟电梯”不是一道普通的算法题,它本质上是一道“状态机 + 调度策略 + 边界处理”的综合题。很多人一看到题目就开始写排序,其实跑偏了。
电梯的难点可以拆成三个层次。
第一层是状态建模。电梯不是一维数组,它同时拥有“当前楼层”“运行方向”“运行状态”“门状态”这些属性。方向有上行、下行、静止,状态有运行中、停靠中、开关门中。这些状态之间存在约束关系,例如电梯不可能在开门状态下移动,也不可能同时向上和向下运行。
第二层是请求处理。电梯的请求来自两个方向:乘客在楼层按键产生的“外部请求”,以及乘客在轿厢内选楼层产生的“内部请求”。模拟题目通常会把这些请求简化成一条条输入,但你要在内部把它们区分开,因为电梯经过一个楼层时,要看这个楼层是否有当前方向的请求,而不是把所有请求一股脑处理掉。
第三层是运行策略。电梯在某一时刻可能同时面对多个方向的请求,应该先响应哪一个?“电梯算法”给出的思路是:电梯沿着当前方向继续运行,先处理同方向请求,当同方向没有请求时,再判断是否需要换向。这个策略是所有电梯调度器的起点。
如果你理解透了这三点,那么“模拟电梯7”只是换了输入格式和输出要求,核心逻辑完全可以复用。
2. 电梯模拟器的核心概念与调度原理
2.1 状态机:一切电梯模型的基础
电梯的状态可以抽象为以下几个维度:
| 维度 | 取值范围 | 说明 |
|---|---|---|
| 运行方向 | UP / DOWN / IDLE | IDLE 表示没有请求时的静止状态 |
| 运行状态 | MOVING / STOPPED | STOPPED 表示当前停靠 |
| 门状态 | OPEN / CLOSED | 模拟题里可以简化为一个状态字段 |
| 当前楼层 | 整数 | 范围在 1 到 maxFloor 之间 |
在实际代码中,方向是最核心的状态,因为调度逻辑完全依赖它。建议使用枚举而不是字符串常量,这样在 switch 判断和参数传递时更安全。
2.2 请求的分类与去重
电梯请求可以分为两类:
- 上行请求:乘客在某个楼层按下“上”按钮,说明想去更高楼层;
- 下行请求:乘客在某个楼层按下“下”按钮,说明想去更低楼层。
这里要注意一个常见误区:同一个楼层同时可能有上行和下行请求,它们是两个不同的请求,需要分开存储。例如,5 楼有人要上去,同时 5 楼也有人要下去,电梯经过 5 楼时如果方向是上行,应该只处理上行请求,下行请求留到换向后处理。
另外,同一楼层同一方向可能有多人请求,模拟的时候只需要记录一次,不需要重复响应。所以用 Set 结构去重比用 List 更合理。
2.3 电梯算法:同向优先,边界换向
电梯调度用得最多的基础算法是 SCAN,也叫“电梯算法”。它的核心思想是:
- 电梯沿当前方向运行,处理该方向上的所有请求;
- 当前方向没有请求时,判断另一端是否还有请求;
- 如果有,则换向继续运行;
- 如果所有请求都处理完,回到 IDLE 状态。
用一句话概括就是“走到头再回头”。这种算法的优点是实现简单、逻辑清晰,缺点是响应时间不稳定,极端场景下可能让某个请求等待较长时间。但在编程模拟题中,SCAN 已经足够,也是考官最想看到的调度逻辑。
3. 系统设计与数据结构选择
3.1 类设计
为了让代码清晰,建议把电梯模拟拆成三个类:
Direction:方向枚举;Elevator:电梯实体,维护楼层位置、方向、边界;ElevatorController:调度控制器,负责接收请求、判断方向、执行移动;Main:主程序,负责读取输入并驱动模拟循环。
这种拆分的价值在于:电梯实体不关心调度策略,控制器不关心楼层边界,职责分离后,后续扩展多电梯、不同调度策略都更容易。
3.2 为什么请求集合用 TreeSet
在调度过程中,电梯反复要做两件事:
- 判断当前方向上是否还有请求;
- 找到当前方向上的下一个请求楼层。
如果用 ArrayList,每次都需要遍历,而且还要考虑去重和删除,代码会很别扭。这里推荐使用TreeSet<Integer>,它自带自然排序,并且提供两个很关键的方法:
ceiling(floor):返回大于等于指定楼层的最小请求;floor(floor):返回小于等于指定楼层的最大请求。
电梯向上运行时,用ceiling判断上方是否还有请求;向下运行时,用floor判断下方是否还有请求。每次到达楼层后直接remove即可,时间复杂度也比较理想。
3.3 调度主流程
每一次“模拟步”可以拆成三个阶段:
- 服务当前楼层:移除当前楼层在当前方向上能处理的请求;
- 重新决策方向:根据剩余请求和当前方向决定下一步方向;
- 执行移动:按方向移动一层。
关键点在第 2 步。很多人的死循环都出在这里:如果方向判定条件写反,电梯会一直在两三个楼层之间来回走。后面会给出完整代码并解释判定逻辑。
4. 环境准备与项目结构
本文的示例使用 Java 编写,需要的环境非常简单:
- JDK 8 及以上版本;
- 任意文本编辑器或 IDE;
- 命令行终端。
不使用 Maven 或 Gradle,也没有第三方依赖,方便机试环境直接编译运行。
项目目录结构如下:
elevator-simulator/ ├── Direction.java ├── Elevator.java ├── ElevatorController.java └── Main.java也可以全部写在同一个.java文件里,但建议分开,逻辑更清晰。
5. 完整代码实现
5.1 方向枚举:Direction.java
public enum Direction { UP, DOWN, IDLE }IDLE代表静止状态,当没有请求时电梯会自动回到这个状态。在判断方向时加入IDLE,可以避免“电梯没有方向还在移动”的 bug。
5.2 电梯实体:Elevator.java
public class Elevator { private int currentFloor; private int minFloor; private int maxFloor; private Direction direction; public Elevator(int startFloor, int minFloor, int maxFloor) { this.currentFloor = startFloor; this.minFloor = minFloor; this.maxFloor = maxFloor; this.direction = Direction.IDLE; } public int getCurrentFloor() { return currentFloor; } public int getMinFloor() { return minFloor; } public int getMaxFloor() { return maxFloor; } public Direction getDirection() { return direction; } public void setDirection(Direction direction) { this.direction = direction; } public void moveUp() { if (currentFloor < maxFloor) { currentFloor++; } } public void moveDown() { if (currentFloor > minFloor) { currentFloor--; } } }这里把移动动作封装成moveUp()和moveDown(),方法内部做边界检查。这样控制器就不用关心楼层边界,代码更安全。
5.3 调度控制器:ElevatorController.java
这是整个模拟器的核心,请求存储、方向决策、移动触发都在这里完成。
import java.util.TreeSet; public class ElevatorController { private final Elevator elevator; private final TreeSet<Integer> upRequests = new TreeSet<>(); private final TreeSet<Integer> downRequests = new TreeSet<>(); public ElevatorController(Elevator elevator) { this.elevator = elevator; } public void request(int floor, Direction direction) { if (floor < elevator.getMinFloor() || floor > elevator.getMaxFloor()) { System.out.println("非法楼层请求,已忽略:" + floor); return; } if (direction == Direction.UP) { upRequests.add(floor); } else if (direction == Direction.DOWN) { downRequests.add(floor); } else { System.out.println("非法方向请求,已忽略:" + floor); } } public boolean hasPendingRequests() { return !upRequests.isEmpty() || !downRequests.isEmpty(); } public void step() { int currentFloor = elevator.getCurrentFloor(); // 1. 服务当前楼层 boolean served = upRequests.remove(currentFloor); served |= downRequests.remove(currentFloor); if (served) { System.out.println(">>> 电梯停靠 " + currentFloor + " 层,开门上下客"); } // 2. 重新判断方向 decideDirection(); // 3. 执行移动 Direction direction = elevator.getDirection(); if (direction == Direction.UP) { elevator.moveUp(); } else if (direction == Direction.DOWN) { elevator.moveDown(); } } private void decideDirection() { int currentFloor = elevator.getCurrentFloor(); Direction currentDirection = elevator.getDirection(); if (upRequests.isEmpty() && downRequests.isEmpty()) { elevator.setDirection(Direction.IDLE); return; } if (currentDirection == Direction.IDLE) { boolean hasRequestAbove = (!upRequests.isEmpty() && upRequests.last() > currentFloor) || (!downRequests.isEmpty() && downRequests.last() > currentFloor); elevator.setDirection(hasRequestAbove ? Direction.UP : Direction.DOWN); return; } if (currentDirection == Direction.UP) { boolean hasRequestAbove = (!upRequests.isEmpty() && upRequests.last() > currentFloor) || (!downRequests.isEmpty() && downRequests.last() > currentFloor); if (!hasRequestAbove) { elevator.setDirection(Direction.DOWN); } } else if (currentDirection == Direction.DOWN) { boolean hasRequestBelow = (!upRequests.isEmpty() && upRequests.first() < currentFloor) || (!downRequests.isEmpty() && downRequests.first() < currentFloor); if (!hasRequestBelow) { elevator.setDirection(Direction.UP); } } } }这段方向的判断逻辑需要重点解释一下。
以电梯当前向上为例,判断条件并不是“是否有上行请求”,而是“是否有任何请求在当前楼层上方”。只要上方还有请求,电梯就继续向上走。这个设计呼应了 SCAN 算法的思想:电梯向一个方向运行时会顺路处理所有方向相反的请求,所以上行途中的下行请求同样会在这个阶段被服务掉,直到上方再没有请求才换向。
有人可能会问:如果电梯正在向下运行,但下方已经没有请求了,同时上方还有请求,方向会立刻切换成 UP。这个逻辑通过 else 分支实现了。但这套算法追求的是简单和正确,并不保证响应时间最短。
5.4 主程序:Main.java
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); System.out.println("请输入总楼层数和初始楼层(空格分隔):"); int maxFloor = scanner.nextInt(); int startFloor = scanner.nextInt(); Elevator elevator = new Elevator(startFloor, 1, maxFloor); ElevatorController controller = new ElevatorController(elevator); System.out.println("请输入请求数量:"); int n = scanner.nextInt(); System.out.println("请输入" + n + "条请求,每行格式:楼层 方向(UP/DOWN)"); for (int i = 0; i < n; i++) { int floor = scanner.nextInt(); String dir = scanner.next(); controller.request(floor, Direction.valueOf(dir.toUpperCase())); } int step = 0; while (controller.hasPendingRequests()) { controller.step(); System.out.println("执行一步后,电梯在第 " + elevator.getCurrentFloor() + " 层,方向 " + elevator.getDirection()); step++; if (step > 200) { System.out.println("步数超过200,疑似死循环,已终止"); break; } } System.out.println("模拟结束,共执行 " + step + " 步"); } }主程序做了一个“步数保护”,超过 200 步强制结束。这个设计对于模拟类程序非常有价值。一旦调度逻辑写错,电梯陷入死循环,如果不设置上限,程序会一直跑下去,测试时很难发现。
6. 运行结果与效果验证
编译和运行命令如下:
javac Direction.java Elevator.java ElevatorController.java Main.java java Main使用下面的输入:
10 1 4 5 UP 8 DOWN 3 UP 4 DOWN含义是:总共 10 层,电梯从 1 层出发,有 4 条请求:
- 5 楼有人要上行;
- 8 楼有人要下行;
- 3 楼有人要上行;
- 4 楼有人要下行。
程序运行后的关键过程应该是:
- 电梯从 1 层出发,先向上;
- 到达 3 层,服务上行请求,开门;
- 继续向上,到达 4 层,服务下行请求,开门;
- 继续向上,到达 5 层,服务上行请求,开门;
- 继续向上,到达 8 层,服务下行请求,开门;
- 所有请求处理完毕,电梯变为 IDLE。
跑通这个用例,说明基础调度逻辑没有问题。
建议多测试几组边界用例:
- 电梯初始楼层就是最高层,请求都在下方;
- 所有请求都在同一层;
- 请求楼层重复;
- 请求楼层超出建筑范围。
这些都是机试常见的隐藏用例,特别值得提前验证。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 电梯只向上,永远不向下 | 向上方向判断缺少对反向请求的检查,导致上方无请求时没有触发换向 | 在方向判断分支打印当前剩余请求集合 | 使用“是否存在上方请求”替代“是否存在同向请求” |
| 电梯死循环,在两个楼层之间来回走 | 服务请求后没有及时 remove,或换向条件自相矛盾 | 在 step 方法开始和结束分别打印当前楼层和请求集合 | 确保 remove 在移动前执行,方向判断条件只能有一个出口 |
| 请求楼层处理了两次 | 使用了 List 存储请求,没有去重 | 打印每次 remove 结果 | 改用 TreeSet 存储请求 |
| 电梯到了最高层还继续向上 | moveUp 缺少边界检查 | 在 moveUp 里打印当前楼层和 maxFloor | 移动方法内部增加上下限判断 |
| 换向后漏掉反方向请求 | 电梯只在上行集合里找请求,没有检查下行集合 | 检查 decideDirection 里的判断条件 | 判断方向时同时检查 upRequests 和 downRequests |
| 输入非法楼层后程序崩溃 | request 方法没有做边界校验 | 在 request 入口增加日志 | 增加楼层范围和方向合法性判断 |
这些问题里,死循环是最常见的。排查时不要看完整输出,应该看最后几步的日志:如果电梯一直在 A 层和 A+1 层之间来回走,说明换向条件和移动条件冲突了,优先检查decideDirection。
8. 最佳实践与工程建议
8.1 用枚举代替魔法值
方向字段不要用字符串 "up"/"down",也不要直接用 1/-1 表示。使用枚举可以让代码自解释,还能避免拼写错误。尤其在 Controller 的请求入口,Direction.valueOf()直接提供了输入校验能力。
8.2 请求集合选型很重要
TreeSet 在这个模拟场景里几乎是量身定做的。它天然去重、自动排序,配合ceiling和floor方法,可以很容易地找到当前位置上下方的请求。如果用 ArrayList,去重要手动判断,找“最近请求”要遍历,代码量会明显增加,而且容易出错。
8.3 模拟循环要加步数保护
机试环境不会让你的死循环跑太久,但也别指望系统能给你明确报错。自己加上步数保护,一旦超过合理上限就强制退出并打印当前请求状态,能省去大量调试时间。
8.4 将调度策略与电梯实体分离
这个设计看似简单,但在后续扩展时价值很大:
- 想改成多电梯调度,只需要增加多个 Elevator 实例,并让 Controller 管理多个请求队列;
- 想换成最短寻道优先算法,只需要重写
decideDirection; - 想加开关门时间,只需要在
step中增加时间字段。
所以在写模拟器时,不要把所有逻辑塞到 Main 方法里。实体、调度、输入输出分开,是工程化模拟器的基础。
8.5 在真实系统设计中的应用
电梯模拟看似是教学题,但它的建模思路在真实系统中很常见。
电梯调度器对应着任务调度系统:请求队列对应消息队列的待处理任务;方向决策对应负载均衡策略;换向逻辑对应任务消费的优先级切换。掌握电梯模拟,其实是在掌握“一个有状态的服务如何高效处理乱序请求”这一类问题。
9. 总结与后续扩展方向
“模拟电梯”类题目真正的价值,不在于把某一道题的样例跑通,而在于训练一种思维:把现实世界中的设备状态、事件请求、运行规则,翻译成代码中的状态字段、集合操作和条件分支。
本文给出了一个单电梯的 SCAN 调度实现,完整的代码结构分成 Direction、Elevator、ElevatorController、Main 四部分。你可以直接编译运行,也可以在理解思路后尝试以下扩展:
- 把单电梯改成多电梯,并设计简单的“空闲电梯优先”策略;
- 加入电梯内部请求模拟,区分内呼和外呼;
- 在每次停靠时累加时间,比较不同调度策略的总运行时间;
- 把控制台输出改成 GUI 可视化。
如果你能把上面的扩展独立完成,说明状态机建模和调度逻辑已经真正掌握了。下次再看到任何编号的“模拟电梯”题目,都可以从容地把这套核心模型迁移过去,只要针对输入输出格式做适配就够了。