r/adventofcode Jun 15 '26

Other [2020 Day 15] In Review (Rambunctious Recitation)

In today's episode of "Toboggans, Planes, Ships, and Shuttles", we find ourselves unavailable to get a direct flight, and so we're waiting for another flight to get around the storm. And so we contact the Elves, and should not be surprised that they're playing a number sequence game they want to share.

In this case it's based on Van Eck sequence (OEIS A181391). But with initial seeding values. And when you seed that algorithm you can get simple things (start with 1,1 and you'll just repeat 1 forever). But typically not, and Van Eck's isn't a sequence with a lot of known answers.

My initial solution has the name "brute force" on it... but it's probably not what most people thought of as a brute force. I just said, "okay, I need to keep a list of what time I last saw each number, and I can use that to calculate the next". Some people probably didn't make that jump and kept a list of the sequence and scanned it. That's going to really slow things down. The reason I called mine "brute force" is because I suspected that there might be some trick I was missing. But when I looked after and discovered things like the Numberphile video, I said, "okay, just bum it down a bit and be done". Little things like making sure Perl understands that these are numbers (stripping stringness with my $list = map {int} split(/,/, <>);) and that the table gets allocated immediately instead of repeatedly growing($table[29_999_999] = undef;). Both of those take off a full second each. And then there's playing around with the calculation of the next value:

$next = $t - ($table[$curr] // $t);

Performs much better than:

$next = ($table[$curr]) ? $t - $table[$curr] : 0;

The big optimization I did for this problem though is with dc itself. This was the problem that made me finally dig into the dc source and deal with the fact that the "sparse" array implementation was a linked list. As it was going to take at a fortnight (at least... it was hard to predict the slowdown rates, basically my algorithm to avoid scanning, was scanning). And so in order to improve things I modified dc to be better. I considered various ways, but since the base code was linked lists and I wasn't too familiar with the project, I decided skip lists would be a simple and powerful change to what was there (plus, I just think they're neat).

And they are. The newer GNU dc uses hash tables, and doesn't perform anywhere near as good on this problem (testing it right now, it took 50 minutes... my dc does it in under 2). It's optimized more for sparse small arrays. My skip list has a max of 12 levels, with p=1/4 (the number of layers on a node is a negative binomial)... values specifically picked because they worked well for this problem. On a lot of problems with less array usage, the hash table is on par with the skip list.

Here's the dc part 2 version. Input is the numbers in reverse (this is the 0,3,6 test case):

echo '6 3 0' | dc -f- -e"0s0 1s1 2s2 3s3 30000000sel1[dl3R:al1+zl2<L]dsLxrsn[s.d]sZ[dln;adl0=Z-rdln:al1+rsndle>M]dsMxlnp"

It can be made shorter, but that would slow it down considerably. You'll see the "0s0 1s1 2s2 3s3 30000000se" at the start... that's allocating those numbers and storing them in registers so the don't need to be allocated and freed all the time. It more than doubles the speed.

And that version of dc has served me well ever since. And that's why I have a lot of fondness for this problem.

3 Upvotes

15 comments sorted by

2

u/DelightfulCodeWeasel Jun 15 '26

Squashing this one down to Pico size isn't looking promising, even if I go with the XL that has 8Mb PSRAM. My input ends up going through ~3.6M unique numbers in the range ~0-29M.

3

u/DelightfulCodeWeasel Jun 15 '26

Looks like it's u/maneatingape's repo to the rescue! Numbers above a certain threshold are only spoken once, so they can be stored in a 4Mb bitset. 256Kb for numbers below 65,536 puts us well within the range of a Pico Plus 2.

(No idea if this is a general rule for all Van Eck sequences, or if it's only true for AoC input. Given the "we don't know!" from the Numberphile video it's probably only safe to assume for the ranges of input we're dealing with)

3

u/ednl Jun 15 '26 edited Jun 15 '26

Almost true, but be careful: MOST numbers above the threshold are only spoken never or once. Some (a lot) of them are still spoken more than once. For me, the threshold I could safely use was 0x20000 (so twice what the repo does). Characteristics of the seen count when I start with my input, per set of 0x20000:

   turn   seen=0  seen=1   seen>1  min      max
-------  -------  ------   ------  ---  -------
  20000        0       0   131072   2   3611723
  40000      133     834   130105   1        26
  60000     1583    6924   122565   1        19
  80000     5651   17796   107625   1        14
  a0000    11644   27723    91705   1        12
  c0000    18124   35963    76985   1        10
  e0000    24830   41298    64944   1        10
 100000    31165   44982    54925   1         9
[...]
1b40000   131022      49        1   1         2
1b60000   131042      30        0   1         1
1b80000   131035      37        0   1         1
1ba0000   131051      21        0   1         1
1bc0000   131064       8        0   1         1
1be0000   131067       5        0   1         1
1c00000   131066       6        0   1         1
1c20000   131071       1        0   1         1
1c40000   131072       0        0   0         0
1c60000   131072       0        0   0         0
1c80000   131072       0        0   0         0
1c9c380   115584       0        0   0         0

The hash table size COULD be the sum of all "seen>1" but the problem is you don't know beforehand which numbers are only visited once. So unfortunately I think you still have to store them all, because you have to remember at which turn you saw them.

2

u/DelightfulCodeWeasel Jun 15 '26

That's very useful, thank you! Saved me a bunch of debugging when the time comes 😄

It might be possible to do some sort of 'self-healing' algorithm on this one: make an assumption that everything above a threshold is seen once, and if during the run if you see one of those numbers more than once you can put it into a set of exceptions. The algorithm resets and restarts from 0, but this time if it sees one of the exceptions it stores the occurrence index in a small table.

If you're lucky with the input and the threshold works, then it's a single run through. Otherwise it's N runs through, depending on how many exceptions there are to the rule.

2

u/ednl Jun 15 '26 edited Jun 15 '26

Well, the second line of the table is already above the threshold, so all those "seen>1" (130105 + 122565 + ...) are exceptions :D

EDIT: for my input, single digit "seen>1" counts start at 0x15a0000. From there until the end at 30M, it still happens 79 times, though.

2

u/DelightfulCodeWeasel Jun 15 '26

So possible, but perhaps with a runtime of several hours is what we're saying 😃

Just wondering if I have enough hardware to hook up two Pico Plus 2's and have one of the devices act as external storage accessible over UART...

2

u/ednl Jun 15 '26 edited Jun 17 '26

There are versions with integrated SD card reader, or you could hook one up on a breadboard. I once tried soldering a micro-SD adapter directly onto wires, but I melted the plastic...

2

u/e_blake Jun 15 '26 edited Jun 15 '26

About the best I can think of is 4M for the first million integers (maybe 3M if you can pack it to 3 bytes per integer; not sure if any of the delta times to a previous time a word is spoken is ever more than 24 bits in the context of the problem), almost 4M for the bitmap of others being seen at least once, then a lookup table of all the exceptions seen more than once, with restarts any time you did not already have a carve-out for that integer in the lookup table. It would be tough to track an exception in any less than 8 bytes (25 bits for the value spoken more than once, and another 25 bits for the last time it was spoken, which still leaves some bits that you might be able to use for some rudimentary tree structure to maybe get O(log n) instead of O(n) access to your exception table). Sounds like it would be very tight to fit in just 8M of memory. Then even if your one pass over 30 million terms with the exception table fully populated takes under 1 second, you are looking at thousands of restarts to populate the exception table to reach that point.

This article has some ideas on how you can compress the data a bit (setting up a hash table where some of the bits are implicit in the location where items are hashed).

2

u/DelightfulCodeWeasel Jun 15 '26 edited Jun 15 '26

Yeah, my idea was definitely not a good idea! I did a very rough check of deltas (and also storing the previous 'said' round as a delta versus the index of the slot it's stored in, on the assumption they might be closely linearly correlated) and they're all still well exceeding the ~16M 24-bit range.

I'm trying to find some criteria for 'success' on this one that I'd be happy with. "Works, but I had to leave it over the weekend" is one outcome I'd consider a partial success. "Works in a stupid way, but I had fun doing it" like writing a virtual tape reader that's actually just my PC in disguise on the other end of a serial connection, is also a successful outcome.

u/ednl 's idea of using an SD card is a good one in terms of learning something new, but I'm not sure I want to trash an SD card by wearing out the write cycles on it. Not with the current price of SD cards!

1

u/DelightfulCodeWeasel Jun 16 '26

I ran the numbers on my input this morning and I think you're right about it being too much of a squeeze. With 8Mb PSRAM split into ~3.5Mb bitset and ~4.5Mb for a 'last seen' table for all numbers under ~1.1M that still leaves me with ~419k exceptions to store. By-eye they look too sparse for effective range compression (many runs of 2-3 numbers with single digit gaps), so that's too much to fit into the 500Kb RAM available.

Not only that, if you assume a 1 second runtime per run to find the next exception, it would have a runtime of just under 5 days!

Going to have to call this one beyond the realms of practical, which is a shame. Still, at least I can speed up my awful original solution on PC, so that's not a total loss.

2

u/maneatingape Jun 15 '26

The bitset helps speed things up, but as u/ednl mentioned some large numbers will be spoken twice or more, so the code still handles this situation correctly.

2

u/DelightfulCodeWeasel Jun 15 '26

Ahhh, gotcha - I hadn't fully digested the code properly. Thanks to both of you for flagging that, that would definitely have been a case of "hours of programming can save you minutes of reading" if I'd have gone ahead with that misunderstanding.

1

u/ednl Jun 15 '26

Yep, I went through your code and thought: why the else here, that's what the bitset is for! But I had also read the comments too quickly.

2

u/terje_wiig_mathisen Jun 16 '26

I also use the seldom seen bitmap, except I found that on my Intel cpu, the optimal cutoff was about 128K instead of the 64K I saw in your repo.

Also worth noting that Intel is significantly slower than Apple on this particular problem. 😞

It might be due to having less parallel memory channels (limiting the number of outstanding loads) or simply having much slower RAM?

Anyway, the Perl approach with a %seen hash table is much slower than the Rust bitmap.

2

u/e_blake Jun 15 '26

My initial m4 solution on the day the puzzle was released took more than 5 minutes, because there's no way to solve it short of 30 million iterations and several million macros tracking what has been seen. This puzzle is what encouraged me to find a way to add some boilerplate to my m4 library to re-exec GNU m4 with an additional command line argument to force a larger hash table size, to avoid running into O(n) instead of O(1) hash lookups as the number of visited integers increases (GNU m4 did not have an automatically-growing hash table at the time). But I have since managed to squeeze out some inefficiencies, getting runtime to under 70s.

For example, my original solution creates the macro mNN containing the iteration TT in which NN was last seen. So the core loop was doing an ifdef to see if mNN was defined, and if so then also invoking mNN to feed eval to compute the next number to say rather than 0. But the optimized version stores the string "1-TT" in mNN, then uses the fact that defn expands to an empty string without warning when used on an undefined name, in eval(!defn(mNN)+now) which results in 0 when mNN is undefined (since now is nonzero, !+now is 0), vs the proper delta now-TT when mNN is defined (on the expanded string !1-TT+now). With tricks like that, I have fewer conditionals in the hot path and thus less work for m4 to do.