LLD Cohort · Week 7 · Class 1 · recording 2h52m

Parking Lot & Elevator System

Two classic LLD interview problems. The Parking Lot is walked through from requirements to entities, relationships and working code. The Elevator System is live-coded as a multi-threaded design, with one thread per lift, two heaps and the SCAN algorithm. (The DSA hour at the start of the recording, 0:00 – 1:00, is not covered in these notes.)

Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember

LLD: Parking Lot ▶ 1:00:51 – 1:41:50

A central ParkingLot owns floors → spots and the active tickets. Entry and exit gates are thin APIs on top of it. Fees and payments are pluggable Strategies, and a lock makes spot assignment thread-safe.

The problem

In the interview you get a blank page and the words "design a parking lot". The teacher's advice is to fall back on the framework every time you feel stuck: (1) functional + non-functional requirements → (2) core entities and relationships → (3) code. Start with a one-line definition so you never lose the thread: "A parking lot is a place where people park vehicles of different types." Then brain-dump nouns (slots, vehicle types, entry/exit gates, ticket, floors, manager) before you structure anything.

Requirements

FunctionalNon-functional
Users can park and unpark their vehicle.Thread safety: two gates must never assign the same slot to two vehicles.
The lot manages multiple floors, and a floor has multiple slots of different types.Extensibility: add new pricing, payment methods, gates, slot types or vehicle types without rewrites.
The entry gate generates a ticket and allocates a slot.Scalable / low latency (the class debated whether the lot should be a Singleton).
The exit gate processes payment and vacates the slot.
Supports multiple vehicle types and slot types, plus different fee strategies (hourly, fixed) and payment strategies (UPI, card).
Interview tip

Mention design patterns early, even ones you may not have time to implement ("different strategies for fee calculation"). It tells the interviewer right away that you know LLD.

Core entities and relationships

The class named the relationships. "Lot has floors" and "floor has spots" are has-a relationships. The teacher first accepted "association" and then refined it to aggregation. He stressed that aggregation is not about having a list: holding even one object can be aggregation.

Run the use case before coding

Walk the flow once, out loud, before writing code. It is how you catch a wrong entity early, instead of discovering halfway through coding that "it should have been the entry gate, not the manager":

  1. The vehicle asks the ParkingLot (via the entry gate) to be parked.
  2. The lot loops over the floors and asks each floor for a free spot that fits.
  3. The spot parks the vehicle, a ticket is created and stored in active tickets, and the ticket goes back to the driver.
  4. At exit: find the ticket → mark the exit time → the fee strategy computes the amount → the payment strategy collects it → the spot is unparked and the ticket removed.
Real-life picture

The gates are the doors of a building, but the building's control room (the ParkingLot) decides everything. When a door opens, it is because the control room told it to. That is why the gate keeps a reference to the lot and only forwards calls. The teacher's advice: "Don't think emotionally about which object is 'first'. Pick a sensible orchestrator and defend your design."

Python code

Python
import itertools
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional

# ---------- enums ----------
class VehicleType(Enum):
    BIKE = 1
    CAR = 2
    TRUCK = 3

class SpotType(Enum):          # SMALL fits a bike, MEDIUM a car, LARGE a truck
    SMALL = 1
    MEDIUM = 2
    LARGE = 3

FITS = {VehicleType.BIKE: SpotType.SMALL,
        VehicleType.CAR: SpotType.MEDIUM,
        VehicleType.TRUCK: SpotType.LARGE}

@dataclass
class Vehicle:
    number: str
    type: VehicleType

# ---------- spot & floor ----------
class ParkingSpot:
    def __init__(self, spot_id: str, spot_type: SpotType):
        self.id, self.type = spot_id, spot_type
        self.vehicle: Optional[Vehicle] = None   # the real car sits here, not just its id

    def is_free(self) -> bool:
        return self.vehicle is None

    def can_fit(self, v: Vehicle) -> bool:
        return self.is_free() and FITS[v.type] == self.type

    def park(self, v: Vehicle) -> None:
        self.vehicle = v

    def unpark(self) -> None:
        self.vehicle = None

class ParkingFloor:
    def __init__(self, floor_id: int, spots: list[ParkingSpot]):
        self.id, self.spots = floor_id, spots

    def find_spot(self, v: Vehicle) -> Optional[ParkingSpot]:
        for spot in self.spots:            # simple scan is fine in an interview
            if spot.can_fit(v):
                return spot
        return None

# ---------- ticket ----------
@dataclass
class Ticket:
    id: int
    vehicle: Vehicle
    spot: ParkingSpot
    entry_time: datetime = field(default_factory=datetime.now)
    exit_time: Optional[datetime] = None

    def mark_exit(self, when: Optional[datetime] = None) -> None:
        self.exit_time = when or datetime.now()

    def duration(self) -> timedelta:
        return (self.exit_time or datetime.now()) - self.entry_time

# ---------- strategies ----------
class FeeStrategy(ABC):
    @abstractmethod
    def calculate(self, ticket: Ticket) -> float: ...

class HourlyFee(FeeStrategy):
    RATE = {VehicleType.BIKE: 10, VehicleType.CAR: 20, VehicleType.TRUCK: 50}
    def calculate(self, ticket: Ticket) -> float:
        hours = max(1, -(-int(ticket.duration().total_seconds()) // 3600))  # round up, min 1h
        return hours * self.RATE[ticket.vehicle.type]

class FixedFee(FeeStrategy):
    def calculate(self, ticket: Ticket) -> float:
        return 50.0

class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount: float) -> None: ...

class UpiPayment(PaymentStrategy):
    def pay(self, amount: float) -> None:
        print(f"Paid Rs {amount:.0f} via UPI")

# ---------- the orchestrator ----------
class ParkingLot:
    def __init__(self):
        self.floors: list[ParkingFloor] = []
        self.active_tickets: dict[str, Ticket] = {}   # vehicle number -> ticket
        self._ids = itertools.count(1)
        self._lock = threading.Lock()                 # two gates must never get the same spot

    def add_floor(self, floor: ParkingFloor) -> None:
        self.floors.append(floor)

    def assign_spot(self, v: Vehicle) -> Optional[Ticket]:
        with self._lock:                              # find + park is ONE atomic step
            for floor in self.floors:
                spot = floor.find_spot(v)
                if spot:
                    spot.park(v)
                    ticket = Ticket(next(self._ids), v, spot)
                    self.active_tickets[v.number] = ticket
                    return ticket
            return None

    def release(self, vehicle_number: str) -> Ticket:
        with self._lock:
            ticket = self.active_tickets.pop(vehicle_number)
            ticket.spot.unpark()
            return ticket

# ---------- gates: thin API layers over the lot ----------
class EntryGate:
    def __init__(self, lot: ParkingLot):
        self.lot = lot

    def enter(self, v: Vehicle) -> Optional[Ticket]:
        ticket = self.lot.assign_spot(v)
        print(f"Ticket {ticket.id}: {v.number} -> spot {ticket.spot.id}" if ticket else "Lot full")
        return ticket

class ExitGate:
    def __init__(self, lot: ParkingLot, fee: FeeStrategy, payment: PaymentStrategy):
        self.lot, self.fee, self.payment = lot, fee, payment

    def exit(self, ticket: Ticket) -> float:
        ticket.mark_exit()
        amount = self.fee.calculate(ticket)
        self.payment.pay(amount)
        self.lot.release(ticket.vehicle.number)       # vacate the slot + drop active ticket
        return amount

# usage
lot = ParkingLot()
lot.add_floor(ParkingFloor(1, [ParkingSpot("1-S1", SpotType.SMALL),
                               ParkingSpot("1-M1", SpotType.MEDIUM)]))
entry = EntryGate(lot)
exit_gate = ExitGate(lot, HourlyFee(), UpiPayment())
t = entry.enter(Vehicle("KA-01-1234", VehicleType.CAR))   # Ticket 1: KA-01-1234 -> spot 1-M1
entry.enter(Vehicle("KA-02-9999", VehicleType.CAR))       # Lot full (only one medium spot)
exit_gate.exit(t)                                         # Paid Rs 20 via UPI
Why the spot stores the Vehicle object, not its id

"You don't throw your key into the parking spot. You put the car there." The spot really contains the vehicle, so modelling it as an object reference matches the domain.

Java vs Python

The teacher's Java code returned Optional<ParkingSpot> from findSpot, which forces callers to check isPresent() instead of hitting a NullPointerException. In Python, write the return type as Optional[ParkingSpot] and return None. In Java the quick thread-safety fix is synchronized on assignSpot. In Python it is a threading.Lock around "find + park", which must be one atomic step.

UML

classDiagram
  class ParkingLot {
    +floors
    +active_tickets
    +assign_spot(vehicle) Ticket
    +release(vehicle_number) Ticket
  }
  class ParkingFloor {
    +id
    +spots
    +find_spot(vehicle) ParkingSpot
  }
  class ParkingSpot {
    +id
    +type
    +vehicle
    +is_free() bool
    +can_fit(vehicle) bool
    +park(vehicle)
    +unpark()
  }
  class Vehicle {
    +number
    +type
  }
  class Ticket {
    +id
    +entry_time
    +exit_time
    +mark_exit()
    +duration()
  }
  class EntryGate {
    +enter(vehicle) Ticket
  }
  class ExitGate {
    +exit(ticket) float
  }
  class FeeStrategy {
    +calculate(ticket) float
  }
  class PaymentStrategy {
    +pay(amount)
  }
  ParkingLot o-- ParkingFloor
  ParkingFloor o-- ParkingSpot
  ParkingSpot --> Vehicle
  ParkingLot --> Ticket
  Ticket --> ParkingSpot
  Ticket --> Vehicle
  EntryGate --> ParkingLot
  ExitGate --> ParkingLot
  ExitGate --> FeeStrategy
  ExitGate --> PaymentStrategy
  HourlyFee ..|> FeeStrategy
  FixedFee ..|> FeeStrategy
  UpiPayment ..|> PaymentStrategy

Design patterns used and why

PatternWhereWhy
StrategyFeeStrategy, PaymentStrategyHourly vs fixed pricing, UPI vs card. New rules plug in without touching ExitGate.
Facade / orchestratorParkingLotOne central place that owns floors and tickets. Gates are thin API layers.
Singleton (debated)ParkingLotThere is one physical lot, but singletons hurt testing. Mention it and justify your choice either way.

Pitfalls, follow-ups and extensions mentioned

Doubt: Why do we need a Floor entity? Every floor has the same layout.
→

Floors can have different layouts and spot counts, and the floor id matters (which floor to send the car to). Lot → floors → spots is the common, defendable structure, but it is a design choice.

Doubt: Should active tickets be keyed by user id?
→

Use any unique key. Vehicle number is the natural one, and that is what the code uses.

Doubt: Who should own the payment strategy?
→

The exit gate, because that is where the fee is calculated and paid. The exit gate also releases the slot and removes the active ticket.

Key takeaways
  • Framework: requirements (functional + non-functional) → entities and relationships → code. Come back to it whenever you are stuck.
  • Walk the use case end to end before coding, so you can defend each relationship.
  • Lot → Floor → Spot (aggregation). Ticket links vehicle + spot + times.
  • Fee and payment are Strategies owned by the exit gate.
  • Make "find a spot + park" atomic (lock / synchronized) and say so out loud.

LLD: Elevator System (multi-threaded) ▶ 1:42:00 – 2:52:22

Each elevator is an independent thread with an up-stops min-heap and a down-stops max-heap and serves them with the SCAN algorithm. An ElevatorSystem uses a selection Strategy to pick which lift answers a hall call.

The problem

A building has several lifts. People press buttons in the hall and inside the cabin. The system must decide which lift goes where without the lifts zig-zagging ("the people inside will be scared: who wrote this algorithm?"). The teacher chose this problem to teach concurrency, because every lift runs on its own.

Real-life picture

Lifts 1, 2 and 3 don't know or care where the others are. Each keeps its own to-do list. A controller at the front desk only decides which lift gets a new hall call. In code, that means one thread per lift plus a manager object.

How one lift works: two heaps + SCAN

Hall call vs cabin call. A request is just "open the door at floor X". A hall button (floor 5) first goes to the system, which picks the best lift and adds floor 5 to that lift's heaps. Once inside, pressing 11 goes directly to that lift. No selection is needed, and 11 > 5 puts it in up_stops. Treating both the same way keeps the lift logic simple.

Requirements

FunctionalNon-functional
The system manages multiple elevators.Thread-safe. Each elevator runs in its own thread (say this out loud).
Users can request a lift from any floor (hall) and from inside the lift (cabin).Extensible: new selection algorithms.
Uses a pluggable elevator selection strategy.Reliable: edge cases around security, power, doors and weight (out of scope, but mention them).
Lifts don't move to and fro: requests are processed with SCAN.
Out of scope, cover if time permits: floor objects, internal/external request types, admin controller, emergency handling.
Writing requirements

Write what the system should be able to do ("system should process requests from users"), not a list of actors. Listing out-of-scope items shows design thinking without spending time on them.

Core entities

ElevatorSystem (manager), Elevator (runnable/thread), Request (just a floor id), Direction enum (UP / DOWN / IDLE; a state like OUT_OF_SERVICE could be added), ElevatorSelectionStrategy with NearestElevatorStrategy. Another idea from class is "empty-first, else nearest". A Floor would only hold elevator doors, so it is skipped.

Building it step by step (as live-coded)

  1. Request(floor_id), deliberately without a source floor or a user.
  2. Elevator implements Runnable: id, current floor, two priority queues (Java: new PriorityQueue<>() and (a, b) -> b - a for the max-heap), and add_request() with the "greater than current → up" rule.
  3. run(): a loop that processes the up heap while moving up, switches direction when it is empty, then processes the down heap. move() does current += 1 or -= 1 based on direction (a student suggested setting the direction inside move).
  4. ElevatorSystem: creates N elevators and keeps their threads (so it can stop them), calls thread.start() (which calls run()), and request_elevator() = strategy.select() then lift.add_request().
  5. NearestElevatorStrategy is the "find the minimum in an array" pattern over |lift.floor − request.floor|.
  6. A running flag set to false by stop() ends each thread cleanly. wait/notify were promised for the next class. The code below already uses them so idle lifts sleep instead of spinning.

Python code

Python
import heapq
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum

TICK = 0.05   # seconds to travel one floor (fast, for the demo)

class Direction(Enum):
    UP = "UP"
    DOWN = "DOWN"
    IDLE = "IDLE"

@dataclass(frozen=True)
class Request:
    floor_id: int     # "open the door at this floor" - hall button OR cabin button

class Elevator:
    """One lift = one thread. It knows nothing about the other lifts."""

    def __init__(self, elevator_id: int):
        self.id = elevator_id
        self.current_floor = 0
        self.direction = Direction.IDLE
        self.up_stops: list[int] = []      # min-heap: going up, nearest higher floor first
        self.down_stops: list[int] = []    # max-heap (store -floor): going down, nearest lower first
        self._cv = threading.Condition()   # a lock + wait/notify in one object
        self._running = True

    # ----- called by other threads (the system / passengers) -----
    def add_request(self, req: Request) -> None:
        with self._cv:
            if req.floor_id > self.current_floor:
                heapq.heappush(self.up_stops, req.floor_id)
            elif req.floor_id < self.current_floor:
                heapq.heappush(self.down_stops, -req.floor_id)
            else:
                print(f"Elevator {self.id} opens doors at {self.current_floor}")
            self._cv.notify()              # wake the lift if it was sleeping (idle)

    def stop(self) -> None:
        with self._cv:
            self._running = False
            self._cv.notify()

    # ----- the lift's own thread -----
    def run(self) -> None:
        while True:
            with self._cv:
                while self._running and not self.up_stops and not self.down_stops:
                    self.direction = Direction.IDLE
                    self._cv.wait()        # sleep instead of busy-spinning
                if not self._running:
                    return
                self._step()
            time.sleep(TICK)               # travel time, outside the lock

    def _step(self) -> None:
        # SCAN: keep the current direction while it has work, else switch.
        if self.direction != Direction.DOWN and self.up_stops:
            self.direction = Direction.UP
        elif self.down_stops:
            self.direction = Direction.DOWN
        else:
            self.direction = Direction.UP

        if self.direction == Direction.UP:
            if self.up_stops[0] == self.current_floor:
                heapq.heappop(self.up_stops)
                print(f"Elevator {self.id} stopped at {self.current_floor}")
            else:
                self.current_floor += 1
        else:
            if -self.down_stops[0] == self.current_floor:
                heapq.heappop(self.down_stops)
                print(f"Elevator {self.id} stopped at {self.current_floor}")
            else:
                self.current_floor -= 1

# ----- strategy: which lift serves a hall call? -----
class ElevatorSelectionStrategy(ABC):
    @abstractmethod
    def select(self, elevators: list[Elevator], req: Request) -> Elevator: ...

class NearestElevatorStrategy(ElevatorSelectionStrategy):
    def select(self, elevators, req):
        # "find the minimum in an array" - smallest distance wins
        return min(elevators, key=lambda e: abs(e.current_floor - req.floor_id))

class ElevatorSystem:
    def __init__(self, count: int, strategy: ElevatorSelectionStrategy):
        self.strategy = strategy
        self.elevators = [Elevator(i) for i in range(count)]
        self.threads = [threading.Thread(target=e.run, daemon=True) for e in self.elevators]
        for t in self.threads:             # keep the threads so we can stop/join them later
            t.start()

    def request_elevator(self, req: Request) -> Elevator:
        """Hall button: system picks the best lift, then hands it the request."""
        lift = self.strategy.select(self.elevators, req)
        print(f"Assigning elevator {lift.id} for floor {req.floor_id}")
        lift.add_request(req)
        return lift

    def shutdown(self) -> None:
        for e in self.elevators:
            e.stop()
        for t in self.threads:
            t.join()

# usage
system = ElevatorSystem(2, NearestElevatorStrategy())
lift = system.request_elevator(Request(5))   # someone presses the hall button on floor 5
time.sleep(0.4)
lift.add_request(Request(2))                 # inside the cabin they press 2 (no selection needed)
time.sleep(0.4)
system.shutdown()
# Assigning elevator 0 for floor 5
# Elevator 0 stopped at 5
# Elevator 0 stopped at 2
Java vs Python

Java: class Elevator implements Runnable + new Thread(elevator).start(). Python: threading.Thread(target=elevator.run).start(). Java's PriorityQueue takes a comparator, and (a, b) -> b - a gives a max-heap. Python's heapq is min-only, so push -floor for the max-heap. Java's wait()/notify() inside synchronized maps to Python's threading.Condition (cv.wait() / cv.notify() inside with cv:).

UML

classDiagram
  class ElevatorSystem {
    +elevators
    +threads
    +request_elevator(request) Elevator
    +shutdown()
  }
  class Elevator {
    +id
    +current_floor
    +direction
    +up_stops
    +down_stops
    +add_request(request)
    +run()
    +stop()
  }
  class Request {
    +floor_id
  }
  class Direction {
    UP
    DOWN
    IDLE
  }
  class ElevatorSelectionStrategy {
    +select(elevators, request) Elevator
  }
  class NearestElevatorStrategy
  ElevatorSystem *-- Elevator
  ElevatorSystem --> ElevatorSelectionStrategy
  NearestElevatorStrategy ..|> ElevatorSelectionStrategy
  Elevator --> Direction
  Elevator ..> Request

Design patterns used and why

Follow-ups and extensions mentioned

Doubt: Shouldn't the request include the source floor too?
→

Not in this design. A hall press at floor 5 is "open at 5" (handled by the system, which picks a lift). The cabin press of 11 is "open at 11" (sent straight to that lift). From the lift's point of view, both are just floors to stop at.

Doubt: The lift is at 10 and I want to go to 7. Up queue or down queue?
→

7 is not greater than 10, so it goes to down_stops. It is served when the lift travels downwards.

Doubt: Isn't this just two entities, elevator and user?
→

No. The user is represented by a Request, which is its own entity, and there is also the system/manager and the strategy. Floors are kept as plain ids for simplicity.

Key takeaways
  • One thread per elevator. Lifts are independent and only the system chooses between them.
  • Up stops = min-heap, down stops = max-heap. Route a request by comparing it with the current floor.
  • SCAN/LOOK: finish one direction, then switch. Go IDLE when both heaps are empty.
  • Selection algorithm = Strategy. Nearest lift = argmin of distance.
  • Keep the thread references so you can stop the system cleanly.