|
| 1 | +"""Day 15: Beacon Exclusion Zone |
| 2 | +
|
| 3 | +This module provides the solution for Advent of Code 2022 - Day 15. |
| 4 | +
|
| 5 | +It analyzes sensor coverage areas using Manhattan distance to identify positions |
| 6 | +where beacons cannot exist and locate the one position where a distress beacon |
| 7 | +could be hidden. |
| 8 | +
|
| 9 | +The module contains a Solution class that inherits from SolutionBase for |
| 10 | +parsing sensor data and computing coverage zones. |
| 11 | +""" |
| 12 | + |
| 13 | +import re |
| 14 | +from typing import ClassVar |
| 15 | + |
| 16 | +from aoc.models.base import SolutionBase |
| 17 | + |
| 18 | + |
| 19 | +class Solution(SolutionBase): |
| 20 | + """Analyze sensor coverage to find beacon exclusion zones. |
| 21 | +
|
| 22 | + This solution uses Manhattan distance to determine sensor coverage areas. |
| 23 | + Part 1 counts positions in a specific row where beacons cannot exist. |
| 24 | + Part 2 finds the single position within a bounded area not covered by any |
| 25 | + sensor, which must contain the distress beacon. |
| 26 | +
|
| 27 | + The solution uses range merging for efficient coverage calculation and |
| 28 | + perimeter scanning for finding the uncovered position. |
| 29 | + """ |
| 30 | + |
| 31 | + REGEX: ClassVar[re.Pattern[str]] = re.compile(r"x=(-?\d+), y=(-?\d+)") |
| 32 | + |
| 33 | + def _manhattan_distance(self, coord1: tuple[int, int], coord2: tuple[int, int]) -> int: |
| 34 | + """Calculate Manhattan distance between two coordinates. |
| 35 | +
|
| 36 | + Args: |
| 37 | + coord1: First coordinate (x, y) |
| 38 | + coord2: Second coordinate (x, y) |
| 39 | +
|
| 40 | + Returns |
| 41 | + ------- |
| 42 | + int: Manhattan distance (sum of absolute differences) |
| 43 | + """ |
| 44 | + return abs(coord1[0] - coord2[0]) + abs(coord1[1] - coord2[1]) |
| 45 | + |
| 46 | + def parse_data( |
| 47 | + self, data: list[str] |
| 48 | + ) -> tuple[dict[tuple[int, int], int], set[tuple[int, int]]]: |
| 49 | + """Parse sensor and beacon positions from input. |
| 50 | +
|
| 51 | + Extracts sensor positions and their closest beacons, calculating the |
| 52 | + Manhattan distance coverage radius for each sensor. |
| 53 | +
|
| 54 | + Args: |
| 55 | + data: List of strings describing sensor-beacon pairs |
| 56 | +
|
| 57 | + Returns |
| 58 | + ------- |
| 59 | + tuple: Dictionary mapping sensor positions to their coverage radius, |
| 60 | + and set of all beacon positions |
| 61 | + """ |
| 62 | + sensors: dict[tuple[int, int], int] = {} |
| 63 | + beacons: set[tuple[int, int]] = set() |
| 64 | + |
| 65 | + for line in data: |
| 66 | + matches = re.findall(self.REGEX, line) |
| 67 | + sensor_x, sensor_y = matches[0] |
| 68 | + beacon_x, beacon_y = matches[1] |
| 69 | + sensor: tuple[int, int] = (int(sensor_x), int(sensor_y)) |
| 70 | + beacon: tuple[int, int] = (int(beacon_x), int(beacon_y)) |
| 71 | + |
| 72 | + sensors[sensor] = self._manhattan_distance(sensor, beacon) |
| 73 | + beacons.add(beacon) |
| 74 | + |
| 75 | + return sensors, beacons |
| 76 | + |
| 77 | + def get_coverage( |
| 78 | + self, sensors: dict[tuple[int, int], int], target_row: int |
| 79 | + ) -> list[tuple[int, int]]: |
| 80 | + """Calculate x-coordinate ranges covered by sensors on target row. |
| 81 | +
|
| 82 | + For each sensor, determines if it covers any part of the target row |
| 83 | + and calculates the x-coordinate range if so. |
| 84 | +
|
| 85 | + Args: |
| 86 | + sensors: Dictionary of sensor positions to coverage radii |
| 87 | + target_row: Y-coordinate of row to analyze |
| 88 | +
|
| 89 | + Returns |
| 90 | + ------- |
| 91 | + list[tuple[int, int]]: List of (x_min, x_max) ranges covered on row |
| 92 | + """ |
| 93 | + ranges = [] |
| 94 | + |
| 95 | + for (sx, sy), coverage_distance in sensors.items(): |
| 96 | + vertical_distance = abs(sy - target_row) |
| 97 | + remaining_distance = coverage_distance - vertical_distance |
| 98 | + |
| 99 | + if remaining_distance >= 0: |
| 100 | + x_min = sx - remaining_distance |
| 101 | + x_max = sx + remaining_distance |
| 102 | + ranges.append((x_min, x_max)) |
| 103 | + |
| 104 | + return ranges |
| 105 | + |
| 106 | + def merge_ranges(self, ranges: list[tuple[int, int]]) -> list[tuple[int, int]]: |
| 107 | + """Merge overlapping or adjacent ranges into minimal set. |
| 108 | +
|
| 109 | + Args: |
| 110 | + ranges: List of (start, end) integer ranges |
| 111 | +
|
| 112 | + Returns |
| 113 | + ------- |
| 114 | + list[tuple[int, int]]: Sorted list of non-overlapping merged ranges |
| 115 | + """ |
| 116 | + if not ranges: |
| 117 | + return [] |
| 118 | + |
| 119 | + ranges.sort() |
| 120 | + merged: list[tuple[int, int]] = [] |
| 121 | + |
| 122 | + for start, end in ranges: |
| 123 | + if not merged or start > merged[-1][1] + 1: |
| 124 | + merged.append((start, end)) |
| 125 | + else: |
| 126 | + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) |
| 127 | + |
| 128 | + return merged |
| 129 | + |
| 130 | + def count_covered_positions(self, ranges: list[tuple[int, int]]) -> int: |
| 131 | + """Count total positions covered by ranges. |
| 132 | +
|
| 133 | + Args: |
| 134 | + ranges: List of (start, end) ranges |
| 135 | +
|
| 136 | + Returns |
| 137 | + ------- |
| 138 | + int: Total number of positions covered (inclusive) |
| 139 | + """ |
| 140 | + return sum(end - start + 1 for start, end in ranges) |
| 141 | + |
| 142 | + def part1(self, data: list[str]) -> int: |
| 143 | + """Count positions where beacons cannot exist on target row. |
| 144 | +
|
| 145 | + Determines sensor coverage on a specific row (y=10 for examples, y=2000000 |
| 146 | + for actual input) and counts positions that must be empty, excluding |
| 147 | + positions where beacons are actually located. |
| 148 | +
|
| 149 | + Args: |
| 150 | + data: List of strings describing sensor-beacon pairs |
| 151 | +
|
| 152 | + Returns |
| 153 | + ------- |
| 154 | + int: Number of positions on target row where beacon cannot be present |
| 155 | + """ |
| 156 | + target_row = 10 if len(data) < 15 else 2_000_000 |
| 157 | + sensors, beacons = self.parse_data(data) |
| 158 | + ranges = self.get_coverage(sensors, target_row) |
| 159 | + merged_ranges = self.merge_ranges(ranges) |
| 160 | + |
| 161 | + # Only subtract beacons that are ON the target row |
| 162 | + beacons_on_target_row = len([b for b in beacons if b[1] == target_row]) |
| 163 | + |
| 164 | + return self.count_covered_positions(merged_ranges) - beacons_on_target_row |
| 165 | + |
| 166 | + def part2(self, data: list[str]) -> int: |
| 167 | + """Find tuning frequency of distress beacon location. |
| 168 | +
|
| 169 | + Searches for the single position within bounds (0 to 20 for examples, |
| 170 | + 0 to 4000000 for actual input) that is not covered by any sensor. |
| 171 | + Scans perimeters just outside each sensor's range for efficiency. |
| 172 | +
|
| 173 | + Args: |
| 174 | + data: List of strings describing sensor-beacon pairs |
| 175 | +
|
| 176 | + Returns |
| 177 | + ------- |
| 178 | + int: Tuning frequency (x * 4000000 + y) of distress beacon position, |
| 179 | + or -1 if not found |
| 180 | + """ |
| 181 | + sensors, beacons = self.parse_data(data) |
| 182 | + |
| 183 | + max_y = max(max(s[1] for s in sensors), max(b[1] for b in beacons)) |
| 184 | + search_max = 20 if max_y < 100 else 4_000_000 |
| 185 | + |
| 186 | + for (sx, sy), distance in sensors.items(): |
| 187 | + for dx in range(distance + 2): |
| 188 | + dy = (distance + 1) - dx |
| 189 | + |
| 190 | + for sign_x, sign_y in [(1, 1), (1, -1), (-1, 1), (-1, -1)]: |
| 191 | + x = sx + dx * sign_x |
| 192 | + y = sy + dy * sign_y |
| 193 | + |
| 194 | + if not (0 <= x <= search_max and 0 <= y <= search_max): |
| 195 | + continue |
| 196 | + |
| 197 | + if all( |
| 198 | + self._manhattan_distance((x, y), sensor) > dist |
| 199 | + for sensor, dist in sensors.items() |
| 200 | + ): |
| 201 | + return x * 4_000_000 + y |
| 202 | + |
| 203 | + return -1 |
0 commit comments