diff --git a/advent/days/day24/__init__.py b/advent/days/day24/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/days/day24/data/example01.txt b/advent/days/day24/data/example01.txt new file mode 100644 index 0000000..6b9b892 --- /dev/null +++ b/advent/days/day24/data/example01.txt @@ -0,0 +1,6 @@ +#.###### +#>>.<^<# +#.<..<<# +#>v.><># +#<^v^^># +######.# \ No newline at end of file diff --git a/advent/days/day24/data/expected01_01.txt b/advent/days/day24/data/expected01_01.txt new file mode 100644 index 0000000..ccffff0 --- /dev/null +++ b/advent/days/day24/data/expected01_01.txt @@ -0,0 +1,6 @@ +#.###### +#.>3.<.# +#<..<<.# +#>2.22.# +#>v..^<# +######.# \ No newline at end of file diff --git a/advent/days/day24/data/input.txt b/advent/days/day24/data/input.txt new file mode 100644 index 0000000..f64ff83 --- /dev/null +++ b/advent/days/day24/data/input.txt @@ -0,0 +1,22 @@ +#.###################################################################################################################################################### +#^>^^<><^>^^v>vv^v^>>^<>^v^>>v>v><^..>v>>>>^>>.v>.v><<.^>>^^^>v>>^.>vv<<.<^v>^v<>^^.^.<<>^<^>.>vvv><^<^>v.^<># +#<..v<<^^.^^v<<>>v>>.<>>^vv^^v>v^>>>v^vv<<><>>v.>.v>.<..v>^^>^<><>vv>>v>.><.<v^vv<>vvvvv^<^v>.# +#<>>^v^v>v>.^v<>vvv>>^<<^>>v^^^.<<<.>>v.>.v^.>>>^><>v>>v^<.v^<.vv^>^v<><>>v^^<^>..<<^<<<^<^>^<>v>vvv>>^<<>><><<^^.<v># +#>.<<<^..>><>v>^vv>vv^^>>.><^>><^.^<>^v>.<^<>>>^>>^><^><^.^>.><><><^>^>>^>>>^<.>>^<^>vv<^v^>.>^>>vv.^<>>^>>>><>.^.>v<.^<>v^>v>^^>>v<><># +#>>v<^v^<^^><>><>.>v^v>^v.>v^.>>.><><<>^><<>^>^v>v^^^>>^v<<<>vv>>v<^^^<<<<>v<^>>.^v>><.v^<.>>vv>v><><.^^^v><.<^>>><# +#>>vv<^vv<<>^v<^v.>>v<><>.>^.^....<<^<.>^^^<><>.>^<>>^^><>>v^vv^<>v^^^^v.<><^><^v<<.v>>><>>><.v>v>>^.^>^># +#>^v>v^vv^.^<<<^><>v^<.>>^^^<^<^>.>>^<<.^>><<.>^^.vv>.vv><^^.<<v..<^<.vvv<..v>.>>^v<^^<^v^>^.<<^^.^^<>v<<<v>><># +#<^>>^>><<>v.v>.vvv><^>v..<<<>>><^v><^>..<^^v>.<<>.v..^<.^vv.>^><>vv><<>v^>v>><^^>^>><^>.>^^<<><><^.>^v<>v^.>v><>>.v^..v># +#<.<<<^><><^<<^>><..>^<<<>^.<>>v..v^^<<<.><.^.<^>^vvv^^^^>v>^>>>>v<><>v.<>>^.<>v^<.^vv>v^.><<^vv<>><>v><^v>>^^^.v^v>v>^v^>.v<<^>.^v^>vv<# +#<<>^^.^>v.<>>^>^.<<^>^><<>v^.><<<.^^^<<^><<^^>.>vv>^>^^v<.^>.^>^<^>^.<>^v.^^^.v>^.^v^<<<><<>v.>>>>^..v>^^vv^v>># +#<^.<^^><>^^^<<>>^>.^^^<^<^<^^<^v.^^v>^v<>.^v^^<<..v<^<>>^<..<^.>>^>.^v.>>v^.v..v.<^vvv^v<><><<^v^v.^^<<>^vv>^^>v>^v<<^>^<^.^# +#<^^v^v>^<^><>v><.>^>^..v^>^v^>^><>v<^v^<v>^vv<^v.<><>vv>>>>v<>^vv<^v<>^^.v>>^^<<.<^>^>>vv>vv^>^v<<>.>^>^v># +#<<>>>.<<>^>>v>^<^v>><<^v<><<.^<^<>>><^v>>v>^<^vv.<^^^.vv^<<<>^^>v>^v.>^^v>v.<>vvv><v>^v.^>vv<>^>.>.v^v.v^vv^<.<>.>vv>^>v^<.<# +#>^<^<<.<<..>>.>^^v>>v>^^>^^>>>v>v^<>v>>^>vvv>><>>vv>.>^.^<>.>^.v<>^^<^v^>>.>v.v<><<<>^<<^>^>^>^.vv..<>v<^># +#..v<<^v^^>^>^<^v^v^^v>><^><<^v.<.vv^v<<>^v<<^v^v^^.>^>v<^^v...>>vv.<^v>v^<<^v^>v<^>v^<>.>v^^^^.^<>^^.^><.<.>>.><.# +#v^v<<^v^>>^vv.v>>v>.vv^>>vv.>v>>>^.>>><.^.^<><>^vv<^<>><^v^>>.<>v<>v<....^^><.<^^>v^v>v<<>>^<>.v.<.vv<.>^.^v^>.># +#<v>>^>.>^^^<.>>^<><>>.>^v^<.v^^.^<<^>^.^v^^v<>>.>.<>.^.v>.^><^>v>v<>.^v<><.<<.><<^v<><><^>v^...v<<^^<^>v>^^vv^^vvv>^>v>>.>>v^^.vv>^# +#>v<>^v><>^>>><<^.^v<v<>>>.^v><^v<<>v<^^.<.><>^<><.v>><^>^>>.<>vvvv.^^v<<>^<>>><>>^v.<^>.^>><<>v<.<^<^>v^^>v^.><# +#>vv^<^.<^>v^v^<><>v<>>v^<>^>>>^v^>v<<^.^>v^v^<^<^^>^^^vv^.<><>><^v.<^<.<>.>^<^<^<>v<^.^vv><.v<^>># +#>v^.v>^v<>>v^>>>^^^^>.v<<^>^v>.^^>^>v^<^v^^<^^v>v>.v^^v><^^.><^^>>v>v^v><>vv><>^vvv>>v>>^v^^<^>>v^<^vv^<^^^^vv<>^^>v^v<><<<<# +######################################################################################################################################################.# diff --git a/advent/days/day24/solution.py b/advent/days/day24/solution.py new file mode 100644 index 0000000..1fdf946 --- /dev/null +++ b/advent/days/day24/solution.py @@ -0,0 +1,336 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from enum import IntEnum +from queue import PriorityQueue + +from typing import Iterator + + +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) + + +Position = tuple[int, int] +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 1, 0 + case Direction.North: return 0, -1 + case Direction.West: return -1, 0 + case Direction.South: return 0, 1 + + @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[1]): + line = "" + for col in range(self.extent[0]): + if (char := current.get((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[0], extent[1]) + 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[0] + add[0], pos[1] + add[1] + if next_pos[0] >= extent[0]: + next_pos = 0, next_pos[1] + 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[0] + add[0], pos[1] + add[1] + if next_pos[0] < 0: + next_pos = extent[0] - 1, next_pos[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[0] + add[0], pos[1] + add[1] + if next_pos[1] >= extent[1]: + next_pos = next_pos[0], 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[0] + add[0], pos[1] + add[1] + if next_pos[1] < 0: + next_pos = next_pos[0], extent[1] - 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), (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 = width, row + return Valley(Weather.predict_weather(blizzards, extent), extent, + (start_col, -1), (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[0]) + '.' + ('#' * (self.extent[0] - self.start[0])) + last = '#' + ('#' * self.exit[0]) + '.' + ('#' * (self.extent[0] - self.exit[0])) + 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[1] + 1] + lines[self.position[1] + 1] = line[:self.position[0] + 1] + \ + 'E' + line[self.position[0] + 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[0] + add[0], self.position[1] + add[1] + if next_position == self.target: + yield self.reach_target() + + elif (0 <= next_position[0] < self.valley.extent[0] + and 0 <= next_position[1] < self.valley.extent[1]): + if next_position not in impassable: + yield self.move(next_position) diff --git a/advent/days/day24/test_solution.py b/advent/days/day24/test_solution.py new file mode 100644 index 0000000..e796242 --- /dev/null +++ b/advent/days/day24/test_solution.py @@ -0,0 +1,49 @@ +from advent.common import input + +from .solution import BlizTuple, Valley, day_num, part1, part2 + + +def test_part1(): + lines = input.read_lines(day_num, 'example01.txt') + expected = 18 + result = part1(lines) + assert result == expected + + +def test_part2(): + lines = input.read_lines(day_num, 'example01.txt') + expected = 54 + result = part2(lines) + assert result == expected + + +def test_parse_line(): + input = "#>>.<^<#" + expected: BlizTuple = ( + [(0, 0), (1, 0)], + [(4, 0)], + [(3, 0), (5, 0)], + []) + result = Valley.parse_line(([], [], [], []), input, 0) + assert result == expected + + +def test_walk(): + lines = input.read_lines(day_num, 'example01.txt') + valley = Valley.parse(lines) + result = valley.find_way(1) + assert result == 18 + + +def test_walk2(): + lines = input.read_lines(day_num, 'example01.txt') + valley = Valley.parse(lines) + result = valley.find_way(2) + assert result == (18 + 23) + + +def test_back_and_forth(): + lines = input.read_lines(day_num, 'example01.txt') + valley = Valley.parse(lines) + result = valley.find_way(3) + assert result == 54