Skip to Content

Advent of Code 2024 - Day 20, in Kotlin - Race Condition

Kotlin solutions to parts 1 and 2 of Advent of Code 2024, Day 20: 'Race Condition'

Posted on

If all the contestants in this race can “cheat” with the same deterministic rules, doesn’t that bring them back where they started? Boring, deterministic races? Either way, it feels like these rules could also apply to a game of Candy Land , which also has a single track.

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

Puzzle Input

Today we’ll take our input in as a List<String> but not declare it as a property since we’ll parse it into a more usable form right away. We only need the single path through the maze, so we’ll store that as a List<Point2D> from start to finish.

class Day20(track: List<String>) {

    private val path: List<Point2D> = parseTrack(track)

    private fun parseTrack(input: List<String>): List<Point2D> {
        val end = input.findSingle('E')
        return mutableListOf(input.findSingle('S')).apply {
            while (last() != end) {
                add(
                    last()
                        .cardinalNeighbors()
                        .filter { input[it.y][it.x] != '#' }
                        .first { it != getOrNull(lastIndex - 1) }
                )
            }
        }
    }
}

Our parseTrack function finds the end and then creates a mutableList with the starting point in it already. While the last element of the list we’re building isn’t the end, we follow the path along. We get the cardinalNeighbors for the last point in the list, filter out any that are walls, and find the single one remaining that is the path forward. We do this by checking the last-1 element in the list we’re building as that is the only place that could be returned by cardinalNeighbors that we could have visited already.

Since we seem to need to find a single thing among a grid quite frequently, we’ll add findSingle to our Extensions file. Its job is to find the Point2D that a given target can be found at and assumes the target is always found.

// In Extensions.kt

fun List<String>.findSingle(target: Char): Point2D =
    flatMapIndexed { y, row ->
        row.mapIndexedNotNull { x, c ->
            if (c == target) Point2D(x, y) else null
        }
    }.first()

Strategy and Shared Code

Like yesterday, we can share a solution between parts 1 and 2 without any changes. Here we are 20 days into Advent of Code and we finally need a Manhattan Distance function for our Point2D. It feels like past years have needed this a lot earlier. Anyway, I copied distanceTo from my 2023 code, it calculates the Manhattan distance between two Point2D objects by taking the absolute difference in the x and y values. Feel free to make this infix and not use dots to make a.distanceTo(b) look like a distanceTo b if you want.

// In Point2D

fun distanceTo(other: Point2D): Int =
    (x - other.x).absoluteValue + (y - other.y).absoluteValue

Strategy

Since we have an in-order List of Point2D objects, we can determine how many picoseconds apart any two elements are via their relative indexes. Given that, we can compare all pairs of possible cheat start and cheat end points to see which ones are in the right positions given a savingsGoal (the minimum amount of time savings we want to consider worth cheating for), and a cheatTime (how long we’re able to cheat for).

// In Day20

private fun findCheats(savingsGoal: Int, cheatTime: Int): Int =
    path.indices.sumOf { start ->
        (start + savingsGoal..path.lastIndex).count { end ->
            val physicalDistance = path[start].distanceTo(path[end])
            physicalDistance <= cheatTime && physicalDistance <= end - start - savingsGoal
        }
    }

We start by looking at all the indices of the path, calling that the start point. We’ll take the sumOf any cheats we can make from this start point. Inside this loop, we need to find the end points that are worth evaluating. Since we know we’re looking to hit a specific savingsGoal, we can immediately remove end points from consideration that are closer (in picoseconds) to the start. Therefore, we set up a range of start + savingsGoal to the last index of the path. We call this the end of the possible cheating route.

We count any start/end points that meet some criteria for inclusion. To make this calculation we need the physicalDistance between the start and end points to see if we’re able to get there within the cheatTime. To be included, we need the physicalDistance to be less than the cheatTime (meaning: can we physically traverse from start to end within the cheatTime) and we also need to know if this saves us anything by subtracting the start and savingsGoal from the end. If that value is less than the physicalDistance we’ve saved enough time and we can count this start/end pair.

We’re now ready to solve both parts of the puzzle.

⭐ Day 20, Part 1

The puzzle text can be found here.

Now that we’ve done all the work to write findCheats, we can call it with our cheatTime of 2 and the goal passed in from the test case (because the test values and the real values use different goals). This solves part 1.

// In Day20

fun solvePart1(goal: Int): Int =
    findCheats(goal, 2)

Star earned! Onward!

⭐ Day 20, Part 2

The puzzle text can be found here.

Passing the goal and setting the cheatTime to 20 solves part 2.

// In Day20

fun solvePart2(goal: Int): Int =
    findCheats(goal, 20)

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 20
  4. Advent of Code - Come join in and do these challenges yourself!