Skip to content

Commit 79763a9

Browse files
committed
feat: aoc 2025 day 11 solution + tests
1 parent ace7352 commit 79763a9

File tree

4 files changed

+238
-0
lines changed

4 files changed

+238
-0
lines changed

_2025/solutions/day11.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Day 11: Reactor
2+
3+
This module provides the solution for Advent of Code 2025 - Day 11.
4+
5+
It models a directed graph of devices and counts how many distinct
6+
paths exist between given endpoints, with optional requirements to
7+
visit specific devices along the way.
8+
9+
The module contains a Solution class that inherits from SolutionBase
10+
and implements graph construction plus memoized depth-first search
11+
for efficient path counting.
12+
"""
13+
14+
import re
15+
from typing import ClassVar
16+
17+
from aoc.models.base import SolutionBase
18+
19+
20+
class Solution(SolutionBase):
21+
"""Count device paths through the reactor wiring graph.
22+
23+
Each input line describes a device and its outgoing connections in
24+
the form 'name: out1 out2 ...'. Data flows only along these edges.
25+
26+
Part 1 counts all distinct paths from 'you' (the device next to you)
27+
to 'out' (the main reactor output). Part 2 counts only those paths
28+
from 'svr' (the server rack) to 'out' that also visit both 'dac'
29+
and 'fft' at least once, in any order.
30+
"""
31+
32+
REGEX: ClassVar[re.Pattern[str]] = re.compile(r"(\w{3})")
33+
34+
def construct_graph(self, data: list[str]) -> dict[str, list[str]]:
35+
"""Parse device connection lines into an adjacency list.
36+
37+
Each non-empty line starts with a three-letter device name
38+
followed by zero or more three-letter device names it connects
39+
to via its outputs.
40+
41+
Args:
42+
data: List of strings describing device connections
43+
44+
Returns
45+
-------
46+
dict[str, list[str]]: Mapping from device name to list of
47+
device names reachable via its outputs.
48+
"""
49+
graph: dict[str, list[str]] = {}
50+
for line in data:
51+
tokens = re.findall(self.REGEX, line)
52+
if not tokens:
53+
continue
54+
55+
node, outputs = tokens[0], tokens[1:]
56+
graph[node] = outputs
57+
58+
return graph
59+
60+
def dfs(
61+
self,
62+
graph: dict[str, list[str]],
63+
node: str,
64+
target: str,
65+
cache: dict[tuple[str, bool, bool], int],
66+
*,
67+
seen_dac: bool,
68+
seen_fft: bool,
69+
require_both: bool,
70+
) -> int:
71+
"""Count paths from current node to target with optional device constraints.
72+
73+
Uses a memoized depth-first search where the state includes the
74+
current node and whether 'dac' and 'fft' have already been seen
75+
along the current path.
76+
77+
Args:
78+
graph: Adjacency list of the device network.
79+
node: Current device name.
80+
target: Destination device to reach (typically 'out').
81+
cache: Memoization dictionary keyed by (node, seen_dac, seen_fft).
82+
seen_dac: Whether 'dac' has been visited so far on this path.
83+
seen_fft: Whether 'fft' has been visited so far on this path.
84+
require_both: If True, only count paths that have visited both
85+
'dac' and 'fft' by the time they reach target.
86+
87+
Returns
88+
-------
89+
int: Number of valid paths from node to target for this state.
90+
"""
91+
if node == "dac":
92+
seen_dac = True
93+
94+
if node == "fft":
95+
seen_fft = True
96+
97+
state = (node, seen_dac, seen_fft)
98+
if state in cache:
99+
return cache[state]
100+
101+
if node == target:
102+
if require_both:
103+
cache[state] = 1 if (seen_dac and seen_fft) else 0
104+
else:
105+
cache[state] = 1
106+
return cache[state]
107+
108+
total = 0
109+
for nxt in graph.get(node, []):
110+
total += self.dfs(
111+
graph,
112+
nxt,
113+
target,
114+
cache,
115+
seen_dac=seen_dac,
116+
seen_fft=seen_fft,
117+
require_both=require_both,
118+
)
119+
120+
cache[state] = total
121+
return total
122+
123+
def part1(self, data: list[str]) -> int:
124+
"""Count all paths from 'you' to 'out' in the reactor graph.
125+
126+
Builds the directed device graph from the input and then runs a
127+
memoized DFS from 'you' to 'out', counting every distinct path
128+
that data could follow through the devices.
129+
130+
Args:
131+
data: List of device connection lines.
132+
133+
Returns
134+
-------
135+
int: Number of distinct paths from 'you' to 'out'.
136+
"""
137+
graph = self.construct_graph(data)
138+
cache: dict[tuple[str, bool, bool], int] = {}
139+
return self.dfs(
140+
graph,
141+
"you",
142+
"out",
143+
cache,
144+
seen_dac=False,
145+
seen_fft=False,
146+
require_both=False,
147+
)
148+
149+
def part2(self, data: list[str]) -> int:
150+
"""Count paths from 'svr' to 'out' that pass through both 'dac' and 'fft'.
151+
152+
Builds the same device graph but now counts only those paths that
153+
start at 'svr', end at 'out', and visit both 'dac' and 'fft' at
154+
least once somewhere along the path.
155+
156+
Args:
157+
data: List of device connection lines.
158+
159+
Returns
160+
-------
161+
int: Number of paths from 'svr' to 'out' that visit both
162+
'dac' and 'fft'.
163+
"""
164+
graph = self.construct_graph(data)
165+
cache: dict[tuple[str, bool, bool], int] = {}
166+
return self.dfs(
167+
graph,
168+
"svr",
169+
"out",
170+
cache,
171+
seen_dac=False,
172+
seen_fft=False,
173+
require_both=True,
174+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
aaa: you hhh
2+
you: bbb ccc
3+
bbb: ddd eee
4+
ccc: ddd eee fff
5+
ddd: ggg
6+
eee: out
7+
fff: out
8+
ggg: out
9+
hhh: ccc fff iii
10+
iii: out
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
svr: aaa bbb
2+
aaa: fft
3+
fft: ccc
4+
bbb: tty
5+
tty: ccc
6+
ccc: ddd eee
7+
ddd: hub
8+
hub: fff
9+
eee: dac
10+
dac: fff
11+
fff: ggg hhh
12+
ggg: out
13+
hhh: out

_2025/tests/test_11.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Test suite for Day 11: Reactor
2+
3+
This module contains tests for the Day 11 solution, which counts
4+
paths through the reactor's device graph. The tests verify:
5+
6+
1. Part 1: Number of distinct paths from 'you' to 'out'.
7+
2. Part 2: Number of paths from 'svr' to 'out' that also visit
8+
both 'dac' and 'fft'.
9+
"""
10+
11+
from aoc.models.tester import TestSolutionUtility
12+
13+
14+
def test_day11_part1() -> None:
15+
"""Test counting all paths from 'you' to 'out'.
16+
17+
This test runs the solution for Part 1 of the puzzle against the
18+
provided test input and compares the result with the expected output.
19+
"""
20+
TestSolutionUtility.run_test(
21+
year=2025,
22+
day=11,
23+
is_raw=False,
24+
part_num=1,
25+
expected=5,
26+
)
27+
28+
29+
def test_day11_part2() -> None:
30+
"""Test counting constrained paths from 'svr' to 'out'.
31+
32+
This test runs the solution for Part 2 of the puzzle against the
33+
provided test input and compares the result with the expected output.
34+
"""
35+
TestSolutionUtility.run_test(
36+
year=2025,
37+
day=11,
38+
is_raw=False,
39+
part_num=2,
40+
expected=2,
41+
)

0 commit comments

Comments
 (0)