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.
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
Gamegives 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) toKS(king of spades). - 3 players: raises
ValueErrorbecause 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 order10 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
-
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. -
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
keyfunction, or aRankOrder/SuitOrdermapping, into the game instead of hard-coding the enum value. Trump becomes part of trick resolution, not part of hand sorting. -
Different player counts, multiple decks, or jokers. Generalize the deck to
num_deckswith optional jokers. Cards are then no longer unique, so either add adeck_idtoCardor 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. -
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. -
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.SystemRandomfor 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/Playermodel, with each player ending up holding exactly 13 cards. - A sort that orders ranks correctly (
10after9,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
Rulesor 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.

