Advent of Code 2025 - Day 11: Reactor
Day 11 was about finding paths through a directed graph. Part 1 was straightforward DFS pathfinding. Part 2 looked like it needed the same approach, but brute force was too slow - the solution required combinatorics and memoization.
Part 1: Count All Paths
Given a graph of devices with directed connections, count all possible paths from you to out.
Example:
|
|
Paths: you→bbb→ddd→ggg→out, you→bbb→eee→out, you→ccc→ddd→ggg→out, you→ccc→eee→out, you→ccc→fff→out
Total: 5 paths.
Why My Existing DFS Didn’t Work
I already had a dfs_graph() function in my helper library:
|
|
This returns which nodes are reachable, not all paths to reach them. It visits each node once and tracks what’s reachable. Perfect for “can I get from A to B?” but useless for “how many ways can I get from A to B?”
The New Function: dfs_all_paths
I needed to find all paths, not just reachable nodes:
|
|
Key differences:
- Returns list of paths instead of list of nodes
- Builds path as it recurses:
path = path + [start] - Base case: if we reached
end, return this complete path - Recursive case: explore all neighbors, collect all paths from each
Custom File Parser
The input format was specific, so I wrote a parser instead of generalizing my loadFile():
|
|
Input: you: bbb ccc
Output: graph["you"] = ["bbb", "ccc"]
First try!
Part 2: Paths Through Two Waypoints
Now find paths from svr to out that visit both dac and fft (in any order).
The Brute Force Trap
My first instinct:
|
|
This works in theory but is very slow. For large graphs, there could be thousands or millions of paths, and we’d need to generate and check every single one.
The Insight: Multiplication Principle
After drawing the example on paper, I realized you can break this into segments:
Route 1: svr → dac → fft → out
- Count paths from
svrtodac - Count paths from
dactofft - Count paths from
ffttoout - Multiply them together
Route 2: svr → fft → dac → out
- Count paths from
svrtofft - Count paths from
ffttodac - Count paths from
dactoout - Multiply them together
Then add the two routes.
Why Multiplication Works
Let’s trace through the actual test example to see why multiplication works.
Route 1: svr → fft → dac → out
First, count paths for each segment:
svr → fft:
- svr → aaa → fft (1 path)
fft → dac:
- fft → ccc → eee → dac (1 path)
dac → out:
- dac → fff → ggg → out
- dac → fff → hhh → out (2 paths)
Using multiplication: 1 × 1 × 2 = 2 total paths
Why? Because you pick one path from svr→fft (only 1 choice), combine it with one path from fft→dac (only 1 choice), then combine with one path from dac→out (2 choices). That gives you 2 complete paths:
- svr → aaa → fft → ccc → eee → dac → fff → ggg → out
- svr → aaa → fft → ccc → eee → dac → fff → hhh → out
Route 2: svr → dac → fft → out
svr → dac: We need to reach dac from svr. Looking at the graph, there’s no path that goes svr → … → dac without first passing through fft. So: 0 paths
dac → fft: Similarly, once you’re at dac, you can’t reach fft (you can only move forward in this directed graph). So: 0 paths
fft → out: Doesn’t matter, we already have 0 from the previous segments.
Using multiplication: 0 × 0 × anything = 0 total paths
Final answer: 2 + 0 = 2 paths that visit both waypoints.
The multiplication principle works because each segment’s paths are independent choices that must be chained together. If segment A has m paths and segment B has n paths, every one of the m paths in A can be combined with every one of the n paths in B, giving m × n complete paths.
Route 1 (svr→dac→fft→out) and Route 2 (svr→fft→dac→out) are disjoint sets. A single path can’t visit waypoints in both orders. So we add them.
The Memoized Solution
|
|
Why @cache is Critical
Without @cache, we’d recalculate the same paths repeatedly:
|
|
With memoization, count_paths("ccc", "out") is calculated once and reused.
Complexity:
- Without cache: O(exponential) - recalculates everything
- With cache: O(V + E) - each node calculated once
A Critical Bug to Avoid
Initially I tried:
|
|
This fails with TypeError: unhashable type: 'dict'. The @cache decorator needs hashable arguments (strings, numbers, tuples), but dictionaries aren’t hashable.
Solution: Define graph globally before the function:
|
|
First try (after discovering the multiplication insight).
The Lesson
Part 1 and Part 2 both count paths, but the approaches differ:
Part 1: Find all paths explicitly. Manageable for reasonable graph sizes.
Part 2: Use combinatorics to avoid generating paths. Essential when path count explodes.
This is a classic dynamic programming problem. The recurrence relation is:
|
|
The base case is paths(X, X) = 1.
Recognizing that you can break “paths through waypoints” into segments and multiply is the key insight. It transforms an exponential enumeration problem into a polynomial counting problem.
Twenty-two stars total. Graph problems continue to show up, and building a good helper library pays off.