2024-12-29
[Go to index] [Advent of Code website] [My code on Gitlab]
A very enjoyable but slightly painful one for day 24. This time we’re doing some circuitry, and I’ve never been good with circuits.
Our input describes a circuit with some inputs and a bunch of logical gates:
x00: 1
x01: 1
x02: 1
y00: 0
y01: 1
y02: 0
x00 AND y00 -> aed
x01 XOR y01 -> z01
x02 OR y02 -> mij
aed AND mij -> z00
aed OR y01 -> z02
All the xXX
and yYY
are inputs,
all the zZZ
are outputs, all the three-letter names are
wires inside the circuit.
In part 1, we just have to simulate what happens with the circuit and
determine the outputs based on the given inputs. The zZZ
outputs need to be interpreted as a binary representation of an integer,
wit z00
the least significant bit. So for the above
circuit, we would have:
x00: 1
x01: 1
x02: 1
y00: 0
y01: 1
y02: 0
x00 AND y00 = 0 -> aed
x01 XOR y01 = 0 -> z01
x02 OR y02 = 1 -> mij
aed AND mij = 0 -> z00
aed OR y01 = 1 -> z02
z02z01z00
is 100
, so the integer value is
4
. In our actual input file, we have 45 bits in the
x
and y
wires, and 46 z
outputs.
To parse the input, we first separate the two parts – inputs and
circuit – then store the inputs in a dict
and the circuit
as a list of operations, each encoded as a tuple
with
operator, op1, op2, out
. The first part is easily done with
split
, for the second part I also used a simple RegEx to
get the operator and operands.
= tuple[str, str, str, str]
Operation
def parse_input() -> tuple[dict[str, int], list[Operation]]:
with open("day24", 'r') as fp:
= fp.read().strip()
data
= data.split('\n\n')
inputs, network
# parse inputs
= [line.split(': ') for line in inputs.split('\n')]
inputs = {key: int(v) for key, v in inputs}
inputs
# parse network
= []
ops = r'(.*) (XOR|OR|AND) (.*)'
opregex for line in network.split('\n'):
= line.split(' -> ')
op, out = re.match(opregex, op).groups()
op1, operator, op2
ops.append((operator, op1, op2, out))
return inputs, ops
Then, to simulate the circuit, we need to perform all the operations.
To make things a bit easier and avoid big if/elif chains, I put the
operations as lambda
functions in a global
dict
. As we go through the operations, we store the output
values in a dict
.
= {
operations 'AND': lambda a, b: a & b,
'OR': lambda a, b: a | b,
'XOR': lambda a, b: a ^ b
}
def do_ops(inputs: dict[str, int], ops: list[tuple[Callable, str, str, str]]):
= inputs.copy()
values for operation, op1, op2, out in ops:
= operations[operation](values[op1], values[op2])
values[out] return values
If we just run that on our list op operations, it will not work, because they are not properly sorted in the input file: some operations have in their operands a value that only appears as output later in the list. So we also need to sort the operations:
def sort_ops(inputs: dict[str, int], ops: list[tuple[Callable, str, str, str]]):
= []
sorted_ops = list(inputs.keys())
has_value while len(ops) > 0:
= ops.pop(0)
op if op[1] in has_value and op[2] in has_value:
sorted_ops.append(op)3])
has_value.append(op[else:
ops.append(op)return sorted_ops
Finally for the answer we need to convert the binary values of the
z
wires (or any other collection of wires) into an integer.
I had to fairly often get the xXX
, yYY
or
zZZ
names so I also made a helper lambda
for
that.
= lambda p, x: p+str(x).zfill(2)
cname
def getint(values, s: str) -> int:
= 0
vout = 0
bit while (vname := cname(s, bit)) in values:
+= values[vname] << bit
vout += 1
bit
return vout
And we can now put everything together for our first answer:
= parse_input(False)
inputs, ops = sort_ops(inputs, ops)
ops = do_ops(inputs, ops)
values
print(f"Part 1 answer: {getint(values, 'z')}")
In part 2, we learn what the circuit is supposed to do: add two binary numbers. But it doesn’t work right now: some of the operation outputs have been swapped. We need to swap four pairs of operation outputs so that the circuit does what it’s supposed to do.
There are several possible approaches here, and I had a hard time deciding which one to use. The first one is to brute-force all possible swaps until we get a correct answer on a bunch of test input/output values. I wanted to actually look into the circuit. I played a bit with the list of operations to try to map what was going on, and determined the kind of “adder” that it was. So here’s how it’s supposed to work:
Starting with a single bit, the result of x00 + y00
will
be a two-bit number, such that z00 = x00 ^ y00
and the
“carry” is c00 = x00 & y00
. So for bit 1, we now have
to add x01 + y01 + c00
. Do do that, we need a few steps:
first we XOR ^01 = x01 ^ y01
, then we XOR again to get the
output z01 = ^01 ^ c00
. To get the new carry, we need to
perform (x01 & y01) | (^01 & c00)
, so we need first
&01 = x01 & y01
and
b01 = ^01 & c00
so that we can do
c01 = &01 | b01
. We can repeat those steps for every
next bit, and at the very end the last “carry” is our last output
zNN
.
Let’s quickly check that for four bit inputs, adding 11
(1011
) and 6 (0110
):
1011
0110
z00: (1^0) = 1, carry 0
z01: (1^1)^0 = 0, carry (1&1)|(0&0) = 1
z02: (0^1)^1 = 0, carry (0&1)|(1&1) = 1
z03: (1^0)^1 = 0, carry (1&0)|(1&1) = 1
z04: 1
Result:
10001
End result: 10001
, or 17.
Great, now how does that help us? Well first, we can make a correct circuit for the right number of bits:
def make_circuit(bits: int = 45):
= [("XOR", "x00", "y00", "z00"), ("AND", "x00", "y00", "c00")]
ops
for bit in range(1, bits):
= ("XOR", cname("x", bit), cname("y", bit), cname("^", bit))
xorop = ("AND", cname("x", bit), cname("y", bit), cname("&", bit))
andop = ("XOR", cname("^", bit), cname("c", bit-1), cname("z", bit))
zop = ("AND", cname("^", bit), cname("c", bit-1), cname("b", bit))
bop = ("OR", cname("&", bit), cname("b", bit), cname("c", bit))
cop += [xorop, andop, zop, bop, cop]
ops
= ops.pop()
lastcarry 0], lastcarry[1], lastcarry[2], cname("z", bit+1)))
ops.append((lastcarry[
return ops
This gives us a template which we can use to find mistakes in the actual list of operations. To do that, we need to match the names of the outputs. Our main method is:
def match_circuits(circuit: list[Operation], reference: list[Operation]) -> tuple[bool, str|None, str|None]:
= {}
nameref
for operand, op1, op2, out in reference:
= nameref.get(op1, op1)
op1 = nameref.get(op2, op2)
op2 = find_match((operand, op1, op2, out), circuit)
outref if outref is not None:
= outref
nameref[out] else:
= find_partial((operand, op1, op2, out), circuit)
s1, s2 if s1 is None:
print(operand, op1, op2, out)
raise ValueError()
return False, s1, s2
return True, None, None
We keep a running reference of name matched in a dict
.
For each operation in our reference “correct” circuit, if we already
found a match, we replace the name, then try to find a match in the
actual circuit. A “match” is an operation which has the same operator
and inputs:
def find_match(op, reference):
for ref in reference:
if op[0] == ref[0] and ((op[1]==ref[1] and op[2]==ref[2]) or (op[1]==ref[2] and op[2]==ref[1])):
return ref[3]
return None
If there is a match, we add it to the reference dict
. If
we didn’t find anything, then we’ve it a problem spot, and we need to
determine with which other operation we need to switch the outputs with.
Let’s take an example, and say that we were expecting an operation:
('XOR', '^12', 'c11', 'z12')
. We need to find an operation
with the same operator and output, and one of the operands. The other
operand needs to be switched with the expected one. So if for instance
we found an operation ('XOR', '^12', 'hzf', 'z12')
, that
means that we need to switch c11
with hzf
, a
wire that we haven’t renamed yet.
def find_partial(op, reference):
for ref in reference:
if op[0] == ref[0] and (op[1]==ref[1] or op[2]==ref[2]):
if op[1]==ref[1]:
return ref[2], op[2]
return ref[1], op[1]
if op[0] == ref[0] and (op[1]==ref[2] or op[2]==ref[1]):
if op[1]==ref[2]:
return ref[1], op[2]
return ref[2], op[1]
return None, None
Our match_circuit
method stops as soon as it finds a
switch – or if it can match the whole circuit without error. Now we need
to run this iteratively, applying the switches and going over the
operations again until we’ve matched the whole circuit:
def do_switches(ops, s):
= []
sops = ops.copy()
ops
while len(ops) > 0:
= ops.pop(0)
op for out1, out2 in s:
if op[3] == out1:
= (op[0], op[1], op[2], out2)
op break
elif op[3] == out2:
= (op[0], op[1], op[2], out1)
op break
sops.append(op)return sops
def find_switches(ops: list[Operation], reference: list[Operation]) -> list[tuple[str, str]]:
= []
to_switch
= match_circuits(ops, reference)
ok, s1, s2 while not ok:
to_switch.append((s1, s2))= do_switches(ops, to_switch)
sops = match_circuits(sops, reference)
ok, s1, s2
return to_switch
And we can now put everything together to get the list of wires to switch. The final answer needs to be that list, alphabetically sorted, and joined with commas:
= make_circuit(45)
goodops = find_switches(ops, goodops)
to_switch
= []
sout for s1, s2 in to_switch:
+= [s1, s2]
sout print("Part 2 answer:", ','.join(sorted(sout)))
And with that, day 24 is fully solved.