Source: Day 20: Pulse Propagation
Full solution for today (spoilers!)
Part 1
Simulate a virtual circuit with
high
andlow
pulses and four kinds of chips:
- Broadcast - Re-transmit all pulses
- Flip-flops - On a
low
pulse, toggle internal state; if it was on, sendhigh
; otherwise sendlow
- Conjunction - Remember input from each attached module; if all inputs were
high
, send alow
, otherwise sendhigh
- Output - Do nothing; just receive pulses
Count the product of
low
andhigh
pulses sent after 1000low
inputs tobroadcaster
.
Types and Parsing
Our types are a bit more interesting than often this time:
use fxhash::FxHashMap;
#[derive(Debug, Clone)]
pub enum ModuleType<'a> {
Broadcast,
FlipFlop(bool),
Conjunction(FxHashMap<&'a str, Pulse>),
Output,
}
#[derive(Debug, Clone)]
pub struct Module<'a> {
pub label: &'a str,
pub module_type: ModuleType<'a>,
pub outputs: Vec<&'a str>,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Pulse {
High,
Low,
}
Specifically, the ModuleType::FlipFlop
stores if was previously on and the Conjunction
stores a map of input labels to what the last Pulse
it received from each.
Now for parsing:
fn module_type(input: &str) -> IResult<&str, ModuleType> {
alt((
map(complete::char('%'), |_| ModuleType::FlipFlop(false)),
map(complete::char('&'), |_| {
ModuleType::Conjunction(FxHashMap::default())
}),
))(input)
}
fn broadcast_module(input: &str) -> IResult<&str, (ModuleType, &str)> {
let (input, name) = tag("broadcaster")(input)?;
Ok((input, (ModuleType::Broadcast, name)))
}
fn other_module(input: &str) -> IResult<&str, (ModuleType, &str)> {
pair(module_type, alpha1)(input)
}
fn module(input: &str) -> IResult<&str, Module> {
let (input, (module_type, label)) = alt((broadcast_module, other_module))(input)?;
let (input, _) = delimited(space0, tag("->"), space0)(input)?;
let (input, outputs) = separated_list1(terminated(complete::char(','), space0), alpha1)(input)?;
Ok((
input,
Module {
label,
module_type,
outputs,
},
))
}
pub fn modules(input: &str) -> IResult<&str, FxHashMap<&str, Module>> {
let (input, modules) = separated_list1(line_ending, module)(input)?;
let mut modules = modules
.iter()
.map(|module| (module.label, module.clone()))
.collect::<FxHashMap<_, _>>();
let inputs = modules
.iter()
.flat_map(|(label, module)| module.outputs.iter().map(|output| (*output, *label)))
.collect::<Vec<_>>();
for (output, label) in inputs {
if let Some(module) = modules.get_mut(output) {
// Conjunctions need a reference back to their inputs
match module.module_type {
ModuleType::Conjunction(ref mut inputs) => {
inputs.insert(label, Pulse::Low);
}
_ => {}
}
} else {
// If the output doesn't exist, create it as an output module
modules.insert(
output,
Module {
label: output,
module_type: ModuleType::Output,
outputs: vec![],
},
);
}
}
Ok((input, modules))
}
There are a couple interesting bits here:
- Because
broadcaster
modules don’t have a prefix, we parse it as analt
inmodule
modules
goes through a few steps:- Parse the modules
- Collect the
inputs
by inverting themodule::output
lists - Initialize the
FxHashMap
of previous values forModuleType::Conjunctions
with theinputs
Solution
Okay, let’s do it!
fn main() -> Result<()> {
env_logger::init();
let stdin = io::stdin();
let input = io::read_to_string(stdin.lock())?;
let (s, mut modules) = parse::modules(&input).unwrap();
assert_eq!(s.trim(), "");
let mut state = modules
.keys()
.map(|label| (*label, Pulse::Low))
.collect::<FxHashMap<_, _>>();
let mut low_sent = 0;
let mut high_sent = 0;
for push_i in 1..=1000 {
log::info!("=== Push {push_i} ===");
let mut queue = VecDeque::from(vec![("button", "broadcaster", Pulse::Low)]);
while let Some((src, dst, pulse)) = queue.pop_front() {
log::info!("{src} -{pulse:?}-> {dst}");
match pulse {
Pulse::Low => low_sent += 1,
Pulse::High => high_sent += 1,
}
let module = modules.get_mut(dst).unwrap();
state.insert(dst, pulse);
match module.module_type {
// Broadcast modules send the received pulse to all outputs
ModuleType::Broadcast => {
for output in &module.outputs {
queue.push_back((dst, *output, pulse));
}
}
// Flip-flops flip on low pulses
// If it was off, it turns on and sends high
// If it was on, it turns off and sends low
ModuleType::FlipFlop(ref mut is_on) => {
if pulse == Pulse::Low {
let output_pulse = if *is_on { Pulse::Low } else { Pulse::High };
for output in &module.outputs {
queue.push_back((dst, *output, output_pulse));
}
*is_on = !*is_on;
}
}
// Conjunctions remember previous inputs
// If all inputs are high, sends a low
// Otherwise, send a high
ModuleType::Conjunction(ref mut inputs) => {
inputs.insert(src, pulse);
let output_pulse = if inputs.values().all(|pulse| *pulse == Pulse::High) {
Pulse::Low
} else {
Pulse::High
};
for output in &module.outputs {
queue.push_back((dst, *output, output_pulse));
}
}
// Output modules do nothing
ModuleType::Output => {}
}
}
}
log::info!(" low_sent: {low_sent}");
log::info!("high_sent: {high_sent}");
let result = low_sent * high_sent;
println!("{result}");
Ok(())
}
Each tick waits for the entire simulation to ‘settle’ before moving on, so we’ll keep a new queue
within each iteration. Fire off the broadcaster
and away we go!
I like how storing the extra data (on/off for flip flops and inputs for conjunctions) is the ModuleType
works here. The match
gets the data it needs, but you don’t have extra metadata that you aren’t actually using. enums
with data for the win!
Part 2
How many button presses / cycles does it take for
rx
to send alow
pulse?
Solution 1: Brute Force
Well, worth trying it, no?
fn main() -> Result<()> {
env_logger::init();
let stdin = io::stdin();
let input = io::read_to_string(stdin.lock())?;
let (s, mut modules) = parse::modules(&input).unwrap();
assert_eq!(s.trim(), "");
let mut state = modules
.keys()
.map(|label| (*label, Pulse::Low))
.collect::<FxHashMap<_, _>>();
let mut push_i = 0;
'simulation: loop {
push_i += 1;
let mut queue = VecDeque::from(vec![("button", "broadcaster", Pulse::Low)]);
while let Some((src, dst, pulse)) = queue.pop_front() {
log::info!("{src} -{pulse:?}-> {dst}");
if dst == "rx" && pulse == Pulse::Low {
break 'simulation;
}
// ...
}
}
log::info!(" pushes: {push_i}");
let result = push_i;
println!("{result}");
Ok(())
}
And … away it goes.
And … for hours doesn’t actually return anything.
I expect we’re specifically set up with something that’s going to take ages to simulate again. That seems to be the way things go this year. 😄
A pretty picture
Okay, things are taking entirely too long. Let’s visualize what we’re actually doing here.
A quick script to generate GraphViz Dot files:
fn main() -> Result<()> {
env_logger::init();
let stdin = io::stdin();
let input = io::read_to_string(stdin.lock())?;
let (s, modules) = parse::modules(&input).unwrap();
assert_eq!(s.trim(), "");
println!("digraph G {{");
// Nodes with labels
modules
.iter()
.for_each(|(label, module)| println!("{}", match module.module_type {
ModuleType::Broadcast => format!(" {label}"),
ModuleType::FlipFlop(_) => format!(" {label} [label=\"%{label}\", color=\"blue\"];"),
ModuleType::Conjunction(_) => format!(" {label} [label=\"&{label}\", color=\"green\"];"),
ModuleType::Output => format!(" {label}"),
}));
// Edges
modules.iter().for_each(|(label, module)| {
module
.outputs
.iter()
.for_each(|output| println!(" {} -> {};", label, output));
});
println!("}}");
Ok(())
}
And we have:
That’s actually pretty interesting. I’d rather drag it around a bit, but if you look carefully, it appears that rx
depends entirely on the conjunction gq
. Which in turn, requires that exactly four conjunctions are set: mf
, xj
, km
, and kz
. Going back further, each of those appears to be completely independent–there aren’t connections between the nodes all the way back until the broadcast
node.
Reaching a bit, my bet is that each of those four nodes is actually a very slow generator, it will generate a single pulse ever x
ticks. And then much like day 8… those cycles will align based on their least common multiple.
Let’s try it.
Solution 2: Least common multiple
We have the node names for our specific puzzle, but let’s generalize a bit. Still assume that there’s a single collection node and then 4 before that, but we can collect those:
fn main() -> Result<()> {
env_logger::init();
let stdin = io::stdin();
let input = io::read_to_string(stdin.lock())?;
let (s, mut modules) = parse::modules(&input).unwrap();
assert_eq!(s.trim(), "");
let mut state = modules
.keys()
.map(|label| (*label, Pulse::Low))
.collect::<FxHashMap<_, _>>();
// rx comes from exactly one node
let targets = modules
.iter()
.filter(|(_, module)| module.outputs.iter().any(|output| *output == "rx"))
.map(|(label, _)| *label)
.collect::<Vec<_>>();
// That node in turn comes from 4 that each turn on every so many frames
let mut targets = modules
.iter()
.filter(|(_, module)| module.outputs.iter().any(|output| targets.iter().any(|target| target == output)))
.map(|(label, _)| *label)
.collect::<Vec<_>>();
// Collect the lengths of those cycles
let mut cycles = Vec::new();
let mut push_i = 0;
'simulation: loop {
push_i += 1;
log::info!("=== Push {push_i} ===");
let mut queue = VecDeque::from(vec![("button", "broadcaster", Pulse::Low)]);
while let Some((src, dst, pulse)) = queue.pop_front() {
log::info!("{src} -{pulse:?}-> {dst}");
if let Some(i) = targets.iter().position(|node| *node == dst) {
if pulse == Pulse::Low {
targets.remove(i);
cycles.push(push_i as usize);
}
}
if targets.is_empty() {
break 'simulation;
}
let module = modules.get_mut(dst).unwrap();
state.insert(dst, pulse);
match module.module_type {
// Broadcast modules send the received pulse to all outputs
ModuleType::Broadcast => {
for output in &module.outputs {
queue.push_back((dst, *output, pulse));
}
}
// Flip-flops flip on low pulses
// If it was off, it turns on and sends high
// If it was on, it turns off and sends low
ModuleType::FlipFlop(ref mut is_on) => {
if pulse == Pulse::Low {
let output_pulse = if *is_on { Pulse::Low } else { Pulse::High };
for output in &module.outputs {
queue.push_back((dst, *output, output_pulse));
}
*is_on = !*is_on;
}
}
// Conjunctions remember previous inputs
// If all inputs are high, sends a low
// Otherwise, send a high
ModuleType::Conjunction(ref mut inputs) => {
inputs.insert(src, pulse);
let output_pulse = if inputs.values().all(|pulse| *pulse == Pulse::High) {
Pulse::Low
} else {
Pulse::High
};
for output in &module.outputs {
queue.push_back((dst, *output, output_pulse));
}
}
// Output modules do nothing
ModuleType::Output => {}
}
}
}
log::info!("cycles: {cycles:?}");
fn gcd(a: usize, b: usize) -> usize {
if b == 0 {
a
} else {
gcd(b, a % b)
}
}
fn lcm(a: usize, b: usize) -> usize {
a / gcd(a, b) * b
}
let result = cycles.into_iter().reduce(lcm).unwrap();
println!("{result}");
Ok(())
}
So targets
the first time is gq
and the second (final) time it will be mf
, xj
, km
, and kz
. For each of those, figure out when they fire, then apply the lcm
.
And … it works!
I think there probably could have been some setup to this one (off by 1 errors), but it turned out to be the right answer so I just went with it.
Much like day 8, it feels a bit magic, but at least this time I generated the image and looked at it, so the fact that there are four cycles that have to line up makes sense to me!
And yes. The answer is ~240 trillion. So again, a long time to simulate.
Performance
$ just time 20 1
hyperfine --warmup 3 'just run 20 1'
Benchmark 1: just run 20 1
Time (mean ± σ): 212.6 ms ± 75.9 ms [User: 48.2 ms, System: 19.8 ms]
Range (min … max): 125.3 ms … 350.1 ms 22 runs
$ just time 20 2
hyperfine --warmup 3 'just run 20 2'
Benchmark 1: just run 20 2
Time (mean ± σ): 371.3 ms ± 174.5 ms [User: 67.9 ms, System: 20.0 ms]
Range (min … max): 146.1 ms … 616.9 ms 10 runs
Well, it’s much better than days/months/years for the brute force solution, but I feel like we could do better. Perhaps a direct conversion of the gates?
Another time.