Skip to content

Commit d83a050

Browse files
committed
feat: aoc 2022 day 15 solution + tests
1 parent 962f141 commit d83a050

File tree

5 files changed

+296
-0
lines changed

5 files changed

+296
-0
lines changed

_2022/data/day15/puzzle_input.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Sensor at x=98246, y=1908027: closest beacon is at x=1076513, y=2000000
2+
Sensor at x=1339369, y=2083853: closest beacon is at x=1076513, y=2000000
3+
Sensor at x=679177, y=3007305: closest beacon is at x=1076513, y=2000000
4+
Sensor at x=20262, y=3978297: closest beacon is at x=13166, y=4136840
5+
Sensor at x=3260165, y=2268955: closest beacon is at x=4044141, y=2290104
6+
Sensor at x=2577675, y=3062584: closest beacon is at x=2141091, y=2828176
7+
Sensor at x=3683313, y=2729137: closest beacon is at x=4044141, y=2290104
8+
Sensor at x=1056412, y=370641: closest beacon is at x=1076513, y=2000000
9+
Sensor at x=2827280, y=1827095: closest beacon is at x=2757345, y=1800840
10+
Sensor at x=1640458, y=3954524: closest beacon is at x=2141091, y=2828176
11+
Sensor at x=2139884, y=1162189: closest beacon is at x=2757345, y=1800840
12+
Sensor at x=3777450, y=3714504: closest beacon is at x=3355953, y=3271922
13+
Sensor at x=1108884, y=2426713: closest beacon is at x=1076513, y=2000000
14+
Sensor at x=2364307, y=20668: closest beacon is at x=2972273, y=-494417
15+
Sensor at x=3226902, y=2838842: closest beacon is at x=3355953, y=3271922
16+
Sensor at x=22804, y=3803886: closest beacon is at x=13166, y=4136840
17+
Sensor at x=2216477, y=2547945: closest beacon is at x=2141091, y=2828176
18+
Sensor at x=1690953, y=2203555: closest beacon is at x=1076513, y=2000000
19+
Sensor at x=3055156, y=3386812: closest beacon is at x=3355953, y=3271922
20+
Sensor at x=3538996, y=719130: closest beacon is at x=2972273, y=-494417
21+
Sensor at x=2108918, y=2669413: closest beacon is at x=2141091, y=2828176
22+
Sensor at x=3999776, y=2044283: closest beacon is at x=4044141, y=2290104
23+
Sensor at x=2184714, y=2763072: closest beacon is at x=2141091, y=2828176
24+
Sensor at x=2615462, y=2273553: closest beacon is at x=2757345, y=1800840

_2022/solutions/day15.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Sensor at x=2, y=18: closest beacon is at x=-2, y=15
2+
Sensor at x=9, y=16: closest beacon is at x=10, y=16
3+
Sensor at x=13, y=2: closest beacon is at x=15, y=3
4+
Sensor at x=12, y=14: closest beacon is at x=10, y=16
5+
Sensor at x=10, y=20: closest beacon is at x=10, y=16
6+
Sensor at x=14, y=17: closest beacon is at x=10, y=16
7+
Sensor at x=8, y=7: closest beacon is at x=2, y=10
8+
Sensor at x=2, y=0: closest beacon is at x=2, y=10
9+
Sensor at x=0, y=11: closest beacon is at x=2, y=10
10+
Sensor at x=20, y=14: closest beacon is at x=25, y=17
11+
Sensor at x=17, y=20: closest beacon is at x=21, y=22
12+
Sensor at x=16, y=7: closest beacon is at x=15, y=3
13+
Sensor at x=14, y=3: closest beacon is at x=15, y=3
14+
Sensor at x=20, y=1: closest beacon is at x=15, y=3
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Sensor at x=2, y=18: closest beacon is at x=-2, y=15
2+
Sensor at x=9, y=16: closest beacon is at x=10, y=16
3+
Sensor at x=13, y=2: closest beacon is at x=15, y=3
4+
Sensor at x=12, y=14: closest beacon is at x=10, y=16
5+
Sensor at x=10, y=20: closest beacon is at x=10, y=16
6+
Sensor at x=14, y=17: closest beacon is at x=10, y=16
7+
Sensor at x=8, y=7: closest beacon is at x=2, y=10
8+
Sensor at x=2, y=0: closest beacon is at x=2, y=10
9+
Sensor at x=0, y=11: closest beacon is at x=2, y=10
10+
Sensor at x=20, y=14: closest beacon is at x=25, y=17
11+
Sensor at x=17, y=20: closest beacon is at x=21, y=22
12+
Sensor at x=16, y=7: closest beacon is at x=15, y=3
13+
Sensor at x=14, y=3: closest beacon is at x=15, y=3
14+
Sensor at x=20, y=1: closest beacon is at x=15, y=3

_2022/tests/test_15.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Test suite for Day 15: Beacon Exclusion Zone
2+
3+
This module contains tests for the Day 15 solution, which analyzes sensor
4+
coverage using Manhattan distance to locate exclusion zones and find hidden
5+
beacons. The tests verify:
6+
7+
1. Part 1: Counting positions where beacons cannot exist on a target row
8+
2. Part 2: Finding the tuning frequency of the hidden distress beacon
9+
"""
10+
11+
from aoc.models.tester import TestSolutionUtility
12+
13+
14+
def test_day15_part1() -> None:
15+
"""Test counting impossible beacon positions on target row.
16+
17+
This test runs the solution for Part 1 of the puzzle against the
18+
provided test input and compares the result with the expected output.
19+
"""
20+
TestSolutionUtility.run_test(
21+
year=2022,
22+
day=15,
23+
is_raw=False,
24+
part_num=1,
25+
expected=26,
26+
)
27+
28+
29+
def test_day15_part2() -> None:
30+
"""Test finding tuning frequency of distress beacon location.
31+
32+
This test runs the solution for Part 2 of the puzzle against the
33+
provided test input and compares the result with the expected output.
34+
"""
35+
TestSolutionUtility.run_test(
36+
year=2022,
37+
day=15,
38+
is_raw=False,
39+
part_num=2,
40+
expected=56000011,
41+
)

0 commit comments

Comments
 (0)