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
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
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
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
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
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
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.
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
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. ![]()
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.
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).
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
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.
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)
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
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!
Wow, the compile-time @around trick is pretty neat, I’m stealing it! ![]()
this would have saved the many different times I fat-fingered the iterations for generating a list of neighbours lol
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)}"






















