Sortable Poker Hands
Recently one of the problems that I had to solve on one of my interviews was the sortable poker hands problem , and because I received very good feedback and my solution was described as “uniquely structured to solve the problem in a human way” I thought to share this solution.
TL;DR
You can find the source code for this solution in this repository github.com/gabihodoroaga/sortable-poker-hands.
Prerequisites
- python3
- enough time to have a little fun
The problem
The problem was taken from this Kata and I will describe it in detail below.
Create a poker hand that has a constructor that accepts a string containing 5 cards:
hand = PokerHand("KS 2H 5C JD TD")
The characteristics of the string of cards are:
- A space is used as card separator
- Each card consists of two characters
- The first character is the value of the card, valid characters are:
2, 3, 4, 5, 6, 7, 8, 9, T(en), J(ack), Q(ueen), K(ing), A(ce)
- The second character represents the suit, valid characters are:
S(pades), H(earts), D(iamonds), C(lubs)
The poker hands must be sortable by rank, the highest rank first.
Apply the Texas Hold’em rules for ranking the cards.
There is no ranking for the suits.
An ace can either rank high or rank low in a straight or straight flush. Example of a straight with a low ace:
"5C 4D 3C 2S AS"
The solution
When we, humans, play poker we draw cards one by one and after each draw we evaluate the rank of the hand to see if we have a pair, or a full house, or something else.
This is best explained by an example:
- Draw the first card: 9 of spades (9S), our hand rank is now High card.
- Draw the second card: 9 of hearts (9H), now we have a Pair now and we hope for a three of a kind.
- Draw the third card: 9 of clubs (9C), now we have a Three of a kind, very well, but we hope for the 4th nine.
- Draw the fourth card: 9 of diamond (9D), best card that you can draw, now we have Four of a kind.
- Draw the fifth card: 6 of hearts (6H), it really does not matter that much for this hand.
With this hand you have very good chances to become rich.
The algorithm that we will develop next will follow the same logic.
First let’s create some helper classes
class PokerCard:
values = {'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'T':10,'J':12,'Q':13,'K':14,'A':15}
suites = ['S','H','D','C']
def __init__(self, card):
# no card validation for better performance
self.value = card[0]
self.suite = card[1]
def __lt__(self, card):
return self.values[self.value] < self.values[card.value]
The next class will be the base for all other classes
class Hand:
def __repr__(self):
return self.__class__.__name__
We will define a class for each of the possible hand ranks, and each class is responsible to decide the rank when a new card is drawn by implementing the Draw method, and also we need to implement the __lt__ function in order to be able to decide which hand has a higher value when the rank is the same.
First possible rank is the High card
class HighCard(Hand):
def __init__(self):
self.rank = 1
self.cards = []
def __lt__(self, other):
myCards = self.allCards()
cardCards = other.allCards()
for i in range(0, 5):
if myCards[i] < cardCards[i]:
return True
if myCards[i] > cardCards[i]:
return False
return False
@lru_cache(None)
def allCards(self):
return sorted(self.cards, reverse=True)
def Draw(self, card):
pc = PokerCard(card)
# check for pair
if next((c for c in self.cards if c.value == pc.value), None) != None:
self.cards.append(pc)
return Pair(self.cards, pc)
self.cards.append(pc)
if len(self.cards) == 5:
## check for flush
isFlush = next((c for c in self.cards[:-1] if c.suite != pc.suite), None) == None
## check for straight
cards = sorted(self.cards)
valueList = list(PokerCard.values)
firstCardIdx = valueList.index(cards[0].value)
isStraight = True
for i in range(0, 5):
if cards[i].value != valueList[firstCardIdx + i]:
isStraight = False
break
# check for low straight
if (cards[0].value == '2' and
cards[1].value == '3' and
cards[2].value == '4' and
cards[3].value == '5' and
cards[4].value == 'A'):
isStraight = True
if isFlush and isStraight:
return StraightFlush(self.cards)
if isFlush:
return Flush(self.cards)
if isStraight:
return Straight(self.cards)
return self
If we have a High card and we draw another card we have the following options:
- draw another card with the same value which means we have a pair
- have a straight, a flush, or a straight flush, and this is possible only if we have 5 cards
Also, when we have two high cards, we need to know which one has a lower value according to the rules. Check the __lt__ implementation of the class
Next we have the Pair rank
class Pair(Hand):
def __init__(self, cards, pair):
self.rank = 2
self.cards = cards
self.pair = pair
def __lt__(self, card):
if self.pair < card.pair:
return True
if self.pair > card.pair:
return False
myOtherCards = self.otherCards()
cardOtherCards = card.otherCards()
for i in range(3):
if myOtherCards[i] < cardOtherCards[i]:
return True
if myOtherCards[i] > cardOtherCards[i]:
return False
return False
@lru_cache(None)
def otherCards(self):
return sorted([c for c in self.cards if c.value != self.pair.value], reverse=True)
def Draw(self, card):
pc = PokerCard(card)
# check for 3 of a kind
if self.pair.value == pc.value:
self.cards.append(pc)
return ThreeOfKind(self.cards, pc)
# check for second pair
pair2 = next((c for c in self.cards if c.value != self.pair.value and c.value == pc.value), None)
if (pair2 != None):
self.cards.append(pc)
return TwoPairs(self.cards, self.pair, pc)
self.cards.append(pc)
return self
When we have a pair, the next possible options when we draw another card are:
- three of a kind
- two pairs
Next is two pairs rank
class TwoPairs(Hand):
def __init__(self, cards, pair1, pair2):
self.rank = 3
self.cards = cards
self.pair1 = pair1
self.pair2 = pair2
def __lt__(self, card):
myPairs = self.pairs()
cardPairs = card.pairs()
for i in range(2):
if myPairs[i] < cardPairs[i]:
return True
if myPairs[i] > cardPairs[i]:
return False
return self.otherCard() < card.otherCard()
@lru_cache(None)
def otherCard(self):
return next((c for c in self.cards if c.value != self.pair1.value and c.value != self.pair2.value), None)
def pairs(self):
return sorted([self.pair1, self.pair2], reverse=True)
def Draw(self, card):
pc = PokerCard(card)
# check for full house
pair3 = next((c for c in self.cards if c.value == pc.value), None)
if pair3 != None:
self.cards.append(pc)
return FullHouse(self.cards,
pc, self.pair2 if pc.value == self.pair1.value else self.pair1)
self.cards.append(pc)
return self
From two pairs you can only go to a full house or to remain as two pairs, which is not that bad after all.
Next is three of a kind rank
class ThreeOfKind(Hand):
def __init__(self, cards, pair):
self.rank = 4
self.cards = cards
self.pair = pair
def __lt__(self, card):
if self.pair < card.pair:
return True
if self.pair > card.pair:
return False
myOtherCards = self.otherCards()
cardOtherCards = card.otherCards()
for i in range(3):
if myOtherCards[i] < cardOtherCards[i]:
return True
if myOtherCards[i] > cardOtherCards[i]:
return False
return False
@lru_cache(None)
def otherCards(self):
return sorted([c for c in self.cards if c.value != self.pair.value], reverse=True)
def Draw(self, card):
pc = PokerCard(card)
# Check for FourOfKind
if self.pair.value == pc.value:
self.cards.append(card)
return FourOfKind(self.cards, pc)
# Check for FullHouse
if len(self.cards) == 4:
pair = next((c for c in self.cards
if c.value != self.pair.value and c.value == pc.value), None)
if pair != None:
self.cards.append(pc)
return FullHouse(self.cards, self.pair, pc)
self.cards.append(pc)
return self
From three of a kind the next options are:
- full house
- four of a kind
Next is four of a kind
class FourOfKind(Hand):
def __init__(self, cards, pair):
self.rank = 8
self.cards = cards
self.pair = pair
def __lt__(self, card):
if self.pair < card.pair:
return True
if self.pair > card.pair:
return False
return self.otherCard() < card.otherCard()
@lru_cache(None)
def otherCard(self):
return next((c for c in self.cards if c.value != self.pair.value), None)
def Draw(self, card):
pc = PokerCard(card)
self.cards.append(card)
return self
From four of a kind we can’t go anywhere, we are done but we still need to implement the function to compare 2 four of a kind.
Next is full house rank
class FullHouse(Hand):
def __init__(self, cards, three, two):
self.rank = 7
self.cards = cards
self.three = three
self.two = two
def __lt__(self, card):
if self.three < card.three:
return True
if self.three > card.three:
return False
if self.two < card.two:
return True
return False
def Draw(self, card):
raise ValueError("This should not be possible")
I know that by now this is becoming boring, but these are the rules of the game and we have to implement all the possible ranks.
Next is straight rank
class Straight(Hand):
def __init__(self, cards):
self.rank = 5
self.cards = cards
def __lt__(self, card):
return self.allCards()[0] < card.allCards()[0]
@lru_cache(None)
def allCards(self):
sortedCards = sorted(self.cards, reverse=True)
if sortedCards[0].value == "A" and sortedCards[4].value == '2':
sortedCards.append(sortedCards.pop(0))
return sortedCards
return sortedCards
def Draw(self, card):
raise ValueError("This should not be possible")
and flush rank
class Flush(Hand):
def __init__(self, cards):
self.rank = 6
self.cards = cards
def __lt__(self, card):
myCards = self.allCards()
cardCards = card.allCards()
for i in range(0, 5):
if myCards[i] < cardCards[i]:
return True
if myCards[i] > cardCards[i]:
return False
return False
@lru_cache(None)
def allCards(self):
return sorted(self.cards,reverse=True)
def Draw(self, card):
raise ValueError("This should not be possible")
and the straight flush rank
class StraightFlush(Hand):
def __init__(self, cards):
self.rank = 9
self.cards = cards
def __lt__(self, card):
return self.allCards()[0] < card.allCards()[0]
@lru_cache(None)
def allCards(self):
sortedCards = sorted(self.cards, reverse=True)
if sortedCards[0].value == "A" and sortedCards[4].value == '2':
sortedCards.append(sortedCards.pop(0))
return sortedCards
return sortedCards
def Draw(self, card):
raise ValueError("This should not be possible")
In order to implement the __lt__ operator I used helper functions like allCards() or otherCards() and if you add some caching capabilities to this functions you will get huge improvement in performance. You can read all about caching in python here.
Now, let’s put all together and create our final class
class PokerHand(object):
def __repr__(self): return self.hand
def __init__(self, hand):
self.value = None
self.hand = hand
self.calcValue()
# Your code below:
def __lt__(self, hand):
if self.value.rank == hand.value.rank:
return self.value > hand.value
return self.value.rank > hand.value.rank
def calcValue(self):
r = HighCard()
for card in self.hand.split():
r = r.Draw(card.upper())
self.value = r
The __lt__ function is implemented here in the reverse order to comply with the requirements of the problem.
Now we can play. Let’s test if a pair is lower than a four of a kind.
print(PokerHand("AS AH 9C 8D 2S") > PokerHand("9S 9H 9C 9D JS")) # reverse
You can test the complete solution using this Kata. There are 25.000 random tests. Check out the timing.
Conclusion
Sometimes the best solution to a problem can be found by mimic how we do it in the real world.
You can find all the full source code, and tests of course, here at github.com/gabihodoroaga/sortable-poker-hands.