Wednesday, February 12, 2025

Advent of Code 2024: Day 3

For Day 3, we are given a “corrupted” block of memory as a string. We are to find the “uncorrupted instructions” in the block and emulate them.

For this problem, we don’t attempt to force things into the series paradigm. The cl-ppcre library provides a bunch of functions for working with regular expressions, and the do-register-groups macro is ideal for this problem. It iterates over all the matches of a regular expression, binding submatches to some variables, with some optional processing of the submatch. (If the cl-ppcre library offered a function that returned a series of matches, we could use that, but it offers a do macro.)

First, we read the input:

(defun read-input (input-pathname)
  (read-file-into-string input-pathname))

Next, we define a regular expression to match the instructions:

(defparameter *mul-instruction* "(mul\\((\\d{1,3}),(\\d{1,3})\\))")

Now we just iterate over all the matches:

(defun part-1 ()
  (let ((answer 0))
    (cl-ppcre:do-register-groups (whole (#’parse-integer left) (#’parse-integer right))
        (*mul-instruction* (read-input (input-pathname)))
      (declare (ignore whole))
      (incf answer (* left right)))
    answer))

do-register-groups is an example of a macro where the parenthesized subexpressions do not indicate function calls. The first parenthesized subgroup is a variable list, and within the variable list, a parenthesized subgroup indicates a transformer to be applied before binding the variable. So in this case, we are binding the variables whole, left, and right, and we run the matching subgroup through parse-integer before binding the left and right variables.

The second parenthesized subgroup is a list of the regular expression to match and the string to match within. After these irregular parenthesized subgroups, the remaining is a body of code that is executed for each match.

In the body of the code, we ignore the whole variable (which is the whole match) and increment the answer by the product of the left and right variables. As is usual for a do macro, we transport the data out of the loop by side effecting a lexically scoped variable.

For part 2, we have additional instructions to emulate. Our loop will now have some state to it, and we will side effect the state as we iterate over the matches. We can just extend the regular expression and add a cond to the body of the do-register-groups to handle the new instructions. As we iterate over the matches, we side effect the emulation state:

(defparameter *do-mul-instruction* "(do\\(\\))|(don’t\\(\\))|(mul\\((\\d{1,3}),(\\d{1,3})\\))")

(defun part-2 ()
  (let ((answer 0)
        (on t))
    (cl-ppcre:do-register-groups (turn-on turn-off whole (#’parse-integer left) (#’parse-integer right))
        (*do-mul-instruction* (read-input (input-pathname)))
      (declare (ignore whole))
      (cond (turn-on (setq on t))
            (turn-off (setq on nil))
            (on (incf answer (* left right)))
            (t nil)))
    answer))

Strictly speaking, we don’t need to use side effects. We could rework this to be purely functional, but this seems unnatural. Yes, there is a bit of cognitive overhead in remembering that there is a state variable that determines whether or not we are executing an instruction, but the code is quite understandable.

“Do” macros usually need some side effects, but we can localize the side effects by using a let to lexically bind the state variables and then side effecting them from within the loop. This is an effective compromise between the functional and imperative programming paradigms.

No comments: