Today’s Advent of Code puzzle, “Unstable Diffusion”, is the cellular automaton of the year. Cells on a grid are either elven or dead, and evolve according to a simple pattern.
This is literate Haskell because I wouldn’t want to upset anyone by now, and it starts with a few imports.
import Control.Arrow ((&&&))
import Control.Lens (view)
import Linear (V2(V2),_x,_y)
import Data.List (elemIndex,find)
import Data.Maybe (catMaybes)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
So we have a set of elves again. I’ll represent them with their 2D positions.
type V = V2 Int
Parsing is just labeling each hash mark in the input with its coordinates.
parse :: String -> Set.Set V
= Set.fromList . concat . zipWith row [0..] . lines where
parse = catMaybes . zipWith (col i) [0..]
row i '#' = Just (V2 i j)
col i j = Nothing col _ _ _
The diffusion we’re managing today has a few simple rules. I’m mostly following them, I’ll only allow myself a little twist to make my life easier: whereas the statement says if an elf is isolated, they don’t move, I’m instead expressing it as if isolated elves proposed a “stay in place” move.
Why doesn’t it change anything? When moves are resolved in phase 2, only moves where the target doesn’t collide get performed. Moving to the current place is the same as not moving, but what about the influence on elves that attempt to move to the same place? It turns out there can’t be any, as move proposals only happen when there’s nobody in that general direction.
So let’s implement that. We’re computing the set of elf positions after turn i, considering the former set of elf positions.
step :: Set V -> Int -> Set V
= next where step cur i
Each elf proposes its next position. As mentioned earlier, an isolated elf, i.e. one with no neighbors, proposes to remain in place. The others go in the first cardinal direction from their preference list with no one nearby, be it directly in that direction or diagonally.
proposal from| all ((`Set.notMember` cur) . (from +)) neighbors = from
| otherwise = maybe from head $
all (`Set.notMember` cur)) $
find (map . map) (from +) attempts (
Preferred direction cycles per turn.
=
attempts0 V2 (-1) 0, V2 (-1) 1, V2 (-1) (-1) ]
[ [ V2 1 0, V2 1 1, V2 1 (-1) ]
, [ V2 0 (-1), V2 (-1) (-1), V2 1 (-1) ]
, [ V2 0 1, V2 (-1) 1, V2 1 1 ]
, [
]= splitAt (i `mod` 4) attempts0
(al,ar) = ar ++ al attempts
Let’s compute proposed position for each elf and store that in a map.
= Map.fromSet proposal cur proposals
Between phases 1 and 2, we check for conflicts. A conflict is a cell two1 elves want to move to during the same turn.
= Map.filter (> 1) $ Map.fromListWith (+) $
conflicts zip (Map.elems proposals) (repeat 1)
The actual movement thus only happens in absence of conflict.
= if prop `Map.member` conflicts then from else prop move from prop
And we can compute the global next set.
= Set.fromList $ Map.elems $ Map.mapWithKey move proposals next
The set of neighbors is a constant.
neighbors :: [V]
=
neighbors V2 (-1) (-1), V2 (-1) 0, V2 (-1) 1
[ V2 0 (-1), V2 0 1
, V2 1 (-1), V2 1 0, V2 1 1
, ]
Running the entire simulation is then a simple matter of repeatedly
invoking the step
function on an infinite list of turn
indices.
run :: Set V -> [Set V]
= flip (scanl step) [0..] run
For part 1, we’re asked for an arbitrary checksum of the final position’s span.
checksum :: Set V -> Int
= (imax - imin + 1) * (jmax - jmin + 1) - Set.size s
checksum s where
= minimum (view _x <$> Set.elems s)
imin = maximum (view _x <$> Set.elems s)
imax = minimum (view _y <$> Set.elems s)
jmin = maximum (view _y <$> Set.elems s)
jmax
part1 :: Set V -> Int
= checksum . (!! 10) . run part1
For part 2, we merely time stability.
part2 :: Set V -> Maybe Int
= fmap succ . elemIndex True . (zipWith (==) =<< tail) . run part2
And that’s it!
main :: IO ()
= interact $ show . (part1 &&& part2) . parse main
This concludes today’s solution. See you tomorrow!
Theoretically, or more, but it can’t happen here.↩︎