Skip to main content

aoc/year2022/
day15.rs

1//! # Beacon Exclusion Zone
2use std::ops::Range;
3
4use crate::util::hash::*;
5use crate::util::iter::*;
6use crate::util::parse::*;
7use crate::util::point::*;
8
9pub struct Input {
10    sensor: Point,
11    beacon: Point,
12    manhattan: i32,
13}
14
15pub fn parse(input: &str) -> Vec<Input> {
16    input
17        .iter_signed()
18        .chunk::<4>()
19        .map(|[x1, y1, x2, y2]| {
20            let sensor = Point::new(x1, y1);
21            let beacon = Point::new(x2, y2);
22            Input { sensor, beacon, manhattan: sensor.manhattan(beacon) }
23        })
24        .collect()
25}
26
27/// The example uses y=10 but the real data uses y=2000000, so break out the logic
28/// into a separate function to enable integration testing.
29pub fn part1(input: &[Input]) -> i32 {
30    part1_testable(input, 2_000_000)
31}
32
33/// A beacon cannot be located within the radius of a sensor unless it is the closest beacon.
34///
35/// We first convert each scanner's diamond shaped area into a one-dimensional range at the
36/// specified row. By sorting the ranges, we can quickly calculate the total number of distinct
37/// ranges where another beacon cannot exist, only counting overlapping areas once.
38///
39/// Beacons can also not be located at the same position as another beacon so we then also discount
40/// any beacon located exactly on the specified row.
41pub fn part1_testable(input: &[Input], row: i32) -> i32 {
42    // Converts the "diamond" shaped area of each scanner into a one-dimensional row.
43    // If the scanner's range does not reach the specified row then return `None`.
44    fn build_range(input: &Input, row: i32) -> Option<Range<i32>> {
45        let Input { sensor, manhattan, .. } = input;
46        let extra = manhattan - (sensor.y - row).abs();
47        (extra >= 0).then(|| (sensor.x - extra)..(sensor.x + extra))
48    }
49
50    // Sort the ranges first.
51    let mut ranges: Vec<_> = input.iter().filter_map(|i| build_range(i, row)).collect();
52    ranges.sort_unstable_by_key(|r| r.start);
53
54    let mut total = 0;
55    let mut max = i32::MIN;
56
57    // Compare each range to the next.
58    for Range { start, end } in ranges {
59        if start > max {
60            // If there is no overlap with the previous range, then add the entire length.
61            total += end - start + 1;
62            max = end;
63        } else {
64            // If some part of the range overlaps, then only add any extra length.
65            // (it's possible that there is no extra length)
66            total += (end - max).max(0);
67            max = max.max(end);
68        }
69    }
70
71    // Returns the x position of all beacons that are located on the specified row.
72    let beacons: FastSet<_> =
73        input.iter().filter_map(|i| (i.beacon.y == row).then_some(i.beacon.x)).collect();
74    total - (beacons.len() as i32)
75}
76
77/// Similar to part one, the logic is broken out into a separate function to enable testing.
78pub fn part2(input: &[Input]) -> u64 {
79    part2_testable(input, 4_000_000)
80}
81
82/// The trick to solving this efficiently is to first *rotate* the corners of the diamond
83/// scanner shape by 45 degrees. This transforms them into squares that make it much easier
84/// to find the missing distress beacon.
85///
86/// Of the entire 4000000 by 4000000 area the missing beacon must be located in the only
87/// square area not covered by a scanner.
88pub fn part2_testable(input: &[Input], size: i32) -> u64 {
89    let capacity = input.len();
90    let mut top = FastSet::with_capacity(capacity);
91    let mut left = FastSet::with_capacity(capacity);
92    let mut bottom = FastSet::with_capacity(capacity);
93    let mut right = FastSet::with_capacity(capacity);
94
95    // Rotate points clockwise by 45 degrees, scale by √2 and extend edge by 1.
96    // This transforms each sensor into an axis aligned bounding box.
97    // The distress beacon is located where the top, left, bottom and right
98    // edges of 4 separate bounding boxes intersect.
99    for Input { sensor, manhattan, .. } in input {
100        top.insert(sensor.x + sensor.y - manhattan - 1);
101        left.insert(sensor.x - sensor.y - manhattan - 1);
102        bottom.insert(sensor.x + sensor.y + manhattan + 1);
103        right.insert(sensor.x - sensor.y + manhattan + 1);
104    }
105
106    let horizontal: Vec<_> = top.intersection(&bottom).copied().collect();
107    let vertical: Vec<_> = left.intersection(&right).copied().collect();
108    let range = 0..=size;
109
110    // Many input files have vertical.len() == 1 and horizontal.len() == 1, which implies exactly
111    // one answer, but this check covers more situations and is not too expensive.
112    for &x in &vertical {
113        for &y in &horizontal {
114            // Rotate intersection point counter-clockwise and scale by 1 / √2
115            // to return to original coordinates.
116            #[expect(clippy::manual_midpoint)]
117            let point = Point::new((x + y) / 2, (y - x) / 2);
118            // As we're mixing overlaps from different boxes there may be some spurious false
119            // positives, so double check all points are within the specified area
120            // and outside the range of all scanners.
121            if range.contains(&point.x)
122                && range.contains(&point.y)
123                && input.iter().all(|i| i.sensor.manhattan(point) > i.manhattan)
124            {
125                return 4_000_000 * (point.x as u64) + (point.y as u64);
126            }
127        }
128    }
129
130    unreachable!()
131}