Advent of Code 2024 day 20

    I got lucky in day 20. I made an optimisation to solve part 1 quickly, and that made part 2 very easy.

    Overall, the task is pathfinding again, but with a twist that you can burrow through some walls in the maze. I need to find all the sections that give a large saving over the path without any burrowing.

    The obvious way to do this is

    • for each wall section:
      • remove it
      • find the shortest path

    That would work, but is likely to take some time. Finding a short path is quick, but not free, and there are a lot of wall sections to check. I didn't bother implementing this. Time for a better approach.

    I took inspiration from how Dijkstra's shortest-path algorithm works. That algorithm fills out a table of shortest distance from the start to each position, working out in a breadth-first manner. If I have that table, and one giving the distance from each position to the goal, I can easily work out the distance of a path with burrowing. When I ask the agent to burrow through a wall, the cost of the path is the cost from the start to this side of the wall, plus the cost from the end to that side of the wall, plus the cost to move from one side to the other.

    Data structures

    The track is a record, holding the positions of the walls and the position of the start and end. The costs for each position are stored in a Map, giving the cost of each position from a particular point.

    type Position = V2 Int -- r, c
    
    type Walls = S.Set Position
    
    data Track = Track
      { walls :: Walls
      , start :: Position
      , goal :: Position
      } deriving (Eq, Ord, Show)
    
    type TrackCost = M.Map Position Int

    Reading the map is a walk over the input text.

    mkTrack :: String -> Track
    mkTrack text = Track { walls = walls, start = start, goal = goal }
      where rows = 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 == '#' ]
            start = head [ V2 r c | r <- [0..rMax], c <- [0..cMax], rows !! r !! c == 'S' ]
            goal = head [ V2 r c | r <- [0..rMax], c <- [0..cMax], rows !! r !! c == 'E' ]

    Finding costs

    The core of the solution is to pre-process the track to find all the costs from a position to the start and the end. That's done by costsFrom, called once for the start position and the end end position. It works by breadth-first exploration of the track, maintaining a boundary of positions to process. Note that M.union only adds new items if they're not already in the costs.

    costsFrom :: Track -> TrackCost -> TrackCost -> TrackCost
    costsFrom track costs boundary
      | M.null boundary = costs
      | otherwise = costsFrom track costs' boundary''
      where boundary' = M.foldlWithKey addBoundary M.empty boundary
            addBoundary acc here cost = 
              M.union acc $ M.fromList $ zip (neighbours track here) (repeat (cost + 1))
            boundary'' = boundary' `M.difference` costs
            costs' = costs `M.union` boundary'
    
    neighbours :: Track -> Position -> [Position]
    neighbours track here = 
      filter (`S.notMember` track.walls) 
              [ here ^+^ V2 0 1, here ^+^ V2 0 (-1), 
                here ^+^ V2 1 0, here ^+^ V2 (-1) 0 ]

    Finding cheats

    For part 1, the allowable cheat is just enough to get through a one-space-thick wall. That means I can find all the possible cheats by eliminating each wall position in turn and finding the revised cost. (I only need the costs, not the position of the removed wall as well.)

    allCheatedCosts track costsFromStart costsFromGoal = 
      catMaybes [ pathCostWithCheat track costsFromStart costsFromGoal h 
                | h <- S.toList track.walls 
                ]

    pathCostWithCheat uses the two tables of costs to find the cost after removing a particular wall. It looks up the neighbours of the removed wall section and, if there's at least one of each, it returns the cost as the two half-costs plus the two steps to move through the wall. Note that this returns a Maybe Int, in case the removed section isn't connected to the start or the end.

    pathCostWithCheat :: Track -> TrackCost -> TrackCost -> Position -> Maybe Int
    pathCostWithCheat track costsFromStart costsFromGoal here 
      | (not $ null costsToStart) && (not $ null costsToGoal) = Just $ minimum costsToStart + minimum costsToGoal + 2
      | otherwise = Nothing
      where
        nbrs = neighbours track here
        costsToStart = catMaybes $ fmap (`M.lookup` costsFromStart) nbrs
        costsToGoal  = catMaybes $ fmap (`M.lookup` costsFromGoal)  nbrs

    With allCheatedCosts, the overall solution writes itself.

    part1 track costsFromStart costsFromGoal = length savings
      where fullCost = costsFromStart M.! track.goal
            cheatCosts = allCheatedCosts track costsFromStart costsFromGoal
            savings = filter (>= 100) $ fmap (\c -> fullCost - c) cheatCosts

    You'll also notice the findSavings function, which I used for debugging.

    Part 2

    Part 2 changes the rules, but not in a way that really changes what I do. Now, the amount of cheating can increase, but the precise details of what happens during the cheating aren't important.

    That means the path-with-cheating still has three sections: the normal path to the start of the cheating section, the cheating section, and the normal path from the end of the cheating section to the goal. The cost of each cheating section is the distance travelled; the cost of the normal sections, I look up as before.

    That means pathCostWithCheat changes. I pass in the maximum length of the cheating section and where the cheating starts, and it returns the updated length for all distinct starts-and-ends of the cheating section. I try all cheating-ends that are within range of here, some of which may not be legal.

    pathCostWithCheat :: Int -> Track -> TrackCost -> TrackCost -> Position -> [Int]
    pathCostWithCheat cheatLen track costsFromStart costsFromGoal here =
      fmap (+ costsFromStart M.! here) continueCosts 
      where
        nbrs =  [ here ^+^ (V2 dr dc) 
                | dr <- [-cheatLen .. cheatLen]
                , dc <- [-cheatLen .. cheatLen]
                , abs dr + abs dc <= cheatLen
                ]
        continueCosts = catMaybes $ fmap contCost nbrs
        contCost :: Position -> Maybe Int
        contCost nbr = do gc <- M.lookup nbr costsFromGoal
                          let sc = l2Dist nbr here
                          return $ gc + sc
    
    l2Dist :: Position -> Position -> Int
    l2Dist (V2 r1 c1) (V2 r2 c2) = abs (r1 - r2) + abs (c1 - c2)                     

    allCheatedCosts changes slightly to account for the changed type of pathCostWithCheat.

    allCheatedCosts :: Int -> Track -> TrackCost -> TrackCost -> [Int]
    allCheatedCosts cheatLen track costsFromStart costsFromGoal = 
      concat [ pathCostWithCheat cheatLen track costsFromStart costsFromGoal h 
                | h <- M.keys costsFromStart 
                ]

    I refactored the overall calculation into a new function, bigSavings, and called the same function with different parameters to solve both parts.

    part1, part2 :: Track -> TrackCost -> TrackCost -> Int
    part1 = bigSavings  2 100
    part2 = bigSavings 20 100
    
    bigSavings :: Int -> Int -> Track -> TrackCost -> TrackCost -> Int
    bigSavings cheatLen savingThreshold track costsFromStart costsFromGoal = length savings
      where fullCost = costsFromStart M.! track.goal
            cheatCosts = allCheatedCosts cheatLen track costsFromStart costsFromGoal
            savings = filter (>= savingThreshold) $ fmap (\c -> fullCost - c) cheatCosts
    

    Code

    You can get the code from my locally-hosted Git repo, or from Codeberg. The code for part 1 was modified for part 2; you can find the part 1 only version in an earlier commit.

    Neil Smith

    Read more posts by this author.