diff --git a/puzzles/1.py b/puzzles/1.py index 2887847..7020b20 100644 --- a/puzzles/1.py +++ b/puzzles/1.py @@ -1,33 +1,48 @@ from collections import Counter +from textwrap import dedent +from typing import override +from unittest import TestCase -test_input = """ -3 4 -4 3 -2 5 -1 3 -3 9 -3 3 -""" - -test_solution_p1 = 11 -test_solution_p2 = 31 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - list1, list2 = _parse_lists(puzzle_input) - pairs = zip(sorted(list1), sorted(list2)) - distances = (abs(pair[0] - pair[1]) for pair in pairs) - return sum(distances) +class DayOneSolver(Solver): + list1: list[int] + list2: list[int] + + @override + def __init__(self, puzzle_input: str): + lines = (line.partition(" ") for line in puzzle_input.strip().split("\n")) + list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) + self.list1 = list(list1) + self.list2 = list(list2) + + @override + def solve_p1(self) -> int: + minimum_pairs = zip(sorted(self.list1), sorted(self.list2)) + return sum(abs(a - b) for a, b in minimum_pairs) + + @override + def solve_p2(self) -> int: + occurrences = Counter(self.list2) + return sum(value * occurrences.get(value, 0) for value in self.list1) -def solve_p2(puzzle_input: str) -> int: - list1, list2 = _parse_lists(puzzle_input) - occurrences = Counter(list2) - similarities = (value * occurrences.get(value, 0) for value in list1) - return sum(similarities) - - -def _parse_lists(puzzle_input: str) -> tuple[list[int], list[int]]: - lines = (line.partition(" ") for line in puzzle_input.strip().split("\n")) - list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) - return list1, list2 +class TestDayOneSolver(TestCase): + def test(self): + solver = DayOneSolver( + dedent( + """ + 3 4 + 4 3 + 2 5 + 1 3 + 3 9 + 3 3 + """ + ) + ) + self.assertListEqual(solver.list1, [3, 4, 2, 1, 3, 3]) + self.assertListEqual(solver.list2, [4, 3, 5, 3, 9, 3]) + self.assertEqual(solver.solve_p1(), 11) + self.assertEqual(solver.solve_p2(), 31) diff --git a/puzzles/2.py b/puzzles/2.py index 3e8ba4a..0e84b92 100644 --- a/puzzles/2.py +++ b/puzzles/2.py @@ -1,63 +1,88 @@ -from itertools import islice -from typing import Iterator +from dataclasses import dataclass +from functools import cached_property +from itertools import pairwise +from textwrap import dedent +from typing import Iterator, override +from unittest import TestCase from more_itertools import ilen -test_input = """ -7 6 4 2 1 -1 2 7 8 9 -9 7 6 2 1 -1 3 2 4 5 -8 6 4 4 1 -1 3 6 7 9 -""" - -test_solution_p1 = 2 -test_solution_p2 = 4 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - reports = _parse_reports(puzzle_input) - delta_reports = (_deltas(report) for report in reports) - safe_delta_reports = filter(_is_gradual_monotonic, delta_reports) - return ilen(safe_delta_reports) +@dataclass +class Report: + levels: tuple[int, ...] + + @cached_property + def deltas(self) -> tuple[int, ...]: + return tuple(b - a for a, b in pairwise(self.levels)) + + def is_gradual_monotonic(self) -> bool: + sign = 1 if self.deltas[0] > 0 else -1 + return all( + delta != 0 and delta / abs(delta) == sign and abs(delta) < 4 + for delta in self.deltas + ) + + def permute_dampened_variations(self) -> Iterator["Report"]: + return ( + Report(self.levels[:i] + self.levels[i + 1 :]) + for i in range(0, len(self.levels) + 1) + ) -def solve_p2(puzzle_input: str) -> int: - reports = _parse_reports(puzzle_input) - dampened_report_collections = (_dampen_permutations(report) for report in reports) - delta_report_collections = ( - (_deltas(report) for report in report_collection) - for report_collection in dampened_report_collections - ) - safe_report_collections = filter( - lambda delta_report_collection: any( - _is_gradual_monotonic(delta_report) - for delta_report in delta_report_collection - ), - delta_report_collections, - ) - return ilen(safe_report_collections) +class DayTwoSolver(Solver): + reports: list[Report] + + @override + def __init__(self, puzzle_input: str): + self.reports = list( + Report(tuple(int(level) for level in line.split(" "))) + for line in puzzle_input.strip().splitlines() + ) + + @override + def solve_p1(self) -> int: + safe_reports = filter(Report.is_gradual_monotonic, self.reports) + return ilen(safe_reports) + + @override + def solve_p2(self) -> int: + safe_reports = filter( + lambda report: any( + report_variation.is_gradual_monotonic() + for report_variation in report.permute_dampened_variations() + ), + self.reports, + ) + return ilen(safe_reports) -def _parse_reports(puzzle_input: str) -> Iterator[tuple[int, ...]]: - lines = puzzle_input.strip().splitlines() - return (tuple(int(level) for level in line.split(" ")) for line in lines) - - -def _deltas(report: tuple[int, ...]) -> tuple[int, ...]: - pairs = zip(report, islice(report, 1, None)) - return tuple(b - a for a, b in pairs) - - -def _is_gradual_monotonic(delta_report: tuple[int]) -> bool: - sign = 1 if delta_report[0] > 0 else -1 - return all( - delta != 0 and delta / abs(delta) == sign and abs(delta) < 4 - for delta in delta_report - ) - - -def _dampen_permutations(report: tuple[int, ...]) -> Iterator[tuple[int, ...]]: - yield report - yield from (report[:i] + report[i + 1 :] for i in range(0, len(report))) +class TestDayNSolver(TestCase): + def test(self): + solver = DayTwoSolver( + dedent( + """ + 7 6 4 2 1 + 1 2 7 8 9 + 9 7 6 2 1 + 1 3 2 4 5 + 8 6 4 4 1 + 1 3 6 7 9 + """ + ) + ) + self.assertListEqual( + solver.reports, + [ + Report((7, 6, 4, 2, 1)), + Report((1, 2, 7, 8, 9)), + Report((9, 7, 6, 2, 1)), + Report((1, 3, 2, 4, 5)), + Report((8, 6, 4, 4, 1)), + Report((1, 3, 6, 7, 9)), + ], + ) + self.assertEqual(solver.solve_p1(), 2) + self.assertEqual(solver.solve_p2(), 4) diff --git a/puzzles/3.py b/puzzles/3.py index d3befd8..888beff 100644 --- a/puzzles/3.py +++ b/puzzles/3.py @@ -1,40 +1,58 @@ import re -from functools import reduce +from typing import Literal, override +from unittest import TestCase -test_input_p1 = """ -xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5)) -""" - -test_input_p2 = """ -xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5)) -""" - -test_solution_p1 = 161 -test_solution_p2 = 48 - -instruction_pattern = re.compile(r"mul\((\d+),(\d+)\)|(do(?:n't)?)\(\)") +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - instructions = ( - tuple(map(int, match[:2])) - for match in instruction_pattern.findall(puzzle_input) - if match[0] - ) - return sum(a * b for a, b in instructions) +class DayThreeSolver(Solver): + memory: tuple[int, int] | Literal["do", "don't"] + + @override + def __init__(self, puzzle_input: str): + matches = re.findall(r"mul\((\d+),(\d+)\)|(do(?:n't)?)\(\)", puzzle_input) + self.memory = tuple( + (int(a), int(b)) if a else enable_flag for a, b, enable_flag in matches + ) + + @override + def solve_p1(self) -> int: + total = 0 + + for match in self.memory: + match match: + case a, b: + total += a * b + + return total + + @override + def solve_p2(self) -> int: + total = 0 + enabled = True + + for match in self.memory: + match match: + case "do": + enabled = True + case "don't": + enabled = False + case a, b: + if enabled: + total += a * b + + return total -def solve_p2(puzzle_input: str) -> int: - instructions = ( - match[2] or tuple(map(int, match[:2])) - for match in instruction_pattern.findall(puzzle_input) - ) - return reduce( - lambda a, i: ( - (1 if i == "do" else 0, a[1]) - if isinstance(i, str) - else (a[0], a[1] + a[0] * i[0] * i[1]) - ), - instructions, - (1, 0), - )[1] +class TestDayThreeSolver(TestCase): + def test_solve_p1(self): + solver = DayThreeSolver( + "xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5))\n" + ) + self.assertEqual(solver.solve_p1(), 161) + + def test_solve_p2(self): + solver = DayThreeSolver( + "xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))\n" + ) + self.assertEqual(solver.solve_p2(), 48) diff --git a/puzzles/4.py b/puzzles/4.py index 0291751..aeb9d84 100644 --- a/puzzles/4.py +++ b/puzzles/4.py @@ -1,83 +1,87 @@ -from typing import Iterator +from textwrap import dedent +from typing import Iterator, override +from unittest import TestCase -test_input = """ -MMMSXXMASM -MSAMXMSMSA -AMXSXMAAMM -MSAMASMSMX -XMASAMXAMM -XXAMMXXAMA -SMSMSASXSS -SAXAMASAAA -MAMMMXMMMM -MXMXAXMASX -""" +from more_itertools import sliding_window -test_solution_p1 = 18 -test_solution_p2 = 9 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - word_grid = _parse_world_grid(puzzle_input) - return sum(_count_xmas(line) for line in _scan_word_grid(word_grid)) +class DayFourSolver(Solver): + grid: tuple[tuple[str, ...], ...] + @override + def __init__(self, puzzle_input: str): + self.grid = tuple(tuple(row) for row in puzzle_input.strip().splitlines()) -def solve_p2(puzzle_input: str) -> int: - word_grid = _parse_world_grid(puzzle_input) - squares = _three_squares(word_grid) - return sum(1 for square in squares if _has_cross_mas(square)) - - -def _parse_world_grid(puzzle_input: str) -> tuple[tuple[str, ...], ...]: - return tuple(tuple(row) for row in puzzle_input.strip().splitlines()) - - -def _scan_word_grid( - char_grid: tuple[tuple[str, ...], ...] -) -> Iterator[tuple[str, ...]]: - yield from (row for row in char_grid) - yield from (tuple(col) for col in zip(*char_grid)) - yield from ( - _diagonal(char_grid, i) for i in range(-len(char_grid) + 1, len(char_grid[0])) - ) - yield from ( - _diagonal(tuple(reversed(char_grid)), i) - for i in range(-len(char_grid) + 1, len(char_grid[0])) - ) - - -def _diagonal(grid: tuple[tuple[str, ...], ...], offset=0) -> tuple[str, ...]: - return tuple( - grid[i][i + offset] for i in range(len(grid)) if 0 <= i + offset < len(grid) - ) - - -def _count_xmas(line: tuple[str, ...]) -> int: - return sum( - 1 - for i in range(len(line) - 3) - if line[i : i + 4] in (("X", "M", "A", "S"), ("S", "A", "M", "X")) - ) - - -ThreeSquare = tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]] - - -def _three_squares(word_grid: tuple[tuple[str, ...], ...]) -> Iterator[ThreeSquare]: - yield from ( - ( - word_grid[i][j : j + 3], - word_grid[i + 1][j : j + 3], - word_grid[i + 2][j : j + 3], + @override + def solve_p1(self) -> int: + targets = (("X", "M", "A", "S"), ("S", "A", "M", "X")) + return sum( + 1 + for line in self.scan_lines() + for window in sliding_window(line, 4) + if window in targets + ) + + @override + def solve_p2(self) -> int: + targets = (("M", "A", "S"), ("S", "A", "M")) + return sum( + 1 + for square in self.scan_squares(3) + if (square[0][0], square[1][1], square[2][2]) in targets + and (square[0][2], square[1][1], square[2][0]) in targets + ) + + def scan_lines(self) -> Iterator[tuple[str, ...]]: + yield from self.grid + for col in zip(*self.grid): + yield tuple(col) + for i in range(-len(self.grid) + 1, len(self.grid[0])): + yield self.diagonal(i) + yield self.diagonal(i, inverse=True) + + def scan_squares(self, size=3) -> Iterator[tuple[tuple[str, ...], ...]]: + yield from ( + ( + self.grid[i][j : j + size], + self.grid[i + 1][j : j + size], + self.grid[i + 2][j : j + size], + ) + for i in range(len(self.grid) - size + 1) + for j in range(len(self.grid[0]) - size + 1) + ) + + def diagonal(self, offset=0, inverse=False) -> tuple[str, ...]: + return tuple( + self.grid[i][len(self.grid[0]) - i - offset - 1 if inverse else i + offset] + for i in range(len(self.grid)) + if 0 <= i + offset < len(self.grid) ) - for i in range(len(word_grid) - 2) - for j in range(len(word_grid[0]) - 2) - ) -def _has_cross_mas(square: ThreeSquare): - diag_1 = (square[0][0], square[1][1], square[2][2]) - diag_2 = (square[0][2], square[1][1], square[2][0]) - return (diag_1 == ("M", "A", "S") or diag_1 == ("S", "A", "M")) and ( - diag_2 == ("M", "A", "S") or diag_2 == ("S", "A", "M") - ) +class TestDayFourSolver(TestCase): + def test(self): + solver = DayFourSolver( + dedent( + """ + MMMSXXMASM + MSAMXMSMSA + AMXSXMAAMM + MSAMASMSMX + XMASAMXAMM + XXAMMXXAMA + SMSMSASXSS + SAXAMASAAA + MAMMMXMMMM + MXMXAXMASX + """ + ) + ) + self.assertEqual( + solver.grid[0], + ("M", "M", "M", "S", "X", "X", "M", "A", "S", "M"), + ) + self.assertEqual(solver.solve_p1(), 18) + self.assertEqual(solver.solve_p2(), 9) diff --git a/puzzles/5.py b/puzzles/5.py index 34d2e65..06fe76a 100644 --- a/puzzles/5.py +++ b/puzzles/5.py @@ -1,110 +1,114 @@ -from typing import Iterator +from textwrap import dedent +from typing import Iterator, override +from unittest import TestCase -test_input = """ -47|53 -97|13 -97|61 -97|47 -75|29 -61|13 -75|53 -29|13 -97|29 -53|29 -61|53 -97|53 -61|29 -47|13 -75|47 -97|75 -47|61 -75|61 -47|29 -75|13 -53|13 - -75,47,61,53,29 -97,61,53,29,13 -75,29,13 -75,97,47,61,53 -61,13,29 -97,13,75,29,47 -""" - -test_solution_p1 = 143 -test_solution_p2 = 123 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - rules, updates = _parse_rules_and_updates(puzzle_input) - return sum( - update[len(update) // 2] - for update in updates - if _is_correctly_ordered(rules, update) - ) +class DayFiveSolver(Solver): + ordering_rules: dict[int, set[int]] + updates: list[tuple[int, ...]] + + @override + def __init__(self, puzzle_input: str): + ordering_rules_input, _, updates_input = puzzle_input.partition("\n\n") + self.ordering_rules = {} + + for line in ordering_rules_input.strip().split("\n"): + a, _, b = line.partition("|") + a, b = int(a), int(b) + if b not in self.ordering_rules: + self.ordering_rules[b] = set() + self.ordering_rules[b].add(a) + + self.updates = [ + tuple(map(int, line.split(","))) + for line in updates_input.strip().split("\n") + ] + + @override + def solve_p1(self) -> int: + return sum( + update[len(update) // 2] + for update in self.updates + if self.is_correctly_ordered(update) + ) + + @override + def solve_p2(self) -> int: + return sum( + self.fix_update(update)[len(update) // 2] + for update in self.updates + if not self.is_correctly_ordered(update) + ) + + def is_correctly_ordered(self, update: tuple[int, ...]) -> bool: + contains = set(update) + seen = set() + + for i in update: + for j in self.ordering_rules.get(i, ()): + if j in contains and j not in seen: + return False + seen.add(i) + + return True + + def fix_update(self, update: tuple[int, ...]) -> tuple[int, ...]: + contains = set(update) + seen = set() + + fixed_update = list() + + def fix_item(i): + for j in self.ordering_rules.get(i, ()): + if j in contains and j not in seen: + fix_item(j) + fixed_update.append(i) + seen.add(i) + + for i in update: + if i not in seen: + fix_item(i) + + return tuple(fixed_update) -def solve_p2(puzzle_input: str) -> int: - rules, updates = _parse_rules_and_updates(puzzle_input) - corrected_updates = ( - _fix_update(rules, update) - for update in updates - if not _is_correctly_ordered(rules, update) - ) - return sum(update[len(update) // 2] for update in corrected_updates) +class TestDayFiveSolver(TestCase): + def test(self): + solver = DayFiveSolver( + dedent( + """ + 47|53 + 97|13 + 97|61 + 97|47 + 75|29 + 61|13 + 75|53 + 29|13 + 97|29 + 53|29 + 61|53 + 97|53 + 61|29 + 47|13 + 75|47 + 97|75 + 47|61 + 75|61 + 47|29 + 75|13 + 53|13 - -OrderingRules = dict[int, set[int]] -Update = tuple[int, ...] - - -def _parse_rules_and_updates( - puzzle_input: str, -) -> tuple[OrderingRules, Iterator[Update]]: - ordering_rules, _, updates = puzzle_input.partition("\n\n") - - rules: OrderingRules = {} - for line in ordering_rules.strip().split("\n"): - a, _, b = line.partition("|") - a, b = int(a), int(b) - if b not in rules: - rules[b] = set() - rules[b].add(a) - - return ( - rules, - (tuple(map(int, line.split(","))) for line in updates.strip().split("\n")), - ) - - -def _is_correctly_ordered(rules: OrderingRules, update: Update) -> bool: - contains = set(update) - seen = set() - - for i in update: - for j in rules.get(i, ()): - if j in contains and j not in seen: - return False - seen.add(i) - - return True - - -def _fix_update(rules: OrderingRules, update: Update) -> Update: - contains = set(update) - seen = set() - - fixed_update = list() - - def _fix_item(i): - for j in rules.get(i, ()): - if j in contains and j not in seen: - _fix_item(j) - fixed_update.append(i) - seen.add(i) - - for i in update: - if i not in seen: - _fix_item(i) - - return tuple(fixed_update) + 75,47,61,53,29 + 97,61,53,29,13 + 75,29,13 + 75,97,47,61,53 + 61,13,29 + 97,13,75,29,47 + """ + ) + ) + self.assertEqual(solver.solve_p1(), 143) + self.assertEqual(solver.solve_p2(), 123) diff --git a/puzzles/6.py b/puzzles/6.py index 4c71977..2a63175 100644 --- a/puzzles/6.py +++ b/puzzles/6.py @@ -1,122 +1,160 @@ -from typing import NamedTuple +from dataclasses import dataclass +from textwrap import dedent +from typing import Literal, NamedTuple, get_args, override +from unittest import TestCase -test_input = """ -....#..... -.........# -.......... -..#....... -.......#.. -.......... -.#..^..... -........#. -#......... -......#... -""" +from puzzles._solver import Solver -test_solution_p1 = 41 -test_solution_p2 = 6 - - -def solve_p1(puzzle_input: str) -> int: - map_info = _parse_map(puzzle_input) - traversal_analysis = _traverse_map(map_info) - unique_locations = set((a, b) for a, b, _ in traversal_analysis.visited) - return len(unique_locations) - - -def solve_p2(puzzle_input: str) -> int: - map_info = _parse_map(puzzle_input) - added_obstacles_candidates = set() - - for i, line in enumerate(map_info.map): - for j, cell in enumerate(line): - if not cell: - map_info.map[i][j] = True - if _traverse_map(map_info).stuck: - added_obstacles_candidates.add((i, j)) - map_info.map[i][j] = False - - # Remove guard location from candidates - added_obstacles_candidates.discard((map_info.guard[:2])) - - return len(added_obstacles_candidates) - - -class MapInfo(NamedTuple): - map: list[list[bool]] - guard: tuple[int, int, str] - - -def _parse_map(puzzle_input: str) -> MapInfo: - puzzle_map = [] - guard = None - - for x, line in enumerate(puzzle_input.strip().split("\n")): - if not line: - continue - line_list = list() - for y, cell in enumerate(line): - if cell in "^v<>": - guard = (x, y, cell) - line_list.append(cell == "#") - puzzle_map.append(line_list) - - assert guard is not None, "Guard not found in map" - assert all( - len(line) == len(puzzle_map[0]) for line in puzzle_map - ), "Map is not rectangular" - - return MapInfo(puzzle_map, guard) - - -class TraversalAnalysis(NamedTuple): - visited: set[tuple[int, int]] - stuck: bool - - -_rotation_map = { - "^": ">", - "v": "<", - "<": "^", - ">": "v", +direction_map = { + "^": (-1, 0), + "v": (1, 0), + "<": (0, -1), + ">": (0, 1), } -def _traverse_map(map_info: MapInfo) -> TraversalAnalysis: - x, y, direction = map_info.guard - visited = set() - visited.add((x, y, direction)) +class GuardPosition(NamedTuple): + x: int + y: int + direction: tuple[int, int] - while True: - # Determine next cell - match direction: - case "^": - next_x, next_y = x - 1, y - case "v": - next_x, next_y = x + 1, y - case "<": - next_x, next_y = x, y - 1 - case ">": - next_x, next_y = x, y + 1 - # Guard patrols out of bounds - if ( - next_x < 0 - or next_x >= len(map_info.map) - or next_y < 0 - or next_y >= len(map_info.map[0]) - ): - break +def move_position(position: GuardPosition) -> GuardPosition: + return GuardPosition( + position.x + position.direction[0], + position.y + position.direction[1], + position.direction, + ) - # Turn right if an obstacle is encountered - if map_info.map[next_x][next_y]: - direction = _rotation_map[direction] - # Otherwise, move to next cell - else: - x, y = next_x, next_y - if (x, y, direction) in visited: +def rotate_position(position: GuardPosition) -> GuardPosition: + return GuardPosition( + position.x, + position.y, + (position.direction[1], -position.direction[0]), + ) + + +class TestGuardPosition(TestCase): + def test(self): + guard = GuardPosition(0, 0, (0, 1)) + self.assertEqual(move_position(guard), GuardPosition(0, 1, (0, 1))) + self.assertEqual(rotate_position(guard), GuardPosition(0, 0, (1, 0))) + + +class TraversalAnalysis(NamedTuple): + visited: set[GuardPosition] + stuck: bool + + +def unique_locations(traversal_analysis: TraversalAnalysis) -> set[tuple[int, int]]: + return set((p.x, p.y) for p in traversal_analysis.visited) + + +class TestTraversalAnalysis(TestCase): + def test(self): + analysis = TraversalAnalysis( + {GuardPosition(0, 0, (0, 1)), GuardPosition(2, 5, (1, 0))}, + False, + ) + self.assertEqual( + unique_locations(analysis), + {(0, 0), (2, 5)}, + ) + + +class DaySixSolver(Solver): + puzzle_map: list[list[bool]] + guard_origin: GuardPosition + + @override + def __init__(self, puzzle_input: str): + self.puzzle_map = [] + guard_origin = None + + for x, line in enumerate(puzzle_input.strip().split("\n")): + line_list = list() + for y, cell in enumerate(line): + if cell in direction_map: + guard_origin = GuardPosition(x, y, direction_map[cell]) + line_list.append(cell == "#") + self.puzzle_map.append(line_list) + + if guard_origin is None: + raise ValueError("Guard not found in map") + + self.guard_origin = guard_origin + + if not all(len(line) == len(self.puzzle_map[0]) for line in self.puzzle_map): + raise ValueError("Map is not rectangular") + + @override + def solve_p1(self) -> int: + traversal_analysis = self.traverse_map() + return len(unique_locations(traversal_analysis)) + + @override + def solve_p2(self) -> int: + candidate_locations = unique_locations(self.traverse_map()) + candidate_locations.discard((self.guard_origin.x, self.guard_origin.y)) + verified_locations = set() + + for x, y in candidate_locations: + if not self.puzzle_map[x][y]: + self.puzzle_map[x][y] = True + if self.traverse_map().stuck: + verified_locations.add((x, y)) + self.puzzle_map[x][y] = False + + return len(verified_locations) + + def out_of_bounds(self, guard_position: GuardPosition) -> bool: + return ( + guard_position.x < 0 + or guard_position.x >= len(self.puzzle_map) + or guard_position.y < 0 + or guard_position.y >= len(self.puzzle_map[0]) + ) + + def traverse_map(self) -> TraversalAnalysis: + position = self.guard_origin + visited = set({position}) + + while True: + next_position = move_position(position) + if self.out_of_bounds(next_position): + return TraversalAnalysis(visited, False) + + # Obstacle is encountered + if self.puzzle_map[next_position.x][next_position.y]: + next_position = rotate_position(position) + + # Guard is stuck + if next_position in visited: return TraversalAnalysis(visited, True) - visited.add((x, y, direction)) - return TraversalAnalysis(visited, False) + position = next_position + visited.add(position) + + +class TestDaySixSolver(TestCase): + def test(self): + solver = DaySixSolver( + dedent( + """ + ....#..... + .........# + .......... + ..#....... + .......#.. + .......... + .#..^..... + ........#. + #......... + ......#... + """ + ) + ) + self.assertEqual(solver.guard_origin, GuardPosition(6, 4, (-1, 0))) + self.assertEqual(solver.solve_p1(), 41) + self.assertEqual(solver.solve_p2(), 6) diff --git a/puzzles/_solver.py b/puzzles/_solver.py new file mode 100644 index 0000000..f977b77 --- /dev/null +++ b/puzzles/_solver.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class Solver(ABC): + @abstractmethod + def __init__(self, puzzle_input: str): ... + + @abstractmethod + def solve_p1(self) -> int: ... + + @abstractmethod + def solve_p2(self) -> int: ... diff --git a/solve.py b/solve.py index b15d625..8410349 100644 --- a/solve.py +++ b/solve.py @@ -1,80 +1,60 @@ import sys from importlib import import_module +from inspect import getmembers, isclass from time import time +from unittest import TextTestRunner, defaultTestLoader -from advent.errors import InvalidSessionIDError, PuzzleNotFoundError from advent.functions import get_puzzle_input - -def usage(): - print("Usage: python solve.py ") +from puzzles._solver import Solver def main(): if len(sys.argv) != 2: - usage() - sys.exit(1) + raise RuntimeError("Usage: python solve.py ") try: day = int(sys.argv[1]) except ValueError: - print("Day must be an integer") - usage() - sys.exit(1) + raise RuntimeError("Day must be an integer") + print(f"Loading 2024, day {day} solver...") try: - print(f"Loading 2024, day {day} solver") mod = import_module(f"puzzles.{day}") except ModuleNotFoundError: print(f"Solver for day {day} not found") sys.exit(1) - try: - print("Testing part 1 solution...") - test_input_p1 = getattr(mod, "test_input_p1", getattr(mod, "test_input", None)) - start = time() - assert mod.solve_p1(test_input_p1) == mod.test_solution_p1 - print(f"Test passed in {time() - start:.3f} seconds") + solvers = getmembers(mod, lambda m: isclass(m) and issubclass(m, Solver)) + if len(solvers) == 0: + raise RuntimeError("No solver found") + solver_class: type[Solver] = solvers[0][1] - print("Testing part 2 solution...") - test_input_p2 = getattr(mod, "test_input_p2", getattr(mod, "test_input", None)) - start = time() - assert mod.solve_p2(test_input_p2) == mod.test_solution_p2 - print(f"Test passed in {time() - start:.3f} seconds") + test_suite = defaultTestLoader.loadTestsFromModule(mod) + test_runner = TextTestRunner(verbosity=0) + test_result = test_runner.run(test_suite) + if not test_result.wasSuccessful(): + raise RuntimeError("Tests failed") - except AssertionError: - print("Test failed") - sys.exit(1) + print("Fetching puzzle input...") + puzzle_input = get_puzzle_input(2024, int(day)) - except Exception as exc: - print("Error:", exc) - sys.exit(1) + solver = solver_class(puzzle_input) - try: - print("Fetching puzzle input...") - puzzle_input = get_puzzle_input(2024, int(day)) - except (PuzzleNotFoundError, InvalidSessionIDError) as exc: - print(exc) - sys.exit(1) + print("Solving part 1...") + start = time() + solution_p1 = solver.solve_p1() + print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)") - try: - print("Solving part 1...") - start = time() - solution_p1 = mod.solve_p1(puzzle_input) - print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)") - - print("Solving part 2...") - solution_p2 = mod.solve_p2(puzzle_input) - print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)") - - except NotImplementedError: - print("Puzzle solver is incomplete. Exiting") - sys.exit(1) - - except Exception as exc: - print("Error:", exc) - sys.exit(1) + print("Solving part 2...") + start = time() + solution_p2 = solver.solve_p2() + print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)") if __name__ == "__main__": - main() + try: + main() + except Exception as exc: + print(exc) + sys.exit(1)