Intermediate30 min readMachine Coding Practicelive prototype

Elevator / Lift system

Every other problem in this set asks what to model. This one asks in what order do you serve people, and which car goes. Get the two request types wrong and no scheduling algorithm can save you; serve them in arrival order and the car travels 18 floors where 7 would do.

The idea

What it is

“Design an elevator system.” Most candidates start drawing a car and a shaft. That is not where the difficulty is. A car that moves up and down is twenty lines.

The difficulty is that requests arrive faster than you can serve them, from two completely different places, and you have to decide two things every time: in what order do I serve what I have, and which car takes the next one. Everything else in this problem is scaffolding around those two decisions.

Building — 10 floors, 2 shafts 10 9 8 7 6 5 4 3 2 1 #1 ▲ 🧑 [3] #2 — idle 🧍 Floor just an int 1..10 — it has no behaviour ElevatorCar floor · direction · state upRequests · downRequests HallCall(7, UP) pressed OUTSIDE knows a direction does NOT know where the rider is going CarCall(3) pressed INSIDE knows the destination carries NO direction ElevatorSystem owns the cars · picks which one goes
Look at the two orange labels on the right. The person outside pressed a direction; the rider inside pressed a destination. Those are not the same object, and treating them as one number is the mistake this whole lesson is about.

The whole system in three sentences

A hall call is (floor, direction) and goes to the ElevatorSystem, which asks a strategy which car should take it. A car call is (floor) and goes straight to one car. Each car then serves what it holds using LOOK: keep going the way you are going, stop for everything on the way, and only reverse when there is nothing left ahead.

What is actually being graded

  1. Are there two request types? A HallCall(floor, direction) and a CarCall(floor). One list of integers is the single most common way to fail this round, and it is fatal — you cannot decide whether to stop for someone if you never recorded which way they wanted to go.
  2. Is the serving order LOOK, not FIFO? Arrival order makes the car yo-yo past floors it is standing next to. The interviewer will hand you a request stream and count floors.
  3. Is which car a separate, swappable decision? ElevatorSystem.request() asks a SchedulingStrategy. The car itself knows nothing about the other cars.
  4. Is time an input? A tick() you call from outside, or an injected clock. A Thread.sleep inside the movement means nothing you wrote can be tested.
  5. Does it run? Two cars, a stream of calls, and a main() that prints floor-by-floor and then the total floors travelled.

Mechanics

How it works

Step 1 · Clarify — 5 minutes

  • How many floors and how many cars?“10 floors, 2 cars” is the sweet spot. One car removes the dispatch conversation, which is half the problem. Twenty cars is the same code with a bigger loop.
  • Do riders enter a destination outside, or just a direction? — say just a direction. That is the classic building, and it is what makes hall calls and car calls different objects. Destination entry is the best follow-up you will get, so keep it in your pocket.
  • Should I simulate time, or is this event-driven?“I'll expose a tick() that advances one floor, and drive it from main().” Say this in the first five minutes and you have pre-answered the testability question.
  • Do cars have capacity limits? — ask, then say you will model a count and refuse riders, not requests. It is one field.
  • Doors, motors, sensors, emergency brakes? — out of scope. Doors exist as a state with a timer, nothing more.
IN SCOPE — I will build this 10 floors · 2 cars HallCall(floor, direction) CarCall(floor) LOOK inside each car pluggable dispatch tick() + a main() demo OUT — agreed, not built motor / sensor control door hardware any UI persistence access control / billing destination-entry panels
Note the bottom-right item. Destination dispatch is the follow-up, not the build — but naming it here tells the interviewer you know it exists, which buys you the whole conversation at minute 50.

Step 2 · Two request types — and beginners merge them

Read the problem back and notice there are two buttons in two different places. The one in the lobby says “I am on floor 7 and I want to go up.” The one inside the car says “take me to floor 3.” They carry different information, they are created by different people, and they are answered by different objects.

HALL CALL — pressed outside HallCall(floor, direction) knowswhich floor which way the rider wants to go does notknow the destination answered by system.request(call) — any car in the bank may take it CAR CALL — pressed inside CarCall(floor) knowsthe exact destination does notcarry a direction — it is implied by where the car is answered by car.addRequest(call) — this car and no other ✗ List<Integer> requests — both collapsed into a floor number the car is at floor 5 going UP and sees “7”. Is that someone going up, who it should pick up now? Or someone going down, who should wait for the return trip? The information is gone. It cannot be recovered.
The red strip is the actual failure. Once the direction is thrown away, every scheduling algorithm you write on top is guessing — and the interviewer's next question is exactly the one you can no longer answer.

The question that exposes it

“Your car is at floor 5 going up, and someone on floor 7 pressed the down button. Do you stop for them?” The answer is no — not on this trip. You carry on up, serve everyone going up, and pick them up on the way back down. If your requests are plain integers you cannot even express that answer, let alone code it.

NOUN IN THE PROBLEM BECOMES WHY the building ElevatorSystem owns the cars, routes calls a floor int (1..10) no data, no behaviour — not a class the ▲▼ buttons outside HallCall(floor, dir) a floor AND a direction the number pad inside CarCall(floor) a destination, no direction a lift car ElevatorCar floor, direction, two request sets up / down Direction «enum» UP · DOWN · IDLE moving / doors open CarState «enum» a state machine, one per car “which car goes?” SchedulingStrategy an interface — this is the seam
Two rows earn their place: a floor is not a class (say that out loud — it shows you are modelling, not decorating), and “which car goes” is a noun too, which is how a decision becomes an interface.

Step 3 · FIFO is wrong, and you can measure how wrong

You have a car at floor 1. Four people press up buttons, in this order: floor 5, floor 2, floor 8, floor 3. Serve them in the order they arrived and watch what the car does.

✗ FIFO — serve in arrival order ✓ LOOK — keep going, serve on the way car starts at floor 1 · up calls arrive: 5, 2, 8, 3 identical stream, identical car 8 5 3 2 1 5 2 8 3 floors travelled → 18 floors · 4+3+6+5 8 5 3 2 1 2 3 5 8 floors travelled → 7 floors · one sweep, 1 → 8
18 versus 7 on four requests. FIFO walks past floor 2 twice and floor 3 three times without opening the doors. Run the same comparison yourself with ⚡ Rush hour in the prototype and read floors travelled under each chip.

The fix is called LOOK (the practical cousin of SCAN, the disk-arm algorithm). The rule is one sentence: the car has a direction; it keeps going that way, stopping for every request on the way, and only reverses when there is nothing left ahead of it.

Implementing that becomes easy the moment you hold requests in two sorted sets instead of one list.

car #1 · floor 4 direction = UP ▲ upRequests «sorted ascending» 5 7 9 ← drained first, in this order downRequests «sorted descending» 8 3 2 waiting — wrong direction for now at floor 9 there is nothing above → flip car #1 · floor 9 direction = DOWN ▼ upRequests (empty) downRequests — now the live one 8 3 2 the whole algorithm: drain the set that matches my direction, in its own sort order, then flip.
Two sorted sets turn a scheduling algorithm into a data-structure choice. In Java that is a TreeSet and a TreeSet(reverseOrder()); the code below is barely fifteen lines because the ordering is already done.
the LOOK step, in full
/** ONE floor of movement. Called from outside — there is no sleep in here. */
void step() {
    if (state == CarState.DOORS_OPEN) { state = resume(); return; }   // doors take one tick
    if (!busy()) { direction = Direction.IDLE; state = CarState.IDLE; return; }

    if (direction == Direction.IDLE) direction = towardsNearestRequest();
    if (serveHere()) { state = CarState.DOORS_OPEN; return; }         // already standing on one

    if (!workAhead(direction)) direction = opposite(direction);       // nothing left this way
    if (serveHere()) { state = CarState.DOORS_OPEN; return; }

    floor += (direction == Direction.UP) ? 1 : -1;                    // exactly one floor
    floorsTravelled++;
    state = (direction == Direction.UP) ? CarState.MOVING_UP : CarState.MOVING_DOWN;
    if (serveHere()) state = CarState.DOORS_OPEN;                     // stop for it on the way
}

/** Remove a request at the current floor IF it matches where we are going. */
private synchronized boolean serveHere() {
    if (direction == Direction.UP) {
        if (upRequests.remove(floor)) return true;
        if (!anyAbove(floor) && downRequests.remove(floor)) return true;   // turnaround point
    } else {
        if (downRequests.remove(floor)) return true;
        if (!anyBelow(floor) && upRequests.remove(floor)) return true;
    }
    return false;
}

The two lines everyone forgets

The !anyAbove(floor) branches are the turnaround rule. A down request sitting at the top of the run must be served when the car gets there, otherwise the car flips direction, walks away from it, and comes back later. The other easy miss: a car call you pass on the way must be served. If your serveHere() only looks at the destination it was heading for, you have rebuilt FIFO with extra steps.

Step 4 · Which car goes — a separate, pluggable decision

So far every car knows how to serve what it holds. Nothing yet decides who holds what. That decision belongs to the ElevatorSystem, and it belongs behind an interface — because it is exactly the thing the interviewer will ask you to change.

A cost function you can defend out loud, in three cases: an idle car costs its distance. A car already moving your way, and past-you-is-ahead-of-it, also costs its distance — it will pass you anyway. A car moving away, or moving the wrong way, costs its distance plus a large fixed penalty, so it only wins when nothing else is remotely close.

ElevatorSystem + request(HallCall) : ElevatorCar + tick() + setStrategy(s) ← swappable 1..* ElevatorCar - floor : int - direction : Direction - state : CarState - upRequests : TreeSet↑ - downRequests : TreeSet↓ + addRequest(Request) + step() knows LOOK · knows no other car SchedulingStrategy «interface» + pick(cars, HallCall) : ElevatorCar NearestCarStrategy LeastLoadStrategy add a 3rd here only Request «interface» HallCall floor + direction CarCall floor only Direction UP DOWN IDLE «enum»
Two interfaces, and they do different jobs. Request splits the data; SchedulingStrategy splits the decision. Adding a third policy touches one new file and zero existing ones — that is Open/Closed (OCP) with a number attached. Notation: Class diagrams.
Rider · floor 7 ElevatorSystem SchedulingStrategy ElevatorCar #2 press ▲ system.request(new HallCall(7, UP)) pick(cars, call) car #1 · moving DOWN, away → 4 + 100 car #2 · IDLE, 2 floors away → 2 car #2 addRequest(call) 🔒 upRequests.add(7) — the only shared state
Trace the arrows: the car is never asked to choose. It is handed a request and gets on with LOOK. That split is Single Responsibility (SRP) doing real work — swap the middle box and nothing to its left or right changes. Notation: Sequence diagrams.

Say this when you extract the strategy

request() will not change when the policy does.” That sentence, plus a second implementation you can name — least-loaded, or zoned, or nearest-with-a-wait-time-cap — is worth more than a perfect cost function. It is the same seam Parking Lot used for spot allocation, and the interviewer is checking whether you spot it twice. Background: Strategy.

Step 5 · Time is an input, not something you sleep through

The single fastest way to make this program untestable is to write Thread.sleep(1000) inside the movement. Now a ten-floor trip takes ten real seconds, a test of the turnaround rule takes half a minute, and the interviewer cannot see anything happen before the round ends.

Instead expose tick(). One call advances every car by one floor. main() drives it in a loop, a test drives it three times and asserts a position, and a real building would drive it from a hardware timer. Same code, three clocks.

main() · test · timer ElevatorSystem ElevatorCar tick() step() — for every car 1 · floor += ±1 2 · floorsTravelled++ 3 · serveHere()? yes → remove + DOORS_OPEN next tick: doors close, resume there is no sleep anywhere in this diagram — that is the point the same rule as passing exitAt into a parking-lot bill: the caller owns time, so a test can own it too
Compare the note at the bottom with how Parking Lot handles billing. Same rule, different noun: anything that reads the clock internally cannot be tested without waiting.
IDLE MOVING_UP MOVING_DOWN DOORS_OPEN a request above a request below arrived at a stop arrived at a stop doors close · work still ahead doors close · work still ahead no requests left ONLY when nothing is ahead
The orange arrow in the middle is the only one with a guard on it. A car does not change direction while it still has requests ahead — remove that guard and LOOK collapses back into something worse than FIFO. Notation and vocabulary: State.

Step 6 · Concurrency — one lock, in one place

Hall calls arrive from ten floors at once, and a rider inside is pressing buttons at the same time. The request sets are shared state; two threads adding to a TreeSet at the same moment can corrupt it or lose an entry.

  • Lock the queues, not the movement. addRequest() and the removal inside serveHere() take the car's lock. Moving a floor and updating the counter do not need it.
  • One lock per car, not one global lock. Cars share nothing with each other, so a global lock would serialise a building for no reason.
  • Dispatch reads car state without a lock, and that is fine — the strategy is picking a good car, not a provably optimal one. Say this: a slightly stale floor number costs you one floor of travel, not correctness.
  • Never hold a lock across step(). It is the same rule as brewing outside the lock in Coffee Machine: guard the arithmetic, not the work.

What the interviewer is checking

Not that you wrote a lock-free scheduler. Just that you can point at which field is shared and by whom, and that your answer is proportionate. “One lock per car around the two request sets” is the whole answer, and it takes ten seconds. More on the primitives in Locks, Mutex, Semaphore.

The follow-ups

  • “Make one car an express that only stops at 1 and 6 to 10.” → give the car a servesFloor(int) predicate and have the strategy return infinite cost for a call it cannot serve. No new class in the car, one guard in the cost function.
  • “Add a firefighter / service mode.” → a mode flag on the car that clears both request sets, refuses new ones, and takes a single destination. Cancelling every pending request is the part people forget — the riders waiting on floor 7 must be told, so the system re-dispatches their hall calls to another car.
  • “Capacity and weight limits.” → a riderCount on the car and a limit. Crucially it refuses riders, not requests: a full car still stops where it was going, it just does not let anyone in, and the hall call stays outstanding for the next car.
  • “Twenty cars in one bank.” → the strategy loop is already O(cars); nothing structural changes. What does change is that a purely nearest-car policy starts bunching cars together, so you add a zoning or least-load term. Both are new strategy classes.
  • “Sixty floors, eight cars — what actually changes?”destination dispatch. Riders type their destination on a lobby panel instead of pressing a direction, the system groups people going to similar floors into the same car, and hall calls disappear entirely. HallCall(floor, direction) becomes TripRequest(from, to), and the strategy suddenly has enough information to be genuinely optimal.
  • “How would you test the scheduler?” → because time is a parameter, a test is: build a car, add three requests, call step() five times, assert the floor and the remaining sets. No threads, no waiting. Say this unprompted.

Spending the 60 minutes

60 MINUTES 5m 8m 9m 18m 20m clarify 2 request types API + diagram the LOOK step() strategy + main() demo minute 42 — LOOK works: stop adding, print a trace one car serving correctly beats two cars that never move
Note where the 18-minute block sits. Get one car doing LOOK correctly before you add a second car at all — a bank of cars that each schedule badly is not a partial answer, it is two bugs.
  1. Direction, CarState, HallCall, CarCall (6 min) — the enums and the two request records. Tiny, and everything else leans on them.
  2. ElevatorCar with the two sorted sets and addRequest (6 min) — no movement yet.
  3. step() (18 min) — this is the round. Move one floor, serveHere(), flip when nothing is ahead. Print the floor every tick while you write it.
  4. ElevatorSystem with one hardcoded “nearest car” rule (8 min) — get two cars moving from one request stream.
  5. Extract SchedulingStrategy and add a second implementation (8 min) — a five-minute refactor once it works, a rabbit hole if you start here.
  6. main() that prints a tick-by-tick trace and the total floors travelled (8 min) — the number at the bottom is what makes your design visible.

How this round is lost

  • One List<Integer> requests. Direction is gone, the “do you stop for the down button?” question cannot be answered, and no amount of clever scheduling on top recovers it.
  • Thread.sleep inside the movement. A ten-floor trip now takes ten seconds, nothing can be tested, and the demo runs out of clock before it finishes.
  • Scheduling logic buried inside ElevatorCar. The moment the interviewer says “now pick the least busy car instead”, you are editing the car class — and a second policy cannot exist alongside the first.
  • Forgetting the requests you pass. A car heading to floor 8 that sails past a waiting request at floor 5 has quietly reimplemented FIFO.
  • Never reversing correctly. No turnaround rule means a request at the top of the run gets skipped, the car flips, and the person waits forever. That is starvation, and it is easy for an interviewer to construct.
  • Designing a perfect cost function and never writing step(). The cost function is worth one sentence; LOOK is worth the round.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A 10-floor building with two cars. Press ▲ or ▼ next to any floor to make a hall call — the explain line tells you which car took it and why. Press a floor number inside a car's panel for a car call. Then the whole point: press ⚡ Rush hour, run it under 🧭 LOOK, note floors travelled, press 🔀 FIFO, and run the identical burst again. Same requests, same cars, 11 floors versus 21. The 🎯 Nearest car and ⚖️ Least load chips swap the dispatch policy without changing a single call site.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Make one hall call and read the reason

Press ▲ next to floor 7. The call line shows system.request(new HallCall(7, UP)) and one car flashes. The explain line does not just name the winner — it gives the cost for both cars. Note that the car chosen is the idle one, not necessarily the closest one in raw floors.

try 02

Press a button from inside a car

In the right-hand panel, click a floor number under car #1. The call line becomes car1.addRequest(new CarCall(3)) — a different object, going to a different place, and no strategy is consulted at all. Watch which set it lands in: a car call above the car goes into upRequests, below it into downRequests.

try 03

Run it and watch the sets drain

Press ▶ Run. Each tick moves every car one floor. Watch the chips: the car drains the set matching its direction in sort order, doors flash open on each stop, and the chip disappears. When nothing is left ahead, the direction flips and the other set becomes the live one.

try 04

The comparison — this is the whole lesson

Press ⚡ Rush hour (seven hall calls, always the same ones), make sure 🧭 LOOK is on, and press ▶ Run. Read floors travelled: 11. Now press 🔀 FIFO — the identical requests are replayed from the start — and run again: 21. Same building, same people, same cars. Nearly double the travel, from one line of ordering logic.

try 05

Swap the dispatcher without changing the caller

Press ⚖️ Least load, then make a few hall calls near one car. Under 🎯 Nearest car they all pile onto the closest car; under least-load they spread out. The call line is system.request(...) in both cases — it never changed. That is the strategy seam paying for itself.

try 06

Build it from memory

Blank file, in this order: Direction and CarState enums → HallCall(floor, direction) and CarCall(floor)ElevatorCar with upRequests ascending and downRequests descending → step() with the flip-when-nothing-ahead rule → SchedulingStrategy with a nearest-car implementation → a main() that ticks in a loop and prints the total floors travelled. If your total for the stream 5, 2, 8, 3 from floor 1 is not 7, your serveHere() is not stopping for requests on the way.

In practice

When to use it — and what trips people up

The shape you just learned

Take the building away and this is a queue of requests that must be served in a smart order, and a fleet of workers to spread them across. Those are two independent decisions, and the whole design is about keeping them independent.

  • Disk I/O scheduling — LOOK is literally the disk-arm algorithm. The head sweeps across cylinders serving requests on the way instead of seeking back and forth.
  • Ride hailing — a rider request is a hall call with a direction; matching a driver is SchedulingStrategy.pick(); picking up a second passenger on the way is serveHere().
  • Delivery batching — a courier who already has your street on the route costs nothing extra; one heading the other way costs the detour penalty. Same cost function, different units.
  • Print and job queues with a fleet of workers — dispatch to the least-loaded worker is LeastLoadStrategy with a different name.
  • Any request router — load balancers make the same two decisions: which backend takes it, and in what order does that backend serve what it holds.

The two-sentence version to say out loud

“There are two request types — a hall call carries a direction, a car call carries a destination — and merging them makes correct scheduling impossible. Each car serves what it holds with LOOK: keep going, stop for everything on the way, reverse only when nothing is ahead; and which car takes a hall call is a separate strategy the caller never sees.” That is the design, in 20 seconds.

Where this design stops working

  • At 60 floors and 8 cars. Per-car LOOK plus nearest-car dispatch causes bunching — cars drift into a clump and one region of the building waits. Real buildings switch to destination dispatch, where riders enter their destination in the lobby and the system groups them by trip, which gives the scheduler the information a hall call deliberately hides.
  • When fairness matters more than efficiency. LOOK can starve a floor at the far end of a busy building. The fix is an age term in the cost — a request that has waited long enough overrides distance — and that is a real change to the algorithm, not a parameter.
  • When cars share a shaft. Two cars in one shaft cannot pass each other, so scheduling becomes a constraint problem, not a sorting problem. Nothing you wrote survives that, and it is fine to say so.
  • When you need optimality. Assigning N requests to M cars to minimise total travel is a hard combinatorial problem. Every real system uses a heuristic, which is exactly why the decision lives behind an interface.

If you only remember one thing

Model the direction, then never serve in arrival order. A HallCall(floor, direction) plus two sorted sets is maybe fifteen lines, and it is the difference between 7 floors and 18 on four requests. Everything else in this problem — doors, states, strategies, locks — is decoration on those two ideas.

What it gives you

  • Separating HallCall from CarCall keeps the direction of every waiting rider, which is the one piece of information LOOK cannot work without.
  • Two sorted sets turn scheduling into a data-structure choice: the ordering is free, and step() stays about fifteen lines.
  • Putting dispatch behind SchedulingStrategy means a new policy is one new class and zero edits to ElevatorSystem.request().
  • Driving time with tick() makes the whole scheduler testable synchronously — three calls and an assertion, no threads and no waiting.
  • One lock per car around the two request sets is enough for correctness, and it never blocks the movement of any car.

Common mistakes

  • Per-car LOOK plus nearest-car dispatch causes bunching in tall buildings — cars clump together and one region waits longer than it should.
  • LOOK has no fairness guarantee: a request at the far end of a busy building can wait a long time unless you add an age term to the cost.
  • The cost function is a heuristic, not an optimum — assigning many requests across many cars optimally is a hard combinatorial problem.
  • A tick model quantises time into whole floors, so it cannot express acceleration, variable floor heights, or realistic door dwell times.
  • Because dispatch reads car state without a lock, a request can occasionally be assigned using a floor number that is one tick stale.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

enum Direction { UP, DOWN, IDLE }
enum CarState { IDLE, MOVING_UP, MOVING_DOWN, DOORS_OPEN }

/**
 * TWO kinds of request, and they are NOT the same thing.
 *   HallCall : pressed outside. Knows a floor AND a direction, but no destination.
 *   CarCall  : pressed inside.  Knows a destination, but carries no direction.
 * Collapsing both into "a floor number" is what makes correct scheduling impossible.
 */
sealed interface Request permits HallCall, CarCall { int floor(); }
record HallCall(int floor, Direction direction) implements Request {}
record CarCall(int floor) implements Request {}

class ElevatorCar {
    private final int id;
    private int floor;
    private Direction direction = Direction.IDLE;
    private CarState state = CarState.IDLE;
    private int floorsTravelled = 0;

    // Two sorted sets. The whole algorithm is "drain the one matching my direction, then flip".
    private final NavigableSet<Integer> upRequests   = new TreeSet<>();
    private final NavigableSet<Integer> downRequests = new TreeSet<>(Comparator.reverseOrder());
    private final Object queueLock = new Object();          // guards the two sets, nothing else

    ElevatorCar(int id, int startFloor) { this.id = id; this.floor = startFloor; }

    int id()              { return id; }
    int floor()           { return floor; }
    Direction direction() { return direction; }
    CarState state()      { return state; }
    int floorsTravelled() { return floorsTravelled; }

    int pending()  { synchronized (queueLock) { return upRequests.size() + downRequests.size(); } }
    boolean busy() { return pending() > 0 || state == CarState.DOORS_OPEN; }

    /** Hall calls arrive from many floors at once — this is the only shared state. */
    void addRequest(Request r) {
        synchronized (queueLock) {
            if (r instanceof HallCall h) {
                if (h.direction() == Direction.UP) upRequests.add(h.floor());
                else                               downRequests.add(h.floor());
            } else {                                        // a CarCall: direction comes from where we are
                if (r.floor() > floor)      upRequests.add(r.floor());
                else if (r.floor() < floor) downRequests.add(r.floor());
                else                        state = CarState.DOORS_OPEN;    // already standing here
            }
        }
    }

    /** ONE floor of movement. Time is driven from outside — never sleep in here. */
    void step() {
        if (state == CarState.DOORS_OPEN) { state = resume(); return; }     // doors take one tick
        if (!busy()) { direction = Direction.IDLE; state = CarState.IDLE; return; }

        if (direction == Direction.IDLE) direction = towardsNearest();
        if (serveHere()) { state = CarState.DOORS_OPEN; return; }

        if (!workAhead(direction)) direction = opposite(direction);         // nothing left this way
        if (serveHere()) { state = CarState.DOORS_OPEN; return; }

        floor += (direction == Direction.UP) ? 1 : -1;
        floorsTravelled++;
        state = (direction == Direction.UP) ? CarState.MOVING_UP : CarState.MOVING_DOWN;
        if (serveHere()) state = CarState.DOORS_OPEN;                       // stop for it on the way
    }

    /** Remove a request at this floor IF it matches where we are going. */
    private boolean serveHere() {
        synchronized (queueLock) {
            if (direction == Direction.UP) {
                if (upRequests.remove(floor)) return true;
                if (!anyAbove() && downRequests.remove(floor)) return true;   // turnaround point
            } else {
                if (downRequests.remove(floor)) return true;
                if (!anyBelow() && upRequests.remove(floor)) return true;
            }
            return false;
        }
    }

    private CarState resume() {
        if (pending() == 0) { direction = Direction.IDLE; return CarState.IDLE; }
        return direction == Direction.UP ? CarState.MOVING_UP : CarState.MOVING_DOWN;
    }

    private Direction towardsNearest() {
        int best = Integer.MAX_VALUE, target = floor;
        for (int f : allRequests())
            if (Math.abs(f - floor) < best) { best = Math.abs(f - floor); target = f; }
        return target >= floor ? Direction.UP : Direction.DOWN;
    }

    private boolean workAhead(Direction d) { return d == Direction.UP ? anyAbove() : anyBelow(); }
    private boolean anyAbove() { for (int f : allRequests()) if (f > floor) return true; return false; }
    private boolean anyBelow() { for (int f : allRequests()) if (f < floor) return true; return false; }

    private List<Integer> allRequests() {
        synchronized (queueLock) {
            List<Integer> out = new ArrayList<>(upRequests);
            out.addAll(downRequests);
            return out;
        }
    }

    private static Direction opposite(Direction d) { return d == Direction.UP ? Direction.DOWN : Direction.UP; }

    String describe() {
        synchronized (queueLock) {
            return "car#" + id + " floor " + floor + "  " + pad(state.name()) + " up" + upRequests + "  down" + downRequests;
        }
    }
    private static String pad(String s) { return (s + "           ").substring(0, 11); }
}

/** WHICH car takes a hall call. The one decision the interviewer will ask you to change. */
interface SchedulingStrategy {
    ElevatorCar pick(List<ElevatorCar> cars, HallCall call);
}

class NearestCarStrategy implements SchedulingStrategy {
    private static final int DETOUR = 100;      // big enough that a wrong-way car only wins if nothing else is close

    public ElevatorCar pick(List<ElevatorCar> cars, HallCall call) {
        ElevatorCar best = null;
        int bestCost = Integer.MAX_VALUE;
        for (ElevatorCar c : cars) {
            int cost = cost(c, call);
            if (cost < bestCost) { bestCost = cost; best = c; }
        }
        return best;
    }

    static int cost(ElevatorCar c, HallCall call) {
        int distance = Math.abs(c.floor() - call.floor());
        if (c.direction() == Direction.IDLE) return distance;               // idle: just the walk
        boolean towards = (c.direction() == Direction.UP   && call.floor() >= c.floor())
                       || (c.direction() == Direction.DOWN && call.floor() <= c.floor());
        if (towards && c.direction() == call.direction()) return distance;  // it passes you anyway
        return distance + DETOUR;                                          // it must finish its run first
    }
}

class LeastLoadStrategy implements SchedulingStrategy {
    public ElevatorCar pick(List<ElevatorCar> cars, HallCall call) {
        ElevatorCar best = null;
        int bestCost = Integer.MAX_VALUE;
        for (ElevatorCar c : cars) {
            int cost = c.pending() * 100 + Math.abs(c.floor() - call.floor());
            if (cost < bestCost) { bestCost = cost; best = c; }
        }
        return best;
    }
}

class ElevatorSystem {
    private final List<ElevatorCar> cars;
    private SchedulingStrategy strategy;

    ElevatorSystem(List<ElevatorCar> cars, SchedulingStrategy strategy) {
        this.cars = List.copyOf(cars);
        this.strategy = strategy;
    }

    /** Open for a new policy, closed for modification — request() below never changes. */
    void setStrategy(SchedulingStrategy s) { this.strategy = s; }

    ElevatorCar request(HallCall call) {
        ElevatorCar car = strategy.pick(cars, call);    // WHICH car — pluggable
        car.addRequest(call);                           // WHEN to serve it — the car's own LOOK
        return car;
    }

    ElevatorCar car(int id) {
        for (ElevatorCar c : cars) if (c.id() == id) return c;
        throw new NoSuchElementException("no car " + id);
    }

    /** Time comes from OUTSIDE: a main loop, a test, or a hardware timer. */
    void tick() { for (ElevatorCar c : cars) c.step(); }

    boolean busy() { for (ElevatorCar c : cars) if (c.busy()) return true; return false; }
    int floorsTravelled() { int t = 0; for (ElevatorCar c : cars) t += c.floorsTravelled(); return t; }
    List<ElevatorCar> cars() { return cars; }
}

public class Main {
    /** What FIFO would have cost: walk to each request in arrival order. */
    static int fifoFloors(int start, List<Integer> arrivalOrder) {
        int total = 0, at = start;
        for (int f : arrivalOrder) { total += Math.abs(f - at); at = f; }
        return total;
    }

    public static void main(String[] args) {
        System.out.println("=== one car, four hall calls: 5 up, 2 up, 8 up, 3 up ===");
        ElevatorSystem one = new ElevatorSystem(List.of(new ElevatorCar(1, 1)), new NearestCarStrategy());
        for (int f : new int[] { 5, 2, 8, 3 }) one.request(new HallCall(f, Direction.UP));

        int t = 0;
        while (one.busy() && t < 200) {                 // the caller owns time
            one.tick();
            t++;
            System.out.println("t=" + (t < 10 ? "0" : "") + t + "  " + one.car(1).describe());
        }
        System.out.println("LOOK floors travelled: " + one.floorsTravelled());
        System.out.println("FIFO floors travelled: " + fifoFloors(1, List.of(5, 2, 8, 3)) + "   (1 -> 5 -> 2 -> 8 -> 3)");

        System.out.println();
        System.out.println("=== two cars, dispatch is a separate decision ===");
        ElevatorSystem bank = new ElevatorSystem(
                List.of(new ElevatorCar(1, 1), new ElevatorCar(2, 9)), new NearestCarStrategy());

        for (int f : new int[] { 8, 7, 6 })
            System.out.println("hall call " + f + " DOWN -> car #" + bank.request(new HallCall(f, Direction.DOWN)).id());

        bank.setStrategy(new LeastLoadStrategy());      // the ONLY line that changes
        System.out.println("swapped strategy to LeastLoad — request() did not change");
        System.out.println("hall call 5 DOWN -> car #" + bank.request(new HallCall(5, Direction.DOWN)).id());
    }
}

/* expected output
=== one car, four hall calls: 5 up, 2 up, 8 up, 3 up ===
t=01  car#1 floor 2  DOORS_OPEN  up[3, 5, 8]  down[]
t=02  car#1 floor 2  MOVING_UP   up[3, 5, 8]  down[]
t=03  car#1 floor 3  DOORS_OPEN  up[5, 8]  down[]
t=04  car#1 floor 3  MOVING_UP   up[5, 8]  down[]
t=05  car#1 floor 4  MOVING_UP   up[5, 8]  down[]
t=06  car#1 floor 5  DOORS_OPEN  up[8]  down[]
t=07  car#1 floor 5  MOVING_UP   up[8]  down[]
t=08  car#1 floor 6  MOVING_UP   up[8]  down[]
t=09  car#1 floor 7  MOVING_UP   up[8]  down[]
t=10  car#1 floor 8  DOORS_OPEN  up[]  down[]
t=11  car#1 floor 8  IDLE        up[]  down[]
LOOK floors travelled: 7
FIFO floors travelled: 18   (1 -> 5 -> 2 -> 8 -> 3)

=== two cars, dispatch is a separate decision ===
hall call 8 DOWN -> car #2
hall call 7 DOWN -> car #2
hall call 6 DOWN -> car #2
swapped strategy to LeastLoad — request() did not change
hall call 5 DOWN -> car #1
*/

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

Why must a hall call and a car call be different types?

question 02 / 08

A car sits at floor 1. Up calls arrive for floors 5, 2, 8 and 3, in that order. How many floors does the car travel under FIFO, and under LOOK?

question 03 / 08

Your car is at floor 5 moving up, with a request at floor 9. Someone on floor 7 presses the DOWN button. What should the car do?

question 04 / 08

Why keep two sorted sets per car instead of one sorted list of pending floors?

question 05 / 08

Where should the “which car takes this hall call” decision live?

question 06 / 08

Why should movement be driven by a tick() call instead of a sleep inside the car?

question 07 / 08

A firefighter mode is requested. What is the part candidates most often forget?

question 08 / 08

The building grows to 60 floors and 8 cars. What is the real structural change?

0/8 answered