2024-12-07
In today’s Advent of Code puzzle, we have a reasonably easy solution that is not super efficient, but I did learn a few things along the way, which is also the point of doing these things. Aside from the fact that they are fun, I mean.
Here is the example data:
190: 10 19
3267: 81 40 27
83: 17 5
156: 15 6
7290: 6 8 6 15
161011: 16 10 13
192: 17 8 14
21037: 9 7 18 13
292: 11 6 16 20
Each line has the form: ANSWER: OP1 OP2 ... OPN
. We have
to find for each line if there is a way to put +
and
*
signs between the operands to get the answer, knowing
that the evaluation will always take place left-to-right. So:
190 = 10*19
is correct. The final answer to the puzzle is
the sum of all answers with at least one way of combining the operands
that works.
As usual, we begin by parsing the data, reusing the
load_as_str
function I previously define to just get the
content of the file as a string. Splitting by :
then by
, we get a list of tuples of the form
(ANSWER, [OP1, OP2..., OPN])
.
def load_operations():
= load_as_str('day7').strip()
data = data.split('\n')
lines = []
operations for l in lines:
= l.split(':')
ans, operands = operands.strip().split(' ')
operands int(ans), [int(o) for o in operands]))
operations.append((return operations
Now we have to test all the different combinations of +
and *
. If we have n
operands, we will have
n-1
signs, and therefore 2^(n-1)
possible
combinations. I initially tried to do something with
itertools.accumulate
, but I initially couldn’t find a
non-cursed way of making the operator in accumulate
change
during the accumulation. And it was not very efficient anyway. So I
settled on pre-generating all possible binary patterns for the lengths
of operands list that are present in the dataset. To do that, I iterated
integer values and convert them to binary text representation, and then
converted 0
s to operator.add
and
1
s to operator.mul
.
def generate_all_binary_patterns(n: int):
= []
patterns for i in range(0, 2**n):
= [operator.add]*n
cur_pattern = bin(i)[2:]
b for j, k in enumerate(b[::-1]):
if k == '1':
-1-j] = operator.mul
cur_pattern[n
patterns.append(cur_pattern)return patterns
= load_operations()
operations = set([len(ops)-1 for ans, ops in operations])
ns = {n: generate_all_binary_patterns(n) for n in ns} all_patterns
Then it’s a matter of applying all those patterns in order until we hit one where the total matches the answer.
def accfn(operands: list[int], operators: list, target: int):
= operands[0]
total for v, op in zip(operands[1:], operators):
= op(total, v)
total if total > target:
break
return total
def verify_all_operations(operations, all_patterns):
= 0
total_correct for ans, ops in operations:
= all_patterns[len(ops)-1]
patterns for pattern in patterns:
if accfn(ops, pattern, ans) == ans:
+= ans
total_correct break
print(total_correct)
For part 2, a new operator appears, which concatenates two number together. So we define the operation:
def concat(a, b):
return int(str(a)+str(b))
And now instead of generating binary patterns we have to generate ternary patterns. I found some code on StackOverflow to convert an integer to ternary string, because I didn’t want to spend too much time on that solution as I knew I’d be coming back to find something a bit less clunky later. Still, I wanted to check that the rest of the code was reasonably resistant to change.
def ternary (n):
"""https://stackoverflow.com/a/34559825"""
if n == 0:
return '0'
= []
nums while n:
= divmod(n, 3)
n, r str(r))
nums.append(return ''.join(reversed(nums))
def generate_all_ternary_patterns(n: int):
= []
patterns for i in range(0, 3**n):
= [operator.add]*n
cur_pattern = ternary(i)
t for j, k in enumerate(t[::-1]):
if k == '1':
-1-j] = operator.mul
cur_pattern[nif k == '2':
-1-j] = concat
cur_pattern[n
patterns.append(cur_pattern)return patterns
Replacing the binary function by the ternary function to generate all patterns worked easily (albeit not quickly), which was a relief. Altogether I think this was the challenge that I solved the quickest. But I did come back for some refactoring later…
I didn’t like having to make a whole new function to add the new
operator. I wanted to ideally just have to provide the list of
operations at the input of the system, and then have the rest generic.
Thanks to Juhis’
solution I realized that I could do that with itertools.product
.
I knew itertools
was the way, I just never used that one
before. Such a great library.
Anyway, with:
def generate_all_patterns(operators: list, n: int):
return list(itertools.product(operators, repeat=n))
We can generate all combinations of size n
of the
operators
, which is great. Our main function becomes:
def verify_all_operations(operations, operators):
= set([len(ops)-1 for ans, ops in operations])
ns = {n: generate_all_patterns(operators, n) for n in ns}
all_patterns
= 0
total_correct for ans, ops in operations:
= all_patterns[len(ops)-1]
operators for pattern in operators:
if accfn(ops, pattern, ans) == ans:
+= ans
total_correct break
print(total_correct)
= load_operations('')
operations verify_all_operations(operations, [operator.add, operator.mul, concat])
With accfn
unchanged. Still slow, still brute-forcy, but
a lot more elegant.
Presented with no comment. I’m not sorry.
class SwitchAccumulator:
def __init__(self, pattern: list):
self.pattern = pattern.copy()
def switchop(self, a, b):
return self.pattern.pop(0)(a, b)
...
def verify_all_operations(operations, operators):
...= list(itertools.accumulate(ops, SwitchAccumulator(pattern).switchop)).pop()
total if total == ans:
+= ans
total_correct break
print(total_correct)