@@ -2,14 +2,306 @@ import Solver from "../../../../../website/src/components/Solver.js"
22
33# Day 11: Reactor
44
5+ by [ @merlinorg ] ( https://github.com/merlinorg )
6+
57## Puzzle description
68
79https://adventofcode.com/2025/day/11
810
11+ ## Solution Summary
12+
13+ We use a simple recursive algorithm to count the number of paths in
14+ part 1. We add memoization to make part 2 tractable.
15+
16+ ### Part 1
17+
18+ Part 1 challenges us to count the number of paths from a start node
19+ to an end node in a
20+ [ directed acyclic graph] ( https://en.wikipedia.org/wiki/Directed_acyclic_graph ) (DAG).
21+
22+ #### Input Modeling
23+
24+ We will model the input as an
25+ [ adjacency list] ( https://en.wikipedia.org/wiki/Adjacency_list )
26+ from each device to those to which it is connected:
27+
28+ ``` scala 3
29+ type AdjacencyList = Map [String , List [String ]]
30+ ```
31+
32+ We can then add an extension method to ` String ` to parse the input.
33+ One line at a time, we parse the start device and its connections,
34+ and then split the connections into a list:
35+
36+ ``` scala 3
37+ extension (self : String )
38+ def parse : AdjacencyList = self.linesIterator
39+ .collect:
40+ case s " $a: $b" => a -> b.split(' ' ).toList
41+ .toMap
42+ ```
43+
44+ For example:
45+
46+ ``` scala 3
47+ val a = " aaa: you hhh" .parse
48+ a: Map (" aaa" -> List (" you" , " hhh" ))
49+ ```
50+
51+ #### Counting Paths
52+
53+ Graph traversal is typically done with either
54+ [ breadth-first search] ( https://en.wikipedia.org/wiki/Breadth-first_search )
55+ or [ depth-first search] ( https://en.wikipedia.org/wiki/Depth-first_search ) .
56+ Breadth-first is appropriate for finding shortest paths and other
57+ such minima, typically with a short-cut exit, but can have significant
58+ [ space complexity] ( https://en.wikipedia.org/wiki/Space_complexity )
59+ (memory usage). In this case, we want to traverse all paths, so depth-first
60+ is more appropriate, having _ O(log(N))_ space complexity.
61+
62+ To count the paths we will use a recursive loop:
63+
64+ - The number of paths from ** B** to ** B** is 1.
65+ - The number of paths from ** A** to ** B** is equal to the sum of the
66+ number of paths from all nodes adjacent to ** A** to ** B** .
67+
68+ We will implement this as an extension method on our ` AdjacencyList `
69+ type:
70+
71+ ``` scala 3
72+ extension (adjacency : AdjacencyList )
73+ def countPaths (from : String , to : String ): Long =
74+ def loop (loc : String ): Long =
75+ if loc == to then 1L else adjacency(loc).map(loop).sum
76+ loop(from)
77+ ```
78+
79+ We use ` Long ` as our result type; AoC is notorious for
80+ problem that overflow the size of an ` Int ` . This is not a
81+ tail-recursive loop because the recursive call is not the last statement.
82+
83+ #### Solution
84+
85+ With this framework in place, we can now easily solve part 1:
86+
87+ ``` scala 3
88+ def part1 (input : String ): Long = input.parse.countPaths(" you" , " out" )
89+ ```
90+
91+ ### Part 2
92+
93+ Part 2 asks us to again count the number of paths through a graph,
94+ but this time from a different start location, and counting only
95+ paths that pass through two particular nodes (in any order).
96+
97+ One might be tempted to approach this by keeping track of all the
98+ nodes through which we have traversed using a ` Set[String] ` , and
99+ checking for the two named nodes when we reach the end; or, indeed,
100+ by simply keeping track of whether we have encountered the two
101+ specific nodes using two ` Boolean ` s.
102+ This extra work is unnecessary, however, if you make the following
103+ observation:
104+
105+ - If there are ** n** paths from ** A** to ** B** , and ** m** paths
106+ from ** B** to ** C** , then there are ** n** ×** m** paths from
107+ ** A** to ** C** .
108+
109+ To solve the problem, we then just need to consider two routes
110+ through the system:
111+
112+ - ** svr** →⋯→ ** fft** →⋯→ ** dac** →⋯→ ** out**
113+ - ** svr** →⋯→ ** dac** →⋯→ ** fft** →⋯→ ** out**
114+
115+ For each option, we count the number of paths between each pair
116+ of nodes, take the product of those intermediate results, and then
117+ sum the two results. (Because this graph is acyclic, there in fact
118+ cannot be both a path ** fft** ⇢ ** dac** and a path ** dac** ⇢ ** fft** ,
119+ so one of these values will be zero.)
120+
121+ #### Initial Solution
122+
123+ Our initial solution is to sum the solutions through these these two routes;
124+ we split each route into pairs of nodes using ` sliding(2) ` , count
125+ the paths between these pairs, and take the product of those values:
126+
127+ ``` scala 3
128+ def part2 (input : String ): Long =
129+ val adjacency = input.parse.withDefaultValue(Nil )
130+ List (" svr-dac-fft-out" , " svr-fft-dac-out" )
131+ .map: route =>
132+ route
133+ .split('-' )
134+ .sliding(2 )
135+ .map: pair =>
136+ adjacency.countPaths(pair(0 ), pair(1 ))
137+ .product
138+ .sum
139+ ```
140+
141+ Note one small addition; we add a default value of ` Nil `
142+ (the empty list) to our adjacency list, to easily accommodate
143+ routes that do not terminate at the ** out** node.
144+
145+ ##### The Problem
146+
147+ It rapidly becomes clear that this solution will not complete
148+ within any reasonable time. If ** A** is connected to both ** B** and ** C** ,
149+ and both of those are connected to both ** D** and ** E** , and so on,
150+ then the number of paths we have to traverse will grow exponentially.
151+ We did not encounter this in part 1 because the problem was constructed
152+ such that there was no exponential growth from that other starting point.
153+
154+ #### Memoization
155+
156+ The solution to this problem is
157+ [ memoization] ( https://en.wikipedia.org/wiki/Memoization ) . In the problem
158+ described just above, when we are counting the number of paths from ** B** , we
159+ have to count number of paths from ** D** and ** E** . When we are then looking
160+ at ** C** , we have already calculated the results for ** D** and ** E**
161+ so we don't need to repeat those calculations. If we store every
162+ intermediate result, we can avoid the exponential growth.
163+
164+ To apply this fix, we can use a ` mutable.Map ` as a memo to store these
165+ intermediate values inside our ` countPaths ` function. The logic we want
166+ basically looks like the following:
167+
168+ ``` scala 3
169+ def countPaths (from : String , to : String ): Long =
170+ val memo = mutable.Map .empty[String , Long ]
171+ def loop (loc : String ): Long =
172+ if memo.contains(loc) then memo(loc)
173+ else
174+ val count = if loc == to then 1L else adjacency(loc).map(loop).sum
175+ memo.update(loc, count)
176+ count
177+ loop(from)
178+ ```
179+
180+ The ` mutable.Map ` class provides a ` getOrElseUpdate ` method that
181+ allows us to efficiently and cleanly express this:
182+
183+ ``` scala 3
184+ def countPaths (from : String , to : String ): Long =
185+ val memo = mutable.Map .empty[String , Long ]
186+ def loop (loc : String ): Long =
187+ lazy val count if loc == to then 1L else adjacency(loc).map(loop).sum
188+ memo.getOrElseUpdate(loc, count)
189+ loop(from)
190+ ```
191+
192+ We use a ` lazy val ` just for clarity here. The second parameter to
193+ ` getOrElseUpdate ` is a
194+ [ by-name parameter] ( https://docs.scala-lang.org/tour/by-name-parameters.html ) ,
195+ so the ` count ` computation will only be evaluated if the key is not already
196+ present in the map.
197+
198+ With this enhancement, the computation completes in moments and the
199+ result is very large – almost a quadrillion in my case. Without
200+ memoization, the universe would be a distant memory before the initial
201+ solution would complete.
202+
203+ ## On Mutability
204+
205+ As someone (although apparently not on the Internet) once said:
206+
207+ > “Abjure mutability, embrace purity and constancy”
208+
209+ Mutability is absolutely necessary in order to efficiently solve
210+ many problems. But, like children, it is best kept in a small
211+ room under the stairs.
212+
213+ Oftentimes we seek to encapsulate mutability
214+ in library helper functions, to avoid sullying our day to day code.
215+ We can do just the same here.
216+
217+ If you consider the ` loop ` function inside ` countPaths ` : It takes an
218+ input parameter of type ` A ` (` String ` in this case) and produces an
219+ output of type ` Result ` (` Long ` in this case), and is called with an
220+ initial value (` from ` ). As part of its operation, it needs to be able
221+ to recursively call itself.
222+
223+ Consider now the following ` memoized ` function signature: It has the same two
224+ type parameters, ` A ` and ` Result ` , an initial value ` init ` ,
225+ and a function from ` A ` to ` Result ` – but this function has a second
226+ parameter, a function from ` A ` to ` Result ` (the recursion call) –
227+ and it returns a value of type ` Result ` .
228+
229+ ``` scala 3
230+ def memoized [A , Result ](init : A )(f : (A , A => Result ) => Result ): Result
231+ ```
232+
233+ With something like this, we can rewrite ` countPaths ` just so:
234+
235+ ``` scala 3
236+ def countPaths (from : String , to : String ): Long =
237+ memoized[Result = Long ](from): (loc, loop) =>
238+ if loc == to then 1L else adjacency(loc).map(loop).sum
239+ ```
240+
241+ If you squint, this is now almost identical to the non-memoized
242+ code. Our shameful mutability is hidden.
243+
244+ This uses [ named type arguments] ( https://docs.scala-lang.org/scala3/reference/experimental/named-typeargs.html ) ,
245+ an experimental language feature that lets us avoid specifying
246+ both types. In this case, the result type can't be automatically inferred.
247+
248+ And what does ` memoized ` look like? It creates a ` Function1 `
249+ class that encapsulates the memo and allows the recursive function
250+ to be called, like the [ Y combinator] ( https://en.wikipedia.org/wiki/Fixed-point_combinator#Y_combinator ) .
251+
252+ ``` scala 3
253+ def memoized [A , Result ](init : A )(f : (A , A => Result ) => Result ): Result =
254+ class Memoize extends (A => B ):
255+ val memo = mutable.Map .empty[A , B ]
256+ def apply (a : A ): B = memo.getOrElseUpdate(a, f(a, this ))
257+ Memoize ()(init)
258+ ```
259+
260+ ## Final Code
261+
262+ ``` scala 3
263+ def part1 (input : String ): Long = input.parse.countPaths(" you" , " out" )
264+
265+ def part2 (input : String ): Long =
266+ val adjacency = input.parse.withDefaultValue(Nil )
267+ List (" svr-dac-fft-out" , " svr-fft-dac-out" )
268+ .map: route =>
269+ route
270+ .split('-' )
271+ .sliding(2 )
272+ .map: pair =>
273+ adjacency.countPaths(pair(0 ), pair(1 ))
274+ .product
275+ .sum
276+
277+ type AdjacencyList = Map [String , List [String ]]
278+
279+ import scala .language .experimental .namedTypeArguments
280+
281+ extension (adjacency : AdjacencyList )
282+ def countPaths (from : String , to : String ): Long =
283+ memoized[Result = Long ](from): (loc, loop) =>
284+ if loc == to then 1L else adjacency(loc).map(loop).sum
285+
286+ def memoized [A , Result ](init : A )(f : (A , A => Result ) => Result ): Result =
287+ class Memoize extends (A => B ):
288+ val memo = mutable.Map .empty[A , B ]
289+ def apply (a : A ): B = memo.getOrElseUpdate(a, f(a, this ))
290+ Memoize ()(init)
291+
292+ extension (self : String )
293+ def parse : AdjacencyList = self.linesIterator
294+ .collect:
295+ case s " $a: $b" => a -> b.split(' ' ).toList
296+ .toMap
297+ ```
298+
9299## Solutions from the community
10300- [ Solution] ( https://codeberg.org/nichobi/adventofcode/src/branch/main/2025/11/solution.scala ) by [ nichobi] ( https://nichobi.com )
11301
12302- [ Solution] ( https://github.com/Philippus/adventofcode/blob/main/src/main/scala/adventofcode2025/Day11.scala ) by [ Philippus Baalman] ( https://github.com/philippus )
13303
304+ - [ Solution] ( https://github.com/merlinorg/advent-of-code/blob/main/src/main/scala/year2025/day11.scala ) by [ merlin] ( https://github.com/merlinorg/ )
305+
14306Share your solution to the Scala community by editing this page.
15307You can even write the whole article! [ Go here to volunteer] ( https://github.com/scalacenter/scala-advent-of-code/discussions/842 )
0 commit comments