Advent of Code 2023 Day 7 Highlights

2023-12-07
aoc
aoc2023
elixir
livebook

Day 7 is one of my favorites so far. The task is not very challenging but it was fun to solve, and selecting Elixir for it helped with solving it elegantly.

Let’s dive in, but first take a look at the livebook.

Part 1

We start with the typical input parsing, but it’s very straightforward here, thankfully:

def parse_input(input) when is_binary(input) do
  input
  |> String.trim()
  |> String.split("\n")
  |> Enum.map(fn line ->
    [hand, bid] = String.split(line)
    {hand, String.to_integer(bid)}
  end)
end

The interesting part is sorting hands by strength. Elixir allows you to compare any term to any term, and you can find the rules of those comparisons and relative ordering of term types here.

So, the core idea of my solution is to rely on those rules: we will use two element tuples to represent the strength of a hand, where the first element of the tuple will be a number corresponding to hand type strength (five of a kind, full house, etc.) and the second element will be a list of card strength - we convert them to numbers too, with "T" being 10, "J" being 11 (in the first part), etc.

Then the comparison between two hand strengths will work exactly like the rules of the game: we first compare strength of the type of the hand, with larger ones winning (if tuples are the same size, the one with the larger first element is considered larger) and if they are equal, we compare the card strength lists (starting from the first element, the list having a larger element earlier is considered larger as long as the lists are the same length).

So essentially, we just need to produce the tuple, then do Enum.sort, and then add indices and do multiplication and sum. Easy enough:

def hand_strength(hand) when is_binary(hand) do
  cards = String.graphemes(hand)

  freqs =
    cards
    |> Enum.frequencies()
    |> Map.values()
    |> Enum.sort(:desc)

  hand_type_strength =
    case freqs do
      [5] -> 7
      [4, 1] -> 6
      [3, 2] -> 5
      [3, 1, 1] -> 4
      [2, 2, 1] -> 3
      [2, 1, 1, 1] -> 2
      [1, 1, 1, 1, 1] -> 1
    end

  card_strengths =
    Enum.map(cards, fn
      "A" -> 14
      "K" -> 13
      "Q" -> 12
      "J" -> 11
      "T" -> 10
      card -> String.to_integer(card)
    end)

  {hand_type_strength, card_strengths}
end

def get_winnings(hands, opts \\ []) when is_list(hands) do
  hands
  |> Enum.sort_by(fn {hand, _bid} -> hand_strength(hand, opts) end)
  |> Enum.with_index(1)
  |> Enum.map(fn {{_hand, bid}, rank} -> bid * rank end)
  |> Enum.sum()
end

def p1(hands) when is_list(hands), do: get_winnings(hands)

Part 2

Part 2 is a straightforward modification of part 1’s solution. We will pass some opts to reuse code between parts 1 and 2 and we’ll extract a card_frequencies function, because that part will be substantially different + we’ll add an additional case in our card strength computation to assign "J" to 1 in case joker_rule is in effect.

Let’s look at card_frequencies:

def card_frequencies(cards, _joker_rule = false) when is_list(cards) do
  cards
  |> Enum.frequencies()
  |> Map.values()
  |> Enum.sort(:desc)
end

def card_frequencies(cards, _joker_rule = true) when is_list(cards) do
  groups = Enum.group_by(cards, fn x -> x end)

  {groups, joker_bonus} =
    if Map.has_key?(groups, "J") && map_size(groups) > 1 do
      {Map.delete(groups, "J"), groups["J"] |> length()}
    else
      {groups, 0}
    end

  [highest_freq | rest] =
    groups
    |> Map.values()
    |> Enum.map(&length/1)
    |> Enum.sort(:desc)

  [highest_freq + joker_bonus | rest]
end

As you can see, the _joker_rule = false case is the one from the part 1’s solution. For part 2 (_joker_rule = true), instead of using Enum.frequencies we’ll use Enum.group_by - this way we can both easily compute the frequencies of the cards and retain their identities. If we find a group of "J" we delete that group from the groups and assign the length of its value (the list of "J"s found in cards) to a joker_bonus variable.

We also handle an edge case of 5 jokers by restricting our groups modification to requiring to have more than one group.

Then, we convert the groups to a frequencies list and sort it in the descending order. We deconstruct the list, and add our joker_bonus to the head of the list (the highest card frequency).

With this function ready, the rest of the hand_strength function is mostly the same, barring the opts passing and the "J" card strength evaluation case for joker_rule = true:

def hand_strength(hand, opts \\ []) when is_binary(hand) do
  joker_rule = Keyword.get(opts, :joker_rule, false)

  cards = String.graphemes(hand)
  freqs = card_frequencies(cards, joker_rule)

  hand_type_strength =
    case freqs do
      [5] -> 7
      [4, 1] -> 6
      [3, 2] -> 5
      [3, 1, 1] -> 4
      [2, 2, 1] -> 3
      [2, 1, 1, 1] -> 2
      [1, 1, 1, 1, 1] -> 1
    end

  card_strengths =
    Enum.map(cards, fn
      "A" -> 14
      "K" -> 13
      "Q" -> 12
      "J" when joker_rule -> 1
      "J" when not joker_rule -> 11
      "T" -> 10
      card -> String.to_integer(card)
    end)

  {hand_type_strength, card_strengths}
end

We also need to pass the opts to hand_strength in the get_winnings function:

def get_winnings(hands, opts \\ []) when is_list(hands) do
  hands
  |> Enum.sort_by(fn {hand, _bid} -> hand_strength(hand, opts) end)
  |> Enum.with_index(1)
  |> Enum.map(fn {{_hand, bid}, rank} -> bid * rank end)
  |> Enum.sum()
end

def p2(hands) when is_list(hands), do: get_winnings(hands, joker_rule: true)

And that’s it, day 7 is solved :)

If you enjoyed this content, you can sponsor me on Github to produce more videos / educational blog posts.

And if you're looking for consulting services, feel free to contact me .