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 Elfs. 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 Directions 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 GroveSimulation
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 groveI split each simulation round into five stages:
- Each
Elfproposes 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
updateCountI'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 Elfs. 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 clashesI 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 sThen 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 = elfMove 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 0Those 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 ^+^) deltasnotElves 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 elvesProfiling 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) $ anyNeighbouThat 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.