Advent of Code 2023 day 23
Day 23 was about finding the longest paths in a graph, which is an NP-complete problem. Luckily, this problem is fairly small, so I can solve it in a sensible time.
Representation
I decided on a "sparse" representation of the map, which made part 2 a little bit more convenient.
A Position
is the now-typical V2
. The Slide
is a new type. The forest grid is a Set
of Position
; the Slides
is a Map
from Position
to Slide
.
data Slide = SlideLeft | SlideRight | SlideUp | SlideDown
deriving (Show, Eq)
type Position = V2 Int -- r, c
_r, _c :: Lens' (V2 Int) Int
_r = _x
_c = _y
type Grid = S.Set Position
type Slides = M.Map Position Slide
Reading the input is working through character-by-character. The only wrinkle is that I put a "cap" on the start and end positions to prevent the search wandering into the infinite space outside the map.
mkGrid :: String -> (Grid, Slides, Position, Position)
mkGrid text = ((S.union forest caps), slides, start, end)
where rows = lines text
maxR = length rows - 1
maxC = (length $ head rows) - 1
forest = S.fromList [ V2 r c | r <- [0..maxR], c <- [0..maxC]
, rows !! r !! c == '#'
]
slides = M.fromList [ (V2 r c, readSlide (rows !! r !! c))
| r <- [0..maxR], c <- [0..maxC]
, elem (rows !! r !! c) ("<>^v" :: String)
]
start = head $ [ V2 0 c | c <- [0..maxC]
, rows !! 0 !! c == '.'
]
end = head $ [ V2 maxR c | c <- [0..maxC]
, rows !! maxR !! c == '.'
]
caps = S.fromList [start ^+^ (V2 -1 0), end ^+^ (V2 1 0)]
readSlide :: Char -> Slide
readSlide '<' = SlideLeft
readSlide '>' = SlideRight
readSlide '^' = SlideUp
readSlide 'v' = SlideDown
Compression
Part 1 is simple enough to solve with a direct graph search. Part 2 is too large for that. However, the map is organised as mostly long winding trails with only a few junctions. That means the overall problem reduces to one of picking a direction at each junction, and just looking up the distances from junction to junction.
That means I need to find those distances and how to look them up.
A CompressedMap
is a Map
from a Position
(a junction) to a list of directly-connected junctions. Those paths are represented by aCompressedPath
that stores the neighbouring junction and the distance to it.
data CompressedPath = CPath { _nextPos :: Position, _pathLen :: Int }
deriving (Show, Eq)
makeLenses ''CompressedPath
type CompressedMap = M.Map Position [CompressedPath]
Given a forest, the slides, and a position, I can work out the reachable adjacent positions.
adjacents :: Position -> Slides -> Grid -> [Position]
adjacents here slides forest = filter (`S.notMember` forest) $ fmap (here ^+^) deltas
where deltas = case M.lookup here slides of
Nothing -> [ V2 0 1, V2 1 0, V2 0 (-1), V2 (-1) 0 ]
Just SlideLeft -> [ V2 0 (-1) ]
Just SlideRight -> [ V2 0 1 ]
Just SlideUp -> [ V2 (-1) 0 ]
Just SlideDown -> [ V2 1 0 ]
The junctions, the "interesting points", are those with more than two adjacent positions (and the start and end). I use those to initially populate the compressed map.
interestingPoints :: Slides -> Grid -> Position -> Position -> CompressedMap
interestingPoints slides forest start end = M.fromList [(p, []) | p <- pointsSE]
where Just minR = minimumOf (folded . _r) forest
Just maxR = maximumOf (folded . _r) forest
Just minC = minimumOf (folded . _c) forest
Just maxC = maximumOf (folded . _c) forest
points = [ V2 r c | r <- [(minR + 2)..(maxR - 2)]
, c <- [(minC + 1)..(maxC - 1)]
, (V2 r c) `S.notMember` forest
, (length $ adjacents (V2 r c) slides forest) > 2
]
pointsSE = start : end : points
I then do a breadth-first search from each interesting point, stopping when the path reaches another interesting point. I store the results in the compressed map, so long as it's not a loop back to the original point.
searchStep :: Slides -> Grid -> [Position] -> [[Position]]
searchStep _ _ [] = []
searchStep slides forest path@(here:rest) = fmap (:path) valids
where nexts = adjacents here slides forest
valids = filter (`notElem` rest) nexts
search :: Slides -> Grid -> [Position] -> CompressedMap -> [[Position]] -> CompressedMap
search _ _ _ foundPaths [] = foundPaths
search slides forest goals foundPaths (current:agenda)
| head current `elem` goals = search slides forest goals foundPaths' agenda
| otherwise = search slides forest goals foundPaths (agenda ++ extendeds)
where extendeds = searchStep slides forest current
origin = last current
foundPaths' = if origin == head current then foundPaths
else M.adjust (cp :) origin foundPaths
cp = CPath (head current) (length current - 1)
Solution
Given the compressed map, finding the solution is much the same. This time, I use a depth-first search, recording all complete paths from start to end.
searchCompressed :: CompressedMap -> Position -> [[Position]] -> [[Position]] -> [[Position]]
searchCompressed _ _ found [] = found
searchCompressed map goal found (current:agenda)
| head current == goal = searchCompressed map goal (current:found) agenda
| otherwise = searchCompressed map goal found (nextPositions ++ agenda)
where neighbours0 = map M.! (head current)
neighbours = neighbours0 ^.. folded . filtered ((`notElem` current) . _nextPos)
nextPositions = fmap ((: current) . _nextPos) neighbours
Once I have a path, I find its length by adding up the lengths of all the path segments.
pathLength :: CompressedMap -> [Position] -> Int
pathLength map ps = sum $ zipWith (stepLength map) ps $ tail ps
stepLength :: CompressedMap -> Position -> Position -> Int
stepLength map here there =
head $ (map M.! there) ^.. folded . filteredBy (nextPos . only here) . pathLen
Part 1 and part 2 only differ in how the compressed map is made: part 1 includes the slides, part 2 ignores them.
part1, part2 :: Slides -> Grid -> Position -> Position -> Int
part1 slides forest start end = maximum $ fmap (pathLength cMap) paths
where cMap = compress slides forest start end
paths = searchCompressed cMap end [] [[start]]
part2 _ forest start end = maximum $ fmap (pathLength cMap) paths
where cMap = compress M.empty forest start end
paths = searchCompressed cMap end [] [[start]]
Code
You can get the code from my locally-hosted Git repo, or from Gitlab.