Posted: 2024-12-06
Last modified: 2024-12-06 @ 8f68892

Advent of Code 2024: Day 06

This post is part of the following series: Advent of Code 2024, Advent of Code.

Code for solutions available on GitHub.

Guard Gallivant

Ah, what would an Advent of Code be without a grid you have to walk through somehow. Today we have a grid of empty tiles (.) and obstacles (#), and the grid is patrolled by a guard (^). The guard is initially facing up and walks in a straight line. When they can’t walk forward due to an obstacle, they turn right and keep going. This continues until the guard leaves the grid.

Here’s the given example input:

....#.....
.........#
..........
..#.......
.......#..
..........
.#..^.....
........#.
#.........
......#...

Initially the guard will go up, hit the obstacle at the top, turn right, hit another obstacle, turn down, and so on.

The task for part 1 is to count the number of unique positions the guard visits before exiting the grid. The grid is handily parsed into a Vec<Vec<Tile>>, where Tile is defined as

enum Tile {
    Empty,
    Obstacle,
    Visited,
}

The tile the guard starts at is marked as Tile::Visited. The rest of the puzzle information can be put into some helper types and a Map struct, defined as:

struct Map {
    guard: Guard,
    grid: Vec<Vec<Tile>>,
}

struct Guard {
    position: Point,
    facing: Direction,
}

struct Point { x: usize, y: usize }
enum Direction {
    Up,
    Down,
    Left,
    Right,
}

For brevity I will leave out some details of the implementation in the rest of this post.

Next, we need some sort of step() method on our Map so that we can run the simulation.

impl Map {
    fn step(&mut self) -> Option<Point> {
        // Point::next is a pseudo-method for this
        // post that adds 1 in the facing direction
        // of the guard, bounds-checks, and returns
        // None if out of bounds.
        let next_position = self.guard.position.next()?;

        match self.grid[next_position.y][next_position.x] {
            Tile::Obstacle => {
                self.guard.facing = self.guard.facing.turn();
                Some(self.guard.position)
            }
            _ => {
                // Self::visit sets the index of next_position
                // in the grid to Tile::Visited.
                self.visit(next_position);
                self.guard.position = next_position;

                Some(next_position)
            }
        }
    }
}

Now that we have that, we can run the whole simulation like this:

while map.step().is_some() {}

And finally, to count all the unique visited tiles, after running the simulation:

let result = map.grid
    .into_iter()
    .flatten()
    .filter(|&t| t == Tile::Visited)
    .count();

Done!

Part 2

The puzzle is crafted in such a way that from the given input, the guard will always leave the grid. The task for part 2 is to stop this from happening by inserting a single obstacle into the grid that will make the guard get stuck in a loop. Then count how many positions you can insert an obstacle into to cause the looping. Also you’re not allowed to put an obstacle on the guard’s starting tile.

I don’t think there’s a better way to do this other than running the simulation, keeping track of which tiles have been visited and in which direction the guard visited them. Then one can insert an obstacle anywhere along the path and run the simulation again, checking if the guard got stuck in a loop. What’s the definition of being stuck in a loop? Well, if you visit a tile that you have already visited and going in the same direction, you’re stuck in a loop. Everything past that point is already determined.

To keep track of visited directions, I initially added some data to the Tile::Visited variant like this:

enum Tile {
    Empty,
    Obstacle,
    Visited([bool; 4]),
}

The variant can then be indexed by the Direction enum by casting it as a usize1.

To detect the loops, the step() method needs to return whether or not the next position has already been seen in the same direction.

impl Map {
    fn step(&mut self) -> Option<(Point, bool)> {
        let next_position = self.guard.position.next()?;

        match self.grid[next_position.y][next_position.x] {
            Tile::Obstacle => {
                self.guard.facing = self.guard.facing.turn();
                Some((self.guard.position, false))
            }
            Tile::Visited(v) => {
                // Self::visit now sets the Tile::Visited slice
                // to true for the direction the guard is facing.
                self.visit(next_position);
                self.guard.position = next_position;
                Some((next_position, v[self.guard.facing as usize]))
            }
            Tile::Empty => {
                self.visit(next_position);
                self.guard.position = next_position;
                Some((next_position, false))
            }
        }
    }
}

With that little modification, we can now add a method which detects loops like so:

impl Map {
    // ---<snip>---

    fn loops(self) -> bool {
        while let Some((_, already_visited)) = self.step() {
            if already_visited {
                return true;
            }
        }
        false
    }
}

Then to the actual solving. My initial solution involved simulating the map once, extracting the positions of visited tiles from the grid, then for each position I run the .loops() method on a copy of the original map with a single extra obstacle inserted for each position that would have been visited. This worked, but was really slow. Took about 160ms on my machine2.

To speed it up, I simply tried slapping a par_iter on the whole iterator looping through the visited tiles and simulating. This brought the runtime down to 17ms. Pretty good, but I’ve always felt like it’s a kind of cheap (as in not honest) way to improve the runtime of AoC solutions. Ideally I would actually optimize the single-threaded solution.

My first idea was that running the entire simulation to extract the path, then running all other simulations was not very good. It does a lot of repeated work. So I refactored the solution to instead insert an obstacle in front of the guard on a copy of the map, running that whole simulation to determine if it loops, then stepping the original map. That way the guard’s path is cached and will speed up iterations that have gone very deep.

This brought the single-threaded runtime down to about 56ms. There were still some gains to be found.

Moving from a Vec<Vec<Tile>> to a [[Tile; 130]; 130] shaved off a few more milliseconds. The input was 130x130, so it made sense to just hard-code it.

Then, instead of having the Visited variant contain a slice, I made it contain a bitmask of directions, like this:

enum Tile {
    Empty,
    Obstacle,
    Visited(u8),
}

#[repr(u8)]
enum Direction {
    Up = 1,
    Down = 2,
    Left = 3,
    Right = 4,
}

This made the .visit() method able to just bitwise-or the facing direction of the guard into the tile. And to determine if a tile had already been visited in that direction, the .step() method can just bitwise-or the tile with the facing direction of the guard. This removed a lot of indexing, and brought the runtime down to about 25ms.

I’m not happy with having milliseconds pop up so early on, but I will leave it here. Finding a good sub-millisecond solution probably requires me to rewrite the solution from the ground up. That’s the tricky part of Advent of Code. You can get locked into your solution to part 1 in such a way that your solution for part 2 is sub-optimal. Oh well. I don’t really mind leaving this solution like this. Going from 160ms down to 25 is still decent. :)


  1. As long as the enum has the #[repr(usize)] attribute. ↩︎

  2. I mean, that’s still pretty fast. But relative to my other solutions so far it is by far the slowest. ↩︎

Other posts in Advent of Code 2024

  1. Advent of Code 2024: Day 01
  2. Advent of Code 2024: Day 02
  3. Advent of Code 2024: Day 03
  4. Advent of Code 2024: Day 04
  5. Advent of Code 2024: Day 05
  6. Advent of Code 2024: Day 06
  7. Advent of Code 2024: Day 07
  8. Advent of Code 2024: Day 08
  9. Advent of Code 2024: Day 09
  10. Advent of Code 2024: Day 10
  11. Advent of Code 2024: Day 11

Other posts in Advent of Code

  1. Advent of Code 2023: Day 01
  2. Advent of Code 2023: Day 02
  3. Advent of Code 2023: Day 03
  4. Advent of Code 2023: Day 04
  5. Advent of Code 2023: Day 05
  6. Advent of Code 2023: Day 06
  7. Advent of Code 2023: Day 07
  8. Advent of Code 2023: Day 08
  9. Advent of Code 2023: Day 09
  10. Advent of Code 2023: Day 10
  11. Advent of Code 2023: Day 11
  12. Advent of Code 2024: Day 01
  13. Advent of Code 2024: Day 02
  14. Advent of Code 2024: Day 03
  15. Advent of Code 2024: Day 04
  16. Advent of Code 2024: Day 05
  17. Advent of Code 2024: Day 06
  18. Advent of Code 2024: Day 07
  19. Advent of Code 2024: Day 08
  20. Advent of Code 2024: Day 09
  21. Advent of Code 2024: Day 10
  22. Advent of Code 2024: Day 11