This post follows on from Advent of Code 2023: Day 1 Part 1 – Calibrating Snow Operations where we were busy solving the Advent of Code 2023 Day 1 exercises using Kotlin and TDD.
Part 1 is solved, but as soon as you provide the answer, Part 2 becomes available. Mean.
The problem is similar, but requires an iteration on the solution to Part 1. Or it did in my case.
The Problem
OK, so the revised problem is as follows:
Your calculation isn’t quite right. It looks like some of the digits are actually spelled out with letters: one
, two
, three
, four
, five
, six
, seven
, eight
, and nine
also count as valid “digits”.
Equipped with this new information, you now need to find the real first and last digit on each line. For example:
two1nine
eightwothree
abcone2threexyz
xtwone3four
4nineeightseven2
zoneight234
7pqrstsixteen
In this example, the calibration values are 29
, 83
, 13
, 24
, 42
, 14
, and 76
. Adding these together produces 281
.
What is the sum of all of the calibration values?
Creating Tests
This problem initially provides enough test cases for writing 7 unit tests.
It also makes use of the existing input.txt
we created in Part 1. Initially that won’t be a concern.
I got started by creating a new package – day1b
– and adding the following tests:
package com.codereviewvideos.aoc23.day1b
import org.junit.jupiter.api.Test
import java.io.File
import kotlin.test.assertEquals
class CalibratorV2Test {
@Test
fun `two1nine returns 29`() {
assertEquals(29, CalibratorV2.calibrate("two1nine"))
}
@Test
fun `eightwothree returns 83`() {
assertEquals(83, CalibratorV2.calibrate("eightwothree"))
}
@Test
fun `abcone2threexyz returns 13`() {
assertEquals(13, CalibratorV2.calibrate("abcone2threexyz"))
}
@Test
fun `xtwone3four returns 24`() {
assertEquals(24, CalibratorV2.calibrate("xtwone3four"))
}
@Test
fun `4nineeightseven2 returns 42`() {
assertEquals(42, CalibratorV2.calibrate("4nineeightseven2"))
}
@Test
fun `zoneight234 returns 14`() {
assertEquals(14, CalibratorV2.calibrate("zoneight234"))
}
@Test
fun `7pqrstsixteen returns 76`() {
assertEquals(76, CalibratorV2.calibrate("7pqrstsixteen"))
}
}
Code language: Kotlin (kotlin)
At this point CalibratorV2
didn’t exist, so I let IntelliJ create this for me, which again resulted in a very similar setup to Part 1:
package com.codereviewvideos.aoc23.day1b
class CalibratorV2 {
companion object {
fun calibrate(s: String): Int {}
package com.codereviewvideos.aoc23.day1b
class CalibratorV2 {
companion object {
fun calibrate(s: String): Int {
}
}
}
Code language: Kotlin (kotlin)
I then copied across my implementation from Part 1, and ran the first test.
It failed, giving:
Expected :29
Actual :11
Code language: CSS (css)
Which makes sense because Part 1 only concerned itself with finding the numbers in the string, and not the number spelled out as a written word.
Initial Thoughts On How To Solve
As I know that there is a requirement to parse a full text file full of strings, I’m going to keep the basic setup I had from Part 1:
package com.codereviewvideos.aoc23.day1a
class CalibratorV2 {
companion object {
fun calibrate(s: String): Int {
val split = s.split("\n")
return split.sumOf { calibrateLine(it) }
}
private fun calibrateLine(s: String): Int {
// snow magic happens here
}
}
}
Code language: Kotlin (kotlin)
Doing the absolute least possible to get a pass, we can ‘cheat’ a bit by hard coding the return value to make the first test go green:
private fun calibrateLine(s: String): Int {
return 29
}
Code language: Kotlin (kotlin)
That falls apart on the second test, of course.
So we need something a little more robust.
My thinking is to use recursion. The idea is that I will start with the initial string, then using a similar idea to Part 1, get the first and last character in the string, then see if that matches a word.
Here’s a more visual example:
Taking the first test:
@Test
fun `two1nine returns 29`() {
assertEquals(29, CalibratorV2.calibrate("two1nine"))
}
Code language: Kotlin (kotlin)
The idea is the function will do the following:
- check the first letter to see if the letters following it match
one
ortwo
orthree
orfour
… ornine
- check the last letter to see if the preceding it match
one
ortwo
orthree
orfour
… ornine
- if they do, we have found a match
Excellent, that actually covers off the first test.
But a more realistic example is one where that doesn’t immediately happen. In that case we have:
This corresponds to the following test:
@Test
fun `abcone2threexyz returns 13`() {
assertEquals(13, CalibratorV2.calibrate("abcone2threexyz"))
}
Code language: Kotlin (kotlin)
In this case, a
and z
do not match.
So we need to remove those letters from the string, giving us a new string of bcone2threexy
and then call the function again with this new input.
Repeat this process, trimming the string down until we do get a match.
Implementing This Idea As Code
Like in Part 1 whereby we were able to use first
and last
, Kotlin again comes to our rescue with startsWith
and endsWith
.
And this works identically for endsWith
.
Which gave me something like this:
val first =
when {
s.startsWith("one") -> 1
s.startsWith("two") -> 2
s.startsWith("three") -> 3
s.startsWith("four") -> 4
s.startsWith("five") -> 5
s.startsWith("six") -> 6
s.startsWith("seven") -> 7
s.startsWith("eight") -> 8
s.startsWith("nine") -> 9
else -> null
}
Code language: Kotlin (kotlin)
This got my spidey senses tingling however, as I recognised immediately I would need the same thing for endsWith
, and that was already going to be over 25 lines of code.
Usually these code puzzles can be solved with a few number of lines… so I was thinking I’d gone awry.
However, the gist of this should work.
There is also the continued need to work with digits, so I had to add one other entry to my when
:
val first =
when {
s.startsWith("one") -> 1
s.startsWith("two") -> 2
s.startsWith("three") -> 3
s.startsWith("four") -> 4
s.startsWith("five") -> 5
s.startsWith("six") -> 6
s.startsWith("seven") -> 7
s.startsWith("eight") -> 8
s.startsWith("nine") -> 9
s.first().isDigit() -> s.first().digitToInt()
else -> null
}
Code language: Kotlin (kotlin)
I should point out that originally I had this as s.firstOrNull()?.isDigit()
…
But after a bit of thought I realised that I didn’t need a null
check here, as that would be handled by the else
.
Again, I needed this for both the first and last values, so I ended up with a lot of code here.
Trimming The String
This actually works well any test case where we begin and end with a valid number.
It needs a bit more code to trim the string when that is not the case.
Here’s the solution I came up with:
package com.codereviewvideos.aoc23.day1b
class CalibratorV2 {
companion object {
fun calibrate(s: String): Int {
val split = s.split("\n")
return split.sumOf { calibrateLine(it) }
}
private fun calibrateLine(s: String, firstValue: Int? = null, secondValue: Int? = null): Int {
var first = firstValue
if (first == null) {
first =
when {
s.startsWith("one") -> 1
s.startsWith("two") -> 2
s.startsWith("three") -> 3
s.startsWith("four") -> 4
s.startsWith("five") -> 5
s.startsWith("six") -> 6
s.startsWith("seven") -> 7
s.startsWith("eight") -> 8
s.startsWith("nine") -> 9
s.first().isDigit() -> s.first().digitToInt()
else -> null
}
}
var second = secondValue
if (second == null) {
second =
when {
s.endsWith("one") -> 1
s.endsWith("two") -> 2
s.endsWith("three") -> 3
s.endsWith("four") -> 4
s.endsWith("five") -> 5
s.endsWith("six") -> 6
s.endsWith("seven") -> 7
s.endsWith("eight") -> 8
s.endsWith("nine") -> 9
s.last().isDigit() -> s.last().digitToInt()
else -> null
}
}
var newString = if (first == null) s.subSequence(1, s.length).toString() else s
newString =
if (second == null) newString.subSequence(0, newString.length - 1).toString()
else newString
if (first == null || second == null) {
return calibrateLine(newString, first, second)
}
return (first.toString() + second).toInt()
}
}
}
Code language: Kotlin (kotlin)
I had to update the private fun calibrateLine
to know whether it had already found a match for the two values we care about. Initially both would be null
, but on any subsequent calls either could be populated.
One thing I am not so keen on with Kotlin is their lack of a ternary operator. Instead we have to use the if
/ else
expression.
On line 48 we initialise a mutable variable newString
.
It uses the if
expression to check whether the variable first
is null
.
If first
is null
, it takes a substring of s
starting from index 1 (excluding the character at index 0) to the end of the string (s.length
).
If first
is not null
, it simply assigns the value of s
to newString
.
This is then repeated on lines 49 through 51, only taking the end of the string instead.
If either first
or second
are still null
after this process, we go round again.
Otherwise, return the answer, stolen from Part 1.
And this works. All tests pass.
But it’s not pretty.
Can We Do Better?
TDD gives us the concept of red, green, refactor.
We have green. We have a pass.
For this puzzle we could move on.
But it would be nice to see if we can learn a little more Kotlin in the process, now that we don’t need to worry about finding the answer.
I have a bunch of things to cover on that front, but I will do it in my next post.