Advent of Code 2023 - Day 14

By Eric Burden | December 14, 2023

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Kotlin. I’ll post my solutions and code to GitHub as well. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 14 - Parabolic Reflector Dish

Find the problem description HERE.

The Input - We All Have A Platform

Today is another “grid-from-text” input, which we’re pretty well familiar with by this point. The one big “twist” here is that there are three different kinds of tiles (’.’, ‘#’, and ‘O’). Still, grid to list-of-lists, here we come!


/**
 * This enum represents each possible shape on our platform
 *
 * @property rep The character representation of the enum variant
 */
enum class MapTile(private val rep: Char) {
    EMPTY('.'), SQUARE('#'), ROUND('O');

    companion object {
        /**
         * Parse a [MapTile] from a character
         *
         * @param char The character to parse.
         * @return The [MapTile] representation of `char`.
         */
        fun fromChar(char: Char): MapTile {
            return when (char) {
                '.' -> EMPTY
                '#' -> SQUARE
                'O' -> ROUND
                else -> throw Exception("$char does not represent a map tile!")
            }
        }
    }

    /**
     * Produce a string representation of this [MapTile]
     *
     * @return A string representing this [MapTile].
     */
    override fun toString(): String {
        return rep.toString()
    }
}

/**
 * This class represents the state of the large metal platform
 *
 * @property grid The configuration of rocks on the platform.
 */
data class Platform(val grid: List<List<MapTile>>) {
    companion object {
        /**
         * Parse a [Platform] from the input file
         *
         * @param input The character grid from the input file.
         * @return The [Platform] parsed from the input.
         */
        fun fromInput(input: List<List<Char>>): Platform {
            val rotatedInput = input.rotateClockwise()
            val grid = rotatedInput.map { row -> row.map(MapTile::fromChar) }
            return Platform(grid)
        }
    }
}


class Day14(input: List<List<Char>>) {

    // Today's input is a grid of characters that needs to be treated like a
    // big metal platform with rolling and stationary rocks.
    private val parsed = Platform.fromInput(input)
    
}

Now that we’ve been re-platformed, it’s time to get tilted!

Part One - Rolling Stones

In today’s puzzle, we’re (I think) planning to adjust a large mirror array by rolling rocks around on a large metal platform with (what I assume to be) strategically placed obstacles. This seems like maybe not the best way to manage complex mirror alignments, but I’m no astrologist, so what do I know?


// enum class MapTile(private val rep: Char) { ... }

/**
 * Rotate a rectangular grid
 *
 * Transforms a rectangular grid (list of lists) as shown below:
 *
 *   [[1, 2, 3],            [[7, 4, 1],
 *    [4, 5, 6],     ->      [8, 5, 2],
 *    [7, 8, 9]]             [9, 6, 3]]
 *
 * @return A copy of the original grid, rotated one quarter turn clockwise.
 */
fun <T> List<List<T>>.rotateClockwise(): List<List<T>> {
    if (isEmpty() || first().isEmpty()) return this

    val numRows = this.size
    val numCols = first().size

    return List(numCols) { col ->
        List(numRows) { row ->
            this[numRows - 1 - row][col]
        }
    }
}

data class Platform(val grid: List<List<MapTile>>) {
    companion object {
    
        // fun fromInput(input: List<List<Char>>): Platform { ... }
    
        /**
         * Roll the round rocks to the end of the line
         *
         * This function accepts a line representing a row or column from
         * the platform and rolls all the round rocks from the start of the
         * line towards the end, as far as they will go.
         *
         * @param line A list of [MapTile]s representing a row or column on the
         * platform.
         * @return A copy of the input list with the round rocks rolled as far
         * as they will go from beginning to end.
         */
        private fun tiltToEnd(line: List<MapTile>): List<MapTile> {
            val output = line.toMutableList()
            var lastOpenIdx = output.size   // The index a rock can roll to

            // Walking backwards from the end of the line...
            for (currentIdx in output.lastIndex downTo 0) {
                when (output[currentIdx]) {
                    // If we're currently checking an empty tile and we haven't
                    // found an empty tile to roll a rock to yet...
                    MapTile.EMPTY -> {
                        if (lastOpenIdx == output.size) lastOpenIdx = currentIdx
                    }

                    // If we're checking a square rock, then the next possibly
                    // empty tile will be the tile before the square rock.
                    MapTile.SQUARE -> lastOpenIdx = currentIdx - 1

                    // If we're checking a round rock...
                    MapTile.ROUND -> {
                        if (lastOpenIdx == currentIdx) {
                            // ...and there's no open position to move it to,
                            // treat it like a square rock and set the next
                            // possibly open index to the tile before this rock.
                            lastOpenIdx -= 1
                        } else if (lastOpenIdx < output.size) {
                            // ...and there's an open position to move it to,
                            // move the round rock to that open position and
                            // mark the tile before the destination as a
                            // possibly open tile.
                            output[lastOpenIdx] = MapTile.ROUND
                            output[currentIdx] = MapTile.EMPTY
                            lastOpenIdx -= 1
                        }
                    }
                }
            }

            return output
        }
        
        /**
         * Calculate the load of a line of [MapTile]s
         *
         * The load of a line of [MapTile]s is the sum of the 1-indexed indices
         * occupied by round rocks. For example, a round rock in the fifth
         * position adds 5 to the load.
         *
         * @param line A list of [MapTile]s representing a row or column on the
         * platform.
         * @return The calculated load of the line.
         */
        private fun calculateLoad(line: List<MapTile>): Int =
            line.withIndex().filter { (_, tile) -> tile == MapTile.ROUND }
                .sumOf { (idx, _) -> idx + 1 }
    }
    
    /**
     * Produce a copy of this [Platform] tilted towards the end
     *
     * The 'end' of a platform is defined by the individual rows in the `grid`.
     * The last index of the grid is the 'end', and rocks will roll that
     * direction. In order to roll the rocks North, the grid should be rotated
     * such that the last index in each row of the grid is the North end.
     *
     * @return A copy of this [Platform] with rocks rolled towards the end.
     */
    fun tiltToEnd(): Platform = grid.map(Platform::tiltToEnd).toPlatform()

    /**
     * Calculate the load of this [Platform]
     *
     * As with the tilt, load is calculated oriented towards the 'end' of
     * the [Platform], which is based on the orientation of the underlying grid.
     * To get the load on the North pillar, the North end of the grid must be
     * oriented towards the end.
     *
     * @return The load on the end of this [Platform].
     */
    fun calculateLoad(): Int = grid.sumOf(Platform::calculateLoad)
    
}

/**
 * Converts a grid of [MapTile]s to a [Platform]
 *
 * This function extends the functionality of a [List<List<MapTile>>], adding
 * a `toPlatform` function that converts the grid of [MapTile]s into a
 * [Platform].
 *
 * @return The converted [Platform].
 */
fun List<List<MapTile>>.toPlatform(): Platform = Platform(this)

class Day14(input: List<List<Char>>) {

    // private val parsed = ...

    // Tilt the platform to the north and calculate the load on the northern
    // pillar.
    fun solvePart1(): Int = parsed.tiltToEnd().calculateLoad()
    
}

I’m not going to lie, I was pretty pleased with myself for that tilting algorithm! Tilting an entire platform in O(n * m) seemed like it should be possible, and it really was! This contrasts to yesterday where my attempt at O(n) mirror detection flopped. Progress!

Part Two - Spread the Load

Well, this is fair. It would honestly be kind of weird if we were able to adjust the mirror correctly by just shoving all the rocks in one direction. Again, what an excellent engineering setup! In part two we need to determine what the maximum load on the north pillar would be after a billion iterations of tilting the platform in all four cardinal directions. You know, I bet there’s a way to figure this out without actually tilting a billion times…


// enum class MapTile(private val rep: Char) { ... }

// fun <T> List<List<T>>.rotateClockwise(): List<List<T>> { ... }

data class Platform(val grid: List<List<MapTile>>) {
    companion object {
    
        // fun fromInput(input: List<List<Char>>): Platform { ... }
    
        // private fun tiltToEnd(line: List<MapTile>): List<MapTile> { ... }
        
        // private fun calculateLoad(line: List<MapTile>): Int = ...
    }
    
    // fun tiltToEnd(): Platform = ...

    // fun calculateLoad(): Int = ...
    
    /**
     * Conduct a full cycle of tilting and rotating the [Platform]
     *
     * Tilts the platform, rolling all rocks towards the end, then rotates
     * the platform clockwise one quarter turn. Repeat for all four cardinal
     * directions, until the returned [Platform] is oriented in the same
     * direction as this one was to begin with.
     *
     * @return A copy of this [Platform] tilted and rotated through a full cycle.
     */
    fun tiltFullCycle(): Platform {
        var grid = grid

        repeat(4) {
            grid = grid.map(Platform::tiltToEnd)
            grid = grid.rotateClockwise()
        }

        return Platform(grid)
    }
    
}

class Day14(input: List<List<Char>>) {

    // private val parsed = ...

    // fun solvePart1(): Int = parsed.tiltToEnd().calculateLoad()
    
    // There's no way we're actually supposed to do a billion cycles. There
    // must be a better way!
    fun solvePart2(): Int {
        var cyclesRemaining = 1_000_000_000
        var platform = parsed

        // First, check how many cycles it takes to see a platform
        // configuration appear twice. This indicates a repeating sequence!
        val seenStates = mutableSetOf<Platform>()
        while (!seenStates.contains(platform)) {
            seenStates.add(platform)
            platform = platform.tiltFullCycle()
            cyclesRemaining -= 1
        }

        // Now, we know where the repeating sequence starts, we need to
        // determine how long the sequence is. Reset our seen states and
        // keep cycling the platform until we finish another repeating
        // sequence.
        val repeatingSequenceStart = cyclesRemaining
        seenStates.clear()
        while (!seenStates.contains(platform)) {
            seenStates.add(platform)
            platform = platform.tiltFullCycle()
            cyclesRemaining -= 1
        }

        // Armed with the knowledge of how many states are in the
        // repeating sequence...
        val repeatingSequenceLength = repeatingSequenceStart - cyclesRemaining

        // ... we can make a list of the loads of each of those states. Now
        // we know all the possible load values that can appear from here on
        // out. But which one? Well...
        val repeatingSequenceLoads = mutableListOf<Int>()
        repeat(repeatingSequenceLength) {
            platform = platform.tiltFullCycle()
            repeatingSequenceLoads.add(platform.calculateLoad())
            cyclesRemaining -= 1
        }

        // Assume the sequence repeats over and over. Eventually, we'll run out
        // of cycles on the platform. So, the number of cycles left after the
        // last full repeating sequence tells us which of the load values
        // to use.
        val cyclesRemainingAfterLastSequence =
            cyclesRemaining % repeatingSequenceLength
        return repeatingSequenceLoads[cyclesRemainingAfterLastSequence - 1]
    }
}

Remember Day 17 from last year, Pyroclastic Flow? Yeah, that’s what this reminds me of. Thankfully, we really didn’t need to spin this thing a billion times. The repeated stress would probably have made it collapse!

Wrap Up

This was a good day! Granted, I did have the advantage of having seen a similar problem last year, but really, that’s a big part of what solving coding puzzles comes down to: pattern recognition. It’s one of the reasons that I encourage others to give Advent of Code a go. Being exposed to a variety of problems and solutions trains your brain to recognize when similar problems arise in other puzzles (and even sometimes in real life) and gives you a push in the right direction, even if you don’t remember exactly how that previous problem went. Good stuff.

comments powered by Disqus