import numpy as np
import pandas as pd
2023: Day 7 - Camel Cards
Setup
Part 1
Notes:
- Desert Island uses machines to make sand out of rocks but the machines have broken down because they’re not getting the parts needed for periodical repairs.
- Have to play Camel Cards during the journey to figure out why the parts deliveries have stopped.
In Camel Cards, you get a list of hands, and your goal is to order them based on the strength of each hand. A hand consists of five cards labeled one of A, K, Q, J, T, 9, 8, 7, 6, 5, 4, 3, or 2. The relative strength of each card follows this order, where A is the highest and 2 is the lowest.
Every hand is exactly one type. From strongest to weakest, they are:
Five of a kind, where all five cards have the same label: AAAAA
Four of a kind, where four cards have the same label and one card has a different label: AA8AA
Full house, where three cards have the same label, and the remaining two cards share a different label: 23332
Three of a kind, where three cards have the same label, and the remaining two cards are each different from any other card in the hand: TTT98
Two pair, where two cards share one label, two other cards share a second - label, and the remaining card has a third label: 23432
One pair, where two cards share one label, and the other three cards have a different label from the pair and each other: A23A4
High card, where all cards’ labels are distinct: 23456
Hands are ordered first by type. Then, the following rule applies:
Start by comparing the first card in each hand. If these cards are different, the hand with the stronger first card is considered stronger. If the first card in each hand have the same label, however, then move on to considering the second card in each hand. If they differ, the hand with the higher second card wins; otherwise, continue with the third card in each hand, then the fourth, then the fifth.
So, 33332 and 2AAAA are both four of a kind hands, but 33332 is stronger because its first card is stronger. Similarly, 77888 and 77788 are both a full house, but 77888 is stronger because its third card is stronger (and both hands have the same first and second card).
Also, there is a bid for each hand. In the example input:
32T3K 765
T55J5 684
KK677 28
KTJJT 220
QQQJA 483
Each hand wins an amount equal to its bid multiplied by its rank, where the weakest hand gets rank 1, the second-weakest hand gets rank 2, and so on up to the strongest hand. Because there are five hands in this example, the strongest hand will have rank 5 and its bid will be multiplied by 5.
In this example, we first need to put the hands in order of strength.
- 32T3K is the only one pair and the other hands are all a stronger type, so it gets rank 1.
- KK677 and KTJJT are both two pair. Their first cards both have the same label, but the second card of KK677 is stronger (K vs T), so KTJJT gets rank 2 and KK677 gets rank 3.
- T55J5 and QQQJA are both three of a kind. QQQJA has a stronger first card, so it gets rank 5 and T55J5 gets rank 4.
After doing that, we then multiply these ranks by their corresponding bids to calculate the total winnings.
Total winnings = (765 * 1 + 220 * 2 + 28 * 3 + 684 * 4 + 483 * 5) = 6440.
The prompt asks to obtain the number of total winnings the input data.
Importing the data
First, I’m loading all the puzzle input as a numpy
array of strings/characters:
input = np.loadtxt('input', dtype = 'object')
input[:5]
array([['99898', '978'],
['T99A9', '198'],
['43Q34', '550'],
['KK8QK', '418'],
['Q6Q57', '767']], dtype=object)
The input
array is kind of problematic since it combines cards and bids, which need to be processed differently. So, let’s split the data into two lists, one for the cards, another one for the bids.
= list(input[:, 0])
cards = list(map(int, list(input[:, 1]))) bids
Ranking the hands
Let’s assign points to the hands based on the rules and then sort or rank them based on those points.
The algorithm would be something like this:
- Identify the TYPE of the hand: five of a kind > four of a kind > full house > three of a kind > two pair > one pair > high card. Assign points based on that.
- Then, assign points based on the value of each of the cards of the hand, from left to right, using this sorting: (highest score) A, K, Q, J, T, 9, 8, 7, 6, 5, 4, 3, 2 (lowest score).
A reasonable way of doing this would be to create a DataFrame
where each row is a hand, then add a column with the points based on type, followed by additional columns for each card’s points. Finally, perform a sort_values()
, first by the hand type column, and then using the card columns to the right to break any ties.
Function for Assigning Points Based on Hand Type
Let’s start solving for a particular case: identifying ‘AAAAA’ as a Five of a Kind:
# Helper function
def n_distinct_cards(hand):
return len(set(list(hand)))
= 'AAAAA'
example_card
= n_distinct_cards(example_card)
my_n_diff_cards my_n_diff_cards
1
from collections import Counter
# Frequency (count) of the most common card in the hand
= max(Counter(list(example_card)).values())
max_n_cards max_n_cards
5
# Logic for assigning points to the hand
if (my_n_diff_cards == 1):
print('Five of a kind')
elif (my_n_diff_cards == 2):
# In this case, the hand could either be four of a kind or full house
if (max_n_cards == 4):
print('Four of a kind')
else:
print('Full house')
elif (my_n_diff_cards == 3):
# could be: three of a kind OR two pair
if (max_n_cards == 3):
print('Three of a kind')
else:
print('Two pair')
elif (my_n_diff_cards == 4):
print('One pair')
else:
print('High card')
Five of a kind
It seems to work as expected.
Let’s wrap the procedure into a function (and make it return the corresponding points instead of the name of the hand):
def points_by_type(hand):
= n_distinct_cards(hand)
my_n_diff_cards = max(Counter(list(hand)).values())
max_n_cards
if (my_n_diff_cards == 1):
#'Five of a kind'
return 7
elif (my_n_diff_cards == 2):
# In this case, the hand could either be four of a kind or full house
if (max_n_cards == 4):
# Four of a kind
return 6
else:
return 5
elif (my_n_diff_cards == 3):
# could be: three of a kind OR two pair
if (max_n_cards == 3):
# Three of a kind
return 4
else:
# Two pair
return 3
elif (my_n_diff_cards == 4):
# One pair
return 2
else:
# High card
return 1
Testing the function with some hands from the input:
print(cards[:5])
print(list(map(points_by_type, cards[:5])))
['99898', 'T99A9', '43Q34', 'KK8QK', 'Q6Q57']
[5, 4, 3, 4, 2]
Function for Assigning Points Based on Each Card
I’ll sort the kinds by their strength and then determine their points based on their positions in the list.
= list("AKQJT98765432"[::-1])
kinds_cards
= 'A'
my_card = kinds_cards.index(my_card)
points_by_card points_by_card
12
Now let’s create a function that, given an entire hand, returns a score that allows sorting them based on the points of their individual cards.
def points_by_card(hand):
= 0
points_by_cards = list(hand)
my_cards
for card in my_cards:
= points_by_cards*13 + kinds_cards.index(card)
points_by_cards
return points_by_cards
Creating a DataFrame
where hand is a row:
= pd.DataFrame({
df 'hands': cards,
'bids': bids
})
3) df.head(
hands | bids | |
---|---|---|
0 | 99898 | 978 |
1 | T99A9 | 198 |
2 | 43Q34 | 550 |
Calculating the points of each hand (both by hand type and based on individual cards):
= df.assign(
df1 = list(map(points_by_type, df.hands)),
points_type = list(map(points_by_card, df.hands)))\
points_cards = ['points_type', 'points_cards'])
.sort_values(by
3) df1.head(
hands | bids | points_type | points_cards | |
---|---|---|---|---|
64 | 236Q8 | 82 | 1 | 3009 |
986 | 237T4 | 883 | 1 | 3148 |
136 | 2397K | 106 | 1 | 3456 |
Creating the rank
column:
= True, inplace = True)
df1.reset_index(drop 'rank'] = df1.index + 1
df1[3) df1.head(
hands | bids | points_type | points_cards | rank | |
---|---|---|---|---|---|
0 | 236Q8 | 82 | 1 | 3009 | 1 |
1 | 237T4 | 883 | 1 | 3148 | 2 |
2 | 2397K | 106 | 1 | 3456 | 3 |
The only step left is to multiply the ranks by the bids and sum up the totals:
sum(df1["bids"] * df1["rank"])
246163188
The solution is correct ✅🙌🏽
Part 2
Now, J cards are jokers - wildcards that can act like whatever card would make the hand the strongest type possible.
To balance this, J cards are now the weakest individual cards, weaker even than 2. The other cards stay in the same order: A, K, Q, T, 9, 8, 7, 6, 5, 4, 3, 2, J.
Implementing this change in the points_by_card
function is straightforward. I just need to re-arrange the list that maps the cards to their scores.
= list("AKQT98765432J"[::-1]) kinds_cards
But I suspect incorporating this new logic into the points_by_type
function will be more complicated. For each joker, we have to explore what is, potentially, the best type each hand could be if we could swap that joker for any other kind of card.
Idea: create a function that receives as input the number of jokers in a hand, along with the original hand type (ignoring the joker rule) and returns the hand type considering the joker rule.
First, let’s see if this function is feasible and how it would work:
- Five of a kind > This is the best hand possible, so it can’t improve.
- Four of a kind > two scenarios
- 4 jokers: the jokers can be swaped to match the odd card. Hand type would go up to Five of a kind.
- 1 joker: the joker can be swaped to match the other 4 cards. Hand type goes up too.
- Full house > two scenarios
- 3 jokers: hand becomes Five of a kind.
- 2 jokers: hand becomes Five of a kind too.
- Three of a kind > two scenarios
- 3 jokers: hand becomes Four of a kind.
- 1 joker: hand becomes Four of a kind too.
- Two pair > two scenarios
- 2 jokers: hand becomes Four of a kind.
- 1 joker: hand becomes Full House.
- One pair > two scenarios
- 2 jokers: hand becomes Three of a kind.
- 1 joker: hand becomes Three of a kind too.
- High card > There can only be one joker by definition, which would turn the hand into One Pair.
We’ve confirmed that, depending on the number of jokers and the initial hand type, we can predict how the hand’s type will change under these new rules (good news!).
Now let’s implement this logic as a function named new_type
:
def new_type(points_type, n_jokers):
if n_jokers == 0 or points_type == 7:
return points_type
# Four of a kind becomes five of a kind
if points_type == 6:
return 7
# Full house becomes five of a Kind
if points_type == 5:
return 7
# Three of a kind becomes four of a kind
if points_type == 4:
return 6
# Two pair can become four of a kind or full house
if points_type == 3:
if n_jokers == 2:
return 6
else:
return 5
# One pair becomes three of a kind
if points_type == 2:
return 4
# High card becomes one pair
if points_type == 1:
return 2
We also need a function that counts jokers:
# Example case
= 'JAJAJ'
example_card list(example_card))['J'] Counter(
3
def joker_counter(hand):
return Counter(list(hand))['J']
Next steps:
- Add a column to
df1
indicating then_jokers
in each hand. - Combine that new column with the existing
points_type
to determine thenew_points_type
. - Use the updated
points_by_card
function to calculate thenew_points_card
.
= df1.assign(
df1 = list(map(joker_counter, df1.hands)))
n_jokers
= df1.assign(
df1 = list(map(new_type, df1.points_type, df1.n_jokers)),
new_points_type # Here I can apply the same function as before because I changed the value of kinds_cards
= list(map(points_by_card, df1.hands))
new_points_cards
)
3) df1.head(
hands | bids | points_type | points_cards | rank | n_jokers | new_points_type | new_points_cards | |
---|---|---|---|---|---|---|---|---|
0 | 236Q8 | 82 | 1 | 3009 | 1 | 0 | 1 | 33937 |
1 | 237T4 | 883 | 1 | 3148 | 2 | 0 | 1 | 34089 |
2 | 2397K | 106 | 1 | 3456 | 3 | 0 | 1 | 34396 |
Finally, just compute the new ranks and re-calculate the total points (sum()
of rank
times the bids
)
= df1.sort_values(by = ['new_points_type', 'new_points_cards'])\
df2 = True)
.reset_index(drop
'rank'] = df2.index + 1
df2[
sum(df2["bids"] * df2["rank"])
245794069
The solution is correct! 👌🏽