Advent of Code 2024 - Day 3

Ok I’m giving myself one resubmit for golfing.

defmodule Aoc2024.Day03 do
  def part1(file), do: sum(file, ~r/mul\((\d+),(\d+)\)/)
  def part2(file), do: sum(file, ~r/don't\(\)|do\(\)|mul\((\d+),(\d+)\)/)

  def sum(file, regex) do
    Regex.scan(regex, File.read!(file))
    |> Enum.reduce({0, true}, fn
      ["do" <> rest], {s, _} -> {s, rest == "()"}
      [_, a, b], {s, add?} -> {s + ((add? && elem(Code.eval_string("#{a}*#{b}"), 0)) || 0), add?}
    end)
    |> elem(0)
  end
end
#!/usr/bin/env elixir

# Aoc 2024. day 3.

# part 1
File.read!("../day03.txt")
|> then(fn s -> Regex.scan(~r/mul\((\d+),(\d+)\)/, s, capture: :all_but_first) end)
|> Enum.reduce(0, fn [op1, op2], sum -> sum+String.to_integer(op1)*String.to_integer(op2) end)
|> IO.inspect(label: "part 1")

# part 2
File.read!("../day03.txt")
|> then(fn s -> Regex.scan(~r/do\(\)|don't\(\)|mul\((\d+),(\d+)\)/, s) end)
|> Enum.reduce({0, true}, fn
  ["do()" | _], {sum, _} -> {sum, true}
  ["don't()" | _], {sum, _} -> {sum, false}
  [_ | [op1, op2]], {sum, true} -> {sum+String.to_integer(op1)*String.to_integer(op2), true}
  [_ | [_op1, _op2]], {sum, false} -> {sum, false}
end)
|> tap(fn {sum, _} -> IO.puts("part 2: #{sum}") end)

part 1

defmodule Solver1 do
  def parse(line) do
    parse(line, [])
  end

  for i <- [8, 16, 24], j <- [8, 16, 24] do
    defp parse(
           <<"mul("::binary, d1::integer-size(unquote(i)), ","::binary,
             d2::integer-size(unquote(j)), ")"::binary, rest::binary>>,
           state
         ) do
      new_value =
        String.to_integer(:binary.encode_unsigned(d1)) *
          String.to_integer(:binary.encode_unsigned(d2))
      parse(rest, [new_value | state])
    end
  end

  ## end 
  defp parse(<<_e::binary-size(1), rest::binary>>, state) do
    parse(rest, state)
  end

  defp parse(<<>>, [:dont, tail ]), do:  Enum.sum(tail)
  defp parse(<<>>, state), do:  Enum.sum(state)
end

Part 2

defmodule Solver2 do
  def parse(line) do
    parse(line, [])
  end

  defp parse(<<"don't()"::binary, rest::binary>>, [:dont | _] = state) do
    parse(rest, state)
  end

  defp parse(<<"don't()"::binary, rest::binary>>, state) do
    state = [:dont | state]
    parse(rest, state)
  end

  defp parse(<<"do()"::binary, rest::binary>>, [:dont | tail ] = _state) do
    state =  tail
    parse(rest, state)
  end

  defp parse(<<_e::binary-size(1), rest::binary>>, [:dont | _tail] = state) do
    parse(rest, state)
  end

  for i <- [8, 16, 24], j <- [8, 16, 24] do
    defp parse(
           <<"mul("::binary, d1::integer-size(unquote(i)), ","::binary,
             d2::integer-size(unquote(j)), ")"::binary, rest::binary>>,
           state
         ) do
      new_value =
        String.to_integer(:binary.encode_unsigned(d1)) *
          String.to_integer(:binary.encode_unsigned(d2))
      parse(rest, [new_value | state])
    end
  end

  ## end 
  defp parse(<<_e::binary-size(1), rest::binary>>, state) do
    parse(rest, state)
  end

  defp parse(<<>>, [:dont, tail ]), do:  Enum.sum(tail)
  defp parse(<<>>, state), do:  Enum.sum(state)
end

Here’s mine for the day, had to really un-archive my Regex to get this to work.

defmodule Aoc2024.Solutions.Y24.Day03 do
  alias AoC.Input

  def parse(input, _part) do
    Input.read!(input)
  end

  def part_one(problem) do
    Regex.scan(~r/mul\((\d{1,3}),(\d{1,3})\)/, problem)
    |> Enum.map(fn [_, a, b] ->
      String.to_integer(a) * String.to_integer(b)
    end)
    |> Enum.sum()
  end

  def part_two(problem) do
    problem = "do()" <> String.replace(problem, "\n", "") <> "don't()"

    Regex.scan(~r/do\(\).*?don't\(\)/, problem)
    |> Enum.flat_map(fn input ->
      input |> Enum.map(&part_one/1)
    end)
    |> Enum.sum()
  end
end

Oh, I forgot about replace/3, it’s better than what I used this time: tag/3.

I really dont like regex so I used nimble parsec (for the first time):

defmodule Advent2024.Day03 do
  @test_data1 "xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5))"
  @test_data2 "xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))"

  defmodule Parser do
    import NimbleParsec

    defcombinator :int, integer(min: 1, max: 3)

    defcombinator :do, string("do()") |> replace(:do)
    defcombinator :dont, string("don't()") |> replace(:dont)

    defparsec :mul,
              ignore(string("mul("))
              |> parsec(:int)
              |> ignore(string(","))
              |> parsec(:int)
              |> ignore(string(")"))
              |> reduce(:collect_args)

    defp collect_args([a, b]), do: {:mul, a, b}

    defparsec :eval,
              choice([parsec(:do), parsec(:mul), parsec(:dont)]) |> eventually() |> repeat()
  end

  alias Advent2024.Day03.Parser

  defp parse_line(input) do
    {:ok, result, _, _, _, _} = Parser.eval(input)
    result
  end

  defp parse_input(input) do
    input
    |> Enum.take_while(&Kernel.!=(&1, ""))
    |> Enum.flat_map(&parse_line/1)
  end

  def part1(input) do
    input
    |> parse_input()
    |> Enum.map(fn
      {:mul, a, b} -> a * b
      _ -> 0
    end)
    |> Enum.sum()
  end

  defp handle_instructions(:do, {acc, _}), do: {acc, true}
  defp handle_instructions(:dont, {acc, _}), do: {acc, false}
  defp handle_instructions({:mul, a, b}, {acc, true}), do: {acc + a * b, true}
  defp handle_instructions(_, {acc, false}), do: {acc, false}

  def part2(input) do
    input
    |> parse_input()
    |> Enum.reduce({0, true}, &handle_instructions/2)
    |> elem(0)
  end

  def test_input1() do
    [@test_data1]
  end

  def test_input2() do
    [@test_data2]
  end

  def input() do
    File.stream!("data/data_03.txt")
  end
end

How did you find NimbleParsec? Tempted to try and get my head around it for aoc this year, I always end up stitching myself up with a silly mistake with Regex at some point.

It was okay. In the past I use a Rust NIF that used the nom library, which had the added complexity of ruslter + a rust toolchain, but nom is a much better parser library. I still think nimble_parsec is more readable than regex, but it’s api isn’t exactly ergonomic.

Part 1

r = ~r/mul\(([0-9]+?),([0-9]+?)\)/

for [n0, n1] <- Regex.scan(r, puzzle_input, capture: :all_but_first),
  n0 = String.to_integer(n0),
  n1 = String.to_integer(n1),
  reduce: 0 do
  acc -> acc + n0 * n1
end

Part 2

r = ~r/do\(\)|don't\(\)|mul\(([0-9]+?),([0-9]+?)\)/

for match <- Regex.scan(r, puzzle_input), reduce: {0, true} do
  {acc, conditional} ->
    case match do
      ["do()"] ->
        {acc, true}

      ["don't()"] ->
        {acc, false}

      [_, n0, n1] ->
        if conditional do
          n0 = String.to_integer(n0)
          n1 = String.to_integer(n1)
          {acc + n0 * n1, conditional}
        else
          {acc, conditional}
        end
    end
end

Has anyone carried out a bench to see how performs regex vs binary solutions? I’m curious to see the results.

My first guess would be regex being faster because OTP’s re module uses NIFs under the hood but I may be wrong.

I only have Regex timings, but mine are:

Solution for 2024 day 3
part_one: 162813399 in 2.63ms
part_two: 53783319 in 619µs

Here’s my solution for Day 3:

defmodule AdventOfCode.DayThree do
  def part_one(input) do
    input
    |> parse_mul_instructions()
    |> Enum.sum()
  end

  def part_two(input) do
    input
    |> parse_mul_and_do_instructions()
    |> Enum.reduce({:do, 0}, fn
      i, {_, acc} when is_atom(i) -> {i, acc}
      n, {:do, acc} -> {:do, n + acc}
      _, {:dont, acc} -> {:dont, acc}
    end)
    |> elem(1)
  end

  defp parse_mul_instructions(input) do
    regex = ~r/(mul)\((\d{1,3}),(\d{1,3})\)/

    regex
    |> Regex.scan(input)
    |> Enum.map(fn [_, _, n1, n2] ->
      String.to_integer(n1) * String.to_integer(n2)
    end)
  end

  defp parse_mul_and_do_instructions(input) do
    regex = ~r/(mul\((\d{1,3}),(\d{1,3})\)|do\(\)|don'?t\(\))/

    regex
    |> Regex.scan(input)
    |> Enum.map(fn
      ["do()", _] -> :do
      ["don't()", _] -> :dont
      [_, _, n1, n2] -> String.to_integer(n1) * String.to_integer(n2)
    end)
  end
end

I hardly ever use regex and haven’t written much Elixir recently, but this one went pretty quick once I figured out the regex bits. I also feel like Enum.map() is the only function I know for some reason. :slight_smile:

#Part 1
defmodule Day03 do
  def sum_input(input) do
    Regex.scan(~r/mul\(\d+,\d+\)/, input)
    |> Enum.map(&(Regex.scan(~r/\d+,\d+/, List.first(&1)) |> List.first() |> List.first()))
    |> Enum.map(&String.replace(&1, ",", "*"))
    |> Enum.map(fn s -> Code.eval_string(s) |> elem(0) end)
    |> Enum.sum()
  end
end

Day03.sum_input(input)


#Part 2
input = "do()" <> input <> "don't()"

Regex.scan(~r/do\(\)(.*?)don\'t\(\)/s, input)
|> Enum.map(&Day03.sum_input(List.first(&1)))
|> Enum.sum()

Day 3 we wrote another hand written parser becaus regexes suck. It uses the match context which you can tell by compiling with the flag:

# ERL_COMPILER_OPTIONS=bin_opt_info mix compile --force

It looks more verbose because of this, but essentially we keep an index of where in the string we are and use that to then extract data.

Part 1

  def day_3_1() do
    "./day_3_input.txt"
    |> File.read!()
    |> parse(1, [], 0)
  end

  def parse(<<>>, _, _, total), do: total

  @mul_start "mul("
  @comma ","
  @mul_end ")"
  # If we see a mul start but the stack is not empty then it can't be valid.
  def parse(<<@mul_start, rest::binary>>, current_index, [], total) do
    new_current_index = current_index + 3
    end_index = parse_number(rest, new_current_index)

    if end_index == new_current_index do
      parse(rest, end_index, [], total)
    else
      <<number::binary-size(end_index - new_current_index), rest::binary>> = rest
      first_int = String.to_integer(number)
      parse(rest, end_index, [first_int], total)
    end
  end

  def parse(<<@comma, rest::binary>>, current_index, [first_int], total) do
    new_current_index = current_index + 1
    end_index = parse_number(rest, new_current_index)

    if end_index == new_current_index do
      parse(rest, current_index, [], total)
    else
      <<number::binary-size(end_index - new_current_index), rest::binary>> = rest
      second_int = String.to_integer(number)
      parse(rest, end_index, [second_int, first_int], total)
    end
  end

  def parse(<<@mul_end, rest::binary>>, current_index, [first, second], total) do
    parse(rest, current_index + 1, [], first * second + total)
  end

  # We reset the stack if we had started a mult that never happened.
  def parse(<<_::binary-size(1), rest::binary>>, index, _stack, total) do
    parse(rest, index + 1, [], total)
  end

  @all_digits ~c"0123456789"
  for digit <- @all_digits do
    def parse_number(<<unquote(digit), rest::bits>>, end_index) do
      parse_number(rest, end_index + 1)
    end
  end

  def parse_number(_rest, end_index), do: end_index

Part 2

Part two just adds a couple more functions to the mix, but repeated myself so part 1 still works on its own.

  def day_3_2() do
    "./day_3_input.txt"
    |> File.read!()
    |> parse_instructions(1, [], 0)
  end

  def parse_instructions(<<>>, _, _, total), do: total

  @doo "do()"
  @dont "don't()"
  def parse_instructions(<<@dont, rest::binary>>, current_index, _, total) do
    parse_instructions(rest, current_index + 6, [:dont], total)
  end

  def parse_instructions(<<@doo, rest::binary>>, current_index, [:dont], total) do
    parse_instructions(rest, current_index + 3, [], total)
  end

  # Now if the stack has :dont in it we skip.
  def parse_instructions(<<_::binary-size(1), rest::binary>>, current_index, [:dont], total) do
    parse_instructions(rest, current_index + 1, [:dont], total)
  end

  def parse_instructions(<<@mul_start, rest::binary>>, current_index, [], total) do
    new_current_index = current_index + 3
    end_index = parse_number(rest, new_current_index)

    if end_index == new_current_index do
      parse_instructions(rest, end_index, [], total)
    else
      <<number::binary-size(end_index - new_current_index), rest::binary>> = rest
      first_int = String.to_integer(number)
      parse_instructions(rest, end_index, [first_int], total)
    end
  end

  def parse_instructions(<<@comma, rest::binary>>, current_index, [first_int], total) do
    new_current_index = current_index + 1
    end_index = parse_number(rest, new_current_index)

    if end_index == new_current_index do
      parse_instructions(rest, current_index, [], total)
    else
      <<number::binary-size(end_index - new_current_index), rest::binary>> = rest
      second_int = String.to_integer(number)
      parse_instructions(rest, end_index, [second_int, first_int], total)
    end
  end

  def parse_instructions(<<@mul_end, rest::binary>>, current_index, [first, second], total) do
    parse_instructions(rest, current_index + 1, [], first * second + total)
  end

  def parse_instructions(<<_::binary-size(1), rest::binary>>, index, _stack, total) do
    parse_instructions(rest, index + 1, [], total)
  end

You can make binary pattern matching much faster than regex. The latest json parser in otp doesn’t use regex and it’s rapid. I compared my part 1 to this:

  def regex() do
    problem =
      "./day_3_input.txt"
      |> File.read!()
      |> String.trim()

    ~r/mul\(([0-9]{1,3}),([0-9]{1,3})\)/
    |> Regex.scan(problem)
    |> Enum.reduce(0, fn [_, a, b], acc ->
      String.to_integer(a) * String.to_integer(b) + acc
    end)
  end

And the results are:

Operating System: macOS
CPU Information: Apple M1 Pro
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.15.2
Erlang 25.3
JIT enabled: false

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 10 s
memory time: 2 s
reduction time: 2 s
parallel: 1
inputs: none specified
Estimated total run time: 32 s

Name             ips        average  deviation         median         99th %
Binary        2.54 K      394.40 μs   ±131.81%      369.29 μs      574.74 μs
Regex         1.88 K      531.98 μs    ±81.77%      505.54 μs      711.30 μs

Comparison:
Binary        2.54 K
Regex         1.88 K - 1.35x slower +137.58 μs

Memory usage statistics:

Name      Memory usage
Binary        57.45 KB
Regex        333.88 KB - 5.81x memory usage +276.43 KB

**All measurements for memory usage were the same**

Reduction count statistics:

Name   Reduction count
Binary         21.11 K
Regex          18.04 K - 0.85x reduction count -3.07300 K

**All measurements for reduction count were the same**

Hello. I’m new and trying to learn. This was my first time writing Elixir, so I don’t know if this is how it’s supposed to be done. Be kind :slight_smile:

For part 2 I decided to just remove the irrelevant parts of the input.

defmodule Three do
  def get_input do
    File.read!("./input.txt")
  end

  def extract_operations(input) do
    Regex.scan(~r/mul\((\d{1,3}),(\d{1,3})\)/, input)
    |> Enum.map(fn [_op, num1, num2] ->
      num1 = String.to_integer(num1)
      num2 = String.to_integer(num2)
      [num1 * num2]
    end)
  end

  def sum_products(ops) do
    List.flatten(ops)
    |> Enum.filter(fn x -> is_integer(x) end)
    |> Enum.sum()
  end

  def part1 do
    extract_operations(get_input())
    |> sum_products()
  end

  def part2 do
    String.split(get_input(), ~r/don\'t\(\)[\s\S]*?do\(\)/)
    |> Enum.map(&extract_operations/1)
    |> sum_products()
  end
end

IO.puts("part 1: #{Three.part1()}")
IO.puts("part 2: #{Three.part2()}")

Pretty new to Elixir and using AOC to practice. Got the day 3 solution working with regexs to avoid having to track state.

defmodule Aoc2024.Day3 do
  @moduledoc false

  defp get_input(file) do
    File.read!(file)
  end

  defp parse_muls(data) do
    Regex.scan(~r/mul\((?<a>\d{1,3}),(?<b>\d{1,3})\)/, data)
  end

  defp mul([_, a, b]) do
    String.to_integer(a) * String.to_integer(b)
  end

  defp disable_instructions(data) do
    data
    |> String.replace("\n", ".")
    |> String.replace(~r/don't\(\).*do\(\)/U, ".")
    |> String.replace(~r/don't\(\).*\z/, ".")
  end

  def part1(file) do
    get_input(file)
    |> parse_muls()
    |> Enum.map(&mul/1)
    |> Enum.sum()
  end

  def part2(file) do
    get_input(file)
    |> disable_instructions()
    |> parse_muls()
    |> Enum.map(&mul/1)
    |> Enum.sum()
  end
end

AOC is a great way to learn Elixir and get really familiar with the standard library! Nice work :raising_hands: