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.

    Neil Smith

    Read more posts by this author.