Advent of Code 2023 - Day 3

Don’t know why the regex ~r/[\W && [^\.]]/x does not work in Elixir. It works pretty well in Ruby.

Anyway, here is my solution:

6 Likes

I like it, it’s very concise.

Mine is quite long, and this does not even contains the code for the Grid helper :smiley:

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

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

  def parse_input(input, _part) do
    AoC.Grid.parse_stream(input, &parse_char/1)
  end

  defp parse_char(<<n>>) when n in ?0..?9, do: {:ok, n - ?0}
  defp parse_char(<<?.>>), do: :ignore
  defp parse_char(<<?*>>), do: {:ok, :gear}
  defp parse_char(<<_>>), do: {:ok, :sym}

  def part_one(grid) do
    grid
    |> Enum.filter(fn {xy, val} -> is_integer(val) and sym_neighbour?(grid, xy) end)
    |> Enum.map(fn {xy, _} -> first_digit(grid, xy) end)
    |> Enum.uniq()
    |> Enum.map(&collect_number(grid, &1))
    |> Enum.sum()
  end

  defp sym_neighbour?(grid, xy) do
    xy |> AoC.Grid.cardinal8() |> Enum.any?(fn txy -> Map.get(grid, txy) in [:sym, :gear] end)
  end

  defp first_digit(grid, xy) do
    west_xy = AoC.Grid.translate(xy, :w)

    case Map.get(grid, west_xy) do
      n when is_integer(n) -> first_digit(grid, west_xy)
      _ -> xy
    end
  end

  defp collect_number(grid, xy) do
    collect_number(grid, xy, [Map.fetch!(grid, xy)])
  end

  defp collect_number(grid, xy, acc) do
    east_xy = AoC.Grid.translate(xy, :e)

    case Map.get(grid, east_xy) do
      n when is_integer(n) -> collect_number(grid, east_xy, [n | acc])
      _ -> acc |> :lists.reverse() |> Integer.undigits()
    end
  end

  # -- Part 2 -----------------------------------------------------------------

  def part_two(grid) do
    grid
    # For each digit in the grid, associate the digit XY with the neighbouring
    # gear XY, keeping only digits with such neighbour.
    |> Enum.flat_map(fn
      {xy, val} when is_integer(val) ->
        case gear_neighbour(grid, xy) do
          nil -> []
          gear_xy -> [{gear_xy, xy}]
        end

      _ ->
        []
    end)
    # Group those digits XY by their neighbouring gear XY.
    |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
    # Transform the digits XY into the first digit XY of their number
    |> Enum.flat_map(fn {_gear_xy, digits_xys} ->
      first_digits = digits_xys |> Enum.map(&first_digit(grid, &1)) |> Enum.uniq()
      # Keep only the gear groups with exactly two numbers. Collect the actual
      # numbers and compute the product.
      case first_digits do
        [digit_a_xy, digit_b_xy] ->
          num_a = collect_number(grid, digit_a_xy)
          num_b = collect_number(grid, digit_b_xy)
          [num_a * num_b]

        _ ->
          []
      end
    end)
    |> Enum.reduce(&Kernel.+/2)
  end

  defp gear_neighbour(grid, xy) do
    xy |> AoC.Grid.cardinal8() |> Enum.find(fn txy -> Map.get(grid, txy) == :gear end)
  end
end

But hey, it works :smiley: Part 2 is solved in 5ms which I am not very satisfied with.

3 Likes

I’m not satisfied with my Part 2, either. It costs 300ms to run on my computer :face_with_spiral_eyes:

Wow this one. Not a dataset I’d like to traverse at midnight. But I love LiveBook. I really do.

defmodule AdventOfCode.Y2023.Day03 do
  alias AdventOfCode.Helpers.{InputReader, Transformers}

  def input, do: InputReader.read_from_file(2023, 3)

  def run(input \\ input()) do
    input = parse(input)

    {run_1(input), run_2(input)}
  end

  defp run_1(input) do
    input
    |> Enum.map(fn {v, _, _} -> String.to_integer(v) end)
    |> Enum.sum()
  end

  defp run_2(input) do
    input
    |> Enum.map(fn {v, _, gear} -> {String.to_integer(v), Enum.uniq(gear)} end)
    |> Enum.flat_map(fn {num, gears} ->
      gears
      |> Enum.map(fn gear ->
        {gear, num}
      end)
    end)
    |> Enum.group_by(fn {a, _} -> a end, fn {_, b} -> b end)
    |> Map.filter(fn {_, v} -> length(v) == 2 end)
    |> Map.values()
    |> Enum.map(fn [a, b] -> a * b end)
    |> Enum.sum()
  end

  def parse(data \\ input()) do
    grid = data |> Transformers.lines() |> Enum.map(&String.graphemes/1) |> Transformers.grid2d()

    0..(grid |> Map.keys() |> Enum.max() |> elem(0))
    |> Enum.flat_map(&collect_all(grid, &1))
    |> Enum.filter(fn {_, n, _} -> n == true end)
  end

  defp dirs(x, y) do
    [
      {x + 1, y},
      {x - 1, y},
      {x, y + 1},
      {x, y - 1},
      {x + 1, y + 1},
      {x - 1, y - 1},
      {x + 1, y - 1},
      {x - 1, y + 1}
    ]
  end

  @reject MapSet.new(~w/. 1 2 3 4 5 6 7 8 9 0/)
  defp is_part(grid, {x, y}) do
    dirs(x, y)
    |> Enum.map(&grid[&1])
    |> Enum.reject(&(is_nil(&1) || MapSet.member?(@reject, &1)))
    |> Enum.empty?()
    |> Kernel.not()
  end

  defp get_gears(grid, {x, y}), do: Enum.filter(dirs(x, y), &(grid[&1] == "*"))

  defp collect_one(grid, pos), do: collect_one(grid[pos], grid, pos, "", false, [])

  defp collect_one(cur, grid, {x, y}, digits, part?, gears) when cur in ~w/1 2 3 4 5 6 7 8 9 0/ do
    collect_one(
      grid[{x, y + 1}],
      grid,
      {x, y + 1},
      digits <> cur,
      part? || is_part(grid, {x, y}),
      get_gears(grid, {x, y}) ++ gears
    )
  end

  defp collect_one(nil, _, {_, _}, digits, part?, gears), do: {:halt, digits, part?, gears}
  defp collect_one(_, _, {_, y}, digits, part?, gears), do: {{:cont, y}, digits, part?, gears}

  defp collect_all(grid, row), do: collect_all(grid, row, 0, [])

  defp collect_all(grid, row, col, numbers) do
    case collect_one(grid, {row, col}) do
      {:halt, "", _, _} ->
        numbers

      {_, "", _, _} ->
        collect_all(grid, row, col + 1, numbers)

      {:halt, number, part?, gears} ->
        [{number, part?, gears} | numbers]

      {{:cont, next}, number, part?, gears} ->
        collect_all(grid, row, next, [{number, part?, gears} | numbers])
    end
  end
end
3 Likes

This one took me two hours continuously to get working.

contains_symbol = fn s ->
  match = Regex.match?(~r/[^\d.]/, s)
  match
end

grab_numbers = fn line ->
  Regex.scan(~r/\d+/, line, return: :index)
  |> Enum.map(fn [{index, len}] ->
    {index, len, line |> String.slice(index, len) |> Integer.parse() |> elem(0)}
  end)
end

part1 = fn s ->
  for [pre, curr, post] <-
        s
        |> Enum.reject(&(String.length(&1) == 0))
        |> then(fn x -> Enum.concat([""], x) end)
        |> Enum.chunk(3, 1)
        |> Enum.map(fn u -> Enum.map(u, &String.trim/1) end),
      [{index, length}] <- Regex.scan(~r/\d+/, curr, return: :index),
      contains_symbol.(String.slice(curr, max(index - 1, 0), length + 2)) ||
        contains_symbol.(String.slice(pre, max(index - 1, 0), length + 2)) ||
        contains_symbol.(String.slice(post, max(index - 1, 0), length + 2)),
      {number, _} = String.slice(curr, index, length) |> Integer.parse() do
    number
  end
  |> Enum.sum()
end

part2 = fn s ->
  for [pre, curr, post] <-
        s
        |> Enum.reject(&(String.length(&1) == 0))
        |> Enum.chunk(3, 1)
        |> Enum.map(fn u -> Enum.map(u, &String.trim/1) end),
      curr |> String.contains?("*"),
      [{gear_idx, _}] <- Regex.scan(~r/\*/, curr, return: :index),
      matches =
        grab_numbers.(pre) |> Enum.concat(grab_numbers.(post)) |> Enum.concat(grab_numbers.(curr)),
      valid_matches =
        matches
        |> Enum.filter(fn {m_idx, m_len, _} ->
          gear_idx in max(0, m_idx - 1)..(m_idx + m_len)
        end),
      valid_matches |> Enum.count() == 2,
      [{_, _, a}, {_, _, b}] = valid_matches do
    a * b
  end
  |> Enum.sum()
end

part1.(IO.stream()) |> IO.puts()
4 Likes

I’m not proud of this code, but hey, it works and it’s fast.

2 Likes

I got tripped up on an off-by-one error with my ranges in part 1. That’s what I get for staying up late to do this one.

2 Likes

Not super happy about that one, I guess the parser emission could’ve been more elegant

Performance of list_adjecent_numbers is bad but it works… :upside_down_face:

My beginner’s solution, Day 3 part 1.
My focus was on readability. How I did?

defmodule Day03 do

  def part1(input) do
    # " 467..
    #   ...*.
    #   ..35  "    

    schematic = input
    |> String.split("\n")
    |> Enum.map(&String.graphemes/1)
# [ ["4", "6", "7", ".", "."],
#   [".", ".", ".", "*", "."],
#   [".", ".", "3", "5", "."]  ]
    
    matrix_of_positions_adjecent_to_symbols(schematic)
# [ [false, false, true, true, true],
#   [false, false, true, false, true],
#   [false, false, true, true, true]  ]
    |> Enum.zip(schematic)
    |> Enum.map(fn {m, s} -> Enum.zip(s, m) end)
# [ [{"4", false}, {"6", false}, {"7", true}, {".", true}, {".", true}],
#   [{".", false}, {".", false}, {".", true}, {"*", false}, {".", true}],
#   [{".", false}, {".", false}, {".", true}, {"3", true}, {"5", true}]  ]
    |> Enum.map(&extract_valid_numbers_from_zipped_row/1)
    |> List.flatten
    |> Enum.sum   
  end

  def matrix_of_positions_adjecent_to_symbols(schematic) do
    blank_matrix = List.duplicate(false, 140)
    |> List.duplicate(140)
# [ [false, false, false, false, false],
#   [false, false, false, false, false],
#   [false, false, false, false, false]  ]    

    schematic
    |> Enum.map(&Enum.with_index/1) 
    |> Enum.with_index
# [ {[{"4", 0}, {"6", 1}, {"7", 2}, {".", 3}, {".", 4}], 0},
#   {[{".", 0}, {".", 1}, {".", 2}, {"*", 3}, {".", 4}], 1},
#   {[{".", 0}, {".", 1}, {".", 2}, {"3", 3}, {"5", 4}], 2}  ]
    |> Enum.reduce(blank_matrix, 
         fn {row, row_index}, acc -> 
            Enum.reduce(row, acc, 
              fn {p, index}, acc -> 
                cond do
                  is_symbol?(p) -> encrircle_with_true_at(acc, row_index, index)
                  true -> acc
                end  
             end)
        end)
# [ [false, false, true, true, true],
#   [false, false, true, false, true],
#   [false, false, true, true, true]  ]
  end

  def is_symbol?(grapheme) do
    ["*", "&", "@", "/", "+", "-", "%", "$", "=", "#"]
    |> Enum.member?(grapheme)
  end

  def is_digit?(grapheme) do
    ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
    |> Enum.member?(grapheme)
  end
  
  def encrircle_with_true_at(matrix, row, pos) do
    matrix
    |> replace_with_true_at(row-1, pos-1)
    |> replace_with_true_at(row-1, pos)
    |> replace_with_true_at(row-1, pos+1)
    |> replace_with_true_at(row, pos-1)
    |> replace_with_true_at(row, pos+1)
    |> replace_with_true_at(row+1, pos-1)
    |> replace_with_true_at(row+1, pos)
    |> replace_with_true_at(row+1, pos+1)
  end
      
  def replace_with_true_at(matrix, row, position) do
    matrix
    |> List.pop_at(row)
    |> elem(0)
    |> List.replace_at(position, true)
    |> then(&List.replace_at(matrix, row, &1))
  end

  def extract_valid_numbers_from_zipped_row(row) do  
# [{"4", false}, {"6", false}, {"7", true}, {".", true}, {".", true}]
    row
    |> Enum.chunk_by(fn {n, _} -> is_digit?(n) end)
    |> Enum.filter(fn x -> x |> List.first |> elem(0) |> is_digit? end)
    |> Enum.map(&Enum.unzip/1)
    # [{["4", "6", "7"], [false, false, true]}, {[...],[...]}, ...]
    |> Enum.filter(fn {_, truth_list} -> Enum.any?(truth_list) end)
    |> Enum.map(fn x -> x
                  |> elem(0)
                  |> Enum.join
                  |> String.to_integer
                end)
  end

end
1 Like

Preprocessing:

defmodule Day03 do
  def parse_line("", {_, _, acc}), do: acc
  def parse_line("." <> rest, {x, y, acc}), do: parse_line(rest, {x + 1, y, acc})

  def parse_line(<<c>> <> _ = str, {x, y, items}) when c in ?0..?9 do
    {num, rest} = Integer.parse(str)
    len = floor(:math.log10(num) + 1)
    ref = make_ref()

    points =
      for i <- 0..(len - 1) do
        {{x + i, y}, {ref, num}}
      end

    parse_line(rest, {x + len, y, points ++ items})
  end

  def parse_line(<<c>> <> rest, {x, y, items}) do
    parse_line(rest, {x + 1, y, [{{x, y}, <<c>>} | items]})
  end

  def around({x, y}) do
    for dx <- -1..1, dy <- -1..1, do: {x + dx, y + dy}
  end
end

grid =
  puzzle_input
  |> String.split()
  |> Enum.with_index()
  |> Enum.flat_map(fn {line, y} ->
    Day03.parse_line(line, {0, y, []})
  end)

{parts, ids} =
  grid
  |> Enum.split_with(fn {_, value} ->
    is_binary(value)
  end)

parts = Map.new(parts)
ids = Map.new(ids)

Part 1:

used_parts =
  for {xy, _} <- parts,
      dxy <- Day03.around(xy),
      {:ok, val} <- [Map.fetch(ids, dxy)],
      into: %{},
      do: val

Enum.sum(Map.values(used_parts))

Part 2:

cogs =
  for {xy, _} <- parts,
      values =
        Map.take(ids, Day03.around(xy)) |> Map.values() |> Enum.uniq() |> Enum.map(&elem(&1, 1)),
      match?([_, _], values),
      do: Enum.product(values)

Enum.sum(cogs)
1 Like

My solution:

2 Likes

I have to admit, my favorite part of these puzzles is

  1. Seeing if I can parse the input into a structure that is useful for parts one and two
  2. Seeing if I can break apart part one into functions also useful for part two

Essentially, seeing if I can implement good abstractions upfront. Today was a good day for that!

Input

Processing

The datastructure I chose to use here was {symbols, numbers}, each as maps, with their indexed location in the grid as the keys and the parsed data from the grid as the values. In the case of numbers, their column index is a range representing their span in the grid.

Types
@type index :: non_neg_integer()

@type symbol_index :: {row_num :: index(), col_num :: index()}

@type symbols :: %{
  symbol_index() => symbol :: String.t()
}

@type number_index :: {row_num :: index(), col_num :: Range.t()}

@type numbers :: %{
  number_index() => number :: non_neg_integer()
}

@type input :: {symbols(), numbers()}
Example Input
{symbols, numbers} = {%{
   {1, 3} => "*",
   {3, 6} => "#",
   {4, 3} => "*",
   {5, 5} => "+",
   {8, 3} => "$",
   {8, 5} => "*"
 },
 %{
   {0, 0..2} => 467,
   {0, 5..7} => 114,
   {2, 2..3} => 35,
   {2, 6..8} => 633,
   {4, 0..2} => 617,
   {5, 7..8} => 58,
   {6, 2..4} => 592,
   {7, 6..8} => 755,
   {9, 1..3} => 664,
   {9, 5..7} => 598
 }}

Regex.scan(regex, string, return: :index) carried the day. I never really use it for anything but programming puzzles where I am indexing into a string grid, but boy is it handy here.

Source code available here.

defmodule AoC.Day.Three.Input do
  def parse(input_file \\ System.fetch_env!("INPUT_FILE")) do
    input_file
    |> File.read!()
    |> String.split("\n")
    |> Enum.with_index()
    |> Enum.reduce({%{}, %{}}, fn {row, y}, {symbols, numbers} ->
      symbols = scan_symbols({row, y}, symbols)
      numbers = scan_numbers({row, y}, numbers)

      {symbols, numbers}
    end)
  end

  def scan_symbols({row, y}, symbols) do
    Regex.scan(~r{[^\d\.]}, row, return: :index)
    |> List.flatten()
    |> Enum.reduce(symbols, fn {x, 1}, symbols ->
      Map.put(symbols, {y, x}, String.at(row, x))
    end)
  end

  def scan_numbers({row, y}, numbers) do
    Regex.scan(~r{\d+}, row, return: :index)
    |> List.flatten()
    |> Enum.reduce(numbers, fn {x, length}, numbers ->
      x_start = x
      x_end = x + length - 1

      number =
        row
        |> String.slice(x_start..x_end)
        |> String.to_integer()

      Map.put(numbers, {y, x_start..x_end}, number)
    end)
  end
end

Part One

Solution

My adjacency check here is not as inefficient as scanning the whole grid ever time, but still suboptimal. It was easy and fun to implement, thought. It checks every symbol against every number.

A more optimal approach would probably be to generate the indices of every possible symbol location around each number, and do a lookup to see if there was an entry (or vice-versa).

I don’t love the variable names I came up with in the adjacency logic, but readability is less at a premium in these sorts of application as being able to see where the different - 1s and + 1s are, and terseness helps there.

Source code available here.

defmodule AoC.Day.Three.Part.One do
  def solve({symbols, numbers}) do
    symbols
    |> Enum.map(&adjacent_numbers_indices(&1, numbers))
    |> List.flatten()
    |> Enum.uniq()
    |> Enum.map(&Map.get(numbers, &1))
    |> Enum.sum()
  end

  def adjacent_numbers_indices({symbol_index, _symbol}, numbers) do
    numbers
    |> Map.keys()
    |> Enum.filter(&adjacent?(symbol_index, &1))
  end

  # The geometry of considers 8 neighbours to be adjacent, cardinals and diagonals
  # We hardcode these in function heads, going clockwise, starting north
  def adjacent?(symbol_index, number_index)

  # North
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y + 1 == n_y and s_x in n_xs..n_xe,
      do: true

  # North-East
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y + 1 == n_y and (s_x + 1) in n_xs..n_xe,
      do: true

  # East
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y == n_y and (s_x + 1) in n_xs..n_xe,
      do: true

  # South-East
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y - 1 == n_y and (s_x + 1) in n_xs..n_xe,
      do: true

  # South
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y - 1 == n_y and s_x in n_xs..n_xe,
      do: true

  # South-West
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y - 1 == n_y and (s_x - 1) in n_xs..n_xe,
      do: true

  # West
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y == n_y and (s_x - 1) in n_xs..n_xe,
      do: true

  # North-West
  def adjacent?({s_y, s_x}, {n_y, n_xs..n_xe})
      when s_y + 1 == n_y and (s_x - 1) in n_xs..n_xe,
      do: true

  def adjacent?(_symbol_index, _number_index), do: false
end

Part Two

Solution

Exactly what you want to see: use the same datastructure you developed for part one, use functions from part one to solve! Source code available here.

defmodule AoC.Day.Three.Part.Two do
  @gear "*"

  import AoC.Day.Three.Part.One

  def solve({symbols, numbers}) do
    symbols
    |> Enum.filter(fn {_index, symbol} -> symbol == @gear end)
    |> Enum.map(&adjacent_numbers_indices(&1, numbers))
    |> Enum.filter(&(length(&1) == 2))
    |> Enum.reduce(0, fn [gear1, gear2], acc ->
      acc + numbers[gear1] * numbers[gear2]
    end)
  end
end
2 Likes

I did something I didn’t see in other solutions. I created an index of positions for all the numbers, but also added a unique id to each number. That way, the number 467 can be indexed as [{{0,0}, {id_1, 467}}, {{0,1}, {id_1, 467}}, {{0,2}, {id_1, 467}}]
That way, adjacency is trivial, and then deduplicating numbers is also trivial (as the same number might be adjacent to a symbol multiple times)

Solution: https://github.com/juanperi/advent_of_code/blob/master/2023/day_03.ex

2 Likes

I have not yet looked at the solutions in this thread but I just wanted to state that for day 3 this was a very difficult challenge relative to previous years. I think there may have been an effort to handicap the AI coders with the difficult input parsing this year.

4 Likes

It could also be because it’s Sunday, and they’re typically more involved.

I skipped part 2 today, not a fan of these grid challenges in Elixir. I did get part 1 running in 3ms using :ets for constant time lookups though. And I wrote a crazy single-pass binary pattern-matching recursive function to parse the input that I’ll spare you all from seeing :laughing:

1 Like

Finally managed to get part 2 correct after completely redoing my parser approach. I still don’t understand the bug my original approach had as it worked for part 1 and the example input for part 2. Still, the second attempt is much cleaner (not CLEAN, but not the mess of spaghetti my first attempt was).
Full code here. Just the parser:

defmodule Parser do
    alias AocToolbox.Input

    defstruct [
      :max_ndx,
      line: "",
      row: 0,
      ndx: 0,
      graph: %{},
      num_map: %{},
      symbol_neighbors_map: %{}
    ]

    def parse_raw_graph(text) do
      text
      |> Input.lines()
      |> Enum.with_index()
      |> Enum.map(fn {line, row} ->
        parse_lines(%__MODULE__{line: line, row: row, max_ndx: String.length(line)})
      end)
      |> Enum.reduce(fn row, acc ->
        row
        |> Enum.map(fn {key, val} ->
          {key, Map.merge(val, acc[key], fn _, v1, v2 -> MapSet.union(v1, v2) end)}
        end)
      end)
    end

    def parse_lines(%__MODULE__{
          ndx: ndx,
          max_ndx: max_ndx,
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        })
        when ndx > max_ndx,
        do: [graph: graph, num_map: num_map, symbol_neighbors_map: symbol_neighbors_map]

    def parse_lines(%__MODULE__{
          line: <<".", rest::binary>>,
          row: row,
          ndx: ndx,
          max_ndx: max_ndx,
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        }),
        do:
          parse_lines(%__MODULE__{
            line: rest,
            row: row,
            ndx: ndx + 1,
            max_ndx: max_ndx,
            graph: Map.put(graph, {row, ndx}, nil),
            num_map: num_map,
            symbol_neighbors_map: symbol_neighbors_map
          })

    def parse_lines(%__MODULE__{
          line: <<?*, rest::binary>>,
          row: row,
          ndx: ndx,
          max_ndx: max_ndx,
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        }) do
      parse_lines(%__MODULE__{
        line: rest,
        row: row,
        ndx: ndx + 1,
        max_ndx: max_ndx,
        graph: Map.put(graph, {row, ndx}, :gear),
        num_map: num_map,
        symbol_neighbors_map:
          update_symbol_neighbors(symbol_neighbors_map, row, ndx, max_ndx, :gear)
      })
    end

    def parse_lines(%__MODULE__{
          line: <<d, rest::binary>>,
          row: row,
          ndx: ndx,
          max_ndx: max_ndx,
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        })
        when d > 47 and d < 58 do
      {num, rem_line, last_ndx} = parse_nums(d - 48, rest, ndx)

      graph =
        ndx..last_ndx
        |> Enum.reduce(graph, fn n, g -> Map.update(g, {row, n}, num, fn _ -> num end) end)

      num_map =
        all_associations(ndx, last_ndx, row)
        |> Map.merge(num_map, fn _, v1, v2 -> MapSet.union(v1, v2) end)

      parse_lines(%__MODULE__{
        line: rem_line,
        row: row,
        ndx: last_ndx + 1,
        max_ndx: max_ndx,
        graph: graph,
        num_map: num_map,
        symbol_neighbors_map: symbol_neighbors_map
      })
    end

    def parse_lines(%__MODULE__{
          line: "",
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        }),
        do: [graph: graph, num_map: num_map, symbol_neighbors_map: symbol_neighbors_map]

    def parse_lines(%__MODULE__{
          line: <<s, rest::binary>>,
          row: row,
          ndx: ndx,
          max_ndx: max_ndx,
          graph: graph,
          num_map: num_map,
          symbol_neighbors_map: symbol_neighbors_map
        }),
        do:
          parse_lines(%__MODULE__{
            line: rest,
            row: row,
            ndx: ndx + 1,
            max_ndx: max_ndx,
            graph: Map.put(graph, {row, ndx}, :symbol),
            num_map: num_map,
            symbol_neighbors_map:
              update_symbol_neighbors(symbol_neighbors_map, row, ndx, max_ndx, :symbol)
          })

    defp parse_nums(acc, <<d, rest::binary>>, ndx) when d > 47 and d < 58 do
      parse_nums(acc * 10 + d - 48, rest, ndx + 1)
    end

    defp parse_nums(acc, bin, ndx), do: {acc, bin, ndx}

    defp all_associations(start, stop, row) do
      start..stop
      |> Enum.to_list()
      |> AocToolbox.Math.permutations()
      |> Enum.map(fn [a | rest] ->
        {{row, a}, MapSet.new(Enum.map(rest, fn b -> {row, b} end))}
      end)
      |> Enum.into(%{})
    end

    defp update_symbol_neighbors(symbol_neighbors_map, row, ndx, max_ndx, symbol) do
      for i <- -1..1,
          j <- -1..1,
          not (i == 0 and j == 0),
          row + i >= 0,
          ndx + j >= 0,
          ndx + j <= max_ndx,
          reduce: symbol_neighbors_map do
        acc ->
          Map.update(acc, {symbol, {row, ndx}}, MapSet.new([{row + i, ndx + j}]), fn curr ->
            MapSet.put(curr, {row + i, ndx + j})
          end)
      end
    end
1 Like

For part 1 I decided to try a “sliding window” approach. After splitting into lines, the code “slides” a window over three adjacent lines simultaneously looking for a number on the second line that is has a symbol somewhere around it. This limits the amount of parsing done and yes, it’s not very reusable for the second part (which I may not finish today).

It’s not super pretty, but it does run in about 3.5ms on my MacBook Pro (M1 Max). And I do think its quite easy to comprehend.

Preprocessing Just split the lines. To ensure a regular window frame for the sliding window, add a border of one "." one each side.
  def part_1 do
    input = parse_input()

    find_parts(input, 0, tl(input))
  end

  def parse_input do
    list = String.split(@input, "\n", trim: true)
    length = String.length(hd(list))
    header = String.duplicate(".", length)

    [header | list] ++ [header]
    |> Enum.map(&("." <> &1 <> "."))
  end

# Guards used for surrounding symbol detection
  defguard are_digits(c1, c2, c3) when
    (c1 in ?0..?9 and c2 in ?0..?9 and c3 in ?0..?9)

  defguard are_digits(c1, c2) when
    (c1 in ?0..?9 and c2 in ?0..?9)

  defguard are_digits(c1) when
    (c1 in ?0..?9)

  defguard is_surrounded_by_symbol(l1c1, l1c2, l1c3, l1c4, l1c5, l3c1, l3c2, l3c3, l3c4, l3c5, l2c1, l2c5) when
    (l1c1 != ?. or l1c2 != ?. or l1c3 != ?. or l1c4 != ?. or l1c5 != ?. or
    l3c1 != ?. or l3c2 != ?. or l3c3 != ?. or l3c4 != ?. or l3c5 != ?. or
    l2c1 != ?. or l2c5 != ?.)

  defguard is_surrounded_by_symbol(l1c1, l1c2, l1c3, l1c4, l3c1, l3c2, l3c3, l3c4, l2c1, l2c4) when
    (l1c1 != ?. or l1c2 != ?. or l1c3 != ?. or l1c4 != ?. or
    l3c1 != ?. or l3c2 != ?. or l3c3 != ?. or l3c4 != ?. or
    l2c1 != ?. or l2c4 != ?.)

  defguard is_surrounded_by_symbol(l1c1, l1c2, l1c3, l3c1, l3c2, l3c3, l2c1, l2c3) when
    (l1c1 != ?. or l1c2 != ?. or l1c3 != ?. or
    l3c1 != ?. or l3c2 != ?. or l3c3 != ?. or
    l2c1 != ?. or l2c3 != ?.)
Part 1
  # Three digit number
  def find_parts([<<l1c1, l1c2, l1c3, l1c4, l1c5, l1_rest::binary>>, <<l2c1, l2c2, l2c3, l2c4, l2c5, l2_rest::binary>>, <<l3c1, l3c2, l3c3, l3c4, l3c5, l3_rest::binary>> | rest], acc, tail)
      when are_digits(l2c2, l2c3, l2c4) and
        is_surrounded_by_symbol(l1c1, l1c2, l1c3, l1c4, l1c5, l3c1, l3c2, l3c3, l3c4, l3c5, l2c1, l2c5) do
    acc = ((l2c2 - ?0) * 100) + ((l2c3 - ?0) * 10) + (l2c4 - ?0) + acc
    find_parts([<<l1c5,  l1_rest::binary>>, <<l2c5, l2_rest::binary>>, <<l3c5, l3_rest::binary>> | rest], acc, tail)
  end

  # Three digit number but no symbol, skip over the number
  def find_parts([<<_, _, _, _, l1c5, l1_rest::binary>>, <<_, l2c2, l2c3, l2c4, l2c5, l2_rest::binary>>, <<_, _, _, _, l3c5, l3_rest::binary>> | rest], acc, tail)
      when are_digits(l2c2, l2c3, l2c4) do
   find_parts([<<l1c5,  l1_rest::binary>>, <<l2c5, l2_rest::binary>>, <<l3c5, l3_rest::binary>> | rest], acc, tail)
  end

  # Two digit number surrounded by a symbol
  def find_parts([<<l1c1, l1c2, l1c3, l1c4, l1_rest::binary>>, <<l2c1, l2c2, l2c3, l2c4, l2_rest::binary>>, <<l3c1, l3c2, l3c3, l3c4, l3_rest::binary>> | rest], acc, tail)
    when are_digits(l2c2, l2c3) and
      is_surrounded_by_symbol(l1c1, l1c2, l1c3, l1c4, l3c1, l3c2, l3c3, l3c4, l2c1, l2c4) do
    acc = ((l2c2 - ?0) * 10) + (l2c3 - ?0) + acc
    find_parts([<<l1c4,  l1_rest::binary>>, <<l2c4, l2_rest::binary>>, <<l3c4, l3_rest::binary>> | rest], acc, tail)
  end

  # Two digit number but no symbol, skip over the number
  def find_parts([<<_, _, _, l1c4, l1_rest::binary>>, <<_, l2c2, l2c3, l2c4, l2_rest::binary>>, <<_, _, _, l3c4, l3_rest::binary>> | rest], acc, tail)
      when are_digits(l2c2, l2c3) do
   find_parts([<<l1c4,  l1_rest::binary>>, <<l2c4, l2_rest::binary>>, <<l3c4, l3_rest::binary>> | rest], acc, tail)
  end

  # One digit number surrounded with a symbol
  def find_parts([<<l1c1, l1c2, l1c3, l1_rest::binary>>, <<l2c1, l2c2, l2c3, l2_rest::binary>>, <<l3c1, l3c2, l3c3, l3_rest::binary>> | rest], acc, tail)
      when are_digits(l2c2) and
       is_surrounded_by_symbol(l1c1, l1c2, l1c3, l3c1, l3c2, l3c3, l2c1, l2c3) do
   acc = (l2c2 - ?0) + acc
   find_parts([<<l1c3,  l1_rest::binary>>, <<l2c3, l2_rest::binary>>, <<l3c3, l3_rest::binary>> | rest], acc, tail)
  end

  # Move the window across one character
  def find_parts([<<_, rest_1::binary>>, <<_, rest_2::binary>>, <<_, rest_3::binary>> | rest], acc, tail) do
    find_parts([rest_1, rest_2, rest_3 | rest], acc, tail)
  end

  # Move the window down one line
  def find_parts(["", "", "" | _rest], acc, [line_1, line_2, line_3 | tail]) do
    find_parts([line_1, line_2, line_3], acc, [line_2, line_3 | tail])
  end

  # End of input, return the accumulator
  def find_parts([_, _, _], acc, _tail) do
    acc
  end
1 Like

I did the same, just with make_ref() instead of number.

1 Like