Practice · Low Level Design · State Machines
Design an Elevator System
Several elevators, many floors, impatient people: the state machine behind each car, and a scheduler that decides which elevator answers a call.
Learn mode: the full solution is shown below.
Problem introduction
| Difficulty | Intermediate |
| Time | 2–3 hours to design and code; about 45 minutes in an interview |
| Prerequisites | State machines, interfaces, locks |
| Concepts | A state machine per car, a dispatching Strategy, the LOOK algorithm, simulated time, buttons and a ticker on different goroutines |
| Real world | Lifts in office towers, hospitals and apartment buildings; any system that assigns moving workers to requests (rides, deliveries) |
The elevator question checks whether you can split a moving, concurrent system into clean pieces: each car knows how to move and where it has promised to stop, and a dispatcher decides which car answers a call. Interviewers then push on the dispatching rule, because that's where the trade-offs live.
Problem statement
A ten-floor office building in Gulshan has two lifts, A and B. People on a floor press up or down (a hall call); people inside a car press a floor number (a car call). Each car moves one floor per tick, stops at the floors it promised, and opens its doors.
What we are building: the controller that assigns hall calls to cars and moves the cars.
Why it matters: a bad dispatcher sends the far car, or sends a car that's going the other way, and people wait minutes in the lobby.
In one line: assign each hall call to the car that can get there soonest, and move each car efficiently through its stops.
Requirements
Functional requirements
- N floors, M cars. Each car has a current floor, a direction (idle, up, down) and a set of stops.
- Hall call (floor, direction): pick a car and add the floor to its stops.
- Car call (car, floor): add the floor to that car's stops.
- Tick: every car moves one floor towards its next stop and opens its doors when it reaches one.
- A car keeps going in its direction while there are stops ahead, then turns (no zig-zag).
- Refuse floors that don't exist and cars that don't exist.
Non-functional requirements
| Requirement | What it means here |
|---|---|
| Short waits | The dispatcher prefers idle cars and cars already heading to the floor in the right direction |
| Extensibility | Swapping the dispatching rule (rush-hour mode, VIP floor) doesn't touch the cars |
| Correctness under concurrency | Buttons are pressed on many goroutines while a ticker moves the cars |
| Testability | Time is a Tick() call, so tests don't sleep |
Assumptions and out of scope
- Doors open and close within a tick; no capacity limit, no emergency or maintenance mode.
What to hand in
- Entities, a state diagram for a car, and the dispatching rule.
- Code for hall calls, car calls and ticks.
- Tests for dispatching, the LOOK order and concurrency.
Hint 1: who decides?
A car shouldn't decide which hall calls it takes; it only sees itself. Put that decision in one object that sees all cars.
Hint 2: a car's state
Idle, going up, going down. What makes it switch from one to another?
Hint 3: which car is "nearest"?
Distance alone isn't enough. A car two floors away that's going the other way may take much longer than one five floors away coming towards you.
Hint 4: time
Don't use real sleeps. A Tick() method that moves every car one floor makes the whole system testable.
Core entities
| Entity | Job in one sentence | Holds | Does |
|---|---|---|---|
Car | Moves through its own stops | name, floor, direction, stops | AddStop, Step (LOOK) |
Direction | The car's state | idle / up / down | nothing: an enum |
Dispatcher (interface) | Picks a car for a hall call | depends on the rule | Pick(cars, floor, dir) |
Nearest | The default rule: cheapest car by distance and direction | floors (for the penalty) | cost, Pick |
Building | The entry point; owns the cars and the lock | floors, cars, dispatcher | Call, Press, Tick, Idle |
Relationships: Building owns its cars and uses a Dispatcher (Strategy). Each Car owns its stops and changes its own direction. Hall calls go through the dispatcher; car calls go straight to the car.
Diagrams
Class diagram
How to read it: Building owns the cars and holds the lock. It doesn't choose cars itself: it holds a Dispatcher and asks it to Pick. Nearest is today's rule; a rush-hour rule would be another box implementing the same interface. Each Car owns its stops and its Direction, which is the small state machine from the LOOK diagram.
Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.
Sequence: a hall call, then a tick
How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. The person and the ticker are two different callers, often on two different goroutines, and both go through Building, which is why it holds the lock. Nearest only answers a question; it never changes a car. The change (AddStop) is made by Building while it holds the lock.
Dispatching a hall call
How to read it: each shaft is a car. The cost rule charges plain distance for an idle car or a car already heading to the floor in the caller's direction, and adds a penalty (twice the building height) for a car going the other way. The lowest cost wins; ties go to the first car.
LOOK: one direction at a time
How to read it: a car with stops on both sides finishes everything in its current direction before turning. This is the LOOK algorithm (also used for disk heads): it avoids starving far floors and avoids the wasted back-and-forth of always going to the nearest stop.
Design principles
- Each car owns its movement; the dispatcher owns the choice. Neither reaches into the other's data.
- Open/closed: a rush-hour dispatcher (send idle cars to the lobby at 9 a.m.) is a new
Dispatcher;CarandBuildingdon't change. - Time as an input:
Tick()is called by a ticker in production and by tests directly. - One lock for the shared state: buttons and the ticker both go through
Building, which takes the lock.
Design patterns
State (for a car's direction)
A three-value enum with clear transitions: idle → up/down when a stop is added; up ↔ down when nothing is left ahead; up/down → idle when the last stop is served. An enum is enough here; the full State pattern (one class per state) would be overkill for three states with simple behaviour.
Strategy (for dispatching)
- Why: the "best car" rule changes with the building and the time of day.
- What it solves: dispatching is one swappable object,
Dispatcher. - Without it: an
if rushHour { … }grows insideCall. - Trade-off: one interface; trivial cost.
- Where:
dispatcher.go;Building.Callasksb.dispatcher.Pick(...).
Complete solution in Go
Project structure
elevator/
├── go.mod
├── car.go Car, Direction, the LOOK step
├── dispatcher.go the Dispatcher strategy and the Nearest rule
├── building.go Building: Call, Press, Tick, the lock
├── building_test.go tests, including buttons and ticks at once
└── cmd/demo/main.go a morning with two lifts
Step 1: the car
// car.go
package elevator
import "sort"
// Direction is the car's state: a tiny state machine (Idle <-> Up/Down).
type Direction int
const (
Idle Direction = iota
Up
Down
)
func (d Direction) String() string { return [...]string{"idle", "up", "down"}[d] }
// Car keeps its own floor, direction and the floors it has promised to stop at.
type Car struct {
Name string
Floor int
Dir Direction
stops map[int]bool
}
func NewCar(name string, floor int) *Car {
return &Car{Name: name, Floor: floor, stops: map[int]bool{}}
}
func (c *Car) AddStop(floor int) {
if floor == c.Floor && c.Dir == Idle {
return
}
c.stops[floor] = true
if c.Dir == Idle {
c.Dir = c.towards(floor)
}
}
func (c *Car) towards(floor int) Direction {
switch {
case floor > c.Floor:
return Up
case floor < c.Floor:
return Down
}
return Idle
}
// hasStop reports whether any stop lies in direction d from the current floor.
func (c *Car) hasStop(d Direction) bool {
for f := range c.stops {
if (d == Up && f > c.Floor) || (d == Down && f < c.Floor) {
return true
}
}
return false
}
// Step moves one floor (the LOOK algorithm: keep going while there are stops ahead, then
// turn). It returns true if the car stopped at a floor it promised.
func (c *Car) Step() bool {
if len(c.stops) == 0 {
c.Dir = Idle
return false
}
if !c.hasStop(c.Dir) { // nothing ahead: turn around
if c.Dir == Up {
c.Dir = Down
} else {
c.Dir = Up
}
}
if c.Dir == Up {
c.Floor++
} else {
c.Floor--
}
if c.stops[c.Floor] {
delete(c.stops, c.Floor)
if len(c.stops) == 0 {
c.Dir = Idle
}
return true
}
return false
}
// Stops lists pending stops in order (for display and tests).
func (c *Car) Stops() []int {
out := []int{}
for f := range c.stops {
out = append(out, f)
}
sort.Ints(out)
return out
}
Step 2: the dispatcher
// dispatcher.go
package elevator
// Dispatcher is the Strategy that decides which car answers a hall call.
type Dispatcher interface {
Pick(cars []*Car, floor int, dir Direction) *Car
}
// Nearest prefers an idle car or one already heading to the floor in the caller's direction;
// a car going the other way is charged a penalty. Ties go to the first car.
type Nearest struct{ Floors int }
func (n Nearest) cost(c *Car, floor int, dir Direction) int {
dist := c.Floor - floor
if dist < 0 {
dist = -dist
}
switch {
case c.Dir == Idle:
return dist
case c.Dir == Up && dir == Up && floor >= c.Floor:
return dist
case c.Dir == Down && dir == Down && floor <= c.Floor:
return dist
}
return dist + 2*n.Floors // it must finish its trip and come back
}
func (n Nearest) Pick(cars []*Car, floor int, dir Direction) *Car {
best := cars[0]
for _, c := range cars[1:] {
if n.cost(c, floor, dir) < n.cost(best, floor, dir) {
best = c
}
}
return best
}
Step 3: the building
// building.go
package elevator
import (
"fmt"
"sync"
)
// Building is the entry point: buttons call it from many goroutines, a ticker calls Tick.
type Building struct {
mu sync.Mutex
floors int
cars []*Car
dispatcher Dispatcher
}
func NewBuilding(floors int, d Dispatcher, cars ...*Car) *Building {
return &Building{floors: floors, cars: cars, dispatcher: d}
}
// Call is a hall button: someone on `floor` wants to go `dir`. It returns the car assigned.
func (b *Building) Call(floor int, dir Direction) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if floor < 0 || floor >= b.floors {
return "", fmt.Errorf("no floor %d", floor)
}
c := b.dispatcher.Pick(b.cars, floor, dir)
c.AddStop(floor)
return c.Name, nil
}
// Press is a button inside a car.
func (b *Building) Press(car string, floor int) error {
b.mu.Lock()
defer b.mu.Unlock()
if floor < 0 || floor >= b.floors {
return fmt.Errorf("no floor %d", floor)
}
for _, c := range b.cars {
if c.Name == car {
c.AddStop(floor)
return nil
}
}
return fmt.Errorf("no car %s", car)
}
// Tick moves every car one floor and returns the cars that opened their doors.
func (b *Building) Tick() []string {
b.mu.Lock()
defer b.mu.Unlock()
var opened []string
for _, c := range b.cars {
if c.Step() {
opened = append(opened, fmt.Sprintf("car %s opens at %d", c.Name, c.Floor))
}
}
return opened
}
// Idle reports whether every car has nothing left to do, and where they are.
func (b *Building) Idle() (bool, string) {
b.mu.Lock()
defer b.mu.Unlock()
where := ""
for i, c := range b.cars {
if c.Dir != Idle {
return false, ""
}
if i > 0 {
where += ", "
}
where += fmt.Sprintf("%s at %d", c.Name, c.Floor)
}
return true, where
}
Step 4: run it
// cmd/demo/main.go
package main
import (
"fmt"
"elevator"
)
func main() {
b := elevator.NewBuilding(10, elevator.Nearest{Floors: 10},
elevator.NewCar("A", 0), elevator.NewCar("B", 9))
call := func(t, floor int, dir elevator.Direction) {
car, _ := b.Call(floor, dir)
fmt.Printf("t%-2d call floor %d %-4s -> car %s\n", t, floor, dir, car)
}
press := func(t int, car string, floor int) {
b.Press(car, floor)
fmt.Printf("t%-2d car %s: passenger goes to %d\n", t, car, floor)
}
call(0, 3, elevator.Up)
call(0, 7, elevator.Down)
for t := 1; t <= 20; t++ {
for _, e := range b.Tick() {
fmt.Printf("t%-2d %s\n", t, e)
}
switch t { // what people do at each moment of the story
case 2:
press(t, "B", 1)
case 3:
press(t, "A", 8)
call(t, 5, elevator.Down)
}
if idle, where := b.Idle(); idle {
fmt.Printf("t%-2d all cars idle: %s\n", t, where)
return
}
}
}
$ go run ./cmd/demo
t0 call floor 3 up -> car A
t0 call floor 7 down -> car B
t2 car B opens at 7
t2 car B: passenger goes to 1
t3 car A opens at 3
t3 car A: passenger goes to 8
t3 call floor 5 down -> car B
t4 car B opens at 5
t8 car A opens at 8
t8 car B opens at 1
t8 all cars idle: A at 8, B at 1
Reading the output:
- t0: floor 3 up goes to A (3 floors away, idle); floor 7 down goes to B (2 floors away, idle).
- t2: B reaches 7; its passenger presses 1.
- t3: A reaches 3; its passenger presses 8. Then floor 5 calls down: A is going up (cost 2 + 20), B is at 6 going down (cost 1), so B gets it.
- t4: B stops at 5 on the way.
- t8: A reaches 8 and B reaches 1 on the same tick; both are idle.
The other five languages
from __future__ import annotations
import threading
from enum import Enum
from typing import Protocol
class Dir(Enum):
"""The car's state: a tiny state machine (IDLE <-> UP/DOWN)."""
IDLE = "idle"
UP = "up"
DOWN = "down"
class Car:
def __init__(self, name: str, floor: int):
self.name, self.floor, self.dir = name, floor, Dir.IDLE
self.stops: set[int] = set()
def add_stop(self, floor: int) -> None:
if floor == self.floor and self.dir is Dir.IDLE:
return
self.stops.add(floor)
if self.dir is Dir.IDLE:
self.dir = Dir.UP if floor > self.floor else Dir.DOWN
def _has_stop(self, d: Dir) -> bool:
return any((d is Dir.UP and f > self.floor) or (d is Dir.DOWN and f < self.floor) for f in self.stops)
def step(self) -> bool:
"""LOOK: keep going while there are stops ahead, then turn. True if it stopped."""
if not self.stops:
self.dir = Dir.IDLE
return False
if not self._has_stop(self.dir):
self.dir = Dir.DOWN if self.dir is Dir.UP else Dir.UP
self.floor += 1 if self.dir is Dir.UP else -1
if self.floor in self.stops:
self.stops.remove(self.floor)
if not self.stops:
self.dir = Dir.IDLE
return True
return False
class Dispatcher(Protocol):
"""The Strategy that decides which car answers a hall call."""
def pick(self, cars: list[Car], floor: int, d: Dir) -> Car: ...
class Nearest:
def __init__(self, floors: int):
self.floors = floors
def cost(self, c: Car, floor: int, d: Dir) -> int:
dist = abs(c.floor - floor)
if c.dir is Dir.IDLE:
return dist
if c.dir is Dir.UP and d is Dir.UP and floor >= c.floor:
return dist
if c.dir is Dir.DOWN and d is Dir.DOWN and floor <= c.floor:
return dist
return dist + 2 * self.floors # it must finish its trip and come back
def pick(self, cars: list[Car], floor: int, d: Dir) -> Car:
return min(cars, key=lambda c: self.cost(c, floor, d)) # min() keeps the first on ties
class Building:
def __init__(self, floors: int, dispatcher: Dispatcher, *cars: Car):
self._lock = threading.Lock()
self.floors, self.dispatcher, self.cars = floors, dispatcher, list(cars)
def call(self, floor: int, d: Dir) -> str:
with self._lock:
if not 0 <= floor < self.floors:
raise ValueError(f"no floor {floor}")
c = self.dispatcher.pick(self.cars, floor, d)
c.add_stop(floor)
return c.name
def press(self, car: str, floor: int) -> None:
with self._lock:
next(c for c in self.cars if c.name == car).add_stop(floor)
def tick(self) -> list[str]:
with self._lock:
return [f"car {c.name} opens at {c.floor}" for c in self.cars if c.step()]
def idle(self) -> tuple[bool, str]:
with self._lock:
if any(c.dir is not Dir.IDLE for c in self.cars):
return False, ""
return True, ", ".join(f"{c.name} at {c.floor}" for c in self.cars)
b = Building(10, Nearest(10), Car("A", 0), Car("B", 9))
def call(t: int, floor: int, d: Dir) -> None:
car = b.call(floor, d)
print(f"t{t:<2} call floor {floor} {d.value:<4} -> car {car}")
def press(t: int, car: str, floor: int) -> None:
b.press(car, floor)
print(f"t{t:<2} car {car}: passenger goes to {floor}")
call(0, 3, Dir.UP)
call(0, 7, Dir.DOWN)
for t in range(1, 21):
for e in b.tick():
print(f"t{t:<2} {e}")
if t == 2:
press(t, "B", 1)
elif t == 3:
press(t, "A", 8)
call(t, 5, Dir.DOWN)
done, where = b.idle()
if done:
print(f"t{t:<2} all cars idle: {where}")
break// The car's state: a tiny state machine (idle <-> up/down).
type Dir = "idle" | "up" | "down";
class Car {
readonly name: string;
floor: number;
dir: Dir = "idle";
readonly stops = new Set<number>();
constructor(name: string, floor: number) {
this.name = name;
this.floor = floor;
}
addStop(floor: number): void {
if (floor === this.floor && this.dir === "idle") return;
this.stops.add(floor);
if (this.dir === "idle") this.dir = floor > this.floor ? "up" : "down";
}
private hasStop(d: Dir): boolean {
for (const f of this.stops) if ((d === "up" && f > this.floor) || (d === "down" && f < this.floor)) return true;
return false;
}
// LOOK: keep going while there are stops ahead, then turn. True if it stopped.
step(): boolean {
if (this.stops.size === 0) {
this.dir = "idle";
return false;
}
if (!this.hasStop(this.dir)) this.dir = this.dir === "up" ? "down" : "up";
this.floor += this.dir === "up" ? 1 : -1;
if (this.stops.delete(this.floor)) {
if (this.stops.size === 0) this.dir = "idle";
return true;
}
return false;
}
}
// The Strategy that decides which car answers a hall call.
interface Dispatcher {
pick(cars: Car[], floor: number, d: Dir): Car;
}
class Nearest implements Dispatcher {
readonly floors: number;
constructor(floors: number) {
this.floors = floors;
}
cost(c: Car, floor: number, d: Dir): number {
const dist = Math.abs(c.floor - floor);
if (c.dir === "idle") return dist;
if (c.dir === "up" && d === "up" && floor >= c.floor) return dist;
if (c.dir === "down" && d === "down" && floor <= c.floor) return dist;
return dist + 2 * this.floors; // it must finish its trip and come back
}
pick(cars: Car[], floor: number, d: Dir): Car {
return cars.reduce((best, c) => (this.cost(c, floor, d) < this.cost(best, floor, d) ? c : best));
}
}
// No lock: one thread, and nothing here awaits.
class Building {
readonly floors: number;
readonly dispatcher: Dispatcher;
readonly cars: Car[];
constructor(floors: number, dispatcher: Dispatcher, ...cars: Car[]) {
this.floors = floors;
this.dispatcher = dispatcher;
this.cars = cars;
}
call(floor: number, d: Dir): string {
if (floor < 0 || floor >= this.floors) throw new Error(`no floor ${floor}`);
const c = this.dispatcher.pick(this.cars, floor, d);
c.addStop(floor);
return c.name;
}
press(car: string, floor: number): void {
this.cars.find((c) => c.name === car)!.addStop(floor);
}
tick(): string[] {
return this.cars.filter((c) => c.step()).map((c) => `car ${c.name} opens at ${c.floor}`);
}
idle(): [boolean, string] {
if (this.cars.some((c) => c.dir !== "idle")) return [false, ""];
return [true, this.cars.map((c) => `${c.name} at ${c.floor}`).join(", ")];
}
}
const b = new Building(10, new Nearest(10), new Car("A", 0), new Car("B", 9));
const tt = (t: number) => `t${String(t).padEnd(2)}`;
function call(t: number, floor: number, d: Dir): void {
console.log(`${tt(t)} call floor ${floor} ${d.padEnd(4)} -> car ${b.call(floor, d)}`);
}
function press(t: number, car: string, floor: number): void {
b.press(car, floor);
console.log(`${tt(t)} car ${car}: passenger goes to ${floor}`);
}
call(0, 3, "up");
call(0, 7, "down");
for (let t = 1; t <= 20; t++) {
for (const e of b.tick()) console.log(`${tt(t)} ${e}`);
if (t === 2) press(t, "B", 1);
if (t === 3) {
press(t, "A", 8);
call(t, 5, "down");
}
const [done, where] = b.idle();
if (done) {
console.log(`${tt(t)} all cars idle: ${where}`);
break;
}
}use std::collections::BTreeSet;
use std::fmt;
use std::sync::Mutex;
// The car's state: a tiny state machine (Idle <-> Up/Down).
#[derive(Clone, Copy, PartialEq)]
enum Dir { Idle, Up, Down }
impl fmt::Display for Dir {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.pad(match self { Dir::Idle => "idle", Dir::Up => "up", Dir::Down => "down" })
}
}
struct Car { name: &'static str, floor: i32, dir: Dir, stops: BTreeSet<i32> }
impl Car {
fn new(name: &'static str, floor: i32) -> Car { Car { name, floor, dir: Dir::Idle, stops: BTreeSet::new() } }
fn add_stop(&mut self, floor: i32) {
if floor == self.floor && self.dir == Dir::Idle { return; }
self.stops.insert(floor);
if self.dir == Dir::Idle { self.dir = if floor > self.floor { Dir::Up } else { Dir::Down }; }
}
fn has_stop(&self, d: Dir) -> bool {
self.stops.iter().any(|&f| (d == Dir::Up && f > self.floor) || (d == Dir::Down && f < self.floor))
}
// LOOK: keep going while there are stops ahead, then turn. True if it stopped.
fn step(&mut self) -> bool {
if self.stops.is_empty() { self.dir = Dir::Idle; return false; }
if !self.has_stop(self.dir) { self.dir = if self.dir == Dir::Up { Dir::Down } else { Dir::Up }; }
self.floor += if self.dir == Dir::Up { 1 } else { -1 };
if self.stops.remove(&self.floor) {
if self.stops.is_empty() { self.dir = Dir::Idle; }
return true;
}
false
}
}
// The Strategy that decides which car answers a hall call (returns an index into cars).
trait Dispatcher: Send { fn pick(&self, cars: &[Car], floor: i32, d: Dir) -> usize; }
struct Nearest { floors: i32 }
impl Nearest {
fn cost(&self, c: &Car, floor: i32, d: Dir) -> i32 {
let dist = (c.floor - floor).abs();
match (c.dir, d) {
(Dir::Idle, _) => dist,
(Dir::Up, Dir::Up) if floor >= c.floor => dist,
(Dir::Down, Dir::Down) if floor <= c.floor => dist,
_ => dist + 2 * self.floors, // it must finish its trip and come back
}
}
}
impl Dispatcher for Nearest {
fn pick(&self, cars: &[Car], floor: i32, d: Dir) -> usize {
let mut best = 0;
for i in 1..cars.len() { if self.cost(&cars[i], floor, d) < self.cost(&cars[best], floor, d) { best = i; } }
best
}
}
struct Building { floors: i32, dispatcher: Box<dyn Dispatcher>, cars: Mutex<Vec<Car>> }
impl Building {
fn call(&self, floor: i32, d: Dir) -> Result<&'static str, String> {
if floor < 0 || floor >= self.floors { return Err(format!("no floor {floor}")); }
let mut cars = self.cars.lock().unwrap();
let i = self.dispatcher.pick(&cars, floor, d);
cars[i].add_stop(floor);
Ok(cars[i].name)
}
fn press(&self, car: &str, floor: i32) {
let mut cars = self.cars.lock().unwrap();
if let Some(c) = cars.iter_mut().find(|c| c.name == car) { c.add_stop(floor); }
}
fn tick(&self) -> Vec<String> {
let mut cars = self.cars.lock().unwrap();
cars.iter_mut().filter_map(|c| c.step().then(|| format!("car {} opens at {}", c.name, c.floor))).collect()
}
fn idle(&self) -> Option<String> {
let cars = self.cars.lock().unwrap();
if cars.iter().any(|c| c.dir != Dir::Idle) { return None; }
Some(cars.iter().map(|c| format!("{} at {}", c.name, c.floor)).collect::<Vec<_>>().join(", "))
}
}
fn main() {
let b = Building { floors: 10, dispatcher: Box::new(Nearest { floors: 10 }), cars: Mutex::new(vec![Car::new("A", 0), Car::new("B", 9)]) };
let call = |t: i32, floor: i32, d: Dir| {
let car = b.call(floor, d).unwrap();
println!("t{t:<2} call floor {floor} {d:<4} -> car {car}");
};
let press = |t: i32, car: &str, floor: i32| {
b.press(car, floor);
println!("t{t:<2} car {car}: passenger goes to {floor}");
};
call(0, 3, Dir::Up);
call(0, 7, Dir::Down);
for t in 1..=20 {
for e in b.tick() { println!("t{t:<2} {e}"); }
match t {
2 => press(t, "B", 1),
3 => { press(t, "A", 8); call(t, 5, Dir::Down); }
_ => {}
}
if let Some(where_) = b.idle() { println!("t{t:<2} all cars idle: {where_}"); break; }
}
}import java.util.List;
import java.util.TreeSet;
import java.util.stream.Collectors;
public class Main {
// The car's state: a tiny state machine (IDLE <-> UP/DOWN).
enum Dir {
IDLE, UP, DOWN;
@Override public String toString() { return name().toLowerCase(); }
}
static final class Car {
final String name;
int floor;
Dir dir = Dir.IDLE;
final TreeSet<Integer> stops = new TreeSet<>();
Car(String name, int floor) { this.name = name; this.floor = floor; }
void addStop(int f) {
if (f == floor && dir == Dir.IDLE) return;
stops.add(f);
if (dir == Dir.IDLE) dir = f > floor ? Dir.UP : Dir.DOWN;
}
boolean hasStop(Dir d) {
return d == Dir.UP ? stops.higher(floor) != null : stops.lower(floor) != null;
}
// LOOK: keep going while there are stops ahead, then turn. True if it stopped.
boolean step() {
if (stops.isEmpty()) { dir = Dir.IDLE; return false; }
if (!hasStop(dir)) dir = dir == Dir.UP ? Dir.DOWN : Dir.UP;
floor += dir == Dir.UP ? 1 : -1;
if (stops.remove(floor)) {
if (stops.isEmpty()) dir = Dir.IDLE;
return true;
}
return false;
}
}
// The Strategy that decides which car answers a hall call.
interface Dispatcher { Car pick(List<Car> cars, int floor, Dir d); }
record Nearest(int floors) implements Dispatcher {
int cost(Car c, int floor, Dir d) {
int dist = Math.abs(c.floor - floor);
if (c.dir == Dir.IDLE) return dist;
if (c.dir == Dir.UP && d == Dir.UP && floor >= c.floor) return dist;
if (c.dir == Dir.DOWN && d == Dir.DOWN && floor <= c.floor) return dist;
return dist + 2 * floors; // it must finish its trip and come back
}
public Car pick(List<Car> cars, int floor, Dir d) {
Car best = cars.get(0);
for (Car c : cars) if (cost(c, floor, d) < cost(best, floor, d)) best = c;
return best;
}
}
static final class Building {
private final int floors;
private final Dispatcher dispatcher;
private final List<Car> cars;
Building(int floors, Dispatcher d, Car... cars) { this.floors = floors; this.dispatcher = d; this.cars = List.of(cars); }
synchronized String call(int floor, Dir d) {
if (floor < 0 || floor >= floors) throw new IllegalArgumentException("no floor " + floor);
Car c = dispatcher.pick(cars, floor, d);
c.addStop(floor);
return c.name;
}
synchronized void press(String car, int floor) {
cars.stream().filter(c -> c.name.equals(car)).findFirst().orElseThrow().addStop(floor);
}
synchronized List<String> tick() {
return cars.stream().filter(Car::step).map(c -> "car " + c.name + " opens at " + c.floor).toList();
}
synchronized String idle() {
if (cars.stream().anyMatch(c -> c.dir != Dir.IDLE)) return null;
return cars.stream().map(c -> c.name + " at " + c.floor).collect(Collectors.joining(", "));
}
}
static Building b = new Building(10, new Nearest(10), new Car("A", 0), new Car("B", 9));
static void call(int t, int floor, Dir d) {
System.out.printf("t%-2d call floor %d %-4s -> car %s%n", t, floor, d, b.call(floor, d));
}
static void press(int t, String car, int floor) {
b.press(car, floor);
System.out.printf("t%-2d car %s: passenger goes to %d%n", t, car, floor);
}
public static void main(String[] args) {
call(0, 3, Dir.UP);
call(0, 7, Dir.DOWN);
for (int t = 1; t <= 20; t++) {
for (String e : b.tick()) System.out.printf("t%-2d %s%n", t, e);
if (t == 2) press(t, "B", 1);
if (t == 3) { press(t, "A", 8); call(t, 5, Dir.DOWN); }
String where = b.idle();
if (where != null) { System.out.printf("t%-2d all cars idle: %s%n", t, where); break; }
}
}
}#include <cstdio>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <vector>
// The car's state: a tiny state machine (Idle <-> Up/Down).
enum class Dir { Idle, Up, Down };
const char* name(Dir d) { return d == Dir::Up ? "up" : d == Dir::Down ? "down" : "idle"; }
struct Car {
Car(std::string n, int f) : name(std::move(n)), floor(f) {}
std::string name;
int floor;
Dir dir = Dir::Idle;
std::set<int> stops;
void addStop(int f) {
if (f == floor && dir == Dir::Idle) return;
stops.insert(f);
if (dir == Dir::Idle) dir = f > floor ? Dir::Up : Dir::Down;
}
bool hasStop(Dir d) const {
return d == Dir::Up ? stops.upper_bound(floor) != stops.end() : stops.lower_bound(floor) != stops.begin();
}
// LOOK: keep going while there are stops ahead, then turn. True if it stopped.
bool step() {
if (stops.empty()) {
dir = Dir::Idle;
return false;
}
if (!hasStop(dir)) dir = dir == Dir::Up ? Dir::Down : Dir::Up;
floor += dir == Dir::Up ? 1 : -1;
if (stops.erase(floor)) {
if (stops.empty()) dir = Dir::Idle;
return true;
}
return false;
}
};
// The Strategy that decides which car answers a hall call.
class Dispatcher {
public:
virtual ~Dispatcher() = default;
virtual size_t pick(const std::vector<Car>& cars, int floor, Dir d) const = 0;
};
class Nearest : public Dispatcher {
public:
explicit Nearest(int floors) : floors_(floors) {}
int cost(const Car& c, int floor, Dir d) const {
int dist = std::abs(c.floor - floor);
if (c.dir == Dir::Idle) return dist;
if (c.dir == Dir::Up && d == Dir::Up && floor >= c.floor) return dist;
if (c.dir == Dir::Down && d == Dir::Down && floor <= c.floor) return dist;
return dist + 2 * floors_; // it must finish its trip and come back
}
size_t pick(const std::vector<Car>& cars, int floor, Dir d) const override {
size_t best = 0;
for (size_t i = 1; i < cars.size(); i++)
if (cost(cars[i], floor, d) < cost(cars[best], floor, d)) best = i;
return best;
}
private:
int floors_;
};
class Building {
public:
Building(int floors, std::unique_ptr<Dispatcher> d, std::vector<Car> cars)
: floors_(floors), dispatcher_(std::move(d)), cars_(std::move(cars)) {}
std::string call(int floor, Dir d) {
std::lock_guard lock(mu_);
if (floor < 0 || floor >= floors_) throw std::invalid_argument("no floor " + std::to_string(floor));
Car& c = cars_[dispatcher_->pick(cars_, floor, d)];
c.addStop(floor);
return c.name;
}
void press(const std::string& car, int floor) {
std::lock_guard lock(mu_);
for (auto& c : cars_)
if (c.name == car) c.addStop(floor);
}
std::vector<std::string> tick() {
std::lock_guard lock(mu_);
std::vector<std::string> opened;
for (auto& c : cars_)
if (c.step()) opened.push_back("car " + c.name + " opens at " + std::to_string(c.floor));
return opened;
}
std::optional<std::string> idle() {
std::lock_guard lock(mu_);
std::string where;
for (const auto& c : cars_) {
if (c.dir != Dir::Idle) return std::nullopt;
where += (where.empty() ? "" : ", ") + c.name + " at " + std::to_string(c.floor);
}
return where;
}
private:
std::mutex mu_;
int floors_;
std::unique_ptr<Dispatcher> dispatcher_;
std::vector<Car> cars_;
};
int main() {
Building b(10, std::make_unique<Nearest>(10), {Car{"A", 0}, Car{"B", 9}});
auto call = [&](int t, int floor, Dir d) {
std::string car = b.call(floor, d);
std::printf("t%-2d call floor %d %-4s -> car %s\n", t, floor, name(d), car.c_str());
};
auto press = [&](int t, const std::string& car, int floor) {
b.press(car, floor);
std::printf("t%-2d car %s: passenger goes to %d\n", t, car.c_str(), floor);
};
call(0, 3, Dir::Up);
call(0, 7, Dir::Down);
for (int t = 1; t <= 20; t++) {
for (const auto& e : b.tick()) std::printf("t%-2d %s\n", t, e.c_str());
if (t == 2) press(t, "B", 1);
if (t == 3) {
press(t, "A", 8);
call(t, 5, Dir::Down);
}
if (auto where = b.idle()) {
std::printf("t%-2d all cars idle: %s\n", t, where->c_str());
break;
}
}
}What changes from language to language: Java and C++ use ordered sets (TreeSet, std::set) so "is there a stop above me?" is one call (higher, upper_bound). Rust's dispatcher returns an index instead of a reference, so it can pick a car and then mutate it under the same lock. Rust's match with guards ((Dir::Up, Dir::Up) if floor >= c.floor) reads almost like the cost table. TypeScript needs no lock.
Testing
// building_test.go
package elevator
import (
"slices"
"sync"
"testing"
)
func TestNearestIdleCarWins(t *testing.T) {
b := NewBuilding(10, Nearest{10}, NewCar("A", 0), NewCar("B", 9))
if car, _ := b.Call(7, Down); car != "B" {
t.Fatalf("got %s", car)
}
if car, _ := b.Call(1, Up); car != "A" {
t.Fatalf("got %s", car)
}
}
func TestCarGoingTheWrongWayIsAvoided(t *testing.T) {
a, b := NewCar("A", 4), NewCar("B", 0)
a.AddStop(9) // A is going up, past floor 3
bld := NewBuilding(10, Nearest{10}, a, b)
if car, _ := bld.Call(3, Down); car != "B" {
t.Fatalf("A is heading away; got %s", car)
}
}
func TestLookServesStopsAheadFirst(t *testing.T) {
c := NewCar("A", 5)
c.AddStop(8) // going up
c.AddStop(2) // behind it
var order []int
for range 20 {
if c.Step() {
order = append(order, c.Floor)
}
}
if !slices.Equal(order, []int{8, 2}) || c.Dir != Idle {
t.Fatalf("order %v, dir %v", order, c.Dir)
}
}
func TestInvalidFloorRefused(t *testing.T) {
b := NewBuilding(10, Nearest{10}, NewCar("A", 0))
if _, err := b.Call(12, Up); err == nil {
t.Fatal("floor 12 should be refused")
}
if err := b.Press("Z", 3); err == nil {
t.Fatal("unknown car should be refused")
}
}
func TestEveryCallIsEventuallyServed(t *testing.T) {
b := NewBuilding(10, Nearest{10}, NewCar("A", 0), NewCar("B", 9))
for _, f := range []int{2, 5, 8, 1, 6} {
b.Call(f, Up)
}
for range 40 {
b.Tick()
}
if idle, _ := b.Idle(); !idle {
t.Fatal("cars still busy after 40 ticks")
}
}
// Buttons pressed from many goroutines while the ticker runs. Run with -race.
func TestConcurrentButtonsAndTicks(t *testing.T) {
b := NewBuilding(10, Nearest{10}, NewCar("A", 0), NewCar("B", 9))
var wg sync.WaitGroup
for i := range 30 {
wg.Add(2)
go func() { defer wg.Done(); b.Call(i%10, Up) }()
go func() { defer wg.Done(); b.Tick() }()
}
wg.Wait()
for range 60 {
b.Tick()
}
if idle, _ := b.Idle(); !idle {
t.Fatal("not idle")
}
}
$ go test -race -v ./...
=== RUN TestNearestIdleCarWins
--- PASS: TestNearestIdleCarWins (0.00s)
=== RUN TestCarGoingTheWrongWayIsAvoided
--- PASS: TestCarGoingTheWrongWayIsAvoided (0.00s)
=== RUN TestLookServesStopsAheadFirst
--- PASS: TestLookServesStopsAheadFirst (0.00s)
=== RUN TestInvalidFloorRefused
--- PASS: TestInvalidFloorRefused (0.00s)
=== RUN TestEveryCallIsEventuallyServed
--- PASS: TestEveryCallIsEventuallyServed (0.00s)
=== RUN TestConcurrentButtonsAndTicks
--- PASS: TestConcurrentButtonsAndTicks (0.00s)
PASS
ok elevator 1.015s
? elevator/cmd/demo [no test files]
| Test | Checks |
|---|---|
TestNearestIdleCarWins | Calls go to the nearest idle car |
TestCarGoingTheWrongWayIsAvoided | A closer car heading away loses to a farther idle one |
TestLookServesStopsAheadFirst | From 5 with stops 8 and 2, the order is 8 then 2 |
TestInvalidFloorRefused | Floor 12 and an unknown car are refused |
TestEveryCallIsEventuallyServed | Five calls, and both cars are idle within 40 ticks |
TestConcurrentButtonsAndTicks | Calls and ticks from many goroutines at once |
Without the lock in Tick, the race detector reported 28 data races and the concurrency test failed.
Design review
What's good: clean split between movement (car) and choice (dispatcher); time is testable; the cost rule is easy to explain and change.
| "What if…" | What to change |
|---|---|
| Rush hour | A new dispatcher that sends idle cars to the lobby and favours up-calls from floor 0. |
| Car capacity | Cars report load; the dispatcher skips full cars. |
| Destination dispatch (pick floor in the lobby) | Calls carry the destination, so the dispatcher can group people going to nearby floors. |
| Re-assigning calls | If a better car frees up, move the hall call; keep hall calls in the building, not only in cars. |
| Emergency / fire mode | A building-wide state that sends every car to the ground floor and ignores calls. |
Interview questions
Practice questions
Beginner
- Why does the dispatcher, not the car, decide which car takes a call?
- What are a car's states, and what moves it between them?
Intermediate
- Why isn't "closest car" enough?
- How do you test a moving system without sleeping?
Advanced
- Compare LOOK with always going to the nearest stop.
- Buttons and the ticker run on different goroutines. What can go wrong?
Common design mistakes
- Each car grabbing calls on its own, so two cars come for one person.
- Pure distance dispatching, sending a car that's going the other way.
- Real
time.Sleepin the logic, making tests slow and flaky. - No lock between buttons and the ticker.
Answers
1. Why the dispatcher?
Only something that sees every car can compare them. If cars chose for themselves, two might take the same call, or none.
2. A car's states?
Idle, up, down. Adding a stop moves idle to up or down; serving the last stop ahead turns it around if there are stops behind, or makes it idle if there are none.
3. Why not just the closest car?
A close car heading away must finish its trip first. The cost rule adds a penalty for that, so a farther idle or approaching car can win.
4. Testing without sleeping?
Make time an explicit Tick(). Tests call it in a loop and check where each car is; production calls it from a ticker.
5. LOOK vs nearest stop?
Nearest-stop can bounce between nearby floors and starve far ones. LOOK finishes one direction before turning: predictable, fair, and fewer reversals.
6. Buttons vs ticker without a lock?
A call could add a stop while Step is deleting from the same map (a data race, and in Go a possible crash), or two calls could be assigned using a car's stale state. Our test reported 28 data races without the lock.
What interviewers look for: car vs dispatcher responsibilities, a sensible cost rule, LOOK, testable time, and thread safety.
Designed it yourself and understood the solution? Mark it to track your progress.