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