diff --git a/advent/common/position.py b/advent/common/position.py index 2395456..327852a 100644 --- a/advent/common/position.py +++ b/advent/common/position.py @@ -5,66 +5,97 @@ from typing import Iterator, Literal @dataclass(slots=True, frozen=True, order=True) class Position: + """ This class represents a position in 2D integer space """ x: int y: int + @classmethod + def splat(cls, value: int) -> Position: + """ Creates a Position with two equal values """ + return Position(value, value) + + def __str__(self) -> str: + return f"({self.x}, {self.y})" + def __getitem__(self, index: Literal[0, 1]) -> int: + """ Gets the first or second component of this position """ match index: case 0: return self.x case 1: return self.y case _: raise IndexError() def __iter__(self) -> Iterator[int]: + """ Iterates over all components of this psition """ yield self.x yield self.y def set_x(self, x: int) -> Position: + """ Creates a copy of this position with x set to the new value """ return Position(x, self.y) def set_y(self, y: int) -> Position: + """ Creates a copy of this position with y set to the new value """ return Position(self.x, y) def __neg__(self) -> Position: + """ Creates a copy of this position with all values negated """ return Position(-self.x, -self.y) def __add__(self, other: Position) -> Position: + """ Adds the components of these two positions """ return Position(self.x + other.x, self.y + other.y) def __sub__(self, other: Position) -> Position: + """ Subtracts the components of these two positions """ return Position(self.x - other.x, self.y - other.y) def __mul__(self, factor: int) -> Position: + """ Multiplies all components of this position by the given factor """ return Position(self.x * factor, self.y * factor) - def taxicab_distance(self, other: Position | None = None) -> int: - if other is None: - return abs(self.x) + abs(self.y) - else: - return abs(self.x - other.x) + abs(self.y - other.y) - def right(self) -> Position: - return Position(self.x + 1, self.y) + """ Returns the neighboring position to the right """ + return self + UNIT_X def up(self) -> Position: - return Position(self.x, self.y - 1) + """ Returns the neighboring position above """ + return self + UNIT_NEG_Y def left(self) -> Position: - return Position(self.x - 1, self.y) + """ Returns the neighboring position to the left """ + return self + UNIT_NEG_X def down(self) -> Position: - return Position(self.x, self.y + 1) + """ Returns the neighboring position below """ + return self + UNIT_Y def unit_neighbors(self) -> Iterator[Position]: + """ Returns an iterator of all direct neighbors """ yield self.right() yield self.up() yield self.left() yield self.down() def is_within(self, top_left: Position, bottom_right: Position) -> bool: + """ + Checks if this point is within the rectangle spanned by the given positions. + bottom_right is considered to be the first point outside the spanned rectangle. + Behavior is undefined if top_left and bottom_right are not in the correct order. + """ return top_left.x <= self.x < bottom_right.x and top_left.y <= self.y < bottom_right.y + def taxicab_distance(self, other: Position | None = None) -> int: + """ + If other is given returns the taxicab distance from this point to other. + Otherwise returns the taxicab distance of this position to the Origin. + """ + if other is None: + return abs(self.x) + abs(self.y) + else: + return abs(self.x - other.x) + abs(self.y - other.y) -ORIGIN = Position(0, 0) + +ORIGIN = Position.splat(0) UNIT_X = Position(1, 0) UNIT_Y = Position(0, 1) UNIT_NEG_X = Position(-1, 0) diff --git a/advent/days/day15/solution.py b/advent/days/day15/solution.py index 06b90cd..3f906ab 100644 --- a/advent/days/day15/solution.py +++ b/advent/days/day15/solution.py @@ -1,9 +1,10 @@ from __future__ import annotations from dataclasses import dataclass +from itertools import combinations from typing import Iterator, Self -from advent.common.position import Position +from advent.common.position import ORIGIN, Position day_num = 15 @@ -25,26 +26,65 @@ ColRange = tuple[int, int] @dataclass(slots=True, frozen=True) class Sensor: - sensor: Position + number: int + position: Position distance: int @classmethod - def parse(cls, line: str) -> tuple[Self, Position]: + def parse(cls, line: str, number: int) -> tuple[Self, Position]: parts = line.split('=') sensor = Position(int(parts[1].split(',')[0].strip()), int(parts[2].split(':')[0].strip())) beacon = Position(int(parts[3].split(',')[0].strip()), int(parts[4].strip())) - return cls(sensor, sensor.taxicab_distance(beacon)), beacon + return cls(number, sensor, sensor.taxicab_distance(beacon)), beacon def col_range_at_row(self, row: int) -> ColRange | None: - col_distance = self.distance - abs(self.sensor.y - row) + col_distance = self.distance - abs(self.position.y - row) if col_distance < 0: return None - from_x = self.sensor.x - col_distance - to_x = self.sensor.x + col_distance + from_x = self.position.x - col_distance + to_x = self.position.x + col_distance return from_x, to_x + def is_within(self, position: Position) -> bool: + return self.position.taxicab_distance(position) <= self.distance + + +@dataclass(slots=True, frozen=True) +class ManhattenLine: + start: Position + direction_up: bool + steps: int + + @classmethod + def create(cls, start: Position, end: Position) -> ManhattenLine | None: + if start.x > end.x: + start, end = end, start + steps_x = end.x - start.x + steps_y = end.y - start.y + if steps_x != abs(steps_y): + return None + return ManhattenLine(start, steps_y < 0, steps_x) + + def crosspoint(self, other: ManhattenLine) -> Position | None: + if self.direction_up == other.direction_up: + return None + elif self.direction_up: + bottom_up, top_down = self, other + else: + bottom_up, top_down = other, self + + r2 = bottom_up.start.x + bottom_up.start.y - (top_down.start.x + top_down.start.y) + if r2 % 2 != 0: + return None + + r = r2 // 2 + if r < 0 or r > top_down.steps: + return None + + return top_down.start + Position.splat(r) + @dataclass(slots=True, frozen=True) class SensorMap: @@ -55,8 +95,8 @@ class SensorMap: def parse(cls, lines: Iterator[str]) -> SensorMap: sensors: list[Sensor] = [] beacons: set[Position] = set() - for line in lines: - sensor, beacon = Sensor.parse(line) + for number, line in enumerate(lines): + sensor, beacon = Sensor.parse(line, number) sensors.append(sensor) beacons.add(beacon) return cls(sensors, beacons) @@ -83,7 +123,7 @@ class SensorMap: yield current current = col_range else: - current = current[0], col_range[1] + current = ColRange((current[0], col_range[1])) yield current def count_impossible(self, row: int) -> int: @@ -94,21 +134,57 @@ class SensorMap: return seen - beacons - def get_possible(self, max_range: int) -> Position: - for row in range(max_range): - col_ranges = sorted(self.get_impossible(row)) + @classmethod + def tuning_frequency(cls, position: Position) -> int: + return position.x * 4_000_000 + position.y - curr1 = col_ranges[0][1] - for one0, one1 in col_ranges: - if curr1 < one1: - if curr1 < one0: - return Position(curr1 + 1, row) - if one1 > max_range: - break - curr1 = one1 + @classmethod + def get_midline(cls, sensor1: Sensor, sensor2: Sensor) -> ManhattenLine | None: + distance = sensor1.position.taxicab_distance(sensor2.position) + if distance != sensor1.distance + sensor2.distance + 2: + return None - raise Exception("No best spot found") + if sensor1.position.x == sensor2.position.x or sensor1.position.y == sensor2.position.y: + return None # Don't know how to handle that now, maybe include later + + if sensor1.position.x > sensor2.position.x: + sensor1, sensor2 = sensor2, sensor1 + + if sensor1.position.y < sensor2.position.y: + if sensor1.distance < sensor2.distance: + return ManhattenLine.create(Position(sensor1.position.x + sensor1.distance + 1, + sensor1.position.y), + Position(sensor1.position.x, + sensor1.position.y + sensor1.distance + 1)) + else: + return ManhattenLine.create(Position(sensor2.position.x - sensor2.distance - 1, + sensor2.position.y), + Position(sensor2.position.x, + sensor2.position.y - sensor2.distance - 1)) + else: + if sensor1.distance < sensor2.distance: + return ManhattenLine.create(Position(sensor1.position.x, + sensor1.position.y - sensor1.distance - 1), + Position(sensor1.position.x + sensor1.distance + 1, + sensor1.position.y)) + else: + return ManhattenLine.create(Position(sensor2.position.x, + sensor2.position.y + sensor2.distance + 1), + Position(sensor2.position.x - sensor2.distance - 1, + sensor2.position.y)) def get_possible_frequency(self, max_range: int) -> int: - freq_x, freq_y = self.get_possible(max_range) - return freq_x * 4_000_000 + freq_y + max_point = Position.splat(max_range) + midlines: list[ManhattenLine] = [] + for sensor1, sensor2 in combinations(self.sensors, 2): + midline = SensorMap.get_midline(sensor1, sensor2) + if midline is not None: + midlines.append(midline) + for line1, line2 in combinations(midlines, 2): + point = line1.crosspoint(line2) + if point is not None: + if (point.is_within(ORIGIN, max_point) + and all(not sensor.is_within(point) for sensor in self.sensors)): + return SensorMap.tuning_frequency(point) + + raise Exception("No point found") diff --git a/advent/days/day15/test_solution.py b/advent/days/day15/test_solution.py index 449782f..33d0ea5 100644 --- a/advent/days/day15/test_solution.py +++ b/advent/days/day15/test_solution.py @@ -20,14 +20,14 @@ def test_part2(): def test_parse(): input = "Sensor at x=2, y=18: closest beacon is at x=-2, y=15" - expected = Sensor(Position(2, 18), 7), Position(-2, 15) - result = Sensor.parse(input) + expected = Sensor(1, Position(2, 18), 7), Position(-2, 15) + result = Sensor.parse(input, 1) assert result == expected def test_x_range(): input = "Sensor at x=8, y=7: closest beacon is at x=2, y=10" - sensor, _ = Sensor.parse(input) + sensor, _ = Sensor.parse(input, 1) assert sensor.col_range_at_row(10) == (2, 14) assert sensor.col_range_at_row(11) == (3, 13)