2023: Day 7 - Camel Cards

python
Published

December 7, 2023

Setup

The original challenge

My data

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.

import numpy as np
import pandas as pd

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.

cards = list(input[:, 0])
bids = list(map(int, list(input[:, 1])))

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:

  1. 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.
  2. 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)))

example_card = 'AAAAA'

my_n_diff_cards = n_distinct_cards(example_card)
my_n_diff_cards
1
from collections import Counter
# Frequency (count) of the most common card in the hand
max_n_cards = max(Counter(list(example_card)).values())
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):
  my_n_diff_cards = n_distinct_cards(hand)
  max_n_cards = max(Counter(list(hand)).values())

  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.

kinds_cards = list("AKQJT98765432"[::-1])

my_card = 'A'
points_by_card = kinds_cards.index(my_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):
  points_by_cards = 0
  my_cards = list(hand)

  for card in my_cards:
    points_by_cards = points_by_cards*13 + kinds_cards.index(card)

  return points_by_cards

Creating a DataFrame where hand is a row:

df = pd.DataFrame({
  'hands': cards,
  'bids': bids
})

df.head(3)
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):

df1 = df.assign(
  points_type = list(map(points_by_type, df.hands)),
  points_cards = list(map(points_by_card, df.hands)))\
    .sort_values(by = ['points_type', 'points_cards'])

df1.head(3)
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:

df1.reset_index(drop = True, inplace = True)
df1['rank'] = df1.index + 1
df1.head(3)
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.

kinds_cards = list("AKQT98765432J"[::-1])

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
example_card = 'JAJAJ'
Counter(list(example_card))['J']
3
def joker_counter(hand):
  return Counter(list(hand))['J']

Next steps:

  1. Add a column to df1 indicating the n_jokers in each hand.
  2. Combine that new column with the existing points_type to determine the new_points_type.
  3. Use the updated points_by_card function to calculate the new_points_card.
df1 = df1.assign(
  n_jokers = list(map(joker_counter, df1.hands)))

df1 = df1.assign(
  new_points_type = list(map(new_type, df1.points_type, df1.n_jokers)),
  # Here I can apply the same function as before because I changed the value of kinds_cards
  new_points_cards = list(map(points_by_card, df1.hands))
)

df1.head(3)
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)

df2 = df1.sort_values(by = ['new_points_type', 'new_points_cards'])\
  .reset_index(drop = True)


df2['rank'] = df2.index + 1

sum(df2["bids"] * df2["rank"])
245794069

The solution is correct! 👌🏽