Intermediate30 min readMachine Coding Practicelive prototype

Meeting room scheduler

Every other problem in this set is about objects. This one is about one line of code: two intervals overlap if aStart < bEnd && bStart < aEnd. Get that line right — and get the < right, not <= — and the rest of the round is scaffolding. Get it wrong and every back-to-back booking in the building is refused.

The idea

What it is

“Design a meeting room booking system for our office.” Three rooms, a day, and people who want to book them. It sounds like the easiest problem in the set.

It is the easiest one to start. It is also the one most people get wrong, because the whole system rests on a single predicate — do these two time ranges overlap? — and there is a boundary case in it that almost everybody trips over.

one day · 9:00 → 18:00 TimeSlot [11:00, 12:00) 9 10 11 12 13 14 15 16 17 18 R1 · Huddle seats 4 R2 · Sync seats 8 · projector R3 · Boardroom seats 12 · projector Retro Design Interview book a room room: R2 · Sync when: 11:00 – 12:00 seats: 6 · projector yes 📅 Book User Scheduler.book("R2", slot) Room id · seats · features · lock Booking room · slot · organiser · attendees
Every noun on this picture is already a class. The one that does the work is the small orange bracket at the top: a TimeSlot is a pair of instants, and the entire system is one question asked about pairs of them.

The whole system in three sentences

A booking is a room plus a TimeSlot. A slot is a half-open interval [start, end), and two slots overlap iff aStart < bEnd && bStart < aEnd. Booking means: take the room's lock, ask that question against the room's existing bookings, and write only if the answer is no.

✓ IN SCOPE — say this out loud in minute two rooms · capacity · features (projector, VC) book a slot · cancel a booking refuse anything that overlaps find ANY free room for a slot recurring series, with an end date two people booking at the same instant six behaviours · all reachable in 60 minutes ✗ OUT OF SCOPE — one sentence each, then move on login, roles, who may book what sending calendar invites / email door locks, displays, occupancy sensors billing, catering, floor plans federating with external calendars persistence and a real database name them so they cannot be used against you
Notice what stayed in: concurrency and recurrence. Those are the two places this problem gets interesting, and cutting them leaves you with a list and an if.

What is actually being graded

  1. Is the overlap test one line? aStart < bEnd && bStart < aEnd. If you write four if branches, the interviewer will find the wrong one — they always do.
  2. Did you say [start, end) out loud? Half-open intervals mean 10–11 and 11–12 do not conflict. State the convention before you write the code; the convention is part of the answer.
  3. Do you know what a room lookup costs? Linear scan is fine and you should say so — but you must be able to describe the sorted-list and interval-tree versions and when each earns its keep.
  4. Is check-and-write atomic, and per room? Two people clicking Book at the same instant is the second act of this problem. One lock per room, not one for the building.
  5. Does it run? A main() that books back-to-back slots successfully, refuses an overlap, finds any free room, and answers “how many rooms do these five meetings need?” with a min-heap.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • Can meetings be back-to-back? — the single most valuable question in this round. Ask it, then answer it yourself: “I will treat slots as half-open [start, end), so 10–11 and 11–12 do not conflict.”
  • Fixed rooms, or can rooms be added at runtime? — a fixed list is fine; say the room set is data either way.
  • Do bookings have capacity and equipment requirements? — yes, and they are fields on Room, not subclasses.
  • Recurring meetings? — say yes, daily/weekly with an end date, and that you will generate occurrences from a rule rather than store a thousand rows.
  • Can two people book at once? — say yes. This is the concurrency conversation, and it is worth more marks than everything else combined.
  • Timezones? — store instants in UTC, keep the local time plus the zone for recurring rules. Two sentences, unprompted, and it lands well.
  • Auth, invites, hardware, billing? — out of scope, one sentence.

Ask about back-to-back before you write anything

It takes eight seconds and it decides your comparison operators. Candidates who skip it write <=, refuse every consecutive booking in the building, and then spend ten minutes debugging in front of the interviewer. Say the convention, write it as a comment above overlaps(), and move on.

Step 2 · The overlap test — the whole problem, in one line

Two intervals [aStart, aEnd) and [bStart, bEnd) overlap if and only if aStart < bEnd && bStart < aEnd. That is it. No case analysis, no branches, no ordering assumption — it is symmetric, so overlaps(a, b) and overlaps(b, a) are the same expression with the operands swapped.

The reasoning is worth saying out loud: “A starts before B ends, and B starts before A ends. If either half fails, one of them is entirely finished before the other begins.” Two comparisons, one for each direction.

✓ ONE LINE — aStart < bEnd && bStart < aEnd · every case, no branches a b 10 < 15 && 13 < 12 → false · no conflict, book it a ends before b starts a b 10 < 14 && 11 < 12 → true · refuse a and b partly overlap a b 11 < 14 && 10 < 12 → true · refuse a sits entirely inside b a b 9 < 12 && 11 < 15 → true · refuse a completely contains b ✗ WHAT CANDIDATES WRITE if (aStart >= bStart && aStart < bEnd) return true; if (bStart >= aStart && bStart < aEnd) return true; if (aStart <= bStart && aEnd >= bEnd) return true; if (bStart <= aStart && bEnd >= aEnd) return true; return false; // eight chances to get one wrong ✓ WHAT IT COLLAPSES TO return aStart < bEnd && bStart < aEnd; one comparison in each direction symmetric: overlaps(a,b) == overlaps(b,a)
Look at the four expressions in the middle column: they are the same expression four times, only the numbers change. That is what “no case analysis” means, and it is why the one-liner cannot have a wrong branch — it has no branches.
the entire core of the problem
/** Half-open [start, end): 10:00-11:00 and 11:00-12:00 do NOT conflict. */
record TimeSlot(Instant start, Instant end) {
    TimeSlot {
        if (!start.isBefore(end)) throw new IllegalArgumentException("end must be after start");
    }

    boolean overlaps(TimeSlot other) {
        return start.isBefore(other.end) && other.start.isBefore(end);
        //     aStart < bEnd            &&  bStart < aEnd
        //     strict "<" on both sides is what makes back-to-back legal
    }
}

Step 3 · Half-open intervals, and the off-by-one that loses the round

A meeting from 10:00 to 11:00 occupies 10:00 and does not occupy 11:00. That is what [start, end) means: the start is included, the end is not. It is the same convention as a Python slice or a for (i = start; i < end; i++) loop, and it is the reason the operators are < and not <=.

two meetings that touch at 11:00 10:00 11:00 12:00 standup design [10:00, 11:00) [11:00, 12:00) [ ) [ ) the end is excluded, the start is included — so nothing is ever double-counted at 11:00 ✓ STRICT < 10 < 12 && 11 < 11 → true && false false — no conflict, the booking is allowed back-to-back meetings work, as everyone expects ✗ INCLUSIVE <= 10 <= 12 && 11 <= 11 → true && true true — refused, for touching at one instant every consecutive booking in the building breaks
One character. Flip the inclusive chip in the prototype and book 10–11 then 11–12 — the second one is refused and the two blocks are drawn touching, so you can see there is nothing between them to fight over.

The convention is part of the answer

Do not silently pick one. Say “I am treating slots as half-open — [start, end) — so back-to-back bookings are legal”, and put it in a comment. An interviewer who wanted the other convention will tell you, and either way you have shown you know the boundary exists. Silence here reads as luck.

Step 4 · The classes

Six types, and only one of them has interesting behaviour. TimeSlot is a value object — immutable, compared by value, and the owner of overlaps(). Putting the predicate anywhere else is the classic feature envy mistake: the method belongs to the data it interrogates. See Immutability & value objects.

Scheduler + book(roomId, slot) : Booking + findRoom(slot, seats, features) + cancel(id) · roomsNeeded(list) 1..* Room - id · label : String - seats : int - features : Set<Feature> - lock : Lock «per room» Booking - id · roomId : String - organiser : String - slot : TimeSlot - attendees : List<String> TimeSlot «value» - start : Instant - end : Instant + overlaps(other) half-open [start, end) RecurrenceRule - freq : DAILY | WEEKLY - until : LocalDate - zone : ZoneId AvailabilityIndex «interface» + firstConflict(slot) : Booking? LinearIndex O(n) SortedIndex O(log n) IntervalTreeIndex O(log n) + ranges swap the index without touching Scheduler or Room
Two things to look at. The lock lives on Room, so two rooms never wait on each other. And AvailabilityIndex is a Strategy seam — start with LinearIndex, and the upgrade is a constructor argument, not a rewrite. Notation: Class diagrams.

Step 5 · Finding a room, three ways — and what each really costs

“How do you check whether a room is free?” The honest first answer is: loop over that room's bookings for that day and run overlaps() on each. A room has maybe fifteen bookings in a day. Fifteen comparisons is nothing. Say this, and say that you would only change it under measurement — over-engineering here is a real way to lose marks.

Then show you know the upgrades exist. If the bookings for a room are kept sorted by start and non-overlapping, you can binary-search for the insertion point and check only two bookings: the one immediately before and the one immediately after. Nothing else can reach across them, because if it did, it would be overlapping its own neighbour — and the list is non-overlapping by construction.

insert [13:00, 14:00) — binary search for the insertion point mid = 3 → [14,15) starts at 14 > 13 · go left mid = 1 → [10,11) starts at 10 ≤ 13 · go right mid = 2 → [12,13) starts at 12 ≤ 13 · go right → insertion point = 3 [9,10) [10,11) [12,13) [14,15) [15,16) [16,17) [17,18) the only two that can possibly conflict prev [12,13) : 13 < 13 && 12 < 14 → false next [14,15) : 13 < 15 && 14 < 14 → false free — insert at index 3 two comparisons, not seven the list is sorted AND non-overlapping, so anything further away would have to cross a neighbour first
The interesting part is not the binary search — it is the invariant. Because the list is kept non-overlapping, an entry three positions away cannot reach your slot without first overlapping the entry next to it. That argument is what earns the mark.

The third option is an interval tree: a balanced BST keyed by start, where every node also stores maxEnd — the largest end time anywhere in its subtree. The pruning rule is the whole idea: if a subtree's maxEnd is <= your query start, nothing in that entire subtree can overlap you, so skip it. That single check turns a walk of the whole tree into a walk of one path.

query: does anything overlap [13:00, 14:00) ? [12,13) maxEnd 18 [9,10) maxEnd 11 [10,11) maxEnd 11 [15,16) maxEnd 18 [14,15) maxEnd 15 [17,18) maxEnd 18 maxEnd 11 ≤ query start 13 → skip this whole subtree root [12,13) : 13 < 13 → false · no overlap · left subtree pruned, go right [15,16) then [14,15) : still false → the slot is free, in one path instead of six nodes
Follow the dashed red box. Two nodes were never visited — not because of the interval in them, but because of the maxEnd summary carried by their parent. Naming the structure is worth nothing; explaining this rule is worth everything.
STRUCTURE QUERY INSERT WHEN YOU WOULD ACTUALLY REACH FOR IT linear scan over a day a list, one overlaps() per booking O(n) O(1) always, first. n is one day in one room — tens of entries. Ship this. sorted by start binary search, check 2 neighbours O(log n) O(log n) one room with a long history, or when you also want the next free gap interval tree (maxEnd) augmented BST, prune by maxEnd O(log n) O(log n) a calendar view: “everything overlapping this window”, across rooms and months
Read the right-hand column, not the middle one. The costs are easy; knowing that linear is the correct answer for a building and being able to say why is the harder and more valuable thing.

The classic follow-up: how many rooms do these meetings need?

“Forget my building. Here are N meetings — what is the minimum number of rooms that could hold them all?” This gets asked almost every time, and it has a clean answer: sort by start time, and keep a min-heap of end times.

  1. Sort the meetings by start.
  2. For each meeting: if the heap is non-empty and its smallest end time is <= this meeting's start, that room has freed up — pop it. (Half-open again: a room that ends exactly when this one starts is reusable.)
  3. Push this meeting's end time. That is the room it is now sitting in.
  4. The peak size of the heap across the whole walk is the number of rooms you need.
9 10 11 12 13 14 15 A [9,12) B [9,11) C [10,13) D [11,12) E [13,15) at 10:30 three meetings are running at once 1 · A starts 9 heap empty → open a room heap of end times 12 2 · B starts 9 min end 12 > 9 → open a room heap of end times 11 12 3 · C starts 10 min end 11 > 10 → open a room peak — 3 in the heap 11 12 13 4 · D starts 11 min end 11 ≤ 11 → reuse that room heap of end times 12 12 13 5 · E starts 13 min end 12 ≤ 13 → reuse that room heap of end times 12 13 15 rooms needed: 3
The heap never gets bigger than the number of meetings running at the same instant — which is exactly the dashed orange line at 10:30. Press 📊 Rooms needed in the prototype and watch this walk animate, card by card.
the min-heap answer, in full
static int roomsNeeded(List<TimeSlot> meetings) {
    List<TimeSlot> sorted = new ArrayList<>(meetings);
    sorted.sort(Comparator.comparing(TimeSlot::start));

    PriorityQueue<Instant> endsInUse = new PriorityQueue<>();   // min-heap of end times
    int peak = 0;
    for (TimeSlot m : sorted) {
        // half-open again: a room that ends exactly when this one starts is reusable
        if (!endsInUse.isEmpty() && !endsInUse.peek().isAfter(m.start())) endsInUse.poll();
        endsInUse.add(m.end());
        peak = Math.max(peak, endsInUse.size());
    }
    return peak;   // the peak, not the final size
}

Two details people get wrong here

Return the peak, not the final heap size — the heap shrinks as the day empties out. And only pop one room per meeting: popping every expired room in a loop still works for counting, but it changes nothing and it hides the invariant that the heap size is the room count.

Booking, end to end

User Scheduler Room «lock» AvailabilityIndex book("R2", [11,12)) lock() firstConflict([11,12)) none — nothing overlaps add(Booking#417) unlock() Booking#417 the lock covers check AND write
The green bar on the Room lifeline is the locked region. Notice it starts before the conflict check and ends after the write — if it did not span both, the whole thing would be pointless. Notation: Sequence diagrams.

The second act · two people click Book at the same instant

Ana and Bo both want R2 at 11:00. Both requests run the overlap check. Both see an empty slot. Both write. Now one room holds two meetings, and at 11:00 two groups of people walk into the same room. This is check-then-act on a shared resource, the exact shape you already met in Coffee Machine and Parking Lot.

⚠️ UNGUARDED — both check before either writes Ana check [11,12) → free ✓ insert booking Bo check [11,12) → free ✓ insert booking R2 · 11:00 — two blocks stacked in one slot two teams walk into the same room at 11:00 · double-booked 🔒 PER-ROOM LOCK — check and write are one indivisible step Ana lock R2 · free ✓ · insert · unlock Bo waiting… lock R2 · conflict → REFUSED 1 booked, 1 refused · the slot holds exactly one meeting R1 and R3 are untouched — the lock is per ROOM, so bookings in other rooms never wait
Run both halves with ⚔️ Two users book at once. The double-booked counter is the whole point: it is a number that should be structurally impossible, and without the lock it is not.

One lock per room — not one lock for the building

A single synchronized on the Scheduler is correct, and it turns a fifty-room office into a queue: booking R1 blocks somebody booking R47, and those two have nothing to say to each other. The natural unit of contention is the room, because that is the thing whose bookings you are comparing. See Locks, Mutex, Semaphore.

check and write, under the room's own lock
Booking book(String roomId, TimeSlot slot, String organiser) {
    Room room = rooms.get(roomId);
    room.lock.lock();                      // this ROOM only — R1 and R3 carry on
    try {
        var clash = room.firstConflict(slot);          // check
        if (clash.isPresent())
            throw new ConflictException(clash.get());  // names WHICH booking it clashed with
        Booking b = new Booking(nextId(), roomId, organiser, slot);
        room.add(b);                                   // ...and write, still holding the lock
        return b;
    } finally {
        room.lock.unlock();
    }
}

And the answer they are really fishing for

“What if this runs on three servers?” An in-process lock protects nothing across machines. The real fix lives in the database: a unique or exclusion constraint on (room, time range) — Postgres will do exactly this with a tsrange and EXCLUDE USING gist — or optimistic concurrency with a version column and a retry. Say which you would build in the 60 minutes (the per-room lock) and which you would ship in production (the constraint). The database is the only place the check can truly be atomic.

Time in, timezones out

Take time as a parameter, never Instant.now() inside the scheduler — otherwise you cannot write a test that books a meeting next Tuesday. Store every booking's start and end as a UTC instant. Rendering into somebody's local time is a display concern.

Then raise the thing nobody prepares for. A recurring 9am standup is 9am local, and local time moves relative to UTC twice a year. If you store the rule as a fixed UTC instant plus 24-hour steps, the standup drifts to 8am or 10am the morning after the clocks change. So for a recurring rule you store the local time plus the zone09:00 and Europe/London — and re-resolve it to an instant for every occurrence. Two sentences, said unprompted, and it is one of the best things you can do in this round.

Recurring meetings, and why they are all-or-nothing

“Book this every weekday until the end of March.” You do not write a row per occurrence and you do not expand forever. You store a RecurrenceRule — frequency, an until date, the zone — and generate the occurrences on demand, capped by a horizon.

  • A recurring booking conflicts if any generated occurrence conflicts. Book the whole series or none of it, and name the offending date: “refused — Wednesday 25th clashes with the design review”.
  • Cap the horizon. “Every Monday, forever” has to become a bounded list at some point, or the conflict check never terminates. A year is a fine default, and saying you capped it deliberately is the point.
  • Exceptions belong beside the rule, not inside it: a small list of “this occurrence was moved / deleted” entries. Trying to encode moves into the rule itself is how calendar code becomes unmaintainable.
  • Generating and checking is the same two-pass pattern as Coffee Machine: check every occurrence first, then insert every occurrence. Inserting as you go leaves half a series booked when day four collides.

Capacity and features are data, not subclasses

findRoom(slot, minCapacity, needsProjector) filters rooms before it ever asks about time. The filter reads fields on Room: seats >= minCapacity && features.containsAll(needed). The moment somebody writes class ProjectorRoom extends Room, adding a whiteboard means a new class and a room with both means a class that cannot exist. Features are a set on the object; see Open/Closed (OCP) for why that is the version that survives contact with new requirements.

Cancelling is the easy half: drop the booking from the room's index under the same lock, then publish a BookingCancelled event so attendees get notified — Observer, one line, and it keeps notification out of the booking path.

The 60-minute budget

0 5 12 18 24 42 50 56 60 clarify entities + TimeSlot APIs class diagram overlaps() + AvailabilityIndex + book() per-room lock main() demo follow-ups 18 of the 60 minutes are one predicate and the structure that calls it — everything else is scaffolding around it
The orange block is the only part that is genuinely this problem. If you are still drawing boxes at minute 30, you will not reach the lock — and the lock is where the marks are.

The follow-ups

  • “Suggest the earliest slot when all five attendees are free.” → turn each attendee's bookings into a list of free intervals, then intersect them pairwise: [max(aStart, bStart), min(aEnd, bEnd)), keeping any result long enough for the meeting. Sketch the intersection formula — it is the overlap test's twin.
  • “Add 15 minutes of buffer between meetings.” → do not change overlaps(). Widen the candidate slot by the buffer before you test it, and leave the stored booking as booked. One line, in one place.
  • “Auto-release a room if nobody checks in within 10 minutes.” → a checkedIn flag on Booking plus a scheduled sweep that cancels un-checked-in bookings. Say it is a job, not a thread inside Room.
  • “Show me the whole floor for next week.” → this is the query the interval tree exists for: everything overlapping a window, across rooms. With a linear index it is a full scan per room, which is fine at office scale and not fine at company scale.
  • “What if a booking needs approval?” → the booking gets a status and becomes a small State machine — PENDING → CONFIRMED → CANCELLED — and a pending booking still holds the slot, or you have re-opened the race.
  • “Make it survive a restart.” → the AvailabilityIndex becomes a repository over a table, and the overlap check becomes a WHERE start < ? AND ? < end query with the constraint doing the guarding.

How this round is lost

  • <= instead of <. Every back-to-back meeting in the building is refused, and the bug is invisible until somebody tries it.
  • Four if branches instead of one line. One of them will be wrong, the interviewer will find the case, and you will debug it live.
  • Never asking whether bookings can be back-to-back. Even if you guess right, you guessed.
  • Scanning every booking ever made. The overlap check should look at one room's bookings in one window, not the entire history of the office.
  • One global lock. Correct, and it serialises fifty rooms that have nothing to do with each other.
  • class ProjectorRoom extends Room. Now a room with a projector and a whiteboard needs a class that inherits from two places.
  • Inserting a recurring series as you go. Day four collides and the calendar is left with three orphaned meetings nobody asked for.

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 live day board: three rooms, hours 9→18, bookings drawn as blocks. Pick a room, a start hour and a duration and press 📅 Book — the right-hand panel shows the overlap test being evaluated against every existing booking, with the real numbers substituted. Book 10–11 then 11–12: both are allowed, because the intervals are half-open. Now flip the inclusive chip and book the same pair again — the second one is suddenly refused. Then press ⚔️ Two users book at once in ⚠️ Unguarded mode to put two bookings in one slot, and again under 🔒 Per-room lock to watch the second be refused.

Hands-on

Try these yourself

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

try 01

Book something, and read the predicate

Leave the defaults and press 📅 Book. The block appears on R2, and the right-hand panel shows the overlap test run against every booking already in that room — the actual numbers substituted into aStart < bEnd && bStart < aEnd, with each result. Every line says false, so the booking is allowed.

try 02

Make it clash

Pick a start hour that lands inside an existing block and press 📅 Book again. The attempted block flashes red and is refused, and in the right-hand panel exactly one line turns red — the comparison that returned true, next to the name of the booking it collided with. That is what a good error message looks like: it names the clash.

try 03

Prove that back-to-back works — then break it

Book 10–11, then book 11–12 in the same room. Both are allowed, and the two blocks are drawn touching. Now flip the inclusive chip and try the same pair from a clean board: the second booking is refused for touching at a single instant. One character, and every consecutive meeting in the building stops working.

try 04

Let the scheduler pick

Set seats ≥ 8 and turn on 📽️ projector, choose ✨ Any room, and press 📅 Book. The explain line names each room it skipped and why — too small, busy at that hour — before it lands on one. That is findRoom(slot, minCapacity, needsProjector) filtering on data, not on subclasses.

try 05

Create the double booking

In ⚠️ Unguarded mode press ⚔️ Two users book at once. Both requests check, both see a free slot, both write — and the grid draws two blocks stacked in one cell while the double-booked counter ticks to 1. Switch to 🔒 Per-room lock, reset, and run the identical demo: the second request waits, sees the first booking, and is refused. 1 booked, 1 refused, 0 double-booked.

try 06

Recurring, and counting rooms

Press 🔁 Recurring to book the same slot across the five-day strip — if any single day clashes, the whole series is refused and the offending day is named. Then press 📊 Rooms needed and watch the min-heap walk five fixed meetings left to right, opening and reusing rooms, ending on rooms needed: 3.

try 07

Build it from memory

Blank file, in this order: TimeSlot as an immutable pair with one overlaps() method → Room with seats, features and its own lock → an AvailabilityIndex interface with a linear implementation → Scheduler.book() that checks and writes inside the room's lock → a main() that books 10–11 and 11–12 successfully, refuses an overlap, and runs roomsNeeded() on five meetings. If back-to-back bookings are refused, you wrote <=.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the meetings away and this is reserving a non-overlapping range on a resource. Once you can write the overlap predicate without thinking, and once you know that [start, end) is the convention that makes adjacency work, an entire family of problems becomes the same problem.

  • Hotel and seat booking — a room for three nights is an interval; check-out day is the exclusive end, which is exactly why hotels never double-count it.
  • Machine or vehicle scheduling — one crane, one operating theatre, one rental car, a queue of interval requests.
  • Cron and job windows“is any job already running in this maintenance window?” is the same overlaps().
  • Calendar free/busy and availability search — intersecting free intervals across people is the predicate's twin.
  • Version and validity ranges — a price valid [from, to), a feature flag active in a window; databases model this as a range type for exactly this reason.

The twenty-second version to say out loud

“A slot is half-open [start, end), so back-to-back meetings are legal. Two slots overlap iff aStart < bEnd && bStart < aEnd — one line, no case analysis. Booking takes the room's own lock, runs that check against the room's bookings, and writes while still holding it. And if you ask how many rooms N meetings need: sort by start, min-heap of end times, the peak heap size is the answer.”

Where this design stops working

  • When it runs on more than one machine. A per-room lock in one process guards nothing across servers. The check has to move into the database as an exclusion constraint, or become optimistic concurrency with a version and a retry.
  • When the calendar is enormous. A linear scan per room is right for an office and wrong for a booking platform. That is when the interval tree stops being a talking point and starts being the implementation.
  • When intervals are not the model. Overlapping is fine for rooms; for people you often want capacity — a resource that can take three simultaneous bookings turns the boolean predicate into a counting problem, and the min-heap becomes a sweep line over start and end events.
  • When recurrence gets real. “The third Thursday of every month, except December, moved to the following week” is a rules engine. Storing a rule plus an exception list holds up for a long time, but it is not free, and the RFC that standardises it is longer than this lesson.

If you only remember one thing

aStart < bEnd && bStart < aEnd — strict on both sides. One line, no branches, and the strictness is what makes 10–11 and 11–12 both bookable. Say the half-open convention out loud before you write it, and you have already answered the two questions this round exists to ask.

What it gives you

  • The overlap test is a single symmetric expression, so there is no wrong branch to hide in — the correctness of the whole system rests on two comparisons.
  • Half-open intervals make back-to-back bookings work with no special case, and they are the same convention as array slices, so nothing new has to be remembered.
  • A lock per room means two rooms are never in each other's way, which is the difference between a fifty-room office and a fifty-deep queue.
  • AvailabilityIndex is an interface, so the linear scan you write in the interview can become a sorted list or an interval tree by changing one constructor argument.
  • Capacity and features are fields on Room, so a room with a projector and a whiteboard needs no new type — and neither does the room somebody adds next year.

Common mistakes

  • The per-room lock is worthless the moment the service runs on two machines; correctness then depends entirely on a database constraint you have not written yet.
  • A linear index rescans a room's whole list for every query, which is fine at office scale and quietly quadratic when someone imports a year of history.
  • Refusing a whole recurring series because one occurrence clashes is correct but blunt — real calendars offer to skip or move that occurrence, and that needs an exception list you now have to maintain.
  • Storing instants in UTC makes every display path do timezone conversion, and any recurring rule that forgets to carry its zone silently drifts an hour when the clocks change.
  • Treating rooms as single-occupancy means the model cannot express a resource with capacity greater than one without replacing the boolean check with a counting sweep.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;

enum Feature { PROJECTOR, VIDEO_CONF, WHITEBOARD }

/**
 * A half-open interval [start, end). 10:00-11:00 and 11:00-12:00 do NOT conflict.
 * Say this convention out loud in the interview - it IS part of the answer.
 */
record TimeSlot(Instant start, Instant end) {
    TimeSlot {
        Objects.requireNonNull(start);
        Objects.requireNonNull(end);
        if (!start.isBefore(end)) throw new IllegalArgumentException("end must be after start");
    }

    /** THE predicate. One line, no case analysis, strict "<" on both sides. */
    boolean overlaps(TimeSlot other) {
        return start.isBefore(other.end) && other.start.isBefore(end);
    }

    /** Local wall time plus a zone -> a UTC instant. Storage is always UTC. */
    static TimeSlot on(LocalDate day, int fromHour, int toHour, ZoneId zone) {
        return new TimeSlot(day.atTime(fromHour, 0).atZone(zone).toInstant(),
                            day.atTime(toHour, 0).atZone(zone).toInstant());
    }

    String show(ZoneId zone) {
        var day = DateTimeFormatter.ofPattern("MM-dd");
        var time = DateTimeFormatter.ofPattern("HH:mm");
        return day.format(start.atZone(zone)) + " " + time.format(start.atZone(zone))
             + "-" + time.format(end.atZone(zone));
    }
}

record Booking(String id, String roomId, String organiser, TimeSlot slot, List<String> attendees) {}

/** The swappable seam: one contract, three cost profiles. */
interface AvailabilityIndex {
    Optional<Booking> firstConflict(TimeSlot slot);
    void add(Booking booking);
    void remove(String bookingId);
    int size();
}

/** O(n) per query. Boring, honest, and the right answer for one building. */
class LinearIndex implements AvailabilityIndex {
    private final List<Booking> bookings = new ArrayList<>();
    public Optional<Booking> firstConflict(TimeSlot slot) {
        for (Booking b : bookings) if (b.slot().overlaps(slot)) return Optional.of(b);
        return Optional.empty();
    }
    public void add(Booking b) { bookings.add(b); }
    public void remove(String id) { bookings.removeIf(b -> b.id().equals(id)); }
    public int size() { return bookings.size(); }
}

/**
 * O(log n). The entries are sorted by start AND non-overlapping, so only the booking
 * immediately before and the one immediately after the insertion point can conflict.
 */
class SortedIndex implements AvailabilityIndex {
    private final NavigableMap<Instant, Booking> byStart = new TreeMap<>();

    public Optional<Booking> firstConflict(TimeSlot slot) {
        var before = byStart.floorEntry(slot.start());
        if (before != null && before.getValue().slot().overlaps(slot)) return Optional.of(before.getValue());
        var after = byStart.ceilingEntry(slot.start());
        if (after != null && after.getValue().slot().overlaps(slot)) return Optional.of(after.getValue());
        return Optional.empty();   // nothing else can reach across those two
    }
    public void add(Booking b) { byStart.put(b.slot().start(), b); }
    public void remove(String id) { byStart.values().removeIf(b -> b.id().equals(id)); }
    public int size() { return byStart.size(); }
}

class Room {
    final String id, label;
    final int seats;
    final Set<Feature> features;                       // data, NOT subclasses
    final ReentrantLock lock = new ReentrantLock();    // per ROOM - R1 never waits on R3
    private final AvailabilityIndex index;

    Room(String id, String label, int seats, Set<Feature> features, AvailabilityIndex index) {
        this.id = id; this.label = label; this.seats = seats;
        this.features = Set.copyOf(features); this.index = index;
    }

    boolean suits(int minSeats, Set<Feature> needed) {
        return seats >= minSeats && features.containsAll(needed);
    }
    Optional<Booking> firstConflict(TimeSlot slot) { return index.firstConflict(slot); }
    void add(Booking b) { index.add(b); }
    void remove(String id) { index.remove(id); }
}

class ConflictException extends RuntimeException {
    final Booking clash;
    ConflictException(Booking clash) {
        super("clashes with " + clash.id());           // names WHICH booking
        this.clash = clash;
    }
}

/** Generates occurrences; never stores one row per day. */
record RecurrenceRule(Frequency freq, LocalDate until, ZoneId zone) {
    enum Frequency { DAILY, WEEKLY }
    static final int MAX_OCCURRENCES = 260;            // cap the horizon, always

    List<LocalDate> occurrences(LocalDate from) {
        List<LocalDate> out = new ArrayList<>();
        int step = freq == Frequency.DAILY ? 1 : 7;
        for (LocalDate d = from; !d.isAfter(until) && out.size() < MAX_OCCURRENCES; d = d.plusDays(step))
            out.add(d);
        return out;
    }
}

class Scheduler {
    private final Map<String, Room> rooms = new LinkedHashMap<>();
    private final List<Consumer<String>> listeners = new CopyOnWriteArrayList<>();
    private int lastId = 400;

    void addRoom(Room r) { rooms.put(r.id, r); }
    void onEvent(Consumer<String> listener) { listeners.add(listener); }
    private void publish(String msg) { listeners.forEach(l -> l.accept(msg)); }
    private synchronized String nextId() { return "B" + (++lastId); }

    Booking book(String roomId, TimeSlot slot, String organiser, List<String> attendees) {
        Room room = rooms.get(roomId);
        if (room == null) throw new IllegalArgumentException("no such room: " + roomId);
        room.lock.lock();                              // check AND write, one indivisible step
        try {
            var clash = room.firstConflict(slot);
            if (clash.isPresent()) throw new ConflictException(clash.get());
            Booking b = new Booking(nextId(), roomId, organiser, slot, List.copyOf(attendees));
            room.add(b);
            publish("booked " + b.id() + " in " + roomId);
            return b;
        } finally {
            room.lock.unlock();
        }
    }

    /** Capacity and features filter FIRST, time second. */
    Optional<Booking> findRoom(TimeSlot slot, int minSeats, Set<Feature> needed, String organiser) {
        for (Room r : rooms.values()) {
            if (!r.suits(minSeats, needed)) continue;
            try { return Optional.of(book(r.id, slot, organiser, List.of())); }
            catch (ConflictException busy) { /* try the next room */ }
        }
        return Optional.empty();
    }

    /** A series conflicts if ANY occurrence conflicts. Check them all, then write them all. */
    List<Booking> bookRecurring(String roomId, LocalDate from, int fromHour, int toHour,
                                RecurrenceRule rule, String organiser) {
        Room room = rooms.get(roomId);
        room.lock.lock();
        try {
            List<TimeSlot> slots = new ArrayList<>();
            for (LocalDate day : rule.occurrences(from)) {
                // 9am LOCAL on every occurrence - the UTC instant moves when the clocks do
                TimeSlot slot = TimeSlot.on(day, fromHour, toHour, rule.zone());
                var clash = room.firstConflict(slot);
                if (clash.isPresent())
                    throw new IllegalStateException("series refused: " + day + " clashes with " + clash.get().id());
                slots.add(slot);
            }
            List<Booking> made = new ArrayList<>();
            for (TimeSlot slot : slots) {
                Booking b = new Booking(nextId(), roomId, organiser, slot, List.of());
                room.add(b);
                made.add(b);
            }
            publish("booked a " + made.size() + "-occurrence series in " + roomId);
            return made;
        } finally {
            room.lock.unlock();
        }
    }

    void cancel(String roomId, String bookingId) {
        Room room = rooms.get(roomId);
        room.lock.lock();
        try { room.remove(bookingId); publish("cancelled " + bookingId); }
        finally { room.lock.unlock(); }
    }

    /** "How many rooms do these meetings need?" Sort by start, min-heap of end times. */
    static int roomsNeeded(List<TimeSlot> meetings) {
        List<TimeSlot> sorted = new ArrayList<>(meetings);
        sorted.sort(Comparator.comparing(TimeSlot::start));
        PriorityQueue<Instant> endsInUse = new PriorityQueue<>();
        int peak = 0;
        for (TimeSlot m : sorted) {
            // half-open: a room ending exactly when this starts is reusable
            if (!endsInUse.isEmpty() && !endsInUse.peek().isAfter(m.start())) endsInUse.poll();
            endsInUse.add(m.end());
            peak = Math.max(peak, endsInUse.size());
        }
        return peak;                                   // the PEAK, not the final size
    }
}

public class Main {
    static final ZoneId ZONE = ZoneId.of("Europe/London");

    public static void main(String[] args) throws Exception {
        Scheduler scheduler = new Scheduler();
        scheduler.addRoom(new Room("R1", "Huddle", 4, Set.of(), new SortedIndex()));
        scheduler.addRoom(new Room("R2", "Sync", 8, Set.of(Feature.PROJECTOR), new SortedIndex()));
        scheduler.addRoom(new Room("R3", "Boardroom", 12,
                Set.of(Feature.PROJECTOR, Feature.VIDEO_CONF), new SortedIndex()));
        scheduler.onEvent(msg -> System.out.println("   [notify] " + msg));

        LocalDate mon = LocalDate.of(2026, 3, 23);

        System.out.println("-- back to back is allowed --");
        scheduler.book("R2", TimeSlot.on(mon, 10, 11, ZONE), "ana", List.of("bo"));
        scheduler.book("R2", TimeSlot.on(mon, 11, 12, ZONE), "bo", List.of("ana"));
        System.out.println("   10-11 and 11-12 both booked: [start, end) is half-open");

        System.out.println("-- an overlapping request is refused --");
        try { scheduler.book("R2", TimeSlot.on(mon, 11, 13, ZONE), "cy", List.of()); }
        catch (ConflictException e) { System.out.println("   refused: " + e.getMessage()); }

        System.out.println("-- find any room seating 8 with a projector --");
        var found = scheduler.findRoom(TimeSlot.on(mon, 11, 12, ZONE), 8, Set.of(Feature.PROJECTOR), "dee");
        System.out.println("   -> " + found.map(b -> b.roomId() + " " + b.slot().show(ZONE)).orElse("nothing free"));

        System.out.println("-- a recurring series is all-or-nothing --");
        scheduler.book("R1", TimeSlot.on(mon.plusDays(2), 9, 10, ZONE), "eli", List.of());
        try {
            scheduler.bookRecurring("R1", mon, 9, 10,
                    new RecurrenceRule(RecurrenceRule.Frequency.DAILY, mon.plusDays(4), ZONE), "ana");
        } catch (IllegalStateException e) { System.out.println("   " + e.getMessage()); }

        System.out.println("-- how many rooms do these five meetings need? --");
        List<TimeSlot> five = List.of(
                TimeSlot.on(mon, 9, 12, ZONE), TimeSlot.on(mon, 9, 11, ZONE),
                TimeSlot.on(mon, 10, 13, ZONE), TimeSlot.on(mon, 11, 12, ZONE),
                TimeSlot.on(mon, 13, 15, ZONE));
        System.out.println("   rooms needed: " + Scheduler.roomsNeeded(five));

        System.out.println("-- two people book the same slot at the same instant --");
        var gate = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(2);
        List<Future<String>> race = new ArrayList<>();
        for (String who : List.of("fay", "gus")) {
            race.add(pool.submit(() -> {
                gate.await();
                try {
                    var b = scheduler.book("R3", TimeSlot.on(mon, 15, 16, ZONE), who, List.of());
                    return who + " got " + b.id();
                } catch (ConflictException e) {
                    return who + " refused: " + e.getMessage();
                }
            }));
        }
        gate.countDown();
        for (var f : race) System.out.println("   " + f.get());
        pool.shutdown();
        System.out.println("exactly one winner, because check and write share the room's lock");
    }
}

/* expected output (the two racers may swap places):

-- back to back is allowed --
   [notify] booked B401 in R2
   [notify] booked B402 in R2
   10-11 and 11-12 both booked: [start, end) is half-open
-- an overlapping request is refused --
   refused: clashes with B402
-- find any room seating 8 with a projector --
   [notify] booked B403 in R3
   -> R3 03-23 11:00-12:00
-- a recurring series is all-or-nothing --
   [notify] booked B404 in R1
   series refused: 2026-03-25 clashes with B404
-- how many rooms do these five meetings need? --
   rooms needed: 3
-- two people book the same slot at the same instant --
   [notify] booked B405 in R3
   fay got B405
   gus refused: clashes with B405
exactly one winner, because check and write share the room's lock
*/

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

What is the correct test for whether two half-open intervals [aStart, aEnd) and [bStart, bEnd) overlap?

question 02 / 08

A meeting runs 10:00–11:00 and someone requests 11:00–12:00 in the same room. What should happen, and why?

question 03 / 08

Bookings for a room are kept in a list sorted by start time. Why is checking only the neighbour before and the neighbour after the insertion point enough?

question 04 / 08

In an interval tree, each node stores maxEnd — the largest end time in its subtree. What does that let you do?

question 05 / 08

Given N meetings, how do you compute the minimum number of rooms needed?

question 06 / 08

Two people press Book for the same room and slot at the same instant. What is the right fix in a 60-minute round?

question 07 / 08

A daily 9am standup recurs for six months. How should the recurrence be stored?

question 08 / 08

Rooms differ by seat count and by equipment (projector, video conferencing). How should that be modelled?

0/8 answered