Advent of Code 2023 - Day 1

Welcome to ElixirForum :slight_smile:

I handn’t considered hardcoding all the overlaps :smile:

A couple of functions that might be useful to simplify:

I used a regex trick that I just learned yesterday.

My code is messy so won’t share it. But I’m gonna share my trick.

Regex.scan(~r/(?=(\d+))/, "123456789", capture: :all_but_first)

returns

[
  ["123456789"],
  ["23456789"],
  ["3456789"],
  ["456789"],
  ["56789"],
  ["6789"],
  ["789"],
  ["89"],
  ["9"]
]

The key is the (?=) part and the () combined with the capture: :all_but_first option, not the \d+ part in the regex.

1 Like

I stand corrected (I was in fact thinking of Ruby)! Updated my comment and referenced yours, thanks for teaching me something today.

I’ve not done AoC before so this was a fun diversion for a Saturday morning.

For part 1 I had a simple for comprehension that I think is quite easy to understand:

  def extract_integer(text) do
    [first, last] =
      for <<char <- text>>, char in ?0..?9, reduce: [nil, nil] do
        [nil, nil] -> [char - ?0, char - ?0]
        [first, _] -> [first, char - ?0]
      end

    first * 10 + last
  end

But it runs in about 500 μs on my MacBook Pro (M1 Max) and uses about 1.6 MB of memory whereas my final solution just uses recursive functions and it finishes in less than 200 μs using less than 128 KB of memory.

Part 1
  def extract_integer_1b(<<>>, [first, last]) do
    first * 10 + last
  end

  def extract_integer_1b(<<char, rest::binary>>, [nil, nil]) when char in ?0..?9 do
    extract_integer_1b(rest, [char - ?0, char - ?0])
  end

  def extract_integer_1b(<<char, rest::binary>>, [first, _]) when char in ?0..?9 do
    extract_integer_1b(rest, [first, char - ?0])
  end

  def extract_integer_1b(<<_char, rest::binary>>, acc) do
    extract_integer_1b(rest, acc)
  end

The second part just builds on the first part. And, like others, it generates the function heads for the recursive functions from static data. This runs in about 300 μs.

Part 2
  @integer_map %{
    "zero" => 0,
    "one" => 1,
    "two" => 2,
    "three" => 3,
    "four" => 4,
    "five" => 5,
    "six" => 6,
    "seven" => 7,
    "eight" => 8,
    "nine" => 9
  }

  def extract_integer_2(<<>>, [first, last]) do
    first * 10 + last
  end

  for {text, int} <- @integer_map do
    last_char = String.last(text)

    def extract_integer_2(<<unquote(text) , rest::binary>>, [nil, nil]) do
      extract_integer_2(<<unquote(last_char), rest::binary>>, [unquote(int), unquote(int)])
    end

    def extract_integer_2(<<unquote(text) , rest::binary>>, [first, _]) do
      extract_integer_2(<<unquote(last_char), rest::binary>>, [first, unquote(int)])
    end
  end

  def extract_integer_2(<<char, rest::binary>>, [nil, nil]) when char in ?0..?9 do
    extract_integer_2(rest, [char - ?0, char - ?0])
  end

  def extract_integer_2(<<char, rest::binary>>, [first, _]) when char in ?0..?9 do
    extract_integer_2(rest, [first, char - ?0])
  end

  def extract_integer_2(<<_char, rest::binary>>, acc) do
    extract_integer_2(rest, acc)
  end
2 Likes

Wow some solutions here are cool.

I used a simple regular expressions based approach

My solution does have to parse all numbers instead of just the first and last, and does not support “oNe” @al2o3cr :smiley: but it’s still fast.

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

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

  def parse_input(input, _part) do
    input
  end

  def part_one(problem) do
    problem
    |> Enum.map(&String.to_charlist/1)
    |> Enum.map(&solve_line_p1/1)
    |> Enum.reduce(&Kernel.+/2)
  end

  defp solve_line_p1(chars) do
    digits = Enum.filter(chars, &(&1 in ?0..?9))
    {first, last} = first_last(digits)
    List.to_integer([first, last])
  end

  defp first_last(items) do
    [h | _] = items
    [g | _] = :lists.reverse(items)
    {h, g}
  end

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

  defp solve_line_p2(line) do
    line
    |> collect_digits([])
    |> first_last()
    |> then(fn {a, b} -> a * 10 + b end)
  end

  defp collect_digits(<<c, rest::binary>>, acc) when c in ?0..?9,
    do: collect_digits(rest, [c - ?0 | acc])

  ~w(one two three four five six seven eight nine)
  |> Enum.with_index(1)
  |> Enum.each(fn {<<c, keep::binary>>, digit} ->
    defp collect_digits(<<unquote(c), unquote(keep)::binary, rest::binary>>, acc) do
      collect_digits(<<unquote(keep)::binary, rest::binary>>, [unquote(digit) | acc])
    end
  end)

  defp collect_digits(<<_, rest::binary>>, acc), do: collect_digits(rest, acc)
  defp collect_digits(<<>>, acc), do: :lists.reverse(acc)
end

You can also use a capture in an positive lookahead to write the whole thing as a one-liner using Perl:

perl -pe 's/^[^\d]*(?=(\d)).*(\d)[^\d]*$/\1\2\n/' input.txt | paste -s -d+ - | bc

The lookahead is critical because otherwise inputs like abc4def won’t find the 4 twice.

1 Like

Here the my solution that uses macro to generate functions

3 Likes

My beginner’s solution

defmodule Day01 do

  def part2(str) do
    str
    |> String.split
    |> Enum.map(&spelled_out_words_to_digits/1)
    |> Enum.map(&recover_two_digit_number/1)
    |> Enum.sum  
  end
  
  def part1(str) do
    str
    |> String.split
    |> Enum.map(&recover_two_digit_number/1)
    |> Enum.sum  
  end

  def recover_two_digit_number(str) do
    digits = str
      |> String.graphemes
      |> Enum.filter(&is_digit?/1) 

    List.first(digits) <> List.last(digits)
    |> String.to_integer
    
  end

  def is_digit?(s) do
    s |> String.contains?(["0", "1", "2", "3", 
                  "4", "5", "6", "7", "8", "9"])
  end 

  def spelled_out_words_to_digits(str) do
    str
    |> String.replace("one", "one1one")
    |> String.replace("two", "two2two")
    |> String.replace("three", "three3three")
    |> String.replace("four", "four4four")
    |> String.replace("five", "five5five")
    |> String.replace("six", "six6six")
    |> String.replace("seven", "seven7seven")
    |> String.replace("eight", "eight8eight")
    |> String.replace("nine", "nine9nine")
    
  end
end
1 Like

I am using Livebook for my solution.

Part 1
Kino.FS.file_path("01.txt")
|> File.stream!()
|> Stream.map(fn line ->
  matches =
    Regex.scan(~r/\d/, line)
    |> List.flatten()

  first = List.first(matches)
  last = List.last(matches)

  String.to_integer("#{first}#{last}")
end)
|> Enum.sum()
Part 2
pattern = ~r/(?=(\d|one|two|three|four|five|six|seven|eight|nine))/

Kino.FS.file_path("01.txt")
|> File.stream!()
|> Stream.map(fn line ->
  matches =
    Regex.scan(pattern, line, capture: :all_but_first)
    |> List.flatten()
    |> Enum.map(fn
      "one" -> "1"
      "two" -> "2"
      "three" -> "3"
      "four" -> "4"
      "five" -> "5"
      "six" -> "6"
      "seven" -> "7"
      "eight" -> "8"
      "nine" -> "9"
      input -> input
    end)

  first = List.first(matches)
  last = List.last(matches)

  String.to_integer(first <> last)
end)
|> Enum.sum()

You can also find the code on GitHub.

Really was not prepared for the overlapping text integers. In fact, based on my example input I assumed that was explicitly NOT supposed to be valid. Does not bode well for my performance long term this year, LOL.

My focus was on using a single pass over the string, i.e., avoiding one pass forward and another pass over the reversed string. Wish I had known about the :global flag for String.replace as I think that would have been cleaner than the regex solution I’m using.


    @digit_strings %{
      "one" => "1",
      "two" => "2",
      "three" => "3",
      "four" => "4",
      "five" => "5",
      "six" => "6",
      "seven" => "7",
      "eight" => "8",
      "nine" => "9"
    }

    @regex ("(?=(" <> (@digit_strings |> Map.keys() |> Enum.join("|") |> Kernel.<>("))")))
           |> Regex.compile()
           |> elem(1)


    def parse(input) do
      input
      |> Day1.input()
      |> Input.lines()
      |> Enum.map(fn line ->
        Regex.replace(@regex, line, fn _, x -> Map.get(@digit_strings, x) end)
      end)
  end

def do_solve(data) do
      data
      |> Enum.map(fn line ->
        first_and_last_ints(line)
        |> String.to_integer()
      end)
      |> Enum.sum()
    end

    defp first_and_last_ints(line) when is_binary(line) do
      line
      |> String.graphemes()
      |> Enum.reduce({"", nil}, fn g, {s, last} ->
        with {i, ""} <- Integer.parse(g) do
          if s == "" do
            {s <> "#{i}", i}
          else
            {s, i}
          end
        else
          _ ->
            {s, last}
        end
      end)
      |> append_last()
    end

    defp append_last({s, i}), do: s <> "#{i}"
1 Like

For Part 2, I wrote a NimbleParsec parser

defmodule Replacer do
  import NimbleParsec

  replacements =
    for {<<head::binary-size(1), rest::binary>>, numeral} <-
          Enum.with_index(~w[one two three four five six seven eight nine], 1) do
      numeral = to_string(numeral)

      choice([
        string(numeral),
        string(head) |> lookahead(string(rest)) |> replace(numeral)
      ])
    end

  ignored = ignore(utf8_string([], 1))

  defparsec(:replace, repeat(choice(replacements ++ [ignored])))
end
2 Likes

But it runs in about 500 μs on my MacBook Pro (M1 Max) and uses about 1.6 MB of memory whereas my final solution just uses recursive functions and it finishes in less than 200 μs using less than 128 KB of memory.

Sorry, I am relatievely new to Elixir. Can you share how you are gathering these metrics?

1 Like

benchee is the go-to for benchmarking in Elixir. You can see my configuration in the bench directory in my advent_of_code repo.

To use it you would do mix run ./bench/bench.exs then sit back and relax :slight_smile:

5 Likes

I also have a rudimentary version in my answer using :timer.tc

1 Like

Also wrote a parser, but yours is way better: https://github.com/ream88/advent-of-code-2023/blob/main/01/part2.exs

it’s always interesting to see the different solutions and learn from each other

not sure using the String.replace or Regex.scan is efficient as you process the whole line, most of the processing is therefore useless

I opted to scan left to right and right to left with a pattern-matching function.
Although it’s more verbose, I think it’s more efficient as you stop processing a line asap.

  def p2(file) do
    file
    |> File.read!()
    |> String.split("\n")
    |> Enum.map(fn line ->
      ldigit(line) * 10 + rdigit(String.reverse(line))
    end)
    |> Enum.sum()
  end

  defp ldigit("one" <> _), do: 1
  defp ldigit("two" <> _), do: 2
  defp ldigit("three" <> _), do: 3
  defp ldigit("four" <> _), do: 4
  defp ldigit("five" <> _), do: 5
  defp ldigit("six" <> _), do: 6
  defp ldigit("seven" <> _), do: 7
  defp ldigit("eight" <> _), do: 8
  defp ldigit("nine" <> _), do: 9
  defp ldigit("zero" <> _), do: 0
  defp ldigit(<<c>> <> _) when c >= ?0 and c <= ?9, do: c - ?0
  defp ldigit(<<_, s::binary>>), do: ldigit(s)

  defp rdigit("eno" <> _), do: 1
  defp rdigit("owt" <> _), do: 2
  defp rdigit("eerht" <> _), do: 3
  defp rdigit("ruof" <> _), do: 4
  defp rdigit("evif" <> _), do: 5
  defp rdigit("xis" <> _), do: 6
  defp rdigit("neves" <> _), do: 7
  defp rdigit("thgie" <> _), do: 8
  defp rdigit("enin" <> _), do: 9
  defp rdigit("orez" <> _), do: 0
  defp rdigit(<<c>> <> _) when c >= ?0 and c <= ?9, do: c - ?0
  defp rdigit(<<_, s::binary>>), do: rdigit(s)
1 Like

What I did is slightly different than what you did.

Instead of reversing each line, I reversed the whole input character-by-character :rofl:

Here’s my code:

I noticed that the 2-digit number in each line is just first_digit * 10 + last_digit, and what we need to do is to sum up those 2-digit numbers, and number addition is both commutative and associative, so I can just do this:

Enum.sum(first_digits) * 10 + Enum.sum(last_digits)
1 Like

Multi-line Regex (Greedy & Reluctant) Solution

I came up with a relatively clean solution for part 2:

puzzle_input = File.read!("/path/to/aoc2023/day01.txt")

digit_map = %{
  "one" => 1,
  "two" => 2,
  "three" => 3,
  ...,
  "0" => 0,
  "1" => 1,
  "2" => 2,
  ...
}

# ~r/^.*?(0|1|2|...|one|two|...)/m
# Note the reluctant quantifier `*?`
first_digits_regex =
  digit_map
  |> Map.keys()
  |> Enum.join("|")
  |> then(&(~r/^.*?(#{&1})/m))

# ~r/^.*(0|1|2|...|one|two|...)/m
# Note the greedy quantifier `*`
last_digits_regex =
  digit_map
  |> Map.keys()
  |> Enum.join("|")
  |> then(&(~r/^.*?(#{&1})/m))

first_digits_sum =
  first_digits_regex
  |> Regex.scan(puzzle_input, capture: :all_but_first)
  |> List.flatten()
  |> Stream.map(&digit_map[&1])
  |> Enum.sum()

last_digits_sum =
  last_digits_regex
  |> Regex.scan(puzzle_input, capture: :all_but_first)
  |> List.flatten()
  |> Stream.map(&digit_map[&1])
  |> Enum.sum()

first_digits_sum * 10 + last_digits_sum

Here’s the livebook file: