Advent of Code Day 3
TLDR: Regular expressions are awesome, edge cases in these puzzles are not. Skip to my solution
Part 1
Today we have the first Advent of Code regular expression challenge of 2024! As usual, the first one was a great warm up and I’m looking forward to exploring them a bit more later into the month.
Our input for the puzzle is a string representing corrupted computer memory for a program designed to multiply integers together. For example:
switch:%mul(4,5)then[a-4532}do_not()mul(8,4)
Our job in part 1 is to extract the mul(a,b)
instructions, evaluate them by
multiplying a
and b
, and finally report the sum of the results. The Ruby
implementation is trivial using today’s cool method, String#scan
.
memory.scan(/mul\((\d{1,3}),(\d{1,3})\)/)
When memory
contains the contents of our puzzle input, this will return a 2D
array with each element containing the multiplication operands. The holiday magic
comes from capture groups, denoted by unescaped parentheses. When a regular
expression containing groups is used with #scan
, the result is the contents of
those groups rather than the total matched string. Let’s build up the expression
in parts:
# Matches a digit (using \d) between 1 and 3 characters long (0-999). This regex
# is used twice, once for each operand.
/\d{1,3}/
# Matches the entire mul(a,b) expression, using the digit regex above.
/mul\(\d{1,3},\d{1,3}\)/
# Add in capture groups using parentheses to extract each operand
/mul\((\d{1,3}),(\d{1,3})\)/
Now all that’s left to do is convert the operands to integers, multiply them,
and then calculate the sum. Easy enough with Ruby Enumerable
built-ins.
memory.scan(/mul\((\d{1,3}),(\d{1,3})\)/).map { |operands| operands[0].to_i * operands[1].to_i }.sum
That’s all there is to it. I don’t typically add any complicated edge-case handling to these solutions because the puzzle makes several guarantees about the input format (I’m big fan of YAGNI).
Part 2
Part 2 introduces additional pre-processing on the corrupted memory before
executing the same evaluation from part 1. There are now additional instructions
we need to pay attention to in the input: do()
and don't()
. These enable and
disable evaluation of mul(a,b)
expressions, respectively. In my solution, I
opted to do some simple string splitting on the new instructions that allows us
to identify memory fragments that are operable (i.e. enabled with do()
).
The first step is to split the entirety of our puzzle input on any don't()
instructions. This gives us an array of memory fragments that start with
disabled instructions. We can further process those fragments to remove the
disabled part, recreating our program memory with only the instructions that are
enabled with do()
.
The instructions start out enabled, so we can automatically include the first
fragment in the operable memory. After that, we split each fragment on the first
do()
instruction. The original memory was split on don't()
, so the entire
fragment after the first do()
is operable.
fragments = memory.split("don't")
operable_memory = fragments[0]
fragments[1..-1].each do |fragment|
fragment_parts = fragment.split("do()", 2)
next if fragment_parts.size <= 1
operable_memory += fragment_parts[1]
end
When I first implemented this, the operable_memory += fragment_parts[1]
was
throwing an exception because fragment_parts[1]
was nil
. This happened when
the corrupted memory included consecutive don't()
instructions. Attempting to
split these fragments on do()
resulted in an array with only one element,
causing the string concatenation to fail. It was a quick fix to add the next if
guard clause once I figured out what was going on.
This edge case is a quirk of my implementation - the only guidance given by the
puzzle text on consecutive instructions is that only the most recent do()
or
don't()
instruction is relevant. There’s probably a way to do this using a more
complicated regular expression such that all of the processing can be completed
with a single #scan
call, avoiding string splitting and the bug that it caused.
However, I’ve found that trying to do more than one or two things with a single
regex introduces a level of complexity that typically isn’t worth it. Even though
string splitting caused a bug, identifying and fixing it still took less time than
it would’ve to come up with a “smarter” solution.
Solution
Full solution for today’s problem on GitHub.
memory = File.read(ARGV[0])
def part_1(memory)
# String#scan does the heavy lifting by using regular expression capturing.
memory.scan(/mul\((\d{1,3}),(\d{1,3})\)/).map { |operands| operands[0].to_i * operands[1].to_i }.sum
end
p "Part 1: #{part_1 memory}"
def part_2(memory)
fragments = memory.split("don't()")
# Operation enabled at start, so we automatically include the first fragment
operable_memory = fragments[0]
fragments[1..-1].each do |fragment|
# The first element of this array is always disabled because it immediately
# follows a don't() instruction. If there's only one element, then the
# entire fragment is disabled.
fragment_parts = fragment.split("do()", 2)
next if fragment_parts.size <= 1
# We split on don't() originally, so everything after the first do() is operable
operable_memory += fragment_parts[1]
end
# Now that we've reconstructed memory to only include operable statements,
# it can be processed just like part 1.
part_1 operable_memory
end
p "Part 2: #{part_2 memory}"