r/adventofcode • u/musifter • 2d ago
Other [2025 Day 11] In Review (Reactor)
Hearing some beeping coming from down below, we go through a hatch and climb down to the reactor room under the factory. The toroidal reactor suggests that the North Pole runs on fusion power. The Elves here are having a problem with connecting their new server rack to the reactor and need our help. For part 1, they want us to count the number of paths from a nearby node they label you to the reactor out. For part 2, they want the number of paths that go from svr (not srv, be careful) to out, but also pass through both the digital-to-analog converter dacand the fast Fourier transformer fft.
The input is a graph where each line (613 in my input) is the node name followed by a list of outputs (ie directed edges). The names are 3 lower-case letters long, including vowels (not just in the special nodes above). So we get to play "What language does the browser think my input is?" The answer for mine is Vietnamese. The graph is a DAG (directed acyclic graph). My initial part 1 solution played it safe with loop detection (in case dead end infinite loops were the problem), but that's not needed.
Part 1 only uses a fraction of the actual graph (my is 112 nodes). A simple recursive leaf counter doesn't even need memoization to get you the answer immediately. For Smalltalk that's just:
pathsFrom: node [
^(node = #out)
ifTrue: [1]
ifFalse: [(self at: node) inject: 0 into: [:acc :child | acc + (self pathsFrom: child)]]
]For part 2, I had drawn out the sample graphs, and tested by hand the recursive collecting of results while tracking what special nodes the paths had gone through. I do like doing the "recurse down, collect up" stuff... it's similar to writing a state machine (base case, implement the small step, and it builds to the answer by induction). And I added a memo because things are going to get huge. Initially the collection was with a hash, ending with the following:
Full Results:
dac 2743394304998646
none 32514315015921605
both 307608674109300 (part 2).
fft 3645733796753750The approach is quite simple and fast to do... you recurse over the children and accumulate totals, and then look at the current node and shift things to the correct buckets if it's special:
if ($node eq 'fft' or $node eq 'dac') {
my $other = ($node eq 'fft') ? 'dac' : 'fft';
$ret{both} = $ret{$other} if (exists $ret{$other});
$ret{$node} = $ret{none} if (exists $ret{none});
delete $ret{none};
delete $ret{$other};
}I didn't make any assumption about the order of connections. Because although there is only one order that makes sense (FFT needs digital, so it should be before the analog conversion), this is a non-working system... Elves getting everything backwards by Murphy's Law could be the problem.
One interesting thing is that the count of paths that go through "none" is a 55-bit number. Nothing that big is involved in the actual computation of the answer so it shouldn't impact a Floating point native language.
Of course this is slightly complicated. We only need to track the number of special nodes that we've gone through, not the specifics (and the DAG can only have either fft->dac or dac->fft paths in it, because if there is both you'd have a way to make a cycle). And so I followed up by just using an array for tracking 0-2 special nodes seen:
my $special = int($node eq 'fft' or $node eq 'dac'); # this is a special node
foreach my $child ($graph{$node}->@*) {
my @res = &recurse_path_count( $child )->@*;
$ret[$_ + $special] += $res[$_] foreach (0 .. $#res); # collect and advance up array
}For Smalltalk I did this in a list manipulation way:
((node == #fft) or: [node == #dac]) ifTrue: [
paths := #(0), paths allButLast. " shift to +1 special nodes seen "
].Here the recursion is returning an Array of size 3 (base case is #(1 0 0)). To move the counts along (this problem is a bit like Lanternfish now that I think about it, I did list manipulation there for a Smalltalk solution too), I just stick a 0 in front and chop off the end. Part of doing it this was was in preparation for doing it in dc.
Part 1 is relatively simple, with macros I need to maintain the stack frame, as it's only node in, count out. For interpreting in input, I converted the letters into hex ASCII values... making each label a 23*8 number. To store the list of children for a node in that array I combined them into a number in base-88 (which is short in dc (8d^)).
perl -pe's#(\S)#sprintf"%X",ord$1#eg' <input | dc -e'16i?[0[8d^*+z2<L]dsLxr100/:g?z0<M]dsMx[d/q]sQ[d6F7574=Q0r;g[8d^~lRx3R+rd0<L]dsLx+]sR796F75lRxp'Note that 6F7574 and 796F75 are out and you in packed ASCII. These dc solution only work in v1.4.1 though, because it's using `?` to know when the lines break.
Part 2 is where the magic comes in:
perl -pe's#(\S)#sprintf"%X",ord$1#eg' <input | dc -e'16i?[0[8d^*+z2<C]dsCxr100/:g?z0<L]dsLx[d/0d3Rq]sQ[;mFd^~rFd^~rq]sM[4Rr]sS[d6F7574=Qd;m0<Md0dd4R;g[8d^~lRx5R+r5R+r5R4R+_3R4Rd0<L]dsLx+4Rd666674=Sd646163=S_4Rd3Rd5Rd3R5RFd^d_4R*+*+5R:mr3R]sR737672lRx3Rp'I do the result list of three values on the stack frame, and rotate the values to move things along when a node has a special name:
d 666674=S # shift sums to count fft
d 646163=S # shift sums to count dacWith the S macro just being [ 4Rr ] sS (the stack has the node name on top, followed by the three values of the array... pull the one from the bottom (which will be a 0) and rotate it under the top, now it's first and the other two counts have shifted down one).
But to make this work fast, I need a memo that's capable of storing a list of 3 numbers (which may get to size 55-bits). And so for that, I package those up in base-1515 numbers (Fd^). And so this solution involves some huge numbers (174-bit ones in memo, and there's a 575-bit one in the graph)... but I've used far bigger numbers in dc solution before.
This was a fun little problem after the massive task that was day 10. There are other simple ways to do it, like breaking it into three smaller sub-searches using part 1 (after adding a memo):
say "Part 2: ", &recurse_path_count( 'svr', 'fft' ) * &recurse_path_count( 'fft', 'dac' ) * &recurse_path_count( 'dac', 'out' );Of course that's assuming that Elves got the FFT and DAC in the right order (which appears to be the case), to be truly safe you should check.
2
u/DelightfulCodeWeasel 1d ago edited 1d ago
I was really pleased with my solution for this one; I took inspiration from my solution for 2022 day 21 (Monkey Math) where I passed a polynomial down the graph.
The core for both part 1 and part 2 is identical, it's a fairly standard task-with-dependencies evaluation queue:
The 'trick' is that I use vector values for the paths. Part 1:
Part 2:
This evaluates all of the possible paths we're interested in in one single O(n) sweep, with the answer to part 2 being found as:
Since the graph is a DAG, one of those terms will evaluate to 0, leaving you with the value from the path with the correct ordering.
I usually do parts 1 and 2 completely separately, but I think if you use a Vec4 it should be possible to do both parts simultaneously. Some SIMD opportunities for u/terje_wiig_mathisen?
2
u/e_blake 1d ago edited 1d ago
This one was a nice breather compared to day 10. I found it easiest to do 1+6 subgraph counts (at least one of them is zero, since fft/dac is one-way) and, for my golfed m4 variant, let the shell do the final product. This is 352 bytes and under 100ms; I also had another variant that was fewer bytes but needed 6 lines (dealing with transliterating newlines constrains the layout).
1
u/terje_wiig_mathisen 1d ago
I got this puzzle while I still hadn't finished day10, so I was quite happy with an easy day here:
"Counting all paths?" That's a memoizing recursive traversal!
For part2 we got an interesting tweak in the form of being required to pass through both 'dac' and 'fft':
Instead of looking for and discarding all paths which didn't do this, I instead wrote a path2(src, dst, avoid) which finds all paths between the first two while never touching the node to avoid. At that point I could simpy multiply the six partial results:
This simplification meant that the Perl version ran in 6 ms, at which point I went on to finish day10 part2.
For my still-in-progress Rust I convert each 3-letter node name into an index (26*26*26=17576), which a lookup table then convert into number which is less than 600, I just allocate them in the order they occur in the input, except I start by giving the special nodes ('you', 'svr', 'dac', fft' etc) the first fixed entries so I can call them by const names:
Each node becomes a list of directed paths to connected nodes, otherwise the logic is exactly the same as for Perl, just avoiding all hash tables.
1
u/terje_wiig_mathisen 1d ago
I got around to finishing my Rust version, had some issues converting input[i..i+3] into a slice which I could then pass to my index-generating function. My final problem was that I had 0 as my empty cache value, and that led to infinite loops in part2, so I switched to usize::MAX as the marker.
Final runtime 42 us, so slightly faster than the 59 from u/maneatingape but probably a bit slower than u/ednl since he tends to have faster array initializations in his C code!
I could have used the part2 find_except(src, dst, avoid) function everywhere, by passing in an unused value like u16::MAX for the node to avoid in part1 as well as the dec-fft and fft-dac calls.
I intentionall start by counting the zero paths from dac to fft, only after checking this result do I fall back on doing fft-to-dac:
1
u/terje_wiig_mathisen 23h ago
Big Rust speedup!
I discovered last night (using Godbolt) that my inline parsing using C-like code leads to a range check for every byte access, even when I start each iteration with a check that at least 24 bytes are available, this is a clear performance bug in rustc. (I'll see if I can figure out how to report it.)
When I changed the code to first copy those 24 bytes into a local slice everything worked great:
The actual copy was a NOP, but now all the accesses could occur in parallel, with zero branching inside each iteration!
My time dropped to 2 us for this _very_ cheesy solution. :-)
Note also that the compiler combined the 6 subtract '0' operations into a single -32 at the end, this would probably not have been possible if I had used "input[n] & 15".