Skip to main content

Day 11: Reactor

by @merlinorg

Puzzle description

https://adventofcode.com/2025/day/11

Solution Summary

We use a simple recursive algorithm to count the number of paths in part 1. We add memoization to make part 2 tractable.

Part 1

Part 1 challenges us to count the number of paths from a start node to an end node in a directed acyclic graph (DAG).

Input Modeling

We will model the input as an adjacency list from each device to those to which it is connected:

type AdjacencyList = Map[String, List[String]]

We can then add an extension method to String to parse the input. One line at a time, we parse the start device and its connections, and then split the connections into a list:

extension (self: String)
def parse: AdjacencyList = self.linesIterator
.collect:
case s"$a: $b" => a -> b.split(' ').toList
.toMap

For example:

val a = "aaa: you hhh".parse
a: Map("aaa" -> List("you", "hhh"))

Counting Paths

Graph traversal is typically done with either breadth-first search or depth-first search. Breadth-first is appropriate for finding shortest paths and other such minima, typically with a short-cut exit, but can have significant space complexity (memory usage). In this case, we want to traverse all paths, so depth-first is more appropriate, having O(log(N)) space complexity.

To count the paths we will use a recursive loop:

  • The number of paths from B to B is 1.
  • The number of paths from A to B is equal to the sum of the number of paths from all nodes adjacent to A to B.

We will implement this as an extension method on our AdjacencyList type:

extension (adjacency: AdjacencyList)
def countPaths(from: String, to: String): Long =
def loop(loc: String): Long =
if loc == to then 1L else adjacency(loc).map(loop).sum
loop(from)

We use Long as our result type; AoC is notorious for problem that overflow the size of an Int. This is not a tail-recursive loop because the recursive call is not the last statement.

Solution

With this framework in place, we can now easily solve part 1:

def part1(input: String): Long = input.parse.countPaths("you", "out")

Part 2

Part 2 asks us to again count the number of paths through a graph, but this time from a different start location, and counting only paths that pass through two particular nodes (in any order).

One might be tempted to approach this by keeping track of all the nodes through which we have traversed using a Set[String], and checking for the two named nodes when we reach the end; or, indeed, by simply keeping track of whether we have encountered the two specific nodes using two Booleans. This extra work is unnecessary, however, if you make the following observation:

  • If there are n paths from A to B, and m paths from B to C, then there are n×m paths from A to C.

To solve the problem, we then just need to consider two routes through the system:

  • svr →⋯→ fft →⋯→ dac →⋯→ out
  • svr →⋯→ dac →⋯→ fft →⋯→ out

For each option, we count the number of paths between each pair of nodes, take the product of those intermediate results, and then sum the two results. (Because this graph is acyclic, there in fact cannot be both a path fftdac and a path dacfft, so one of these values will be zero.)

Initial Solution

Our initial solution is to sum the solutions through these these two routes; we split each route into pairs of nodes using sliding(2), count the paths between these pairs, and take the product of those values:

def part2(input: String): Long =
val adjacency = input.parse.withDefaultValue(Nil)
List("svr-dac-fft-out", "svr-fft-dac-out")
.map: route =>
route
.split('-')
.sliding(2)
.map: pair =>
adjacency.countPaths(pair(0), pair(1))
.product
.sum

Note one small addition; we add a default value of Nil (the empty list) to our adjacency list, to easily accommodate routes that do not terminate at the out node.

The Problem

It rapidly becomes clear that this solution will not complete within any reasonable time. If A is connected to both B and C, and both of those are connected to both D and E, and so on, then the number of paths we have to traverse will grow exponentially. We did not encounter this in part 1 because the problem was constructed such that there was no exponential growth from that other starting point.

Memoization

The solution to this problem is memoization. In the problem described just above, when we are counting the number of paths from B, we have to count number of paths from D and E. When we are then looking at C, we have already calculated the results for D and E so we don't need to repeat those calculations. If we store every intermediate result, we can avoid the exponential growth.

To apply this fix, we can use a mutable.Map as a memo to store these intermediate values inside our countPaths function. The logic we want basically looks like the following:

  def countPaths(from: String, to: String): Long =
val memo = mutable.Map.empty[String, Long]
def loop(loc: String): Long =
if memo.contains(loc) then memo(loc)
else
val count = if loc == to then 1L else adjacency(loc).map(loop).sum
memo.update(loc, count)
count
loop(from)

The mutable.Map class provides a getOrElseUpdate method that allows us to efficiently and cleanly express this:

  def countPaths(from: String, to: String): Long =
val memo = mutable.Map.empty[String, Long]
def loop(loc: String): Long =
lazy val count if loc == to then 1L else adjacency(loc).map(loop).sum
memo.getOrElseUpdate(loc, count)
loop(from)

We use a lazy val just for clarity here. The second parameter to getOrElseUpdate is a by-name parameter, so the count computation will only be evaluated if the key is not already present in the map.

With this enhancement, the computation completes in moments and the result is very large – almost a quadrillion in my case. Without memoization, the universe would be a distant memory before the initial solution would complete.

On Mutability

As someone (although apparently not on the Internet) once said:

“Abjure mutability, embrace purity and constancy”

Mutability is absolutely necessary in order to efficiently solve many problems. But, like children, it is best kept in a small room under the stairs.

Oftentimes we seek to encapsulate mutability in library helper functions, to avoid sullying our day to day code. We can do just the same here.

If you consider the loop function inside countPaths: It takes an input parameter of type A (String in this case) and produces an output of type Result (Long in this case), and is called with an initial value (from). As part of its operation, it needs to be able to recursively call itself.

Consider now the following memoized function signature: It has the same two type parameters, A and Result, an initial value init, and a function from A to Result – but this function has a second parameter, a function from A to Result (the recursion call) – and it returns a value of type Result.

def memoized[A, Result](init: A)(f: (A, A => Result) => Result): Result

With something like this, we can rewrite countPaths just so:

  def countPaths(from: String, to: String): Long =
memoized[Result = Long](from): (loc, loop) =>
if loc == to then 1L else adjacency(loc).map(loop).sum

If you squint, this is now almost identical to the non-memoized code. Our shameful mutability is hidden.

This uses named type arguments, an experimental language feature that lets us avoid specifying both types. In this case, the result type can't be automatically inferred.

And what does memoized look like? It creates a Function1 class that encapsulates the memo and allows the recursive function to be called, like the Y combinator.

def memoized[A, Result](init: A)(f: (A, A => Result) => Result): Result =
class Memoize extends (A => B):
val memo = mutable.Map.empty[A, B]
def apply(a: A): B = memo.getOrElseUpdate(a, f(a, this))
Memoize()(init)

Final Code

def part1(input: String): Long = input.parse.countPaths("you", "out")

def part2(input: String): Long =
val adjacency = input.parse.withDefaultValue(Nil)
List("svr-dac-fft-out", "svr-fft-dac-out")
.map: route =>
route
.split('-')
.sliding(2)
.map: pair =>
adjacency.countPaths(pair(0), pair(1))
.product
.sum

type AdjacencyList = Map[String, List[String]]

import scala.language.experimental.namedTypeArguments

extension (adjacency: AdjacencyList)
def countPaths(from: String, to: String): Long =
memoized[Result = Long](from): (loc, loop) =>
if loc == to then 1L else adjacency(loc).map(loop).sum

def memoized[A, Result](init: A)(f: (A, A => Result) => Result): Result =
class Memoize extends (A => B):
val memo = mutable.Map.empty[A, B]
def apply(a: A): B = memo.getOrElseUpdate(a, f(a, this))
Memoize()(init)

extension (self: String)
def parse: AdjacencyList = self.linesIterator
.collect:
case s"$a: $b" => a -> b.split(' ').toList
.toMap

Solutions from the community

Share your solution to the Scala community by editing this page. You can even write the whole article! Go here to volunteer