Bitmap raster operations are the name of the game for today’s problem. Please ignore my literate Haskell imports so we can jump right in.
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
import Control.Applicative (liftA2)
import Control.Lens (view,_1,_2,(+~))
import Control.Monad (guard)
import Data.Bool (bool)
import Data.Char (isDigit)
import Data.Function ((&),on)
import Data.Ix (Ix,range)
import Data.List (delete,tails,transpose,groupBy)
import Data.List.Split (linesBy)
import Data.Map.Strict (Map,(!))
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Linear (V2(V2),zero)
My input file isn’t too long—144 tiles. I ought to be fine just using lists as a data structure.
type Image = [[Bool]]
The monochrome images come with an ID number. To avoid mixing them up, I’ll pack them together.
data Tile = Tile
tileId :: Int
{ tileImage :: Image
,
}deriving Eq
Now to actually parse the input. I’ll use the same trick as usual to split paragraphs.
parseInput :: String -> [Tile]
= map parseTile . linesBy null . lines parseInput
To parse the Tile
structure itself, I’ll try something
experimental1 to try and answer the great
question: can view patterns be nested?
parseTile :: [String] -> Tile
break (== ' ') ->
parseTile (("Tile",' ' : (span isDigit -> (read -> tileId,":"))))
(: ((map . map) (== '#') -> tileImage))
= Tile{..}
…it seems like they can. I wouldn’t go as far as calling that a good idea, though. Ye gods this is horrendeous! Until someone can show me a visually-pleasing way to format that, I’ll classify this as “never again”.
Ok, so our image was split up in tiles, and the postman tripped on the way so now they’re a mess on the floor. And since they’re square and transparent, there’s no way to know which way to place them so all of them align.
Let’s formalize the various ways it could be messed up.
data Setup = Setup
sTranspose :: Bool
{ sOuterFlip :: Bool
, sInnerFlip :: Bool
,
}deriving (Eq,Ord,Ix,Bounded)
In real life I’d call the enumeration “4 right-angle rotations for 2 sides”. But we’re doing computer processing on lists, I’ll adjust my terminology.
For some reason GHC can’t derive Enum
on this structure,
so I’ll define my “universe” enumerator using Ix
instead.
allSetups :: [Setup]
= range (minBound,maxBound) allSetups
Now I can transform my tile to bring it to and from all of those orientations.
reorient :: Tile -> Setup -> Tile
Setup{..} = tile { tileImage = morph (tileImage tile) }
reorient tile where morph = bool id transpose sTranspose .
id reverse sOuterFlip .
bool id (map reverse) sInnerFlip bool
I chose to keep it in the same type, as there’s no information loss. I need the ID number to remain with it if I want to track it back when I’m done shifting bits around.
Now let’s move towards reassembly. The tiles are going to be arranged
in a grid, so integral-V2
-based cartesian coordinates are
fine.
type Vec = V2 Int
type Pos = Vec
On the other hand, trying all permutations of the tiles in a square patterns seems a bit wasteful. I’ll likely get drastically better performance starting with an arbitrary tile and accreting the others on the fly.
But I can’t know in advance where in the final square the starting
tile will end. So I’ll use a Map
instead of a bidimensional
array.
type Assembly = Map Pos Tile
Today’s neighborhood function only recognizes 4 neighbors per tile.
Please indulge me while I try to go creative with linear
’s
V2
and lenses. I’m attempting to keep up the practice,
here.2
neighbors :: Pos -> Set Pos
= Set.fromList . sequence (liftA2 (+~) [_1,_2] [-1,1]) neighbors
No, it’s not shorter,3 more efficient or in any way better than a simple list comprehension-based implementation. But I welcomed the fun.
I’d better check it actually works.
λ> neighbors (V2 44 55) fromList [V2 43 55,V2 44 54,V2 44 56,V2 45 55]
Looks good :-)
Let’s write a helper to determine whether two tiles can be placed next to each other.
compatible :: Tile -> Vec -> Tile -> Bool
= edge1 (tileImage t1) == edge2 (tileImage t2)
compatible t1 delta t2 where
= case delta of
(edge1,edge2) V2 (-1) 0 -> (top,bottom)
V2 1 0 -> (bottom,top)
V2 0 (-1) -> (left,right)
V2 0 1 -> (right,left)
= head
top = last
bottom = map head
left = map last right
So to reassemble the big square from the tiles, I’ll place the tiles one by one, next to each other, starting with an arbitrary one—the first—, always ensuring the borders of the newly placed one are compatible with the previously placed others. Backtracking through the list monad.
I expect there to be only one solution up to orientations, and the input size is small, so I’m not throwing any optimization in. I pick, in order:
- an empty spot with at least one neighboring spot filled
- a loose tile
- an orientation for it
If it’s compatible with its neighbors, I recurse, else I backtrack.
reassemble :: [Tile] -> Assembly
: tiles0) =
reassemble (startingTile head $ go (Map.singleton zero startingTile) tiles0
where
= pure placed
go placed [] = do
go placed tiles <- Map.keysSet placed &
pos &
Set.map neighbors &
Set.unions `Map.notMember` placed) &
Set.filter (
Set.toListlet nPoss = neighbors pos &
`Map.member` placed) &
Set.filter (
Set.toList
<- tiles
tile <- allSetups
setup let pl = reorient tile setup
$ all (\nPos -> compatible pl (nPos - pos) (placed ! nPos)) nPoss
guard go (Map.insert pos pl placed) (delete tile tiles)
If all goes well, this returns a “floating” map of oriented tiles. The following helper extracts the floating square’s corner ID numbers, for the part 1 answer field.
cornerIds :: Assembly -> [Int]
= tileId . (arr !) <$>
cornerIds arr V2 top left, V2 top right, V2 bottom left, V2 bottom right ]
[ where
= Map.keys arr
ixs = minimum (view _1 <$> ixs)
top = minimum (view _2 <$> ixs)
left = maximum (view _1 <$> ixs)
bottom = maximum (view _2 <$> ixs) right
You may have noticed I’m using the generic _1
and
_2
lenses instead of linear
’s
V2
-specialized _x
and _y
, here
and for the neighborhood function. It’s the clash of worlds: in a linear
algebra world, I’d normally consider X to be the first coordinate,
horizontal and going right, and Y the second, vertical going up. In a
raster world4, I rather see the first coordinate
going down and the second right. I chose to go with the raster vision
all the way, and evade that part of the confusion by using the numbered
accessors.
λ> let assembly = reassemble (parseInput sample) λ> cornerIds assembly [2971,1171,1951,3079]
All good and a gold star!
For part 2 we merge the oriented tiles. I’m somewhat arbitrarily
repacking it in a Tile
so I can reuse the orientation
routines later.
merge :: Assembly -> Tile
=
merge Tile undefined . -- image to tile
foldr1 (++) . -- fuse vertically
map (foldr1 (zipWith (++))) . -- fuse horizontally
map . map) snd . -- drop coordinates
(==) `on` (view (_1 . _1))) . -- rasterize
groupBy ((. -- map to list
Map.assocs fmap tileTrimmedImage -- trim
The tile trimmer is similar in style: composition of simpler functions.
tileTrimmedImage :: Tile -> Image
= trimRight . trimLeft . trimBottom . trimTop . tileImage
tileTrimmedImage where
= init
trimBottom = tail
trimTop = map init
trimRight = map tail trimLeft
Now to locate sea monsters. Let’s make sure we know what we’re looking for.
seaMonster :: Image
= (map . map) (== '#')
seaMonster " # "
[ "# ## ## ###"
, " # # # # # # "
, ]
To count them, I’ll match by following both lists of lists in lockstep. There are two pitfalls here.
- I’m working on lists of lists of
Bool
s. It’s all too natural to reach for zip-like functions, e.g.zipWith (==)
or similar. But those constructs stop on the shortest sequence of those provided. When matching, it has a very different meaning to exhaust the pattern (success) or the input string (failure). I’ll alleviate with a dedicated helper. - The comparison semantics are asymmetrical.
match :: Image -> Image -> Bool
= allMatch row ref input
match ref input where
:as) (b:bs) = p a b && allMatch p as bs
allMatch p (a= True -- out of pattern, success
allMatch _ [] _ = False -- out of input, failure
allMatch _ _ _
= allMatch pixel
row
False = const True -- blank pattern, always match
pixel True = id -- hash pattern, match on hash pixel
This only matches if the pattern is found at (0,0). Here’s a helper to attempt all shifts of the image .
lowerRights :: [[a]] -> [[[a]]]
= concatMap tails . transpose . map tails lowerRights
And I can now complete the monster counter.
countMonsters :: Tile -> Int
= length . filter (match seaMonster). lowerRights . tileImage countMonsters
A final helper before attempting part 2:
countHashes :: Image -> Int
= length . filter id . concat countHashes
λ> let image = merge assembly λ> maximum $ map (countMonsters . reorient image) allSetups 2 λ> countHashes (tileImage image) -2*countHashes seaMonster 273
Am I playing my luck a bit here? This computation works for the sample, but theoretically fails if a hash can be a part of multiple sea monsters.
Does it happen in my puzzle input? There’s only one way to find out…5
In the meantime, here’s the rest of the code for completeness.
main :: IO ()
= do
main <- parseInput <$> readFile "day20.in"
tiles let assembly = reassemble tiles
putStrLn $ "Assembly product: " ++ show (product (cornerIds assembly))
let image = merge assembly
= maximum $ map (countMonsters . reorient image) allSetups
monsters = countHashes (tileImage image)
roughness - monsters * countHashes seaMonster
putStrLn $ "Water roughness: " ++ show roughness
Well, that turned out to be enough for the second gold star of the day. I’m not sure I can disprove sea monsters overlapping each other by reading the statement again, so I figure it’s just Eric being nice to us.
Or maybe only to me? O:-)
This concludes this day’s solution. Hope you enjoyed it; see you soon!
Experimental for me. I expect them to be fully specified.↩︎
I got back to lenses for day 14, but that part of the journey isn’t published yet. I’m on it!↩︎
Well, ok, it is shorter, but the point would stand even if it weren’t.↩︎
Matrix addressing, ironically, fits the raster vision.↩︎
Of course there isn’t. I could go the rigorous route and mark the relevant pixels, perform their set union then difference with the rest of the hashes. But that would most likely take me more than a single minute. When the site grants us an attempt per minute, I’d be stupid not to try my luck before implementing the complex stuff.↩︎