Day 3: Mull It Over – Advent of Code 2024 in Racket
Published on 06 Dec 2024 by Ashpool.
Day three presents us with a couple of simple pattern-matching problems. Regular expressions should make these easy. Let’s dive into it.
As always, the solutions are available on GitHub and all Racket code is released into the Public Domain.
Table of Contents
Part One
Problem Statement (in a Nutshell)
We need to find some patterns in a long string and evaluate them. The pattern we are looking for looks like mul(X,Y)
, where X
and Y
are 1-3 digit integers. Evaluating such a pattern simply means multiplying those numbers together. The answer to the problem is the sum of all products across all patterns found in the string. The string also contains some noise, which should simply be ignored.
Solution
We can use regular expressions to find patterns. Evaluating and adding the results together is trivial.
Code
|
|
The first thing that should catch your eye is the new word in the #lang
reader form.
|
|
This is to allow us to use an extension of Racket that supports @
-expressions. They were originaly introduced to the Racket ecosystem as a part of Scribble, the Racket documentation DSL, but they found use outside of it. For the purposes of our program they allow us to avoid quoting strings, serving as a sort of a replacement for “raw strings” implemented in other languages.
With @
-expressions, writing regular expression constants is more pleasant as we don’t need to escape any backslashes that are very prevalent in regexps. The following two syntaxes mean the same thing:
@pregexp{mul\((\d{1,3}),(\d{1,3})\)}
#px"mul\\((\\d{1,3}),(\\d{1,3})\\)"
The use of @
-expressions will be typical for problems that require the use of regular expressions.
We broke up the program into several functions to make it easier to understand. Let’s begin with read-mul
.
|
|
read-mul
simply tries to read a pattern that satisfies the hard-coded regular expression from the input port, which, remember, is pointed to a file named input
using the function with-input-from-file
. The regular expression tries to find the first occurrence of a string that that looks like mul(X,Y)
. X
and Y
are capture groups, each matching a string of 1-3 digits.
If there is no match, regexp-try-match
consumes nothing from the input port. If there is a match in the input port, it is returned as a list of bytes
values like '(#"mul(666,399)" #"666" #"399")
where the first element is the entire match, and any subsequent elements are the matches of the capture groups.
|
|
Now that we’ve matched something, let’s evaluate it. We are only interested in the values of the capture groups, thus the cdr
. We map bytes->number
onto the list of matches and then simply multiply everything together.
|
|
Putting everything together, we first create a stream produced by repeatedly calling the function read-mul
until it returns #f
. We map eval-mul
onto the stream, and fold it with +
to sum up all the products.
Part Two
Problem Statement (in a Nutshell)
For the second part we similarly need to find the mul(X,Y)
instructions and evaluate them, but this time we also need to pay attention to two other kinds of instructions that enable conditional evaluation: do()
and don't()
. After the program encounters a don't()
instruction it should ignore subsequent mul
’s until a do()
is encountered.
Evalutation is enabled by default, and the objective is similar: find the sum of results of evaluating all mul
’s that are not disabled by a don't
.
Solution
Update the regular expression to match any of mul(X,Y)
, do()
or don't()
. Loop over the matches, keeping track of whether or not evalutaion is enabled and adding each result of a mul(X,Y)
evaluation to an accumulator when it is.
Code
|
|
read-op
is similar to read-mul
from the first part except it matches any instruction that is significant: do()
, don't()
, or mul(X,Y)
.
For the main
function, instead of foldl
we opt for a more imperative style using a for
loop with side-effects that modify the state (enable-mul?
and acc
). Folding would also be idiomatic, but less to my taste: the accumulator variable would need to be a struct.
As we loop over the results of repeatedly calling read-op
(until it returns #f
), we try match each result against one of the three templates.
|
|
The first two templates match either do()
or don't()
and enable or disable mul
evaluation, respectively. Note the use of byte-strings instead of regular strings: this is the consequence of using regexp-try-match
directly on an input port.
|
|
If the matched instruction is neither do()
nor don't()
, it must be mul(X,Y)
. Because the value of the entire match will vary depending on the arguments to mul
, we don’t validate it (_
accepts anything), although it would be required in a more complex program.
The cool thing here is the (app ...)
syntax in the template. Not only does it bind the match to a local name (a
or b
), but it also applies an arbitrary function (bytes->number
in our case) to it before binding. After binding, we can simply multiply a * b
(since they are both numbers at that point) and add the result to acc
.
After the for
loop is done, acc
contains the answer.
Conclusion
With the right tools at our disposal (regular expressions and pattern matching), day 3 problems could be the easiest yet this year. Nevertheless, I hope you’ve learned something and I will see you in a future post.
Last updated: 08 Dec 2024.