337 lines
11 KiB
Python
337 lines
11 KiB
Python
from __future__ import annotations
|
|
from dataclasses import dataclass, field
|
|
from enum import IntEnum
|
|
from queue import PriorityQueue
|
|
|
|
from typing import Iterator
|
|
|
|
from advent.common.position import UNIT_NEG_X, UNIT_NEG_Y, UNIT_X, UNIT_Y, Position
|
|
|
|
|
|
day_num = 24
|
|
|
|
|
|
def part1(lines: Iterator[str]) -> int:
|
|
valley = Valley.parse(lines)
|
|
return valley.find_way(1)
|
|
|
|
|
|
def part2(lines: Iterator[str]) -> int:
|
|
valley = Valley.parse(lines)
|
|
return valley.find_way(3)
|
|
|
|
|
|
def gcd(num1: int, num2: int) -> int:
|
|
assert num1 >= 0 and num2 >= 0
|
|
if num1 == 0 or num2 == 0:
|
|
return 0
|
|
while num2 != 0:
|
|
num1, num2 = num2, num1 % num2
|
|
return num1
|
|
|
|
|
|
def lcm(num1: int, num2: int) -> int:
|
|
return num1 * num2 // gcd(num1, num2)
|
|
|
|
|
|
BlizList = list[Position]
|
|
BlizTuple = tuple[BlizList, BlizList, BlizList, BlizList]
|
|
|
|
|
|
class Direction(IntEnum):
|
|
East = 0
|
|
North = 1
|
|
West = 2
|
|
South = 3
|
|
|
|
@classmethod
|
|
def create(cls, char: str) -> Direction:
|
|
match char:
|
|
case '>': return Direction.East
|
|
case '^': return Direction.North
|
|
case '<': return Direction.West
|
|
case 'v': return Direction.South
|
|
case _: raise Exception("Illegal Direction")
|
|
|
|
def position(self) -> Position:
|
|
match self:
|
|
case Direction.East: return UNIT_X
|
|
case Direction.North: return UNIT_NEG_Y
|
|
case Direction.West: return UNIT_NEG_X
|
|
case Direction.South: return UNIT_Y
|
|
|
|
@property
|
|
def char(self) -> str:
|
|
match self:
|
|
case Direction.East: return ">"
|
|
case Direction.North: return "^"
|
|
case Direction.West: return "<"
|
|
case Direction.South: return "v"
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
class Weather:
|
|
blizzards: list[dict[Position, str]] = field(repr=False)
|
|
extent: Position
|
|
repeat: int
|
|
|
|
def print(self, time: int) -> list[str]:
|
|
current = self.blizzards[self.normal_time(time)]
|
|
lines: list[str] = []
|
|
for row in range(self.extent.y):
|
|
line = ""
|
|
for col in range(self.extent.x):
|
|
if (char := current.get(Position(col, row))) is not None:
|
|
line += char
|
|
else:
|
|
line += '.'
|
|
lines.append(line)
|
|
return lines
|
|
|
|
def normal_time(self, time: int) -> int:
|
|
return time % self.repeat
|
|
|
|
def get(self, time: int) -> dict[Position, str]:
|
|
return self.blizzards[time % self.repeat]
|
|
|
|
@classmethod
|
|
def predict_weather(cls, blizzards: BlizTuple, extent: Position) -> Weather:
|
|
repeat = lcm(extent.x, extent.y)
|
|
weather: list[dict[Position, str]] = [Weather.create_dict(blizzards)]
|
|
for _ in range(repeat - 1):
|
|
blizzards = Weather.progress_blizzards(blizzards, extent)
|
|
weather.append(Weather.create_dict(blizzards))
|
|
|
|
return Weather(weather, extent, repeat)
|
|
|
|
@classmethod
|
|
def move_east(cls, blizzards: BlizList, extent: Position) -> BlizList:
|
|
add = Direction.East.position()
|
|
result: BlizList = []
|
|
for pos in blizzards:
|
|
next_pos = pos + add
|
|
if next_pos.x >= extent.x:
|
|
next_pos = next_pos.set_x(0)
|
|
result.append(next_pos)
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def move_west(cls, blizzards: BlizList, extent: Position) -> BlizList:
|
|
add = Direction.West.position()
|
|
result: BlizList = []
|
|
for pos in blizzards:
|
|
next_pos = pos + add
|
|
if next_pos.x < 0:
|
|
next_pos = next_pos.set_x(extent.x - 1)
|
|
result.append(next_pos)
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def move_south(cls, blizzards: BlizList, extent: Position) -> BlizList:
|
|
add = Direction.South.position()
|
|
result: BlizList = []
|
|
for pos in blizzards:
|
|
next_pos = pos + add
|
|
if next_pos.y >= extent.y:
|
|
next_pos = next_pos.set_y(0)
|
|
result.append(next_pos)
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def move_north(cls, blizzards: BlizList, extent: Position) -> BlizList:
|
|
add = Direction.North.position()
|
|
result: BlizList = []
|
|
for pos in blizzards:
|
|
next_pos = pos + add
|
|
if next_pos.y < 0:
|
|
next_pos = next_pos.set_y(extent.y - 1)
|
|
result.append(next_pos)
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def _add_list(cls, map: dict[Position, str], lst: BlizList, char: str) -> dict[Position, str]:
|
|
for position in lst:
|
|
match map.get(position):
|
|
case None: map[position] = char
|
|
case '2': map[position] = '3'
|
|
case '3': map[position] = '4'
|
|
case _: map[position] = '2'
|
|
return map
|
|
|
|
@classmethod
|
|
def create_dict(cls, blizzards: BlizTuple) -> dict[Position, str]:
|
|
map = Weather._add_list({}, blizzards[0], Direction.East.char)
|
|
map = Weather._add_list(map, blizzards[1], Direction.North.char)
|
|
map = Weather._add_list(map, blizzards[2], Direction.West.char)
|
|
return Weather._add_list(map, blizzards[3], Direction.South.char)
|
|
|
|
@classmethod
|
|
def progress_blizzards(cls, blizzards: BlizTuple, extent: Position) -> BlizTuple:
|
|
return (
|
|
Weather.move_east(blizzards[0], extent),
|
|
Weather.move_north(blizzards[1], extent),
|
|
Weather.move_west(blizzards[2], extent),
|
|
Weather.move_south(blizzards[3], extent),
|
|
)
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
class Valley:
|
|
weather: Weather = field(repr=False)
|
|
extent: Position
|
|
start: Position
|
|
exit: Position
|
|
|
|
@classmethod
|
|
def append(cls, blizzards: BlizTuple, direction: Direction, position: Position) -> BlizTuple:
|
|
return tuple(
|
|
directed if num != direction else directed + [position]
|
|
for num, directed in enumerate(blizzards)
|
|
)
|
|
|
|
@classmethod
|
|
def _get_wallbreak(cls, lines: str) -> int:
|
|
for col, tile in enumerate(lines[1:]):
|
|
if tile == '.':
|
|
return col
|
|
raise Exception("No break in the wall")
|
|
|
|
@classmethod
|
|
def parse_line(cls, blizzards: BlizTuple, line: str, row: int) -> BlizTuple:
|
|
for col, char in enumerate(line[1:]):
|
|
match char:
|
|
case '#':
|
|
return blizzards
|
|
case '>' | '^' | '<' | 'v':
|
|
blizzards = Valley.append(blizzards, Direction.create(char), Position(col, row))
|
|
case '.':
|
|
pass
|
|
case _:
|
|
raise Exception("Unknown char: {char}")
|
|
|
|
raise Exception("line not terminated by wall")
|
|
|
|
@classmethod
|
|
def parse(cls, lines: Iterator[str]) -> Valley:
|
|
first_line = next(lines)
|
|
start_col = Valley._get_wallbreak(first_line)
|
|
width = len(first_line) - 2
|
|
blizzards: BlizTuple = [], [], [], []
|
|
for row, line in enumerate(lines):
|
|
if line.startswith("##"):
|
|
end_col = Valley._get_wallbreak(line)
|
|
extent = Position(width, row)
|
|
return Valley(Weather.predict_weather(blizzards, extent), extent,
|
|
Position(start_col, -1), Position(end_col, row))
|
|
else:
|
|
blizzards = Valley.parse_line(blizzards, line, row)
|
|
assert False, "Unreachable"
|
|
|
|
def __str__(self) -> str:
|
|
return '\n'.join(self.print(0))
|
|
|
|
def print(self, time: int) -> list[str]:
|
|
first = '#' + ('#' * self.start.x) + '.' + ('#' * (self.extent.x - self.start.x))
|
|
last = '#' + ('#' * self.exit.x) + '.' + ('#' * (self.extent.x - self.exit.x))
|
|
lines = self.weather.print(time)
|
|
lines = [first] + ['#' + line + '#' for line in lines] + [last]
|
|
return lines
|
|
|
|
def find_way(self, rounds: int) -> int:
|
|
queue: PriorityQueue[Step] = PriorityQueue()
|
|
queue.put(Step(
|
|
position=self.start,
|
|
time=0,
|
|
round=0,
|
|
valley=self,
|
|
start=self.start,
|
|
target=self.exit))
|
|
reached: set[tuple[Position, int, int]] = set()
|
|
while not queue.empty():
|
|
current = queue.get()
|
|
if current.round == rounds:
|
|
return current.time
|
|
|
|
normal_time = self.weather.normal_time(current.time)
|
|
if (current.position, normal_time, current.round) in reached:
|
|
continue
|
|
|
|
reached.add((current.position, normal_time, current.round))
|
|
for next in current.possible_moves():
|
|
queue.put(next)
|
|
|
|
raise Exception("No path found")
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class Step:
|
|
position: Position
|
|
time: int
|
|
round: int
|
|
start: Position
|
|
target: Position
|
|
valley: Valley
|
|
|
|
def __lt__(self, other: Step) -> bool:
|
|
return self.time < other.time
|
|
|
|
def __str__(self) -> str:
|
|
return '\n'.join(self.print())
|
|
|
|
def print(self) -> list[str]:
|
|
lines = self.valley.print(self.time)
|
|
|
|
line = lines[self.position.y + 1]
|
|
lines[self.position.y + 1] = line[:self.position.x + 1] + \
|
|
'E' + line[self.position.x + 2:]
|
|
|
|
return lines
|
|
|
|
def reach_target(self) -> Step:
|
|
path = Step(position=self.target,
|
|
time=self.time + 1,
|
|
round=self.round + 1,
|
|
valley=self.valley,
|
|
start=self.target,
|
|
target=self.start,
|
|
)
|
|
return path
|
|
|
|
def move(self, position: Position) -> Step:
|
|
return Step(position=position,
|
|
time=self.time + 1,
|
|
round=self.round,
|
|
valley=self.valley,
|
|
start=self.start,
|
|
target=self.target,
|
|
)
|
|
|
|
def wait(self) -> Step:
|
|
return Step(position=self.position,
|
|
time=self.time + 1,
|
|
round=self.round,
|
|
valley=self.valley,
|
|
start=self.start,
|
|
target=self.target,
|
|
)
|
|
|
|
def possible_moves(self) -> Iterator[Step]:
|
|
impassable = self.valley.weather.get(self.time + 1)
|
|
|
|
if self.position not in impassable:
|
|
yield self.wait()
|
|
|
|
for dir in Direction:
|
|
add = dir.position()
|
|
next_position = self.position + add
|
|
if next_position == self.target:
|
|
yield self.reach_target()
|
|
|
|
elif (0 <= next_position.x < self.valley.extent.x
|
|
and 0 <= next_position.y < self.valley.extent.y):
|
|
if next_position not in impassable:
|
|
yield self.move(next_position)
|