Skip to Content

Advent of Code 2023 - Day 16, in Kotlin - The Floor Will Be Lava

Kotlin solutions to parts 1 and 2 of Advent of Code 2023, Day 16: 'The Floor Will Be Lava'

Posted on

This was a fun puzzle for a Saturday morning. We’ll write up a quick breadth-first algorithm to get our answer, and reuse several of the extension functions we’ve already written to make working with grids easier.

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

Puzzle Input

Today our input comes to us as a List<String>, which we map into an Array<CharArray> as a grid. Honestly, because we don’t actually mutate the grid this time, I’d probably have just left it as a List<String> if I hadn’t already written several helper functions that were typed specifically for Array<CharArray>. I guess that’s a good lesson for me to stop and think about types before I throw things into the extension file.


class Day16(input: List<String>) {

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

}

⭐ Day 16, Part 1

The puzzle text can be found here.

If you read over the rules for what to do when we run into a specific symbol, they basically come in three flavors:

  1. Do nothing (keep going in the direction you are going)
  2. Pick a single new direction
  3. Pick two new directions

Also, as the beam makes its way though the grid the important thing to record isn’t just the location of the beam, but also the direction it is moving.

So let’s take these facts and encode a table of allowed movements. We assume anything that isn’t in the map falls into category 1 (keep going in the current direction). Do note that \ is the escape character in Kotlin, so we have to write it as \\ to escape it.

// In Day16

private val movements = mapOf(
    '-' to NORTH to listOf(EAST, WEST),
    '-' to SOUTH to listOf(EAST, WEST),
    '|' to EAST to listOf(NORTH, SOUTH),
    '|' to WEST to listOf(NORTH, SOUTH),
    '\\' to NORTH to listOf(WEST),
    '\\' to WEST to listOf(NORTH),
    '\\' to SOUTH to listOf(EAST),
    '\\' to EAST to listOf(SOUTH),
    '/' to NORTH to listOf(EAST),
    '/' to WEST to listOf(SOUTH),
    '/' to SOUTH to listOf(WEST),
    '/' to EAST to listOf(NORTH)
)

This might be a bit confusing with all the to calls in there. The key to the map is a Pair<Char, Point2D> where the Char represents what the spot in the grid is, and the Point2D represents the direction we’re moving. Remember, we have NORTH/SOUTH/EAST/WEST offsets defined in Point2D already. The value of the map is a List<Point2D> which represents the new directions that the beam can take. In most cases, these are singleton lists, but in four of them the beam splits.

Next we’ll write a function to have the beam traverse the grid until it fully loops back on itself.

// In Day16

private fun energize(startPoint: Point2D, startDirection: Point2D): Int {
    val seen = mutableSetOf(startPoint to startDirection)
    val queue = ArrayDeque<Pair<Point2D, Point2D>>().apply {
        add(startPoint to startDirection)
    }
    while (queue.isNotEmpty()) {
        val (place, direction) = queue.removeFirst()
        val nextDirections = movements[grid[place] to direction] ?: listOf(direction)
        nextDirections.forEach { nextDirection ->
            val nextPlace = place + nextDirection
            val nextPair = nextPlace to nextDirection
            if (nextPair !in seen && grid.isSafe(nextPlace)) {
                queue.add(nextPair)
                seen += nextPair
            }
        }
    }

    return seen.map { it.first }.toSet().size
}

If you’ve followed my solutions, this should look familiar. First, we define a seen set to capture states we’ve already experienced. We use this to cut down on the amount of work we have to do and to detect when we’ve looped around. For example, if we’re on spot 0,0 coming from the SOUTH, and we’ve been in the same place moving the same direction before, there can be no other outcome to doing it a second time, so we can stop working. The queue is there to hold the work we haven’t done yet but know we need to do. For example, if we run into a splitter we will need to add two work items to the queue. In some implementations of this algorithm we care about the order of the items in the queue (in which case we’d possibly opt for PriorityQueue over ArrayDeque) but in this case we don’t. We use ArrayDeque because we can easily add to one and and remove from the other.

Once the work queue has been set up and initialized with the startPoint and startDirection, we loop until the queue is empty. For each work item we remove from the queue via removeFirst, we use destructuring to turn the returned Pair<Point2D, Point2D> into place and direction. We use this information to figure out where we are on the grid and what character we’re looking at and use that to look up our next move in the movements map. If the movements map doesn’t have an answer for us, we assume “keep going” is acceptable and return listOf(direction).

Next, we calculate where we move to given the nextDirection, for each of the directions returned from movements. We add that direction to the current place to figure out where to go next. The only checks we need to make are to check if we’ve seen that state before, and to make sure the nextPlace is in the grid, using the isSafe function we wrote a few days ago. Assuming those checks pass, we add the nextPair to the work queue and the seen set.

At the end of the energize function we find all of the unique places we’ve seen and return the count.

// In Day16

fun solvePart1(): Int =
    energize(Point2D(0, 0), EAST)

Calling energize indicating we start from the top left corner moving EAST solves par 1 of the puzzle.

Star earned! Onward!

⭐ Day 16, Part 2

The puzzle text can be found here.

My strategy for part 2 is to call what we wrote for part 1 for every spot along the outside of the grid. Maybe there’s a better way to do this, but this works fast enough and I’m only running this code once to get an answer for a puzzle.

So let’s generate our data.

// In Day16

fun solvePart2(): Int =
    listOf(
        grid.first().indices.map { Point2D(it, 0) to SOUTH },
        grid.first().indices.map { Point2D(it, grid.lastIndex) to NORTH },
        grid.indices.map { Point2D(0, it) to EAST },
        grid.indices.map { Point2D(grid.first().lastIndex, it) to WEST }
    )
        .flatten()
        .maxOf { energize(it.first, it.second) }

The outer listOf will eventually create a List<List<Pair<Point2D, Point2D>> where the Pair represents a starting point and a direction. We don’t really want this structure though, so we flatten it into a single List<Pair<Point2D, Point2D>> which lets us call maxOf with energize.

Inside the listOf we loop through all of the indices in the x and y directions and set the direction to how we’ll come into the grid. For example the first list is created by going through all of the x indices and assuming y is 0. Since that means we come from the top of the grid, we’ll be moving SOUTH. I will note that I love that Kotlin defines lastIndex so we don’t have to do something like length() -1. For symmetry there is also firstIndex but since it is 0 in these cases, I didn’t use it because it makes the code look busier than it needs to be.

Running this function gives us the answer to part 2!

Star earned! See you tomorrow!

Further Reading

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