Advent of Code 2021 day 15

Day 15 was a standard graph search, so I reused some old code from day 20 of the 2019 Advent of Code. There are some advantages to doing lots of these!

This program uses several "non-beginner" tools in the code which are worth mentioning.

One is the use of the lens library to tidy up some of the record manipulation steps, such as candidate = agendum ^. current to pull out the current field from the agendum record. It's probably overkill, and I'd probably use Record Wildcards if I were writing this now.

Another is the use of the Reader monad to tidy up the code. Everything in the program depends on the detail of the grid being explored. That means I need to feed that in at the root of the function call tree, but it's only used in a few functions at the leaves of the call tree. Using the Reader allows me to hide some of the plumbing of many functions receiving a grid parameter only to pass it unchanged to the functions it calls.

The feature I exploit the most here is type classes. The two parts of today's puzzle differ in how the starting grid and current position are interpreted (with the part 2 grid being several duplicates of the grid in part 1). I could have done that by just building a larger grid and reusing the same code in part 1, or I could use the information in the original to create a "virtual grid" for part 2. Type classes allowed me to do the latter.

I won't describe the A* search algorithm here, just show how I implemented it.

I represent the map as an Array of positions, each with an Int cost. There are three flavours of Position used. BasePosition is the position used when reading the input and for locations in the Grid data structures. A Position is the same, but used in part 1. A TiledPosition is a position in the larger, tiled, version of the map. A TiledPosition can be anywhere in the larger, virtual, version of the map. But more on that in a bit. Finally, a Cave is a grid and an explicit goal location and it has a context for the Reader.

type BasePosition = V2 Int -- r, c
newtype Position = Position BasePosition -- r, c
  deriving (Eq, Ord, Show)
newtype TiledPosition = TiledPosition BasePosition -- r, c
  deriving (Eq, Ord, Show)
type Grid = Array BasePosition Int

data Cave = Cave 
  { _grid :: Grid
  , _goal :: BasePosition
  } deriving (Eq, Ord, Show)
makeLenses ''Cave

type CaveContext = Reader Cave

The search is controlled by each current solution candidate being an Agendum, and the whole search controlled by the Agenda, a priority queue of Agendums. I also have a closed set of ExploredStates. Note how these take a type parameter, the type of the current states. In this case, that's either a Position or a TiledPosition.

data Agendum s = 
    Agendum { _current :: s
            , _trail :: Q.Seq s
            , _trailCost :: Int
            , _cost :: Int
            } deriving (Show, Eq)
makeLenses ''Agendum                       

type Agenda s = P.MinPQueue Int (Agendum s)

type ExploredStates s = S.Set s

The search algorithm is pretty standard. runReader sets off searchCave in the context of a cave. aStar does the search as described, including checking for reaching the goal, checking for membership of the closed set, and updating the agenda. candidates finds the possible next steps from an Agendum, and makeAgendum converts each candidate into an Agendum.

part1 :: Cave -> Int
part1 cave = maybe 0 _cost result
    where result = runReader searchCave cave :: Maybe (Agendum Position)

searchCave ::  SearchState s => CaveContext (Maybe (Agendum s))
searchCave = 
    do agenda <- initAgenda
       aStar agenda S.empty

initAgenda ::  SearchState s => CaveContext (Agenda s)
initAgenda = 
    do let ss = emptySearchState
       c <- estimateCost ss
       return $ P.singleton c Agendum { _current = ss, _trail = Q.empty, _trailCost = 0, _cost = c}

aStar ::  SearchState s => Agenda s -> ExploredStates s -> CaveContext (Maybe (Agendum s))
aStar agenda closed 
    -- | trace ("Peeping " ++ (show $ fst $ P.findMin agenda) ++ ": " ++ (show reached) ++ " <- " ++ (show $ toList $ Q.take 1 $ _trail $ currentAgendum) ++ " :: " ++ (show newAgenda)) False = undefined
    -- | trace ("Peeping " ++ (show $ _current $ snd $ P.findMin agenda) ) False = undefined
    | P.null agenda = return Nothing
    | otherwise = 
        do  let (_, currentAgendum) = P.findMin agenda
            let reached = currentAgendum ^. current
            nexts <- candidates currentAgendum closed
            let newAgenda = foldl' (\q a -> P.insert (_cost a) a q) (P.deleteMin agenda) nexts
            reachedGoal <- isGoal reached
            if reachedGoal
            then return (Just currentAgendum)
            else if reached `S.member` closed
                 then aStar (P.deleteMin agenda) closed
                 else aStar newAgenda (S.insert reached closed)

candidates ::  SearchState s => Agendum s -> ExploredStates s -> CaveContext (Q.Seq (Agendum s))
candidates agendum closed = 
    do  let candidate = agendum ^. current
        let previous = agendum ^. trail
        let prevCost = agendum ^. trailCost
        succs <- successors candidate
        let nonloops = Q.filter (\s -> s `S.notMember` closed) succs
        mapM (makeAgendum previous prevCost) nonloops

makeAgendum ::  SearchState s => (Q.Seq s) -> Int -> s -> CaveContext (Agendum s)
makeAgendum previous prevCost newPosition = 
    do predicted <- estimateCost newPosition
       grid <- asks _grid
       let newTrail = previous |> newPosition
       newPositionCost <- entryCost newPosition
       let incurred = prevCost + newPositionCost
       return Agendum { _current = newPosition
                      , _trail = newTrail
                      , _trailCost = incurred
                      , _cost = incurred + predicted
                      }

You'll notice that these functions don't look at any specifics of the grid itself, using functions like successors and entryCost to do that.

Part 2: Polymorphism

Those grid-specific details are handled by functions in the SearchState class.

class (Eq s, Ord s, Show s) => SearchState s where
    unwrapPos :: s -> BasePosition
    emptySearchState :: s
    successors :: s -> CaveContext (Q.Seq s)
    estimateCost :: s -> CaveContext Int
    isGoal :: s -> CaveContext Bool
    entryCost :: s -> CaveContext Int

instance SearchState Position where
...

instance SearchState TiledPosition where
...

Each SeachState type, Position and TiledPosition, has its own version of these functions. Haskell uses the concrete type to determine which function to use.

When I solved part 2, I kept the underlying grid the same, but mapped the position in the tiled version of the map to the correct position in the underlying grid. For instance, the entryCost function returns the cost to enter a position.

entryCost (TiledPosition (V2 r c)) = 
  do grid <- asks _grid
     let (_, V2 maxR maxC) = bounds grid
     let (tileR, gridR) = r `divMod` (maxR + 1)
     let (tileC, gridC) = c `divMod` (maxC + 1)
     let gridCost = grid ! (V2 gridR gridC)
     let !cost = (gridCost - 1 + tileR + tileC) `mod` 9 + 1
     return cost

This function works out which tile the position is in, and where in that tile, and uses that to calculate the updated cost.

Profiling

The last thing to cover is the profiling. My original version of the code for part 2 took about two minutes to run.

I profiled the runtime of the program and found that entryCost was taking about 50% of the runtime, with another 40% in makeAgendum. A little bit of thinking showed why. My original version of the Agendum record was this:

data Agendum s = 
    Agendum { _current :: s
            , _trail :: Q.Seq s
            , _cost :: Int
            } deriving (Show, Eq)

where _cost is the estimated total cost and the incurred cost was recalculated from the _trail every time a new Agendum was created. That led to a lot of lookups in the grid! A small change to Agendum (to retain the incurred trail cost) and suddenly the runtime dropped from two minutes to under five seconds.

You can see the original version as advent15/src/MainSlow.hs.

Code

You can get the code from my locally-hosed Git repo, or from Gitlab.