4 minute read

❄️ Back to Advent of Code 2023 solutions homepage ❄️

# Imports
from aoc23.utils import read_input
from dataclasses import dataclass
from functools import total_ordering
from collections import defaultdict

Part 1

In part 1 of this puzzle, we are asked to sort a collection of poker hands into rank order. The rank order is slightly different to the usual one, in that it compares the hand type (4 of a kind, full house etc.) as usual, but when the hand type matches, we choose the hand with the best first card (or best subsequent card if this also matches, and so on). First, it would be useful to have a record of the card ranking, and the different hand types, so we can compare them directly later:

CARDS = {
    'A': 14,
    'K': 13,
    'Q': 12,
    'J': 11,
    'T': 10,
    '9': 9,
    '8': 8,
    '7': 7,
    '6': 6,
    '5': 5,
    '4': 4,
    '3': 3,
    '2': 2,
    '-': 1
}
HAND_TYPES = {
    '5K': 7,
    '4K': 6,
    'FH': 5,
    '3K': 4,
    '2P': 3,
    '1P': 2,
    'HC': 1
}

Let’s create a class that can represent a specific card. Note that we can compare the cards directly using the operators ==, <, and so on, and so can also sort a list of Card objects:

@dataclass(frozen=True)
@total_ordering
class Card:
    label: str
        
    def __post_init__(self):
        assert self.label in CARDS.keys()
        
    def __lt__(self, other):
        return CARDS[self.label] < CARDS[other.label]
    
    def __repr__(self):
        return self.label
card_1 = Card('A')
card_2 = Card('K')
card_3 = Card('5')
card_4 = Card('K')
sorted([card_1, card_2, card_3, card_4])
[5, K, K, A]

Now, for the hand: it will be initialised with the hand string from the input, then split into a list of Card objects in the __post_init__ method. To determine the hand type, count the number of occurances of each card type, and convert the counts into the corresponding hand type. Also, by implementing the __eq__ and __lt__ methods, we can compare hands with each other directly, and sort lists of hands too:

@total_ordering
@dataclass(frozen=True)
class Hand:
    hand_str: str
    joker_label: str = '-'
        
    def __post_init__(self):
        assert len(self.hand_str) == 5
        for c in self.hand_str:
            assert c in CARDS, 'element of hand_str must be in CARDS!'
        object.__setattr__(self, 'cards', [Card(i) for i in self.hand_str])
        object.__setattr__(self, 'joker', Card(self.joker_label))

    @property
    def hand_type(self):
        card_count = defaultdict(int)
        num_joker = 0
        
        for card in self.cards:
            if card == self.joker:
                num_joker += 1
            else:
                card_count[card] += 1

        sorted_counts = sorted(card_count.values())
        if len(sorted_counts) == 0:
            return '5K'
        
        sorted_counts[-1] += num_joker
        
        match sorted_counts:
            case [5]:
                return '5K'
            case [1, 4]:
                return '4K'
            case [2, 3]:
                return 'FH'
            case [1, 1, 3]:
                return '3K'
            case [1, 2, 2]:
                return '2P'
            case [1, 1, 1, 2]:
                return '1P'
            case [1, 1, 1, 1, 1]:
                return 'HC'

    def __lt__(self, other):
        if self.hand_type != other.hand_type:
            return HAND_TYPES[self.hand_type] < HAND_TYPES[other.hand_type]
        else:
            for card_1, card_2 in zip(self.cards, other.cards):
                match (card_1 == self.joker, card_2 == other.joker):
                    case (True, False):
                        return True
                    case (False, True):
                        return False
                    case (False, False):
                        if card_1 < card_2:
                            return True
                        elif card_1 > card_2:
                            return False
            return False
        
    def __eq__(self, other):
        for card_1, card_2 in zip(self.cards, other.cards):
            if card_1 == self.joker and card_2 == other.joker:
                continue
            elif card_1 != card_2:
                return False
            
        return True

(Note: the implementation of part 2 is also included here, but doesn’t affect the logic for part 1). Here are some checks to see if Hand is working as expected:

assert Hand('22222') < Hand('33333')
assert Hand('22333') < Hand('22444')
assert Hand('23456') == Hand('23456')
assert Hand('42333') < Hand('23333')
assert Hand('J2233', 'J') < Hand('2233J', 'J')
assert Hand('J2233', 'J') < Hand('22333', 'J')
assert Hand('J2233', 'J') < Hand('22339', '9')
assert Hand('J2233', 'J') == Hand('92233', '9')

To compute the answer for part 1, simply create a list of hands from the input file:

games = [line.split() for line in read_input(7)]
hands = [(Hand(hand, '-'), int(bid)) for hand, bid in games]

Then sort this list (using the first entry of the (hand, bid) tuple):

sorted_hands = sorted(hands, key=lambda x: x[0])
sorted_hands[:5], sorted_hands[-5:]
([(Hand(hand_str='23857', joker_label='-'), 982),
  (Hand(hand_str='23A49', joker_label='-'), 485),
  (Hand(hand_str='23AK6', joker_label='-'), 59),
  (Hand(hand_str='246T8', joker_label='-'), 674),
  (Hand(hand_str='25K9A', joker_label='-'), 336)],
 [(Hand(hand_str='AA8AA', joker_label='-'), 895),
  (Hand(hand_str='AA9AA', joker_label='-'), 823),
  (Hand(hand_str='AAA6A', joker_label='-'), 181),
  (Hand(hand_str='AAAA5', joker_label='-'), 764),
  (Hand(hand_str='JJJJJ', joker_label='-'), 512)])

Looking good to me! The final answer is the sum of the rank multiplied by the bid for each hand:

sum([(i+1)*sorted_hands[i][1] for i in range(len(sorted_hands))])
247961593

So part 1 answer is: 247961593.

Part 2

In the second part, jokers are introduced - in this case, ‘J’ cards are treated as jokers. Joker cards can be replaced with whichever card causes the hand to have the most valuable hand type - for example, the hand ‘J5544’ has the hand type ‘FH’, as the ‘J’ is converted into a ‘5’.

In the code above, jokers have been implemented in a backwards compatible way, by introducing a default joker card of ‘-‘, with value 1 (less than all other cards), and which does not occur in any actual hand. During the hand type computation, the count of jokers is added to the largest non-joker count; also, additional logic is required in the dunder methods to handle comparisons when the hand type is matching. The advantage of this method is that we can now specify any card (not just ‘J’) to act as a joker card!

Now, the same analysis can be repeated using ‘J’ cards as jokers:

hands = [(Hand(hand, 'J'), int(bid)) for hand, bid in games]
sorted_hands = sorted(hands, key=lambda x: x[0])
sorted_hands[:5], sorted_hands[-5:]
([(Hand(hand_str='23857', joker_label='J'), 982),
  (Hand(hand_str='23A49', joker_label='J'), 485),
  (Hand(hand_str='23AK6', joker_label='J'), 59),
  (Hand(hand_str='246T8', joker_label='J'), 674),
  (Hand(hand_str='25K9A', joker_label='J'), 336)],
 [(Hand(hand_str='TTTTJ', joker_label='J'), 944),
  (Hand(hand_str='QQJQQ', joker_label='J'), 342),
  (Hand(hand_str='KJKJK', joker_label='J'), 939),
  (Hand(hand_str='KKJKK', joker_label='J'), 497),
  (Hand(hand_str='AJAJA', joker_label='J'), 99)])

As expected, the ‘AJAJA’ hand is now the best, being treated as 5 of a kind aces. All that is left is to recompute the final answer:

sum([(i+1)*sorted_hands[i][1] for i in range(len(sorted_hands))])
248750699

So part 2 answer is: 248750699.

❄️ Back to Advent of Code 2023 solutions homepage ❄️

Tags:

Updated:

Comments