Skip to Content

Advent of Code 2022 - Day 9, in Kotlin - Rope Bridge

Kotlin solutions to parts 1 and 2 of Advent of Code 2022, Day 9: 'Rope Bridge'

Posted on

I enjoyed today’s puzzle, this is one of my favorites of 2022 so far! In our solution we’ll perform some refactoring between Part One and Part Two to make our solution easier to manage.

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

Puzzle Input

In today’s puzzle, we receive our input as a set of instructions, one per line, which we’ll import as a List<String>.

class Day09(input: List<String>) {

    private val headPath: String = parseInput(input)
}

We’ll parse this input from a List<String> to a single String called headPath, where each character of the headPath represents a single instruction. Why do we do this? Personally, I find "UUUU" easier to deal with than "U 4".

// In Day09

private fun parseInput(input: List<String>): String =
    input.joinToString("") { row ->
        val direction = row.substringBefore(" ")
        val numberOfMoves = row.substringAfter(' ').toInt()
        direction.repeat(numberOfMoves)
    }

To parse the input, we call joinToString over the List<String> taking care to specify that the delimiter we want is "" (no delimiter). For each element in the input, we calculate the direction via substringBefore and the numberOfMoves via substringAfter, and then converting it to an integer via toInt. Because there is a repeat function on String, we can use that to multiply our direction by the numberMoves. Joining all of these together gives us a nicely formatted String of headMoves.

⭐ Day 9, Part 1

The puzzle text can be found here.

The first thing we know for a fact we need is a Point class to keep track of where we are on the grid. Some things to remember are that the grid is unbounded, and we don’t really care what the actual place values are, just how they relate to each other. I’m tempted to pull this Point class out into its own top-level definition as I’m pretty sure we’ll need another. However, I’m going to leave this as a private inner class in Day09 because I suspect we’ll want a more fully-featured Point2D (or Point3D) class like we’ve had in years past. I’ll wait until that happens.

// In Day09

private data class Point(val x: Int = 0, val y: Int = 0) {
    fun move(direction: Char): Point =
        when (direction) {
            'U' -> copy(y = y - 1)
            'D' -> copy(y = y + 1)
            'L' -> copy(x = x - 1)
            'R' -> copy(x = x + 1)
            else -> throw IllegalArgumentException("Unknown Direction: $direction")
        }
    }

At first, we’ll define Point to have two properties (x and y representing coordinates) and a move function. The move function takes a Char representing the direction we want to move relative to the Point it is being called on, and returns a brand new Point. We take advantage of Kotlin’s when for this, looking for all of the valid inputs and throwing an exception if we get an invalid input. While I can guarantee that the Advent of Code input won’t throw any surprises at us (unlike real production code!), Kotlin can’t know that and demands that our when expression be what’s called “exhaustive”. Meaning, it always produces a result no matter what.

Note that the default values for the x and y properties is 0. We do this because that is the origin spot, and we create a few Point instances today and it saves us the hassle of specifying it during instantiation. Because we don’t actually care what x and y are when we start, you can pick anything for these! Zero just makes the most sense for me and is helpful for debugging.

To do the actual movement, we take advantage of the fact that Point is a data class and comes with a copy function. With this we can just specify what changes during our move. Granted, there are only two properties so copy isn’t all that useful here. But if you have a data class with more than a handful of properties and only a couple of them change, you’ll be thankful that copy exists.

At this point, lets stop and write our main logic for Part One in a function called followPathToBeRefactored. Maybe you can see where this is going? In Part Two we’ll replace this version with a newer, better version. I just don’t want to explain line-by-line replacements later so instead I’m writing this function knowing we’ll throw the whole thing away. Fair warning, this function refers to some other functions on Point that we haven’t written yet (we will below) because I think it might be easier to understand the logic of the whole process before we implement the details. And indeed, this is usually how I write my code - algorithm first, and then implement the details later.

// Day09

private fun followPathToBeRefactored(): Int {
    var head = Point()
    var tail = Point()
    val tailVisits = mutableSetOf(Point())

    headPath.forEach { direction ->
        head = head.move(direction)
        if (!head.touches(tail)) {
            tail = tail.moveTowards(head)
        }
        tailVisits += tail
    }
    return tailVisits.size
}

Let’s go over followPathToBeRefactored. First, we set aside two new Point instances to represent the head and the tail of the rope. We also create a MutableSet<Point> called tailVisits to hold all of the unique Points that the tail has visited. Ultimately, the size of this set will give us our answer.

Next we start in on following instructions. We loop through each direction command in the headPath and do what it says. The first order of business is to move the head in the direction specified. We’ve already written move so this should work already.

With the head taken care of we need to take care of the tail. The puzzle calls for a somewhat complicated set of actions to take place with the tail after the head, but if you think about it, it comes down to “If the head and tail don’t touch, move the tail towards the head”. So we know eventually we know we’ll need two new functions on Point - one called touches to see if two Point objects touch one another, and one called moveTowards to move a Point in the general direction of another Point.

At the end of this loop we add the tail to the tailVisits set to record where it has been. At the end of this function, we return the size of the tailVisits set.

Now let’s write our touches function on Point:

// In Day09.Point

fun touches(other: Point): Boolean =
    (x - other.x).absoluteValue <= 1 && (y - other.y).absoluteValue <= 1

To figure out how touches works lets imagine a grid with two spots filled that happen to be next to one another. Looking at it, we can see that they touch. But how do we know? What if we’re only presented with coordinate data? We know if two spots touch if their x and y coordinates are at most 1 away from each other. If either x OR y are farther away that one place, the points on the grid don’t touch. We’ll use that to derive our answer. For both x and y, we subtract the current value from the other value (or vice versa, it doesn’t matter) and take the absoluteValue of the result. If both of these calculations results in 0 or 1, the Point objects touch one another.

Now let’s write moveTowards:

// In Day09.Point

fun moveTowards(other: Point): Point =
    Point(
        (other.x - x).sign + x,
        (other.y - y).sign + y
    )

The first thing to notice is that moveTowards returns a new Point object. To calculate the new x and y coordinates after a movement takes place, we subtract the source (this) x and y coordinates from the target (other) x and y coordinates. We don’t really care about the actual values here, per se, just the sign of them (so if the answer is -3, we only care that it is negative, so -1 will do). Thankfully, Kotlin has a handy sign function on Int to do this work. We can add that sign value to both x and y and that results in a new Point that has moved towards the other point.

And now we can solve Part One!

// Day09

fun solvePart1(): Int =
    followPathToBeRefactored()

Star earned! Onward!

⭐ Day 9, Part 2

The puzzle text can be found here.

OK, so basically we’re following the same logic we just implemented but with a few more knots, and not just a head and tail. But if you think about it, each knot (except the ends) is both a head and a tail, depending no how you look at it. It is a tail the first time it is pulled along by its head, and then becomes a head relative to the knot behind it, which becomes the tail.

So let’s rewrite our followPath function with that in mind.

// Day09

private fun followPath(knots: Int): Int {
    val rope = Array(knots) { Point() }
    val tailVisits = mutableSetOf(Point())

    headPath.forEach { direction ->
        rope[0] = rope[0].move(direction)
        rope.indices.windowed(2, 1) { (head, tail) ->
            if (!rope[head].touches(rope[tail])) {
                rope[tail] = rope[tail].moveTowards(rope[head])
            }
        }
        tailVisits += rope.last()
    }
    return tailVisits.size
}

Before we set up a head and tail as individual values. Here, we’re going to create an Array of Point objects to represent the entire rope, all at the origin. We’ll keep the concept of tailVisits from the previous version. We still generally do the same work by iterating through each direction in the headPath and moving the head. The head is always going to be the first element of the rope array, so we can move it and set rope[0] to the new location of the head.

But what about the knots that follow? Kotlin Standard Library to the rescue! We’ll look at all the knots in the rope and get the indices of them. This just gives us numbers instead of actual points, but since we’re going to write over the spots in the rope array, this is what we want. We take a windowed view of these indices of width 2, sliding over 1 place each time. So if we start with [0, 1, 2, 3, 4...] we end up with [0,1], [1,2], [2,3], [3,4].... The first element in the windowed array is the index in the rope of the head and the second element is the index of the tail. We use Kotlin’s support for destructuring to pick these out and name them head and tail.

The logic we do with head and tail is identical to Part One, except we have to deal with the fact that they are elements in rope now. So our logic stays the same but we refer to their positions rather than the actual head and tail points themselves.

And now we can solve Part Two by passing 10 (the number of knots) to the followPath function! I’ve also included what Part One looks like now for completeness. You can delete the followPathToBeRefactored function.

// In Day09

fun solvePart1(): Int =
    followPath(2)

fun solvePart2(): Int =
    followPath(10)

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