from misc.helper import verify_answer
= "../inputs/example_day_17_1.txt"
example_input_file_1 = "../inputs/example_day_17_2.txt"
example_input_file_2 = "../inputs/day_17.txt" input_file
Day 17
Today, together with historian elves we’re falling into despair, or rather having to debug some very weird computer program. Must admit, reading the instructions the first time made me question if I want to do it. Not because the task is hard, but because the instructions are so weird. But let’s not get ahead of ourselves.
We are falling through a void and the only way to save us is to debug a 3-bit (weird I might add!) computer program. Read the whole story on the official Day 17 of Advent of Code 2024 website.
Here’s a ChatGPT negotiated illustration of the setup:
Part 1: Debug the debugging
We are given an input that specifies the state of input registers, and the program itself. Program is composed of 3-bit integers (0-7) separated by commas. Each pair of integers represent an instruction
and an operand
. An operand can be a literal
operand, or can be a combo
operand. Literal operand is just the number itself, while combo
operand follows its separate rules. Whether an operand
should be treated as a literal
or a combo
operand is determined by the instruction.
Combo operands:
* 0
- 3
- same as literal
operand.
* 4
- value of register A
.
* 5
- value of register B
.
* 6
- value of register C
.
* 7
- is reserved and does not appear as combo operand.
Instructions:
* 0
- division of value in register A
by the 2 to the power of its combo
operand; The result truncated to integer is stored in register A
.
* 1
- bitwise XOR of register B
and literal
operand; The result is stored in register B
.
* 2
- calculates its combo
operand module 8 and saves it in register B
.
* 3
- does nothing if register A
is 0, but if register A
is not 0, it jumps to the instruction specified by its literal
operand.
* 4
- bitwise XOR of register B
and register C
and stores the result in register B
.
* 5
- calculates combo
operand modulo 8 and outputs the result.
* 6
- division of value in register A
by the 2 to the power of its combo
operand; The result truncated to integer is stored in register B
.
* 7
- division of value in register A
by the 2 to the power of its combo
operand; The result truncated to integer is stored in register C
.
# bitwise xor in python
def xor(a, b):
return a ^ b
import re
from pathlib import Path
def parse_input(input):
if Path(input).exists():
input = Path(input).read_text()
= {}
registers for line in input.strip().split("\n"):
if line.startswith("Register"):
# extract regster letter from "Register X:"
= re.findall(r"Register (\w):", line)[0]
register = int(line.split(": ")[1])
registers[register] elif line.startswith("Program"):
= [int(el) for el in line.split(": ")[1].split(",")]
program
return registers, program
= """
example Register A: 17
Register B: 21
Register C: 3
Program: 0,1,2,3,5,3
"""
parse_input(example)
({'A': 17, 'B': 21, 'C': 3}, [0, 1, 2, 3, 5, 3])
def execute_program(program, registers):
= program.copy()
program = registers.copy()
registers
def combo_operand(operand: int):
match operand:
case x if x < 4: # Guard clause for values less than 4
return operand
case 4:
return registers["A"]
case 5:
return registers["B"]
case 6:
return registers["C"]
case 7:
raise ValueError("Invalid operand")
case _:
raise ValueError("Operand not recognized")
= 0
i = []
out while i < len(program):
match program[i]:
case 0:
"A"] = int(
registers["A"] / 2 ** combo_operand(program[i + 1])
registers[
)+= 2
i case 1:
"B"] = xor(registers["B"], program[i + 1])
registers[+= 2
i case 2:
"B"] = combo_operand(program[i + 1]) % 8
registers[+= 2
i case 3:
if registers["A"] == 0:
+= 2
i else:
= program[i + 1]
i case 4:
"B"] = xor(registers["B"], registers["C"])
registers[+= 2
i case 5: # out
+ 1]) % 8)
out.append(combo_operand(program[i += 2
i case 6:
"B"] = int(
registers["A"] / 2 ** combo_operand(program[i + 1])
registers[
)+= 2
i case 7:
"C"] = int(
registers["A"] / 2 ** combo_operand(program[i + 1])
registers[
)+= 2
i case _:
raise ValueError("Invalid instruction")
return ",".join(map(str, out))
def part_one(input):
= parse_input(input)
registers, program return execute_program(program, registers)
"3") verify_answer(part_one, example,
✔️ That's right! The answer is 3.
"4,6,3,5,6,3,5,2,1,0") verify_answer(part_one, example_input_file_1,
✔️ That's right! The answer is 4,6,3,5,6,3,5,2,1,0.
%time part_one(input_file)
CPU times: user 515 μs, sys: 601 μs, total: 1.12 ms
Wall time: 780 μs
'1,5,3,0,2,5,2,5,3'
That’s the right answer! You are one gold star ⭐ closer to finding the Chief Historian.
Part 2: Output the program sequence
In Part 2, we ae asked to fix the program by changing the register A
value such that the program sequence is outputted.
It took me quite a few tries to get this one correct after my brute force approach was taking too long to be reasonable. I decided to turn to an LLM for guidance. I copied u/quetzelcoatlus1’s solution from this Reddit thread and asked ChatGPT to explain it step by step until I understood how it worked. It took longer than I’d like to admit, but I eventually pieced it all together.
The key insight came from understanding how numbers work in binary. Shifting a number to the left by 1 is equivalent to multiplying it by 2, and shifting it to the right by 1 is equivalent to dividing it by 2. It’s like moving a decimal number left or right by one place (e.g., multiplying 1 -> 10
or dividing by 10 100 -> 10
) but in base-2. This property is what allowed me to construct the number A
, appending a 3-bit value at each step.
Here’s an example that helped me understand binary shifting:
def print_binary(n):
return bin(n)[2:]
def binary_to_int(n):
return int(n, 2)
print(f"1 written in binary is `{print_binary(1)}` and shifted 1 bit to the left is '{print_binary(1 << 1)}' ({binary_to_int(print_binary(1 << 1))}). Shifting 1 bit to the left is the same as multiplying by 2^1 = 2")
print(f"2 written in binary is `{print_binary(2)}` and shifted 2 bits to the left is '{print_binary(2 << 1)}' ({binary_to_int(print_binary(2 << 1))}). Shifting 2 bits to the left is the same as multiplying by 2^2 = 4")
print(f"3 written in binary is `{print_binary(3)}` and shifted 3 bits to the left is '{print_binary(3 << 3)}' ({binary_to_int(print_binary(3 << 3))}). Shifting 3 bits to the left is the same as multiplying by 2^3 = 8")
1 written in binary is `1` and shifted 1 bit to the left is '10' (2). Shifting 1 bit to the left is the same as multiplying by 2^1 = 2
2 written in binary is `10` and shifted 2 bits to the left is '100' (4). Shifting 2 bits to the left is the same as multiplying by 2^2 = 4
3 written in binary is `11` and shifted 3 bits to the left is '11000' (24). Shifting 3 bits to the left is the same as multiplying by 2^3 = 8
In the task, the program outputs a 3-bit number (via the out
operation that computes mod 8
of the combo operand). This constrains the search space for A
to building it gradually, 3 bits at a time. Each recursive step shifts the current value of A
left by 3 bits (making room for the next 3-bit value) and adds a candidate digit (0
to 7
).
While we are shifting A
leftwards (appending new 3-bit values to the least significant bits), the output is generated from left to right, matching the sequence of 3-bit values. This ensures the program’s constraints are respected, and the final value of A
is built incrementally.
To build A
recursively, we try all possible 3-bit values (0
to 7
) at each step and check if the output sequence generated so far matches the expected sequence. If it does, we continue building A
by shifting it left by 3 bits, appending the next candidate value, and moving the index to the next part of the output. If the output at any step does not match the expected output, we prune the current branch of the recursion and backtrack until we find a branch that produces the correct output.
def find_matching_a_recursive(program):
def run_program(a_value, index):
= {"A": a_value, "B": 0, "C": 0}
registers = 0
program_counter = []
output
def combo_operand(operand):
match operand:
case x if x < 4:
return operand
case 4:
return registers["A"]
case 5:
return registers["B"]
case 6:
return registers["C"]
case 7:
raise ValueError("Reserved operand")
case _:
raise ValueError("Invalid combo operand")
while program_counter < len(program) - 1:
= program[program_counter]
opcode = program[program_counter + 1]
operand match opcode:
case 0: # adv
"A"] = registers["A"] // (
registers[2 ** combo_operand(operand)
)case 1: # bxl
"B"] ^= operand
registers[case 2: # bst
"B"] = combo_operand(operand) % 8
registers[case 3: # jnz
if registers["A"] != 0:
= operand
program_counter continue
case 4: # bxc
"B"] ^= registers["C"]
registers[case 5: # out
% 8)
output.append(combo_operand(operand) if index < len(program) and output[-1] != program[index]:
return False
+= 1
index case 6: # bdv
"B"] = registers["A"] // (
registers[2 ** combo_operand(operand)
)case 7: # cdv
"C"] = registers["A"] // (
registers[2 ** combo_operand(operand)
)case _:
raise ValueError(f"Unknown opcode: {opcode}")
+= 2
program_counter
return index == len(program)
def solve(a_value, index):
for i in range(8):
= (a_value << 3) + i
candidate_a if run_program(candidate_a, index):
if index == 0:
return candidate_a
= solve(candidate_a, index - 1)
result if result is not None:
return result
return None
return solve(0, len(program) - 1)
def part_two(input):
= parse_input(input)
_, program return find_matching_a_recursive(program)
117440) verify_answer(part_two, example_input_file_2,
✔️ That's right! The answer is 117440.
%time part_two(input_file)
CPU times: user 2.45 ms, sys: 799 μs, total: 3.25 ms
Wall time: 3.24 ms
108107566389757
That’s the right answer! You are one gold star ⭐ closer to finding the Chief Historian.