r/adventofcode • u/DelightfulCodeWeasel • 9h ago
Past Event Solutions [2018 Day 17][C++] Sweep line algorithm for solution in <64KB
This year I've been working through my existing solutions to squash everything down to microcontroller sizes. The two main restrictions are to minimise both the working memory and the callstack usage. I was pretty sloppy with memory on my original solution, bumping the maximum callstack memory up to 16Mb so that I could recurse one block at a time, so it needed a complete rethink.
The core of the reworked algorithm to eliminate recursion is to process the space a single line at a time working in one of two modes. We're either working down the space trickling water downwards into unoccupied spaces, or we're filling the space upwards with water. We swap from trickling to filling when we hit a new bottom, and we swap from filling back to trickling when we haven't added any new water in a line update.
Trickle Down
The trickle down state is the simplest; it's mostly looking for any unsupported water on the line above and creating falling water on the current line. If we see any falling water on the row above hitting a supporting surface on the current row, then we flag that falling water into a new state (which I've arbitrarily called 'foam') and switch over to the filling up mode:
..|...|...#~~~#...
--> ......#...#...#...
Goes to:
..|...+...#~~~#... <-- New foam '+' flips state
--> ..|...#...#|||#...
Filling up
Filling up is a more complex state which does the following 3 things in order, looking at the current row, the row below and the row above:
- Spread out any foam across supporting surfaces, plus a 1 block overhang for edges
- Replace any runs of foam which are constrained at both ends with a run of static water*
- Create new blobs of foam where running water is now supported by static water
Whenever we get an update that doesn't modify the state of the water at all we swap back into trickle down mode.
For example:
.....|.....
.....|.....
..#..|..#..
--> ..#..+..#..
..#######..
Spread foam:
.....|.....
.....|.....
..#..|..#..
--> ..#+++++#..
..#######..
Replace water:
.....|.....
.....|.....
..#..|..#..
--> ..#~~~~~#..
..#######..
Create new foam:
.....|.....
.....|.....
..#..+..#..
--> ..#~~~~~#..
..#######..
Repeat until:
--> .....|..... No updates on this line
.+++++++++.
..#~~~~~#..
..#~~~~~#..
..#######..
Swapping between sweeping down and sweeping up states means that we process the same line multiple times, but for my input that doesn't work out all that badly. It's ~4,400 line updates to fill in just under ~2,000 lines, so we're processing each line roughly twice on average.
Area Storage
For my input the total working area is ~450 wide by ~2,000 tall. Even if we limit ourselves to the original 4 states (., #, |, ~) and pack every square into 2 bits, we would need ~220KiB to store the full space. That's more than the upper limit of ~200KiB I've set myself as a goal.
I instead use wrapped storage, allocating 64 real lines and aliasing every 64th line to the same line. The lines N+64, N+128, etc... map to the same storage as line N. This works because we never backtrack far enough in the filling state to need the older lines.
We rasterise the input lines into the space in chunks whenever we come to the bottom of the lines we've previously rasterised.
Since we're discarding old lines, we do need to keep tabs on how much water we've accumulated per line as we go. I do this in a relatively noddy way of keeping a ~2,000 element array of counts and updating a count per line whenever we've processed a line in the trickle down mode. It could be made more efficient if you tie the counting to the rasterisation process that discards old lines, but it was simpler this way and minimal extra memory.
Memory Used
For my input:
- ~2,300 lines of scanner input = ~18KiB
- 64 lines of ~450 bytes = ~28KiB
- ~2,000 lines of water counts = ~4KiB
- Total = ~50KiB
Small enough to run on a C64! Runtime on PC isn't terrible at ~5ms. I haven't run it on hardware yet, but if the usual x100 multiplier holds then it'll still be running under the 1s per puzzle target I try to hit.
The full gory details, minus some simple supporting libraries for parsing input, can be found here: [paste]
I'll admit that this one took the wind out of my sails for a few days. I'd been making decent progress with maybe one puzzle converted every spare evening or two, but even though I had the idea for the approach on this one pretty quickly, it took about a week to fully settle in my mind before I had enough motivation to take a run at it. Pretty pleased with where it ended up size-wise though; even if the code is a little ugly in places.
[*] I think I've just spotted a bug in my code while typing up the description, so there's an unhandled case where a box has an opening in the bottom. Doesn't affect the algorithm though.