Advent of Code 2025: Day 4
Problem
tl;dr — You have a grid with paper rolls (@) and empty spaces (.). A forklift can access a roll only if it has fewer than 4 neighboring rolls (out of the 8 adjacent positions).
Part 1: Count how many rolls are accessible by forklifts (single pass).
Part 2: Remove accessible rolls iteratively. Once a roll is removed, other rolls may become accessible. Keep removing until no more are accessible. Count the total removed.
Example:
..@@.@@@@.
@@@.@.@.@@
@@@@@.@.@@
@.@@@@..@.
@@.@@@@.@@
.@@@@@@@.@
.@.@.@.@@@
@.@@@.@@@@
.@@@@@@@@.
@.@.@@@.@.
| Round | Removed | Running Total |
|---|---|---|
| 1 | 13 | 13 |
| 2 | 12 | 25 |
| 3 | 7 | 32 |
| 4 | 5 | 37 |
| 5 | 2 | 39 |
| 6 | 1 | 40 |
| 7 | 1 | 41 |
| 8 | 1 | 42 |
| 9 | 1 | 43 |
Part 1 answer: 13 Part 2 answer: 43
Key insight: For each @, count its 8 neighbors that are also @. If count < 4, it’s accessible. Part 2 is just Part 1 in a loop until nothing changes.
My Solution (Handcrafted)
Data Structure Choice
When I looked at this problem, I thought about what data structure would work best. The options were:
- A 2D matrix (simple grid)
- An adjacency list (graph-based)
Since the problem is fundamentally about counting neighbors and updating those counts when cells are removed, I went with an adjacency list. This way, each cell knows exactly who its @ neighbors are.
I created a Cell struct to track position and type:
#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Copy)]
struct Cell {
x: usize,
y: usize,
cell_type: char,
}
Part 1: Build and Count
Build the adjacency list by streaming through the grid. For each @ cell, add it as a neighbor to all 8 surrounding cells:
pub fn solve_1(filename: &str) -> usize {
let input = Self::read_input(filename);
let grid = input
.into_iter()
.map(|line| line.chars().collect::<Vec<char>>())
.collect::<Vec<Vec<char>>>();
let adjacency_list: HashMap<Cell, Vec<Cell>> = Self::build_adjacency_list(&grid);
// find out all the cells that have less than 4 neighbors
let mut accessible_cells_count = 0;
for (cell, neighbors) in adjacency_list.iter() {
if cell.cell_type == '@' && neighbors.len() < 4 {
accessible_cells_count += 1;
}
}
accessible_cells_count
}
Part 2: Queue-Based Removal
Here’s where the adjacency list pays off. Instead of re-scanning the entire grid each round, I use a queue:
- Find all initially accessible cells, add them to the queue
- Pop a cell from the queue, remove it
- Update all 8 neighbors — remove this cell from their neighbor lists
- If any neighbor now has < 4 neighbors, add it to the queue
- Repeat until queue is empty
let mut accessible_cells = Vec::new();
for (cell, neighbors) in adjacency_list.iter() {
if cell.cell_type == '@' && neighbors.len() < 4 {
accessible_cells.push(cell.clone());
}
}
let mut total_removed_cells = 0;
loop {
let current_accessible_cells = accessible_cells.pop();
if current_accessible_cells.is_none() {
break;
}
let current_accessible_cell = current_accessible_cells.unwrap();
// ... remove from adjacency list and update neighbors ...
if let Some(neighbors) = adjacency_list.get_mut(&neighbor_cell) {
neighbors.retain(|cell| cell != ¤t_accessible_cell);
if neighbors.len() < 4 {
accessible_cells.push(neighbor_cell);
}
}
}
This approach only processes cells that actually need updating, rather than scanning the whole grid repeatedly.
Gemini’s Solution
Gemini went with the simpler approach: a 2D grid.
Part 1: Nested Loop
Straightforward — loop through every cell, count neighbors for each @:
fn count_neighbors(grid: &Vec<Vec<char>>, r: i32, c: i32, rows: i32, cols: i32) -> i32 {
let offsets = [
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
];
let mut count = 0;
for &(dr, dc) in &offsets {
let nr = r + dr;
let nc = c + dc;
if nr >= 0 && nr < rows && nc >= 0 && nc < cols {
if grid[nr as usize][nc as usize] == '@' {
count += 1;
}
}
}
count
}
pub fn solve_1(filename: &str) -> i32 {
let grid = Self::read_input(filename);
let rows = grid.len() as i32;
let cols = grid[0].len() as i32;
let mut accessible_count = 0;
for r in 0..rows {
for c in 0..cols {
if grid[r as usize][c as usize] == '@' {
if Self::count_neighbors(&grid, r, c, rows, cols) < 4 {
accessible_count += 1;
}
}
}
}
accessible_count
}
Part 2: Full Re-scan Each Round
Gemini’s Part 2 just runs Part 1 in a loop, removing all accessible cells each round:
pub fn solve_2(filename: &str) -> i32 {
let mut grid = Self::read_input(filename);
let rows = grid.len() as i32;
let cols = grid[0].len() as i32;
let mut total_removed = 0;
loop {
let mut to_remove = Vec::new();
for r in 0..rows {
for c in 0..cols {
if grid[r as usize][c as usize] == '@' {
if Self::count_neighbors(&grid, r, c, rows, cols) < 4 {
to_remove.push((r, c));
}
}
}
}
if to_remove.is_empty() {
break;
}
total_removed += to_remove.len() as i32;
for (r, c) in to_remove {
grid[r as usize][c as usize] = '.';
}
}
total_removed
}
Simple, but it re-scans the entire grid every single round — even cells that couldn’t possibly have changed.
Comparison
| Aspect | My Solution | Gemini’s Solution |
|---|---|---|
| Data struct | Adjacency list (HashMap) | 2D grid (Vec<Vec<char>>) |
| Part 1 | Build graph, then count | Nested loop with neighbor func |
| Part 2 | Queue-based incremental updates | Full grid re-scan each round |
| Complexity | O(cells removed × 8) updates | O(rounds × grid size) |
| Code length | More verbose, more setup | Compact and readable |
Reflection
Choosing the right data structure upfront makes a difference.
Part 1: Both approaches work. Gemini’s nested loop is straightforward, my adjacency list requires more setup but models the problem as a graph — which is what it actually is.
Part 2: The adjacency list approach shines here. When you remove a cell, you only update its 8 neighbors instead of re-scanning the entire grid. Gemini’s full re-scan works but does redundant work every round.
Different mental models lead to different solutions. I thought about this as a graph problem from the start, Gemini treated it as a grid simulation. Both are valid, mine just happens to scale better for Part 2.
Time spent: ~30 minutes