|
| 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 | + ) |
0 commit comments