Skip to Content

Advent of Code 2024 - Day 6, in Kotlin - Guard Gallivant

Kotlin solutions to parts 1 and 2 of Advent of Code 2024, Day 6: 'Guard Gallivant'

Posted on

Every year, Advent of Code has a few puzzles like this; traversing a maze or finding a path through a room. I really enjoy because they are so different from the code I normally write.

If you’d rather just view the code, my GitHub Repository is here.

Puzzle Input

Today we’ll take our input as a List<String> and convert it to a List<CharArray> and store it as our grid. Why CharArray? Because in part 2 we’ll be mutating the grid and String doesn’t allow for this.

We will also search through the grid to find the start point, which we’ll represent as a Point2D. If you’ve read any of my Advent of Code posts from years past, you might recognize this. Eventually, I suspect this class will expand to have things like lists of neighbors and calculating distances between points. But for now, we just need to store x and y coordinates and add them together. We’ll also define some directional constants to make our life a bit easier.

// In Point2D.kt

data class Point2D(val x: Int, val y: Int) {

    operator fun plus(other: Point2D): Point2D =
        Point2D(x + other.x, y + other.y)

    companion object {
        val NORTH = Point2D(0, -1)
        val EAST = Point2D(1, 0)
        val SOUTH = Point2D(0, 1)
        val WEST = Point2D(-1, 0)
    }
}

In order to get our start point, we loop through all of the rows and columns to find the single instance of ^, and return it in a Point2D.

// In Day06

class Day06(input: List<String>) {

    private val grid: List<CharArray> = input.map { it.toCharArray() }

    private val start: Point2D = grid
        .flatMapIndexed { y, row ->
            row.mapIndexed { x, c ->
                if (c == '^') Point2D(x, y) else null
            }
        }.filterNotNull().first()
}

OK, enough setup, on to Part 1.

⭐ Day 6, Part 1

The puzzle text can be found here.

Alright, maybe just a bit more setup. First, we’ll need a way to get values out of our grid given a Point2D. We don’t want to do any kind of bounds checking, so we’ll return null when the Point2D represents a space outside the grid. We’ll define the get function as an operator on List<CharArray> (the type of our grid).

// In Day06

private operator fun List<CharArray>.get(at: Point2D): Char? =
    getOrNull(at.y)?.getOrNull(at.x)

We will implement both location and direction of travel with Point2D. The direction will be the changes to x and y so we can easily add direction to location to get a new location in the direction of travel. To make this easier, we’ll write a function to turn a given Point2D. When we hit an obstacle, we’re supposed to turn 90 degrees to the right. We could put this in Point2D but this doesn’t strike me as a reusable thing. Maybe I’m wrong, but let’s leave it here as a private extension function in the Day06 class for now.

// In Day06

private fun Point2D.turn(): Point2D =
    when (this) {
        Point2D.NORTH -> Point2D.EAST
        Point2D.EAST  -> Point2D.SOUTH
        Point2D.SOUTH -> Point2D.WEST
        Point2D.WEST  -> Point2D.NORTH
        else -> throw IllegalStateException("Bad direction: $this")
    }

Now we have everything we need to traverse the grid. First we will need to keep track of each place in the grid we have seen, which will be a MutableSet<Point2D>. We also need a mutable location and direction which we will initialize with the start point we calculated earlier and NORTH, respectively.

// In Day06

private fun traverse(): Int {
    val seen = mutableSetOf<Point2D>()
    var location = start
    var direction = Point2D.NORTH

    while (grid[location] != null ) {
        seen += location
        val next = location + direction

        if (grid[next] == '#') direction = direction.turn()
        else location = next
    }
    return seen.size
}

With the setup done, we want to march around the grid until we fall off. So we will set up a while loop that keeps going so long as location is within the grid. If it isn’t, we’ll get a null instead of some actual Char and we know we’ve fallen off the edge of the world.

Within the while loop, we add the current location to the seen set and calculate our next location by adding the current location to the current direction. This is where we can check if we’ve run into an obstacle. If the next place in the grid is a #, that means we need to turn, otherwise we can safely set the location to next and loop.

The while loop ends when we run off the edge, and the seen set is filled with points we’ve visited. Returning the size of the set gives us the length of the path.

We can solve Part 1 by calling traverse():

// In Day06

fun solvePart1(): Int = traverse()

Star earned! Onward!

⭐ Day 6, Part 2

The puzzle text can be found here.

To solve part 2, we’re going to need to refactor our traverse function to take into account location and direction. This way we can detect cycles. Because if the guard ends up in a location going in the same direction more than once, we know they’re in a cycle and won’t ever fall of the end of the grid. To do this, we’ll find the set of steps the guard takes without us interfering, add an obstacle to each of those spots, traverse the grid, and calculate how many of them are cycles.

Since we will be modifying the grid, we need to add another operator to make working with our List<CharArray> (the grid) easier. This time we need to set the given x/y coordinates (as a Point2D) to the given Char, c. We won’t do bounds checking on this one because know all of the points will be valid in this puzzle. In the “real world”, we’d probably want to do some kind of checking here.

// In Day06

private operator fun List<CharArray>.set(at: Point2D, c: Char) {
    this[at.y][at.x] = c
}

Now we can rewrite traverse to answer two questions:

  1. What locations did the guard touch and
  2. Was there a cycle, or did the guard fall off the grid?

Let’s look at the code and then we’ll go through it.

// In Day06

private fun traverse(): Pair<Set<Point2D>, Boolean> {
    val seen = mutableSetOf<Pair<Point2D, Point2D>>()
    var location = start
    var direction = Point2D.NORTH

    while (grid[location] != null && (location to direction) !in seen) {
        seen += location to direction
        val next = location + direction

        if (grid[next] == '#') direction = direction.turn()
        else location = next
    }
    return seen.map { it.first }.toSet() to (grid[location] != null)
}

In addition to a somewhat unwieldy return type, the only major change is accounting for direction. We modify seen to take a Pair<Point2D, Point2D> where the first element of the pair is the location and the second element of the pair is the direction of travel.

The while loop stays mostly the same except we also want to check that we have not seen this combination of location and direction yet. When we return from the traverse function, we want the set of points the guard has traversed (we aren’t interested in direction at all anymore), and whether the loop ended because of a cycle (non-null current location) or the guard walked off the grid (null current location).

With our newly refactored traverse() function, we can solve Part 2. We will do this by tracing the guard’s original path, and then run a traversal with an obstacle in each place the guard walks. If we count the number of times this results in a cycle, we’ll have our answer.

We’ll start by doing a normal traverse just like in Part 1 to calculate the guard’s path. Since traverse returns a Pair<Set<Point2D>, Boolean> now, we want the first element in the pair. We need to take care here not to put an obstacle right on top of the guard so we filter out the start.

Next, for each candidate place, we place our obstacle using our new set operator, run the traverse, taking care to reset the grid back to not having an obstacle (through the also function), and then pick out the second element of the pair, which tells us if there was a loop (true) or if the guard fell off the grid (false).

// In Day06

fun solvePart2(): Int =
    traverse()
        .first
        .filterNot { it == start }
        .count { candidate ->
            grid[candidate] = '#'
            traverse().also { grid[candidate] = '.' }.second
        }

To fix the now broken solvePart1() (because we changed the signature of traverse()), we need to pick out the first part of the pair (the set of points) and get its size.

// In Day06

fun solvePart1(): Int =
    traverse().first.size

Star earned! See you tomorrow!

Further Reading

  1. Index of All Solutions - All posts and solutions for 2024, in Kotlin.
  2. My Github repo - Solutions and tests for each day.
  3. Solution - Full code for day 6
  4. Advent of Code - Come join in and do these challenges yourself!