Code Review Videos > Kotlin > Advent of Code 2023: Day 1 Part 2 – Fixes Required

Advent of Code 2023: Day 1 Part 2 – Fixes Required

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 lettersonetwothreefourfivesixseveneight, 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 298313244214, 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   :11Code 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 or two or three or four … or nine
  • check the last letter to see if the preceding it match one or two or three or four … or nine
  • 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.

kotlin starts with function

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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.