Restructure solver modules with classes and unittest suites

This commit is contained in:
2024-12-09 20:47:50 -08:00
parent 7c021ab87f
commit bee7f5e59e
11 changed files with 995 additions and 698 deletions

View File

@@ -1,6 +1,38 @@
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
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)
class TestDayOneSolver(TestCase):
def test(self):
solver = DayOneSolver(
dedent(
"""
3 4 3 4
4 3 4 3
2 5 2 5
@@ -8,26 +40,9 @@ test_input = """
3 9 3 9
3 3 3 3
""" """
)
test_solution_p1 = 11 )
test_solution_p2 = 31 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)
def solve_p1(puzzle_input: str) -> int: self.assertEqual(solver.solve_p2(), 31)
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)
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

View File

@@ -1,9 +1,69 @@
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
@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)
)
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)
class TestDayNSolver(TestCase):
def test(self):
solver = DayTwoSolver(
dedent(
"""
7 6 4 2 1 7 6 4 2 1
1 2 7 8 9 1 2 7 8 9
9 7 6 2 1 9 7 6 2 1
@@ -11,53 +71,18 @@ test_input = """
8 6 4 4 1 8 6 4 4 1
1 3 6 7 9 1 3 6 7 9
""" """
test_solution_p1 = 2
test_solution_p2 = 4
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)
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) self.assertListEqual(
solver.reports,
[
def _parse_reports(puzzle_input: str) -> Iterator[tuple[int, ...]]: Report((7, 6, 4, 2, 1)),
lines = puzzle_input.strip().splitlines() Report((1, 2, 7, 8, 9)),
return (tuple(int(level) for level in line.split(" ")) for line in lines) Report((9, 7, 6, 2, 1)),
Report((1, 3, 2, 4, 5)),
Report((8, 6, 4, 4, 1)),
def _deltas(report: tuple[int, ...]) -> tuple[int, ...]: Report((1, 3, 6, 7, 9)),
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
) )
self.assertEqual(solver.solve_p1(), 2)
self.assertEqual(solver.solve_p2(), 4)
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)))

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,6 +1,74 @@
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
from puzzles._solver import Solver
Line = tuple[str, ...]
Grid = tuple[Line, ...]
class DayFourSolver(Solver):
grid: Grid
@override
def __init__(self, puzzle_input: str):
self.grid = tuple(tuple(row) for row in puzzle_input.strip().splitlines())
@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)
)
class TestDayFourSolver(TestCase):
def test(self):
solver = DayFourSolver(
dedent(
"""
MMMSXXMASM MMMSXXMASM
MSAMXMSMSA MSAMXMSMSA
AMXSXMAAMM AMXSXMAAMM
@@ -12,72 +80,11 @@ SAXAMASAAA
MAMMMXMMMM MAMMMXMMMM
MXMXAXMASX MXMXAXMASX
""" """
test_solution_p1 = 18
test_solution_p2 = 9
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))
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]))
) )
self.assertEqual(
solver.grid[0],
def _diagonal(grid: tuple[tuple[str, ...], ...], offset=0) -> tuple[str, ...]: ("M", "M", "M", "S", "X", "X", "M", "A", "S", "M"),
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],
)
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")
) )
self.assertEqual(solver.solve_p1(), 18)
self.assertEqual(solver.solve_p2(), 9)

View File

@@ -1,6 +1,86 @@
from typing import Iterator from textwrap import dedent
from typing import Iterator, override
from unittest import TestCase
test_input = """ from puzzles._solver import Solver
Update = tuple[int, ...]
class DayFiveSolver(Solver):
ordering_rules: dict[int, set[int]]
updates: list[Update]
@override
def __init__(self, puzzle_input: str):
ordering_rules_input, _, updates_input = puzzle_input.partition("\n\n")
self.ordering_rules = {}
for line in ordering_rules_input.strip().split("\n"):
a, _, b = line.partition("|")
a, b = int(a), int(b)
if b not in self.ordering_rules:
self.ordering_rules[b] = set()
self.ordering_rules[b].add(a)
self.updates = [
tuple(map(int, line.split(",")))
for line in updates_input.strip().split("\n")
]
@override
def solve_p1(self) -> int:
return sum(
update[len(update) // 2]
for update in self.updates
if self.is_correctly_ordered(update)
)
@override
def solve_p2(self) -> int:
return sum(
self.fix_update(update)[len(update) // 2]
for update in self.updates
if not self.is_correctly_ordered(update)
)
def is_correctly_ordered(self, update: 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)
class TestDayFiveSolver(TestCase):
def test(self):
solver = DayFiveSolver(
dedent(
"""
47|53 47|53
97|13 97|13
97|61 97|61
@@ -30,81 +110,7 @@ test_input = """
61,13,29 61,13,29
97,13,75,29,47 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) self.assertEqual(solver.solve_p1(), 143)
self.assertEqual(solver.solve_p2(), 123)
OrderingRules = dict[int, set[int]]
Update = tuple[int, ...]
def _parse_rules_and_updates(
puzzle_input: str,
) -> tuple[OrderingRules, Iterator[Update]]:
ordering_rules, _, updates = puzzle_input.partition("\n\n")
rules: OrderingRules = {}
for line in ordering_rules.strip().split("\n"):
a, _, b = line.partition("|")
a, b = int(a), int(b)
if b not in rules:
rules[b] = set()
rules[b].add(a)
return (
rules,
(tuple(map(int, line.split(","))) for line in updates.strip().split("\n")),
)
def _is_correctly_ordered(rules: OrderingRules, update: Update) -> bool:
contains = set(update)
seen = set()
for i in update:
for j in rules.get(i, ()):
if j in contains and j not in seen:
return False
seen.add(i)
return True
def _fix_update(rules: OrderingRules, update: Update) -> Update:
contains = set(update)
seen = set()
fixed_update = list()
def _fix_item(i):
for j in rules.get(i, ()):
if j in contains and j not in seen:
_fix_item(j)
fixed_update.append(i)
seen.add(i)
for i in update:
if i not in seen:
_fix_item(i)
return tuple(fixed_update)

View File

@@ -1,6 +1,147 @@
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
direction_map = {
"^": (-1, 0),
"v": (1, 0),
"<": (0, -1),
">": (0, 1),
}
class GuardPosition(NamedTuple):
x: int
y: int
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:
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)
position = next_position
visited.add(position)
class TestDaySixSolver(TestCase):
def test(self):
solver = DaySixSolver(
dedent(
"""
....#..... ....#.....
.........# .........#
.......... ..........
@@ -12,111 +153,8 @@ test_input = """
#......... #.........
......#... ......#...
""" """
)
test_solution_p1 = 41 )
test_solution_p2 = 6 self.assertEqual(solver.guard_origin, GuardPosition(6, 4, (-1, 0)))
self.assertEqual(solver.solve_p1(), 41)
self.assertEqual(solver.solve_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",
}
def _traverse_map(map_info: MapInfo) -> TraversalAnalysis:
x, y, direction = map_info.guard
visited = set()
visited.add((x, y, direction))
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
# 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)

View File

@@ -1,8 +1,94 @@
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
class Equation(NamedTuple):
target: int
factors: tuple[int, ...]
Operator = Callable[[int, int], 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]):
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)
)
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 190: 10 19
3267: 81 40 27 3267: 81 40 27
83: 17 5 83: 17 5
@@ -13,71 +99,11 @@ test_input = """
21037: 9 7 18 13 21037: 9 7 18 13
292: 11 6 16 20 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)) self.assertEqual(
solver.equations[0],
Equation(190, (10, 19)),
class Equation(NamedTuple):
target: 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(" "))),
) )
self.assertEqual(solver.solve_p1(), 3749)
self.assertEqual(solver.solve_p2(), 11387)
Operator = Callable[[int, int], 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]):
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)
)
def _concat_ints(a: int, b: int) -> int:
return 10 ** int(log(b, 10) + 1) * a + b

View File

@@ -1,7 +1,97 @@
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
Point = tuple[int, int]
class DayEightSolver(Solver):
width: int
height: int
antenna_arrays: dict[str, set[Point]]
@override
def __init__(self, puzzle_input: str):
self.antenna_arrays = {}
lines = puzzle_input.strip().split("\n")
self.width = len(lines[0])
self.height = len(lines)
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))
@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
class TestDayEightSolver(TestCase):
def test(self):
solver = DayEightSolver(
dedent(
"""
............ ............
........0... ........0...
.....0...... .....0......
@@ -15,96 +105,13 @@ test_input = """
............ ............
............ ............
""" """
)
test_solution_p1 = 14 )
test_solution_p2 = 34 self.assertEqual(solver.width, 12)
self.assertEqual(solver.height, 12)
self.assertSetEqual(
def solve_p1(puzzle_input: str) -> int: solver.antenna_arrays["0"],
grid = _parse_signal_grid(puzzle_input) {(1, 8), (2, 5), (3, 7), (4, 4)},
antinodes = _calculate_antinodes(grid) )
# _print_grid(grid, antinodes) self.assertEqual(solver.solve_p1(), 14)
return len(antinodes) self.assertEqual(solver.solve_p2(), 34)
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
height: int
antenna_arrays: dict[str, set[Point]]
def _parse_signal_grid(puzzle_input: str) -> SignalGrid:
lines = puzzle_input.strip().split("\n")
antenna_arrays: dict[str, set[Point]] = {}
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))
return SignalGrid(len(lines[0]), len(lines), antenna_arrays)
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))

View File

@@ -1,26 +1,166 @@
from itertools import islice, pairwise from itertools import count, pairwise
from typing import NamedTuple 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 = """ from puzzles._solver import Solver
2333133121414131402
"""
test_solution_p1 = 1928
test_solution_p2 = 2858
def solve_p1(puzzle_input: str) -> int: class LinkedList[T]:
blocks = _parse_blocks(puzzle_input) __head: "LinkedListNode[T] | None"
compacted_blocks = _compact_blocks(blocks) __tail: "LinkedListNode[T] | None"
return _hash_blocks(compacted_blocks)
__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: class LinkedListNode[T]:
blocks = _parse_blocks(puzzle_input) value: T
compacted_blocks = _compact_blocks_no_fragmentation(blocks) left: "LinkedListNode[T] | None"
return _hash_blocks(compacted_blocks) 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): class Block(NamedTuple):
@@ -29,110 +169,133 @@ class Block(NamedTuple):
size: int size: int
def _parse_blocks(puzzle_input: str) -> list[Block]: def compact_drive(drive: LinkedList[Block]) -> LinkedList[Block]:
blocks: list[Block] = [] if not len(drive):
cursor_position = 0 return drive
current_id = 0
drive = drive.copy()
moving = drive.pop_tail()
cursor = drive.head
assert cursor is not None
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
insertion = Block(
moving.id,
a.position + a.size,
min(free_space, moving.size),
)
cursor.insert_right(insertion)
cursor = cursor.right
if insertion.size < moving.size:
moving = Block(
moving.id,
moving.position,
moving.size - insertion.size,
)
else:
moving = drive.pop_tail()
cursor.insert_right(
Block(
moving.id,
cursor.value.position + cursor.value.size,
moving.size,
)
)
return drive
def compact_drive_without_fragmentation(drive: LinkedList[Block]) -> LinkedList[Block]:
if not len(drive):
return drive
drive = drive.copy()
moving = drive.tail
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.value.size:
moving.pop()
cursor.insert_right(
Block(
moving.value.id,
a.position + a.size,
moving.value.size,
)
)
break
cursor = cursor.right
moving = moving.left
return drive
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(drive):
snapshot.append(str(a.id) * a.size)
snapshot.append("." * (b.position - a.position - a.size))
snapshot.append(str(drive.tail.value.id) * drive.tail.value.size)
print("".join(snapshot))
class DayNineSolver(Solver):
drive: LinkedList[Block]
@override
def __init__(self, puzzle_input: str):
self.drive = LinkedList()
snapshot = map(int, puzzle_input.strip()) snapshot = map(int, puzzle_input.strip())
cursor_position = 0
current_id = count()
for pair in chunked(snapshot, 2): for pair in chunked(snapshot, 2):
if data_size := pair[0]: if data_size := pair[0]:
blocks.append(Block(current_id, cursor_position, data_size)) id = next(current_id)
self.drive.append(Block(id, cursor_position, data_size))
cursor_position += data_size cursor_position += data_size
current_id += 1
if len(pair) == 2: if len(pair) == 2:
cursor_position += pair[1] cursor_position += pair[1]
return blocks @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)
def _compact_blocks(blocks: list[Block]) -> list[Block]: class TestDayNineSolver(TestCase):
assert blocks def test(self):
blocks = blocks.copy() solver = DayNineSolver("2333133121414131402\n")
self.assertEqual(solver.solve_p1(), 1928)
compacted_blocks: list[Block] = [] self.assertEqual(solver.solve_p2(), 2858)
current_position = 0
moving_block = blocks.pop()
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,
)
)
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
compacted_blocks.append(next_block)
current_position += next_block.size
compacted_blocks.append(
Block(
moving_block.id,
current_position,
moving_block.size,
)
)
return compacted_blocks
def _compact_blocks_no_fragmentation(blocks: list[Block]) -> list[Block]:
assert blocks
blocks = blocks.copy()
compacted_blocks: list[Block] = []
while len(blocks) > 1:
moving_block = blocks[-1]
for (_, a), (i, b) in pairwise(enumerate(blocks)):
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,
)
blocks.pop()
blocks.insert(i, modified_block)
break
else:
blocks.pop()
compacted_blocks.append(moving_block)
compacted_blocks.append(blocks[0])
return list(sorted(compacted_blocks, key=lambda block: block.position))
def _print_blocks(blocks: list[Block]) -> str:
snapshot = []
for a, b in pairwise(blocks):
snapshot.append(str(a.id) * a.size)
snapshot.append("." * (b.position - a.position - a.size))
snapshot.append(str(blocks[-1].id) * blocks[-1].size)
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)
)

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)