1+ package day20
2+
3+ import locations .Directory .currentDir
4+ import inputs .Input .loadFileSync
5+ import scala .annotation .tailrec
6+ import scala .collection .immutable .Queue
7+
8+ @ main def part1 : Unit =
9+ println(s " The solution is ${part1(loadInput())}" )
10+ // println(s"The solution is ${part1(sample1)}")
11+
12+ @ main def part2 : Unit =
13+ println(s " The solution is ${part2(loadInput())}" )
14+ // println(s"The solution is ${part2(sample1)}")
15+
16+ def loadInput (): String = loadFileSync(s " $currentDir/../input/day20 " )
17+
18+ val sample1 = """
19+ broadcaster -> a
20+ %a -> inv, con
21+ &inv -> b
22+ %b -> con
23+ &con -> output
24+ """ .strip
25+
26+ type ModuleName = String
27+
28+ // Pulses are the messages of our primary state machine. They are either low
29+ // (false) or high (true) and travel from a source to a destination module
30+
31+ final case class Pulse (
32+ source : ModuleName ,
33+ destination : ModuleName ,
34+ level : Boolean ,
35+ )
36+
37+ object Pulse :
38+ final val ButtonPress = Pulse (" button" , " broadcaster" , false )
39+
40+ // The modules include pass-throughs which simply forward pulses, flip flips
41+ // which toggle state and emit when they receive a low pulse, and conjunctions
42+ // which emit a low signal when all inputs are high.
43+
44+ sealed trait Module :
45+ def name : ModuleName
46+ def destinations : Vector [ModuleName ]
47+ // Generate pulses for all the destinations of this module
48+ def pulses (level : Boolean ): Vector [Pulse ] =
49+ destinations.map(Pulse (name, _, level))
50+ end Module
51+
52+ final case class PassThrough (
53+ name : ModuleName ,
54+ destinations : Vector [ModuleName ],
55+ ) extends Module
56+
57+ final case class FlipFlop (
58+ name : ModuleName ,
59+ destinations : Vector [ModuleName ],
60+ state : Boolean ,
61+ ) extends Module
62+
63+ final case class Conjunction (
64+ name : ModuleName ,
65+ destinations : Vector [ModuleName ],
66+ // The source modules that most-recently sent a high pulse
67+ state : Set [ModuleName ],
68+ ) extends Module
69+
70+ // The machine comprises a collection of named modules and a map that gathers
71+ // which modules serve as sources for each module in the machine.
72+
73+ final case class Machine (
74+ modules : Map [ModuleName , Module ],
75+ sources : Map [ModuleName , Set [ModuleName ]]
76+ ):
77+ inline def + (module : Module ): Machine =
78+ copy(
79+ modules = modules.updated(module.name, module),
80+ sources = module.destinations.foldLeft(sources): (sources, destination) =>
81+ sources.updatedWith(destination):
82+ case None => Some (Set (module.name))
83+ case Some (values) => Some (values + module.name)
84+ )
85+ end Machine
86+
87+ object Machine :
88+ final val Initial = Machine (Map .empty, Map .empty)
89+
90+ // To parse the input we first parse all of the modules and then fold them
91+ // into a new machine
92+
93+ def parse (input : String ): Machine =
94+ val modules = input.linesIterator.map:
95+ case s " % $name -> $targets" =>
96+ FlipFlop (name, targets.split(" , " ).toVector, false )
97+ case s " & $name -> $targets" =>
98+ Conjunction (name, targets.split(" , " ).toVector, Set .empty)
99+ case s " $name -> $targets" =>
100+ PassThrough (name, targets.split(" , " ).toVector)
101+ modules.foldLeft(Initial )(_ + _)
102+ end Machine
103+
104+ // The primary state machine state comprises the machine itself, the number of
105+ // button presses and a queue of outstanding pulses.
106+
107+ final case class MachineFSM (
108+ machine : Machine ,
109+ presses : Long = 0 ,
110+ queue : Queue [Pulse ] = Queue .empty,
111+ ):
112+ def nextState : MachineFSM = queue.dequeueOption match
113+ // If the queue is empty, we increment the button presses and enqueue a
114+ // button press pulse
115+ case None =>
116+ copy(presses = presses + 1 , queue = Queue (Pulse .ButtonPress ))
117+
118+ case Some ((Pulse (source, destination, level), tail)) =>
119+ machine.modules.get(destination) match
120+ // If a pulse reaches a pass-through, enqueue pulses for all the module
121+ // destinations
122+ case Some (passThrough : PassThrough ) =>
123+ copy(queue = tail ++ passThrough.pulses(level))
124+
125+ // If a low pulse reaches a flip-flop, update the flip-flop state in the
126+ // machine and enqueue pulses for all the module destinations
127+ case Some (flipFlop : FlipFlop ) if ! level =>
128+ val flipFlop2 = flipFlop.copy(state = ! flipFlop.state)
129+ copy(
130+ machine = machine + flipFlop2,
131+ queue = tail ++ flipFlop2.pulses(flipFlop2.state)
132+ )
133+
134+ // If a pulse reaches a conjunction, update the source state in the
135+ // conjunction and enqueue pulses for all the module destinations
136+ // according to the conjunction state
137+ case Some (conjunction : Conjunction ) =>
138+ val conjunction2 = conjunction.copy(
139+ state = if level then conjunction.state + source
140+ else conjunction.state - source
141+ )
142+ val active = machine.sources(conjunction2.name) == conjunction2.state
143+ copy(
144+ machine = machine + conjunction2,
145+ queue = tail ++ conjunction2.pulses(! active)
146+ )
147+
148+ // In all other cases just discard the pulse and proceed
149+ case _ =>
150+ copy(queue = tail)
151+ end MachineFSM
152+
153+ // An unruly and lawless find-map-get
154+ extension [A ](self : Iterator [A ])
155+ def findMap [B ](f : A => Option [B ]): B = self.flatMap(f).next()
156+
157+ // The problem 1 state machine comprises the number of low and high pulses
158+ // processed, and whether the problem is complete (after 1000 presses). This
159+ // state machine gets updated by each state of the primary state machine.
160+
161+ final case class Problem1FSM (
162+ lows : Long ,
163+ highs : Long ,
164+ complete : Boolean ,
165+ ):
166+ // If the head of the pulse queue is a low or high pulse then update the
167+ // low/high count. If the pulse queue is empty and the button has been pressed
168+ // 1000 times then complete.
169+ inline def + (state : MachineFSM ): Problem1FSM =
170+ state.queue.headOption match
171+ case Some (Pulse (_, _, false )) => copy(lows = lows + 1 )
172+ case Some (Pulse (_, _, true )) => copy(highs = highs + 1 )
173+ case None if state.presses == 1000 => copy(complete = true )
174+ case None => this
175+
176+ // The result is the product of lows and highs
177+ def solution : Option [Long ] = Option .when(complete)(lows * highs)
178+ end Problem1FSM
179+
180+ object Problem1FSM :
181+ final val Initial = Problem1FSM (0 , 0 , false )
182+
183+ // Part 1 is solved by first constructing the primary state machine that
184+ // executes the pulse machinery. Each state of this machine is then fed to a
185+ // second problem 1 state machine. We then run the combined state machines to
186+ // completion.
187+
188+ def part1 (input : String ): Long =
189+ val machine = Machine .parse(input)
190+ Iterator
191+ .iterate(MachineFSM (machine))(_.nextState)
192+ .scanLeft(Problem1FSM .Initial )(_ + _)
193+ .findMap(_.solution)
194+ end part1
195+
196+ // The problem 2 state machine is looking for the least common multiple of the
197+ // cycle lengths of the subgraphs that feed into the output "rx" module. When it
198+ // observes a high pulse from the final module of one these subgraphs, it
199+ // records the number of button presses to reach this state.
200+
201+ final case class Problem2FSM (
202+ cycles : Map [ModuleName , Long ],
203+ ):
204+ inline def + (state : MachineFSM ): Problem2FSM =
205+ state.queue.headOption match
206+ case Some (Pulse (src, _, true )) if cycles.get(src).contains(0L ) =>
207+ copy(cycles = cycles + (src -> state.presses))
208+ case _ => this
209+
210+ // We are complete if we have the cycle value for each subgraph
211+ def solution : Option [Long ] =
212+ Option .when(cycles.values.forall(_ > 0 ))(lcm(cycles.values))
213+
214+ private def lcm (list : Iterable [Long ]): Long =
215+ list.foldLeft(1L )((a, b) => b * a / gcd(a, b))
216+
217+ @ tailrec private def gcd (x : Long , y : Long ): Long =
218+ if y == 0 then x else gcd(y, x % y)
219+ end Problem2FSM
220+
221+ object Problem2FSM :
222+ def apply (machine : Machine ): Problem2FSM =
223+ new Problem2FSM (subgraphs(machine).map(_ -> 0L ).toMap)
224+
225+ // The problem is characterized by a terminal module ("rx") that is fed by
226+ // several subgraphs so we look to see which are the sources of the terminal
227+ // module; these are the subgraphs whose cycle lengths we need to count.
228+ private def subgraphs (machine : Machine ): Set [ModuleName ] =
229+ val terminal = (machine.sources.keySet -- machine.modules.keySet).head
230+ machine.sources(machine.sources(terminal).head)
231+
232+ // Part 2 is solved by first constructing the primary state machine that
233+ // executes the pulse machinery. Each state of this machine is then fed to a
234+ // second problem 2 state machine. We then run the combined state machines to
235+ // completion.
236+
237+ def part2 (input : String ): Long =
238+ val machine = Machine .parse(input)
239+
240+ Iterator
241+ .iterate(MachineFSM (machine))(_.nextState)
242+ .scanLeft(Problem2FSM (machine))(_ + _)
243+ .findMap(_.solution)
244+ end part2
0 commit comments