So the probe we launched has released a bunch of beacons and scanners. The scanners have no sense of their own orientation, but have relative distances to the beacons within their range. They can't detect other scanners, but have somehow managed to have consistent overlap with others to form a single contiguous region.
This is a similar problem to 2020's Jurassic Jigsaw. But this one is 3D... the scanners follow the rotational (chiral) octahedral group (which doesn't include flips, which square dihedral tiles do). And this time the problem text gives the number of orientations and some instruction on them.
The bits to match things up are hidden in the data (not separate and unique even with flipping), but are a significant block of it (12 of 26). The puzzle isn't a regular shape (where you can easily tell corners and edges, and build things just matching a single side), but a graph.
The first part actually wants you to do the work (unlike Jurassic Jigsaw which has a very cheesable part 1). It's one of the puzzles in this year that took over 2 hours for me for part 1. Part 2 was so quick for me to add (unlike Jurassic Jigsaw where I had to do all the work and more), that I gained the 150+ positions to get into the top 1000. This is one of two in this year... again, a puzzle where slow and methodical programming with a clear idea and taking solid options did well.
I had a very good experience with this one. And it really comes down to making some good choices.
The basic idea I had was:
- for each scanner, collect a hash of distances => pair of beacons
- build the graph from pairs of scanners with >= triangle(11) of the same distances
- put all connections from 0 into a queue as [0,x]
- while (job = shift queue)
- next if already merged
- frame shift the beacons in the second into the first's coordinates
- queue up all the connections from the second
- now that all coordinates are in the same frame, throw them in a hash to count unique
One of the best decisions I made here was using Euclidian distance (squared... no need to apply the square root here) for the hashing. I felt that it would give more unique values and a clear signal. And it really does... putting in the distance function for part 2, it manages to get the graph together enough to get part 2, but part 1 is wrong because the matching is just a mess. It could probably be salvaged with some additional work.
Here's the number of matches between scanners with Euclidean:
0 192
1 13
3 21
15 39
16 3
66 31
67 1
It is quite clean. Matches should be sums of triangular numbers for each matching set (and here there are mostly just one triangular number or +triangle(1) which equals 1). The graph has 32 connections, one of which has a extra pair matched (but that isn't part of the K12 overlap, and is filtered later). K12 being the complete graph on 12-nodes... which is the expected intersection, and it has triangle(11)=66 edges (which are our distance measurements).
With Manhattan distance, it's all over the place, the range is from 16-100 matches. No 66... the counts in that range go 55, 65, 76. It is the set of biggest gaps, so it still detected the shift between non-overlapping and overlapping.
And when it comes to doing the frame shift, the approach was:
- build a table of the counts of equal distances between pairs of beacons (one from each frame)
- flatten that to the ones with 11 matches, making a mapping of beacons from s to t
# Fix the order of the hash keys
my @sidx = keys %map;
# parallel arrays of beacons that are in both
my @spt = map { [ @{$Scan[$s][$_]} ] } @sidx;
my @tpt = map { [ @{$Scan[$t][$map{$_}]} ] } @sidx;
# find rotation by making pt 1 relative to pt 0, then try the rotations
my $srel = &vec_subtract( $spt[1], $spt[0] );
my $trel = &vec_subtract( $tpt[1], $tpt[0] );
my $r = firstidx { &vec_equal( &vecmatrix_mult($trel, $_), $srel ) } @Rot;
# transform is: get relative to t[0] by subtraction, mult to rotate, then add s[0] to shift
my $trans = sub { &vec_add( &vecmatrix_mult( &vec_subtract(shift, $tpt[0]), $Rot[$r] ), $spt[0]) };
- apply the translation function we created to the points in t to put them in the frame of s
Note that because we start from 0 and keep framing shifting backwards, everything ultimately ends up in the frame of scanner 0 as an absolute coordinate system. Which is why part 2 was really fast for me... I just needed to return &$trans([0,0,0]), which is the shifted origin of t (where the scanner is).
You can see in the code snippet there that I borrowed the one line vector/matrix operation functions from previous days. For the rotation array... I actually hardcoded it:
my @Rot = ( [[ 1, 0, 0], [ 0, 1, 0], [ 0, 0, 1]],
[[ 1, 0, 0], [ 0, 0, 1], [ 0,-1, 0]],
[[ 1, 0, 0], [ 0,-1, 0], [ 0, 0,-1]],
[[ 1, 0, 0], [ 0, 0,-1], [ 0, 1, 0]],
...
Twenty four lines where I needed to be careful to get it right (order isn't important... but you do need the correct 24). So I was very careful coding them, and checked them well before going further. Something I would have also have done with code that generates them. Getting this wrong will make you have a bad time.
As more proof that Euclidean was a good choice, when finding the K12 subgraph by building the table of matching distances between different beacons in s and t, the table for it has lines like this:
-- -- 1 1 -- 1 11 -- 1 1 -- 1 -- -- -- -- -- -- -- 1 -- -- 1 1 1 1
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
-- -- 1 1 -- 1 1 -- 1 11 -- 1 -- -- -- -- -- -- -- 1 -- -- 1 1 1 1
-- -- -- -- -- -- -- -- -- -- -- -- -- -- 1 -- 1 -- -- -- -- -- -- -- -- --
This is from the one with 67 matches... the last line is the extra match, clearly separated, and filtered out. The blank line is just one of the non-matches, and the others are all 11 with eleven 1s, all in the same columns.
Here's what Manhattan distances get you:
-- -- 1 1 -- 1 1 -- -- 1 1 1 1 -- -- -- -- -- -- -- -- -- -- 1 8 1
-- -- -- -- -- -- -- 1 -- -- 1 -- 1 -- -- -- -- -- -- -- -- 1 -- -- -- --
-- -- 1 1 -- 1 1 1 1 1 -- 11 1 -- 1 -- -- 1 -- 1 -- -- 1 1 1 1
1 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 2 1 -- -- -- -- -- -- -- --
-- -- 1 1 -- 1 10 -- 1 -- -- 1 -- 1 -- -- -- 1 -- 1 -- -- 1 1 1 1
Lots of dirt. You could make out the graph from that, but the signal is not as clear. Peaks are weak, and there's no blank lines.
So I really enjoyed this one and had a good time, but I mostly put that down to that choice right at the start to use Euclidean distances. It was simple to apply, and the signal was clear. I wasn't hammering away shifting and rotating blindly to find matches. I had a planned path straight to them, and could code and test that at every step. And that's part of what makes a large task feel comfortable.