Shuffle, deal and sort a four-player card hand

0
⑂ RemixPractice

You are building the core of a four-player card game. Model a standard 52-card deck and implement the deal.

Types to define

  • Suit: CLUBS, DIAMONDS, HEARTS, SPADES, in this order.
  • Rank: A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, in this order (ace low).
  • Card: one suit and one rank. Each of the 52 combinations appears exactly once in a deck.
  • Deck: holds the 52 cards and can shuffle them.
  • Player: has a name and a hand of cards.

Implement Game.deal_and_sort_hands() (dealAndSortHands in Java or C++). It must:

  1. Shuffle the deck uniformly at random. Every ordering must be equally likely.
  2. Deal every card so each of the four players receives the same number of cards (13 each).
  3. Sort each player's hand by suit (in the Suit order above), then by rank (in the Rank order above).
  4. Return each player's sorted hand (for example, a map from player name to list of cards).

Requirements

  • Ranks must sort by their order in the game, not as strings: 9 < 10 < J < Q < K.
  • Calling the method again must produce a fresh, complete deal. Cards must not carry over or pile up from the previous one.
  • Make the randomness injectable (for example, a seed) so tests are deterministic.
  • If the number of players does not divide the deck evenly, raise an error and leave the state unchanged.

Example

With players North, East, South, West and some seed, one possible result is:

North: 7C 2D 5D 6D 9D QD 2H 7H KH 2S 3S 4S JS
East:  6C QC 3D KD 4H 9H 10H AS 5S 6S 9S QS KS
...

Each hand has 13 cards and all 52 cards appear exactly once across the four hands. Clubs come before diamonds, then hearts, then spades, and inside a suit 10 sorts after 9.

Example (edge case)

With a single player, the hand is the whole deck in order: AC 2C ... KC AD ... KS. With three players, the call fails, because 52 is not divisible by 3.

Be ready for follow-ups: playing tricks where players must follow the led suit, ace-high or trump ordering, and other player counts.

OpenAIScale AICodingEasy

What L5 looks like

  • Writes Fisher-Yates from memory and explains why the naive swap is biased.
  • Injects the RNG for deterministic tests. Keeps deals repeatable and free of leaks: a fresh deck each deal, cleared hands, validation before any state changes.
  • Keeps the ordering in one place (enum order or a key function), so ace-high or trump is a small change.
  • Moves quickly into the trick-taking follow-up with a suit-bucketed hand and enforces follow-suit rules.
Interviewer expectations for every level ↓

Approach

The task has three steps: shuffle, deal, sort. Each one has a standard right answer. The interviewer is checking whether you get the small details right: a fair shuffle, an even deal, a total ordering for the sort, and state that stays correct when the game is dealt a second time.

Model first. Suit and Rank are enums, and their declaration order is the sort order. That way the order lives in one place and is never compared as strings. A string comparison would put "10" before "2" and "K" before "Q", and this is the most common bug in this question. Card is a frozen dataclass, so it is hashable and immutable, and it exposes sort_key() -> (suit_index, rank_index).

Shuffle. Use Fisher-Yates. Walk i from the last index down to 1, pick j uniformly in [0, i], and swap. Every one of the 52! orders comes out with equal probability. Two naive versions to avoid:

  • Swapping each position with a random index in [0, n) is biased, because n^n outcomes do not divide evenly into n! permutations.
  • Sorting by a random comparator is also biased, and it can break the sort.

Inject the RNG (random.Random(seed)) so tests are deterministic. Calling random.shuffle is fine if you say it is Fisher-Yates under the hood. Many interviewers then ask you to write it yourself.

Deal. Go round-robin: card i goes to player i % n, the way a dealer deals. Contiguous slicing (cards[13*k : 13*(k+1)]) is equally fair after a uniform shuffle, but round-robin matches the domain and handles any n that divides 52. Check that the deck splits evenly before you touch any hands, so a bad call leaves the state unchanged.

Sort. hand.sort(key=Card.sort_key) sorts by suit, then rank. Python's sort is stable and O(k log k) for a 13-card hand. If you want, you can also note that counting sort works: there are only 52 keys, so you can bucket by suit*13 + rank in O(52).

Invariants

  • After a deal, the hands partition the 52 distinct cards. No card is missing or duplicated, and the deck is empty.
  • Every hand has exactly 52 / n cards and is non-decreasing under sort_key.
  • Each deal starts from a fresh deck and clears old hands, so dealing twice never piles up cards.

Code

from __future__ import annotations

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


class Suit(Enum):
    CLUBS = 0
    DIAMONDS = 1
    HEARTS = 2
    SPADES = 3


class Rank(Enum):
    # Declaration order is the sort order: A low, K high.
    A = "A"
    TWO = "2"
    THREE = "3"
    FOUR = "4"
    FIVE = "5"
    SIX = "6"
    SEVEN = "7"
    EIGHT = "8"
    NINE = "9"
    TEN = "10"
    J = "J"
    Q = "Q"
    K = "K"

    @property
    def order(self) -> int:
        return _RANK_ORDER[self]


_RANK_ORDER = {rank: i for i, rank in enumerate(Rank)}


@dataclass(frozen=True)
class Card:
    suit: Suit
    rank: Rank

    def sort_key(self) -> tuple[int, int]:
        return (self.suit.value, self.rank.order)

    def __str__(self) -> str:
        return f"{self.rank.value}{self.suit.name[0]}"


class Deck:
    def __init__(self) -> None:
        self.cards: list[Card] = [Card(s, r) for s in Suit for r in Rank]

    def shuffle(self, rng: random.Random) -> None:
        # Fisher-Yates: every one of the 52! orders is equally likely.
        cards = self.cards
        for i in range(len(cards) - 1, 0, -1):
            j = rng.randint(0, i)
            cards[i], cards[j] = cards[j], cards[i]


@dataclass
class Player:
    name: str
    hand: list[Card] = field(default_factory=list)


class Game:
    def __init__(self, players: list[Player], seed: int | None = None) -> None:
        if not players:
            raise ValueError("need at least one player")
        self.players = players
        self.deck = Deck()
        self.rng = random.Random(seed)

    def deal_and_sort_hands(self) -> dict[str, list[Card]]:
        n = len(self.players)
        self.deck = Deck()                  # fresh 52 every deal
        if len(self.deck.cards) % n:
            raise ValueError(f"{len(self.deck.cards)} cards cannot split evenly among {n}")
        self.deck.shuffle(self.rng)
        for p in self.players:
            p.hand = []
        for i, card in enumerate(self.deck.cards):
            self.players[i % n].hand.append(card)   # round-robin, like a real deal
        self.deck.cards = []
        for p in self.players:
            p.hand.sort(key=Card.sort_key)
        return {p.name: list(p.hand) for p in self.players}


if __name__ == "__main__":
    players = [Player(n) for n in ("North", "East", "South", "West")]
    game = Game(players, seed=7)
    hands = game.deal_and_sort_hands()
    for name, hand in hands.items():
        print(name, " ".join(map(str, hand)))

Complexity

  • Deck() construction: O(52) time and space.
  • shuffle: O(N) time, O(1) extra space, with N = 52.
  • deal_and_sort_hands: O(N) to deal, plus O(n · (N/n) log(N/n)) = O(N log(N/n)) to sort the hands. That is effectively O(N log N) worst case and O(N) with counting sort. Space is O(N) for the hands and the returned copies.
  • Everything is constant for a 52-card deck. State the general bounds so the answer covers multi-deck variants.

Test cases

  • 4 players, seed 1: each hand has 13 cards. The union of the hands is 52 distinct cards, and each hand is sorted by (suit, rank).
  • The same seed with a fresh Game gives identical hands (determinism through the injected RNG).
  • Calling deal_and_sort_hands() twice on one game: hands are 13 cards each again (not 26), and all 52 cards are still distinct.
  • 1 player: the hand is the full deck in order, from AC (ace of clubs) to KS (king of spades).
  • 3 players: raises ValueError because 52 % 3 != 0, and existing hands stay untouched.
  • 0 players: the constructor raises ValueError.
  • Rank order check: a clubs run comes out A 2 3 ... 9 10 J Q K, never the string order 10 2 3 ... A J K Q.
  • Statistical sanity check, optional: over 100k shuffles with a seed, the ace of spades lands in each of the 52 positions about 1/52 of the time.

Follow-ups

  1. Play the hand (trick-taking). Players play in turn order starting from a leader. You must follow the led suit if you can. The highest card of the led suit wins the trick and leads the next one. Add Game.play_trick(leader) -> winner. Keep each hand bucketed by suit (dict[Suit, list[Card]], sorted), so "do I have the led suit?" is O(1) and "play my lowest or highest card of that suit" is O(1) from either end. Simulate 13 tricks and return trick counts. Validate illegal plays explicitly.
  2. Configurable ordering. Support ace-high, a trump suit, or a game-specific suit order (for example, bridge uses clubs < diamonds < hearts < spades, and some games alternate colors). Pass a key function, or a RankOrder/SuitOrder mapping, into the game instead of hard-coding the enum value. Trump becomes part of trick resolution, not part of hand sorting.
  3. Different player counts, multiple decks, or jokers. Generalize the deck to num_decks with optional jokers. Cards are then no longer unique, so either add a deck_id to Card or switch the uniqueness checks to multiset counts. For uneven splits, either deal the remainder to a "kitty" or let the first players get one extra card. Ask which rule applies.
  4. Scoring and winner. Score tricks (for example, hearts are 1 point each and the queen of spades is 13, as in Hearts), track scores across rounds, and rotate the dealer. This leads into a small state machine: DEALING → PLAYING → SCORING → DONE.
  5. Testability and fairness. How do you test a random shuffle? Use a seeded RNG for exact expectations, plus a chi-square test on card positions over many runs. Use secrets.SystemRandom for real-money games, and say why a seeded PRNG with a 32-bit seed cannot reach all 52! orders.

Interviewer expectations

Mid-level (L4)

  • Clean Suit/Rank/Card/Deck/Player model, with each player ending up holding exactly 13 cards.
  • A sort that orders ranks correctly (10 after 9, J < Q < K), not by string.
  • Uses a correct shuffle (Fisher-Yates or random.shuffle) and can explain it when asked.
  • Writes a few asserts: hand sizes, no duplicates, sortedness.

Senior (L5)

  • Writes Fisher-Yates from memory and explains why the naive swap is biased.
  • Injects the RNG for deterministic tests. Keeps deals repeatable and free of leaks: a fresh deck each deal, cleared hands, validation before any state changes.
  • Keeps the ordering in one place (enum order or a key function), so ace-high or trump is a small change.
  • Moves quickly into the trick-taking follow-up with a suit-bucketed hand and enforces follow-suit rules.

Staff (L6+)

  • Separates rules from mechanics: a Rules or strategy object owns ordering, legal moves, trick winner and scoring, so the same engine runs several games.
  • Reasons about randomness quality: PRNG state size vs 52!, SystemRandom, and statistical tests for a shuffle.
  • Discusses the game as a state machine with explicit phases and invariants, and how it would sit behind a multiplayer service (server-authoritative dealing, hiding other hands, replay logs from a seed).
  • Calls out ambiguities early (deal order, ace position, uneven splits) and states the assumptions they chose.

Make it yours

Write each chapter yourself, check it against this answer, then let the coach grill you.