Skip to content

Commit 9a376ac

Browse files
authored
2025 day 11 blog post (#925)
1 parent af1cb28 commit 9a376ac

File tree

1 file changed

+292
-0
lines changed

1 file changed

+292
-0
lines changed

docs/2025/puzzles/day11.md

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

79
https://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+
14306
Share your solution to the Scala community by editing this page.
15307
You can even write the whole article! [Go here to volunteer](https://github.com/scalacenter/scala-advent-of-code/discussions/842)

0 commit comments

Comments
 (0)