Day 24: Arithmetic Logic Unit
This problem asks us to do two things:
-
Implement a simple processor, with four registers
W
,X
,Y
, andZ
all initialized to0
, that accepts a program to run. -
Find the inputs to this program that will lead to the processor’s
Z
register containing0
when its input has been entirely consumed, which we’ll refer to as “acceptable inputs”.
The specific program that this problem gives us reads in 14 digits of input, so it looks something like this:
read 1st digit of input into W register
<several instructions manipulating register contents>
read 2nd digit of input into W register
<several instructions manipulating register contents>
read 3rd digit of input into W register
<several instructions manipulating register contents>
…
read 14th digit of input into W register
<several instructions manipulating register contents>
check if register Z is 0
We will refer to the list of instructions from one read
instruction up to, but excluding, the next read
instruction — such as the instructions contained in lines 1 and 2 of the above text — as an “instruction block”.
Note that while the inputs to the program are digits in the range [1, 9], the registers can contain arbitrary integers.
For instance the program could read |
Setup
use crate::Answer;
use std::{
collections::{BTreeMap as Map, BTreeSet as Set},
ops::{Index, IndexMut},
};
type Num = i32;
type Output = String;
#[derive(Debug, Clone, Copy)]
enum Operand {
Number(Num),
Reg(Register),
}
impl Operand {
fn from_str(s: &str) -> Option<Self> {
use Operand::*;
Some(match s.parse().ok() {
Some(val) => Number(val),
None => Reg(Register::from_str(s)?),
})
}
}
#[derive(Debug, Clone, Copy)]
enum MathOp {
Add,
Mul,
Div,
Mod,
Eql,
}
impl MathOp {
fn from_str(s: &str) -> Option<Self> {
use MathOp::*;
Some(match s {
"add" => Add,
"mul" => Mul,
"div" => Div,
"mod" => Mod,
"eql" => Eql,
_ => return None,
})
}
}
#[derive(Debug, Clone)]
struct MathInstr {
operation: MathOp,
register: Register,
operand: Operand,
}
#[derive(Debug)]
struct InstrBlock {
in_reg: Register,
instrs: Vec<MathInstr>,
}
#[derive(Debug, Clone, Copy)]
enum Register {
W = 0,
X,
Y,
Z,
}
impl Register {
fn from_str(s: &str) -> Option<Self> {
use Register::*;
Some(match s {
"w" => W,
"x" => X,
"y" => Y,
"z" => Z,
_ => return None,
})
}
}
#[derive(Debug)]
struct Alu {
registers: [Num; 4],
}
impl Index<Register> for Alu {
type Output = Num;
fn index(&self, register: Register) -> &Self::Output {
&self.registers[register as usize]
}
}
impl IndexMut<Register> for Alu {
fn index_mut(&mut self, register: Register) -> &mut Self::Output {
&mut self.registers[register as usize]
}
}
impl Alu {
fn new() -> Self {
Self { registers: [0; 4] }
}
fn run_block(&mut self, block: &InstrBlock, input: Num) {
use MathOp::*;
use Operand::*;
self[block.in_reg] = input;
for &MathInstr {
operation,
register,
operand,
} in &block.instrs
{
let value = match operand {
Number(n) => n,
Reg(register) => self[register],
};
let r = &mut self[register];
match operation {
Add => *r += value,
Mul => *r *= value,
Div => *r /= value,
Mod => *r %= value,
Eql => *r = if r == &value { 1 } else { 0 },
}
}
}
fn from_running_block_on(
block: &InstrBlock,
input: Num,
setup: impl FnOnce(&mut Self),
) -> Self {
let mut alu = Alu::new();
setup(&mut alu);
alu.run_block(block, input);
alu
}
}
fn read_input(s: &str) -> Option<Vec<InstrBlock>> {
let mut blocks = vec![];
let mut curr_in_reg = None;
let mut curr_instrs = vec![];
// Dummy input line at the end that tells the last block it's done
for line in s.lines().chain(std::iter::once("inp x")) {
let mut splat = line.split_ascii_whitespace();
let instr_str = splat.next()?;
if instr_str == "inp" {
if let Some(r) = curr_in_reg {
blocks.push(InstrBlock {
in_reg: r,
instrs: curr_instrs.clone(),
});
}
curr_in_reg = Some(Register::from_str(splat.next()?)?);
curr_instrs.clear();
} else {
let operation = MathOp::from_str(instr_str)?;
let register = Register::from_str(splat.next()?)?;
let operand = Operand::from_str(splat.next()?)?;
curr_instrs.push(MathInstr {
operation,
register,
operand,
});
}
}
Some(blocks)
}
fn ans_for_input(input: &str) -> Answer<Output, Output> {
let blocks = read_input(input).unwrap();
let valid_zs = get_valid_zs(&blocks);
(24, (pt1(&blocks, &valid_zs), pt2(&blocks, &valid_zs))).into()
}
pub fn ans() -> Answer<Output, Output> {
ans_for_input(include_str!("input.txt"))
}
A naive solution that ran the program on every every possibility from 1014 to 1015−1 would need to check 9×1014 possibilities, which would be computationally infeasible. (Trust me, I tried. Did not get very far before searching for a better way.)
Instead, we use the fact that the set of register values achievable by instruction block k
is actually quite small (compared to 9×1014).
The algorithm is as follows:
-
Before reading any input, the
Z
register has an initial value ofz0 = 0
. -
Phase 1:
-
We find the set of values
z1
of theZ
register after instruction block 1 reads in a digit. -
For each
z1
, we find the set of valuesz2
of theZ
register after instruction block 2 reads in a digit, and record for eachz2
which values ofz1
led to it. -
For each
z2
, we find the set of valuesz3
of theZ
register after instruction block 3 reads in a digit, and record for eachz3
which values ofz2
led to it. -
This continues until we reach the end, at which point we will have a massive number of
z14
s linked to their precedingz13
s, each of which is linked to its precedingz12
s, each of which is linked to its precedingz11
s, etc., going all the way back to thez1
s, which point toz0 = 0
.
-
-
Phase 2:
-
But we know we’re only concerned with inputs that lead to a final value of
z14 = 0
. -
So keep only those
z13
s that led toz14 = 0
. -
Then keep only those
z12
s that led to thosez13
s. -
Then keep only those
z11
s that to thosez12
s. -
Repeat, going all the way back to find only those
z1
s that led to thosez2
s. -
This produces a diamond-shaped graph, which starts out small at
z0 = 0
, fans out in the middle, and then shrinks again as it approachesz14 = 0
.
-
-
Phase 3:
-
Now that we have this graph, we’re almost done. A 14-digit input that causes the
Z
register’s final value to be0
can be found by finding sequences of digits that take us all the way through the graph from left to right.-
So, if we’ve read in, say, 12345, and digit 6 does not lead to a permissible
z6
value, then we know that 123456 is not the first six digits of any acceptable input.
-
-
The problem asks us specifically for the largest and smallest acceptable inputs. To find these, we simply do a DFS on this graph, preferentially walking along edges formed by the largest digits, but backtracking to a smaller digit where necessary.
To reduce code duplication, this DFS algorithm takes function parameters that give it the next digit to look at or tell it when to backtrack (since, for instance, it will need to backtrack when hitting d=1
when searching for the largest acceptable input but when d=9
for the smallest acceptable input).
fn get_valid_zs(blocks: impl AsRef<[InstrBlock]>) -> Vec<Set<Num>> {
let blocks = blocks.as_ref();
let n_digits = blocks.len();
let mut all_zs_ltr = vec![Map::new(); n_digits + 1];
all_zs_ltr.first_mut().unwrap().insert(0, Set::new());
for (digit_idx, block) in blocks.iter().enumerate() {
let (all_prev_zs, all_next_zs) = all_zs_ltr.split_at_mut(digit_idx + 1);
let prev_zs = &all_prev_zs[all_prev_zs.len() - 1];
let curr_zs = &mut all_next_zs[0];
for &prev_z in prev_zs.keys() {
for digit in 1..=9 {
let z = Alu::from_running_block_on(block, digit, |alu| alu[Register::Z] = prev_z)
[Register::Z];
curr_zs.entry(z).or_insert_with(Set::new).insert(prev_z);
}
}
}
let mut all_valid_zs_rtl = vec![Set::new(); n_digits];
let mut curr_zs = std::iter::once(0).collect::<Set<_>>();
for (digit_idx, valid_zs) in all_valid_zs_rtl.iter_mut().enumerate().rev() {
valid_zs.extend(curr_zs.iter().copied());
let mut new_curr_zs = Set::new();
let prev_zs = &all_zs_ltr[digit_idx + 1];
for z in &curr_zs {
new_curr_zs.extend(prev_zs.get(z).unwrap().iter().copied());
}
curr_zs = new_curr_zs;
}
all_valid_zs_rtl
}
fn find_digits<DigitRange: Iterator<Item = Num>>(
blocks: impl AsRef<[InstrBlock]>,
valid_zs: impl AsRef<[Set<Num>]>,
first_digit: Num,
attempted_digit_range_ctor: impl Fn(Num) -> DigitRange,
get_next_digit: impl Fn(Num) -> Num,
can_continue: impl Fn(Num) -> bool,
) -> Output {
struct CandidateDigit {
z_init: Num,
digit: Num,
next_digit_attempted: Num,
}
let blocks = blocks.as_ref();
let valid_zs = valid_zs.as_ref();
let n_digits = blocks.len();
let mut candidates = vec![CandidateDigit {
z_init: 0,
digit: 0,
next_digit_attempted: first_digit,
}];
'find_digits: while candidates.len() <= n_digits {
let digit_idx = candidates.len() - 1;
let block = &blocks[digit_idx];
let CandidateDigit {
z_init,
digit,
next_digit_attempted,
} = candidates[digit_idx];
for attempted_digit in attempted_digit_range_ctor(next_digit_attempted) {
let z =
Alu::from_running_block_on(block, attempted_digit, |alu| alu[Register::Z] = z_init)
[Register::Z];
if valid_zs[digit_idx].contains(&z) {
candidates[digit_idx].next_digit_attempted = get_next_digit(attempted_digit);
candidates.push(CandidateDigit {
z_init: z,
digit: attempted_digit,
next_digit_attempted: first_digit,
});
continue 'find_digits;
}
}
// Could not find a digit that worked; need to backtrack
if can_continue(digit) {
candidates[digit_idx] = CandidateDigit {
z_init,
digit: get_next_digit(digit),
next_digit_attempted: first_digit,
};
} else {
candidates.pop();
assert_ne!(candidates.len(), 0);
}
}
candidates
.iter()
.skip(1)
.map(|c| c.digit.to_string())
.collect::<Vec<_>>()
.join("")
}
Parts 1 and 2
fn pt1(blocks: impl AsRef<[InstrBlock]>, valid_zs: impl AsRef<[Set<Num>]>) -> Output {
find_digits(
blocks,
valid_zs,
9,
|digit| (1..=digit).rev(),
|digit| digit - 1,
|digit| digit > 1,
)
}
fn pt2(blocks: impl AsRef<[InstrBlock]>, valid_zs: impl AsRef<[Set<Num>]>) -> Output {
find_digits(
blocks,
valid_zs,
1,
|digit| digit..=9,
|digit| digit + 1,
|digit| digit < 9,
)
}