Day 15 part 1 was fairly straightforward, but part 2 was full of detail and checking that all cases were covered.
Data structures
I stored the whole world in a single record. Positions were my typical V2
, walls and boxes were Set
of Position
, and the robot was a Position
. I used patterns for convenience.
type Position = V2 Int -- r, c
type Items = S.Set Position
data World = World { walls :: Items, boxes :: Items, robot :: Position }
deriving (Show, Eq, Ord)
pattern U, D, L, R :: Position
pattern U = V2 (-1) 0
pattern D = V2 1 0
pattern L = V2 0 (-1)
pattern R = V2 0 1
Reading the data wasn't efficient (three passes over the data), but it was good enough.
mkWorld :: String -> World
mkWorld text = World { walls = walls, boxes = boxes, robot = robot }
where rows = takeWhile (not . null) $ lines text
rMax = length rows - 1
cMax = (length $ head rows) - 1
walls = S.fromList [ V2 r c | r <- [0..rMax], c <- [0..cMax], rows !! r !! c == '#' ]
boxes = S.fromList [ V2 r c | r <- [0..rMax], c <- [0..cMax], rows !! r !! c == 'O' ]
robot = head [ V2 r c | r <- [0..rMax], c <- [0..cMax], rows !! r !! c == '@' ]
mkCommands :: String -> [Position]
mkCommands text = fmap readDirection $ concat rows
where rows = tail $ dropWhile (not . null) $ lines text
readDirection '^' = U
readDirection 'v' = D
readDirection '<' = L
readDirection '>' = R
I also wrote a showWorld
function for debugging, to generate the same maps as in the problem description.
showWorld :: World -> String
showWorld World { .. } = unlines rows
where rMax = S.findMax $ S.map (\(V2 r _) -> r) walls
cMax = S.findMax $ S.map (\(V2 _ c) -> c) walls
rows = [ [ if | V2 r c `elem` walls -> '#'
| V2 r c `elem` boxes -> 'O'
| V2 r c == robot -> '@'
| otherwise -> '.'
| c <- [0..cMax] ]
| r <- [0..rMax] ]
Part 1
The main simulation was simple enough: update the position of the robot, make sure it can't go into a wall. The complication came from the boxes. The robot can move into a box if that box can be moved. That means this box and move the next box, of that box can also move. This chain of moving boxes can be any length.
I decided to wrap the box-moving in a Maybe
. If a box could be moved, the moveBoxes
function would return Just
the world after moving the box, or Nothing
if the box couldn't move. A box would test if the next space was free or contained a box that could be moved. That meant a recursive call of moveBoxes
, and testing its result before moving this box.
Chaining interpretation of Maybe
is handled by the type's Monad
instantiation, so I used the do
notation to keep the syntax neat.
In doCommand
, there are three cases. First, if the robot moves into a wall, do nothing. Third, if the robot moves into free space, just update the world with the new position of the robot. But the second case, where the robot tries to move into a box, is handled by moveBoxes
. If that returns Nothing
, the fromMaybe
ensures the world stays the same.
doCommand :: World -> Position -> World
doCommand world dir
| there `S.member` world.walls = world
| there `S.member` world.boxes = fromMaybe world world'
| otherwise = world { robot = there }
where there = world.robot ^+^ dir
world' = do boxed <- moveBoxes world dir there
return boxed { robot = there }
moveBoxes
has the same three cases: do Nothing
if the box would move into a wall; return the world with shifted boxes if the box would move into a free space; or, if the box would move into another box, try to move that other box.
moveBoxes :: World -> Position -> Position -> Maybe World
moveBoxes world dir box
| there `S.member` world.walls = Nothing
| there `S.member` world.boxes = world'
| otherwise = Just $ world { boxes = shift world.boxes }
where there = box ^+^ dir
world' = do boxedWorld <- moveBoxes world dir there
let boxes' = shift boxedWorld.boxes
return boxedWorld { boxes = boxes'}
shift bs = S.insert there $ S.delete box bs
(If you don't really understand the code, don't worry: I barely understood it while writing it. But once it type checked, it gave the right answers. So yay type checkers?)
Calculating the GPS is simple: walk over the grid, adding up the score of each box found.
gps :: World -> Int
gps World { .. } = sum score
where rMax = S.findMax $ S.map (\(V2 r _) -> r) walls
cMax = S.findMax $ S.map (\(V2 _ c) -> c) walls
score = [ 100 * r + c
| c <- [0..cMax]
, r <- [0..rMax]
, V2 r c `elem` boxes
]
Part 2
This was more fiddly. I decided on a representation of the larger world where I only stored the left-most location of the big box in world.boxes
; I'd use code to handle the "phantom" right-most location.
That gave some utility functions for creating the larger world and displaying it.
enlarge :: World -> World
enlarge World { .. } = World { walls = walls', boxes = boxes', robot = robot' }
where rMax = S.findMax $ S.map (\(V2 r _) -> r) walls
cMax = S.findMax $ S.map (\(V2 _ c) -> c) walls
walls' = S.unions $ S.map (\(V2 r c) -> S.fromList [V2 r (2 * c), V2 r (2 * c + 1)]) walls
boxes' = S.map (\(V2 r c) -> V2 r (2 * c)) boxes
V2 rr rc = robot
robot' = V2 rr (2 * rc)
showBigWorld :: World -> String
showBigWorld World { .. } = unlines rows
where rMax = S.findMax $ S.map (\(V2 r _) -> r) walls
cMax = S.findMax $ S.map (\(V2 _ c) -> c) walls
rows = [ [ if | V2 r c `elem` walls -> '#'
| V2 r c `elem` boxes -> '['
| (V2 r c ^+^ L) `elem` boxes -> ']'
| V2 r c == robot -> '@'
| otherwise -> '.'
| c <- [0..cMax] ]
| r <- [0..rMax] ]
I also had a couple of utility functions for handling box locations. Was a location in a box? And, assuming the location is in a box, what's the box's canonical position?
isBigBox :: Position -> Items -> Bool
isBigBox here bs = S.member here bs || S.member (here ^+^ L) bs
bigBoxActual :: Items -> Position -> Position
bigBoxActual bs here
| here `S.member` bs = here
| otherwise = here ^+^ L
The logic for moving followed the same pattern as before. The robot can move if it can move all the boxes it would move into. However, one robot move could move several boxes. That meant the equivalent to moveBoxes
couldn't return the new world; instead it had to return the set of moves, so that those sets could be joined to update the world.
The logic of doCommand
and moveBoxes
is much the same as before, but with some added fiddling to handle all the edge cases.
doBigCommand :: World -> Position -> World
doBigCommand world dir
| there `S.member` world.walls = world
| there `isBigBox` world.boxes = fromMaybe world rWorld
| otherwise = world { robot = there }
where there = world.robot ^+^ dir
movedBox = bigBoxActual world.boxes there
rWorld =
do boxMoves <- moveBigBoxes world dir movedBox
let froms = S.fromList $ fmap fst boxMoves
let tos = S.fromList $ fmap snd boxMoves
let boxes' = S.union tos (world.boxes `S.difference` froms)
return world { boxes = boxes', robot = there }
moveBigBoxes :: World -> Position -> Position -> Maybe [Move]
moveBigBoxes world dir box
| any (\t -> t `S.member` world.walls) there = Nothing
| any (\t -> t `isBigBox` world.boxes) there = allMoves
| otherwise = Just [ thisMove ]
where there = case dir of
U -> [box ^+^ U, box ^+^ R ^+^ U]
D -> [box ^+^ D, box ^+^ R ^+^ D]
L -> [box ^+^ L]
R -> [box ^+^ R ^+^ R]
_ -> []
thisMove = (box, box ^+^ dir)
allMoves = do let there' = nub $ fmap (bigBoxActual world.boxes)
$ filter (\t -> t `isBigBox` world.boxes) there
moves <- traverse (moveBigBoxes world dir) there'
let moves' = concat moves
return $ thisMove : moves'
There was quite a bit of fiddling there to get everything working. Note the use of traverse
to convert a [Maybe move]
into a Maybe [move]
. I actually used Traversable
for a real reason!
A fun little puzzle, closer to real software engineering than the pure computer-science of many of them.
Code
You can get the code from my locally-hosted Git repo, or from Codeberg.