Advent of Code 2023 day 10

Day 10 was definitely the day to practise your graph search algorithms. There was a fair bit to get through with this, especially with my not-very-terse style.

Representation and reading

Some definitions. A Position is a 2d vector of Int. A Pipe section is one of the enumerated type. A Grid is an Array of Pipe, indexed by Position. A Map is a combination of a Grid and the position of the start.

type Position = V2 Int -- r, c

data Pipe = Empty | 
            NorthWest | NorthSouth | NorthEast |
            WestEast | WestSouth |
            SouthEast |
            Start
            deriving (Show, Eq, Enum)

type Grid = Array Position Pipe

data Map = Map { getGrid :: Grid, getStart :: Position } deriving (Show)

I make a map by reading the grid, finding the start, and replacing it with the Pipe section that's actually there. I read the grid by finding its bounds then reading it form the list of cells. readPipe is a list of all the characters and what they represent.

mkMap :: String -> Map
mkMap text = Map grid' start
  where grid = mkGrid text
        start = head $ filter (( == Start) . (grid !)) $ indices grid
        grid' = grid // [(start, startIs $ Map grid start)]

mkGrid :: String -> Grid
mkGrid text = grid
  where rows = lines text
        r = length rows - 1
        c = (length $ head rows) - 1
        grid = listArray ((V2 0 0), (V2 r c)) $ fmap readPipe $ concat rows

readPipe :: Char -> Pipe
readPipe 'J' = NorthWest
readPipe '|' = NorthSouth
readPipe 'L' = NorthEast
readPipe '-' = WestEast
readPipe '7' = WestSouth
readPipe 'F' = SouthEast
readPipe 'S' = Start
readPipe _ = Empty

Finding how to replace the start position is trickier. I need to do this so that searching for the loop doesn't get distracted by other pipe sections that neighbour it. For instance, in the map below, I don't start searching from all the neighbours of the S position, and get distracted by the small loop above and miss the larger loop below.

........
...F--7.
...L--J.
..F-S-7.
..|...|.
..|...|.
..L---J.
........

Essentially, I want to find the shape of the "hole" the Start node fills, then replace the Start with the pipe section that fills that hole.

Working from the bottom up, I define the steps I can take along a pipe (the deltas) and the neighbours of a position in a grid.

deltas :: Pipe -> [Position]
deltas NorthWest = [V2 (-1) 0, V2 0 (-1)]
deltas NorthSouth = [V2 (-1) 0, V2 1 0]
deltas NorthEast = [V2 (-1) 0, V2 0 1]
deltas WestEast = [V2 0 (-1), V2 0 1]
deltas WestSouth = [V2 0 (-1), V2 1 0]
deltas SouthEast = [V2 1 0, V2 0 1]
deltas Start = (deltas NorthSouth) ++ (deltas WestEast)
deltas Empty = []

neighbours :: Map -> Position -> [Position]
neighbours Map{..} p = filter (inRange $ bounds getGrid) $ 
                              fmap (^+^ p) $ deltas $ getGrid ! p

The positions that connect to the start are the neighbours of the start that have the start as their neighbours. (You'll need to read that again.) For example, in the mini-map above, the pipe to the left of the start has the start as one of its neighbours, but the pipe above the start doesn't. Therefore, the pipe to the left is one of the connectors of the start. That's what connectorsToStart finds.

With that, I can cycle through all the types of pipe and compare they connections they need with the connections I have, and that gives me the type of pipe that the big fat S is hiding.

connectorsToStart :: Map -> [Position]
connectorsToStart map@Map{..} = fmap fst connectors
  where nbrs = neighbours map getStart
        nbrsNbrs = fmap (\n -> (n, neighbours map n)) nbrs
        connectors = filter ((getStart `elem`) . snd) nbrsNbrs

startIs :: Map -> Pipe
startIs map = head [ t | t <- [NorthWest .. SouthEast]
                       , (sort $ fmap (^+^ s) $ deltas t) == conns ]
  where conns = sort $ connectorsToStart map
        s = getStart map

Part 1

Finding the loop is a basic depth-first search, but complicated slightly by the need to find a loop rather than just a goal node. I handle this by having the initial agenda be the path from the start to its neighbours, and the goal check by being if the current node is a neighbour of the start.

search :: Map -> Maybe Path
search map = dfs map (initial map)

dfs :: Map -> [Path] -> Maybe Path
dfs _ [] = Nothing
dfs map (p:ps)
  | isGoal map p = Just p
  | otherwise = dfs map $ (successors map p) ++ ps

successors :: Map -> Path -> [Path]
successors map p = fmap (:p) ns'
  where ns = neighbours map $ head p
        ns' = filter (`notElem` p) ns

isGoal :: Map -> Path -> Bool
isGoal map p@(h:_) = ((getStart map) `elem` (neighbours map h)) && length p >= 3

initial :: Map -> [Path]
initial map = fmap (:[s]) $ neighbours map s
  where s = getStart map

Part 2

The description for part 2 says you can squeeze between pipes to connect regions. My first thought was to consider half-integer points in the map, but then I thought it would be easier to just expand the map by a factor of two in each direction. When I do that, I need to also extend the pipe sections so they still join up in the expanded grid.

I represent this expanded grid as an Array Position Bool, showing whether a position is part of the region-splitting loop (a.k.a. the wall) or not. The Region I'm finding is a Set of Position.

When I create the expanded grid, all the positions are False. For each pipe in the loop, I use addWall to set that position (doubled) and its connected neighbours, according to the pipe type. That's a fold to keep updating the same grid.

type BGrid = Array Position Bool
type Region = S.Set Position

expand :: Grid -> Path -> BGrid
expand grid path = foldl' (addWall grid) bgrid path
  where (b0, b1) = bounds grid
        b' = (b0, (b1 ^* 2) ^+^ (V2 1 1))
        bgrid = array b' [(p, False) | p <- range b']

addWall :: Grid -> BGrid -> Position -> BGrid
addWall grid bgrid p = bgrid // fmap ((, True)) adds
  where wallCell = grid ! p
        ds = deltas wallCell
        p' = p ^* 2
        adds = p' : fmap (^+^ p') ds

Filling the regions is a breadth-first search to flood-fill a region, skipping positions that are on the wall or out-of-bounds.

fill :: [Position] -> Region -> BGrid -> Region
fill [] region _ = region
fill (p:ps) region bgrid
  | bgrid ! p = fill ps region bgrid
  | p `S.member` region = fill ps region bgrid
  | otherwise = fill (ps ++ ns) region' bgrid
  where ns = bNeighbours bgrid p
        region' = S.insert p region

bNeighbours :: BGrid -> Position -> [Position]
bNeighbours g p = filter (inRange $ bounds g) $ unboundedNeighbours p

unboundedNeighbours :: Position -> [Position]
unboundedNeighbours p = fmap (^+^ p) [V2 (-1) 0, V2 1 0, V2 0 (-1), V2 0 1]

I create many regions, one for each neighbour (including diagonals) of the start node. Yes, this is inefficient, because I generate the same region several times; but it's quick enough.

regionsFromMap :: Map -> Path -> [Region]
regionsFromMap map boundary = getRegions bgrid starts
  where bgrid = expand (getGrid map) boundary
        starts = filter (inRange $ bounds bgrid) $
                        fmap (\n -> n ^+^ (getStart map ^* 2)) 
                              [V2 dr dc | dr <- [-1, 0, 1], dc <- [-1, 0, 1]]

getRegions :: BGrid -> [Position] -> [Region]
getRegions bgrid starts = fmap (\s -> fill [s] S.empty bgrid) starts                        

A region touches the edge if any point within it is on the edge of the grid. A point is on the edge if any of its neighbours is out of bounds. The inner regions are the regions that don't touch the edge. Yes, this is all really inefficient; but it's still quick enough.

onEdge :: BGrid -> Position -> Bool
onEdge bgrid p = any (not . inRange (bounds bgrid)) $ unboundedNeighbours p

touchesEdge :: BGrid -> Region -> Bool
touchesEdge bgrid region = any (onEdge bgrid) $ S.toList region

innerRegions :: BGrid -> [Region] -> [Region]
innerRegions bgrid regions = filter (not . touchesEdge bgrid) regions

Finally,  a point in a region is a point in the underlying map if both its row and column are even.

truePoints :: Region -> Region
truePoints = S.filter (\(V2 r c) -> r `mod` 2 == 0 && c `mod` 2 == 0)

I can put all that together to find the number of true points in the first inner region of the expanded grid.

part2 :: Map -> Path -> Int
part2 map loop = S.size $ truePoints $ head iRegions
  where bgrid = expand (getGrid map) loop
        regions = regionsFromMap map loop
        iRegions = innerRegions bgrid $ filter (not . S.null) regions

Code

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