Advent Of Code 2022 - Day 15: Beacon Exclusion Zone
Dec 15, 2022
It's December. Time for snow, slippery roads, hot chocolate and cozy fire places. Also time for Advent of Code. An advent calendar with small, daily programming puzzles, growing progressively more difficult.
Every year I participate in a programming language I did not use for Advent of Code before, in order to learn new ways of doing things and to challenge myself. This year, that language is F#.
Day 15: Beacon Exclusion Zone
Summary: Given is a list of sensors and a beacon that is closest to the sensor, in integer 2D coordinates. Sensors can only lock on to the beacon closest to them as measured by the Manhattan distance.
On row y=2000000
, how many x
-positions cannot possibly contain a beacon?
Example input:
Sensor at x=2, y=18: closest beacon is at x=-2, y=15 Sensor at x=9, y=16: closest beacon is at x=10, y=16 Sensor at x=13, y=2: closest beacon is at x=15, y=3 Sensor at x=12, y=14: closest beacon is at x=10, y=16 Sensor at x=10, y=20: closest beacon is at x=10, y=16 Sensor at x=14, y=17: closest beacon is at x=10, y=16 Sensor at x=8, y=7: closest beacon is at x=2, y=10 Sensor at x=2, y=0: closest beacon is at x=2, y=10 Sensor at x=0, y=11: closest beacon is at x=2, y=10 Sensor at x=20, y=14: closest beacon is at x=25, y=17 Sensor at x=17, y=20: closest beacon is at x=21, y=22 Sensor at x=16, y=7: closest beacon is at x=15, y=3 Sensor at x=14, y=3: closest beacon is at x=15, y=3 Sensor at x=20, y=1: closest beacon is at x=15, y=3
For the sample input, take y=10
instead.
Read the full problem statement here.
A tile cannot contain a beacon if it's inside the range of any sensor, unless it already contains a beacon. That is because if there was a beacon inside the range of a sensor, the range of that sensor would be smaller and another beacon would fall outside the range.
Given the constaints of the problem statement we have to fear that part two is
going to ask us to find the same number for many rows, say between 0
and
200000
. So we have to be smarter than just counting every single x
position.
Instead we can define the range for all sensors at a given X position, in the
form (firstX, lastX)
, where the range will include lastX
. We can
then merge those ranges and find their lengths. Two ranges can be merged if they
either overlap or are adjacent to each other.
let mergeable (start1, finish1) (start2, finish2) = (not <| isDisjoint (start1, finish1) (start2, finish2)) || start2 - finish1 = 1
Two ranges overlap if they are not disjoint. Two ranges are disjoint if one
range's largest x
is smaller than the other's smallest x
.
let isDisjoint range range2 = match (range1, range2) with | ((l1, h1), (l2, h2)) when h2 < l1 -> true | ((l1, h1), (l2, h2)) when h1 < l2 -> true | _ -> false
In order to merge two ranges we simply take the smallest x
and the largest x
between them.
let merge (s1, e1) (s2, e2) = ((min s1 s2), (max e1 e2))
In order to merge an entire sequence of ranges we first have to sort them. This way we can ensure that if we look at two ranges and they aren't mergeable, no other range can be merged with the first one. That, in turn, ensures we only have to look at each range at most twice.
let compact (ranges: (int * int) seq) = let sortedRanges = ranges |> Seq.sort Seq.tail sortedRanges |> Seq.fold (fun (head, ranges) nextRange -> if mergable head nextRange then (merge head nextRange, ranges) else (nextRange, (head :: ranges))) (Seq.head sortedRanges, []) |> (fun (head, ranges) -> head :: ranges) |> List.rev
Determining the range of a sensor at row y
To determine the range of a sensor at any given row, we must first determine it's maximum horizontal or vertical range. Since a sensor can scan no further than its closest beacon, that range is the Manhattan distance to said beacon.
The Manhattan distance between two points is the difference between the x
coordinates plus the difference between the y
coordinates. We take the
absolute value so that we don't have to check which is the larger one.
let manhattanDistance (x1, y1) (x2, y2) = abs (x2 - x1) + abs (y2 - y1)
If the y
we are looking it is farther away from the sensor than the maximum
distance, the sensor has no coverage on that row at all. If it's at precisely
the maximum distance, it covers 1 tile: the sensor's x
coordinate. For every
row closer to the sensor, that range extends by 1 tile in both directions.
let rangeForSensorAtRow y ((sensorX, sensorY), beacon) = let maxDistance = Point.manhattanDistance (sensorX, sensorY) beacon let distance = abs (sensorY - y) let diff = maxDistance - distance if diff < 0 then None else Some (sensorX - diff, sensorX + diff)
Putting it together
Since the test input requires us to solve for a different y
than the real
input, we take y
as an argument.
We parse our input to a list of (sensorPoint, beaconPoint)
called
sensorInfo
. We need to count the beacons at y
because while they are in
sensor range, they don't count towards the spots where a beacon cannot be. After
all, there's a beacon!
let beaconsAtY = sensorInfo |> List.map snd |> List.filter (fun (_bx, by) -> by = y) |> List.distinct |> List.length
Then we take our sensor info, convert it to ranges, select only those sensors that have any coverage on the row, collapse the ranges and sum their lengths.
Finally we subtract the beacons.
let solve y input = sensorInfo |> List.map (rangeForSensorAtRow y) |> List.choose id |> Range.compact |> List.sumBy Range.length |> (fun s -> s - beaconsAtY)
Part 2
Summary: There is a beacon that is not in range of any of the sensors. It's
inside the area between 0,0
and 4000000,400000
. There exists precisely one
location where it can be. Determine the beacon's tuning frequency, which is
the x
coordinate multiplied by 4000000
added to the y
coordinate.
For the test input, look at the area between 0,0
and 20,20
.
As predicted, we need to look at a very large area. Is it too large for our setup? That depends on how patient you are.
With the things we've built so far, we can loop over all rows and for each row determine the sensor ranges. Since we're looking for a single tile, all rows except the row with that tile, must have one range. The offending row will have two ranges.
If we have the same sensorInfo
as before, and a maxY
that determines the the
maximum row, here's how that looks:
{0..maxY} |> Seq.pick (fun y -> let ranges = sensorInfo |> List.map (rangeForSensorAtRow y) |> List.choose id |> Range.compact if List.length ranges = 1 then None else Some (int64 y + 4000000L * int64 (ranges |> List.head |> snd |> ((+) 1))))
This has a runtime of about 45 seconds of my machine, which is not very good, but it'll do.
Improvements
I've noticed that in many posts the improvements are pretty much the same. I
hack together a solution using for
-loops and mutation and then refactor the
mutation away, changing the for
-loop into a fold
or a scan
.
In most cases I can also extract the solution to part 1, make one or two things slightly configurable and pass those in both parts.
I'll leave these kinds of improvements out of this section for now and just immediately describe them as they've ended up after refactoring. If I learn something new then it will still end up in this section.
Reflection
When reading today's problem statement I feared that part two would be quite hard. Given the large numbers I was surprised that it could be brute forced so easily. There exists a much better method, but I lack the time to explore and understand it.
At the same time I would have expected my solution to run faster. 4000000 is a large number, but given that I only look at the sensors, and that there are only 38 of those at my input, I would have expected a faster runtime. There's room for improvement there, too.
The full code for the day is on GitHub.