Day 1: Trebuchet?!
Puzzle
FYI: the date of this article may say December 1st, 2023, but I’m actually writing this mid-way through 2024. Sorry!
I learned about Advent of Code in 2020 during Covid and found it to be a fun exercise while locked at home. For those if you unfamiliar with AoC, each day in December leading up to Christmas morning, a new puzzle is released. Given a problem statement and a sample input, you’re tasked with solving the two parts in any language of your choice. For each part solved correctly, you earn a star – the goal being to secure 50 starts (2 for each day, up to 25 days). Sadly, I never ended up finishing it and didn’t pick it back up over the next couple of years.
I’m a couple months out of college now and find myself missing some of the functional programming I had done over the years (OCaml) – so I figure now would be a good time to pick it up again. I’ll be going through last year (2023)’s puzzles and solving them in OCaml. Most of the solutions will be written with the standard library – although I may attempt to use other libraries like Base
or Core
in a few problems here and there. Without further ado, let’s get into Day 1!
Part 1
In this puzzle, we’re given a list of alphanumeric strings and are tasked to recover a calibration value for each string. A calibration value is formed by combining the first digit and the last digits of the string to form a singular two-digit number.
For example:
1abc2
represents the calibration value12
abc123xyz
represents the calibration value13
hello5world
represents the calibration value55
Finally, we’re asked to find the sum of all the calibration values in the input file.
The core of the problem is extracting the calibration value for 1 string. Once we solve that, we simply repeat on all of the remaining strings and take the sum.
For one string, we can break this into two parts:
- Find the first digit.
- Find the last digit out of the remaining string.
Part 1 above is relatively straightforward since we can check if the current character is one of the 9 digits (0
doesn’t appear in these strings.)
Part 2 requires a bit more tinkering. With the remaining string left after Part 1, we need to find the last digit. Since we’re traversing the string left-to-right, we’ll need to keep track of the most recently seen digit as we go along, ensuring that we always have the latest-appearing digit.
Implementation-wise, I wanted to take advantage of OCaml’s beautiful pattern-matching structure. Each string
will be converted into a char list
, allowing us to match on the list and step through one at a time.
module CL = struct
type t = char list
let compare = compare
end
module CLMap = Map.Make (CL)
let char_list_of_string s = s |> String.to_seq |> List.of_seq
(* Determines if [l] starts with [sub]. *)
let rec starts_with sub l =
match (sub, l) with
| [], _ -> true
| _, [] -> false
| hd1 :: tl1, hd2 :: tl2 -> hd1 = hd2 && starts_with tl1 tl2
(* Determines if a key in [mapping] is a prefix to [l],
returning it if so. *)
let starts_with_num l mapping =
let rec acc remaining =
match remaining with
| [] -> None
| (chars, v) :: t -> if starts_with chars l then Some v else acc t
in
acc (CLMap.bindings mapping)
let extract mapping s =
let chars = char_list_of_string s in
let rec acc first second l =
match (first, second, l) with
| _, _, [] -> (first, second)
| None, None, _ :: tl ->
let prefix = starts_with_num l mapping in
acc prefix prefix tl
| Some _, Some _, _ :: tl ->
let prefix = starts_with_num l mapping in
if prefix = None then acc first second tl else acc first prefix tl
| _, _, _ ->
Invalid_argument
"Cannot have non-empty list while first and last differ in Optional \
values" |> raise
in
acc None None chars
let score (d1, d2) =
match (d1, d2) with
| Some d1, Some d2 -> (d1 * 10) + d2
| None, Some d ->
Invalid_argument
(Printf.sprintf "Both values must be Some: (None, Some %d)" d)
|> raise
| Some d, None ->
Invalid_argument
(Printf.sprintf "Both values must be Some: (Some %d, None)" d)
|> raise
| None, None -> Invalid_argument "No digits found" |> raise
let get_part_result ?(debug = false) mapping file =
let lines = file_lines file in
let calibrations = List.map (extract mapping) lines in
let answer = List.fold_left (fun acc p -> acc + score p) 0 calibrations in
if debug then
List.combine lines (List.map score calibrations)
|> Utils.print_list (fun (s, i) -> Printf.sprintf "(%s,%i)" s i);
answer
let digit_mappings =
CLMap.empty |> CLMap.add [ '1' ] 1 |> CLMap.add [ '2' ] 2
|> CLMap.add [ '3' ] 3 |> CLMap.add [ '4' ] 4 |> CLMap.add [ '5' ] 5
|> CLMap.add [ '6' ] 6 |> CLMap.add [ '7' ] 7 |> CLMap.add [ '8' ] 8
|> CLMap.add [ '9' ] 9
let part_one file = get_part_result digit_mappings file
In the above solution, extract
is the function that performs the string traversal, stepping through the string one char at a time, examining the digit, and updating the first
and second
placeholders, if needed.
Note that the extract
function takes in an extra parameter mappings
, which is useful for translating char list
s to the actual digit used for building the int
. It seems trivial here given that each digit is a singular char
, but it provides more flexibility in terms of mappings, which we’ll see in Part 2 momentarily.
Part 2
Whew, that was some boilerplate code. In this follow-up, we’re now told that digits may also be spelled out with letters (in addition to the numeric literal). That is:
one2three
represents the calibration value13
7eightblahnine
represents the calibration value79
Luckily, we added support for a custom mapping of char list
s to int
values. So, we can take our solution to Part 1, add a few key-value mappings, and run it again!
let digit_word_mappings =
digit_mappings
|> CLMap.add (char_list_of_string "one") 1
|> CLMap.add (char_list_of_string "two") 2
|> CLMap.add (char_list_of_string "three") 3
|> CLMap.add (char_list_of_string "four") 4
|> CLMap.add (char_list_of_string "five") 5
|> CLMap.add (char_list_of_string "six") 6
|> CLMap.add (char_list_of_string "seven") 7
|> CLMap.add (char_list_of_string "eight") 8
|> CLMap.add (char_list_of_string "nine") 9
let part_two file = get_part_result digit_word_mappings file
This theme of reducing a complicated problem into a simpler one comes up frequently in AoC (as well as other coding problems), but it’s incredibly satisfying once we get it right.
Alternative Solution
Let’s think about the complexity of my solution for a moment. No matter the input, extract
will necessarily traverse the entire string, even if the relevant calibration digits are at the front and end. This works fine for short strings, but if the inputs are especially long, we can benefit from searching starting from the two endpoints.
Finding the first digit is simple. In fact, we already do this in the above implementation. The difference is in finding the last digit.
We can use a neat trick to find the last digit without having to write as much logic with respect to keeping track of the most-recently seen digit. If we reverse the string, we can utilize our starts_with_num
function to find the first digit. The first digit in the reversed string will actually be the last digit in the original string.
There’s one caveat though: since we reversed the input string, we’ll also need to reverse the text numbers that we search for. That is, instead of matching on one
(for the digit 1
), we need to match on eno
(one
reversed).
I’ll leave the implementation details up to you – I’d imagine this solution results in less code and is simpler to follow.