7
\$\begingroup\$

This code is intended to choose the best poker hand of five out of a set of cards. The cards are represented by a list of strings, where each string contains the rank and suit (e.g., 'AC' represents an ace of clubs). The code also handles numerical ranks for the face cards, so the string could also be '14C' for ace of clubs (in Poker, ace trumps all other cards, so it's treated as 14 in the code).

Given a list of cards, such as ['2H', '5C', 'AC', 'AD', '6C', '7C', 'AS'], the code generates all possible combinations of five cards, and determines the type of hand. For example, ['2H', '5C', 'AC', 'AD', '6C'] is a one pair hand. After finding the strongest type of hand available, there may be multiple hands with the same type. Out of these, the code selects the hand with the greatest sum of the card ranks.

import itertools


def numeric_ranks(cards):
    """
    Changes the input list of card strings to a list of 
    strings with numbers substituting for face cards.
    ex.
    numeric_ranks(['AS','3S','4S','5S','JC'])
    returns ['14S','3S','4S','5S','11C']
    """
    suits = get_suits(cards)
    face_numbers = {'A': 14, 'J': 11, 'Q': 12, 'K': 13}
    for index, card in enumerate(cards):
        rank = card[0:-1]
        try: 
            int(rank)
        except:
            # Rank is a letter, not a number
            cards[index] = str(face_numbers[rank])+suits[index]
    return cards


def get_ranks(cards):
    """
    Returns a list of ints containing the rank of each card in cards.
    ex. 
    get_ranks(['2S','3C','5C','4D','6D'])
    returns [2,3,5,4,6]
    """
    cards = numeric_ranks(cards) # Convert rank letters to numbers (e.g. J to 11)
    return [int(card[0:-1]) for card in cards]


def get_suits(cards):
    """
    Returns a list of strings containing the suit of each card in cards.
    ex. 
    get_ranks(['2S','3C','5C','4D','6D'])
    returns ['S','C','C','D','D']
    """
    return [card[-1] for card in cards]


def evaluate_hand(hand):
    """
    Returns a string containing the name of the hand in poker.
    Input hand must be a list of 5 strings.
    ex. 
    evaluate_hand(['2S','3C','5C','4D','6D'])
    returns 'Straight'
    """
    hand = numeric_ranks(hand)
    ranks = get_ranks(hand)
    suits = get_suits(hand)
    if len(set(hand)) < len(hand) or max(ranks) > 14 or min(ranks) < 1:
        # There is a duplicate
        return 'Invalid hand'
    if isconsecutive(ranks):
        # The hand is a type of straight
        if all_equal(suits):
            # Hand is a flush
            if max(ranks) == 14:
                # Highest card is an ace
                return 'Royal flush'
            return 'Straight flush'
        return 'Straight'
    if all_equal(suits):
        return 'Flush'
    total = sum([ranks.count(x) for x in ranks])
    hand_names = {
        17: 'Four of a kind',
        13: 'Full house',
        11: 'Three of a kind',
        9: 'Two pair',
        7: 'One pair',
        5: 'High card'
        }
    return hand_names[total]


def all_equal(lst):
    """ 
    Returns True if all elements of lst are the same, False otherwise 
    ex.
    all_equal(['S,'S','S']) returns True
    """
    return len(set(lst)) == 1


def show_cards(cards):
    """ Prints the rank and suit for each card in cards. """
    cards = sort_cards(cards)
    all_suits = ['C','D','H','S']
    symbols = dict(zip(all_suits,['\u2667','\u2662','\u2661','\u2664']))
    faces = {14: 'A', 11: 'J', 12: 'Q', 13: 'K'}
    card_symbols = []
    for card in cards:  
        rank = card[0:-1]
        if int(rank) in faces:
            card_symbols.append(faces[int(rank)] + symbols[card[-1]])
        else:
            card_symbols.append(rank + symbols[card[-1]])
    for symbol in card_symbols:
        print(symbol, end = ' ')
    print('')
    return card_symbols

def isconsecutive(lst):
    """ 
    Returns True if all numbers in lst can be ordered consecutively, and False otherwise
    """
    return len(set(lst)) == len(lst) and max(lst) - min(lst) == len(lst) - 1    


def sort_cards(cards):
    """
    Sorts cards by their rank.
    If rank is a string (e.g., 'A' for Ace), then the rank is changed to a number.
    Cards of the same rank are not sorted by suit.
    ex.
    sort_cards(['AS','3S','4S','5S','JC'])
    returns 
    ['3S','4S','5S','11C','14S']
    """ 
    cards = numeric_ranks(cards)
    rank_list = get_ranks(cards)
    # Keep track of the sorting permutation
    new_order = sorted((e,i) for i,e in enumerate(rank_list))
    unsorted_cards = list(cards)
    for index, (a, b) in enumerate(new_order):
        cards[index] = unsorted_cards[b]
    return cards


def get_best_hand(cards):
    """ 
    Returns the best hand of five cards, from a larger list of cards.
    If ranks are alphabetical (e.g., A for ace), it will convert the rank to a number.
    ex.
    get_best_hand(['7C', '7S', '2H', '3C', 'AC', 'AD', '5S'])
    returns
    ['5S', '7C', '7S', '14C', '14D']
    """
    # All combinations of 5 cards from the larger list
    all_hand_combos = itertools.combinations(cards, 5) 
    hand_name_list = [
        'Invalid hand',
        'High card',
        'One pair',
        'Two pair',
        'Three of a kind',
        'Straight',
        'Flush',
        'Full house',
        'Four of a kind',
        'Straight flush',
        'Royal flush'
        ]
    num_hand_names = len(hand_name_list)
    max_value = 0
    best_hands = {x: [] for x in range(num_hand_names)}
    for combo in all_hand_combos:
        hand = list(combo)
        hand_name = evaluate_hand(hand) # Get the type of hand (e.g., one pair)
        hand_value = hand_name_list.index(hand_name)
        if hand_value >= max_value:
            # Stronger or equal hand has been found
            max_value = hand_value
            best_hands[hand_value].append(hand) # Store hand in dictionary
    max_hand_idx = max(k for k, v in best_hands.items() if len(best_hands[k])>0)
    rank_sum, max_sum = 0, 0
    # The strongest hand type out of the combinations has been found
    for hand in best_hands[max_hand_idx]: 
        # Iterate through hands of this strongest type
        ranks = get_ranks(hand)
        rank_sum = sum(ranks)
        if rank_sum > max_sum:
            max_sum = rank_sum
            best_hand = hand # Choose hand with highest ranking cards
    return best_hand

table = ['2H', '5C', 'AC', 'AD', '6C']
hand = ['7C','AS']
cards = hand + table
best_hand = get_best_hand(cards)

print('Hand:')
show_cards(hand), print('')

print('Cards on table:')
show_cards(table), print('')

print('Best hand of five:')
show_cards(best_hand)

print(evaluate_hand(best_hand))

This prints out:

Hand:
7♧ A♤ 

Cards on table:
2♡ 5♧ 6♧ A♧ A♢ 

Best hand of five:
6♧ 7♧ A♤ A♧ A♢ 
Three of a kind
\$\endgroup\$
0

3 Answers 3

6
\$\begingroup\$

OOP for the win

Many of the functions work with a string format of the cards, using expressions like card[0:-1], or card[-1], or worst of all: int(card[0:-1]). Not only this is hard to read, if later you decide to change something in the format, you will have to review all of these functions to make the necessary changes.

It would be better to create a class to represent cards, and convert from the string format as early as possible, and work with that intuitive, natural representation throughout the program.

Don't use exceptions for flow control

This code uses exceptions for flow control:

rank = card[0:-1]
try: 
    int(rank)
except:
    # Rank is a letter, not a number
    cards[index] = str(face_numbers[rank])+suits[index]

I'm aware of Python's EAFP:

EAFP

Easier to ask for forgiveness than permission. This common Python coding style assumes the existence of valid keys or attributes and catches exceptions if the assumption proves false. [...]

Emphasis mine. This principle does not mean to "go ahead and use try-except for flow control". I think it means that try-except is totally acceptable when the expectation is the happy path, and violations are rare. That is not the case in the above code. Letter as the rank of a card is not a "violation", therefore good old-fashioned conditionals are more appropriate here:

rank = card[0:-1]
if '0' <= rank <= '9': 
    rank = int(rank)
else:
    rank = ...

Also note:

  • The documentation specifically mentions the validation of keys and attributes.
  • When try-except is used in a loop, and violation is the majority of the outcomes, it will lead to poor performance.

doctests

It's really nice that you took care to document your functions. Keep it up!

There is one easy step that would add a huge value: turn them into doctests! Here's a prime candidate:

def all_equal(lst):
    """
    Returns True if all elements of lst are the same, False otherwise
    ex.
    all_equal(['S,'S','S']) returns True
    """
    return len(set(lst)) == 1

The doctest could replace the code example like this:

def all_equal(lst):
    """
    Returns True if all elements of lst are the same, False otherwise

    >>> all_equal(['S', 'S', 'S'])
    False

    """
    return len(set(lst)) == 1

You can execute doctests with python -m yourscript.py. The output is empty if all tests pass, but a nice report is printed when they fail.

Don't stop at one example. Add as many as makes sense, for example:

    >>> all_equal(['K', 'S', 'S', 'S'])
    False

    >>> all_equal([])
    True

Just make sure to add a blank line between test cases.

Now, with some tests in place, we can safely make improvements. Consider for example the input ['K', 'S', 'S', 'S', 'S', ...], with one 'K' and many 'S'. Logically, after seeing a 'K' and 'S', we could stop immediately, right? But that won't happen in len(set(lst)), all elements will be dutifully added to a set. This can be improved:

    return all((x == lst[0] for x in lst[1:])) and bool(lst)

In this solution, as soon as an element is not the same as the first, processing will stop. Thanks to the doctests, we can very easily verify an implementation against multiple test cases.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Exactly, like that! \$\endgroup\$
    – janos
    Oct 18, 2016 at 18:14
8
\$\begingroup\$

Overall, this solution is quite good, especially if you are a beginner as you say. Each function has a clear purpose, and the docstrings are helpful. The docstrings could be improved by writing the examples as doctests:

def all_equal(lst):
    """ 
    Return True if all elements of lst are the same, False otherwise 

    >>> all_equal(['S,'S','S'])
    True
    >>> all_equal([])
    False
    """
    return len(set(lst)) == 1

Card representation

The numeric_ranks function would be better if it returned a new list instead of mutating the original list.

def numeric_ranks(cards):
    """
    Represent the card ranks numerically, with 'A' as 14, 'K' as 13,
    'Q' as 12, 'J' as 10.

    >>> numeric_ranks(['AS', '3S', '4S', '5S', 'JC'])
    ['14S', '3S', '4S', '5S', '11C']
    """
    FACE_VALUES = {'A': '14', 'J': '11', 'Q': '12', 'K': '13'}
    return [
        FACE_VALUES.get(c[:-1], c[:-1]) + c[-1:]
        for c in cards
    ]

A bigger problem with numeric_ranks, though, is that its result is "stringly typed". After parsing each card, why stuff not represent it in a data structure that is actually useful? You could write a class, but a simple alternative would be to use a tuple (preferably a namedtuple):

from collections import namedtuple

Card = namedtuple('Card', 'numeric_rank rank suit')

def parse_card(card):
    """
    Interpret the card as a namedtuple with a rank and suit.  The rank is
    represented numerically, with 'A' as 14, 'K' as 13, 'Q' as 12, 'J' as
    10.

    >>> parse_card('AS')
    Card(numeric_rank=14, rank='A', suit='♤')
    >>> parse_card('3S')
    Card(numeric_rank=3, rank='3', suit='♤')
    >>> parse_card('JC')
    Card(numeric_rank=11, rank='J', suit='♧')
    """
    FACE_VALUES = {'A': 14, 'J': 11, 'Q': 12, 'K': 13}
    PRETTY_SUITS = {'C': '\u2667', 'D': '\u2662', 'H': '\u2661', 'S': '\u2664'}
    rank, suit = card[:-1], card[-1:]
    return Card(
        numeric_rank=int(FACE_VALUES.get(rank, rank)),
        rank=rank,
        suit=PRETTY_SUITS[suit]
    )

Better yet, let's have Card take care of the pretty-printing while we're at it:

class Card(namedtuple('Card', 'numeric_rank rank suit')):
    def __str__(self):
        return self.rank + self.suit

A bonus of this tuple representation is that you get sorting by numeric rank for free:

>>> [str(c) for c in sorted(map(parse_card, ['3C', 'JS', '3S', 'AC', '10H']))]
['3♤', '3♧', '10♡', 'J♤', 'A♧']

Hand evaluation

The evaluate_hand function could be a bit more compact.

Detecting if-statements for straight / straight flush / royal flush would be more readable using a conditional expression.

The magic numbers would make more sense if you showed their derivation.

In sum([ranks.count(x) for x in ranks]), you should omit the square brackets, to make a generator expression rather than a list comprehension.

def evaluate_hand(cards):
    ranks = [card.numeric_rank for card in cards]
    suits = [card.suit for card in cards]
    if is_consecutive(ranks):
        return (
            'Straight' if not all_equal(suits) else
            'Straight flush' if max(ranks) < 14 else
            'Royal flush'
        )
    if all_equal(suits):
        return 'Flush'
    return {
        4 + 4 + 4 + 4 + 1: 'Four of a kind',
        3 + 3 + 3 + 2 + 2: 'Full house',
        3 + 3 + 3 + 1 + 1: 'Three of a kind',
        2 + 2 + 2 + 2 + 1: 'Two pair',
        2 + 2 + 1 + 1 + 1: 'One pair',
        1 + 1 + 1 + 1 + 1: 'High card',
    }[sum(ranks.count(r) for r in ranks)]

The get_best_hand function is, in essence, a kind of max() function, and should be written as such.

def best_hand(hand):
    def hand_score(cards):
        type_score = [
            'High card',
            'One pair',
            'Two pair',
            'Three of a kind',
            'Straight',
            'Flush',
            'Full house',
            'Four of a kind',
            'Straight flush',
            'Royal flush',
        ].index(evaluate_hand(cards))
        return (type_score, sum(card.numeric_rank for card in cards))

    if len(set(hand)) != len(hand):
        raise ValueError('Duplicate card in hand')
    return max(itertools.combinations(cards, 5), key=hand_score)

Suggested solution

Putting it all together…

from collections import namedtuple
import itertools

def all_equal(lst):
    return len(set(lst)) == 1

def is_consecutive(lst):
    return len(set(lst)) == len(lst) and max(lst) - min(lst) == len(lst) - 1

class Card(namedtuple('Card', 'numeric_rank rank suit')):
    def __str__(self):
        return self.rank + self.suit

def parse_card(card):
    """
    Interpret the card as a namedtuple with a rank and suit.  The rank is
    represented numerically, with 'A' as 14, 'K' as 13, 'Q' as 12, 'J' as
    10.

    >>> parse_card('AS')
    Card(numeric_rank=14, rank='A', suit='♤')
    >>> parse_card('3S')
    Card(numeric_rank=3, rank='3', suit='♤')
    >>> parse_card('JC')
    Card(numeric_rank=11, rank='J', suit='♧')
    """
    FACE_VALUES = {'A': 14, 'J': 11, 'Q': 12, 'K': 13}
    PRETTY_SUITS = {'C': '\u2667', 'D': '\u2662', 'H': '\u2661', 'S': '\u2664'}
    rank, suit = card[:-1], card[-1:]
    numeric_rank=int(FACE_VALUES.get(rank, rank))
    if not 2 <= numeric_rank <= 14:
        raise ValueError('Invalid card: ' + card)
    return Card(
        numeric_rank=int(FACE_VALUES.get(rank, rank)),
        rank=rank,
        suit=PRETTY_SUITS[suit]
    )

def parse_cards(cards):
    return [parse_card(card) for card in cards]

def show_cards(cards):
    return ' '.join(str(card) for card in sorted(cards))

def evaluate_hand(cards):
    ranks = [card.numeric_rank for card in cards]
    suits = [card.suit for card in cards]
    if is_consecutive(ranks):
        return (
            'Straight' if not all_equal(suits) else
            'Straight flush' if max(ranks) < 14 else
            'Royal flush'
        )
    if all_equal(suits):
        return 'Flush'
    return {
        4 + 4 + 4 + 4 + 1: 'Four of a kind',
        3 + 3 + 3 + 2 + 2: 'Full house',
        3 + 3 + 3 + 1 + 1: 'Three of a kind',
        2 + 2 + 2 + 2 + 1: 'Two pair',
        2 + 2 + 1 + 1 + 1: 'One pair',
        1 + 1 + 1 + 1 + 1: 'High card',
    }[sum(ranks.count(r) for r in ranks)]

def best_hand(hand):
    def hand_score(cards):
        type_score = [
            'High card',
            'One pair',
            'Two pair',
            'Three of a kind',
            'Straight',
            'Flush',
            'Full house',
            'Four of a kind',
            'Straight flush',
            'Royal flush',
        ].index(evaluate_hand(cards))
        return (type_score, sum(card.numeric_rank for card in cards))

    if len(set(hand)) != len(hand):
        raise ValueError('Duplicate card in hand')
    return max(itertools.combinations(cards, 5), key=hand_score)

table = parse_cards(['2H', '5C', 'AC', 'AD', '6C'])
hand = parse_cards(['7C','AS'])
cards = hand + table
best = best_hand(cards)

print("""Hand:
{}

Cards on table:
{}

Best hand of five:
{}
{}""".format(
    show_cards(hand),
    show_cards(table),
    show_cards(best),
    evaluate_hand(best)
))
\$\endgroup\$
4
\$\begingroup\$

One small thing that I haven't seen in other answers is to eliminate some special cases. In this case, cards of rank Ten are treated oddly because 10 is two characters. I'd follow the convention that Ten be treated like a face card and use the letter T. Then it's easy to have

rank_string = card[0]
suit_string = card[1]

I'd also put all your rank-to-integer conversions in one dict.

If you have

ranks = {str(i): i for i in range(2,10)}
ranks.update({"A":14, "K":13, "Q":12, "J":11, "T":10})

then you don't need to worry about checking whether your rank can be parsed into an int. All your rank conversions can look just like your symbol conversions

rank = ranks[card[0]]
symbol = symbols[card[1]]

Wikipedia link showing that T is acceptable in place of 10

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.