Skip to content

Commit dd76d26

Browse files
committed
Add post: Day 10 - Factory
1 parent 9a376ac commit dd76d26

File tree

1 file changed

+390
-0
lines changed

1 file changed

+390
-0
lines changed

docs/2025/puzzles/day10.md

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,396 @@ import Solver from "../../../../../website/src/components/Solver.js"
66

77
https://adventofcode.com/2025/day/10
88

9+
## Solution Summary
10+
11+
The input includes multiple lines, each of them can be handled independently, the result is the sum from the output of each scenario.
12+
13+
For part 1, I applied the [Breadth-first Search](https://en.wikipedia.org/wiki/Breadth-first_search) algorithm (BFS), BFS allows finding the shorest-path from a state to another when every transition has the same cost, this was quicky to implement and produced the correct output.
14+
15+
For part 2, BFS wasn't adequate due to the number of potential states, there is an alternative [Divide-and-conquer](https://en.wikipedia.org/wiki/Divide-and-conquer_algorithm) approach which is fast enough.
16+
17+
18+
## Parsing the input
19+
20+
Let's represent the input scenario as a case class:
21+
22+
```scala
23+
case class InputCase(target: String, buttons: IndexedSeq[Set[Int]], joltage: IndexedSeq[Int])
24+
```
25+
26+
For parsing the input, I'll mix pattern matching to extract the input pieces combined with `String` utilities (`replace` and `split`):
27+
28+
```scala
29+
def parseInput(input: String): IndexedSeq[InputCase] = {
30+
input.split("\n").collect { case s"[$lightDiagram]$buttonsStr{$joltageStr}" =>
31+
val buttons = buttonsStr.split(" ").filter(_.nonEmpty)
32+
.map(_.replace("(", "").replace(")", "").split(",").map(_.toInt).toSet)
33+
.toIndexedSeq
34+
val joltage = joltageStr.split(",").map(_.toInt).toIndexedSeq
35+
InputCase(target = lightDiagram, buttons = buttons, joltage = joltage)
36+
}
37+
}
38+
```
39+
40+
## Part 1
41+
42+
The BFS algorithm is adequate because in every step we can take a button to produce a new state, we have to avoid visiting the same state more than once.
43+
44+
For example, given this input `[####] (0) (3) (0,1) (1,2)`:
45+
46+
- The target state is `####` (all lights turned on).
47+
- The initial state is `....` (all lights off).
48+
- The buttons allow toggling light `0` or `3`, or, lights `0,1` or lights `1,2`.
49+
50+
I have created a few helper functions:
51+
52+
- `flip` receives the state from a light and toggles its value, `.` to `#`, and, `#` to `.`.
53+
- `transform` receives the lights state and toggles the lights covered by a button.
54+
55+
```scala
56+
def flip(value: Char): Char = if (value == '#') '.' else '#'
57+
def transform(source: String, button: Set[Int]): String = {
58+
source.toList.zipWithIndex.map { case (value, index) =>
59+
if (button.contains(index)) flip(value)
60+
else value
61+
}.mkString("")
62+
}
63+
```
64+
65+
The way to implement a BFS requires a `Queue`, a `State`, and, a way to track the visited values, for what I used a `Set`.
66+
67+
The state for the BFS can de defined with the current light's state and the number of steps required to get there:
68+
69+
```scala
70+
case class State(value: String, steps: Int)
71+
```
72+
73+
Naturally, the initial state would have `value` to be only dots (`.`) with `0` steps.
74+
75+
Given everything described until now, it is only required to define the BFS process, for this I used a tail-recursive function that does the following:
76+
77+
- When the queue is empty, there is no solution.
78+
- When the current state has reached the goal, return the number of steps.
79+
- Otherwise, find the new states that can be visited, push them to the queue and repeat.
80+
81+
```scala
82+
@scala.annotation.tailrec
83+
def loop(queue: Queue[State], touched: Set[String], inputCase: InputCase): Option[Int] = {
84+
queue.dequeueOption match {
85+
case None => None
86+
case Some((current, _)) if current.value == inputCase.target => Some(current.steps)
87+
case Some((current, nextQueue)) =>
88+
val newValues = inputCase.buttons
89+
.map(button => transform(current.value, button))
90+
.filterNot(touched.contains)
91+
92+
val newTouched = touched ++ newValues
93+
val newStates = newValues.map { newValue => State(newValue, current.steps + 1) }
94+
val newQueue = nextQueue.enqueueAll(newStates)
95+
96+
loop(newQueue, newTouched, inputCase)
97+
}
98+
}
99+
```
100+
101+
The final piece is just wiring the existing functionaly to cover each scenario and sum the results:
102+
103+
```scala
104+
def part1(input: String): Unit = {
105+
import scala.collection.immutable.Queue
106+
107+
def flip(value: Char): Char = ???
108+
def transform(source: String, button: Set[Int]): String = ???
109+
110+
case class State(value: String, steps: Int)
111+
112+
@scala.annotation.tailrec
113+
def loop(queue: Queue[State], touched: Set[String], inputCase: InputCase): Option[Int] = ???
114+
115+
def resolveCase(inputCase: InputCase): Int = {
116+
val initial = inputCase.target.map(_ => '.')
117+
loop(Queue(State(initial, 0)), Set(initial), inputCase)
118+
.getOrElse(throw new RuntimeException("Answer not found"))
119+
}
120+
121+
val total = parseInput(input).map(resolveCase).sum
122+
println(total)
123+
}
124+
```
125+
126+
127+
There are potential alternatives to deal with this but given the input size, they are not necessary, for example:
128+
129+
- Use a `BitSet` data structure instead of the `String`.
130+
- Applying the same button twice does not make sense because the effect gets reverted which simplifies the problem to either use a button or not.
131+
132+
133+
## Part 2
134+
135+
My initial reaction was that resolving this might be trivial to do by reusing the BFS implementation while by changing a few operations:
136+
137+
- State is now the joltage.
138+
- Instead of going from the empty state to the goal, let's go from the given joltage to `0` values.
139+
- Filter out invalid states (`joltage[k] < 0`)
140+
141+
```diff
142+
diff --git a/Main.scala b/Main.scala
143+
index 21ccc48..b98c9e4 100644
144+
--- a/Main.scala
145+
+++ b/Main.scala
146+
@@ -19,25 +19,26 @@ object Main extends App {
147+
def part1(input: String): Unit = {
148+
import scala.collection.immutable.Queue
149+
150+
- def flip(value: Char): Char = if (value == '#') '.' else '#'
151+
- def transform(source: String, button: Set[Int]): String = {
152+
- source.toList.zipWithIndex.map { case (value, index) =>
153+
- if (button.contains(index)) flip(value)
154+
+ def isValid(source: IndexedSeq[Int]): Boolean = source.forall(_ >= 0)
155+
+ def transform(source: IndexedSeq[Int], button: Set[Int]): IndexedSeq[Int] = {
156+
+ source.zipWithIndex.map { case (value, index) =>
157+
+ if (button.contains(index)) value - 1
158+
else value
159+
- }.mkString("")
160+
+ }
161+
}
162+
163+
- case class State(value: String, steps: Int)
164+
+ case class State(value: IndexedSeq[Int], steps: Int)
165+
166+
@scala.annotation.tailrec
167+
- def loop(queue: Queue[State], touched: Set[String], inputCase: InputCase): Option[Int] = {
168+
+ def loop(queue: Queue[State], touched: Set[IndexedSeq[Int]], inputCase: InputCase): Option[Int] = {
169+
queue.dequeueOption match {
170+
case None => None
171+
- case Some((current, _)) if current.value == inputCase.target => Some(current.steps)
172+
+ case Some((current, _)) if current.value.forall(_ == 0) => Some(current.steps)
173+
case Some((current, nextQueue)) =>
174+
val newValues = inputCase.buttons
175+
.map(button => transform(current.value, button))
176+
.filterNot(touched.contains)
177+
+ .filter(isValid)
178+
179+
val newTouched = touched ++ newValues
180+
val newStates = newValues.map { newValue => State(newValue, current.steps + 1) }
181+
@@ -48,7 +49,7 @@ object Main extends App {
182+
}
183+
184+
def resolveCase(inputCase: InputCase): Int = {
185+
- val initial = inputCase.target.map(_ => '.')
186+
+ val initial = inputCase.joltage
187+
loop(Queue(State(initial, 0)), Set(initial), inputCase)
188+
.getOrElse(throw new RuntimeException("Answer not found"))
189+
}
190+
```
191+
192+
This resolved the example input but was too slow with the actual test scenarios, I tried a few heuristics to trim unnecessary paths, tried `DFS` with prunning, `A*`, and, I was close to implement a [bidirectional BFS](https://en.wikipedia.org/wiki/Bidirectional_search).
193+
194+
Eventually, I got an idea from [reddit](https://old.reddit.com/r/adventofcode/comments/1pk87hl/2025_day_10_part_2_bifurcate_your_way_to_victory/) about using a [Divide-and-conquer](https://en.wikipedia.org/wiki/Divide-and-conquer_algorithm) approach instead which goes like this, if the path from `0` to `T` takes `N` steps, the path from `0` to `2T` takes `2N` steps, with this:
195+
196+
- We can try to get convert the `joltage` into even numbers.
197+
- Resolve `joltage / 2`.
198+
- The answer for the current step would be `f(joltage / 2)*2 + currentCost`.
199+
- There are many overlaping sub-problems but we can use a cache to avoid unnecessary recomputation.
200+
201+
**DISCLAIMER** I have no proof that this handles every possible scenario but with the test cases I prepared, the BFS result leads to the same from this, and, the result has been accepted by Advent of Code.
202+
203+
Let's start by defining how to press the buttons to get `` from the alternatives to resolve part 1, I mentioned that applying a button more than once is not necessary because it invalidates the previous action, in this case with integer values, we can claim the same but it is not about the value itself but the parity.
204+
205+
For example, applying a buton `0, 3` to joltages `3, 4, 5, 6` leads to joltates `2, 4, 4, 6` (all even) but applying the same button again will revert the parity back.
206+
207+
When all joltages are even (like `2, 4, 4, 6`), we can divide them by 2, leaving us with joltages `1, 2, 2, 3`, then, we apply the same process recursively.
208+
209+
Having said this, let's generate all possible transitions given the available buttons, this is, all subsets from the given buttons, leveraging the powerful Scala stdlib, we can call the `Set#subsets` function:
210+
211+
```scala
212+
scala> List(1, 2, 3).toSet.subsets.foreach(println)
213+
Set()
214+
Set(1)
215+
Set(2)
216+
Set(3)
217+
Set(1, 2)
218+
Set(1, 3)
219+
Set(2, 3)
220+
Set(1, 2, 3)
221+
```
222+
223+
**NOTE**: The empty subset is important because that allow us to take the existing joltages which could be already divisible by 2.
224+
225+
The code to generate the moves generates the transformation vector for every button, for example, in the case of a button `0,2`, with `4` joltages, the transformation vector becomes `1,0,1,0`, this is the delta to apply to the joltages with the cost equal to the number of buttons required to get this combination:
226+
227+
```scala
228+
val allMoves = inputCase.buttons.toSet.subsets().toList.map { set =>
229+
set.foldLeft(IndexedSeq.fill(inputCase.joltage.size)(0) -> 0) { case ((acc, cost), button) =>
230+
val newVector = acc.zipWithIndex.map { case (x, index) =>
231+
if (button.contains(index)) x + 1
232+
else x
233+
}
234+
newVector -> (cost + 1)
235+
}
236+
}
237+
```
238+
239+
Essentially, `allMoves` have the transformation options associated with the cost to apply them.
240+
241+
We'll require a cache to avoid recomputing the same value twice:
242+
243+
```scala
244+
// min moves to switch the given joltages to 0
245+
var cache = Map.empty[IndexedSeq[Int], Option[Int]]
246+
```
247+
248+
At last, the actual function to compute the cost required to transform the given joltages into 0s:
249+
250+
- When joltages is composed by only 0s, we have the answer (no cost).
251+
- When the answer for the given joltages is already cached, reuse it.
252+
- Otherwise, apply any transformations that lead to even-joltages, resolve the smaller problem and compute the answer, out of those options, keep the minimum cost and put it into the cache.
253+
254+
```scala
255+
def f(joltage: IndexedSeq[Int]): Option[Int] = {
256+
if (joltage.forall(_ == 0)) Option(0)
257+
else if (cache.contains(joltage)) cache(joltage)
258+
else {
259+
val choices = allMoves.flatMap { case (delta, cost) =>
260+
val newJoltage = joltage.zip(delta).map( (goal, diff) => goal - diff)
261+
262+
if (newJoltage.forall(_ >= 0) && newJoltage.forall(_ % 2 == 0))
263+
f(newJoltage.map(_ / 2)).map(res => cost + (res * 2))
264+
else
265+
None
266+
}
267+
268+
val best = choices.minOption
269+
cache = cache + (joltage -> best)
270+
271+
best
272+
}
273+
}
274+
```
275+
276+
Now, the whole code becomes:
277+
278+
```scala
279+
def part2(input: String): Unit = {
280+
def resolveCase(inputCase: InputCase): Int = {
281+
val allMoves = ???
282+
283+
// min moves to switch the given joltages to 0
284+
var cache = Map.empty[IndexedSeq[Int], Option[Int]]
285+
286+
def f(joltage: IndexedSeq[Int]): Option[Int] = ???
287+
288+
f(inputCase.joltage).getOrElse(throw new RuntimeException("Answer not found"));
289+
}
290+
291+
val total = parseInput(input).map(resolveCase).sum
292+
println(total)
293+
}
294+
```
295+
296+
297+
## Final code
298+
299+
```scala
300+
case class InputCase(target: String, buttons: IndexedSeq[Set[Int]], joltage: IndexedSeq[Int])
301+
302+
def parseInput(input: String): IndexedSeq[InputCase] = {
303+
input.split("\n").collect { case s"[$lightDiagram]$buttonsStr{$joltageStr}" =>
304+
val buttons = buttonsStr.split(" ").filter(_.nonEmpty)
305+
.map(_.replace("(", "").replace(")", "").split(",").map(_.toInt).toSet)
306+
.toIndexedSeq
307+
val joltage = joltageStr.split(",").map(_.toInt).toIndexedSeq
308+
InputCase(target = lightDiagram, buttons = buttons, joltage = joltage)
309+
}
310+
}
311+
312+
def part1(input: String): Unit = {
313+
import scala.collection.immutable.Queue
314+
315+
def flip(value: Char): Char = if (value == '#') '.' else '#'
316+
def transform(source: String, button: Set[Int]): String = {
317+
source.toList.zipWithIndex.map { case (value, index) =>
318+
if (button.contains(index)) flip(value)
319+
else value
320+
}.mkString("")
321+
}
322+
323+
case class State(value: String, steps: Int)
324+
325+
@scala.annotation.tailrec
326+
def loop(queue: Queue[State], touched: Set[String], inputCase: InputCase): Option[Int] = {
327+
queue.dequeueOption match {
328+
case None => None
329+
case Some((current, _)) if current.value == inputCase.target => Some(current.steps)
330+
case Some((current, nextQueue)) =>
331+
val newValues = inputCase.buttons
332+
.map(button => transform(current.value, button))
333+
.filterNot(touched.contains)
334+
335+
val newTouched = touched ++ newValues
336+
val newStates = newValues.map { newValue => State(newValue, current.steps + 1) }
337+
val newQueue = nextQueue.enqueueAll(newStates)
338+
339+
loop(newQueue, newTouched, inputCase)
340+
}
341+
}
342+
343+
def resolveCase(inputCase: InputCase): Int = {
344+
val initial = inputCase.target.map(_ => '.')
345+
loop(Queue(State(initial, 0)), Set(initial), inputCase)
346+
.getOrElse(throw new RuntimeException("Answer not found"))
347+
}
348+
349+
val total = parseInput(input).map(resolveCase).sum
350+
println(total)
351+
}
352+
353+
def part2(input: String): Unit = {
354+
def resolveCase(inputCase: InputCase): Int = {
355+
val allMoves = inputCase.buttons.toSet.subsets().toList.map { set =>
356+
set.foldLeft(IndexedSeq.fill(inputCase.joltage.size)(0) -> 0) { case ((acc, cost), button) =>
357+
val newVector = acc.zipWithIndex.map { case (x, index) =>
358+
if (button.contains(index)) x + 1
359+
else x
360+
}
361+
newVector -> (cost + 1)
362+
}
363+
}
364+
365+
// min moves to switch the given joltages to 0
366+
var cache = Map.empty[IndexedSeq[Int], Option[Int]]
367+
368+
def f(joltage: IndexedSeq[Int]): Option[Int] = {
369+
if (joltage.forall(_ == 0)) Option(0)
370+
else if (cache.contains(joltage)) cache(joltage)
371+
else {
372+
val choices = allMoves.flatMap { case (delta, cost) =>
373+
val newJoltage = joltage.zip(delta).map( (goal, diff) => goal - diff)
374+
375+
if (newJoltage.forall(_ >= 0) && newJoltage.forall(_ % 2 == 0))
376+
f(newJoltage.map(_ / 2)).map(res => cost + (res * 2))
377+
else
378+
None
379+
}
380+
381+
val best = choices.minOption
382+
cache = cache + (joltage -> best)
383+
384+
best
385+
}
386+
}
387+
388+
f(inputCase.joltage).getOrElse(throw new RuntimeException("Answer not found"));
389+
}
390+
391+
val total = parseInput(input).map(resolveCase).sum
392+
println(total)
393+
}
394+
395+
val input = readInput()
396+
part1(input)
397+
part2(input)
398+
```
9399
## Solutions from the community
10400

11401
- [Solution](https://github.com/merlinorg/advent-of-code/blob/main/src/main/scala/year2025/day10.scala) (and

0 commit comments

Comments
 (0)