Skip to Content

Advent of Code 2021 - Day 5, in Kotlin - Hydrothermal Venture

Kotlin solutions to parts 1 and 2 of Advent of Code 2021, Day 5: 'Hydrothermal Venture'

Posted on

Let’s get ready to draw some lines! In today’s solution we’ll learn about several powerful functions in the Kotlin standard library, and a couple of functional programming concepts. I hope you have fun with this one, I sure did. Just be aware that if you are trying to solve part one without spoilers for part two, I do jump ahead here. So just watch out.

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

Problem Input

Before we get into parsing, let’s define a class to represent a Point. We’ll call this Point2d and store it in a file called Geometry.kt. Remember, in Kotlin we can put as many classes or functions in a file as we want, so we’ll put all of our geometry and location work in there for Advent of Code 2021.

// In Geometry.kt

data class Point2d(val x: Int, val y: Int)

Don’t worry if that appears to be a bit sparse, we’ll add some functions later as we need them.

Now that we have that, we can parse our intput. Today, we’ll store each of the instructions into a List<Pair<Point2d, Point2d>>. Each row is a Pair and each side of the arrow is a Point2d within the Pair. Since we have a bunch of them, we’ll store them in a List.

class Day05(input: List<String>) {

    private val instructions = input.map { parseRow(it) }

    private fun parseRow(input: String): Pair<Point2d, Point2d> =
        Pair(
            input.substringBefore(" ").split(",").map { it.toInt() }.let { Point2d(it.first(), it.last()) },
            input.substringAfterLast(" ").split(",").map { it.toInt() }.let { Point2d(it.first(), it.last()) }
        )
}

The parseRow function makes use of two of my favorite Kotlin standard library functions - substringBefore and substringAfterLast. By using these two along with split, we can succinctly parse the String for today’s input without having to learn about Regular Expressions.

⭐ Day 5, Part 1

The puzzle text can be found here.

Before we get started, I want to warn you that we’re going to do more work than is strictly necessary to solve part one, for a couple of reasons. First, we’re being asked to filter out diagonal lines. Why put them in the puzzle if we’re not going to use them? We probably are, so let’s figure them out. Second, I know how part two goes, and it’s just easier to explain everything if we assume some things we could probably guess from the puzzle text.

First, let’s give our Point2d the ability to tell us if it shares an axis with another point. This should allow us to filter for points that are directly horizontal or vertical to one another:

// In Point2d:

infix fun sharesAxisWith(that: Point2d): Boolean =
    x == that.x || y == that.y

If you haven’t seen infix before, let me explain that. The infix keyword tells Kotlin that we might want to call this function in a special way. Both of these are valid ways to call sharesAxisWith

// EXAMPLE ONLY: 

somePoint.sharesAxisWith(someOtherPoint)   // Not using infix

somePoint sharesAccessWith someOtherPoint  // Infix

By using infix, our function looks more like it is part of the language itself and is easier to read (subjective: to me). Note that even though we define our function as infix, we can still call it the traditional way. Both ways I’ve illustrated above will work.

And now that we know that, we can add a function to draw lines between two Point2d objects:

// In Point2d

infix fun lineTo(that: Point2d): List<Point2d> {
    val xDelta = (that.x - x).sign
    val yDelta = (that.y - y).sign

    val steps = maxOf((x - that.x).absoluteValue, (y - that.y).absoluteValue)

    return (1 .. steps).scan(this) { last, _ -> Point2d(last.x + xDelta, last.y + yDelta) }
}

The first two lines of this function figure out what kind of delta we need to apply to x and y for each new Point2d we will generate. We can use the handy sign function (which I didn’t originally know about, thanks u/clouddjr!), which allows us to figure out how many to add or subtract from each point to move it one spot in the x or y direction. Next, we need to figure out how many points we will have on our line. This is done by finding the maximum distance between the x values for both points and the y values for both points. Finally, we set up a range from 1 to the number if steps we need to take and use scan, a nice new function in the Kotlin standard library. The scan function works almost like a fold (we iterate over something and carry state along for each iteration). The major difference is that fold only keeps the last value and scan keeps every value we generate. Perfect for drawing a line! The lambda we pass to scan generates a new point one-off from the previous point, and the whole function returns them as a List. Pretty handy! Oh, and if you’ve never seen _ before, that’s Kotlin-shorthand for “I don’t care”. We don’t care about the range value, we ignore it.

Before we solve part one, let’s get a brief lesson in a functional programming concept called Higher-Order Functions. A Higher-Order function is a function that either takes a function as an argument, or returns a function as a result. This can be very handy because we can write a function generically (as we are about to do) but let our caller provide a piece of the logic in the form of a function.

We now have enough to solve part one of our puzzle. Let’s encapsulate this logic into a function called solve, which takes a Higher-Order function telling us what instructions to work with (again: I’ve read ahead and we’ll need this for part two).

// In Day05

private fun solve(lineFilter: (Pair<Point2d, Point2d>) -> Boolean) =
    instructions
        .filter { lineFilter(it) }
        .flatMap { it.first lineTo it.second }
        .groupingBy { it }
        .eachCount()
        .count { it.value > 1 }

Our Higher-Order function in this case is lineFilter. This tells Kotlin: “Give me a function that takes a Pair<Point2d,Point2d> and returns a Boolean”. First, we use this to filter in anything we want. More on actually calling this function below. Next, we want to turn each Pair<Point2d,Point2d> into a List<Point2d> representing a line between them. We flatMap here rather than map because if we map we’ll end up with List<List<Point2d>> and we just want one single list - List<Point2d>, and flatMap handles that for us. This is a single list representing every point we’re going to look at. Next, we can use groupingBy and eachCount from the Kotlin standard library to group the points into a Map<Point2D, Int>, where the key is a point and the value is how many times we’ve seen it. Finally, we can count the number of values in that map that are more than 1 - meaning we’ve drawn a line over that Point2d more than once.

Calling this function with our line filtering logic solves part one:

// In Day05

fun solvePart1(): Int =
    solve { it.first sharesAxisWith it.second }

Our line filtering function is that sharesAxisWith infix function we wrote way back at the start of the solution. This tells our solve function to only keep lines that are not diagonals.

Star earned! Onward!

⭐ Day 5, Part 2

The puzzle text can be found here.

Because we wrote so much code to parse our input and solve part one, we’re all set for part two. The only difference between part one and two is which instructions we care about. In part one, we only want horizontal or vertical lines but in part two we want of the line instructions. Thankfully our line drawing algorithm handles both cases. This means that if we pass a filter to our solve function that accepts any line, we’re done.

// In Day05

fun solvePart2(): Int =
    solve { true }

In this case we don’t have to do any filtering so we can just pass true and the filter in solve will accept all of the lines. Does this mean we waste some time? Yes. Does this also lend itself to a nice clean solution? Also, yes.

Star earned!

Further Reading

  1. Index of All Solutions - All posts and solutions for 2021, in Kotlin.
  2. My Github repo - Solutions and tests for each day.
  3. Solution - Full code for day 5
  4. Advent of Code - Come join in and do these challenges yourself!