Advent of Code 2023 day 03

Day 3 was conceptually quite simple, but some of the concepts required were complex to implement.

First off, some concepts. A Position is a pair of row and column. An Engine is an Array of characters. A Region is a group of Positions.

type Position = V2 Int -- r, c
type Engine = Array Position Char
type Region = [Position]

Building the engine from the input file is a matter of finding the size then filling the array.

mkEngine :: String -> Engine
mkEngine text = grid
  where rows = lines text
        r = length rows - 1
        c = (length $ head rows) - 1
        grid = listArray ((V2 0 0), (V2 r c)) $ concat rows

Finding numbers

The first hard-to-implement concept is that of finding all the numbers in the engine. As numbers are only horizontal, I can worry about just finding the numbers in each row, then combine the results.

The overall method of finding numbers in a row is a fold over the characters in that row. As I walk along the row, I keep track of

  1. All the numbers I've found so far
  2. The number I'm currently building

(Recording numbers as the Region they occupy, as I need that for working out what touches what later.)

I can keep that in a record

data NumberSeek = 
  NumberSeek { positions :: Region
             , foundNumbers :: [Region]
             } deriving (Show)

That means, when I come across a new character in a row, there are four alternatives, depending on whether I'm currently building a number and whether the current character is a digit:

  1. I'm building a number and this character is a digit → add the position to the current number, keep building
  2. I'm building a number and this character is not a digit → add the built number to the list of found ones, stop building
  3. I'm not building a number and this character is a digit → add the position to the current number, keep building
  4. I'm not building a number and this character is not a digit → skip ahead.

Those cases translate into the four cases in buildNumber, plus a bit of housekeeping needed for the case where a number ends with the last character in a row.

findNumbersInRow :: Engine -> Int -> NumberSeek
findNumbersInRow engine r
  | not $ null positions = 
      NumberSeek [] ((reverse $ positions):foundNumbers)
  | otherwise = finalSeek
  where finalSeek@NumberSeek{..} = 
            foldl' (buildNumber engine) 
                   (NumberSeek [] []) 
                   $ range $ rowBounds engine r

buildNumber :: Engine -> NumberSeek -> Position -> NumberSeek
buildNumber engine NumberSeek{..} p 
  | (not $ null positions) && isDigit c = NumberSeek (p:positions) foundNumbers
  | (not $ null positions) && not (isDigit c) = NumberSeek [] ((reverse positions):foundNumbers)
  | (null positions) && isDigit c = NumberSeek [p] foundNumbers
  | otherwise = NumberSeek [] foundNumbers
  where c = engine ! p

rowBounds :: Engine -> Int -> (V2 Int, V2 Int)
rowBounds engine r = (V2 r c1, V2 r c2)
  where (V2 _ c1, V2 _ c2) = bounds engine

With that, I can find all the numbers in the engine by appling findNumbersInRow to every row and combining the results with concatMap.

findNumbers :: Engine -> [Region]
findNumbers engine = numbers
  where ((V2 r1 _), (V2 r2 _)) = bounds engine
        rows = [r1..r2]
        numbers = concatMap (foundNumbers . (findNumbersInRow engine)) rows

The final thing with numbers is to convert the positions of the digits into an actual number.

readNumber :: Engine -> Region -> Int
readNumber engine ps = read $ map (engine !) ps

Touching

The next concept is that of touching. The puzzle is all about numbers touching symbols, so I need to implement that.

The neighbours of a point are all the points surrounding that point.

A region touches a point if any neighbours of that point are in the region.

Region a touches region b if any point in a touches b.

neighbours :: Position -> Region
neighbours p = [p ^+^ V2 dr dc | dr <- [-1..1], dc <- [-1..1]
                               , (dr, dc) /= (0, 0) ]

touchPoint :: Region -> Position -> Bool
touchPoint region point = not $ null $ intersect region $ neighbours point

touchRegion :: Region -> Region -> Bool
touchRegion region1 region2 = any (touchPoint region2) region1

Solving the puzzle

Now I can solve the puzzle! Preprocessing is to find all the part numbers in the engine. Those are the numbers that touch a symbol.

The set of all symbols is itself a Region (albeit non-contiguous).

isEngineSymbol :: Char -> Bool
isEngineSymbol c = (not $ isDigit c) && (c /= '.')

findSymbols :: Engine -> Region
findSymbols engine = filter (isEngineSymbol . (engine !)) $ indices engine

I can find all the numbers, find the symbols, then find which numbers touch the symbols.

main :: IO ()
main = 
  do  dataFileName <- getDataFileName
      text <- readFile dataFileName
      let engine = mkEngine text
      let allNums = findNumbers engine
      let symbols = findSymbols engine
      let partNums = filter (touchRegion symbols) allNums
      print $ part1 engine partNums
      print $ part2 engine partNums

Part 1

For part 1, I conver the part numbers (as Regions) into numbers and add them up.

part1 :: Engine -> [Region] -> Int
part1 engine partNums = sum partNumValues
  where partNumValues = map (readNumber engine) partNums

Part 2

This is a bit more involved. First, I find all the stars. Then, for each star, I find the part numbers that touch that star; that gives a list of numbers at each star. If there are exactly two numbers at the star, those numbers are the components of that gear. Having found all the gears, I do the arithmetic to find the sum of the gear ratios.

part1 :: Engine -> [Region] -> Int
part2 engine partNums = sum $ fmap product gearRatios
  where stars = findStars engine
        touchingStars = fmap (possibleGears partNums) stars
        gears = filter ((==) 2 . length) touchingStars
        gearRatios = fmap (fmap (readNumber engine)) gears

findStars :: Engine -> Region
findStars engine = filter ((==) '*' . (engine !)) $ indices engine

possibleGears :: [Region] -> Position -> [Region]
possibleGears nums star = filter (flip touchPoint star) nums  

Code

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