AOC 23 - Day 07 - Camel Cards

Link to Advent of Code day 7

We won the trip to Desert Island, and as we arrive, we meet an Elf on a camel. We need to go on a journey with him to investigate why the parts helping to make the sand go to Island Island are missing.

Part 1

Rules

On the way, she offers to play a game named Camel Cards. It's similar to poker, but designed to be easier to play while riding a camel.

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 primarily ordered based on type; for example, every full house is stronger than any three of a kind.

If two hands have the same type, a second ordering rule takes effect. 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).

The input looks like this:

32T3K 765
T55J5 684
KK677 28
KTJJT 220
QQQJA 483

This example shows five hands; each hand is followed by its bid amount. 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.

Now, you can determine the total winnings of this set of hands by adding up the result of multiplying each hand's bid with its rank (765 * 1 + 220 * 2 + 28 * 3 + 684 * 4 + 483 * 5). So the total winnings in this example are 6440.

Find the rank of every hand in your set. What are the total winnings?

Code

For the data, I chose to store each hand in a dedicated class. This class will contain the cards, the hand type, and it's bid.

This way we will have to compute the hand type once, et we'll easily add some helpers if required for part 2.

For the card values, I will directly store each card as an integer, to facilitate comparisons.

To get the card types, using sort followed by itertools.groupby will help to get the duplicate labels, and hence the hand type.

So the parsing part will looks like this:

from dataclasses import dataclass
from itertools import groupby


@dataclass
class Hand:
    hand_type: int
    hand: list[int]
    bid: int


def get_hands(input_file: str) -> list[Hand]:
    card_values = {
        "T": 10,
        "J": 11,
        "Q": 12,
        "K": 13,
        "A": 14,
    }

    hands = []
    with open(input_file) as f:
        for line in f.readlines():
            hand, bid = line.strip().split()
            hand = [card_values.get(card) or int(card) for card in hand]
            hand_type = get_hand_type(hand)
            hands.append(Hand(hand_type, hand, int(bid)))
    return hands


def get_hand_type(hand: list[int]) -> int:
    sorted_cards = sorted(hand)
    groups = [len(list(g)) for (_, g) in groupby(sorted_cards)]
    sorted_groups = sorted(groups, reverse=True)

    if sorted_groups[0] == 5:  # Five of a kind
        return 6
    elif sorted_groups[0] == 4: # Four of a king
        return 5
    elif sorted_groups[0] == 3 and sorted_groups[1] == 2:  # Full house
        return 4
    elif sorted_groups[0] == 3:  # Three of a kind
        return 3
    elif sorted_groups[0] == 2 and sorted_groups[1] == 2 :  # Two pairs
        return 2
    elif sorted_groups[0] == 2:  # One pair
        return 1
    else:  # High card
        return 0

Once we have this, we can simply sort the cards :

def part_one(input_file: str):
    hands = get_hands(input_file)
    sorted_hands = sorted(hands, key=lambda h: (h.hand_type, h.hand))
    total = 0
    for idx, hand in enumerate(sorted_hands):
        total += (idx + 1) * hand.bid
    return total

And that's it for part one !

Part 2

Rules

It's been fun, but the Elf suggest a variant of this game: the cards J are now jokers.

This changes 2 things:

  1. J cards are now the weakest individual cards
  2. J cards can pretend to be whatever card is best for the purpose of determining hand type; for example, QJJQ2 is now considered four of a kind.

As for part 1, we need to find the total winnings with this new rule.

Code

We can apply the same logic as for part one, we just need to do two things:

  1. replace the values of J to the lowest one
  2. get the best hand type for hands with jokers

The first point is easy enough, we can just change the value for J in the dictionary if we play with the variant.

The point 2 is a bit more tricky, but we can observe that for each hand type, having a joker will always lead to the same upgrade.

For example, a hand of type One pair will always be upgraded to Three of a kind. A hand of type Two pair will be upgraded to a Full house.

Knowing that, we can just have a table containing the upgrade for each hand type. We just have to keep in mind that when calculating the initial hand type, we must ignore the jokers.

For once, instead of updating the logic, I'll update the parsing function: there is no point parsing the card first, and then updating them again.

def get_hands(input_file: str, joker_variant: bool = False) -> list[Hand]:
    card_values = {
        # ...
    }
    if joker_variant:
        card_values["J"] = -1

    hands = []
    with open(input_file) as f:
        for line in f.readlines():
            # ...
            if joker_variant:
                hand_type = get_hand_type_with_joker_variant(hand)
            else:
                hand_type = get_hand_type(hand)
            # ...
    return hands


def get_hand_type(hand: list[int]) -> int:
    sorted_cards = sorted(hand)
    groups = [len(list(g)) for (_, g) in groupby(sorted_cards)]
    sorted_groups = sorted(groups, reverse=True)
    # We add an empty group here only to avoid an IndexError,
    # in case we are evaluating a hand with too many removed jokers
    sorted_groups += [0]
    # ...


def get_hand_type_with_joker_variant(hand: list[int]) -> int:
    hand_upgrades = {0: 1, 1: 3, 2: 4, 3: 5, 4: 5, 5: 6, 6: 6}
    jokers_nbr = sum([1 for card in hand if card == -1])
    cards_without_joker = [card for card in hand if card != -1]
    hand_type = get_hand_type(cards_without_joker)
    for _ in range(jokers_nbr):
        hand_type = hand_upgrades[hand_type]
    return hand_type

And it works. Goodbye day 7 !


The full code is available here