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 __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: class TestDayOneSolver(TestCase):
list1, list2 = _parse_lists(puzzle_input) def test(self):
occurrences = Counter(list2) solver = DayOneSolver(
similarities = (value * occurrences.get(value, 0) for value in list1) dedent(
return sum(similarities) """
3 4
4 3
def _parse_lists(puzzle_input: str) -> tuple[list[int], list[int]]: 2 5
lines = (line.partition(" ") for line in puzzle_input.strip().split("\n")) 1 3
list1, list2 = zip(*((int(line[0]), int(line[2])) for line in lines)) 3 9
return list1, list2 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 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: class DayTwoSolver(Solver):
reports = _parse_reports(puzzle_input) reports: list[Report]
dampened_report_collections = (_dampen_permutations(report) for report in reports)
delta_report_collections = ( @override
(_deltas(report) for report in report_collection) def __init__(self, puzzle_input: str):
for report_collection in dampened_report_collections self.reports = list(
) Report(tuple(int(level) for level in line.split(" ")))
safe_report_collections = filter( for line in puzzle_input.strip().splitlines()
lambda delta_report_collection: any( )
_is_gradual_monotonic(delta_report)
for delta_report in delta_report_collection @override
), def solve_p1(self) -> int:
delta_report_collections, safe_reports = filter(Report.is_gradual_monotonic, self.reports)
) return ilen(safe_reports)
return ilen(safe_report_collections)
@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, ...]]: class TestDayNSolver(TestCase):
lines = puzzle_input.strip().splitlines() def test(self):
return (tuple(int(level) for level in line.split(" ")) for line in lines) solver = DayTwoSolver(
dedent(
"""
def _deltas(report: tuple[int, ...]) -> tuple[int, ...]: 7 6 4 2 1
pairs = zip(report, islice(report, 1, None)) 1 2 7 8 9
return tuple(b - a for a, b in pairs) 9 7 6 2 1
1 3 2 4 5
8 6 4 4 1
def _is_gradual_monotonic(delta_report: tuple[int]) -> bool: 1 3 6 7 9
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 self.assertListEqual(
) solver.reports,
[
Report((7, 6, 4, 2, 1)),
def _dampen_permutations(report: tuple[int, ...]) -> Iterator[tuple[int, ...]]: Report((1, 2, 7, 8, 9)),
yield report Report((9, 7, 6, 2, 1)),
yield from (report[:i] + report[i + 1 :] for i in range(0, len(report))) 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)
return sum(a * b for a, b in instructions) 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: 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)) return sum(
1
for line in self.scan_lines()
def _parse_world_grid(puzzle_input: str) -> tuple[tuple[str, ...], ...]: for window in sliding_window(line, 4)
return tuple(tuple(row) for row in puzzle_input.strip().splitlines()) if window in targets
)
def _scan_word_grid( @override
char_grid: tuple[tuple[str, ...], ...] def solve_p2(self) -> int:
) -> Iterator[tuple[str, ...]]: targets = (("M", "A", "S"), ("S", "A", "M"))
yield from (row for row in char_grid) return sum(
yield from (tuple(col) for col in zip(*char_grid)) 1
yield from ( for square in self.scan_squares(3)
_diagonal(char_grid, i) for i in range(-len(char_grid) + 1, len(char_grid[0])) if (square[0][0], square[1][1], square[2][2]) in targets
) and (square[0][2], square[1][1], square[2][0]) in targets
yield from ( )
_diagonal(tuple(reversed(char_grid)), i)
for i in range(-len(char_grid) + 1, len(char_grid[0])) def scan_lines(self) -> Iterator[Line]:
) yield from self.grid
for col in zip(*self.grid):
yield tuple(col)
def _diagonal(grid: tuple[tuple[str, ...], ...], offset=0) -> tuple[str, ...]: for i in range(-len(self.grid) + 1, len(self.grid[0])):
return tuple( yield self.diagonal(i)
grid[i][i + offset] for i in range(len(grid)) if 0 <= i + offset < len(grid) yield self.diagonal(i, inverse=True)
)
def scan_squares(self, size=3) -> Iterator[Grid]:
yield from (
def _count_xmas(line: tuple[str, ...]) -> int: (
return sum( self.grid[i][j : j + size],
1 self.grid[i + 1][j : j + size],
for i in range(len(line) - 3) self.grid[i + 2][j : j + size],
if line[i : i + 4] in (("X", "M", "A", "S"), ("S", "A", "M", "X")) )
) for i in range(len(self.grid) - size + 1)
for j in range(len(self.grid[0]) - size + 1)
)
ThreeSquare = tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]]
def diagonal(self, offset=0, inverse=False) -> Line:
return tuple(
def _three_squares(word_grid: tuple[tuple[str, ...], ...]) -> Iterator[ThreeSquare]: self.grid[i][len(self.grid[0]) - i - offset - 1 if inverse else i + offset]
yield from ( for i in range(len(self.grid))
( if 0 <= i + offset < len(self.grid)
word_grid[i][j : j + 3],
word_grid[i + 1][j : j + 3],
word_grid[i + 2][j : j + 3],
) )
for i in range(len(word_grid) - 2)
for j in range(len(word_grid[0]) - 2)
)
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):
a, _, b = line.partition("|") ordering_rules_input, _, updates_input = puzzle_input.partition("\n\n")
a, b = int(a), int(b) self.ordering_rules = {}
if b not in rules:
rules[b] = set()
rules[b].add(a)
return ( for line in ordering_rules_input.strip().split("\n"):
rules, a, _, b = line.partition("|")
(tuple(map(int, line.split(","))) for line in updates.strip().split("\n")), 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: class TestDayFiveSolver(TestCase):
contains = set(update) def test(self):
seen = set() 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: 75,47,61,53,29
for j in rules.get(i, ()): 97,61,53,29,13
if j in contains and j not in seen: 75,29,13
return False 75,97,47,61,53
seen.add(i) 61,13,29
97,13,75,29,47
return True """
)
)
def _fix_update(rules: OrderingRules, update: Update) -> Update: self.assertEqual(solver.solve_p1(), 143)
contains = set(update) self.assertEqual(solver.solve_p2(), 123)
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)

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]
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 def move_position(position: GuardPosition) -> GuardPosition:
if ( return GuardPosition(
next_x < 0 position.x + position.direction[0],
or next_x >= len(map_info.map) position.y + position.direction[1],
or next_y < 0 position.direction,
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 def rotate_position(position: GuardPosition) -> GuardPosition:
else: return GuardPosition(
x, y = next_x, next_y position.x,
if (x, y, direction) in visited: 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) 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)

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") self.width = len(lines[0])
antenna_arrays: dict[str, set[Point]] = {} 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 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]: class TestDayEightSolver(TestCase):
antinodes: set[Point] = set() def test(self):
solver = DayEightSolver(
for antenna_array in grid.antenna_arrays.values(): dedent(
for a, b in combinations(antenna_array, 2): """
dx = b.x - a.x ............
dy = b.y - a.y ........0...
.....0......
# Left antinodes .......0....
if harmonic: ....0.......
antinode = a ......A.....
while True: ............
antinodes.add(antinode) ............
antinode = Point(antinode.x - dx, antinode.y - dy) ........A...
if not _is_in_grid(grid, antinode): .........A..
break ............
else: ............
antinode = Point(a.x - dx, a.y - dy) """
if _is_in_grid(grid, antinode): )
antinodes.add(antinode) )
self.assertEqual(solver.width, 12)
# Right antinodes self.assertEqual(solver.height, 12)
if harmonic: self.assertSetEqual(
antinode = b solver.antenna_arrays["0"],
while True: {(1, 8), (2, 5), (3, 7), (4, 4)},
antinodes.add(antinode) )
antinode = Point(antinode.x + dx, antinode.y + dy) self.assertEqual(solver.solve_p1(), 14)
if not _is_in_grid(grid, antinode): self.assertEqual(solver.solve_p2(), 34)
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))

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("Fetching puzzle input...")
print("Test failed") puzzle_input = get_puzzle_input(2024, int(day))
sys.exit(1)
except Exception as exc: solver = solver_class(puzzle_input)
print("Error:", exc)
sys.exit(1)
try: print("Solving part 1...")
print("Fetching puzzle input...") start = time()
puzzle_input = get_puzzle_input(2024, int(day)) solution_p1 = solver.solve_p1()
except (PuzzleNotFoundError, InvalidSessionIDError) as exc: print(f"Part 1 solution: {solution_p1} ({time() - start:.3f} seconds)")
print(exc)
sys.exit(1)
try: print("Solving part 2...")
print("Solving part 1...") start = time()
start = time() solution_p2 = solver.solve_p2()
solution_p1 = mod.solve_p1(puzzle_input) print(f"Part 2 solution: {solution_p2} ({time() - start:.3f} seconds)")
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)
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except Exception as exc:
print(exc)
sys.exit(1)