Spoiler alert
This is a full solution for Advent of Code 2025, day 4 puzzles with explanations.
If you want to try to solve it yourself first, head over to https://adventofcode.com/2025/day/4 and give it a go and then come back and compare notes.
You can find the full code at https://github.com/Hamatti/adventofcode-2025/blob/main/src/day_4.py
Paper rolls and forklifts! We have arrived at the printing department and want to make our way to the cafeteria just behind the wall. But all the forklifts that could be used to break the wall are being used manouvering paper rolls around. Let’s help the elves to figure out which paper rolls to move so we can borrow the forklifts.
Read input
Today is the first grid puzzle of the year, what a wonderful day it is. I’m using what’s called a sparse grid approach: instead of storing every element into a list of lists, I store them in a dictionary and only store positions that have a paper roll.
from collections import namedtuple
Coordinate = namedtuple("Coordinate", ["x", "y"])Coordinate systems can get annoying to debug quite quickly when you try to balance in your head if you stored x or y first and dealing with numeric tuple indices is an easy way to get a small nasty bug introduced in your code.
Hence, I’m using one of my all-time favourite data structures, a namedtuple. It allows us to give a tuple a name and give name to both of all of its indices. (If you want to learn more about why they rock, check out my blog post on namedtuples.)
To read the grid in, I have a helper function in my utility belt:
def create_grid(
inputs: List[List[str]],
map_fn: Callable[[str], any] = lambda x: x,
predicate: Callable[[str], bool] = lambda x: True,
):
"""
Turns a 2D list of lists into a dictionary with (x, y)
coordinates as keys.
:param map_fn Maps input value into a new value.
:param predicate Filter function for which values to keep
:returns Dictionary with coordinate tuples as keys and converted values as values
>>> create_grid([['1','2'], ['4','5']])
{Coordinate(x=0, y=0): '1', Coordinate(x=1, y=0): '2', Coordinate(x=0, y=1): '4', Coordinate(x=1, y=1): '5'}
>>> create_grid([['10', '20'], ['30', '40']], map_fn=int)
{Coordinate(x=0, y=0): 10, Coordinate(x=1, y=0): 20, Coordinate(x=0, y=1): 30, Coordinate(x=1, y=1): 40}
>>> create_grid([['.', '20'], ['30', '.']], map_fn=int, predicate=lambda x: x != '.')
{Coordinate(x=1, y=0): 20, Coordinate(x=0, y=1): 30}
"""
grid = {}
for y, row in enumerate(inputs):
for x, cell in enumerate(row):
if predicate(cell):
grid[Coordinate(x, y)] = map_fn(cell)
return gridIt takes a list of list as inputs (this is what my read_input returns when I use list as a mapping function), a mapper function that is called on every cell and a predicate function that is used for filtering which cells we include.
Using these, I can build my sparse grid:
PAPER_ROLL = '@'
def is_paper_roll(cell: str) -> bool:
"""Checks whether a string is a @"""
return cell == PAPER_ROLL
# Later on when reading the data
inputs = read_input(4, list)
grid = create_grid(inputs, predicate=is_paper_roll)I give @ a name of PAPER_ROLL. In programming, these kind of strings and numbers are called magic numbers when they are in the code unnamed. It’s a bad habit because it can lead to confusion on the reader’s part but also leads to bugs through typos.
Also, if I’d need to ever later change this symbol used — for example in real life the data format could change later — I’d only ever need to change it in one place instead of having to find all the places it’s being used in the code.
Part 1
In the first part, we need to calculate how many paper rolls can be moved. A paper roll is accessible if it has another paper roll in less than 4 of its neighbouring positions.
def can_access(position: Coordinate, grid: Dict[Coordinate, str]) -> bool:
"""Given a position and sparse grid,
checks whether the position has less than 4
paper rolls in its 8 neighbouring cells"""
MAX_ALLOWED_NEIGHBOURS = 4
if position not in grid:
return False
# Deltas for each neighbour, going clock-wise
# from top-left
neighbours = [
(-1, -1), (0, -1), (1, -1), (1, 0),
(1, 1), (0, 1), (-1, 1), (-1, 0)
]
neighbouring_rolls = 0
for dx, dy in neighbours:
neighbour = Coordinate(x=position.x+dx, y=position.y+dy)
if neighbour in grid:
neighbouring_rolls += 1
return neighbouring_rolls < MAX_ALLOWED_NEIGHBOURSFirst we need a way to know if a paper roll can be accessed or not.
The first if clause is a safeguard against silly bugs: I’m only ever calling this function with positions present in the grid but wanted to make sure results didn’t get skewed if I made a mistake somewhere down the line.
We then list all the neighbouring coordinates as deltas from the point of origin (current position).
We then go through all these deltas, create a real coordinate for them and check if they exist in the grid. If less than 4 of these exists, the position is accessible. As the only items in our sparse grid dictionary are paper rolls, we don’t need to worry about the values, just the keys.
Here, I’m once again giving a name to a magic number. The last line is much more readable now compared to it being return neighbouring_rolls < 4. The reader of the code doesn’t have to think about “hmm, what does this 4 mean” but get the logical reason right away. It could also be made a parameter of the function if later on we’d need to check for a different value.
To then calculate how many paper rolls are accessible all together, we loop over all positions and check:
def part_1() -> int:
"""How many rolls of paper can be accessed by a forklift?"""
inputs = read_input(4, list)
grid = create_grid(inputs, predicate=is_paper_roll)
accessible_rolls = 0
for pos in grid:
if can_access(pos, grid):
accessible_rolls += 1
return accessible_rollsI think this kind of for loop is great for readability: almost anyone can follow the code and what happens. In Python, we also have list comprehensions that I mentioned a couple of days ago and this could be completed in a comprehension:
def part_1() -> int:
"""How many rolls of paper can be accessed by a forklift?"""
inputs = read_input(4, list)
grid = create_grid(inputs, predicate=is_paper_roll)
return sum(1 for position in grid if can_access(position, grid))Sometimes it can be nice to one-line things but it shouldn’t be your goal as a programmer. Code readability is more important than “clever” solutions but if you write more Python later, you’re bound to running into these comprehensions so I’ll show how it’s done with them.
Here, we use a generator expression which is similar to list comprehensions but doesn’t actually create a list in memory. It looks like (value for item in iterable) and we can add an if clause at the end to filter only the items we want. Since we’re counting how many there are, we use constant value 1 and then run sum() function over it.
Part 2
Now that we know how many we can access, it’s time to start moving them out of the way. In this second part, we need to find out how many we can move away until we can’t anymore.
My solution here could probably be optimised more but it still runs in less than a second so I’m not worried about it.
def part_2() -> int:
"""How many rolls of paper in total can be removed by the Elves and their forklifts?
This time, once a batch of rolls has been removed, another batch will be removed as long as there are accessible rolls."""
inputs = read_input(4, list)
grid = create_grid(inputs, predicate=is_paper_roll)
was_removed = 0
while True:
to_remove = {}
for pos in grid:
if can_access(pos, grid):
was_removed += 1
to_remove[pos] = PAPER_ROLL
if to_remove:
grid = dict(grid.items() - to_remove.items())
else:
break
return was_removedHere, I keep track of how many were removed and on each iteration, what we can remove. After we’ve gone through all positions, we remove those from the original grid and keep going until nothing was moved.
Double ⭐️ day again, we’re 8/8!
Using a sparse grid here made the code so much simpler and cleaner and I’m really happy with how it reads.
Addendum A - calculating removed rolls
As I let the puzzle simmer in my subconscious throughout the day, I realised I can simplify the code for where I calculate how many are removed.
Since I’m using a sparse grid where only coordinates where I have a roll are included in the dictionary, I can compare the original length with the final length to find out how many were removed.
This lets me remove a helper variable was_removed and its increments from within the code.
def part_2() -> int:
"""How many rolls of paper in total can be removed by the Elves and their forklifts?
This time, once a batch of rolls has been removed, another batch will be removed as long as there are accessible rolls."""
inputs = read_input(4, list)
grid = create_grid(inputs, predicate=is_paper_roll)
original_roll_count = len(grid)
while True:
to_remove = {}
for pos in grid:
if can_access(pos, grid):
to_remove[pos] = PAPER_ROLL
if to_remove:
grid = dict(grid.items() - to_remove.items())
else:
break
return original_roll_count - len(grid)Appendum B - using sets
A good example of letting the domain (it’s a grid!) to influence data structure selection as I went with a dictionary which is my go-to for grids. Once I read Dr. Neil’s solution that used sets instead, I realised it would be a cool approach indeed.
I added a set-based solution as a separate file but here’s the gist:
Since we’re dealing with a sparse grid where we don’t care about the values, we can use sets instead of dictionaries. This makes neighbour calculation a set intersect between the grid set and neighbours set.
def can_access(position: Coordinate, grid: set) -> bool:
"""Given a position and sparse grid,
checks whether the position has less than 4
paper rolls in its 8 neighbouring cells"""
MAX_ALLOWED_NEIGHBOURS = 4
# Deltas for each neighbour, going clock-wise
# from top-left
neighbours = set()
for dx, dy in (
(-1, -1),
(0, -1),
(1, -1),
(1, 0),
(1, 1),
(0, 1),
(-1, 1),
(-1, 0),
):
neighbours.add(Coordinate(x=position.x + dx, y=position.y + dy))
return len(grid & neighbours) < MAX_ALLOWED_NEIGHBOURSalso in part 2, I can delete removed items by using a set instead of dict with
if to_remove:
grid -= to_removeThanks Neil for sharing your solution and giving me new ideas!
Get in touch
If you want to comment on these solutions, I share them in Mastodon where you can look for the post for the right date or you can start a discussion over email with juhamattisantala@gmail.com.
Follow via RSS
I have created a custom RSS feed for these solutions so you can follow along and not miss any of them!