Skip to Content

Advent of Code 2022 - Day 16, in Kotlin - Proboscidea Volcanium

Kotlin solutions to parts 1 and 2 of Advent of Code 2022, Day 16: 'Proboscidea Volcanium'

Posted on

The solution to Part Two of this puzzle took me a really long time to get right. I had a few ideas and tried them all. Some worked slowly, and some didn’t work at all. In the end I compromised on one of my principles for these solutions (no external libraries) in order to get past a sticking point, and it worked! I’m pretty happy with how this ended up.

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

Puzzle Input

We’ll parse our input into a class called ValveRoom, which contains the name of the room itself, a list of paths leading from this room, and the flowRate of the valve in this room. As usual, we’ll define an of function in the companion object to help with parsing. And as usual, I’m being stubborn about regular expressions and using substringBefore and substringAfter.

// In Day16

private data class ValveRoom(val name: String, val paths: List<String>, val flowRate: Int) {
        companion object {
            fun of(input: String): ValveRoom =
                ValveRoom(
                    input.substringAfter(" ").substringBefore(" "),
                    input.substringAfter("valve").substringAfter(" ").split(", "),
                    input.substringAfter("=").substringBefore(";").toInt()
                )
        }
    }

We will map each line of input to its ValveRoom giving us a List<ValveRoom>. Because we want to map the ValveRoom objects by their name, we’ll use associateBy from the Kotlin Standard Library to do this, giving us a Map<String, ValveRoom> of rooms.

class Day16(input: List<String>) {

    private val rooms: Map<String, ValveRoom> = input.map { ValveRoom.of(it) }.associateBy { it.name }

}

Adding A Combinatorics Library

For Part Two, we’re going to need an open source library called combinatoricskt , written by @shiguruikai . Its API is easy to understand, and most importantly, got me past what was probably a buggy attempt at doing this myself.

To add this, we’ll modify build.gradle.kts and add it to the dependencies section:

// In build.gradle.kts

dependencies {
    implementation("com.github.shiguruikai:combinatoricskt:1.6.0") {
        because("I need combinations of sets for Day 16 and this was a bug-free way to do it")
    }
}

In theory, the because part is optional but I like to include my reasoning for adding a library as a form of documentation. If you do this on larger projects, your future self will thank you!

⭐ Day 16, Part 1

The puzzle text can be found here.

We’ll start by refining our ValveRoom objects down to the set of transitions we actually care about. If you look at the input, there are a lot of rooms that don’t do anything. We could move to them, but doing so would be a waste. What we need is to know the cost of moving from one room to any other room that has work to do, but only for rooms that have actual work to do. This reduces the search space significantly. We’ll call this function calculateShortestPaths, and its return type is a bit hard to read. It is a Map<String, Map<String, Int>> where the key to the first map is the name of a room, and the value is another map whose key is a destination room, and its value is the cost to move to that room and turn on the valve.

// In Day16

private fun calculateShortestPaths(): Map<String, Map<String, Int>> {
    val shortestPaths = rooms.values.associate {
        it.name to it.paths.associateWith { 1 }.toMutableMap()
    }.toMutableMap()

    shortestPaths.keys.permutations(3).forEach { (waypoint, from, to) ->
        shortestPaths[from, to] = minOf(
            shortestPaths[from, to], // Existing Path
            shortestPaths[from, waypoint] + shortestPaths[waypoint, to] // New Path
        )
    }
    val zeroFlowRooms = rooms.values.filter { it.flowRate == 0 || it.name == "AA" }.map { it.name }.toSet()
    shortestPaths.values.forEach { it.keys.removeAll(zeroFlowRooms) }
    val canGetToFromAA: Set<String> = shortestPaths.getValue("AA").keys
    return shortestPaths.filter { it.key in canGetToFromAA || it.key == "AA" }
}

We start by looking at all the rooms and calling associate and asociateWith to create our structure. Since we’ll be manipulating the data before we return it, we’ll be sure to convert each map to a MutableMap. At this point shortestPaths has a key of a source room, and a value of another map whose key is the destination room and the cost to move there is 1.

Next, we will use the combinatoricskt library to generate permutations of all our room names. I had originally written a function to do this, but since we’ll use this library for Part Two, we can use it here too. Basically, we get a steady stream of List<String> of length 3. We will destructure these into three names - waypoint, to, and from. If we can get from to to waypoint and from waypoint to from cheaper than our previous answer of going directly from to to from, we take that and store it in the shortestPath map. You might see some funky array acessors, we’ll discuss those in a moment.

So now we have our map of sources, destinations, and cost. We want to filter this down to just the rooms that make sense. Basically anything with a positive flow rate and AA (the starting point). So we refine our shortestPath map structure down to just the rooms with a positive flow rate, and AA.

Now about those funky array-style accessors:

// In Day16

private operator fun Map<String, MutableMap<String, Int>>.set(key1: String, key2: String, value: Int) {
    getValue(key1)[key2] = value
}

private operator fun Map<String, Map<String, Int>>.get(key1: String, key2: String, defaultValue: Int = 31000): Int =
    get(key1)?.get(key2) ?: defaultValue

I don’t especially like nested maps, and I don’t especially like the awkward syntax to deal with nullable values coming out of them. So we’ll hide this behind two operator functions - get and set. Thankfully, Kotlin lets us define these functions and provide whatever arguments we want. In both cases, we define the key to the outer and inner maps. We pick 31000 as the default defauleValue because it’s a high number (rather than make this nullable, which would be confusing, I think).

We’ll store the results of the shortestPath calculation in cheapestPathCosts.

// In Day16

private val cheapestPathCosts: Map<String, Map<String, Int>> = calculateShortestPaths()

To do the work of evaluating the cost of turning on various valves, we’ll write a recursive function (a function that calls itself). Each time through the call stack, we find one of the cheapestPathCosts rooms that we have not already seen, go to it by incurring them movement cost, gain from it by adding the total amount of flow it generates, adding the room to the list of rooms we’ve visited, and then calling ourselves with this new data. This strategy will quickly work through every combination of room visits we could possible have, and because we use maxOfOrNull, we will eventually get the right answer.

// In Day16

private fun searchPaths(
    location: String,
    timeAllowed: Int,
    seen: Set<String> = emptySet(),
    timeTaken: Int = 0,
    totalFlow: Int = 0
): Int = cheapestPathCosts
    .getValue(location)
    .asSequence()
    .filterNot { (nextRoom, _) -> nextRoom in seen }
    .filter { (_, traversalCost) -> timeTaken + traversalCost + 1 < timeAllowed }
    .maxOfOrNull { (nextRoom, traversalCost) ->
        searchPaths(
            nextRoom,
            timeAllowed,
            seen + nextRoom,
            timeTaken + traversalCost + 1,
            totalFlow + ((timeAllowed - timeTaken - traversalCost - 1) * rooms.getValue(nextRoom).flowRate)
        )
    } ?: totalFlow

Some interesting things to notice - we use the _ to indicate to Kotlin that we don’t care about a specific value. I wanted to use destructuring here as much as possible with clear names, but sometimes we have values we don’t care about in this context. That’s why _ is nice, we don’t have to make up a dummy name.

We can now solve Part One by calling searchPaths and giving it the starting point “AA”, and 30 as a time budget.

// In Day16

fun solvePart1(): Int = searchPaths("AA", 30)

Star earned! Onward!

⭐ Day 16, Part 2

The puzzle text can be found here.

For all the grief this part caused me, I’m surprised the solution has relatively little code.

The basic theory is this: We take the total list of rooms to go visit and divide it up between me and the elephant. We run the Part One solution for my half of the list, and add it to Part One answer for the elephant’s half of the list. By generating every possible combination of rooms, and taking the maximum value of the two runs, we’ll get our answer.

This is where the combinatoricskt comes in handy - it generates the combinations of the list of rooms we can move to. I just could not figure out a bug-free way of doing this, and this library saved the day.

// In Day16

fun solvePart2(): Int =
    cheapestPathCosts.keys.filter { it != "AA" }
        .combinations(cheapestPathCosts.size / 2)
        .map { it.toSet() }
        .maxOf { halfOfTheRooms ->
            searchPaths("AA", 26, halfOfTheRooms) + 
            searchPaths("AA", 26, cheapestPathCosts.keys - halfOfTheRooms)
        }

Let’s break this function down. First, we get a List<String> representing every one of the rooms in the cheapestPathCosts set, minus the starting room (“AA”). We then use the combinatoricskt to get combindations of this list. The argument we pass to combinations is the size of the resulting combinations lists we want. In our case half the total rooms. This returns us a Sequence<List<String>>, which we map down to Sequence<Set<String>>.

At this point, we have a steady stream of halfOfTheRooms, and we can use this to solve the puzzle. The answer (for my input, and the sample input, anyway) is to use searchPaths from Part One with a timeAllowed of 26 instead of 30. So how does this help? We run it twice and add the results - once for our trip through the rooms (using half of the rooms) and another for the elephant (using the other half of the rooms). Since we’re passing in a new set of seen rooms to each call, we can assure ourselves that neither we or the elephant will see each others rooms! Adding the results of me looking at my half of the rooms, and the elephant looking at its half of the rooms, give us an answer. Taking the maximum value of that calculation gives us the solution to the puzzle!

On my computer, this finishes in about seven seconds. That’s the slowest runtime we’ve had for a puzzle this year, but I’ll take it as it is not an unreasonable amount of time to spend on this.

Star most definitely earned! See you tomorrow!

Further Reading

  1. Index of All Solutions - All posts and solutions for 2022, 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!