Jc-alt logo
jc
Open Source: Design

Open Source: Design

··
13 min read
·data structures and algorithms

Design Intro:

Programming questions found on threads and comment sections. Open source!

Design A Deck Of Cards ::2::

Topics: Array, Design, Enum, Randomized

Intro

Design a deck of playing cards. A standard deck has 52 cards: 4 suits (clubs, diamonds, hearts, spades), each with 13 ranks (2 through 10, jack, queen, king, ace). The deck should support: shuffle(): randomly reorders the deck so every ordering is equally likely. deal(): removes and returns the top card, or None if the deck is empty. dealHand(n): removes and returns up to n cards from the top. remaining(): returns how many cards are left to deal. reset(): returns every dealt card to the deck.

Follow up: Design a hand of cards that can be scored for a specific game (e.g., Blackjack), where the rules for card values are not part of the deck itself.

Example InputOutput
deck = Deck(), deck.remaining()52
deck.deal(), deck.remaining()Card(...), 51
deck.dealHand(5), deck.remaining()[5 cards], 46
deck.reset(), deck.remaining()52

Constraints:

Exactly 52 unique cards, no jokers

deal() on an empty deck must not crash

shuffle() must be uniform: all 52! orderings equally likely

Abstraction

Model a card as an immutable (suit, rank) pair, keep them in an array, and deal by moving a pointer instead of removing elements.

Pseudocode

Sol 1: Card Array With Next Index Pointer
1. (cards = [Card(suit, rank) for every suit and rank], next_idx = 0)
2. shuffle():
    a. next_idx = 0
    b. for i from 0 to n - 1:
         j = random index in [i, n - 1]
         swap cards[i] and cards[j]
3. deal():
    a. if next_idx == n:
         return None
    b. card = cards[next_idx]
    c. next_idx += 1
    d. return card
4. dealHand(count):
    a. hand = []
    b. while len(hand) < count and next_idx < n:
         hand.append(deal())
    c. return hand
5. remaining():
    a. return n - next_idx
6. reset():
    a. next_idx = 0

Sol 2: [Follow up] Blackjack Hand With Soft Ace Scoring
1. (cards = [])
2. add(card):
    a. cards.append(card)
3. value():
    a. (total = 0, aces = 0)
    b. for card in cards:
         if card is ace:
             total += 11, aces += 1
         elif card is 10, jack, queen, or king:
             total += 10
         else:
             total += card rank
    c. while total > 21 and aces > 0:
         total -= 10, aces -= 1
    d. return total
4. isBust():
    a. return value() > 21

Solution 1: Card Array With Next Index Pointer [TC Opt] - Array/Design

import random
from dataclasses import dataclass
from enum import Enum, IntEnum


class Suit(Enum):

    CLUBS = "C"
    DIAMONDS = "D"
    HEARTS = "H"
    SPADES = "S"


class Rank(IntEnum):

    # IntEnum so ranks compare and sort naturally: Rank.KING > Rank.TEN
    TWO = 2
    THREE = 3
    FOUR = 4
    FIVE = 5
    SIX = 6
    SEVEN = 7
    EIGHT = 8
    NINE = 9
    TEN = 10
    JACK = 11
    QUEEN = 12
    KING = 13
    ACE = 14


# frozen so a card is immutable and hashable, dealt cards cannot be altered
@dataclass(frozen=True)
class Card:

    suit: Suit
    rank: Rank


class Deck:

    # Note:
    # - a card is just data: (suit, rank), no game rules or values baked in
    #   games score cards differently (ace is high, low, or 1 or 11), so scoring lives elsewhere
    # - cards live in one fixed array, the deck is never resized
    # - next_idx points at the top card: everything before it is dealt, everything from it on is left
    # - dealing moves the pointer instead of popping from the list
    #   pop(0) is O(n) because every element shifts, the pointer makes deal O(1)
    # - dealt cards stay in the array, so reset is just moving the pointer back to 0
    # - shuffle uses Fisher Yates: for each index i, swap with a random index j in [i, n - 1]
    #   every one of the n! orderings is equally likely, j must start at i not 0
    #   picking from the full range biases the result
    # - shuffle also resets next_idx, since shuffling means putting the whole deck back together
    # - use random.SystemRandom (or secrets.randbelow) for real money or security sensitive games
    #   random is a predictable pseudorandom generator, an attacker can recover its state
    # - Assumptions:
    #   - no jokers, exactly 52 cards
    #   - deal on an empty deck returns None instead of raising

    # overall: tc O(1) for the 52 fixed cards
    # overall: sc O(1) for the 52 fixed cards
    def __init__(self):

        # tc: O(52) ~ O(1)
        self.cards = [Card(suit, rank) for suit in Suit for rank in Rank]
        self.next_idx = 0

    # overall: tc O(n)
    # overall: sc O(1) shuffles in place
    def shuffle(self) -> None:

        # All cards go back into the deck before shuffling
        self.next_idx = 0

        n = len(self.cards)

        # Fix each position with a random card from the not yet fixed suffix
        # tc: O(n)
        for i in range(n):
            j = random.randint(i, n - 1)
            self.cards[i], self.cards[j] = self.cards[j], self.cards[i]

    # overall: tc O(1)
    # overall: sc O(1)
    def deal(self) -> Card | None:

        # Every card has been dealt
        if self.next_idx == len(self.cards):
            return None

        # Top card is at the pointer, move the pointer past it
        card = self.cards[self.next_idx]
        self.next_idx += 1

        return card

    # overall: tc O(k) for k cards dealt
    # overall: sc O(k) for the returned hand
    def dealHand(self, count: int) -> list[Card]:

        hand = []

        # Stop early if the deck runs out
        # tc: O(k)
        while len(hand) < count and self.next_idx < len(self.cards):
            hand.append(self.deal())

        return hand

    # overall: tc O(1)
    # overall: sc O(1)
    def remaining(self) -> int:

        return len(self.cards) - self.next_idx

    # overall: tc O(1)
    # overall: sc O(1)
    def reset(self) -> None:

        # Dealt cards never left the array, moving the pointer back returns them
        # cards keep their current order, call shuffle() for a fresh random order
        self.next_idx = 0

Solution 2: [Follow up] Blackjack Hand With Soft Ace Scoring - Array/Design

class BlackjackHand:

    # Note:
    # - the Deck and Card stay game agnostic
    #   a game specific hand owns the scoring rules, so poker, blackjack,
    #   and others can each define their own hand without changing Card or Deck
    # - blackjack values: 2-10 are face value, jack / queen / king are 10, ace is 1 or 11
    # - count every ace as 11 first, then demote aces to 1 (subtract 10) one at a time
    #   only while the total is over 21
    #   this always yields the best legal score without trying every ace combination
    # - a hand that still counts an ace as 11 is "soft", the ace can absorb a bust
    # - Assumptions:
    #   - standard blackjack scoring, bust means a value over 21

    # overall: sc O(k) for k cards in the hand
    def __init__(self):

        self.cards = []

    # overall: tc O(1)
    # overall: sc O(1)
    def add(self, card: Card) -> None:

        self.cards.append(card)

    # overall: tc O(k) for k cards in the hand
    # overall: sc O(1)
    def value(self) -> int:

        total = 0
        aces = 0

        # Score every card, treating aces as 11 for now
        # tc: O(k)
        for card in self.cards:
            if card.rank == Rank.ACE:
                total += 11
                aces += 1

            # 10, jack, queen, king all count as 10
            elif card.rank >= Rank.TEN:
                total += 10

            else:
                total += int(card.rank)

        # Over 21, demote aces from 11 to 1 until under or out of aces
        # tc: O(aces) at most 4 in one deck
        while total > 21 and aces > 0:
            total -= 10
            aces -= 1

        return total

    # overall: tc O(k)
    # overall: sc O(1)
    def isBust(self) -> bool:

        return self.value() > 21

Design Parking System ::2::

Topics: Hash Table, String, Design, Hash Function

Intro

Design a parking system for a parking lot. The parking lot has three kinds of parking spaces: big, medium, and small, with a fixed number of slots for each size. Implement the ParkingSystem class: ParkingSystem(int big, int medium, int small) Initializes object of the ParkingSystem class. The number of slots for each parking space are given as part of the constructor. bool addCar(int carType) Checks whether there is a parking space of carType for the car that wants to get into the parking lot. carType can be of three kinds: big, medium, or small, which are represented by 1, 2, and 3 respectively. A car can only park in a parking space of its carType. If there is no space available, return false, else park the car in that size space and return true.

Follow up (Amazon phone screen version): Track the time each car entered. Allow smaller cars to park in bigger spaces (small in medium or big, medium in big). Know which spot each car is in. Calculate the price when a car exits.

Example InputOutput
["ParkingSystem", "addCar", "addCar", "addCar", "addCar"] [[1, 1, 0], [1], [2], [3], [1]][null, true, true, false, false]

Constraints:

0 ≤ big, medium, small ≤ 1000

carType is 1, 2, or 3

At most 1000 calls will be made to addCar

Abstraction

Parking lot!

Pseudocode

Sol 1: Remaining Slots Array Indexed By Car Type
1. (slots = [big, medium, small])
2. addCar(carType):
    a. if slots[carType - 1] == 0:
         return false
    b. slots[carType - 1] -= 1
    c. return true

Sol 2: [Follow up] Free Spot Queues Per Size + Parked Cars Hashmap
1. (free = [queue of big spot ids, queue of medium spot ids, queue of small spot ids],
    parked = {}, rates = [big rate, medium rate, small rate])
2. enter(carId, carType, time):
    a. if carId in parked:
         return None
    b. for size from carType down to 1:
         if free[size - 1] not empty:
             spot = free[size - 1].popleft()
             parked[carId] = (spot, size, carType, time)
             return spot
    c. return None
3. exit(carId, time):
    a. if carId not in parked:
         return None
    b. (spot, size, carType, entered) = parked.pop(carId)
    c. free[size - 1].append(spot)
    d. hours = max(1, ceil((time - entered) / 3600))
    e. return hours * rates[carType - 1]
4. getSpot(carId):
    a. if carId not in parked:
         return None
    b. return parked[carId] spot

Solution 1: Remaining Slots Array Indexed By Car Type [TC Opt] - Array/Design

class ParkingSystem:

    # Note:
    # - only the count of free slots per type matters, no need to track individual cars
    # - store remaining slots in an array indexed by carType - 1
    #   (carType 1, 2, 3 maps to index 0, 1, 2)
    # - addCar checks the count, decrements it if positive, and returns whether a slot was free
    # - array of fixed size 3 gives direct O(1) access, no hashing or branching needed

    # overall: sc O(1) fixed array of 3
    def __init__(self, big: int, medium: int, small: int):

        self.slots = [big, medium, small]

    # overall: tc O(1)
    # overall: sc O(1)
    def addCar(self, carType: int) -> bool:

        # No free slot of this type
        if self.slots[carType - 1] == 0:
            return False

        # Park the car, use up one slot
        self.slots[carType - 1] -= 1

        return True

Solution 2: [Follow up] Free Spot Queues Per Size + Parked Cars Hashmap - Hashmap/Design

from collections import deque

class ParkingLot:

    # Note:
    # - spot identity now matters, so counters are no longer enough
    #   track the id of every free spot, one queue per spot size
    # - spot ids are unique across the lot:
    #   big spots first, then medium, then small
    # - carType and spot size share the same numbering (1 big, 2 medium, 3 small)
    #   a car fits any spot whose size is <= its carType number
    #   (small car 3 fits sizes 3, 2, 1, medium car 2 fits sizes 2, 1, big car 1 fits size 1)
    # - always try the smallest fitting spot first, then move to bigger ones
    #   this keeps big spots free for big cars, which can park nowhere else
    # - parked: carId -> (spot id, spot size, car type, entry time)
    #   spot size is stored so the spot returns to the right queue on exit
    #   car type is stored separately since a small car can sit in a big spot
    # - free spots are stored in deques, popleft to assign and append to release, both O(1)
    #   a min heap would always assign the lowest numbered spot, but costs O(log n)
    # - Assumptions:
    #   - time is in seconds, exit time >= entry time
    #   - price is per hour by car type (not by spot size), partial hours round up
    #   - minimum charge is one hour
    #   - carId is unique among cars currently parked

    SECONDS_PER_HOUR = 3600

    # overall: tc O(S) for S total spots
    # overall: sc O(S + C) for S spots and C parked cars
    def __init__(self, big: int, medium: int, small: int, rates: list[int]):

        # Spot ids: big [0, big), medium [big, big + medium), small [big + medium, total)
        # tc: O(S)
        self.free = [
            deque(range(0, big)),
            deque(range(big, big + medium)),
            deque(range(big + medium, big + medium + small)),
        ]

        self.rates = rates
        self.parked = {}

    # overall: tc O(1) at most 3 size checks
    # overall: sc O(1) per car
    def enter(self, carId: int, carType: int, time: int) -> int | None:

        # Car is already parked, reject duplicate entry
        # tc: O(1)
        if carId in self.parked:
            return None

        # Try the smallest fitting spot first, then bigger ones
        # carType 3 tries sizes 3, 2, 1, carType 1 only tries size 1
        # tc: O(1) at most 3 iterations
        for size in range(carType, 0, -1):
            spots = self.free[size - 1]

            if spots:
                spot = spots.popleft()

                # tc: O(1)
                self.parked[carId] = (spot, size, carType, time)

                return spot

        # No spot of any fitting size is free
        return None

    # overall: tc O(1)
    # overall: sc O(1)
    def exit(self, carId: int, time: int) -> int | None:

        # Car is not parked
        # tc: O(1)
        if carId not in self.parked:
            return None

        # tc: O(1)
        spot, size, carType, entered = self.parked.pop(carId)

        # Return the spot to the queue matching its size, not the car type
        # tc: O(1)
        self.free[size - 1].append(spot)

        # Round partial hours up, charge at least one hour
        # tc: O(1)
        duration = time - entered
        hours = max(1, (duration + self.SECONDS_PER_HOUR - 1) // self.SECONDS_PER_HOUR)

        # Price depends on the car type, not the spot it ended up in
        return hours * self.rates[carType - 1]

    # overall: tc O(1)
    # overall: sc O(1)
    def getSpot(self, carId: int) -> int | None:

        # Car is not parked
        if carId not in self.parked:
            return None

        return self.parked[carId][0]