Before coding, spend 3–5 minutes clarifying these with your interviewer. The key distinction to establish immediately is whether there is one elevator or many, and whether you need a real scheduling algorithm or a naive FIFO is sufficient.
- Support a building with N floors and M elevators
- Handle external requests: hallway UP / DOWN buttons on each floor
- Handle internal requests: floor buttons inside the elevator cabin
- Dispatch the most suitable elevator for each request
- Each elevator maintains its own queue and moves using the SCAN algorithm
- Thread-safe operations — multiple requests can arrive concurrently
- Singleton ElevatorSystem — one controller per building
- Pluggable dispatch strategy (Nearest Car, Zone-based, etc.)
- Support MAINTENANCE mode — out-of-service elevators are skipped
Identify the real-world objects first. The elevator system has a clean separation: physical objects (Elevator), events (ElevatorRequest), and orchestrators (ElevatorSystem + DispatchStrategy).
| Class / Interface | Type | Responsibility |
|---|---|---|
| Direction | Enum | UP · DOWN · IDLE |
| ElevatorState | Enum | MOVING · STOPPED · MAINTENANCE |
| RequestType | Enum | INTERNAL (cabin) · EXTERNAL (hallway) |
| ElevatorRequest | Class | Floor + direction + type of a call |
| Elevator | Class | Physical car — state, queues, movement |
| DispatchStrategy | Interface | selectElevator(elevators, request) |
| NearestCarStrategy | Class | Pick closest idle/same-direction elevator |
| ZoneStrategy | Class | Assign floors to dedicated elevators |
| ElevatorSystem | Singleton | Orchestrates all elevators + dispatch |
ElevatorSystem (Singleton)
├── elevators: List<Elevator>
│ ├── upQueue: SortedSet<Integer> // floors to visit going UP
│ └── downQueue: SortedSet<Integer> // floors to visit going DOWN
└── dispatchStrategy: DispatchStrategy
├── NearestCarStrategy // min(|currentFloor - targetFloor|)
└── ZoneStrategy // floor / zoneSize → elevator index
ElevatorRequest
├── floor: int
├── direction: Direction // UP | DOWN | IDLE
└── type: RequestType // INTERNAL | EXTERNALThere are two request paths: External (a person presses UP/DOWN in the hallway — needs dispatch) and Internal (a person inside the cabin presses a floor — no dispatch needed, goes directly to the elevator's queue).
External Happy Path
Hallway button → ElevatorSystem → DispatchStrategy selects best elevator → addRequest() → SCAN moves elevator → door opens.
Internal Happy Path
Cabin button → ElevatorSystem.selectFloor() → directly calls elevator.addRequest() → SCAN moves → door opens.
All Elevators in Maintenance
DispatchStrategy throws RuntimeError / IllegalStateException. Caller must handle gracefully (e.g. display "No elevator available").
Concurrent Requests
requestElevator() and selectFloor() are both wrapped in a ReentrantLock / threading.Lock to prevent race conditions.
Draw this on the whiteboard before coding. Key relationships to highlight: ElevatorSystem composes Elevators (1..*), delegates dispatch to the DispatchStrategy interface (Strategy Pattern), and creates ElevatorRequests on each call.
Solid lines = association/composition | Dashed lines = dependency | Hollow triangles = inheritance/implementation
The SCAN algorithm (also called the Elevator Algorithm, originally from disk scheduling) is the standard approach for elevator movement. Each elevator sweeps in one direction, serving all queued floors along the way, then reverses direction when there are no more requests in the current direction.
Why SCAN?
Prevents starvation — every floor eventually gets served. Simple O(log n) insert via sorted set, O(1) next-stop lookup.
vs. FIFO
FIFO (process requests in arrival order) is simpler but causes the elevator to zigzag — Floor 1 then Floor 10 then Floor 2 — wasting time.
vs. LOOK
LOOK is a variant of SCAN that reverses as soon as there are no more requests ahead, instead of going to the end. Slightly more efficient.
// SCAN example trace — Elevator starts at floor 3, going UP // Requests: 5, 8, 10, 2, 1 Step 1: Serve 5 (already going UP, 5 > 3) ✓ Step 2: Serve 8 (continue UP) ✓ Step 3: Serve 10 (continue UP, top) ✓ Step 4: Reverse → going DOWN Step 5: Serve 2 (going DOWN, 2 < 10) ✓ Step 6: Serve 1 (continue DOWN) ✓ // With FIFO the order would be: 5, 8, 10, 2, 1 // (same here by luck, but not always — see: 10, 2, 5, 8, 1)
Enums make the state machine explicit and prevent invalid states. The Direction enum drives SCAN's queue selection, while ElevatorState enables MAINTENANCE mode to gracefully take an elevator out of service.
// ── Enums ─────────────────────────────────────────────────────
public enum Direction { UP, DOWN, IDLE }
public enum ElevatorState { MOVING, STOPPED, MAINTENANCE }
public enum RequestType { INTERNAL, EXTERNAL } // button inside vs hallwayElevatorRequest is a simple value object (or dataclass in Python) that captures everything about a call. Keeping it as an immutable record makes it safe to pass between threads without copying. The RequestType field lets the dispatcher treat internal and external calls differently if needed.
floorThe floor where the request originates
directionUP or DOWN — where the person wants to go
typeINTERNAL (cabin) or EXTERNAL (hallway)
// ── ElevatorRequest ───────────────────────────────────────────
public class ElevatorRequest {
private final int floor;
private final Direction direction; // which way the person wants to go
private final RequestType type;
public ElevatorRequest(int floor, Direction direction, RequestType type) {
this.floor = floor;
this.direction = direction;
this.type = type;
}
public int getFloor() { return floor; }
public Direction getDirection() { return direction; }
public RequestType getType() { return type; }
@Override
public String toString() {
return String.format("Request[floor=%d, dir=%s, type=%s]", floor, direction, type);
}
}Single Responsibility Principle
Elevator class knows only about its own movement. It has no knowledge of the building, other elevators, or dispatch logic. It accepts requests via addRequest() and advances one step at a time via step().The two sorted queues are the core of the SCAN implementation.upQueue stores floors to visit going up (ascending order),downQueue stores floors to visit going down (descending order).step() always picks the head of the current direction's queue, and reverses when that queue is empty.
The costToServe() method is the key hook used by the dispatch strategy — it returns the Manhattan distance between the elevator's current floor and the requested floor, providing a simple but effective fitness score.
// ── Elevator ──────────────────────────────────────────────────
import java.util.TreeSet;
public class Elevator {
private final int id;
private int currentFloor;
private Direction direction;
private ElevatorState state;
private final int minFloor;
private final int maxFloor;
// Two sorted sets act as the up-queue and down-queue (SCAN algorithm)
private final TreeSet<Integer> upQueue = new TreeSet<>(); // floors to visit going up
private final TreeSet<Integer> downQueue = new TreeSet<>(java.util.Collections.reverseOrder()); // going down
public Elevator(int id, int minFloor, int maxFloor) {
this.id = id;
this.currentFloor = minFloor;
this.direction = Direction.IDLE;
this.state = ElevatorState.STOPPED;
this.minFloor = minFloor;
this.maxFloor = maxFloor;
}
/** Add a stop to this elevator's queue */
public void addRequest(int floor) {
if (floor > currentFloor || direction == Direction.UP)
upQueue.add(floor);
else
downQueue.add(floor);
}
/**
* Move one step: open door at current floor if queued,
* then advance to next floor in the SCAN order.
*/
public void step() {
if (state == ElevatorState.MAINTENANCE) return;
// Serve current floor first
if (upQueue.contains(currentFloor)) upQueue.remove(currentFloor);
if (downQueue.contains(currentFloor)) downQueue.remove(currentFloor);
if (!upQueue.isEmpty() && (direction == Direction.UP || downQueue.isEmpty())) {
direction = Direction.UP;
state = ElevatorState.MOVING;
currentFloor = upQueue.first();
System.out.println("Elevator " + id + " → floor " + currentFloor + " (UP)");
} else if (!downQueue.isEmpty()) {
direction = Direction.DOWN;
state = ElevatorState.MOVING;
currentFloor = downQueue.first();
System.out.println("Elevator " + id + " → floor " + currentFloor + " (DOWN)");
} else {
direction = Direction.IDLE;
state = ElevatorState.STOPPED;
}
}
public int getId() { return id; }
public int getCurrentFloor() { return currentFloor; }
public Direction getDirection() { return direction; }
public ElevatorState getState() { return state; }
public boolean isIdle() { return direction == Direction.IDLE; }
/** Estimated cost to serve a new floor request (used by dispatcher) */
public int costToServe(int targetFloor) {
return Math.abs(targetFloor - currentFloor);
}
}Strategy Pattern
DispatchStrategy is the Strategy Pattern applied to elevator selection. Swap algorithms at runtime — switch from NearestCar during off-peak hours to ZoneStrategy during rush hour — with zero changes to ElevatorSystem.NearestCarStrategy scans all non-maintenance elevators and returns the one with the minimum costToServe(). Simple, O(n) in the number of elevators — fine for most buildings with 2–8 elevators.
ZoneStrategy pre-assigns each elevator to a floor range. Elevator 1 handles floors 1–5, Elevator 2 handles 6–10, etc. O(1) lookup. Ideal for tall buildings where minimising inter-zone travel is more important than absolute proximity.
// ── ElevatorDispatcher (Strategy Pattern) ─────────────────────
public interface DispatchStrategy {
Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request);
}
// ── Nearest Car (simplest & most common) ──────────────────────
public class NearestCarStrategy implements DispatchStrategy {
@Override
public Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request) {
return elevators.stream()
.filter(e -> e.getState() != ElevatorState.MAINTENANCE)
.min(Comparator.comparingInt(e -> e.costToServe(request.getFloor())))
.orElseThrow(() -> new IllegalStateException("No elevators available"));
}
}
// ── Zone Strategy (each elevator serves a floor range) ────────
public class ZoneStrategy implements DispatchStrategy {
private final int zonesPerElevator;
public ZoneStrategy(int totalFloors, int elevatorCount) {
this.zonesPerElevator = totalFloors / elevatorCount;
}
@Override
public Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request) {
int zoneIndex = request.getFloor() / zonesPerElevator;
zoneIndex = Math.min(zoneIndex, elevators.size() - 1);
Elevator zoneElevator = elevators.get(zoneIndex);
if (zoneElevator.getState() == ElevatorState.MAINTENANCE) {
// fallback to nearest
return new NearestCarStrategy().selectElevator(elevators, request);
}
return zoneElevator;
}
}Thread Safety
requestElevator() and selectFloor()are both protected by a ReentrantLock (Java) or threading.Lock (Python). Without this, two threads could both evaluate the best elevator simultaneously and both assign it — causing double-assignment.ElevatorSystem is the single entry point for the building controller. It uses double-checked locking for thread-safe singleton initialisation, and exposes only two public methods for requests: one for external (hallway) buttons, one for internal (cabin) buttons.
The tick() method advances every elevator one step. In a real system you'd replace this with a per-elevator thread that loops on its queue — the internal lock in each Elevator.step() would replace the system-level lock for better concurrency.
// ── ElevatorSystem (Singleton) ────────────────────────────────
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
public class ElevatorSystem {
private static volatile ElevatorSystem instance;
private final List<Elevator> elevators;
private final int totalFloors;
private DispatchStrategy dispatchStrategy;
private final ReentrantLock lock = new ReentrantLock();
private ElevatorSystem(int totalFloors, int elevatorCount) {
this.totalFloors = totalFloors;
this.elevators = new ArrayList<>();
this.dispatchStrategy = new NearestCarStrategy();
for (int i = 0; i < elevatorCount; i++)
elevators.add(new Elevator(i + 1, 1, totalFloors));
}
public static ElevatorSystem getInstance(int floors, int elevators) {
if (instance == null) {
synchronized (ElevatorSystem.class) {
if (instance == null) instance = new ElevatorSystem(floors, elevators);
}
}
return instance;
}
public void setDispatchStrategy(DispatchStrategy s) { this.dispatchStrategy = s; }
/** External call: person presses UP or DOWN on a floor */
public void requestElevator(int floor, Direction direction) {
lock.lock();
try {
ElevatorRequest req = new ElevatorRequest(floor, direction, RequestType.EXTERNAL);
Elevator chosen = dispatchStrategy.selectElevator(elevators, req);
chosen.addRequest(floor);
System.out.printf("Dispatched Elevator %d for external request at floor %d (%s)%n",
chosen.getId(), floor, direction);
} finally { lock.unlock(); }
}
/** Internal call: person inside cabin presses a floor button */
public void selectFloor(int elevatorId, int floor) {
lock.lock();
try {
Elevator elevator = elevators.stream()
.filter(e -> e.getId() == elevatorId)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown elevator: " + elevatorId));
elevator.addRequest(floor);
System.out.printf("Elevator %d floor button: %d%n", elevatorId, floor);
} finally { lock.unlock(); }
}
/** Advance all elevators one step (called in a simulation loop / scheduler) */
public void tick() {
elevators.forEach(Elevator::step);
}
public void printStatus() {
System.out.println("=== Elevator Status ===");
for (Elevator e : elevators) {
System.out.printf(" Elevator %d | Floor: %2d | Dir: %-5s | State: %s%n",
e.getId(), e.getCurrentFloor(), e.getDirection(), e.getState());
}
}
}The demo creates a 20-floor building with 3 elevators, fires off 3 external requests, simulates 5 ticks of movement, then adds an internal floor request and simulates 5 more ticks.
// ── Demo ───────────────────────────────────────────────────────
public class Main {
public static void main(String[] args) {
ElevatorSystem system = ElevatorSystem.getInstance(20, 3);
system.setDispatchStrategy(new NearestCarStrategy());
// External requests from hallway buttons
system.requestElevator(5, Direction.UP); // person on floor 5 wants to go up
system.requestElevator(12, Direction.DOWN); // person on floor 12 wants to go down
system.requestElevator(1, Direction.UP); // person on floor 1 wants to go up
// Simulate elevator movement over several ticks
for (int i = 0; i < 5; i++) {
system.tick();
system.printStatus();
System.out.println();
}
// Internal request — person inside Elevator 1 presses floor 8
system.selectFloor(1, 8);
for (int i = 0; i < 5; i++) {
system.tick();
system.printStatus();
System.out.println();
}
}
}Expected output (excerpt):
Dispatched Elevator 1 for external request at floor 5 (UP)
Dispatched Elevator 2 for external request at floor 12 (DOWN)
Dispatched Elevator 3 for external request at floor 1 (UP)
Elevator 1 → floor 5 (UP)
Elevator 2 → floor 12 (DOWN)
Elevator 3 → floor 1 (UP)
… (more ticks)
Elevator 1 floor button: 8
Elevator 1 → floor 8 (UP)
Elevator systems are inherently concurrent — multiple people press buttons at the same time, elevators move independently, and the controller must coordinate all of them. Here are the three key race conditions and how we prevent them:
Race: Double-Assignment
Without a lock, Thread A and Thread B both call requestElevator() simultaneously. Both evaluate NearestCarStrategy and both pick Elevator 1. Both call elevator.addRequest(floor) — now Elevator 1 has the same floor twice, wasting a trip.
✓ Fix: The system-level ReentrantLock ensures selectElevator + addRequest is atomic across all threads.
Race: Elevator State Corruption
Without per-elevator synchronization, step() running in one thread and addRequest() called from another thread could corrupt the sorted queue mid-iteration (ConcurrentModificationException in Java).
✓ Fix: In a real multi-threaded implementation, per-elevator locks (or ConcurrentSkipListSet in Java) protect the up/down queues.
Singleton Initialization Race
Two threads could both pass the outer null-check, both enter the constructor, and create two ElevatorSystem instances — each with their own set of elevators and completely divergent state.
✓ Fix: volatile + synchronized inner block (Java) / threading.Lock (Python) guarantees single creation.
Complexity analysis for the key operations in the system:
| Operation | Time | Notes |
|---|---|---|
| requestElevator() | O(E) | E = number of elevators; NearestCar scans all |
| selectFloor() | O(log Q) | Q = queue size; insert into sorted set |
| addRequest(floor) | O(log Q) | TreeSet / SortedList insert |
| step() — next stop | O(log Q) | Remove head from sorted set + update floor |
| costToServe(floor) | O(1) | Simple absolute difference |
| ZoneStrategy dispatch | O(1) | Direct array index by zone |
Discuss these with the interviewer to show you're thinking beyond the basic design. Don't implement them all — just articulate the design approach.
Priority / VIP Floors
Add a priority field to ElevatorRequest. Use a PriorityQueue instead of SortedSet so higher-priority stops are served first within the same direction sweep.
Emergency Mode
Override all queues and dispatch every elevator to floor 1 immediately. Add an EMERGENCY state to ElevatorState and short-circuit step() logic.
Floor Display Board
Observer/Publisher: Elevator emits FloorChangedEvent on every step(). DisplayBoard subscribes and updates "Elevator 2 → Floor 7 ↑" in real-time.
Weight / Capacity Sensor
Add a currentLoad field to Elevator. DispatchStrategy skips elevators at capacity. Elevator refuses addRequest() if weight sensor reports overload.
Distributed Multi-Building
Extract ElevatorSystem to a microservice per building. A central controller aggregates availability across buildings. Use message queues (Kafka) for request routing.
Machine Learning Dispatch
Replace NearestCarStrategy with an MLDispatchStrategy that predicts peak-hour demand per floor and pre-positions elevators. Same interface, zero system changes.
15. Key Design Decisions
SCAN Algorithm (Elevator Algorithm)
Each elevator sweeps in one direction, picking up all requests along the way, then reverses. This minimises total travel distance and prevents starvation — exactly like a disk scheduler.
Strategy Pattern for Dispatch
The DispatchStrategy interface lets you swap Nearest Car, Zone-based, or ML-based dispatch without touching ElevatorSystem. New strategy = new class, zero changes elsewhere.
Two Sorted Queues per Elevator
Up-requests and down-requests are stored in separate sorted sets. This makes SCAN trivially O(log n) insert and O(1) next-stop lookup, while correctly handling direction reversal.
Tick-based simulation vs. real threading
The demo uses a tick() loop for simplicity. In production, each elevator runs in its own thread with a scheduler. The lock-based design supports both models without changes.
16. Common Follow-up Interview Questions
- How would you handle an elevator that gets stuck mid-floor?
💡 Add a STUCK state, a watchdog timer per elevator, and an alerting mechanism (Observer).
- How would you implement a "group dispatch" algorithm?
💡 NearestCar considers direction match — prefer elevators already moving towards the request floor.
- How would you add a weight/capacity limit per elevator?
💡 Track currentLoad in Elevator; DispatchStrategy skips elevators where load >= capacity.
- How would you support VIP/express elevators for certain floors?
💡 Add floorFilter: Set<Integer> per Elevator; DispatchStrategy only considers matching elevators.
- How would you prevent elevator starvation on lower floors?
💡 SCAN guarantees no starvation — every floor is served within one full sweep up and down.
- How would you design the system for a 100-floor skyscraper?
💡 ZoneStrategy + sky lobbies; elevators only serve their assigned zone, inter-zone transfers at lobby floors.
- How would you scale to multiple buildings in a city?
💡 Each building is an ElevatorSystem instance; a BuildingNetwork aggregates availability via REST/message queue.
- How would you make each elevator run in its own thread?
💡 Replace tick() loop with per-elevator Runnable/Thread; per-elevator ReentrantLock replaces system-level lock.