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..371babe 100644 --- a/puzzles/4.py +++ b/puzzles/4.py @@ -1,83 +1,90 @@ -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 + +Line = tuple[str, ...] +Grid = tuple[Line, ...] -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: Grid + @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[Line]: + 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[Grid]: + 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) -> Line: + 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..027aa62 100644 --- a/puzzles/5.py +++ b/puzzles/5.py @@ -1,110 +1,116 @@ -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 +from puzzles._solver import Solver -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 - - -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) - ) - - -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) - - -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") +class DayFiveSolver(Solver): + ordering_rules: dict[int, set[int]] + updates: list[Update] - 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) + @override + def __init__(self, puzzle_input: str): + ordering_rules_input, _, updates_input = puzzle_input.partition("\n\n") + self.ordering_rules = {} - return ( - rules, - (tuple(map(int, line.split(","))) for line in updates.strip().split("\n")), - ) + 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: Update) -> 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: Update) -> Update: + 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 _is_correctly_ordered(rules: OrderingRules, update: Update) -> bool: - contains = set(update) - seen = set() +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 - 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/7.py b/puzzles/7.py index 9437c31..a499837 100644 --- a/puzzles/7.py +++ b/puzzles/7.py @@ -1,46 +1,10 @@ from functools import partial from math import log -from typing import Callable, Iterator, NamedTuple +from textwrap import dedent +from typing import Callable, Iterator, NamedTuple, override +from unittest import TestCase -test_input = """ -190: 10 19 -3267: 81 40 27 -83: 17 5 -156: 15 6 -7290: 6 8 6 15 -161011: 16 10 13 -192: 17 8 14 -21037: 9 7 18 13 -292: 11 6 16 20 -""" - -test_solution_p1 = 3749 -test_solution_p2 = 11387 - - -def solve_p1(puzzle_input: str) -> int: - equations = _parse_equations(puzzle_input) - calibration_guard = partial( - _is_calibrated, - [ - int.__add__, - int.__mul__, - ], - ) - return sum(equation.target for equation in filter(calibration_guard, equations)) - - -def solve_p2(puzzle_input: str) -> int: - equations = _parse_equations(puzzle_input) - calibration_guard = partial( - _is_calibrated, - [ - int.__add__, - int.__mul__, - _concat_ints, - ], - ) - return sum(equation.target for equation in filter(calibration_guard, equations)) +from puzzles._solver import Solver class Equation(NamedTuple): @@ -48,36 +12,98 @@ class Equation(NamedTuple): factors: tuple[int, ...] -def _parse_equations(puzzle_input: str) -> Iterator[Equation]: - for line in puzzle_input.strip().split("\n"): - if line: - result_string, _, factors_string = line.partition(": ") - yield Equation( - int(result_string), - tuple(map(int, factors_string.split(" "))), - ) - - Operator = Callable[[int, int], int] -def _is_calibrated(operators: list[Operator], equation: Equation) -> bool: - def _eval_permutations(factors: tuple[int, ...]) -> Iterator[int]: +def is_calibrated(operators: list[Operator], equation: Equation) -> bool: + def eval_permutations(factors: tuple[int, ...]) -> Iterator[int]: assert len(factors) > 0 tail = factors[-1] if len(factors) == 1: yield tail return - for head in _eval_permutations(factors[:-1]): + for head in eval_permutations(factors[:-1]): if head > equation.target: continue for operator in operators: yield operator(head, tail) return any( - result == equation.target for result in _eval_permutations(equation.factors) + result == equation.target for result in eval_permutations(equation.factors) ) -def _concat_ints(a: int, b: int) -> int: +def concat_ints(a: int, b: int) -> int: return 10 ** int(log(b, 10) + 1) * a + b + + +class TestEquation(TestCase): + def test_is_calibrated(self): + equation = Equation(190, (10, 19)) + self.assertTrue(is_calibrated([int.__add__, int.__mul__], equation)) + self.assertFalse(is_calibrated([int.__add__], equation)) + + def test_concat_ints(self): + self.assertEqual(concat_ints(1, 2), 12) + self.assertEqual(concat_ints(12, 345), 12345) + + +class DaySevenSolver(Solver): + equations: list[Equation] + + @override + def __init__(self, puzzle_input: str): + self.equations = [] + for line in puzzle_input.strip().split("\n"): + result_string, _, factors_string = line.partition(": ") + self.equations.append( + Equation( + int(result_string), + tuple(map(int, factors_string.split(" "))), + ) + ) + + @override + def solve_p1(self) -> int: + return sum( + equation.target + for equation in filter( + partial(is_calibrated, [int.__add__, int.__mul__]), + self.equations, + ) + ) + + @override + def solve_p2(self) -> int: + return sum( + equation.target + for equation in filter( + partial(is_calibrated, [int.__add__, int.__mul__, concat_ints]), + self.equations, + ) + ) + + +class TestDaySevenSolver(TestCase): + def test(self): + solver = DaySevenSolver( + dedent( + """ + 190: 10 19 + 3267: 81 40 27 + 83: 17 5 + 156: 15 6 + 7290: 6 8 6 15 + 161011: 16 10 13 + 192: 17 8 14 + 21037: 9 7 18 13 + 292: 11 6 16 20 + """ + ) + ) + self.assertEqual( + solver.equations[0], + Equation(190, (10, 19)), + ) + self.assertEqual(solver.solve_p1(), 3749) + self.assertEqual(solver.solve_p2(), 11387) diff --git a/puzzles/8.py b/puzzles/8.py index 2e3ea81..5027ab3 100644 --- a/puzzles/8.py +++ b/puzzles/8.py @@ -1,110 +1,117 @@ +from functools import reduce from itertools import combinations -from typing import NamedTuple +from textwrap import dedent +from typing import Set, override +from unittest import TestCase -test_input = """ -............ -........0... -.....0...... -.......0.... -....0....... -......A..... -............ -............ -........A... -.........A.. -............ -............ -""" +from puzzles._solver import Solver -test_solution_p1 = 14 -test_solution_p2 = 34 +Point = tuple[int, int] -def solve_p1(puzzle_input: str) -> int: - grid = _parse_signal_grid(puzzle_input) - antinodes = _calculate_antinodes(grid) - # _print_grid(grid, antinodes) - return len(antinodes) - - -def solve_p2(puzzle_input: str) -> int: - grid = _parse_signal_grid(puzzle_input) - antinodes = _calculate_antinodes(grid, harmonic=True) - # _print_grid(grid, antinodes) - return len(antinodes) - - -class Point(NamedTuple): - x: int - y: int - - -class SignalGrid(NamedTuple): +class DayEightSolver(Solver): width: int height: int antenna_arrays: dict[str, set[Point]] + @override + def __init__(self, puzzle_input: str): + self.antenna_arrays = {} -def _parse_signal_grid(puzzle_input: str) -> SignalGrid: - lines = puzzle_input.strip().split("\n") - antenna_arrays: dict[str, set[Point]] = {} + lines = puzzle_input.strip().split("\n") + self.width = len(lines[0]) + self.height = len(lines) - for x, line in enumerate(lines): - for y, char in enumerate(line): - if char.isalnum(): - if char not in antenna_arrays: - antenna_arrays[char] = set() - antenna_arrays[char].add(Point(x, y)) + for x, line in enumerate(puzzle_input.strip().split("\n")): + for y, char in enumerate(line): + if char.isalnum(): + if char not in self.antenna_arrays: + self.antenna_arrays[char] = set() + self.antenna_arrays[char].add((x, y)) - return SignalGrid(len(lines[0]), len(lines), antenna_arrays) + @override + def solve_p1(self) -> int: + return len( + reduce( + Set.union, + ( + self.antinodes(a, b, harmonic=False) + for antenna_array in self.antenna_arrays.values() + for a, b in combinations(antenna_array, 2) + ), + ) + ) + + @override + def solve_p2(self) -> int: + return len( + reduce( + Set.union, + ( + self.antinodes(a, b, harmonic=True) + for antenna_array in self.antenna_arrays.values() + for a, b in combinations(antenna_array, 2) + ), + ) + ) + + def antinodes(self, a: Point, b: Point, harmonic=False) -> set[Point]: + points = set() + + dx = b[0] - a[0] + dy = b[1] - a[1] + + if harmonic: + antinode = a + while self.is_in_grid(antinode): + points.add(antinode) + antinode = (antinode[0] - dx, antinode[1] - dy) + + antinode = b + while self.is_in_grid(antinode): + points.add(antinode) + antinode = (antinode[0] + dx, antinode[1] + dy) + + else: + antinode = (a[0] - dx, a[1] - dy) + if self.is_in_grid(antinode): + points.add(antinode) + + antinode = (b[0] + dx, b[1] + dy) + if self.is_in_grid(antinode): + points.add(antinode) + + return points + + def is_in_grid(self, point: Point) -> bool: + return 0 <= point[0] < self.height and 0 <= point[1] < self.width -def _calculate_antinodes(grid: SignalGrid, harmonic=False) -> set[Point]: - antinodes: set[Point] = set() - - for antenna_array in grid.antenna_arrays.values(): - for a, b in combinations(antenna_array, 2): - dx = b.x - a.x - dy = b.y - a.y - - # Left antinodes - if harmonic: - antinode = a - while True: - antinodes.add(antinode) - antinode = Point(antinode.x - dx, antinode.y - dy) - if not _is_in_grid(grid, antinode): - break - else: - antinode = Point(a.x - dx, a.y - dy) - if _is_in_grid(grid, antinode): - antinodes.add(antinode) - - # Right antinodes - if harmonic: - antinode = b - while True: - antinodes.add(antinode) - antinode = Point(antinode.x + dx, antinode.y + dy) - if not _is_in_grid(grid, antinode): - break - else: - antinode = Point(b.x + dx, b.y + dy) - if _is_in_grid(grid, antinode): - antinodes.add(antinode) - - return antinodes - - -def _is_in_grid(grid: SignalGrid, point: Point) -> bool: - return 0 <= point.x < grid.height and 0 <= point.y < grid.width - - -def _print_grid(grid: SignalGrid, antinodes: set[Point]): - output = [["."] * grid.width for _ in range(grid.height)] - for antinode in antinodes: - output[antinode.x][antinode.y] = "#" - for frequency, antenna_array in grid.antenna_arrays.items(): - for antenna in antenna_array: - output[antenna.x][antenna.y] = frequency - print("\n".join("".join(row) for row in output)) +class TestDayEightSolver(TestCase): + def test(self): + solver = DayEightSolver( + dedent( + """ + ............ + ........0... + .....0...... + .......0.... + ....0....... + ......A..... + ............ + ............ + ........A... + .........A.. + ............ + ............ + """ + ) + ) + self.assertEqual(solver.width, 12) + self.assertEqual(solver.height, 12) + self.assertSetEqual( + solver.antenna_arrays["0"], + {(1, 8), (2, 5), (3, 7), (4, 4)}, + ) + self.assertEqual(solver.solve_p1(), 14) + self.assertEqual(solver.solve_p2(), 34) diff --git a/puzzles/9.py b/puzzles/9.py index eb84203..66b041e 100644 --- a/puzzles/9.py +++ b/puzzles/9.py @@ -1,26 +1,166 @@ -from itertools import islice, pairwise -from typing import NamedTuple +from itertools import count, pairwise +import math +from typing import Iterable, NamedTuple, override +from unittest import TestCase -from more_itertools import chunked, sliced +from more_itertools import chunked, ilen -test_input = """ -2333133121414131402 -""" - -test_solution_p1 = 1928 -test_solution_p2 = 2858 +from puzzles._solver import Solver -def solve_p1(puzzle_input: str) -> int: - blocks = _parse_blocks(puzzle_input) - compacted_blocks = _compact_blocks(blocks) - return _hash_blocks(compacted_blocks) +class LinkedList[T]: + __head: "LinkedListNode[T] | None" + __tail: "LinkedListNode[T] | None" + + __slots__ = ("__head", "__tail") + + @property + def head(self) -> "LinkedListNode[T] | None": + if not self.__head and self.__tail: + self.__head = self.__tail + while self.__head and self.__head.left: + self.__head = self.__head.left + return self.__head + + @property + def tail(self) -> "LinkedListNode[T] | None": + if not self.__tail and self.__head: + self.__tail = self.__head + while self.__tail and self.__tail.right: + self.__tail = self.__tail.right + return self.__tail + + def __init__(self, *values: Iterable[T]) -> None: + self.__head = None + self.__tail = None + + for value in values: + self.append(value) + + def append(self, value: T) -> None: + if tail := self.tail: + self.__tail = LinkedListNode(value, tail, None) + tail.right = self.__tail + else: + self.__tail = LinkedListNode(value, None, None) + + def prepend(self, value: T) -> None: + if head := self.head: + self.__head = LinkedListNode(value, None, head) + head.left = self.__head + else: + self.__head = LinkedListNode(value, None, None) + + def pop_tail(self) -> T | None: + if (tail := self.tail) is None: + raise IndexError("pop from empty LinkedList") + + if tail.left: + tail.left.right = None + + self.__tail = tail.left + tail.left = None + + return tail.value + + def pop_head(self) -> T: + if (head := self.head) is None: + raise IndexError("pop from empty LinkedList") + + if head.right: + head.right.left = None + + self.__head = head.right + head.right = None + + return head.value + + def copy(self) -> "LinkedList[T]": + return LinkedList(*self) + + def __iter__(self) -> Iterable[T]: + node = self.head + while node is not None: + yield node.value + node = node.right + + def __reversed__(self) -> Iterable[T]: + node = self.tail + while node is not None: + yield node.value + node = node.left + + def __len__(self) -> int: + return ilen(self) + + def __str__(self) -> str: + return f"LinkedList({', '.join(map(str, self))})" -def solve_p2(puzzle_input: str) -> int: - blocks = _parse_blocks(puzzle_input) - compacted_blocks = _compact_blocks_no_fragmentation(blocks) - return _hash_blocks(compacted_blocks) +class LinkedListNode[T]: + value: T + left: "LinkedListNode[T] | None" + right: "LinkedListNode[T] | None" + + __slots__ = ("value", "left", "right") + + def __init__( + self, + value: T, + left: "LinkedListNode[T] | None", + right: "LinkedListNode[T] | None", + ) -> None: + self.value = value + self.left = left + self.right = right + + def insert_left(self, value: T) -> None: + node = LinkedListNode(value, self.left, self) + if self.left: + self.left.right = node + self.left = node + + def insert_right(self, value: T) -> None: + node = LinkedListNode(value, self, self.right) + if self.right: + self.right.left = node + self.right = node + + def pop(self) -> T: + if self.left: + self.left.right = self.right + if self.right: + self.right.left = self.left + return self.value + + +class TestLinkedList(TestCase): + def test(self): + linked_list = LinkedList(1, 2, 3, 4, 5) + self.assertEqual(linked_list.head.value, 1) + self.assertEqual(linked_list.tail.value, 5) + self.assertListEqual(list(linked_list), [1, 2, 3, 4, 5]) + self.assertListEqual(list(reversed(linked_list)), [5, 4, 3, 2, 1]) + self.assertEqual(len(linked_list), 5) + + self.assertEqual(linked_list.pop_tail(), 5) + self.assertEqual(linked_list.tail.value, 4) + self.assertEqual(linked_list.pop_head(), 1) + self.assertEqual(linked_list.head.value, 2) + linked_list.append(6) + self.assertEqual(linked_list.tail.value, 6) + linked_list.prepend(7) + self.assertEqual(linked_list.head.value, 7) + self.assertListEqual(list(linked_list), [7, 2, 3, 4, 6]) + + middle = linked_list.tail.left.left + self.assertEqual(middle.value, 3) + middle.insert_left(8) + self.assertListEqual(list(linked_list), [7, 2, 8, 3, 4, 6]) + middle.insert_right(9) + self.assertListEqual(list(linked_list), [7, 2, 8, 3, 9, 4, 6]) + self.assertEqual(middle.pop(), 3) + self.assertListEqual(list(linked_list), [7, 2, 8, 9, 4, 6]) class Block(NamedTuple): @@ -29,110 +169,133 @@ class Block(NamedTuple): size: int -def _parse_blocks(puzzle_input: str) -> list[Block]: - blocks: list[Block] = [] - cursor_position = 0 - current_id = 0 +def compact_drive(drive: LinkedList[Block]) -> LinkedList[Block]: + if not len(drive): + return drive - snapshot = map(int, puzzle_input.strip()) - for pair in chunked(snapshot, 2): - if data_size := pair[0]: - blocks.append(Block(current_id, cursor_position, data_size)) - cursor_position += data_size - current_id += 1 - if len(pair) == 2: - cursor_position += pair[1] + drive = drive.copy() + moving = drive.pop_tail() + cursor = drive.head + assert cursor is not None - return blocks + while cursor.right is not None: + a = cursor.value + b = cursor.right.value + free_space = b.position - a.position - a.size + if not free_space: + cursor = cursor.right + continue -def _compact_blocks(blocks: list[Block]) -> list[Block]: - assert blocks - blocks = blocks.copy() + insertion = Block( + moving.id, + a.position + a.size, + min(free_space, moving.size), + ) - compacted_blocks: list[Block] = [] - current_position = 0 - moving_block = blocks.pop() + cursor.insert_right(insertion) + cursor = cursor.right - while blocks: - next_block = blocks.pop(0) - free_space = next_block.position - current_position - while free_space: - assert moving_block.size > 0 - allocation = min(free_space, moving_block.size) - compacted_blocks.append( - Block( - moving_block.id, - current_position, - allocation, - ) + if insertion.size < moving.size: + moving = Block( + moving.id, + moving.position, + moving.size - insertion.size, ) - current_position += allocation - free_space -= allocation - if moving_block.size > allocation: - moving_block = Block( - moving_block.id, - moving_block.position + allocation, - moving_block.size - allocation, - ) - elif blocks: - moving_block = blocks.pop() - else: - break + else: + moving = drive.pop_tail() - compacted_blocks.append(next_block) - current_position += next_block.size - - compacted_blocks.append( + cursor.insert_right( Block( - moving_block.id, - current_position, - moving_block.size, + moving.id, + cursor.value.position + cursor.value.size, + moving.size, ) ) - return compacted_blocks + return drive -def _compact_blocks_no_fragmentation(blocks: list[Block]) -> list[Block]: - assert blocks - blocks = blocks.copy() +def compact_drive_without_fragmentation(drive: LinkedList[Block]) -> LinkedList[Block]: + if not len(drive): + return drive - compacted_blocks: list[Block] = [] + drive = drive.copy() + moving = drive.tail - while len(blocks) > 1: - moving_block = blocks[-1] - for (_, a), (i, b) in pairwise(enumerate(blocks)): + while moving: + cursor = drive.head + + while cursor != moving: + a = cursor.value + b = cursor.right.value free_space = b.position - a.position - a.size - if free_space >= moving_block.size: - modified_block = Block( - moving_block.id, - a.position + a.size, - moving_block.size, + + if free_space >= moving.value.size: + moving.pop() + cursor.insert_right( + Block( + moving.value.id, + a.position + a.size, + moving.value.size, + ) ) - blocks.pop() - blocks.insert(i, modified_block) break - else: - blocks.pop() - compacted_blocks.append(moving_block) - compacted_blocks.append(blocks[0]) + cursor = cursor.right - return list(sorted(compacted_blocks, key=lambda block: block.position)) + moving = moving.left + + return drive -def _print_blocks(blocks: list[Block]) -> str: +def hash_drive(drive: LinkedList[Block]) -> int: + return sum( + block.id * block.size * (block.position * 2 + block.size - 1) // 2 + for block in drive + ) + + +def print_drive(drive: LinkedList[Block]) -> None: snapshot = [] - for a, b in pairwise(blocks): + for a, b in pairwise(drive): snapshot.append(str(a.id) * a.size) snapshot.append("." * (b.position - a.position - a.size)) - snapshot.append(str(blocks[-1].id) * blocks[-1].size) + snapshot.append(str(drive.tail.value.id) * drive.tail.value.size) + print("".join(snapshot)) -def _hash_blocks(blocks: list[Block]) -> int: - return sum( - block.id * pos - for block in blocks - for pos in range(block.position, block.position + block.size) - ) +class DayNineSolver(Solver): + drive: LinkedList[Block] + + @override + def __init__(self, puzzle_input: str): + self.drive = LinkedList() + snapshot = map(int, puzzle_input.strip()) + cursor_position = 0 + current_id = count() + + for pair in chunked(snapshot, 2): + if data_size := pair[0]: + id = next(current_id) + self.drive.append(Block(id, cursor_position, data_size)) + cursor_position += data_size + if len(pair) == 2: + cursor_position += pair[1] + + @override + def solve_p1(self) -> int: + compacted_drive = compact_drive(self.drive) + return hash_drive(compacted_drive) + + @override + def solve_p2(self) -> int: + compacted_drive = compact_drive_without_fragmentation(self.drive) + return hash_drive(compacted_drive) + + +class TestDayNineSolver(TestCase): + def test(self): + solver = DayNineSolver("2333133121414131402\n") + self.assertEqual(solver.solve_p1(), 1928) + self.assertEqual(solver.solve_p2(), 2858) 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)