In “Beacon Scanner”, the Advent of Code day 19 puzzle, we are to make sense of a series of scanner logs. This post is literate Haskell with a “few” imports to get the ball rolling.
import Control.Applicative (liftA2)
import Control.Arrow ((***))
import Control.Lens
import Data.Maybe (mapMaybe,listToMaybe)
import Control.Monad (guard,join)
import Data.List (delete)
import Data.List.Split (splitOn,wordsBy)
import qualified Data.IntMap as IMap
import qualified Data.IntSet as ISet
import qualified Data.Set as Set
The logs are given as a list of blips, as seen relative to the scanner’s position and orientation. The scanners have a Chebyshev range of 1 000 that’s mostly irrelevant to the solution process. Still, we’ll be shifting coordinate systems around a lot, so let’s cover our bases.
import Linear.Matrix
import Linear.V3
import Linear.Vector
manhattan :: V3 Int -> V3 Int -> Int
= ((sum . abs) .) . subtract manhattan
Scanner logs are provided in an ad-hoc format for which the following parser works—trust me on this; it’s really not the most interesting part of the puzzle.
data Scanner = Scanner
scannerId :: !Int
{ scannerBlips :: [V3 Int]
,
}
parse :: String -> [Scanner]
= map (parseScanner . lines) . splitOn "\n\n" where
parse :t) = Scanner (parseHeader h) vs
parseScanner (hwhere vs = map (toV3 . parseV) t
words -> ["---","scanner",n,"---"]) = read n
parseHeader (= zero & partsOf traversed .~ es
toV3 es = map read . wordsBy (== ',') parseV
Judging by the Twitter Zeitgeist at time of solving, generating the
set of 24 possible orientations was not an obvious endeavour. My
approach is to pick a first axis (dir1
) from a canonical
3-basis, a distinct second anonymous axis, generate two directions
([id,negated]
yielding v1
and v2
)
for each and deduce the third basis vector by cross product.
orientations :: [M33 Int]
= do
orientations <- basis
dir1 <- [id,negated] ?? dir1
v1 <- [id,negated] <*> delete dir1 basis
v2 pure (transpose (V3 v1 v2 (cross v1 v2)))
It’s not often I get to wield (??)
; please hold on for a
minute while I enjoy the moment.
Thank you for your understanding.
Now comes the core operation: given two scanner logs, can we decide whether they overlap, and by how much? The simple answer comes from the problem statement: we want to try to match them over every possible pair of orientations the scanners may have. But that’s a bit much: we can reduce it to every possible (single) relative orientation. For each of those, we need to find candidate offsets, and see whether or not subtracting it to one side of the blips brings twelve of them in the same position as blips from the other side. How do we get likely candidate offsets? By trying every pair of points, one per scanner.
It’s tractable: that’s 24 orientations times around 26 blips on one radar times say 27 blips on the other for the orientation-offset candidate, then up to 26 blips to transform and check for presence on the other side. On top of the already quadratic-by-default pairwise scanner matching algorithm.
There’s a huge speedup to gain by optimizing the common case of a scanner mismatch: what property can we find to quickly be in a position to say: “these two scanners can’t match”? A simple one would be a internal measure of shape. For example we can measure all distances between blips seen by each scanner. There are twelve to find in common: those would translate in 66 (no self, no duplicates) to 144 (selves and duplicates, easier code) distances to find in common between two scanners.
fastFilter :: MonadFail m => Scanner -> Scanner -> m ()
fastFilter s1 s2| bagIntersect ds1 ds2 >= (144 :: Int) = pure ()
| otherwise = fail "Not enough internal similarity"
where ds1 = innerDistances s1
= innerDistances s2
ds2 = (sum .) . IMap.intersectionWith min
bagIntersect = IMap.fromListWith (+) . flip zip (repeat 1) .
innerDistances . scannerBlips join (liftA2 manhattan)
I’ll extend it to a fast (on average) reliable (always) scanner overlap checker. In case of a match, I return the offset and orientation matrix—the second scanner’s basis expressed in the first one’s coordinates.
checkOverlap :: Scanner -> Scanner -> Maybe (M33 Int,V3 Int)
= listToMaybe $ do
checkOverlap s1 s2
fastFilter s1 s2<- orientations
m let s1' = scannerBlips s1
= (m !*) <$> scannerBlips s2
s2' <- s1'
v1 <- s2'
v2 let v = v1 - v2
= (+ v) <$> s2'
s2'' length (filter (`notElem` s1') s2'') >= 12)
guard (pure (m,v)
I can now go through all scanners and try to connect them to those I already grouped together. As the resulting graph is necessarily connected, a simple DFS suffices.
connectAll :: [Scanner] -> (Set.Set (V3 Int),[V3 Int])
=
connectAll scanners head scanners)]
dfs ISet.empty [(identity,zero,where
= mempty
dfs _ [] :q)
dfs cl ((m,v,s)| scannerId s `ISet.member` cl = dfs cl q
| otherwise = localInfo <> dfs cl' (q' ++ q)
where cl' = ISet.insert (scannerId s) cl
= mapMaybe (toNext =<< checkOverlap s) $
q' filter ((`ISet.notMember` cl') . scannerId) scanners
= (Set.fromList ((\b -> m !* b + v) <$> scannerBlips s),[v])
localInfo = (\(m',v') -> (m !*! m', m !* v' + v,s')) <$> mb toNext mb s'
The aggregated information per node is:
- the set of blips, normalized to first scanner’s viewpoint
- the scanner’s origin
The set of blips is useful in counting the total number of visible beacons:
part1 :: Set.Set a -> Int
= Set.size part1
The scanners’ origins are useful in finding out the biggest pairwise distance:
part2 :: [V3 Int] -> Int
= maximum . (liftA2 manhattan =<< id) part2
A simple wrapper binds it all.
main :: IO ()
= interact $ show . (part1 *** part2) . connectAll . parse main
This concludes today’s solution. See you tomorrow!