7 votes

Day 4: Printing Department

Today's problem description: https://adventofcode.com/2025/day/4

Please post your solutions in your own top-level comment. Here's a template you can copy-paste into your comment to format it nicely, with the code collapsed by default inside an expandable section with syntax highlighting (you can replace python with any of the "short names" listed in this page of supported languages):

<details>
<summary>Part 1</summary>

```python
Your code here.
```

</details>

16 comments

  1. fidwell
    Link
    Finally, a character grid problem. If you've done one of these before, hopefully you've saved up some utility methods from previous years to solve this one in a snap. Thoughts My old utility code...

    Finally, a character grid problem. If you've done one of these before, hopefully you've saved up some utility methods from previous years to solve this one in a snap.

    Thoughts

    My old utility code already had methods for (1) parsing the input into an array of chars; (2) give you an IEnumerable of all coordinates in the grid; (3) find the coordinates of the eight neighbors of a coordinate; and (4) replace the character at a coordinate. All I had to write is checking if the neighbor count of `@` is less than 4., and write a loop to replace them until we can't anymore. Nothing really challenging here, and the code runs mighty fast without the need for any micro-optimizations.

    C# code

    3 votes
  2. [2]
    jzimbel
    (edited )
    Link
    Elixir I've built up a pretty robust Grid module over the years—maybe concerningly robust given that it's used solely for a bunch of toy code puzzles—so today's solution was a quick one. No...

    Elixir

    I've built up a pretty robust Grid module over the years—maybe concerningly robust given that it's used solely for a bunch of toy code puzzles—so today's solution was a quick one.

    No special optimizations, I just took the obvious approach.

    Both parts
    defmodule AdventOfCode.Solution.Year2025.Day04 do
      alias AdventOfCode.Grid, as: G
    
      use AdventOfCode.Solution.SharedParse
    
      @impl true
      def parse(input), do: G.from_input(input)
    
      def part1(grid), do: map_size(forklift_cells(grid))
    
      def part2(grid) do
        grid
        |> Stream.unfold(&remove_forkliftable/1)
        |> Enum.sum()
      end
    
      defp forklift_cells(grid) do
        for {coords, ?@} <- grid, forkliftable?(coords, grid), into: %{}, do: {coords, ?.}
      end
    
      defp forkliftable?(coords, grid) do
        grid
        |> G.adjacent_values(coords)
        |> Enum.count(&(&1 == ?@))
        |> Kernel.<(4)
      end
    
      defp remove_forkliftable(grid) do
        removals = forklift_cells(grid)
    
        case map_size(removals) do
          # End the stream.
          0 -> nil
          n -> {n, G.replace(grid, removals)}
        end
      end
    end
    
    Benchmarks
    Name             ips        average  deviation         median         99th %
    Parse         526.68        1.90 ms     ±1.87%        1.90 ms        1.97 ms
    Part 1        159.39        6.27 ms     ±3.94%        6.27 ms        6.95 ms
    Part 2          7.69      130.08 ms     ±0.99%      129.91 ms      134.38 ms
    
    Bonus: Animated passes

    I exported an image of the grid after each forklift pass and assembled a looping animation.

    3 votes
    1. hpr
      Link Parent
      Huh, I was scratching my head a bit if there isn't a better / more efficient way to handle arrays in elixir, but also just opted for a map of coordinates. Minus some naming, fancy language...

      Huh, I was scratching my head a bit if there isn't a better / more efficient way to handle arrays in elixir, but also just opted for a map of coordinates. Minus some naming, fancy language features and your grid module, we landed on pretty much the same solution with very similar performance characteristics too.

      Name               ips        average  deviation         median         99th %
      part one         96.13       10.40 ms     ±5.26%       10.04 ms       11.51 ms
      part two          7.02      142.51 ms     ±2.42%      140.66 ms      147.03 ms
      

      (each part parses once, so that's included here)

      2 votes
  3. zkxs
    Link
    Rust Part 1, 69 µs - Simple 3x3 kernel sliding over every "pixel". I started doing a GPU implementation, which I've never done before and I was horrified at how much boilerplate code it requires,...

    Rust

    Part 1, 69 µs - Simple 3x3 kernel sliding over every "pixel". I started doing a GPU implementation, which I've never done before and I was horrified at how much boilerplate code it requires, and that just setting up the GPU pipeline takes ~100ms. I ditched that idea before I even made it past the GPU example code and went back to the CPU...

    Part 2, 847 µs - Smallest tweak I've had to make yet for a part 2--I just remove @s as I go and keep looping until no more @s are removed in a pass. I thought briefly about getting fancy and trying to track cells that had changes, but I think the move here is just to place my trust in the CPU cache and keep the loop doing a simple linear traversal over memory? I'm not certain about that, though.

    3 votes
  4. [4]
    balooga
    Link
    TypeScript Fairly straightforward today. I did spend some extra time working on optimizations. I'm formatting the grid as boolean[][] to avoid working with strings, and I found that I could...

    TypeScript

    Fairly straightforward today. I did spend some extra time working on optimizations. I'm formatting the grid as boolean[][] to avoid working with strings, and I found that I could determine roll accessibility by just totaling all neighboring cell values if I cast those bools as numbers (either 0 or 1) using Number(grid[row][col]).

    In Part 2 I was initially detecting stability by comparing hashes of the previous and current grid array in each step. It was a real forehead-smacker when I realized I could just check my count of how many rolls were removed in the current iteration, and if the number's zero that means it's stable. Ripped out a lot of slow code to clean that up!

    I was also naively doing a lot of deep clones of the grid array early on, and swapped that out for a 2-grid (current and next) approach that removed a lot of overhead. I'm relatively happy with where this ended up in the end.

    Parts 1 and 2
    type InputData = boolean[][];
    
    const makeEmptyGridLike = (grid: InputData): InputData => grid.map(row => row.map(() => false));
    
    function formatInput(input: string): InputData {
      const inputArr = input.trim().split('\n');
      return inputArr.map(line => line.split('').map(positionString => positionString === '@'));
    }
    
    export function run(input: string): string[] {
      const data = formatInput(input);
    
      const neighborOffsets = [
        [-1, -1],
        [-1, 0],
        [-1, 1],
        [0, -1],
        [0, 1],
        [1, -1],
        [1, 0],
        [1, 1],
      ];
    
      const getCellValue = (grid: InputData, row: number, col: number): number => {
        if (row >= 0 && row < grid.length && col >= 0 && col < grid[row].length) {
          return Number(grid[row][col]);
        }
        return 0;
      };
    
      const countRemovedRollsOnce = (
        sourceGrid: InputData,
        destinationGrid: InputData = makeEmptyGridLike(sourceGrid)
      ): number => {
        let total = 0;
    
        for (let row = 0; row < sourceGrid.length; row++) {
          const sourceRow = sourceGrid[row];
          const destRow = destinationGrid[row];
          for (let col = 0; col < sourceRow.length; col++) {
            const cell = sourceRow[col];
            if (!cell) {
              destRow[col] = false;
              continue;
            }
            let neighborCount = 0;
            for (const [rowOffset, colOffset] of neighborOffsets) {
              neighborCount += getCellValue(sourceGrid, row + rowOffset, col + colOffset);
            }
            destRow[col] = neighborCount >= 4;
            !destRow[col] && total++;
          }
        }
    
        return total;
      };
    
      const countRemovedRollsUntilStable = (grid: InputData): number => {
        let currentGrid = grid.map(row => [...row]);
        let nextGrid = makeEmptyGridLike(grid);
        let totalRemoved = 0;
        let removedThisIteration: number;
    
        do {
          removedThisIteration = countRemovedRollsOnce(currentGrid, nextGrid);
          if (removedThisIteration > 0) {
            totalRemoved += removedThisIteration;
            [currentGrid, nextGrid] = [nextGrid, currentGrid];
          }
        } while (removedThisIteration > 0);
    
        return totalRemoved;
      };
    
      return [`${countRemovedRollsOnce(data)}`, `${countRemovedRollsUntilStable(data)}`];
    }
    

    Part 1 completes in 7ms on my machine; Part 2 completes in 56ms. I have to admit I'm jealous of some of you Rust folks, et al., who are measuring sub-millisecond times for these. I think that kind of performance is always going to be out of reach for my stack! I'm not even sure if the capability exists for me to measure with that kind of precision.

    2 votes
    1. [3]
      zkxs
      Link Parent
      Benchmarking is one of those things that looks pretty simple on the surface (just time it, right?) but actually goes decently deep. The general idea is you don't just measure the code once--you...

      Benchmarking is one of those things that looks pretty simple on the surface (just time it, right?) but actually goes decently deep. The general idea is you don't just measure the code once--you run it in a loop many many times so that you can see how long it takes on average. You also want a warmup phase in which you don't measure the first few seconds of looping. JIT-compiled languages need this warmup because they need to optimize the code, but even AoT-compiled languages need it because the computer has a bunch of sneaky caches lying around in the filesystem and the CPU that you want warmed so your timing becomes consistent. You've also got to be careful that the compiler doesn't notice your benchmark setup isn't using the result of the computation, lest it optimize the computation away completely.

      It's very likely that most of that time you're measuring is due to the JS interpreter not being warm, so it's super unfair to compare it to the AoT language gang where we don't have that problem... and also I'm using a benchmarking framework that's making sure all those other sneaky caches I mentioned are also warm.

      2 votes
      1. [2]
        balooga
        Link Parent
        Very good points here. Currently my AoC runner just grabs a start timestamp and an end timestamp and prints the difference. It only has millisecond precision and I do get varying results each time...

        Very good points here. Currently my AoC runner just grabs a start timestamp and an end timestamp and prints the difference. It only has millisecond precision and I do get varying results each time I run it.

        Not that this is particularly important but I can probably improve that in a few different ways. Could be a fun exercise. I’ll poke at it tomorrow.

        1 vote
        1. balooga
          Link Parent
          @zkxs Just finished up my benchmarking improvements! Haven't started today's puzzle yet though, lol... Some of the improvements I made: Increased precision to nanosecond resolution (which is...

          @zkxs Just finished up my benchmarking improvements! Haven't started today's puzzle yet though, lol...

          Some of the improvements I made:

          • Increased precision to nanosecond resolution (which is overkill and later gets rounded down to microseconds and milliseconds)
          • Stopped including input formatting in solution benchmarks (I've separated that into its own measurement now)
          • Removed filesystem / IO stuff from measurement, since it's additional overhead that doesn't have any bearing on my solutions
          • Added configurable iteration count, using 50 for now, to run each part repeatedly and return the min, max, and median durations

          And check out my fancy new tabular printout:

          $ ./bin/aoc -l
          Running solution for Advent of Code 2025, Day 04...
          
          Part 1:
          [REDACTED]
          
          Part 2:
          [REDACTED]
          
          ╔═════════════════════════════ BENCHMARKS (50 PASSES) ══════════════════════════════╗
          ║                                                                                   ║
          ║                    ----- Median -----   ------ Min -------   ------ Max -------   ║
          ║   Format Input:      0ms     (197µs)      0ms     (189µs)      0ms     (336µs)    ║
          ║   Part 1:            1ms     (917µs)      1ms     (899µs)      1ms    (1150µs)    ║
          ║   Part 2:           28ms   (27726µs)     27ms   (27103µs)     36ms   (36476µs)    ║
          ║                                                                                   ║
          ╚═══════════════════════════════════════════════════════════════════════════════════╝
          

          It's wild what a difference it makes — the benchmarks I posted here yesterday were 7ms for Part 1 and 56ms for Part 2. Without changing any of my solution code I've more than halved my numbers and have more confidence their accuracy. Feels good.

          2 votes
  5. scarecrw
    (edited )
    Link
    Over two hours to write and over a minute to run... Yesterday I would have said Prolog didn't seem so bad and I was getting a feel for things, today I spent the better part of an hour getting a...

    Over two hours to write and over a minute to run...

    Yesterday I would have said Prolog didn't seem so bad and I was getting a feel for things, today I spent the better part of an hour getting a Moore neighborhood to work.

    Real tired and pretty disappointed after this one. Hopefully get a second wind with some free time this weekend.

    Prolog Solution
    :- ['src/util.pl'].
    :- table contains_paper/3.
    
    contains_paper(Grid, 0, Position):-
        Position = (RowIndex, ColIndex),
        nth0(RowIndex, Grid, Row),
        string_chars(Row, RowChars),
        nth0(ColIndex, RowChars, Char),
        Char = '@'.
    
    contains_paper(Grid, Step, Position):-
        succ(Step1, Step),
        contains_paper(Grid, Step1, Position),
        not(forkliftable(Grid, Step1, Position)).
    
    forkliftable(Grid, Step, Position):-
        contains_paper(Grid, Step, Position),
        findall(Pos, neighbor(Position, Pos), Neighbors),
        count(contains_paper(Grid, Step), Neighbors, NeighborPaper),
        NeighborPaper < 4.
    
    neighbor(Position1, Position2):-
        Position1 = (Row1, Col1),
        between(-1, 1, DR),
        between(-1, 1, DC),
        (DR, DC) \= (0, 0),
        Row2 is Row1 + DR,
        Col2 is Col1 + DC,
        Position2 = (Row2, Col2).
    
    paper_removed(Grid, Step, PaperRemoved):-
        findall(Pos, contains_paper(Grid, 0, Pos), OriginalPaper),
        findall(Pos, contains_paper(Grid, Step, Pos), FinalPaper),
        length(OriginalPaper, OriginalCount),
        length(FinalPaper, FinalCount),
        PaperRemoved is OriginalCount - FinalCount.
    
    main:-
        Filename = 'inputs/day04.txt',
        read_file_to_strings(Filename, Grid),
        paper_removed(Grid, 1, Result1),
        write("Part 1: "), write(Result1), nl,
        paper_removed(Grid, 100, Result2),
        write("Part 2: "), write(Result2), nl,
        halt.
    
    :- initialization(main).
    
    1 vote
  6. [2]
    imperialismus
    Link
    I sat there staring at part 1 for a while until I realized I'd made an incredibly obvious mistake, and after fixing that part 1 worked and part 2 was trivial. Thoughts The mistake was that I was...

    I sat there staring at part 1 for a while until I realized I'd made an incredibly obvious mistake, and after fixing that part 1 worked and part 2 was trivial.

    Thoughts

    The mistake was that I was counting all grid cells that had less than 4 paper roll neighbors, when I actually needed to count only the grid cells that contained a paper roll and had less than 4 neighbors. Here's my solution for part 2, in Python today:

    def get_pos(s, x, y, dim):
        if not x in range(dim) or not y in range(dim): return False
        return s[x][y] == "@"
    
    def count_adjacent(s, x, y, dim):
        count = 0
        MATRIX = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]
        for (x2,y2) in MATRIX:
            if get_pos(s, x+x2, y+y2, dim):
                count += 1
        return count
    
    def count(s):
        dim = len(s)
        count = 0
        for x in range(0,dim):
            for y in range(0,dim):
                if s[x][y] == "@" and count_adjacent(s, x, y, dim)<4:
                    count += 1
                    s[x][y] = "."
        return count, s
    
    def count_recursive(s):
        s = [list(x) for x in s.split("\n")]
        total = 0
        while True:
            num, s = count(s)
            if num == 0: break
            total += num
        return total
    
    print(count_recursive(open("./input.txt").read()))
    

    I saw some comments on reddit saying that based on previous years, the difficulty should start ramping up soon, especially with only twelve days this year. For those of you who have done previous years, does it get progressively harder, and what do you think of this year's difficulty so far?

    1 vote
    1. Berdes
      Link Parent
      I'm also expecting the difficulty to start ramping up soon, although we might still get 1 or 2 days for which a brute force approach still works. I only finished all days in 2020 due to time...

      I'm also expecting the difficulty to start ramping up soon, although we might still get 1 or 2 days for which a brute force approach still works.

      I only finished all days in 2020 due to time constraints, but from what I remember, the difficulty increases over time with the only exception being the 25th that was always super simple. With the shorter schedule, I would expect the 12th problem to be the hardest since there would be no obvious reasons to break the pattern.

      2 votes
  7. Berdes
    Link
    The problem itself wasn't super difficult. Even part 2 could likely be implemented by repeatedly iterating over the whole map, removing what could be removed until nothing could be removed. This...

    The problem itself wasn't super difficult. Even part 2 could likely be implemented by repeatedly iterating over the whole map, removing what could be removed until nothing could be removed. This approach can be improved by having a list of cells whose neighbors have been modified and need rechecking. Even better, keeping track of the number of neighboring rolls + a list of rolls to be removed allows us to directly know if a neighbor is newly removable when removing a roll.

    On the Rust side, today was about learning many of the pitfalls when using closures the same way I sometimes use C++ lambdas with auto arguments. At first, I had tons of issues with the type inference, where |x, y| data[x][y] doesn't compile, even though |x, y| fn(x, y) does, which I find confusing. Then, I had to satisfy the borrow checker because if I want to mutate a capture inside a closure, I cannot use this object outside of the closure. My initial solution was to explicitly pass the stuff I wanted to mutate as argument to the closure, but I later transitioned to a nicer (maybe?) solution where I put my data into a struct and declared methods for the struct. Hopefully I'll get familiar with all those things before I end up in a situation where I'll need to use explicit lifetime annotations.

    Part 1 and 2 (Rust) after tons of refactoring and cleanup
    use std::io;
    use std::ops::Range;
    use std::vec::Vec;
    
    fn neighbor_ints(x: usize, max: usize) -> Range<usize> {
      let start = if x > 0 { x - 1 } else { 0 };
      let end = (x + 2).min(max);
      return start..end;
    }
    
    struct Map {
      data: Vec<Vec<char>>,
      height: usize,
      width: usize,
    }
    
    fn make_map(data: Vec<Vec<char>>) -> Map {
      let height = data.len();
      assert!(height > 0);
      let width = data[0].len();
      for x in 1..height {
        assert_eq!(data[x].len(), width, "line {}", x)
      }
      Map {
        data: data,
        height,
        width,
      }
    }
    
    impl Map {
      fn foreach_pos<F: FnMut(usize, usize)>(&self, mut f: F) {
        for x in 0..self.height {
          for y in 0..self.width {
            f(x, y);
          }
        }
      }
    
      fn foreach_neighbor<F: FnMut(usize, usize)>(&self, x: usize, y: usize, mut f: F) {
        for nx in neighbor_ints(x, self.height) {
          for ny in neighbor_ints(y, self.width) {
            if nx != x || ny != y {
              f(nx, ny);
            }
          }
        }
      }
    
      fn num_neighbor_rolls(&self, x: usize, y: usize) -> u8 {
        let mut num_rolls = 0;
        self.foreach_neighbor(x, y, |nx: usize, ny: usize| {
          if self.data[nx][ny] == '@' {
            num_rolls += 1;
          }
        });
        num_rolls
      }
    }
    
    fn part1(map: &Map) -> u64 {
      let mut ret = 0;
      map.foreach_pos(|x: usize, y: usize| {
        if map.data[x][y] == '@' && map.num_neighbor_rolls(x, y) < 4 {
          ret += 1;
        }
      });
      ret
    }
    
    fn part2(map: &Map) -> u64 {
      let mut ret = 0;
      let mut to_remove = Vec::<(usize, usize)>::new();
      let mut num_neighbor_rolls = vec![vec![0 as u8; map.width]; map.height];
      map.foreach_pos(|x: usize, y: usize| {
        if map.data[x][y] == '.' {
          return;
        }
        let num_rolls = map.num_neighbor_rolls(x, y);
        num_neighbor_rolls[x][y] = num_rolls;
        if num_rolls < 4 {
          to_remove.push((x, y));
        }
      });
      while !to_remove.is_empty() {
        let (x, y) = to_remove.pop().unwrap();
        ret += 1;
        map.foreach_neighbor(x, y, |nx: usize, ny: usize| {
          let nr = &mut num_neighbor_rolls[nx][ny];
          *nr -= 1;
          if *nr == 3 {
            to_remove.push((nx, ny));
          }
        });
      }
      ret
    }
    
    fn main() {
      let map = make_map(
        io::stdin()
          .lines()
          .map(|l| l.unwrap().chars().collect::<Vec<char>>())
          .collect(),
      );
      println!("Part 1: {}", part1(&map));
      println!("Part 2: {}", part2(&map));
    }
    
    1 vote
  8. jonah
    Link
    Python Standard brute-force method of solving both of these and didn't take too long on my machine (sub 1s for both) so I'm happy enough to share my solution. I'm used to there being some kind of...

    Python

    Standard brute-force method of solving both of these and didn't take too long on my machine (sub 1s for both) so I'm happy enough to share my solution. I'm used to there being some kind of edge case that my test input didn't capture, but it was pretty straight forward today and I didn't end up running into any of those problems.

    I'm also going to remove my utility grid_at method because... well you can imagine how that's implemented and it's just noise in the code (and I did not save any grid utilities from last year...)

    Part 1
    from common import load_input
    input = load_input()
    
    # def grid_at(grid, x, y)
    
    relative = [
        (-1, -1), (0, -1), (1, -1),
        (-1, 0),           (1, 0),
        (-1, 1),  (0, 1),  (1, 1),
    ]
    
    grid = list(map(lambda x: list(x), input.splitlines()))
    w, h = len(grid[0]), len(grid)
    
    total = 0
    for y in range(h):
        for x in range(w):
            if grid_at(grid, x, y) != '@':
                continue
            res = list(map(lambda d: grid_at(grid, x + d[0], y + d[1]), relative))
            if res.count('@') < 4:
                total += 1
    
    print(total)
    
    Part 2
    from common import load_input
    input = load_input()
    
    # def grid_at(grid, x, y)
    
    relative = [
        (-1, -1), (0, -1), (1, -1),
        (-1, 0),           (1, 0),
        (-1, 1),  (0, 1),  (1, 1),
    ]
    
    grid = list(map(lambda x: list(x), input.splitlines()))
    w, h = len(grid[0]), len(grid)
    
    total = 0
    
    while True:
        to_remove = []
        local_total = 0
        for y in range(h):
            for x in range(w):
                if grid_at(grid, x, y) != '@':
                    continue
                res = list(map(lambda d: grid_at(grid, x + d[0], y + d[1]), relative))
                if res.count('@') < 4:
                    to_remove.append((x, y))
                    local_total += 1
        if local_total == 0:
            break
        total += local_total
        for (x, y) in to_remove:
            grid[y][x] = '.'
    
    print(total)
    
    1 vote
  9. lily
    Link
    Relatively easy problem (easier than yesterday's, I think), though I took some time today to make my solution more efficient. The naive bruteforce solution only took about half a second to run on...

    Relatively easy problem (easier than yesterday's, I think), though I took some time today to make my solution more efficient. The naive bruteforce solution only took about half a second to run on my machine, so it wasn't critical to improve it, but I could tell there was room for improvement and so wanted to give it a try. Probably my solution here is somewhat overengineered (it also has some repetitive code), but it runs more or less instantly, so I'm happy enough with it.

    Solution (Lily)
    import (Set, integer_pair_hash) "modules/set"
    
    var lines = File.read_to_string("inputs/day_04.txt").split("\n")
    
    var width = lines[0].size()
    var height = lines.size()
    
    var adjacent = List.fill(height, (|_| List.repeat(width, -1)))
    
    for y, line in lines: {
        for x, char in line: {
            if char != '@': {
                continue
            }
    
            var count = 0
    
            for delta_y in -1...1: {
                var adjacent_y = y + delta_y
                if adjacent_y < 0 || adjacent_y >= height: {
                    continue
                }
    
                for delta_x in -1...1: {
                    if delta_y == 0 && delta_x == 0: {
                        continue
                    }
    
                    var adjacent_x = x + delta_x
                    if adjacent_x < 0 || adjacent_x >= width: {
                        continue
                    }
    
                    if lines[adjacent_y][adjacent_x] == '@': {
                        count += 1
                    }
                }
            }
    
            adjacent[y][x] = count
        }
    }
    
    var to_remove: Set[Tuple[Integer, Integer]] = Set(integer_pair_hash)
    for y, row in adjacent: {
        for x, count in row: {
            if count >= 0 && count < 4: {
                to_remove.add(<[x, y]>)
            }
        }
    }
    
    print("Part 1: {}".format(to_remove.size()))
    
    var total_rolls_removed = 0
    
    do: {
        var to_remove_new: Set[Tuple[Integer, Integer]] = Set(integer_pair_hash)
    
        to_remove.each(|position| adjacent[position[1]][position[0]] = -1)
        to_remove.each(|position|
            var x = position[0]
            var y = position[1]
    
            for delta_y in -1...1: {
                var adjacent_y = y + delta_y
                if adjacent_y < 0 || adjacent_y >= height: {
                    continue
                }
    
                for delta_x in -1...1: {
                    if delta_y == 0 && delta_x == 0: {
                        continue
                    }
    
                    var adjacent_x = x + delta_x
                    if adjacent_x < 0 || adjacent_x >= width: {
                        continue
                    }
    
                    var row = adjacent[adjacent_y]
    
                    if row[adjacent_x] < 0: {
                        continue
                    }
    
                    row[adjacent_x] -= 1
    
                    if row[adjacent_x] < 4: {
                        to_remove_new.add(<[adjacent_x, adjacent_y]>)
                    }
                }
            }
        )
    
        total_rolls_removed += to_remove.size()
        to_remove = to_remove_new
    } while to_remove.size()
    
    print("Part 2: {}".format(total_rolls_removed))
    
    Set module
    class Set[A](private var @hash_function: Function(A => Integer)) {
        private var @hashes: Hash[Integer, Unit] = []
        private var @values: List[A] = []
    
        public define size: Integer {
            return @hashes.size()
        }
    
        public define add(value: A) {
            var hash = @hash_function(value)
            if !@hashes.has_key(hash): {
                @hashes[hash] = unit
                @values.push(value)
            }
        }
    
        public define remove(value: A) {
            @hashes.delete(@hash_function(value))
        }
    
        public define contains(value: A): Boolean {
            return @hashes.has_key(@hash_function(value))
        }
    
        public define each(fn: Function(A)) {
            @values.each(fn)
        }
    }
    
    define integer_pair_hash(pair: Tuple[Integer, Integer]): Integer {
        var first = pair[0]
        var second = pair[1]
    
        return (
            first >= second
            ? first * first + first + second
            : first + second * second
        )
    }
    
    1 vote
  10. mawelborn
    Link
    This was a fun one! It strikes that perfect balance of hard enough to make you think, but simple enough to have a pleasing solution after ~30 minutes of work. After writing some utility generators...

    This was a fun one! It strikes that perfect balance of hard enough to make you think, but simple enough to have a pleasing solution after ~30 minutes of work.

    After writing some utility generators to yield positions with paper rolls and positions adjacent to them, it was an easy solve with Python sets and Counters. I didn't want to deal with conditional logic for what's "inside" the bounds of the grid; so I forewent a list in favor of a sparse set of roll positions and heatmap of adjacent roll counts.

    There's some performance left on the table for Part 02 with the way I check all remaining rolls to calculate the next batch of rolls to remove after removing the previous batch. Doing them one at a time and maintaining a stack of rolls would be more efficient. I could check only the remaining roll positions when updating the adjacent counts and push them onto the stack when they fall below 4. But this way made for a cleaner, simpler solution that runs in 0.5s on my old Thinkpad.

    Part 1
    Position: TypeAlias = tuple[int, int]
    
    
    def solve(input: str) -> int:
        roll_positions = set(positions_with_rolls(input))
        adjacent_roll_count = Counter(adjacent_positions(roll_positions))
        return len([
            position
            for position in roll_positions
            if adjacent_roll_count[position] < 4
        ])
    
    
    def positions_with_rolls(input: str) -> Iterator[Position]:
        for row, rolls in enumerate(input.split()):
            for column, roll in enumerate(rolls):
                if roll == "@":
                    yield row, column
    
    
    def adjacent_positions(positions: Iterable[Position]) -> Iterator[Position]:
        for row, column in positions:
            yield from (
                (row - 1, column - 1),
                (row - 1, column),
                (row - 1, column + 1),
                (row, column - 1),
                # (row, column),
                (row, column + 1),
                (row + 1, column - 1),
                (row + 1, column),
                (row + 1, column + 1),
            )
    
    Part 2
    Position: TypeAlias = tuple[int, int]
    
    
    def solve(input: str) -> int:
        roll_positions = set(positions_with_rolls(input))
        adjacent_roll_count = Counter(adjacent_positions(roll_positions))
        removed_roll_count = 0
    
        while positions_to_remove := set(
            position
            for position in roll_positions
            if adjacent_roll_count[position] < 4
        ):
            roll_positions.difference_update(positions_to_remove)
            adjacent_roll_count.subtract(adjacent_positions(positions_to_remove))
            removed_roll_count += len(positions_to_remove)
    
        return removed_roll_count
    
    
    def positions_with_rolls(input: str) -> Iterator[Position]:
        for row, rolls in enumerate(input.split()):
            for column, roll in enumerate(rolls):
                if roll == "@":
                    yield row, column
    
    
    def adjacent_positions(positions: Iterable[Position]) -> Iterator[Position]:
        for row, column in positions:
            yield from (
                (row - 1, column - 1),
                (row - 1, column),
                (row - 1, column + 1),
                (row, column - 1),
                # (row, column),
                (row, column + 1),
                (row + 1, column - 1),
                (row + 1, column),
                (row + 1, column + 1),
            )
    
    1 vote
  11. DeaconBlue
    (edited )
    Link
    @zkxs I stole your code for populating the grid from a file just because it was cleaner than mine and not particularly relevant for the actual challenge. I don't like defining the 139 size, I...

    @zkxs I stole your code for populating the grid from a file just because it was cleaner than mine and not particularly relevant for the actual challenge. I don't like defining the 139 size, I would rather read line length from the file from the argument, but it needed to be defined for the recursive function signature. Is this where macros come in?

    I learned a long time ago that when dealing with neighboring cells on a grid, it's often easier to just extend the grid by one in every direction and fill it with junk to make all of the neighbor checks cleaner.

    I solved this one earlier using the loop method where you keep checking the entire grid for any available to remove and removing them, but I wanted to take a swing at a recursive option. Iterate over the grid once, but whenever you remove a roll, see if it makes any new rolls available to be removed.

    This runs in 5ms on my machine which isn't really faster or slower than the original approach, but recursion always tickles my brain in a good way so I prefer this way.

    Part 2
    use std::{
        env,
        fs::File,
        io::{BufReader, Read},
        time::Instant,
    };
    
    const SQUARE_SIZE: usize = 139;
    const BIGGER_SQUARE: usize = SQUARE_SIZE + 2;
    
    fn main() {
        let now = Instant::now();
        let mut sum: i32 = 0;
        let args: Vec<String> = env::args().collect();
    
        let reader = BufReader::new(File::open(&args[1]).expect("Failure to open file"));
        let mut grid = [[0u8; BIGGER_SQUARE]; BIGGER_SQUARE];
        grid[1..BIGGER_SQUARE]
            .iter_mut()
            .flat_map(|x| x[1..BIGGER_SQUARE].iter_mut())
            .zip(reader.bytes())
            .for_each(|(dst, src)| {
                *dst = src.unwrap();
            });
    
        for x in 1..BIGGER_SQUARE - 1 {
            for y in 1..BIGGER_SQUARE - 1 {
                sum += check_neighbors(&mut grid, x, y)
            }
        }
        println!("Sum {sum}");
        let elapsed = now.elapsed();
        println!("Elapsed: {:.2?}", elapsed);
    }
    
    fn check_neighbors(grid: &mut [[u8; BIGGER_SQUARE]; BIGGER_SQUARE], x: usize, y: usize) -> i32 {
        let mut total = 0;
        let mut ret_val = 0;
        let mut cache = vec![];
        if grid[x][y] == b'@' {
            for i in 0..=2 {
                for j in 0..=2 {
                    if (i != 1 || j != 1) && grid[x + i - 1][y + j - 1] == b'@' {
                        cache.push((x + i - 1, y + j - 1));
                        total += 1;
                    }
                }
            }
            if total <= 3 {
                grid[x][y] = b'x';
                for cache_coord in cache {
                    ret_val += check_neighbors(grid, cache_coord.0, cache_coord.1);
                }
                ret_val += 1;
            }
        }
        return ret_val;
    }
    
    1 vote