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\$
7
\$\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 is abusing 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]

Don't do this. Use good old-fashioned conditions to verify that the letter is a digit, and then you can safely cast it.

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

@user2357112 reacted to this with:

"Don't use exceptions for flow control" - actually, Python is unusual in that using exceptions for flow control is completely normal and expected. A try-except around an int call is the normal way to check whether a string can be interpreted as an integer. Even core flow control features like for loops are implemented using exceptions.

I assume he refers to the EAFP principle: "easier to as for forgiveness than permission". The application of this principle is often debated. There are cases where it applies, and cases where it doesn't. Here, it doesn't, let me explain why.

The principle applies well in cases where the failure of a condition is rare, unexpected. These are acceptable examples:

  1. Iterating over a generator: when there are no more elements, StopIteration is raised, and this exception is used to stop the loop. The key point is that this only happens once, at the end of the loop.

  2. Getting a key from a dictionary: when the key doesn't exist, KeyError is raised. Instead of checking key in mydict, it's common to not check, and wrap mydict[key] in a try-catch. This only works well when the key exists in mydict most of the time. If most of the time it doesn't exist, there may be noticeable performance degradation.

  3. Getting an object from a database raises TheObj.DoesNotExist exception when the object does not exist. It's common to not check with a condition first, use object_mapper.get(...) directly, wrapped in a try-catch. As in the dictionary case, this works well in practice when most of the time the object exists.

  4. Parsing integer from user input, for example, an age. It's common to wrap this in a try-catch, because users are expected to enter valid integers most of the time, only rarely make mistakes or try to abuse the system. When it becomes an abuse, and the exception becomes far more common then the normal case, there may be performance degradation.

In all these patterns, try-catch is used for operations that are commonly handled with conditions in other languages. But the circumstances of these uses are crucial. These uses for flow control are acceptable in Python, and work well, if and only if the exceptions are rare events.

In your example, it's not a rare event that a card doesn't have a numeric value, it's perfectly legal. Therefore, the case of such cards should not be handled with try-catch. Conditions are more appropriate here.

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\$
  • \$\begingroup\$ That if else statement is a good idea, I hadn't thought to compare strings like that. I'm pretty new to OOP, but I guess the card will have a rank property and a suit property? \$\endgroup\$ – Vermillion Oct 18 '16 at 17:38
  • 1
    \$\begingroup\$ Exactly, like that! \$\endgroup\$ – janos Oct 18 '16 at 18:14
  • 5
    \$\begingroup\$ "Don't use exceptions for flow control" - actually, Python is unusual in that using exceptions for flow control is completely normal and expected. A try-except around an int call is the normal way to check whether a string can be interpreted as an integer. Even core flow control features like for loops are implemented using exceptions. \$\endgroup\$ – user2357112 Oct 18 '16 at 21:40
  • \$\begingroup\$ @user2357112 I'm well aware of that aspect of Python. I added more explanation. Let me know if you're still not convinced. \$\endgroup\$ – janos Oct 19 '16 at 5:06
7
\$\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, privacy policy and cookie policy

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