Advent of Code 2024 - Day 8

27 lines

3 Likes

Not the most efficient way to do it (especially part 2) but it takes like 1ms to run anyway so it literally doesn’t matter :stuck_out_tongue:

Now back to doing previous year puzzles that I never completed…

I used Enum.dedup/1 instead of Enum.uniq/1 when attempting to solve part 1. This mistake hid another bug, and so I got the correct result for the example but not for my input.

The following solution is refactored to share most of the code for the solution:

1 Like
#!/usr/bin/env elixir

# AoC 2024. day 8.

###########################################################
# Setup

{coords2antennas, nrows, ncols} = File.stream!("../day08.txt")
  |> Stream.with_index(1)
  |> Enum.reduce({%{}, 0, 0}, fn {line, row}, {map, nrows, ncols} ->
    line
    |> String.trim_trailing()
    |> String.to_charlist()
    |> Enum.with_index(1)
    |> Enum.reduce({map, nrows, ncols}, fn {c, col}, {map, nrows, ncols} ->
      map = (if c != ?., do: Map.put(map, {row,col}, c), else: map)
      {map, max(nrows, row), max(ncols, col)}
    end)
  end)

defmodule M do
  # values become keys and vice-versa. values are stored in a list.
  def inside_out(map) do
    map |> Enum.reduce(%{}, fn {k,v}, res ->
      Map.update(res, v, [k], fn lst -> [k|lst] end)
    end)
  end
  def offset({r1,c1}, {r2,c2}), do: {r2-r1,c2-c1}
  def add({r,c},{ro,co}), do: {r+ro,c+co}
  def sub({r,c},{ro,co}), do: {r-ro,c-co}
  def inside({r,c}, nrows, ncols), do: r >= 1 && r <= nrows && c >= 1 && c <= ncols
  def pairs([]), do: []
  def pairs([x | rest]), do: Enum.map(rest, fn y -> {x,y} end) ++ pairs(rest)
end

###########################################################
# Part 1

coords2antennas
|> M.inside_out()
|> Enum.reduce(MapSet.new(), fn {_antenna, coords}, set ->
  pairs = M.pairs(coords)
  Enum.reduce(pairs, set, fn {coord1, coord2}, set ->
    offset = M.offset(coord1, coord2)
    set
    |> then(fn set ->
      anti = M.sub(coord1, offset)
      if M.inside(anti, nrows, ncols), do: MapSet.put(set, anti), else: set
    end)
    |> then(fn set ->
      anti = M.add(coord2, offset)
      if M.inside(anti, nrows, ncols), do: MapSet.put(set, anti), else: set
    end)
  end)
end)
|> tap(fn set -> IO.puts("Part 1. Number of antinodes: #{MapSet.size(set)}") end)

###########################################################
# Part 2

coords2antennas
|> M.inside_out()
|> Enum.reduce(MapSet.new(), fn {_antenna, coords}, set ->
  pairs = M.pairs(coords)
  Enum.reduce(pairs, set, fn {coord1, coord2}, set ->
    offset = M.offset(coord1, coord2)
    set = Stream.unfold(coord1, fn coord ->
        anti = M.sub(coord, offset)
        if M.inside(anti, nrows, ncols), do: {anti, anti}
      end)
      |> Enum.reduce(set, fn anti, set -> MapSet.put(set, anti) end)
    Stream.unfold(coord2, fn coord ->
      anti = M.add(coord, offset)
      if M.inside(anti, nrows, ncols), do: {anti, anti}
    end)
    |> Enum.reduce(set, fn anti, set -> MapSet.put(set, anti) end)
    |> MapSet.put(coord1)
    |> MapSet.put(coord2)
  end)
end)
|> tap(fn set -> IO.puts("Part 2. Number of antinodes: #{MapSet.size(set)}") end)

Fun day! Finally less bruteforce thinking!

Both solutions were pretty fast to run:

===== YEAR 2024 DAY 8 PART 1 =====
Result: 
Took: 5ms
===== YEAR 2024 DAY 8 PART 2 =====
Result: 
Took: 2ms

Solution (still working to generalize it):

defmodule AOC.Y2024.Day8 do
  @moduledoc false

  use AOC.Solution

  @impl true
  def load_data() do
    Data.load_day_as_grid(2024, 8)
    |> then(fn {grid, m, n} ->
      antennas =
        grid
        |> Enum.filter(fn {_, v} -> v != "." end)
        |> Enum.group_by(fn {_, v} -> v end, fn {k, _} -> k end)

      {antennas, m, n}
    end)
  end

  @impl true
  def part_one({antennas, m, n}) do
    solve(antennas, m, n, &find_antinodes/3)
  end

  @impl true
  def part_two({antennas, m, n}) do
    solve(antennas, m, n, &find_antinodes2/3)
  end

  defp solve(antennas, m, n, func) do
    antennas
    |> Enum.flat_map(fn {_, coords} ->
      func.(coords, m, n)
    end)
    |> Enum.uniq()
    |> Enum.count()
  end

  defp find_antinodes(coords, m, n) do
    coords
    |> Enum.with_index(1)
    |> Enum.flat_map(fn {coord, i} ->
      coords |> Enum.slice(i..-1//1) |> Enum.map(fn other -> {coord, other} end)
    end)
    |> Enum.flat_map(fn {{a, b}, {c, d}} ->
      da = abs(a - c)
      db = abs(b - d)
      da_sign = div(a - c, abs(a - c))
      db_sign = div(b - d, abs(b - d))
      nx = {a + da * da_sign, b + db * db_sign}
      ny = {c + da * -da_sign, d + db * -db_sign}
      [nx, ny]
    end)
    |> Enum.filter(fn {a, b} ->
      a in 0..(m - 1) and b in 0..(n - 1)
    end)
  end

  defp find_antinodes2(coords, m, n) do
    coords
    |> Enum.with_index(1)
    |> Enum.flat_map(fn {coord, i} ->
      coords |> Enum.slice(i..-1//1) |> Enum.map(fn other -> {coord, other} end)
    end)
    |> Enum.flat_map(fn {x, y} ->
      inf_antinodes(x, y, m, n)
    end)
  end

  defp inf_antinodes({a, b}, {c, d}, m, n) do
    da = abs(a - c)
    db = abs(b - d)
    da_sign = div(a - c, abs(a - c))
    db_sign = div(b - d, abs(b - d))

    Stream.iterate({a, b}, fn {x, y} ->
      {x + da * da_sign, y + db * db_sign}
    end)
    |> Stream.take_while(fn {x, y} -> x in 0..(m - 1) and y in 0..(n - 1) end)
    |> Stream.concat(
      Stream.iterate({c, d}, fn {x, y} ->
        {x + da * -da_sign, y + db * -db_sign}
      end)
      |> Stream.take_while(fn {x, y} -> x in 0..(m - 1) and y in 0..(n - 1) end)
    )
    |> Enum.to_list()
    |> Enum.uniq()
  end
end

I struggled way more than expected with my x and y calculations :exploding_head:

Part 1

defmodule Advent.Y2024.Day08.Part1 do
  @grid_size 49

  def run(puzzle) do
    puzzle
    |> parse()
    |> find_antinodes(&antinodes/2)
    |> Enum.count()
  end

  def parse(puzzle) do
    for {line, y} <- puzzle |> String.split("\n") |> Enum.with_index(), reduce: %{} do
      acc ->
        for {c, x} <- line |> String.graphemes() |> Enum.with_index(), c != ".", reduce: acc do
          acc -> Map.update(acc, c, [{x, y}], fn pos -> [{x, y} | pos] end)
        end
    end
  end

  def find_antinodes(g, fun) do
    for {_, pos} <- g, a1 <- pos, a2 <- pos, a1 != a2, n <- fun.(a1, a2), reduce: MapSet.new() do
      nodes -> MapSet.put(nodes, n)
    end
  end

  defp antinodes({x1, y1}, {x2, y2}) do
    dx = abs(x1 - x2)
    dy = abs(y1 - y2)

    Enum.filter(
      [
        {if(x1 > x2, do: x1 + dx, else: x1 - dx), if(y1 > y2, do: y1 + dy, else: y1 - dy)},
        {if(x2 > x1, do: x2 + dx, else: x2 - dx), if(y2 > y1, do: y2 + dy, else: y2 - dy)}
      ],
      fn {x, y} -> x in 0..@grid_size and y in 0..@grid_size end
    )
  end
end

Part 2

defmodule Advent.Y2024.Day08.Part2 do
  @grid_size 49

  alias Advent.Y2024.Day08.Part1

  def run(puzzle) do
    puzzle
    |> Part1.parse()
    |> Part1.find_antinodes(&antinodes/2)
    |> Enum.count()
  end

  defp antinodes({x1, y1}, {x2, y2}) do
    dx = abs(x1 - x2)
    dy = abs(y1 - y2)

    [{x1, y1}, {x2, y2}] ++
      in_direction({x1, y1}, {if(x1 > x2, do: dx, else: -dx), if(y1 > y2, do: dy, else: -dy)}) ++
      in_direction({x2, y2}, {if(x2 > x1, do: dx, else: -dx), if(y2 > y1, do: dy, else: -dy)})
  end

  defp in_direction({x, y}, {dx, dy}, acc \\ []) do
    {nx, ny} = {x + dx, y + dy}

    if nx in 0..@grid_size and ny in 0..@grid_size do
      in_direction({nx, ny}, {dx, dy}, [{nx, ny} | acc])
    else
      acc
    end
  end
end

Pretty tame after Friday’s loop detection, I was expecting worse for Sunday (although I took a break yesterday so I’m not sure how that was).

Each part completes in under a millisecond.

def calc_resonant_harmonics({{x_a, y_a}, {x_b, y_b}}, max_x, max_y) do
  dx = x_a - x_b
  dy = y_a - y_b

  [{x_a, y_a}, {x_b, y_b}] ++
    resonate(x_a, y_a, dx, dy, max_x, max_y) ++
    resonate(x_b, y_b, dx * -1, dy * -1, max_x, max_y)
end

def resonate(x, y, dx, dy, max_x, max_y, multiplier \\ 1) do
  next_x = x + dx * multiplier
  next_y = y + dy * multiplier

  if next_x < 0 or next_x >= max_x or next_y < 0 or next_y >= max_y do
    []
  else
    [{next_x, next_y} | resonate(x, y, dx, dy, max_x, max_y, multiplier + 1)]
  end
end
1 Like

Sundays are supposed to be harder but today was quite easy:)

defmodule AdventOfCode.Solutions.Y24.Day08 do
  alias AdventOfCode.Grid
  alias AoC.Input

  def parse(input, _part) do
    {_grid, _bounds} =
      input
      |> Input.stream!()
      |> Grid.parse_lines(fn
        _, ?. -> :ignore
        _, ?\n -> raise "parses new line"
        _, c -> {:ok, c}
      end)
  end

  def part_one({grid, bounds}) do
    for({xy_l, l} <- grid, {xy_r, r} <- grid, l == r, xy_l < xy_r, do: antinodes_p1(xy_l, xy_r))
    |> :lists.flatten()
    |> Enum.uniq()
    |> Enum.filter(&in_bounds?(&1, bounds))
    |> length()
  end

  defp antinodes_p1({xl, yl}, {xr, yr}) do
    x_diff = xr - xl
    y_diff = yr - yl

    [
      # Lower node
      {xl - x_diff, yl - y_diff},

      # Higher node
      {xr + x_diff, yr + y_diff}
    ]
  end

  defp in_bounds?({x, y}, {xa, xo, ya, yo}) do
    x >= xa and x <= xo and
      y >= ya and y <= yo
  end

  def part_two({grid, bounds}) do
    for(
      {xy_l, l} <- grid,
      {xy_r, r} <- grid,
      l == r,
      xy_l < xy_r,
      do: antinodes_p2(xy_l, xy_r, bounds)
    )
    |> :lists.flatten()
    |> Enum.uniq()
    |> length()
  end

  defp antinodes_p2({xl, yl}, {xr, yr}, bounds) do
    x_diff = xr - xl
    y_diff = yr - yl

    higher =
      {xr, yr}
      |> Stream.iterate(fn {x, y} -> {x + x_diff, y + y_diff} end)
      |> Enum.take_while(&in_bounds?(&1, bounds))

    lower =
      {xl, yl}
      |> Stream.iterate(fn {x, y} -> {x - x_diff, y - y_diff} end)
      |> Enum.take_while(&in_bounds?(&1, bounds))

    [higher, lower]
  end
end

No optimization at all :slight_smile: its under 1ms as well.

1 Like

My solution today:

That’s a clever way of iterating through the grid.

I did a Map.values() into a MapSet to get possible values, then did a Map.filter() on each to get the matching tower locations, which seems messy in comparison :sweat_smile:.

1 Like

The grid ones are tricky to golf.

LOC: 23

defmodule Aoc2024.Day08 do
  import Enum

  def part1(file), do: main(file, 1..1)
  def part2(file), do: main(file)

  def main(file, range \\ nil) do
    {grid, n} = file_to_charmap_grid(file)

    for {{x1, y1}, z1} <- grid, {{x2, y2}, z2} <- grid, z1 == z2, z1 != ?., x1 < x2 do
      map(range || -n..n, fn m -> {m * (x2 - x1), m * (y2 - y1)} end)
      |> flat_map(fn {dx, dy} -> [[x1 - dx, y1 - dy], [x2 + dx, y2 + dy]] end)
      |> filter(fn coor -> all?(coor, &(&1 in 0..(n - 1))) end)
    end
    |> reduce(MapSet.new(), &MapSet.union(&2, MapSet.new(&1)))
    |> MapSet.size()
  end

  def file_to_charmap_grid(f) do
    r = f |> File.read!() |> String.trim() |> String.split("\n") |> map(&String.to_charlist/1)
    {for({s, i} <- with_index(r), {x, j} <- with_index(s), into: %{}, do: {{i, j}, x}), length(r)}
  end
end

This one made me wish I could reach for the extra comprehension powers hinted at in the for let proposal.

4 Likes

Here is mine:

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

  def parse(input, _part) do
    stream = Input.stream!(input, trim: true)

    antennas =
      stream
      |> Stream.with_index()
      |> Enum.reduce(%{}, fn {line, x}, antennas ->
        line
        |> String.to_charlist()
        |> Stream.with_index()
        |> Enum.reduce(antennas, fn
          {?., _}, antennas -> antennas
          {symbol, y}, antennas -> Map.update(antennas, symbol, [{x, y}], &[{x, y} | &1])
        end)
      end)
      |> Map.values()

    row_end = Enum.count(stream) - 1
    col_end = length(String.to_charlist(Enum.at(stream, 0))) - 1

    {antennas, row_end, col_end}
  end

  def part_one({antennas, row_end, col_end}) do
    antennas
    |> generate_combinations()
    |> Enum.reduce(MapSet.new(), fn combinations, antinodes ->
      Enum.reduce(combinations, antinodes, fn {{x1, y1}, {x2, y2}}, antinodes ->
        antinodes
        |> add_antinodes(x1, y1, x2 - x1, y2 - y1, row_end, col_end)
        |> add_antinodes(x2, y2, x1 - x2, y1 - y2, row_end, col_end)
      end)
    end)
    |> Enum.count()
  end

  def part_two({antennas, row_end, col_end}) do
    antennas
    |> generate_combinations()
    |> Enum.reduce(MapSet.new(List.flatten(antennas)), fn combinations, antinodes ->
      Enum.reduce(combinations, antinodes, fn {{x1, y1}, {x2, y2}}, antinodes ->
        antinodes
        |> add_antinodes_recursive(x1, y1, x2 - x1, y2 - y1, row_end, col_end)
        |> add_antinodes_recursive(x2, y2, x1 - x2, y1 - y2, row_end, col_end)
      end)
    end)
    |> Enum.count()
  end

  defp generate_combinations(antennas) do
    for antenna <- antennas do
      for {a, x} <- Enum.with_index(antenna),
          {b, y} <- Enum.with_index(antenna),
          x < y,
          do: {a, b}
    end
  end

  defp add_antinodes(antinodes, x_original, y_original, dx, dy, row_end, col_end) do
    {x, y} = {x_original - dx, y_original - dy}

    if x >= 0 and x <= row_end and y >= 0 and y <= col_end do
      MapSet.put(antinodes, {x, y})
    else
      antinodes
    end
  end

  defp add_antinodes_recursive(antinodes, x_original, y_original, dx, dy, row_end, col_end) do
    {x, y} = {x_original - dx, y_original - dy}

    if x >= 0 and x <= row_end and y >= 0 and y <= col_end do
      antinodes = MapSet.put(antinodes, {x, y})
      add_antinodes_recursive(antinodes, x, y, dx, dy, row_end, col_end)
    else
      antinodes
    end
  end
end

Can somebody explain to me why is part 2 faster although it’s the same kind of function for calculation as in part 1 only recursive. :confused:

Solution for 2024 day 8
part_one: 376 in 8.85ms
part_two: 1352 in 1.22ms

@ken-kost your part 1 was faster for me. I’m not using aoc though so that might have something to do with it?

I ran:

Benchee.run(%{
  "part1" => fn ->
    "inputs/day08/input2.txt"
    |> Aoc2024.Solutions.Y24.Day08.parse(1)
    |> Aoc2024.Solutions.Y24.Day08.part_one()
  end,
  "part2" => fn ->
    "inputs/day08/input2.txt"
    |> Aoc2024.Solutions.Y24.Day08.parse(2)
    |> Aoc2024.Solutions.Y24.Day08.part_two()
  end
})

After replacing the Aoc.Input part with:

# Before
stream = Input.stream!(input, trim: true)

# After
stream =
  input
  |> File.stream!()
  |> Stream.map(&String.trim/1)
  |> Stream.reject(&(&1 == ""))

and I got:

Name            ips        average  deviation         median         99th %
part1        1.25 K        0.80 ms    ±39.13%        0.75 ms        1.43 ms
part2        0.99 K        1.01 ms    ±19.68%        0.95 ms        1.70 ms
1 Like

Today’s problem is more like a reading comprehension problem than an algorithm problem, especially for a non English native speaker. Fortunately, I was a student of physics when I was at university, and this problem brings me back to those days.

I really liked how this worked out in the end <3

and i like to use structs as you can see. It saves me from hidden issues like calling non existing keys on maps … and getting nil back …

i just changed the existing part1 soluition for part2 as always

this was a fun excercice :slight_smile:

btw i see most of you try to make it as few lines of possible and as fast as possible … but i just sincerely enjoy doing the exercises :wink: I wonder what i will do rest of the year now, after december … excercism ?

There’s always previous year puzzles to do, if you haven’t done those!

https://everybody.codes/ is anothe one that popped up last month for more puzzley goodness

2 Likes

Catching back up.

Here’s my pt 1:

#!/usr/bin/env elixir

defmodule Day8.Part1 do
  @open_space ~c"." |> List.first()

  defp parse(str) do
    [row | _rows] =
      rows =
      str
      |> String.split("\n")
      |> Enum.map(&to_charlist/1)

    height = Enum.count(rows)
    length = Enum.count(row)

    antennae =
      for y <- 0..(height - 1), x <- 0..(length - 1) do
        {x, y}
      end
      |> Enum.reduce(
        %{},
        fn {x, y} = pos, acc ->
          c = rows |> Enum.at(y) |> Enum.at(x)

          if c == @open_space,
            do: acc,
            else: acc |> Map.update("#{c}", [pos], fn coordinates -> [pos | coordinates] end)
        end
      )

    %{
      antennae: antennae,
      height: height,
      length: length
    }
  end

  defp on_board?(height, length, {x, y}) when x < 0 or x >= length or y < 0 or y >= height,
    do: false

  defp on_board?(_height, _length, _pair), do: true

  # Produce a List of coordinate pairs representing antinodes
  defp pairwise_antinodes_on_board(height, length, antennae) do
    Stream.unfold(
      antennae,
      fn
        [] -> nil
        [_h | t] = antennae -> {antennae, t}
      end
    )
    |> Enum.map(fn antennae -> pairwise_antinodes_on_board_for_antenna(antennae, height, length) end)
    |> List.flatten()
  end

  defp pairwise_antinodes_on_board_for_antenna(
         [_antenna],
         _height,
         _length
       ),
       do: []

  defp pairwise_antinodes_on_board_for_antenna(
         [{x_1, y_1} = antenna_1, {x_2, y_2} = _antenna_2 | antennae],
         height,
         length
       )
       when abs(x_1 - x_2) > length / 2 or abs(y_1 - y_2) > height / 2,
       do: pairwise_antinodes_on_board_for_antenna([antenna_1 | antennae], height, length)

  defp pairwise_antinodes_on_board_for_antenna(
         [{x_1, y_1} = antenna_1, {x_2, y_2} = _antenna_2 | antennae],
         height,
         length
       ) do
    south_ish? = y_2 - y_1 < 0
    west_ish? = x_2 - x_1 < 0
    {x_min, x_max} = Enum.min_max([x_1, x_2])
    delta_x = abs(x_1 - x_2)
    {y_min, y_max} = Enum.min_max([y_1, y_2])
    delta_y = abs(y_1 - y_2)

    {antinode_1_x, antinode_2_x} =
      if west_ish? do
        {x_max + delta_x, x_min - delta_x}
      else
        {x_min - delta_x, x_max + delta_x}
      end

    {antinode_1_y, antinode_2_y} =
      if south_ish? do
        {y_max + delta_y, y_min - delta_y}
      else
        {y_min - delta_y, y_max + delta_y}
      end

    [
      [{antinode_1_x, antinode_1_y}, {antinode_2_x, antinode_2_y}]
      |> Enum.filter(&on_board?(height, length, &1))
      | pairwise_antinodes_on_board_for_antenna([antenna_1 | antennae], height, length)
    ]
    |> List.flatten()
  end

  defp count_antinodes(%{
         antennae: antennae,
         height: height,
         length: length
       }) do
    antennae
    |> Map.keys()
    |> Enum.reduce(
      MapSet.new(),
      fn frequency, antinodes ->
        pairwise_antinodes_on_board(height, length, antennae[frequency])
        |> Enum.reduce(antinodes, &MapSet.put(&2, &1))
      end
    )
    |> Enum.count()
  end

  def solve() do
    File.read!("08/input.txt")
    |> parse()
    |> count_antinodes()
    |> IO.puts()
  end
end

Day8.Part1.solve()

And part 2:

#!/usr/bin/env elixir

defmodule Day8.Part1 do
  @open_space ~c"." |> List.first()

  defp parse(str) do
    [row | _rows] =
      rows =
      str
      |> String.split("\n")
      |> Enum.map(&to_charlist/1)

    height = Enum.count(rows)
    length = Enum.count(row)

    antennae =
      for y <- 0..(height - 1), x <- 0..(length - 1) do
        {x, y}
      end
      |> Enum.reduce(
        %{},
        fn {x, y} = pos, acc ->
          c = rows |> Enum.at(y) |> Enum.at(x)

          if c == @open_space,
            do: acc,
            else: acc |> Map.update("#{c}", [pos], fn coordinates -> [pos | coordinates] end)
        end
      )

    %{
      antennae: antennae,
      height: height,
      length: length
    }
  end

  defp pairwise_antinodes_on_board(height, length, antennae) do
    Stream.unfold(
      antennae,
      fn
        [] -> nil
        [_h | t] = antennae -> {antennae, t}
      end
    )
    |> Enum.map(fn antennae -> pairwise_antinodes_on_board_for_antenna(antennae, height, length) end)
    |> List.flatten()
  end

  defp pairwise_antinodes_on_board_for_antenna(
         [_antenna],
         _height,
         _length
       ),
       do: []

  defp pairwise_antinodes_on_board_for_antenna(
         [{x_1, y_1} = antenna_1, {x_2, y_2} = _antenna_2 | antennae],
         height,
         length
       ) do
    south_ish? = y_2 - y_1 < 0
    west_ish? = x_2 - x_1 < 0
    {x_min, x_max} = Enum.min_max([x_1, x_2])
    delta_x = x_max - x_min
    {y_min, y_max} = Enum.min_max([y_1, y_2])
    delta_y = y_max - y_min

    north_eastern = [x_max..(length - 1)//delta_x, y_max..(height - 1)//delta_y]
    south_western = [x_min..0//-delta_x, y_min..0//-delta_y]
    south_eastern = [x_max..(length - 1)//delta_x, y_min..0//-delta_y]
    north_western = [x_min..0//-delta_x, y_max..(height - 1)//delta_y]

    [antinodes_1, antinodes_2] =
      if west_ish? and south_ish? or (not west_ish? and not south_ish?) do
        [south_western, north_eastern]
      else
        [south_eastern, north_western]
      end
      |> Enum.map(
        fn ranges ->
          ranges
          |> Enum.map(&Enum.to_list(&1))
          |> Enum.zip()
        end
      )

    [
      antinodes_1 | [antinodes_2 | pairwise_antinodes_on_board_for_antenna([antenna_1 | antennae], height, length)]
    ]
    |> List.flatten()
  end

  defp count_antinodes(%{
         antennae: antennae,
         height: height,
         length: length
       }) do
    antennae
    |> Map.keys()
    |> Enum.reduce(
      MapSet.new(),
      fn frequency, antinodes ->
        pairwise_antinodes_on_board(height, length, antennae[frequency])
        |> Enum.reduce(antinodes, &MapSet.put(&2, &1))
      end
    )
    |> Enum.count()
  end

  def solve() do
    File.read!("08/input.txt")
    |> parse()
    |> count_antinodes()
    |> IO.puts()
  end
end

Day8.Part1.solve()

A bit of a crude solution, particularly when it came to part2. Copy/paste/modify got the job done without bothering to refactor anything. Trying to get caught up.

defmodule Aoc2024.Day8 do
  @moduledoc false

  defp coord(i, width) do
    {Integer.mod(i, width), Integer.floor_div(i, width)}
  end

  defp get_bounds(data) do
    map =
      data
      |> String.split("\n", trim: true)
      |> Enum.map(&String.split(&1, "", trim: true))

    {length(List.first(map)) - 1, length(map) - 1}
  end

  defp get_input(data) do
    map =
      data
      |> String.split("\n", trim: true)
      |> Enum.map(&String.split(&1, "", trim: true))

    width = length(List.first(map))

    data
    |> String.replace("\n", "")
    |> String.split("", trim: true)
    |> Enum.with_index()
    |> Enum.filter(fn {c, _} -> c != "." end)
    |> Enum.reduce(Map.new(), fn {v, i}, antennas ->
      Map.update(antennas, v, [coord(i, width)], fn s ->
        [coord(i, width) | s]
      end)
    end)
  end

  # Generate the pair of antinodes for a given pair of nodes.
  defp antinode(a = {ax, ay}, b = {bx, by}) do
    dx = bx - ax
    dy = by - ay
    if a != b, do: [{ax - dx, ay - dy}, {bx + dx, by + dy}], else: []
  end

  # Generate all antinodes including those out of bounds and duplicates.
  defp find_antinodes(antennas) do
    for locations <- Map.values(antennas) do
      for a <- locations, b <- locations do
        antinode(a, b)
      end
    end
  end

  defp in_bounds?({x, y}, {last_x, last_y}) do
    x >= 0 and x <= last_x and y >= 0 and y <= last_y
  end

  def part1(file) do
    data = File.read!(file)

    get_input(data)
    |> find_antinodes()
    |> List.flatten()
    |> Enum.uniq()
    |> Enum.filter(&in_bounds?(&1, get_bounds(data)))
    |> Enum.count()
  end

  # Generate all antinodes for a pair of antennas radiating outward.
  defp antinode2(a = {ax, ay}, b = {bx, by}, mult \\ 1) do
    # Cheating a bit here instead of passing in the maximum position indexes.
    size = 49
    dx = (bx - ax) * mult
    dy = (by - ay) * mult

    if a == b do
      [a]
    else
      n = {ax - dx, ay - dy}
      m = {bx + dx, by + dy}
      if not in_bounds?(n, {size, size}) and not in_bounds?(m, {size, size}) do
        []
      else
        [n | [m | antinode2(a, b, mult + 1)]]
      end
    end
  end

  defp find_antinodes2(antennas) do
    for locations <- Map.values(antennas) do
      for a <- locations, b <- locations do
        antinode2(a, b)
      end
    end
  end

  def part2(file) do
    data = File.read!(file)

    get_input(data)
    |> find_antinodes2()
    |> List.flatten()
    |> Enum.uniq()
    |> Enum.filter(&in_bounds?(&1, get_bounds(data)))
    |> Enum.sort()
    |> Enum.count()
  end
end