Advent of Code 2025 - Day 4

defmodule Day04 do
  def part1(input) do
    grid = parse(input)
    Enum.count(removable(grid))
  end

  def part2(input) do
    grid = parse(input)
    remove(grid, 0)
  end

  defp remove(grid, num_removed) do
    case removable(grid) do
      [] ->
        num_removed
      [_|_]=ps ->
        grid = Enum.reduce(ps, grid, &(Map.delete(&2, &1)))
        remove(grid, num_removed + length(ps))
    end
  end

  defp removable(grid) do
    grid
    |> Enum.filter(fn {_position, what} -> what === ?@ end)
    |> Enum.filter(fn {position, _} ->
      adjacent_squares(position)
      |> Enum.count(fn position->
        get_grid(grid, position) === ?@
      end)
      |> then(&(&1 < 4))
    end)
    |> Enum.map(&elem(&1, 0))
  end

  defp get_grid(grid, position) do
    Map.get(grid, position, ?.)
  end

  defp adjacent_squares({row, col}) do
    [{row - 1, col - 1}, {row - 1, col}, {row - 1, col + 1},
     {row, col - 1}, {row, col + 1},
     {row + 1, col - 1}, {row + 1, col}, {row + 1, col + 1}]
  end

  defp parse(input) do
    parse_grid(input)
  end

  defp parse_grid(grid) do
    grid
    |> Enum.with_index
    |> Enum.flat_map(fn {line, row} ->
      String.to_charlist(line)
      |> Enum.with_index
      |> Enum.flat_map(fn {char, col} ->
        position = {row, col}
        [{position, char}]
      end)
    end)
    |> Map.new
  end
end

8 Likes

I love immutability because you can just compare a term against its previous version without managing the copy yourself:

case Map.drop(grid, Map.keys(accessibles(grid))) do
  ^grid -> grid
  new_grid -> loop_remove(new_grid)
end

In context:

defmodule AdventOfCode.Solutions.Y25.Day04 do
  alias AoC.Input

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

    for {row, y} <- Enum.with_index(lines),
        {"@", x} <- Enum.with_index(String.graphemes(row)),
        reduce: %{},
        do: (grid -> Map.put(grid, {x, y}, true))
  end

  def part_one(grid) do
    map_size(accessibles(grid))
  end

  def part_two(grid) do
    clear_grid = loop_remove(grid)
    map_size(grid) - map_size(clear_grid)
  end

  defp loop_remove(grid) do
    case Map.drop(grid, Map.keys(accessibles(grid))) do
      ^grid -> grid
      new_grid -> loop_remove(new_grid)
    end
  end

  defp accessibles(grid) do
    Map.filter(grid, &less_than_four_neighbors?(&1, grid))
  end

  defp less_than_four_neighbors?({{x, y}, _}, grid) do
    for(nx <- (x - 1)..(x + 1), ny <- (y - 1)..(y + 1), {nx, ny} != {x, y}, do: {nx, ny})
    |> Enum.count(&Map.has_key?(grid, &1))
    |> Kernel.<(4)
  end
end
5 Likes
defmodule Day4 do
  def file, do: Parser.read_file(4)
  def test, do: Parser.read_file("test")

  def parse(input) do
    Utils.to_xy_map(input)
  end

  def solve(input \\ file()) do
    map = parse(input)

    map
    |> Enum.reduce(0, fn
      {_pos, "."}, acc -> acc
      {pos, _value}, acc -> if four_rolls?(pos, map), do: acc + 1, else: acc
    end)
  end

  def four_rolls?(pos, map) do
    pos
    |> Utils.neighbours_coordinates()
    |> Enum.filter(fn coord -> Map.get(map, coord) == "@" end)
    |> Enum.count()
    |> Kernel.<(4)
  end

  def solve_two(input \\ file()) do
    map = parse(input)
    remove_rolls({map, 0})
  end

  def remove_rolls(tuple, map \\ %{})
  def remove_rolls({map, counter}, map), do: counter

  def remove_rolls({map, _counter} = tuple, _oldmap) do
    map
    |> Enum.reduce(tuple, fn
      {_pos, "."}, acc ->
        acc

      {pos, _value}, {updated_map, counter} ->
        if four_rolls?(pos, updated_map) do
          {Map.put(updated_map, pos, "."), counter + 1}
        else
          {updated_map, counter}
        end
    end)
    |> remove_rolls(map)
  end
end

Utils function ( always have grid for AOC so I’m re-using those )

defmodule Utils do
  def to_xy_map(input) do
    input
    |> to_list_of_list()
    |> nested_list_to_xy_map()
  end

  def to_list_of_list(file), do: Enum.map(file, &String.graphemes/1)

  def nested_list_to_xy_map(list) do
    list
    |> Enum.map(&Stream.with_index/1)
    |> Stream.with_index()
    |> Enum.reduce(%{}, fn {list, x}, acc ->
      Enum.reduce(list, acc, fn {value, y}, acc ->
        Map.put(acc, {y, x}, value)
      end)
    end)
  end

  def neighbours_coordinates({x, y}) do
    [
      {x + 1, y},
      {x + 1, y - 1},
      {x + 1, y + 1},
      {x, y - 1},
      {x, y + 1},
      {x - 1, y},
      {x - 1, y - 1},
      {x - 1, y + 1}
    ]
  end
end
1 Like

I already had a module for working with 2D arrays defined for prior AOC runs, so today’s was straightforward:

defmodule Y2025.Day04 do
  def parse(s) do
    s
    |> String.split("\n")
    |> Enum.map(&String.graphemes/1)
    |> Array.from_list()
  end

  def part1(s) do
    a = parse(s)

    a
    |> Array.map_all(fn x, y, v -> removable?(a, x, y, v) end)
    |> Array.to_list()
    |> Enum.count(& &1)
  end

  def removable?(a, x, y, v) do
    v == "@" &&
      Array.neighbors(a, {x, y}) # gives the indexes of all valid neighbors
      |> Enum.map(&Array.get(a, &1))
      |> Enum.count(&(&1 == "@")) < 4
  end

  def part2(s) do
    a = parse(s)
    b = fixed_point(&remove/1, a)

    count(a) - count(b)
  end

  def fixed_point(f, x) do
    do_fixed_point(f, count(x), f.(x))
  end

  def do_fixed_point(f, old_count, a) do
    new_count = count(a)

    if new_count == old_count do
      a
    else
      do_fixed_point(f, new_count, f.(a))
    end
  end

  def remove(a) do
    Array.map_all(a, fn x, y, v -> if removable?(a, x, y, v), do: ".", else: v end)
  end

  def count(a) do
    Array.to_list(a) |> Enum.count(&(&1 == "@"))
  end
end

2 Likes

I did my best to never construct a matrix, binaries rock. Also, Stream.iterate(&calc/1) is a lovely approach to code reuse.

  defmodule Day4 do
    @input "day4_1.input" |> File.read!() |> String.trim()

    defp read_input(input) do
      input
      |> String.split(["\s", "\n"], trim: true)
      |> then(fn [h | t] ->
        stub = "." |> List.duplicate(byte_size(h)) |> Enum.join()
        [stub, h | t] ++ [stub]
      end)
      |> Enum.map(&("." <> &1 <> "."))
    end

    def calc({input, acc} \\ {@input, 0}) do
      input
      |> read_input()
      |> Enum.chunk_every(3, 1, :discard)
      |> Enum.reduce({0, []}, fn [h, l, t], {count, res} ->
        res = ["" | res]

        Enum.reduce(1..(byte_size(l) - 2), {count, res}, fn idx, {count, [hres | tres]} ->
          with <<_::binary-size(idx - 1), r1::binary-size(3), _::binary>> <- h,
               <<_::binary-size(idx - 1), r21::binary-size(1), r22::binary-size(1),
                 r23::binary-size(1), _::binary>> <- l,
               <<_::binary-size(idx - 1), r3::binary-size(3), _::binary>> <- t,
               false <- r22 == "@" and String.count(r1 <> r21 <> r23 <> r3, "@") < 4,
               do: {count, [r22 <> hres | tres]},
               else: (_ -> {count + 1, ["x" <> hres | tres]})
        end)
      end)
      |> then(fn {count, data} -> {Enum.join(data, "\n"), acc + count} end)
    end

    def calc_2(input \\ @input) do
      {input, 0}
      |> Stream.iterate(&calc/1)
      |> Enum.reduce_while({0, -1}, fn
        {_, prev}, {acc, prev} -> {:halt, acc}
        {_, count}, {acc, _} -> {:cont, {count, acc}}
      end)
    end
  end
3 Likes

Omitting input parsing code because I think it’s trivial.

coords in the following code is a MapSet.t({i, j}) where {i, j}'s are the coordinates of paper scrolls (@) only.

Part 1

Enum.count(coords, fn {i, j} ->
  for di <- -1..1//1,
      dj <- -1..1//1,
      {i + di, j + dj} in coords,
      reduce: 0 do
    acc -> acc + 1
  end
  |> Kernel.<(5)
end)

Part 2

The idea is to prioritize the removal of the scroll with fewest neighbors.

defmodule P2 do
  def solve(coords) do
    heap =
      coords
      |> Stream.map(&{length(adjs(coords, &1)), &1})
      |> MinHeap.new()

    after_removal = remove(coords, heap)

    MapSet.size(coords) - MapSet.size(after_removal)
  end

  defp remove(coords, heap) do
    # Pop the (maybe removed) scroll with the least neighbors
    # at the time when it was pushed into the heap.
    # Practically impossible to pop from an empty heap.
    {:ok, {adj_count, curr}, heap} = MinHeap.pop(heap)

    cond do
      # If the scroll (could have been removed) had 4 or more neighbors
      # at the time it was pushed into the heap,
      # then there's no more scroll to remove.
      # Stop the recursion.
      adj_count >= 4 ->
        coords

      # If the scroll has already been removed,
      # then just ignore this scroll and continue recursion.
      !MapSet.member?(coords, curr) ->
        remove(coords, heap)

      # Otherwise, remove the current scroll,
      # get the neighbors of the current scroll,
      # for each neighbor, count the neighbors of that neighbor,
      # push {neighbor_neighbor_count, neighbor} into the heap,
      # and continue recursion.
      true ->
        coords = MapSet.delete(coords, curr)

        heap =
          coords
          |> adjs(curr)
          |> Enum.reduce(heap, fn adj, heap ->
            MinHeap.push(heap, {length(adjs(coords, adj)), adj})
          end)

        remove(coords, heap)
    end
  end

  defp adjs(coords, {i, j}) do
    for di <- [-1, 0, 1],
        dj <- [-1, 0, 1],
        di != 0 or dj != 0,
        coord = {i + di, j + dj},
        MapSet.member?(coords, coord),
        do: coord
  end
end

Appendix: MinHeap impl

defmodule MinHeap do
  def new(enumerable \\ []) do
    enumerable
    |> Enum.sort(:desc)
    |> Enum.reduce([], &[{&1, &2}])
    |> List.first()
  end

  def push(heap, value) do
    meld(heap, {value, []})
  end

  def pop(nil) do
    {:error, :empty, nil}
  end

  def pop({value, children}) do
    {:ok, value, pair(children)}
  end

  defp meld(nil, heap), do: heap
  defp meld(heap, nil), do: heap
  defp meld({v1, c1} = h1, {v2, c2} = h2) do
    if v1 < v2 do
      {v1, [h2 | c1]}
    else
      {v2, [h1 | c2]}
    end
  end

  defp pair([]), do: nil
  defp pair([heap]), do: heap
  defp pair([h1, h2 | t]) do
    meld(meld(h1, h2), pair(t))
  end
end
1 Like

This was pretty easy - I also have a Grid module in my app that converts 2D grids into a map of %{{row, col} => val}.

My initial implementation for part 2 was super naive, and checked all rolls present in the grid on every pass. But you only need to check rolls that are adjacent to rolls that were removed on the last pass, which cut my run time from 230ms to 30ms.

3 Likes

Pretty easy. I’ve got to look at all these grid modules people are using. For me I just stored all the indices in a Map and used their presence in the map to indicate where the rolls of paper are.

defmodule RAoc.Solutions.Y25.Day04 do
  alias AoC.Input

  def parse(input, _part) do
    Input.stream!(input, trim: true)
    |> Stream.with_index(fn line, y ->
      line
      |> String.split("", trim: true)
      |> Enum.with_index(fn char, x ->
        {{x, y}, char}
      end)
      |> Enum.filter(fn {_, char} -> char == "@" end)
    end)
    |> Enum.to_list()
    |> List.flatten()
    |> Enum.into(%{})
  end

  def part_one(problem) do
    Enum.reduce(problem, 0, fn {{x, y}, _}, count ->
      if neighbors(x, y, problem) < 4, do: count + 1, else: count
    end)
  end

  def part_two(problem) do
    count_and_remove(problem, 0)
  end

  defp count_and_remove(map, count) do
    {new_map, this_count} =
      Enum.reduce(map, {map, 0}, fn {{x, y}, _}, {new_map, count} ->
        if neighbors(x, y, map) < 4 do
          {Map.delete(new_map, {x, y}), count + 1}
        else
          {new_map, count}
        end
      end)

    if this_count == 0 do
      count
    else
      count_and_remove(new_map, count + this_count)
    end
  end

  defp neighbors(x, y, problem) do
    for xn <- (x - 1)..(x + 1), yn <- (y - 1)..(y + 1), reduce: 0 do
      count ->
        if (x != xn or y != yn) and Map.get(problem, {xn, yn}) != nil do
          count + 1
        else
          count
        end
    end
  end
end

3 Likes
defmodule Aoc2025.Solutions.Y25.Day04 do
  alias AoC.Input

  def parse(input, _part) do
    input
    |> Input.read!()
    |> String.trim()
    |> String.split("\n")
    |> Enum.with_index()
    |> Enum.map(fn {line, y} ->
      line
      |> String.codepoints()
      |> Enum.with_index()
      |> Enum.map(fn {char, x} ->
        case char do
          "." -> {{x, y}, false}
          "@" -> {{x, y}, true}
        end
      end)
    end)
    |> List.flatten()
    |> Enum.into(%{})
  end

  def part_one(problem) do
    list = Map.to_list(problem)
    {_new_problem, acc} = find_roles_of_paper(list, problem, 0, %{})
    acc
  end

  def part_two(problem, acc \\ 0) do
    list = Map.to_list(problem)
    {new_problem, new_acc} = find_roles_of_paper(list, problem, acc, %{})
    if new_problem == problem, do: new_acc, else: part_two(new_problem, new_acc)
  end

  defp find_roles_of_paper([], _problem, acc, new_problem), do: {new_problem, acc}

  defp find_roles_of_paper([{{x, y}, is_paper} | rest], problem, acc, new_problem) do
    condition = is_paper and less_than_four_neighbors?(x, y, problem)
    acc = if condition, do: acc + 1, else: acc
    is_paper = not condition and is_paper
    new_problem = Map.put(new_problem, {x, y}, is_paper)
    find_roles_of_paper(rest, problem, acc, new_problem)
  end

  defp less_than_four_neighbors?(x, y, problem) do
    Enum.zip([-1, 1, 0, 0, -1, 1, -1, 1], [0, 0, -1, 1, -1, -1, 1, 1])
    |> Enum.map(fn {x_diff, y_diff} -> {x + x_diff, y + y_diff} end)
    |> Enum.filter(fn {x, y} -> Map.get(problem, {x, y}, false) end)
    |> length() < 4
  end
end

Finally an easier one. :sweat_smile:

1 Like

Nice. Question: What Array module are you using here? Home grown? Last year I was using :arrays from hex packages, but it doesn’t compile anymore w/ Elixir 1.19.

1 Like

Parse

rolls =
  puzzle_input
  |> String.split("\n", trim: true)
  |> Enum.with_index()
  |> Enum.flat_map(fn {line, row} ->
    line
    |> String.to_charlist()
    |> Enum.with_index()
    |> Enum.filter(&(elem(&1, 0) == ?@))
    |> Enum.map(&{elem(&1, 1), row})
  end)
  |> MapSet.new()

Implementation

defmodule PaperRolls do
  def adjacent({x, y}) do
    for dx <- -1..1,
        dy <- -1..1,
        {dx, dy} != {0, 0},
        do: {x + dx, y + dy}
  end

  def free?(pos, map) do
    pos
    |> adjacent()
    |> Enum.count(&(&1 in map))
    |> then(&(&1 < 4))
  end
end

Part 1

Enum.count(rolls, &PaperRolls.free?(&1, rolls))

Part 2

cleaned =
  Stream.repeatedly(fn -> [] end)
  |> Enum.reduce_while(rolls, fn _, acc ->
    removable =
      Enum.filter(acc, &PaperRolls.free?(&1, acc))
      |> MapSet.new()

    case MapSet.difference(acc, removable) do
      ^acc -> {:halt, acc}
      remaining -> {:cont, remaining}
    end
  end)

At first I was removing rolls one by one in P2, but that was super slow. After changing it to do everything at once, then it became much faster (90s+ vs 0.2s).

3 Likes

Today I want to propose a new function for Elixir kernel: String.to_aoc_grid/1. But it’s not yet implemented so here we go (again)

Ps. Yes, there is a boolean arg. Don’t try that at home.
Pps. Counter + count. Naming is hard.

defmodule Aoc2025.Solutions.Y25.Day04 do
  alias AoC.Input

  @max_surrounding_rolls 3
  @roll ?@

  def parse(input, _part) do
    Input.read!(input)
    |> String.trim()
    |> String.split("\n", split: true)
    |> Enum.map(&String.to_charlist/1)
    |> build_initial_grid()
  end

  def part_one(problem), do: count_accessible_rolls(problem)
  def part_two(problem), do: count_accessible_rolls(problem, true)

  def build_initial_grid(bare_grid_rows) do
    for {row_data, row_idx} <- Enum.with_index(bare_grid_rows),
        {col_data, col_idx} <- Enum.with_index(row_data),
        into: %{} do
      {{row_idx, col_idx}, col_data == @roll}
    end
  end

  def count_surrounding_rolls(grid, {row, col}) do
    for y <- [row - 1, row, row + 1],
        x <- [col - 1, col, col + 1],
        !(row == y && col == x),
        reduce: 0 do
      counter -> if grid[{y, x}], do: counter + 1, else: counter
    end
  end

  def count_accessible_rolls(problem, reduce \\ false, counter \\ 0) do
    {grid, count} =
      Enum.reduce(problem, {problem, 0}, fn
        {_cell, false}, acc ->
          acc

        {cell, true}, {grid, count} = acc ->
          if count_surrounding_rolls(problem, cell) > @max_surrounding_rolls,
            do: acc,
            else: {%{grid | cell => false}, count + 1}
      end)

    if reduce && count > 0 do
      count_accessible_rolls(grid, reduce, counter + count)
    else
      counter + count
    end
  end
end
1 Like

It’s a homegrown one using array — stdlib v7.1 plus dimension info so that it can convert {x, y} into x + y * x_max etc.

I think part of the fun of solving AOC is growing your own little utility libraries over time. I had so much fun improving my backtracking library, for example, and can’t wait for another problem that would require it.

2 Likes

2025 Dec 04

Printing Department

defmodule Forklift do
  def parse_line({line, y}, set) do
    String.trim(line)
      |> to_charlist()
      |> Enum.with_index()
      |> Enum.reduce(set, fn {ch, x}, set ->
        case ch do
          ?. -> 
            set
          ?@ ->
            MapSet.put(set, {x, y})
        end
      end)
  end

  def parse(lines) do
    Enum.with_index(lines)
      |> Enum.reduce(MapSet.new(), &parse_line/2)
  end

  def neighbors({x, y}) do
    for xx <- (x - 1)..(x + 1), yy <- (y - 1)..(y + 1), x != xx or y != yy do
      {xx, yy}
    end
  end

  def accessible(set) do
    Enum.filter(set, fn pos ->
      ncount = Enum.reduce(neighbors(pos), 0, fn p2, count ->
        if MapSet.member?(set, p2) do
          count + 1
        else
          count
        end
      end)
      ncount < 4
    end)
      |> MapSet.new()
  end
end
test_set = """
..@@.@@@@.
@@@.@.@.@@
@@@@@.@.@@
@.@@@@..@.
@@.@@@@.@@
.@@@@@@@.@
.@.@.@.@@@
@.@@@.@@@@
.@@@@@@@@.
@.@.@@@.@.
""" |> String.split("\n", trim: true)
  |> Forklift.parse()
Forklift.accessible(test_set)
  |> Enum.count()
input_set = File.stream!(__DIR__ <> "/dec-04-input.txt")
  |> Forklift.parse()
Forklift.accessible(input_set)
  |> MapSet.size()

Part Two

defmodule Forklift2 do
  def total_removable(set, acc \\ 0) do
    accessible = Forklift.accessible(set)
    count = MapSet.size(accessible)
    if count == 0 do
      acc
    else
      set = MapSet.difference(set, accessible)
      total_removable(set, acc + count)
    end
  end
end
Forklift2.total_removable(test_set)
Forklift2.total_removable(input_set)
1 Like

This one was pretty straightforward. Using MapSet (and MapSet operations) and the “for” construct, which is more concise than reduce. No optimization at all


defmodule AdventOfCode.Solution.Year2025.Day04 do
  import Enum, only: [flat_map: 2, with_index: 1, sum: 1, filter: 2, count: 1]

  def parse(input) do
    # Create a MapSet of the {row,col} of the rolls
    with_index(String.split(input, "\n", trim: true))
    |> flat_map(fn {line, row} ->
      for {c, col} <- with_index(to_charlist(line)), c == ?@, do: {row, col}
    end)
    |> MapSet.new()
  end

  def vec_sum({a, b}, {c, d}), do: {a + c, b + d}

  @around for r <- -1..1, c <- -1..1, {r, c} != {0, 0}, do: {r, c}
  def liftable(roll, rolls) do
    sum(for delta <- @around, vec_sum(roll, delta) in rolls, do: 1) < 4
  end

  def part1(input) do
    rolls = parse(input)
    rolls |> filter(&liftable(&1, rolls)) |> count()
  end

  def remove_all(rolls, counter \\ 0) do
    # Identify the removable rolls
    removable = for roll <- rolls, liftable(roll, rolls), into: MapSet.new(), do: roll
    # If nothing to remove, we're done, else remove the removable rolls and increment the counter
    if MapSet.size(removable) == 0,
      do: counter,
      else: remove_all(MapSet.difference(rolls, removable), counter + MapSet.size(removable))
  end

  def part2(input), do: input |> parse() |> remove_all()
end
5 Likes

This is nearly verbatim what I did until the for comprehension. I used a reduce_while inside a reduce_while instead. Time to learn me some better tricks!

2 Likes

Wow, the compile-time @around trick is pretty neat, I’m stealing it! :wink:

1 Like

this would have saved the many different times I fat-fingered the iterations for generating a list of neighbours lol

2 Likes

I first counted the other way around, what rolls are in the way of others, but then I missed the rolls without anything in the way.

Nice to see some Elixir tricks to write it compact.

#!/usr/bin/env elixir

# Advent of Code 2025. Day 4

defmodule M do
  def remove_rolls_once(map) do
    Map.reject(map, fn {{r, c}, _v} ->
      Enum.sum_by([
        map[{r-1,c-1}], map[{r-1,c}], map[{r-1,c+1}],
        map[{r,c-1}], map[{r,c+1}],
        map[{r+1,c-1}], map[{r+1,c}], map[{r+1,c+1}]
        ], fn nil -> 0 ; true -> 1 end) < 4
    end)
  end
  def remove_rolls(map) do
    map2 = remove_rolls_once(map)
    if map_size(map) == map_size(map2), do: map, else: remove_rolls(map2)
  end
end

rows = File.read!("../day04.txt") |> String.split()
map = rows |> Enum.with_index() |> Enum.reduce(%{}, fn {row, r}, map ->
  row |> String.codepoints() |> Enum.with_index() |> Enum.reduce(map, fn {cell, c}, map ->
    if cell == "@", do: Map.put(map, {r, c}, true), else: map
  end)
end)

# Part 1
map2 = M.remove_rolls_once(map)
IO.puts "Day 5. Part 1: #{map_size(map) - map_size(map2)}"

# Part 2
map2 = M.remove_rolls(map)
IO.puts "Day 5. Part 2: #{map_size(map) - map_size(map2)}"
1 Like