Day 23 was the first cellular automaton type puzzle of the year. Every time these come up, I consider implementing it as a Representable
-powered Comonad
of elements, and every year I decide that I've neither the time nor the brain capacity to build one. But one year I will!
Representation
My first decision was how to represent things and keep track of the simulation state. Some decisions were simple: the grove was represented sparsely, as a Set
of Elf
s. Each Elf
knew its position and where it was moving to. The Grove
overall knew about the elves, and also kept track of the current Direction
s for elf movement (an infinite, cycling list) and the simulation generation number.
type Position = V2 Int -- r, c
data Direction = North | South | West | East
deriving (Show, Eq, Ord, Enum, Bounded)
data Elf = Elf { _current :: Position, _proposed :: Position}
deriving (Eq, Ord)
makeLenses ''Elf
instance Show Elf where
show elf = "Elf {c= " ++ (show (elf ^. current))
++ ", p= " ++ (show (elf ^. proposed))
++ " -> " ++ (show (directionOfElf elf))
++ "}"
data Grove = Grove { currentGrove :: S.Set Elf, proposalDirections :: [Direction], elapsedRounds :: Int}
deriving (Eq)
(I used a custom Show
for the Elf
, so I could see the direction the elf was proposing to move in. It made checking my code much easier., and used the helper directionOfElf
)
directionOfElf :: Elf -> Maybe Direction
directionOfElf elf
| delta == V2 0 1 = Just North
| delta == V2 0 -1 = Just South
| delta == V2 1 0 = Just East
| delta == V2 -1 0 = Just West
| otherwise = Nothing
where delta = (elf ^. proposed) ^-^ (elf ^. current)
I was in two minds as to whether to use a State
monad, or just pass around a set of Elves. In the end, I went with the State
monad.
type GroveState = State Grove
Simulation
That meant the simulation was clean and looked rather imperative. There are two functions for running the simulation: simulateToCompletion
runs the simulation until no elf moves; simulateN
simulates n rounds.
simulateToCompletion :: GroveState ()
simulateToCompletion =
do oldGrove <- gets currentGrove
simulateOnce
newGrove <- gets currentGrove
if oldGrove == newGrove
then return ()
else simulateToCompletion
simulateN :: Int -> GroveState ()
simulateN 0 = return ()
simulateN n =
do simulateOnce
simulateN (n - 1)
Note that none of the simulations return a value. I extract what I want from the final state, and run the simulation with execState
part1, part2 :: Grove -> Int
part1 grove = countEmpty grove' bounds
where grove' = currentGrove $ execState (simulateN 10) grove
bounds = findBounds grove'
part2 grove = elapsedRounds grove'
where grove' = execState simulateToCompletion grove
I split each simulation round into five stages:
- Each
Elf
proposes where it wants to move - Elves moving to the same space withdraw their proposed moves
- All the elves move to their proposed spaces
- Update the movement-direction priority list
- Update the number of simulation rounds
simulateOnce =
do proposeMoves
removeClashes
moveElves
updateDirections
updateCount
I'll describe the three interesting stages in turn.
Propose moves
The top-level is simple: get the grove (a.k.a. set of elves), get the direction priority list, then ask each elf in the grove to propose where they want to move to.
proposeMoves =
do grove <- gets currentGrove
proposalsInf <- gets proposalDirections
let proposals = take 4 proposalsInf
let grove' = S.map (makeProposal grove proposals) grove
modify' (\g -> g { currentGrove = grove'})
makeProposal
asks a specific Elf
to propose where it will move next. If it's isolated, it stays still. Otherwise, it checks if it can move in any of the four directions, then there's a horrendous bit of plumbing to find the first viable move.
makeProposal :: S.Set Elf -> [Direction] -> Elf -> Elf
makeProposal grove directions elf
| isolated grove elf = elf
| otherwise = fromMaybe elf $ getFirst $ mconcat $ fmap First $ fmap (proposedStep grove elf) directions
proposedStep :: S.Set Elf -> Elf -> Direction -> Maybe Elf
proposedStep grove elf direction
| noElves grove interfering = Just $ elf & proposed .~ (here ^+^ (stepDelta direction))
| otherwise = Nothing
where here = elf ^. current
interfering = translateTo here $ directionNeighbour direction
(isolated
and noElves
are described below.)
proposedStep
takes and elf and a direction, and returns a Maybe Elf
if the elf could move in that direction (i.e. there are no elves in the "interfering" region).
The plumbing fromMaybe elf $ getFirst $ mconcat $ fmap First $ fmap (proposedStep localElves elf) directions
makes use of the First
class for monoids. Starting from the end, the fmap
turns the list of directions
into a list of Maybe Elf
s. The fmap First
converts each of these Maybe Elf
into a First (Maybe Elf)
, ready for the mconcat
. That combines the elfs into one blob, and getFirst
extracts the first non-Nothing
element of that blob. If there are no viable moves, getFirst
will return Nothing
and fromMaybe
will return the original Elf
.
I hope all that makes sense!
Remove clashes
Removing "clashes" is less complex, but still fiddly. I define a "clash" as when two or more elves are trying to move into the same space.
The basic code is simple enough: find the elves about to clash, then ask them to stay still.
removeClashes =
do grove <- gets currentGrove
let clashes = findClashes grove
stopClashingElves clashes
I find positions of clashes with a MultiSet
. First, I put all the proposed
next positions into the targets
multiset, then create a new MultiSet
from targets
, keeping only the targets
that appear more than once. (I have to use foldOccur
to do this as there's no filterWithOccur
or similar functions in the library.) Finally, I convert the contested targets into a Set
findClashes :: S.Set Elf -> S.Set Position
findClashes grove = MS.toSet $ MS.foldOccur ifMany MS.empty targets
where targets = MS.map _proposed $ MS.fromSet grove
ifMany t n s
| n == 1 = s
| otherwise = MS.insert t s
Then I ask the elves no to clash. That's as a map
over the elves, where each one is asked to notClash
.
notClash
checks if the elf's proposed
move is in the set of clashes
. If it is, the proposed
is reset to current
(meaning the elf doesn't move this round). If it's not a clash, the elf remains the same.
stopClashingElves :: S.Set Position -> GroveState ()
stopClashingElves clashes =
do grove <- gets currentGrove
let grove' = S.map (notClash clashes) grove
modify' (\g -> g { currentGrove = grove'})
notClash :: S.Set Position -> Elf -> Elf
notClash clashes elf
| (elf ^. proposed) `S.member` clashes = elf & proposed .~ (elf ^. current)
| otherwise = elf
Move elves
Finally, asking the elves to move is simple. I just update each elf's current
position to be the same as its proposed
position.
moveElves =
do grove <- gets currentGrove
let grove' = S.map moveElf grove
modify' (\g -> g { currentGrove = grove'})
moveElf :: Elf -> Elf
moveElf elf = elf & current .~ (elf ^. proposed)
Utility functions
There are quite a few utility functions to support all this.
First are a few direction-processing functions. anyNeighbour
returns a set of the eight neighbouring positions of the origin. directionNeighbour
does the same, but only the three positions in a certain direction. stepDelta
is the position change in a particular direction.
anyNeighbour :: S.Set Position
anyNeighbour = S.fromList [ V2 dx dy
| dx <- [-1, 0, 1]
, dy <- [-1, 0, 1]
, not ((dx == 0) && (dy == 0))
]
directionNeighbour :: Direction -> S.Set Position
directionNeighbour North = S.filter (\d -> d ^. _y == 1) anyNeighbour
directionNeighbour South = S.filter (\d -> d ^. _y == -1) anyNeighbour
directionNeighbour West = S.filter (\d -> d ^. _x == -1) anyNeighbour
directionNeighbour East = S.filter (\d -> d ^. _x == 1) anyNeighbour
stepDelta ::Direction -> Position
stepDelta North = V2 0 1
stepDelta South = V2 0 -1
stepDelta West = V2 -1 0
stepDelta East = V2 1 0
Those three functions all generate relative positions. translateTo
converts them to absolute positions, given a start point.
translateTo :: Position -> S.Set Position -> S.Set Position
translateTo here deltas = S.map (here ^+^) deltas
notElves
is a predicate that returns True
if there are no elves at any of the specified positions.
noElves :: S.Set Elf -> S.Set Position -> Bool
noElves elves tests = S.null $ S.intersection tests $ S.map _current elves
Profiling and optimisation
This all worked without too much trouble. Unfortunately, it took a very long time to execute, around 18 minutes on my machine to complete the 1000-odd rounds.
This prompted me to pull out the profiler for the first time this Advent of Code. It revealed that about 95% of the runtime was spent in noElves
, which meant it was spending all the time performing the set intersection
between the few positions and the many elves.
The fix was simple enough. In makeProposal
, I cache the elves near the current elf, and use that small neighbourhood to check for the presence of elves.
makeProposal :: S.Set Elf -> [Direction] -> Elf -> Elf
makeProposal grove directions elf
| isolated localElves elf = elf
| otherwise = fromMaybe elf $ getFirst $ mconcat $ fmap First $ fmap (proposedStep localElves elf) directions
where localElves = nearby grove elf
nearby :: S.Set Elf -> Elf -> S.Set Elf
nearby elves elf = S.filter (\e -> (e ^. current) `S.member` nbrs) elves
where nbrs = translateTo (elf ^. current) $ anyNeighbou
That small change was enough to reduce the runtime by two thirds. A six minute runtime is pretty bad, but there weren't any other quick fixes I could see.
I also tried defining custom Eq
and Ord
implementations for Elf
, so that only the current
position was checked. That had just about no effect.
Code
You can get the code from my locally-hosted Git repo, or from Gitlab.