Advent of Code 2022 day 15

Day 15 was some clever tricks with representing data by functions, and then representing those functions as data.

Regions as functions

The field of possible locations is far too large to represent explicitly. But all I need to do is determine whether a beacon could be hidden in a particular location. That's a function of Position -> Bool. Therefore, I decided to represent all the regions as functions, with the newtype Region.

newtype Region = Region { getRegion :: Position -> Bool }

A Region contains the function I need: a Region defines the area where there cannot be a beacon. getRegion allows be to extract that function from the Region so I can use it.

A bit of preamble, to set up positions and find the distances between points.

type Position = V2 Int

manhattan :: Position -> Position -> Int
manhattan p1 p2 = (abs dx) + (abs dy)
  where V2 dx dy = p1 ^-^ p2

The nearby function creates a Region for a Sensor. The Region contains a function that determines if a point is within the "forbidden zone" of a sensor.

nearby :: Sensor -> Region
nearby (Sensor s b) = Region (\p -> manhattan s p <= dist)
  where dist = manhattan s b

But now, to find how sensors and their regions overlap.

Combining regions

Haskell has some standard machinery for combining things: semigroups and monoids. The semigroup defines how to combine two (or more) things, and the monoid shows what happens with no thing.

I want to combine regions, so I define instances for semigroup and monoid. When regions r1 and r2 overlap, a point is forbidden if it's in r1 or r2. If there are no regions, points are never forbidden.

instance Semigroup Region where
  r1 <> r2 = Region (\p -> getRegion r1 p || getRegion r2 p)

instance Monoid Region where
  mempty = Region (\p -> False)

mconcat combines a list of things in a monoid. I use that to find the entire forbidden region, the coverage of the sensors:

coverage = mconcat $ fmap nearby sensors

A point is forbidden if it is within the coverage. I can ask about a particular point with the function call getRegion coverage point, and the result shows me if that point is in the overall forbidden zone.

Part 1

In part 1, I need to count the number of forbidden points on a particular row. First, I need to find the range of possible points to consider, which ranges from the leftmost forbidden x value to the rightmost forbidden x value.

minX, maxX :: Sensor -> Int
minX (Sensor s@(V2 sx _) b) = sx - (manhattan s b)
maxX (Sensor s@(V2 sx _) b) = sx + (manhattan s b)

globalMinX, globalMaxX :: [Sensor] -> Int
globalMinX = minimum . fmap minX
globalMaxX = maximum . fmap maxX

From that, I can use the range function from Data.Ix to identify all the possibly-forbidden points on the row of interest, remembering to remove the points that are occupied by sensors and beacons!

thisY :: Int
-- thisY = 10
thisY = 2000000

part1 sensors = length $ filter (\p -> p `notElem` occupied) $ filter (getRegion coverage) rowCoords
  where coverage = mconcat $ fmap nearby sensors
        rowCoords = range ((V2 (globalMinX sensors) thisY), (V2 (globalMaxX sensors) thisY))
        occupied = concatMap (\(Sensor s b) -> [s, b]) sensors

Part 2

The direct way to do this is to something similar to part 1, but rather than working on a particular row I should test every point in the search range.

But there are 4,000,000 × 4,000,000 points, and checking 16 × 10 12 points takes a long time. On the other hand, the nature of the problem indicates that there only one such non-forbidden point, so it must lie just outside the boundary of at least one sensor.

That means I need to find the points that are just outside the forbidden zone of one sensor, which involves a bit of faffing with Enum ranges to get the points.

justOutside :: Sensor -> S.Set Position
justOutside (Sensor s@(V2 sx sy) b) = S.fromList (topLeft ++ topRight ++ bottomLeft ++ bottomRight)
  where d = 1 + manhattan s b
        topLeft = [V2 x y | (x, y) <- zip [(sx - d)..sx] [sy..(sy + d)] ]
        topRight = [V2 x y | (x, y) <- zip [(sx + d), (sx + d - 1)..sx] [sy..(sy + d)] ]
        bottomLeft = [V2 x y | (x, y) <- zip [(sx - d)..sx] [sy, (sy - 1)..(sy - d)] ]
        bottomRight = [V2 x y | (x, y) <- zip [(sx + d), (sx + d - 1)..sx] [sy, (sy - 1)..(sy - d)] ]

Combining these for all the sensors, and filtering to be within the given zone, gives about 4.9 million points to search, about one-four-millionth of the size of the entire search region.

searchRange :: (Position, Position)
-- searchRange = ((V2 0 0), (V2 20 20))
searchRange = ((V2 0 0), (V2 4000000 4000000))

part2 sensors = x * 4000000 + y
  where coverage = mconcat $ fmap nearby sensors
        boundaries = S.filter (inRange searchRange) $ S.unions $ fmap justOutside sensors
        V2 x y = S.findMin $ S.filter (\p -> not $ getRegion coverage p) boundaries

It still takes about three minutes on my machine, but it doesn't seem to use multiple cores. But anyway, there's no real optimisation here.

Code

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