Advent of Code 2023 - Day 1, in Kotlin - Trebuchet?!
Kotlin solutions to parts 1 and 2 of Advent of Code 2023, Day 1: 'Trebuchet?!'
Hello again! Another December 1st means the start of a new season of Advent of Code! I look forward to this every year because I have so much fun solving the puzzle and comparing my solutions to those of my friends, and a bunch of random strangers on the internet.
For the past six years I have made a serious effort to solve the puzzle and blog about it on the day it was revealed. However, because of the significant demand on my time that takes, I’ve decided to let myself relax that stance a bit this year. I will try my best to solve and blog each puzzle each day. However, given that I have just started a new job and have several personal obligations during the month of December, I am going to just give a best-effort approach. So we’ll see how well I do. I am going to try and embrace the concept of Eventual Consistency and get these blog posts done eventually, ideally not too far after the puzzle is revealed. Thanks for understanding.
As in past years, I will put my solutions in a GitHub Repository .
Let’s get started!
JetBrains Live Stream
I was fortunate enough to be invited to the JetBrains Live Stream today to go over my solution to today’s puzzle. Check it out, and be sure to check it out for the first 12 days of the Advent of Code. I’ve seen the secret guest list and they’ve got some great folks lined up to solve these puzzles on-air. I think you’ll really enjoy yourself.
Also, JetBrains are running an Advent of Code contest with some nice prizes. Details are in the video as well.
Puzzle Input
Each day we’ll have a class named after the day number (Day01
in our case today) which will have an input
parameter that represents the puzzle input. In our case, this input comes from the unit test that runs the actual puzzle and tests the solution for correctness. The type of input
may change from day to day. Today, for example, it is a List<String>
representing the entire puzzle input. Other days it might be a String
or List<Int>
, depending on how we’ll use it when writing the solution. The work of loading the daily input file and possibly turning it into a List
is done by a helper class class called Resources
. which I’ve used in the past.
Let’s set up our Day01
class, taking the puzzle input
as a List<String>
, which we will make a private property that we can use througout our class. We can take this input as-is since we don’t need to do any additional processing on it.
class Day01(private val input: List<String>) {
}
⭐ Day 1, Part 1
The puzzle text can be found here.
Part 1 asks us to find some integers in each row of input, combine them, and sum them all together. For this, I’ve decided to use first
and last
from the Kotlin Standard Library which will take a lambda and (in our case) return the first
or last
digit in each row
of input. Since these are Character
s, we will need to concatenate them before turning them into an Int
via toInt
. To concatenate, I’ve chosen to make the whole expression a String
. An alternative approach would have been to convert each Character
to an Int
, multiply the first
one by 10
, and add them together.
// In Day01
private fun calibrationValue(row: String): Int =
"${row.first { it.isDigit() }}${row.last { it.isDigit() }}".toInt()
Next, we use sumOf
over the input
to add all of the calibration values together.
// In Day01
fun solvePart1(): Int =
input.sumOf { calibrationValue(it) }
Star earned! Onward!
⭐ Day 1, Part 2
The puzzle text can be found here.
Part 2 might seem tricky on the surface, but we’ll work through it and hopefully come up with a good solution. The first thing we’ll need to do is make a Map<String, Int>
to store the mapping from a word to its value. We could have made the map values Character
s, but I kept them as Int
because I liked the way it highlighted in my editor. :)
// In Day01
private val words: Map<String, Int> = mapOf(
"one" to 1,
"two" to 2,
"three" to 3,
"four" to 4,
"five" to 5,
"six" to 6,
"seven" to 7,
"eight" to 8,
"nine" to 9
)
Before we use our map let’s stop and think. Given an input row, we really only care when an individual Character
is a digit, or is the start of a word that could spell a digit. Any other letter of any kind can be ignored.
To accomplish this, we’ll use sumOf
over each row
of input
after we calculate the calibrationValue
just like in Part 1. This time we’ll need to modify the row
so that the call to calibrationValue
works properly. For this, we’ll look at each Character
of the row
using mapIndexedNotNull
which gives us each Character
of a String
and its index
value.
We’ll look at each Character
and if it is a digit, we just accept it into the eventual String
we end up creating. No more work to do in that case. The next case is more interesting! We will call our possibleWordsAt
function (see below) in order to get all of the String
s starting at this position in the row
(via the index
) that could be a word representing a digit. Once we have the List<String>
that represents possible number-words, we loop through them and see if any of them are in the words
map. If they are, the call to words[candidate]
will return a non-null value, which we’ll add to our String
. If it returns a null (the value is not in the map), we can keep looping. This shows off my favorite Kotlin Standard Library function - firstNotNullOfOrNull
, which roughly translates to “run this lambda over the input until you get a non-null value and then stop, and if you don’t ever find a value, return null”. So for example, if one word returns “two”, we’ll get a 2 from the words
map and add it to our String
.
Since we end that call with a List<String>
, we will join them into a single String
via joinToString
.
// In Day01
fun solvePart2(): Int =
input.sumOf { row ->
calibrationValue(
row.mapIndexedNotNull { index, c ->
if (c.isDigit()) c
else
row.possibleWordsAt(index).firstNotNullOfOrNull { candidate ->
words[candidate]
}
}.joinToString()
)
}
All that’s left is to write our possibleWordsAt
function. I decided to write this as an extension function, but it doesn’t really have to be if you prefer not to. Either way, we only need to consider the next 3 to 5 characters because the words are at least 3 characters long and at most 5. We loop over the range of 3 to 5 and take a substring
starting at the startingAt
point, and extending at most to the end of the String
. To make sure we don’t ask for a substring
larger than we can get, we call coerceAtMost
from the Kotlin Standard Library. Note that there is a corresponding coerceAtLeast
which does the opposite.
// In Day01
private fun String.possibleWordsAt(startingAt: Int): List<String> =
(3..5).map { len ->
substring(startingAt, (startingAt + len).coerceAtMost(length))
}
Calling solvePart2()
should now give us the correct answer.
Star earned! See you tomorrow (hopefully)!
Further Reading
- Index of All Solutions - All posts and solutions for 2023, in Kotlin.
- My Github repo - Solutions and tests for each day.
- Solution - Full code for day 1
- Advent of Code - Come join in and do these challenges yourself!