Advent of Code 2023, Day 7
❄️ 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.
Comments