Restructure solver modules with classes and unittest suites

This commit is contained in:
2024-12-09 20:47:50 -08:00
parent 7c021ab87f
commit f8c829c8af
10 changed files with 736 additions and 602 deletions

View File

@@ -1,33 +1,48 @@
from collections import Counter from collections import Counter
from textwrap import dedent
from typing import override
from unittest import TestCase
test_input = """ from puzzles._solver import Solver
3 4
4 3
2 5
1 3
3 9
3 3
"""
test_solution_p1 = 11
test_solution_p2 = 31
def solve_p1(puzzle_input: str) -> int: class DayOneSolver(Solver):
list1, list2 = _parse_lists(puzzle_input) list1: list[int]
pairs = zip(sorted(list1), sorted(list2)) list2: list[int]
distances = (abs(pair[0] - pair[1]) for pair in pairs)
return sum(distances)
@override
def solve_p2(puzzle_input: str) -> int: def __init__(self, puzzle_input: str):
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")) lines = (line.partition(" ") for line in puzzle_input.strip().split("\n"))
list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines))
return list1, list2 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)
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)

View File

@@ -1,63 +1,88 @@
from itertools import islice from dataclasses import dataclass
from typing import Iterator 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 from more_itertools import ilen
test_input = """ from puzzles._solver import Solver
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
def solve_p1(puzzle_input: str) -> int: @dataclass
reports = _parse_reports(puzzle_input) class Report:
delta_reports = (_deltas(report) for report in reports) levels: tuple[int, ...]
safe_delta_reports = filter(_is_gradual_monotonic, delta_reports)
return ilen(safe_delta_reports)
@cached_property
def deltas(self) -> tuple[int, ...]:
return tuple(b - a for a, b in pairwise(self.levels))
def solve_p2(puzzle_input: str) -> int: def is_gradual_monotonic(self) -> bool:
reports = _parse_reports(puzzle_input) sign = 1 if self.deltas[0] > 0 else -1
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)
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( return all(
delta != 0 and delta / abs(delta) == sign and abs(delta) < 4 delta != 0 and delta / abs(delta) == sign and abs(delta) < 4
for delta in delta_report 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 _dampen_permutations(report: tuple[int, ...]) -> Iterator[tuple[int, ...]]: class DayTwoSolver(Solver):
yield report reports: list[Report]
yield from (report[:i] + report[i + 1 :] for i in range(0, len(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)
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)

View File

@@ -1,40 +1,58 @@
import re import re
from functools import reduce from typing import Literal, override
from unittest import TestCase
test_input_p1 = """ from puzzles._solver import Solver
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)?)\(\)")
def solve_p1(puzzle_input: str) -> int: class DayThreeSolver(Solver):
instructions = ( memory: tuple[int, int] | Literal["do", "don't"]
tuple(map(int, match[:2]))
for match in instruction_pattern.findall(puzzle_input) @override
if match[0] 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
) )
return sum(a * b for a, b in instructions)
@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: class TestDayThreeSolver(TestCase):
instructions = ( def test_solve_p1(self):
match[2] or tuple(map(int, match[:2])) solver = DayThreeSolver(
for match in instruction_pattern.findall(puzzle_input) "xmul(2,4)%&mul[3,7]!@^do_not_mul(5,5)+mul(32,64]then(mul(11,8)mul(8,5))\n"
) )
return reduce( self.assertEqual(solver.solve_p1(), 161)
lambda a, i: (
(1 if i == "do" else 0, a[1]) def test_solve_p2(self):
if isinstance(i, str) solver = DayThreeSolver(
else (a[0], a[1] + a[0] * i[0] * i[1]) "xmul(2,4)&mul[3,7]!^don't()_mul(5,5)+mul(32,64](mul(11,8)undo()?mul(8,5))\n"
), )
instructions, self.assertEqual(solver.solve_p2(), 48)
(1, 0),
)[1]

View File

@@ -1,83 +1,90 @@
from typing import Iterator from textwrap import dedent
from typing import Iterator, override
from unittest import TestCase
test_input = """ from more_itertools import sliding_window
MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX
"""
test_solution_p1 = 18 from puzzles._solver import Solver
test_solution_p2 = 9
Line = tuple[str, ...]
Grid = tuple[Line, ...]
def solve_p1(puzzle_input: str) -> int: class DayFourSolver(Solver):
word_grid = _parse_world_grid(puzzle_input) grid: Grid
return sum(_count_xmas(line) for line in _scan_word_grid(word_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: @override
word_grid = _parse_world_grid(puzzle_input) def solve_p1(self) -> int:
squares = _three_squares(word_grid) targets = (("X", "M", "A", "S"), ("S", "A", "M", "X"))
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( return sum(
1 1
for i in range(len(line) - 3) for line in self.scan_lines()
if line[i : i + 4] in (("X", "M", "A", "S"), ("S", "A", "M", "X")) 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
)
ThreeSquare = tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]] 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]:
def _three_squares(word_grid: tuple[tuple[str, ...], ...]) -> Iterator[ThreeSquare]:
yield from ( yield from (
( (
word_grid[i][j : j + 3], self.grid[i][j : j + size],
word_grid[i + 1][j : j + 3], self.grid[i + 1][j : j + size],
word_grid[i + 2][j : j + 3], self.grid[i + 2][j : j + size],
) )
for i in range(len(word_grid) - 2) for i in range(len(self.grid) - size + 1)
for j in range(len(word_grid[0]) - 2) 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)
) )
def _has_cross_mas(square: ThreeSquare): class TestDayFourSolver(TestCase):
diag_1 = (square[0][0], square[1][1], square[2][2]) def test(self):
diag_2 = (square[0][2], square[1][1], square[2][0]) solver = DayFourSolver(
return (diag_1 == ("M", "A", "S") or diag_1 == ("S", "A", "M")) and ( dedent(
diag_2 == ("M", "A", "S") or diag_2 == ("S", "A", "M") """
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)

View File

@@ -1,110 +1,116 @@
from typing import Iterator from textwrap import dedent
from typing import Iterator, override
from unittest import TestCase
test_input = """ from puzzles._solver import Solver
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
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, ...] Update = tuple[int, ...]
def _parse_rules_and_updates( class DayFiveSolver(Solver):
puzzle_input: str, ordering_rules: dict[int, set[int]]
) -> tuple[OrderingRules, Iterator[Update]]: updates: list[Update]
ordering_rules, _, updates = puzzle_input.partition("\n\n")
rules: OrderingRules = {} @override
for line in ordering_rules.strip().split("\n"): 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 = line.partition("|")
a, b = int(a), int(b) a, b = int(a), int(b)
if b not in rules: if b not in self.ordering_rules:
rules[b] = set() self.ordering_rules[b] = set()
rules[b].add(a) self.ordering_rules[b].add(a)
return ( self.updates = [
rules, tuple(map(int, line.split(",")))
(tuple(map(int, line.split(","))) for line in updates.strip().split("\n")), 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(rules: OrderingRules, update: Update) -> bool: def is_correctly_ordered(self, update: Update) -> bool:
contains = set(update) contains = set(update)
seen = set() seen = set()
for i in update: for i in update:
for j in rules.get(i, ()): for j in self.ordering_rules.get(i, ()):
if j in contains and j not in seen: if j in contains and j not in seen:
return False return False
seen.add(i) seen.add(i)
return True return True
def fix_update(self, update: Update) -> Update:
def _fix_update(rules: OrderingRules, update: Update) -> Update:
contains = set(update) contains = set(update)
seen = set() seen = set()
fixed_update = list() fixed_update = list()
def _fix_item(i): def fix_item(i):
for j in rules.get(i, ()): for j in self.ordering_rules.get(i, ()):
if j in contains and j not in seen: if j in contains and j not in seen:
_fix_item(j) fix_item(j)
fixed_update.append(i) fixed_update.append(i)
seen.add(i) seen.add(i)
for i in update: for i in update:
if i not in seen: if i not in seen:
_fix_item(i) fix_item(i)
return tuple(fixed_update) return tuple(fixed_update)
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
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)

View File

@@ -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 direction_map = {
test_solution_p2 = 6 "^": (-1, 0),
"v": (1, 0),
"<": (0, -1),
def solve_p1(puzzle_input: str) -> int: ">": (0, 1),
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",
} }
def _traverse_map(map_info: MapInfo) -> TraversalAnalysis: class GuardPosition(NamedTuple):
x, y, direction = map_info.guard x: int
visited = set() y: int
visited.add((x, y, direction)) direction: tuple[int, int]
def move_position(position: GuardPosition) -> GuardPosition:
return GuardPosition(
position.x + position.direction[0],
position.y + position.direction[1],
position.direction,
)
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: while True:
# Determine next cell next_position = move_position(position)
match direction: if self.out_of_bounds(next_position):
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
# 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:
return TraversalAnalysis(visited, True)
visited.add((x, y, direction))
return TraversalAnalysis(visited, False) 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)
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)

View File

@@ -1,46 +1,10 @@
from functools import partial from functools import partial
from math import log 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 = """ from puzzles._solver import Solver
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))
class Equation(NamedTuple): class Equation(NamedTuple):
@@ -48,36 +12,98 @@ class Equation(NamedTuple):
factors: tuple[int, ...] 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] Operator = Callable[[int, int], int]
def _is_calibrated(operators: list[Operator], equation: Equation) -> bool: def is_calibrated(operators: list[Operator], equation: Equation) -> bool:
def _eval_permutations(factors: tuple[int, ...]) -> Iterator[int]: def eval_permutations(factors: tuple[int, ...]) -> Iterator[int]:
assert len(factors) > 0 assert len(factors) > 0
tail = factors[-1] tail = factors[-1]
if len(factors) == 1: if len(factors) == 1:
yield tail yield tail
return return
for head in _eval_permutations(factors[:-1]): for head in eval_permutations(factors[:-1]):
if head > equation.target: if head > equation.target:
continue continue
for operator in operators: for operator in operators:
yield operator(head, tail) yield operator(head, tail)
return any( 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 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)

View File

@@ -1,110 +1,117 @@
from functools import reduce
from itertools import combinations from itertools import combinations
from typing import NamedTuple from textwrap import dedent
from typing import Set, override
from unittest import TestCase
test_input = """ from puzzles._solver import Solver
............
........0...
.....0......
.......0....
....0.......
......A.....
............
............
........A...
.........A..
............
............
"""
test_solution_p1 = 14 Point = tuple[int, int]
test_solution_p2 = 34
def solve_p1(puzzle_input: str) -> int: class DayEightSolver(Solver):
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):
width: int width: int
height: int height: int
antenna_arrays: dict[str, set[Point]] 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") lines = puzzle_input.strip().split("\n")
antenna_arrays: dict[str, set[Point]] = {} self.width = len(lines[0])
self.height = len(lines)
for x, line in enumerate(lines): for x, line in enumerate(puzzle_input.strip().split("\n")):
for y, char in enumerate(line): for y, char in enumerate(line):
if char.isalnum(): if char.isalnum():
if char not in antenna_arrays: if char not in self.antenna_arrays:
antenna_arrays[char] = set() self.antenna_arrays[char] = set()
antenna_arrays[char].add(Point(x, y)) 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 _calculate_antinodes(grid: SignalGrid, harmonic=False) -> set[Point]: def antinodes(self, a: Point, b: Point, harmonic=False) -> set[Point]:
antinodes: set[Point] = set() points = set()
for antenna_array in grid.antenna_arrays.values(): dx = b[0] - a[0]
for a, b in combinations(antenna_array, 2): dy = b[1] - a[1]
dx = b.x - a.x
dy = b.y - a.y
# Left antinodes
if harmonic: if harmonic:
antinode = a antinode = a
while True: while self.is_in_grid(antinode):
antinodes.add(antinode) points.add(antinode)
antinode = Point(antinode.x - dx, antinode.y - dy) antinode = (antinode[0] - dx, antinode[1] - 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 antinode = b
while True: while self.is_in_grid(antinode):
antinodes.add(antinode) points.add(antinode)
antinode = Point(antinode.x + dx, antinode.y + dy) antinode = (antinode[0] + dx, antinode[1] + dy)
if not _is_in_grid(grid, antinode):
break
else: else:
antinode = Point(b.x + dx, b.y + dy) antinode = (a[0] - dx, a[1] - dy)
if _is_in_grid(grid, antinode): if self.is_in_grid(antinode):
antinodes.add(antinode) points.add(antinode)
return antinodes 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 _is_in_grid(grid: SignalGrid, point: Point) -> bool: class TestDayEightSolver(TestCase):
return 0 <= point.x < grid.height and 0 <= point.y < grid.width def test(self):
solver = DayEightSolver(
dedent(
def _print_grid(grid: SignalGrid, antinodes: set[Point]): """
output = [["."] * grid.width for _ in range(grid.height)] ............
for antinode in antinodes: ........0...
output[antinode.x][antinode.y] = "#" .....0......
for frequency, antenna_array in grid.antenna_arrays.items(): .......0....
for antenna in antenna_array: ....0.......
output[antenna.x][antenna.y] = frequency ......A.....
print("\n".join("".join(row) for row in output)) ............
............
........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)

12
puzzles/_solver.py Normal file
View File

@@ -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: ...

View File

@@ -1,80 +1,60 @@
import sys import sys
from importlib import import_module from importlib import import_module
from inspect import getmembers, isclass
from time import time from time import time
from unittest import TextTestRunner, defaultTestLoader
from advent.errors import InvalidSessionIDError, PuzzleNotFoundError
from advent.functions import get_puzzle_input from advent.functions import get_puzzle_input
from puzzles._solver import Solver
def usage():
print("Usage: python solve.py <day>")
def main(): def main():
if len(sys.argv) != 2: if len(sys.argv) != 2:
usage() raise RuntimeError("Usage: python solve.py <day>")
sys.exit(1)
try: try:
day = int(sys.argv[1]) day = int(sys.argv[1])
except ValueError: except ValueError:
print("Day must be an integer") raise RuntimeError("Day must be an integer")
usage()
sys.exit(1)
print(f"Loading 2024, day {day} solver...")
try: try:
print(f"Loading 2024, day {day} solver")
mod = import_module(f"puzzles.{day}") mod = import_module(f"puzzles.{day}")
except ModuleNotFoundError: except ModuleNotFoundError:
print(f"Solver for day {day} not found") print(f"Solver for day {day} not found")
sys.exit(1) sys.exit(1)
try: solvers = getmembers(mod, lambda m: isclass(m) and issubclass(m, Solver))
print("Testing part 1 solution...") if len(solvers) == 0:
test_input_p1 = getattr(mod, "test_input_p1", getattr(mod, "test_input", None)) raise RuntimeError("No solver found")
start = time() solver_class: type[Solver] = solvers[0][1]
assert mod.solve_p1(test_input_p1) == mod.test_solution_p1
print(f"Test passed in {time() - start:.3f} seconds")
print("Testing part 2 solution...") test_suite = defaultTestLoader.loadTestsFromModule(mod)
test_input_p2 = getattr(mod, "test_input_p2", getattr(mod, "test_input", None)) test_runner = TextTestRunner(verbosity=0)
start = time() test_result = test_runner.run(test_suite)
assert mod.solve_p2(test_input_p2) == mod.test_solution_p2 if not test_result.wasSuccessful():
print(f"Test passed in {time() - start:.3f} seconds") raise RuntimeError("Tests failed")
except AssertionError:
print("Test failed")
sys.exit(1)
except Exception as exc:
print("Error:", exc)
sys.exit(1)
try:
print("Fetching puzzle input...") print("Fetching puzzle input...")
puzzle_input = get_puzzle_input(2024, int(day)) puzzle_input = get_puzzle_input(2024, int(day))
except (PuzzleNotFoundError, InvalidSessionIDError) as exc:
print(exc)
sys.exit(1)
try: solver = solver_class(puzzle_input)
print("Solving part 1...") print("Solving part 1...")
start = time() start = time()
solution_p1 = mod.solve_p1(puzzle_input) solution_p1 = solver.solve_p1()
print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)") print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)")
print("Solving part 2...") print("Solving part 2...")
solution_p2 = mod.solve_p2(puzzle_input) start = time()
solution_p2 = solver.solve_p2()
print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)") 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)
if __name__ == "__main__": if __name__ == "__main__":
try:
main() main()
except Exception as exc:
print(exc)
sys.exit(1)