Advent of Code 2020 - Day 12, in Kotlin - Rain Risk
Kotlin solutions to parts 1 and 2 of Advent of Code 2020, Day 12: 'Rain Risk'
Today we’ll define some reusable classes for direction and points, which will help us today and hopefully in future puzzles as well.
If you’d rather just view code, the GitHub Repository is here .
Problem Input
We will take in the puzzle input as a List<String>
and not alter it further. While we could convert our Strings into some kind of data class, we only have to go through the input once, and I’m not sure it is worth the effort.
Having said that, it’s time we commit to a Point2D
implementation, and like previous years, a Direction
sealed class structure. These strike me as generally useful to have.
First, let’s talk about our Direction
sealed class:
// In Movement.kt
sealed class Direction {
abstract val turnLeft: Direction
abstract val turnRight: Direction
abstract val offset: Point2D
operator fun invoke(dir: String): Direction =
when (dir) {
"N" -> North
"S" -> South
"E" -> East
"W" -> West
else -> throw IllegalArgumentException("No such direction $dir")
}
object North : Direction() {
override val turnLeft = West
override val turnRight = East
override val offset = Point2D(-1, 0)
}
object South : Direction() {
override val turnLeft = East
override val turnRight = West
override val offset = Point2D(1, 0)
}
object West : Direction() {
override val turnLeft = South
override val turnRight = North
override val offset = Point2D(0, -1)
}
object East : Direction() {
override val turnLeft = North
override val turnRight = South
override val offset = Point2D(0, 1)
}
}
One might ask why we use a sealed class with sealed objects here, rather than an enumeration. The problem with using an enumeration is that we will not be able to reference all of the enumerated values properly in turnLeft
and turnRight
if they haven’t been defined yet. So we’ve resorted to this sealed class.
The first thing to notice is the invoke
operator. This lets us call Direction("W")
to create a new Direction
, instead of having to define a function in the companion and calling Direction.of("W")
or something similar.
Next, we have four directions and each implements the abstract turnLeft
, turnRight
, and offset
values which are specified in the parent class.
Because we reference Point2D
in direction, now is a good time to discuss that implementation:
// In Movement.kt
data class Point2D(val x: Int, val y: Int) {
operator fun plus(other: Point2D): Point2D =
Point2D(x + other.x, y + other.y)
operator fun times(by: Int): Point2D =
Point2D(x * by, y * by)
infix fun distanceTo(other: Point2D): Int =
(x - other.x).absoluteValue + (y - other.y).absoluteValue
companion object {
val ORIGIN = Point2D(0, 0)
}
}
Our simple Point2D
has an x
and a y
coordinate. This should make point-based logic simpler for future puzzles instead of having to resort to Pair<Int,Int>
like we have been. We’ll define a plus
operator so we can add two points together, and we’ll add a times
operator so we can multiply one point by a fixed amount (which we do in the puzzle). I can eventually see a need to multiply one Point2D
by another, but we don’t need it today and we can easily add it later.
We will define an infix
function so we can calculate the distance between two Point2D
objects, as described in the puzzle. An infix
function allows us to leave off the dot and the parenthesis when calling it, making it look like an operator. For example here distanceTo there
, assuming here
and there
are Point2D
objects.
Finally, we’ll declare an ORIGIN
as I can see us using that a lot. I almost made 0,0
the default x,y
values for this point but couldn’t come up with a great reason for doing that.
⭐ Day 12, Part 1
The puzzle text can be found here.
Now that we have a concept of Direction
and place (via Point2D
), we should have an easier time with future puzzles, starting with today!
Let’s define another data class for our Ship
:
// In Day12
data class Ship(val at: Point2D = Point2D.ORIGIN, val facing: Direction = Direction.East) {
fun forward(amount: Int): Ship =
copy(at = at + (facing.offset * amount))
fun move(dir: Direction, amount: Int): Ship =
copy(at = at + (dir.offset * amount))
fun turnLeft(times: Int): Ship =
(0 until times).fold(this) { carry, _ ->
carry.copy(facing = carry.facing.turnLeft)
}
fun turnRight(times: Int): Ship =
(0 until times).fold(this) { carry, _ ->
carry.copy(facing = carry.facing.turnRight)
}
}
As you can see, our Ship
is made up of its location (at
) and the direction it is facing
. Unfortunately we don’t get to put fun names or passengers in this ship. Maybe another day.
The Ship
has a few functions and each one of them returns a new instance of Ship
, making this an immutable data structure. The forward
function generates a new Ship
in a new location, taking advantage of both the plus
and times
operators we defined in Point2D
! Move works similarly, but uses the specified direction rather than the ship’s facing.
The turnLeft
and turnRight
functions use a fold
to generate new Ship
objects that are turned in the direction we specify.
Now that we have all that, we can write a solution to part 1:
// In Day12
fun solvePart1(): Int =
input.fold(Ship(Point2D.ORIGIN, Direction.East)) { ship, instruction ->
val command = instruction.first()
val amount = instruction.substring(1).toInt()
when (command) {
'N' -> ship.move(Direction.North, amount)
'S' -> ship.move(Direction.South, amount)
'E' -> ship.move(Direction.East, amount)
'W' -> ship.move(Direction.West, amount)
'F' -> ship.forward(amount)
'L' -> ship.turnLeft(amount / 90)
'R' -> ship.turnRight(amount / 90)
else -> throw IllegalArgumentException("Unknown instruction: $instruction")
}
}.at distanceTo Point2D.ORIGIN
Because every time we call a function on our Ship
, we can fold
over the input, generating a new Ship
each time through. We’ll parse out the command
(the first character of the input) and the amount
(anything after the command), and use a when
expression to interpret the command
s. I won’t go into detail, mostly this should be self explanatory as we’re delegating the work to our Ship
class, and in turn to either Direction
or Point2D
.
We can use our infix distanceTo
function to figure out the distance from the origin point to the final Ship
that our fold
generates, giving us the answer to part 1. Star earned!
Intermission
If you’ve earned every star up until this point, congratulations! We are half way through with Advent of Code 2020!
⭐ Day 12, Part 2
The puzzle text can be found here.
While most of the code we’ve written is useful for Part 2, we’ll need to make a couple of changes. First, we need to add the concept of rotation to our Point2D
class, by adding some new functions:
// In Point2D
fun rotateLeft(): Point2D =
Point2D(x = y * -1, y = x)
fun rotateRight(): Point2D =
Point2D(x = y, y = x * -1)
These functions too me a while to write because I was having a hard time understanding the instructions as they were written. But after I wrote down and closely followed what we were being told, it eventually made sense. Essentially, we flip the x and y axis and negate one of them, depending on which direction we are turning.
And because we have a Ship
, we might as well have a Waypoint
in today’s puzzle as well:
// In Day12
data class Waypoint(val at: Point2D = Point2D(-1, 10)) {
fun move(dir: Direction, amount: Int): Waypoint =
Waypoint(at + (dir.offset * amount))
fun turnLeft(amount: Int): Waypoint =
(0 until amount).fold(this) { carry, _ ->
Waypoint(carry.at.rotateLeft())
}
fun turnRight(amount: Int): Waypoint =
(0 until amount).fold(this) { carry, _ ->
Waypoint(carry.at.rotateRight())
}
}
We only have to have three functions for the Waypoint
- how to move
and how to turn. The move
calculates a new location by multiplying the directional offset, and the rotations happen similar to how we implemented them earlier, depending on our Point2D
functions.
Now we can solve part 2:
// In Day12
fun solvePart2(): Int {
var waypoint = Waypoint()
var ship = Ship()
input.forEach { instruction ->
val command = instruction.first()
val amount = instruction.substring(1).toInt()
when (command) {
'N' -> waypoint = waypoint.move(Direction.North, amount)
'S' -> waypoint = waypoint.move(Direction.South, amount)
'E' -> waypoint = waypoint.move(Direction.East, amount)
'W' -> waypoint = waypoint.move(Direction.West, amount)
'F' -> ship = ship.copy(at = ship.at + (waypoint.at * amount))
'L' -> waypoint = waypoint.turnLeft(amount / 90)
'R' -> waypoint = waypoint.turnRight(amount / 90)
else -> throw IllegalArgumentException("Unknown instruction: $instruction")
}
}
return Point2D.ORIGIN distanceTo ship.at
}
This looks a lot like the solution to part 1, except we aren’t doing it as a single expression, so I won’t go over it in great details. Because some instructions have us move the Ship
and others have us move the Waypoint
, I felt it was cleaner to make them both vars
and set new values when we moved them, rather than trying to coordinate this all in a single expression. This version works similarly to part 1, but most of the instructions involved the waypoint
instead of the ship
.
Running this solves part 2.
See you tomorrow!
Further Reading
- Index of All Solutions - All posts and solutions for 2020, in Kotlin.
- My Github repo - Solutions and tests for each day.
- Solution - Full code for day 12
- Advent of Code - Come join in and do these challenges yourself!