Advent of Code 2023 - Day 2

Thought I’d kick today’s thread off!

Parsing

Enum rocks, so most of my code was actually in parsing input.

Preprocessing input

Data model is:

%{
  id :: integer() => [
    pull :: %{
      red: integer(),
      green: integer(),
      blue: integer()
    }
  ]
}

I modeled each pull as a struct rather than a bare map, just so I didn’t have to write any extra code to hydrate my maps with default 0 values where input was empty for a color.

Source available here.

defmodule AoC.Day.Two.Input do
  defmodule Pull do
    defstruct red: 0, green: 0, blue: 0
  end

  def parse(input_file \\ System.fetch_env!("INPUT_FILE")) do
    input_file
    |> File.read!()
    |> String.split("\n")
    |> Enum.reject(&(&1 == ""))
    |> Enum.map(&parse_game/1)
    |> Map.new()
  end

  def parse_game("Game " <> game) do
    {id, rest} = Integer.parse(game)
    <<": ">> <> pulls = rest

    pulls =
      pulls
      |> String.trim()
      |> String.split(";")
      |> Enum.map(&String.trim/1)
      |> Enum.map(&parse_pull/1)

    {id, pulls}
  end

  def parse_pull(pull) do
    result =
      pull
      |> String.split(",")
      |> Enum.map(&String.trim/1)
      |> Enum.map(&parse_pull_color/1)

    struct!(Pull, result)
  end

  def parse_pull_color(result) do
    case Integer.parse(result) do
      {num, " red"} -> {:red, num}
      {num, " green"} -> {:green, num}
      {num, " blue"} -> {:blue, num}
    end
  end
end

Part 1

Solution

input |> Enum.filter(Enum.all?) |> Enum.map |> Enum.sum. Source available here.

defmodule AoC.Day.Two.Part.One do
  @red_limit 12
  @green_limit 13
  @blue_limit 14

  def solve(input) do
    input
    |> Enum.filter(fn {_id, pulls} ->
      Enum.all?(pulls, fn
        %{red: red, green: green, blue: blue}
        when red <= @red_limit and green <= @green_limit and blue <= @blue_limit ->
          true

        _ ->
          false
      end)
    end)
    |> Enum.map(fn {id, _} -> id end)
    |> Enum.sum()
  end
end

Part 2

Solution

Simpler still. Could have avoided iterating over pulls 3 times, but not too fussed about it. Source available here.

defmodule AoC.Day.Two.Part.Two do
  def solve(input) do
    input
    |> Enum.map(fn {id, pulls} ->
      min_red = pulls |> Enum.map(&Map.fetch!(&1, :red)) |> Enum.max()
      min_green = pulls |> Enum.map(&Map.fetch!(&1, :green)) |> Enum.max()
      min_blue = pulls |> Enum.map(&Map.fetch!(&1, :blue)) |> Enum.max()

      {id, min_red * min_green * min_blue}
    end)
    |> Enum.map(fn {_id, power} -> power end)
    |> Enum.sum()
  end
end
3 Likes

After seeing part 2, we know that both parts only need the aggregate of all the rounds, so for each game I used a single map representing the maximum number of cubes seen.

|> Enum.reduce(
  %{"red" => 0, "green" => 0, "blue" => 0},
  &Map.merge(&1, &2, fn _colour, count1, count2 -> max(count1, count2) end
)
4 Likes

That Map.merge/3 is a very elegant way to get all maxes in one iteration, I wouldn’t’ve thought of it!

2 Likes

I was thinking maybe this puzzle could be solved using nimble_parsec, but when I tried, I just got lost because I couldn’t understand the documentation and the examples :joy:

Maybe I should just go for yecc.

I struggle with the docs for both every time I have to pick them up :sweat:. Would love to see that incarnation if you get around to it!

I have to attend my sister’s wedding ceremony now. Maybe later.

1 Like

I was able to knock this one out much quicker than yesterday’s

red_limit = 12
green_limit = 13
blue_limit = 14

parse_rounds = fn s ->
  s
  |> String.split(";")
  |> Enum.map(
    &Enum.map(
      String.split(&1, ","),
      fn x ->
        [_, count, color] = Regex.run(~r/(\d+) (.+)/, String.trim(x))
        {count, _} = count |> Integer.parse()
        color = color |> String.to_atom()

        {color, count}
      end
    )
  )
end

all_games =
  for line <- IO.stream(),
      line |> String.trim() |> String.length() > 0,
      [_, id, rounds] = Regex.run(~r/Game (\d+): (.+)/, line) do
    {id |> Integer.parse() |> elem(0), parse_rounds.(rounds)}
  end

part1 = fn games ->
  games
  |> Enum.filter(fn {_, games} ->
    Enum.all?(games, fn game ->
      game |> Keyword.get(:green, 0) <= green_limit &&
        game |> Keyword.get(:red, 0) <= red_limit &&
        game |> Keyword.get(:blue, 0) <= blue_limit
    end)
  end)
  |> Enum.map(&elem(&1, 0))
  |> Enum.sum()
end

part2 = fn games ->
  games
  |> Enum.map(
    &(elem(&1, 1)
      |> Enum.reduce(
        {0, 0, 0},
        fn round, {red, green, blue} ->
          {
            max(red, Keyword.get(round, :red, 0)),
            max(green, Keyword.get(round, :green, 0)),
            max(blue, Keyword.get(round, :blue, 0))
          }
        end
      ))
  )
  |> Enum.map(fn {x, y, z} -> x * y * z end)
  |> Enum.sum()
end

Edit: really impressed with the brevity from some of the solutions I’ve seen for the last two days. Great stuff all.

2 Likes

Did a little more work than I needed to in part 1, but it made the modifications for part 2 pretty easy! This was a nice day 2 problem :slight_smile:

Part 1
defmodule Part1 do
  def answer(text, %{} = bag) do
    text
    |> String.trim_trailing()
    |> String.splitter("\n")
    |> Enum.map(fn line ->
      Regex.scan(~r/(\d+) (\w+)/, line)
      |> Enum.map(fn [_, n, color] ->
        {String.to_integer(n), color}
      end)
      |> Enum.reduce(%{}, fn {n, color}, acc ->
        Map.put(acc, color, max(n, Map.get(acc, color, 0)))
      end)
    end)
    |> Enum.with_index(1)
    |> Enum.filter(fn {set, _} ->
      Enum.all?(set, fn {key, value} -> value <= bag[key] end)
    end)
    |> Enum.map(&elem(&1, 1))
    |> Enum.sum()
  end
end

bag = %{"red" => 12, "green" => 13, "blue" => 14}
Part1.answer(input, bag)strong text
Part 2
defmodule Part2 do
  def answer(text) do
    text
    |> String.trim_trailing()
    |> String.splitter("\n")
    |> Enum.map(fn line ->
      Regex.scan(~r/(\d+) (\w+)/, line)
      |> Enum.map(fn [_, n, color] ->
        {String.to_integer(n), color}
      end)
      |> Enum.reduce(%{}, fn {n, color}, acc ->
        Map.put(acc, color, max(n, Map.get(acc, color, 0)))
      end)
      |> Map.values()
      |> Enum.product()
    end)
    |> Enum.sum()
  end
end

Part2.answer(input)
2 Likes

Quite straightforward with Regex.scan and Enum.group_by:

1 Like

I also elected in part 1 to build a map that kept the maxima for each color for each game. Which meant that part 2 was really trivial. This kind of “one off” multi-level parsing is a bit tricky to produce easily readable code and @christhekeele definitely did better than I did.

Pre-processing
  def parse_input() do
    @input
    |> String.split("\n", trim: true)
    |> Enum.map(fn
      <<"Game ", game::binary-3, ": ", pulls::binary>> ->
        %{game: String.to_integer(game), max: extract_game_max(pulls)}
      <<"Game ", game::binary-2, ": ", pulls::binary>> ->
        %{game: String.to_integer(game), max: extract_game_max(pulls)}
      <<"Game ", game::binary-1, ": ", pulls::binary>> ->
        %{game: String.to_integer(game), max: extract_game_max(pulls)}
    end)
  end

  @default_pull_max %{red: 0, green: 0, blue: 0}

  def extract_game_max(pulls) do
    pulls
    |> String.split("; ", trim: true)
    |> Enum.reduce(@default_pull_max, &extract_pull_max/2)
  end

  def extract_pull_max(pull, acc) do
    pull
    |> String.split(", ")
    |> Enum.reduce(acc, fn c, acc ->
      [int, color] = String.split(c, " ")
      color = String.to_atom(color)
      int = String.to_integer(int)

      Map.put(acc, color, max(int, Map.fetch!(acc, color)))
    end)
  end
Part 1

With the maxima already stored it was straight forward to calculate the games. I tend to reach for Enum/reduce/3 in these cases more than most (it seems).

  def matching_games(games, search) do
    Enum.reduce games, 0, fn %{game: game, max: max}, acc ->
      if search.red >= max.red && search.blue >= max.blue && search.green >= max.green, do: acc + game, else: acc
    end
  end
Part 2 This is trivial since the data is already in the right format. Again I find `Enum.reduce` super handy because it already accumulates.
  def part_2 do
    parse_input()
    |> Enum.reduce(0, fn %{max: %{red: red, green: green, blue: blue}}, acc ->
      acc + (red * green * blue)
    end)
  end
3 Likes

Very elegant, I miss a lot of training with pattern matching.
I did not know one could pattern match on the string directly. That must me a good advantage comparing to other programming langages

I see your code using regexes and it is more concise for this kind of parsing. When there are well defined separators I automatically go for String.split which is more verbose.

Anyway this was easier than day one :smiley:

defmodule AdventOfCode.Y23.Day2 do
  alias AoC.Input, warn: false

  def read_file(file, _part) do
    Input.stream!(file, trim: true)
  end

  def parse_input(input, _) do
    Enum.map(input, &parse_game/1)
  end

  defp parse_game("Game " <> game) do
    {id, ": " <> rest} = Integer.parse(game)
    hands = rest |> String.split("; ") |> Enum.map(&parse_hand/1)
    {id, hands}
  end

  defp parse_hand(txt) do
    txt
    |> String.split(", ")
    |> Enum.map(&Integer.parse/1)
    |> Enum.reduce({0, 0, 0}, fn
      {n, " red"}, {r, g, b} -> {r + n, g, b}
      {n, " green"}, {r, g, b} -> {r, g + n, b}
      {n, " blue"}, {r, g, b} -> {r, g, b + n}
    end)
  end

  def part_one(problem) do
    problem
    |> Enum.filter(fn {_id, hands} -> Enum.all?(hands, &lte?(&1, {12, 13, 14})) end)
    |> Enum.reduce(0, fn {id, _}, acc -> acc + id end)
  end

  def part_two(problem) do
    problem
    |> Enum.map(&power/1)
    |> Enum.reduce(&(&1 + &2))
  end

  defp lte?({r, g, b}, {max_r, max_g, max_b}) do
    r <= max_r and g <= max_g and b <= max_b
  end

  defp power({_id, hands}) do
    {r, g, b} =
      Enum.reduce(hands, fn {r, g, b}, {min_r, min_g, min_b} ->
        {max(min_r, r), max(min_g, g), max(min_b, b)}
      end)

    r * g * b
  end
end

1 Like

I spent most of the time on the parser, which I implemented using NimbleParsec. I’ve used NimbleParsec before, but it has never became second nature for me, so I still have to refer to the documentation a lot.

This time I learned the hard way that nesting a repeat inside a repeat leads to an infinite loop, such as in this code from my parser:

  cubes = repeat(cube)
  |> optional(ignore(string("; ")))
  |> wrap

  subsets = repeat(cubes)
  |> wrap

The problem is that the inner repeat will always succeed (by repeating zero times), and therefore the outer repeat will repeat the inner repeat forever.

I solved it by replacing the inner repeat with times with a minimum of one:

  cubes = times(cube, min: 1)
  |> optional(ignore(string("; ")))
  |> wrap

  subsets = repeat(cubes)
  |> wrap

Solution

3 Likes

Just wanted to say I love the use of Integer.parse/1 here!

4 Likes

Again, nothing notable in my code. I wish I took this opportunity to learn Nimble Parsec though, but Elixir syntax is always a joy to parse.

advent_of_code/lib/2023/day_02.ex at master · code-shoily/advent_of_code (github.com)

I like readable code.

In case any of y’all are interested, we are having a ticket giveaway for the 2024 Carolina Code Conference for participants in AoC.

22 tickets with a lot of different ways to win.

Details here:

1 Like

My beginner’s solution

defmodule Day02 do

  def part2(input) do
    input
    |> String.split("\n")
    |> Enum.map(&String.split(&1, ":"))
    |> Enum.map(&List.last/1)
    |> Enum.map(&power_of_set/1)
    |> Enum.sum
  end

  def part1(input) do
    input
    |> String.split("\n")
    |> Enum.filter(&is_game_possible?/1)
    |> Enum.map(&String.trim_leading(&1,"Game "))
    |> Enum.map(&String.split(&1, ":"))
    |> Enum.map(&List.first/1)
    |> Enum.map(&String.to_integer/1)
    |> Enum.sum
  end

  def is_game_possible?(line) do
    line #Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
    |> String.split(":")
    |> List.last
    |> String.split(";")
    |> Enum.map(&to_color_map/1) 
    |> Enum.map(&is_set_possible?/1)
    |> Enum.all?
  end

  def to_color_map(cube_set) do
    cube_set # 3 blue, 4 red
    |> String.split(",")
    |> Enum.reduce( %{red: 0, green: 0, blue: 0}, 
        fn str, acc ->
   
          amount = str 
          |> String.split
          |> List.first
          |> String.to_integer
    
          color = str
          |> String.split
          |> List.last
          |> String.to_atom    
  
          acc
          |> Map.put(color, amount) 

        end)
  end

  def is_set_possible?(map_of_cube_set, 
          limit \\  %{red: 12, green: 13, blue: 14}  ) do
    [ map_of_cube_set.red <= limit.red,
      map_of_cube_set.green <= limit.green,
      map_of_cube_set.blue <= limit.blue   ]
    |> Enum.all? # true if all elements are truthy
  end

  def power_of_set(game) do
    game # 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
    |> String.split(";")
    |> Enum.map(&to_color_map/1)
    |> Enum.reduce(fn color_map, acc -> 
         %{red:   max(color_map.red, acc.red),
           green: max(color_map.green, acc.green),
           blue:  max(color_map.blue, acc.blue) }
          end )
    |> Map.values
    |> Enum.reduce(&Kernel.*/2) # multiplies values
  end
  
end

1 Like

Not many defstruct-based solutions yet, so here’s one:

defmodule Day2Part1 do
  defmodule Game do
    defstruct [:number, :shown, :max_shown]

    def parse(n, shown_str) do
      %Game{
        number: String.to_integer(n),
        shown: parse_shown(String.split(shown_str, ~r{;\s+}))
      }
    end

    defp parse_shown([]), do: []
    defp parse_shown([s|rest]) do
      [parse_one(s) | parse_shown(rest)]
    end

    defp parse_one(s) do
      matches = Regex.scan(~r{(\d+)\s+(\w+)}, s, capture: :all_but_first)

      Map.new(matches, fn [cs, color] ->
        {color, String.to_integer(cs)}
      end)
    end

    def max_shown(game) do
      Enum.reduce(game.shown, %{}, fn el, acc ->
        Map.merge(acc, el, fn _, v1, v2 -> max(v1, v2) end)
      end)
    end

    def fill_max_shown(game) do
      %{game | max_shown: max_shown(game)}
    end

    def valid?(game, target) do
      all_keys = Map.keys(game.max_shown) ++ Map.keys(target)

      Enum.all?(all_keys, fn k -> game.max_shown[k] <= target[k] end)
    end
  end

  def read(filename) do
    File.stream!(filename)
    |> Stream.map(&String.trim/1)
    |> Stream.map(&Regex.run(~r{^Game (\d+):\s+(.*)$}, &1, capture: :all_but_first))
    |> Stream.map(fn [n, shown] -> Game.parse(n, shown) end)
  end
end

target_cubes = %{"red" => 12, "green" => 13, "blue" => 14}

Day2Part1.read("input.txt")
|> Stream.map(&Day2Part1.Game.fill_max_shown/1)
|> Stream.filter(&Day2Part1.Game.valid?(&1, target_cubes))
|> Stream.map(& &1.number)
|> Enum.sum()
|> IO.inspect()

Some thoughts:

  • parse_shown is recursive for no particular reason; it could equally well be written as another Enum.map
  • fill_max_shown feels a little strange tacked on at the end, but I was pre-optimizing for a part 2 that did something totally different (which was NOT what happened in part 2 :stuck_out_tongue: )
  • interesting factoid: the only place that the color names appear in the code is in target_cubes, the computations are independent of the specific keys

For part 2, only one more function on Game is needed:

    def power(game) do
      game.max_shown
      |> Map.values()
      |> Enum.reduce(1, &Kernel.*/2)
    end
1 Like

My essential “genius” part of code was this:

  def max_takes(takes) do
    Enum.reduce(takes, %{}, fn take, acc ->
      Enum.reduce(take, acc, fn {k, v}, acc ->
        Map.update(acc, k, v, fn prev_v ->
          if v > prev_v do
            v
          else
            prev_v
          end
        end)
      end)
    end)
  end

I already see ideas from other solutions on how it can be simplified.:slight_smile:
Full solution:
https://gitlab.com/mrsk/aoc-elixir/-/blob/main/lib/Aoc2023/D02.ex

Btw. Is that a little cute trebuchet in the bottom left corner of calendar?

  ----@
* ! /^\
2 Likes