Advent of Code 2021 day 23

The solution to day 23 was the longest program so far. At 320 lines of code, this solution is 50% longer than the next longest (day 15, another search problem) and twice as long as day 16, the longest non-search solution. The program was long because it had to contain a lot of domain knowledge about the problem setting.

Understanding the domain

Fundamentally, the problem was similar to many previous ones. The arrangement of amphipods in the burrow represents a state, and there are legal moves that transform one state to another. The problem is to search the space of legal move sequences to turn the initial state into a goal state.

I'd normally handle this by allowing each amphipod to move one space and just allowing the search tree to become deep. But in this case, the additional rules on movement meant I had to take a different approach.

The problem has a constraint that, once an amphipod stops in the hallway, it can't move again until it enters its destination room. I could have handled that by expanding the notion of a problem state to include tracking which amphipod, if any, was currently moving. Instead, I decided to pre-compute "moves" a being the whole trail from start to end, and keeping those moves as atomic.

This meant there were three types of moves:

  1. From a room to the hallway
  2. From the hallway to a room
  3. From a room directly to a room (but not back to the same room)

Including the room-room moves eliminates some duplicate search states if there are several hall spaces between the source and destination rooms. It doesn't matter which one is used to break the journey from room to room, so I eliminate that choice by including them all in one move.

The other piece of domain knowledge I included was ensuring that when an amphipod enters a room, it always moves to the furthest available space. All the spaces must be filled eventually, so it makes little sense to have unfilled spaces at the far end.

Data structures

There ended up being a lot to keep track of, so I created a bunch of data structures to keep track of everything. I also used lenses a lot to access all the different parts of these structures.

Amphipods and positions have explicit types, and the position of the amphipods is wrapped in a MoveState. I also rename the lenses for the V2 constructor, as I'm using (row, column) coordinates.

data Amphipod = A | B | C | D deriving (Show, Read, Eq, Ord, Enum)

type Coord = V2 Int -- r, c
_r :: Lens' (V2 Int) Int
_r = _x
_c :: Lens' (V2 Int) Int
_c = _y

type MoveState = M.Map Coord Amphipod

A Step is a single valid move of an amphipod, including the distance travelled and the spaces moved through; the latter is used to check there's nothing in the way of a move. Steps relates the starting position (the key) with the set of final positions.

An AppliedMove combines the positions of amphipods after a move and the Step that got there.

data Step = Step 
  { _destination :: Coord
  , _distance :: Int
  , _transits :: S.Set Coord
  , _entryRequirement :: Maybe Amphipod
  } deriving (Show, Eq, Ord)
makeLenses ''Step

type Steps = M.Map Coord (S.Set Step)

data AppliedMove = AppliedMove 
  { _afterMove :: MoveState 
  , _appliedStep :: Step
  }
  deriving (Show, Eq, Ord)
makeLenses ''AppliedMove

Finally, a Burrow holds all the possible Steps and information about where the hall and rooms are. That gets fed into the Reader monad, for use inside the search routines.

data Burrow = Burrow 
  { _possibleSteps :: Steps
  , _roomColumns :: M.Map Amphipod Int
  , _hallRow :: Int
  } deriving (Show, Eq)
makeLenses ''Burrow  

type BurrowContext = Reader Burrow

Building the burrow

mkBurrow reads the text from the input file and returns the Burrow (including all the moves) and the initial arrangement of amphipods. Mainly it's just walking over the text file, pulling out bits, and recording their positions.

mkBurrow :: String -> (Burrow, MoveState)
mkBurrow text = (burrow, initState) -- (burrow, initState)
  where rows = lines text
        hall = mkHall (rows!!1)
        rooms = mkRooms $ drop 2 rows
        roomCols = S.map (^. _c) $ M.keysSet rooms
        hall' = S.filter ((`S.notMember` roomCols) . (^. _c)) hall 
        routes = mkRoutes hall' rooms
        roomColMap = M.fromList $ zip [A .. D] $ S.toAscList roomCols
        burrow = Burrow { _possibleSteps = routes, _roomColumns = roomColMap, _hallRow = 1}
        initState = mkInitialState rows

mkHall :: String -> S.Set Coord
mkHall text = S.fromList hallCoords
  where hallCols = filter ((/= '#') . snd) $ zip [0..] text
        hallCoords = map ((V2 1) . fst) hallCols

mkRooms :: [String] -> M.Map Coord Amphipod
mkRooms text = M.unions rooms
  where rooms = map mkRoom $ zip [2..] text

mkRoom :: (Int, String) -> M.Map Coord Amphipod
mkRoom (r, text) = M.fromList roomCoords
  where roomCols = filter ((`elem` ("ABCD." :: String)) . snd) $ zip [0..] text
        roomCoords = zip (map ((V2 r) . fst) roomCols) [A .. D]

The interesting part comes in mkRoutes. It finds all the moves of each separate type (as described above) the combines them with M.unionsWith.

mkRoutes :: S.Set Coord -> M.Map Coord Amphipod -> Steps
mkRoutes halls rooms = M.unionsWith (S.union) [hallRoutes, roomHallRoutes, roomRoomRoutes]
  where hallRoutes = S.foldr' (mkHallRoute rooms) M.empty halls
        roomHallRoutes = S.foldr' (mkRoomHallRoute halls) M.empty (M.keysSet rooms)
        roomRoomRoutes = S.foldr' (mkRoomRoomRoute hallRow rooms) M.empty (M.keysSet rooms)
        hallRow = S.findMin $ S.map (^. _r) halls

(As a little note to self, I spent a fair bit of time running in circles updating the various Maps without using S.union to combine the multiple sets for each key!)

As an example, making the routes from the hall spaces to the room spaces, mkHallRoute, is a nested fold. First I create the routes for each hall space then, for each one, the routes from that hall space to each room space.

mkHallRoute :: M.Map Coord Amphipod -> Coord -> Steps -> Steps
mkHallRoute rooms here routes = M.foldrWithKey' (mkHallRoute1 here) routes rooms

mkHallRoute1 :: Coord -> Coord -> Amphipod -> Steps -> Steps
mkHallRoute1 here@(V2 hr hc) there@(V2 tr tc) entry routes = M.insert here (S.insert step existingRoutes) routes
  where step = Step { _destination = there
                    , _distance = (S.size transits) 
                    , _transits = transits
                    , _entryRequirement = Just entry
                    }
        cMin = min hc tc
        cMax = max hc tc
        transits = S.delete here $ S.fromList $ [V2 hr c | c <- [cMin..cMax]] ++ [V2 r tc | r <- [hr..tr]]
        existingRoutes = M.findWithDefault S.empty here routes

The other types of route creation follow a very similar pattern.

Searching

Most of the search is standard A* search. The interesting bits are the problem-specific parts.

successors finds all the legal applied moves from a particular arrangment of amphipods. It does that by, for each amphipod, finding all the legal moves of that amphipod.

successors :: MoveState -> BurrowContext (S.Set AppliedMove)
successors moveState = 
  do steps <- asks (^. possibleSteps)
     let succs = M.foldrWithKey' (legalSteps steps moveState) S.empty moveState
     return succs
     
legalSteps :: Steps -> MoveState -> Coord -> Amphipod -> S.Set AppliedMove -> S.Set AppliedMove
legalSteps steps state here amphipod acc = S.union appliedSteps acc
  where allSteps = steps ! here
        occupiedSpaces = M.keysSet state
        freeSpaces st = S.null $ S.intersection occupiedSpaces (st ^. transits)
        freeSteps = S.filter freeSpaces allSteps
        validTargetSteps = S.filter (\st -> fromMaybe amphipod (st ^. entryRequirement) == amphipod) freeSteps
        openRoomSteps = S.filter (openRoom state) validTargetSteps
        highestRowSteps = S.filter (highestRow (S.map (^. destination) openRoomSteps)) openRoomSteps
        appliedSteps = S.map (\s -> AppliedMove 
                                      { _afterMove = (applyStep state here s)
                                      , _appliedStep =  s
                                      }
                              ) highestRowSteps 

legalSteps finds the legal moves by finding all the moves (allSteps = steps ! here) then filtering out the illegal ones over several stages:

  • freeSteps are steps where every spaced moved through is empty
  • validTargetSteps are those to a place this amphipod could go (the hall or its own room)
  • openRoomSteps are where the target room is "open", i.e. does not have any non-destined amphipods
  • highestRowSteps are to the furthest unoccupied space in a column

Heuristic

It's A* search, so it's guided by a heuristic. The one I use relaxes the constraints of "can't pass through other amphipods" and "only one amphipod per space in the destination room".

estimateCost :: MoveState -> BurrowContext Int
estimateCost state = 
  do rCols <- asks (^. roomColumns)
     hRow <- asks (^. hallRow)
     let amphipodCosts = M.mapWithKey (estimateACost rCols hRow) state
     return $ sum $ M.elems amphipodCosts

estimateACost :: M.Map Amphipod Int -> Int -> Coord -> Amphipod -> Int
estimateACost rCols hRow (V2 r c) amphipod = (singleStepCost amphipod) * dist
    where targetCol = rCols ! amphipod
          dist = if c == targetCol
                 then 0
                 else (r - hRow) + (abs (c - targetCol)) + 1

It may not be much, but it's enough to halve the runtime of the program.

Profiling

My first attempts at this took several minutes to run part 2. This seemed like too long a time. I profiled the code and found that 96% of the runtime was in legalSteps (which wasn't a huge surprise). But the profiler, by default, works at the level of functions, so didn't give me any more detail than that.

I manually inserted some additional call centres at all the filter stages of legalSteps, with annotations like {-# SCC freeSteps #-}, like this:

legalSteps :: Steps -> MoveState -> Coord -> Amphipod -> S.Set AppliedMove -> S.Set AppliedMove
legalSteps steps state here amphipod acc = S.union appliedSteps acc
  where allSteps = steps ! here
        freeSpaces st = S.null $ S.intersection (M.keysSet state) (st ^. transits)
        freeSteps = {-# SCC freeSteps #-} S.filter freeSpaces allSteps
        validTargetSteps = {-# SCC validTargetSteps #-} S.filter (\st -> fromMaybe amphipod (st ^. entryRequirement) == amphipod) freeSteps
        openRoomSteps = {-# SCC openRoomSteps #-} S.filter (openRoom state) validTargetSteps
        highestRowSteps = {-# SCC highestRowSteps #-} S.filter (highestRow (S.map (^. destination) openRoomSteps)) openRoomSteps
        appliedSteps = S.map (\s -> AppliedMove 
                                      { _afterMove = (applyStep state here s)
                                      , _appliedStep =  s
                                      }
                              ) highestRowSteps 

That showed that most of the time was spent finding the freeSteps. On a hunch that GHC wasn't bright enough to cache the result of M.keysSet state, I pulled that out into a separate named variable:

occupiedSpaces = M.keysSet state
freeSpaces st = S.null $ S.intersection occupiedSpaces (st ^. transits)
freeSteps = S.filter freeSpaces allSteps

Just that halved the runtime.

Code

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