Skip to content

Commit ec3476f

Browse files
committed
Add code for day 20
1 parent d7fac63 commit ec3476f

File tree

1 file changed

+244
-0
lines changed

1 file changed

+244
-0
lines changed

2023/src/day20.scala

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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

Comments
 (0)