Skip to Content

Advent of Code 2023 - Day 24, in Kotlin - Never Tell Me The Odds

Kotlin solutions to parts 1 and 2 of Advent of Code 2023, Day 24: 'Never Tell Me The Odds'

Posted on

I think of all the puzzles in Advent of Code 2023, part 2 of this was the most difficult for me. I’m not great at math, and this just confused the heck out of me. I got a lot of help from Google and Reddit to solve it.

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

Puzzle Input

Our input today represents the position and velocity of a Hailstone, so we’ll model that as a data class. At this point in the implementation, Hailstone doesn’t do much. We have an of function like usual to turn a String into a Hailstone, and we have a slope, which is a Double. Take note that if the x velocity is 0, we can’t calculate the slope so we set it to NaN rather than null (you can change this to Double? if you want, so long as you account for it when we use the slope).

// In Day24

private data class Hailstone(val position: Point3D, val velocity: Point3D) {
    private val slope = if (velocity.x == 0L) Double.NaN else velocity.y / velocity.x.toDouble()

    companion object {
	    fun of(input: String): Hailstone = input.split("@").let { (left, right) ->
	        Hailstone(
	            Point3D.of(left),
	            Point3D.of(right)
	        )
	    }
	}
}

We made it all the way to Day 24 without needing a 3D point reference! Since this is the only day we’ll actually need it, and it really only exists to hold three points of data, we’ll define it as a private data class within the Day24 class. Note that x, y, and z are defined as Long here, rather than Int due to the size of our input. Also, there’s an of function to turn a String into a Point3D.

// In Day24

private data class Point3D(val x: Long, val y: Long, val z: Long) {
    companion object {
        fun of(input: String): Point3D =
            input.split(",").map { it.trim().toLong() }.let {
                Point3D(it[0], it[1], it[2])
            }
    }
}

And now we can parse our input and store it in a List<Hailstone> called hailstones. We won’t need the input after this, so we don’t need to define it as a property.

class Day24(input: List<String>) {

    private val hailstones = input.map { Hailstone.of(it) }

}

⭐ Day 24, Part 1

The puzzle text can be found here.

To solve part 1 we need to determine if and when one hailstone intersects with another. Let’s encode this data in a data class called Intersection. It has x, y, and time variables, all of which are Double. If there is an interaction between two hailstones, we will represent it with Intersection. Otherwise, we will represent it with null.

// In Day24

private data class Intersection(val x: Double, val y: Double, val time: Double)

Now that we have a way to describe a possible Intersection, we need to implement the math, which we’ll do as a function called intersectionWith on the Hailstone class. It takes another Hailstone class as an argument and returns an Intersection?. This will be non-null if the two hailstones intersect and null otherwise.

// In Hailstone

fun intersectionWith(other: Hailstone): Intersection? {
    if (slope.isNaN() || other.slope.isNaN() || slope == other.slope) return null

    val c = position.y - slope * position.x
    val otherC = other.position.y - other.slope * other.position.x

    val x = (otherC - c) / (slope - other.slope)
    val t1 = (x - position.x) / velocity.x
    val t2 = (x - other.position.x) / other.velocity.x

    if (t1 < 0 || t2 < 0) return null

    val y = slope * (x - position.x) + position.y
    return Intersection(x, y, t1)
}

The first check is to make sure that both hailstones have a slope and they don’t equal each other. If any of these is true, these hailstones will never meet up. The rest of this math I had a lot of help with from Google, but basically it figures out the x and y coordinates of when the two lines meet up, and when (t1 and t2). If the time is in the past, we don’t wan to return an Intersection, we return null. Otherwise we can say that yes, these two hailstones do intersect in the future at a specific point.

And to solve part 1, we ram all of the hailstones into each other and figure out which ones happen in the given range for both x and y. We’ll reuse our cartesianPairs() filter out the self references (we could skip this, the first check in intersectionWith would return null) and take any intersections that are not null.

// In Day24

fun solvePart1(range: ClosedFloatingPointRange<Double>): Int =
    hailstones
        .cartesianPairs()
        .filter { it.first != it.second }
        .mapNotNull { it.first.intersectionWith(it.second) }
        .count { (x, y, _) -> x in range && y in range }

Counting the number of resulting Intersection objects whose x and y coordinates happen in the range gives us our answer.

Star earned! Onward!

⭐ Day 24, Part 2

The puzzle text can be found here.

I won’t pretend that I came up with this solution. This part of this puzzle was the hardest one for me all year. I had a lot of help from Google and Reddit. And that’s fine.

Basically what we’re going to do is pretend that the rock we are throwing is still and the hailstones are all moving at some new velocity relative to the now still “thrown” rock. In theory, they should all intersect with some given point, which we can calculate with the code we wrote for part 1.

First up, we need a way to alter the Hailstone given a change (delta) to the x and y velocity.

// In Hailstone

fun withVelocityDelta(vx: Long, vy: Long): Hailstone =
    copy(
        velocity = Point3D(velocity.x + vx, velocity.y + vy, velocity.z)
    )

And we also need a function to predict the value of z given some amount of time the hailstone moves for, and the deltaZ (change in z).

// In Hailstone

fun predictZ(time: Double, deltaVZ: Long): Double =
    (position.z + time * (velocity.z + deltaVZ))

The strategy here is to run through every value for x and y (within some small range) to use as a velocity delta, and find four random hailstones that collide given those deltas. This gives us the x and y points that the rock must be thrown by. And from that, we can iterate through the same range to apply a delta to z, and find the point where all of those same hailstones meet up.

Since I’m picking random hailstones to start with, this may not actually work, so we run this over and over until it does work.

// In Day24

fun solvePart2(): Long {
    val range = -500L..500L
    while (true) {
        val hail = hailstones.shuffled().take(4)
        range.forEach { deltaX ->
            range.forEach { deltaY ->
                val hail0 = hail[0].withVelocityDelta(deltaX, deltaY)
                val intercepts = hail.drop(1).mapNotNull {
                    it.withVelocityDelta(deltaX, deltaY).intersectionWith(hail0)
                }
                if (intercepts.size == 3 &&
                    intercepts.all { it.x == intercepts.first().x } &&
                    intercepts.all { it.y == intercepts.first().y }
                ) {
                    range.forEach { deltaZ ->
                        val z1 = hail[1].predictZ(intercepts[0].time, deltaZ)
                        val z2 = hail[2].predictZ(intercepts[1].time, deltaZ)
                        val z3 = hail[3].predictZ(intercepts[2].time, deltaZ)
                        if (z1 == z2 && z2 == z3) {
                            return (intercepts.first().x + intercepts.first().y + z1).toLong()
                        }

                    }
                }
            }
        }
    }
}

Like I said, I had a lot of help on this one. I never could have figured it out on my own.

Star earned (kinda)! 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 24
  4. Advent of Code - Come join in and do these challenges yourself!