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,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))

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
snapshot = map(int, puzzle_input.strip()) drive = drive.copy()
for pair in chunked(snapshot, 2): moving = drive.pop_tail()
if data_size := pair[0]: cursor = drive.head
blocks.append(Block(current_id, cursor_position, data_size)) assert cursor is not None
cursor_position += data_size
current_id += 1
if len(pair) == 2:
cursor_position += pair[1]
return blocks while cursor.right is not None:
a = cursor.value
b = cursor.right.value
free_space = b.position - a.position - a.size
if not free_space:
cursor = cursor.right
continue
def _compact_blocks(blocks: list[Block]) -> list[Block]: insertion = Block(
assert blocks moving.id,
blocks = blocks.copy() a.position + a.size,
min(free_space, moving.size),
)
compacted_blocks: list[Block] = [] cursor.insert_right(insertion)
current_position = 0 cursor = cursor.right
moving_block = blocks.pop()
while blocks: if insertion.size < moving.size:
next_block = blocks.pop(0) moving = Block(
free_space = next_block.position - current_position moving.id,
while free_space: moving.position,
assert moving_block.size > 0 moving.size - insertion.size,
allocation = min(free_space, moving_block.size)
compacted_blocks.append(
Block(
moving_block.id,
current_position,
allocation,
)
) )
current_position += allocation else:
free_space -= allocation moving = drive.pop_tail()
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) cursor.insert_right(
current_position += next_block.size
compacted_blocks.append(
Block( Block(
moving_block.id, moving.id,
current_position, cursor.value.position + cursor.value.size,
moving_block.size, moving.size,
) )
) )
return compacted_blocks return drive
def _compact_blocks_no_fragmentation(blocks: list[Block]) -> list[Block]: def compact_drive_without_fragmentation(drive: LinkedList[Block]) -> LinkedList[Block]:
assert blocks if not len(drive):
blocks = blocks.copy() return drive
compacted_blocks: list[Block] = [] drive = drive.copy()
moving = drive.tail
while len(blocks) > 1: while moving:
moving_block = blocks[-1] cursor = drive.head
for (_, a), (i, b) in pairwise(enumerate(blocks)):
while cursor != moving:
a = cursor.value
b = cursor.right.value
free_space = b.position - a.position - a.size free_space = b.position - a.position - a.size
if free_space >= moving_block.size:
modified_block = Block( if free_space >= moving.value.size:
moving_block.id, moving.pop()
a.position + a.size, cursor.insert_right(
moving_block.size, Block(
moving.value.id,
a.position + a.size,
moving.value.size,
)
) )
blocks.pop()
blocks.insert(i, modified_block)
break break
else:
blocks.pop()
compacted_blocks.append(moving_block)
compacted_blocks.append(blocks[0]) cursor = cursor.right
return list(sorted(compacted_blocks, key=lambda block: block.position)) moving = moving.left
return drive
def _print_blocks(blocks: list[Block]) -> str: def hash_drive(drive: LinkedList[Block]) -> int:
return sum(
block.id * block.size * (block.position * 2 + block.size - 1) // 2
for block in drive
)
def print_drive(drive: LinkedList[Block]) -> None:
snapshot = [] snapshot = []
for a, b in pairwise(blocks): for a, b in pairwise(drive):
snapshot.append(str(a.id) * a.size) snapshot.append(str(a.id) * a.size)
snapshot.append("." * (b.position - a.position - a.size)) snapshot.append("." * (b.position - a.position - a.size))
snapshot.append(str(blocks[-1].id) * blocks[-1].size) snapshot.append(str(drive.tail.value.id) * drive.tail.value.size)
print("".join(snapshot))
def _hash_blocks(blocks: list[Block]) -> int: class DayNineSolver(Solver):
return sum( drive: LinkedList[Block]
block.id * pos
for block in blocks @override
for pos in range(block.position, block.position + block.size) def __init__(self, puzzle_input: str):
) self.drive = LinkedList()
snapshot = map(int, puzzle_input.strip())
cursor_position = 0
current_id = count()
for pair in chunked(snapshot, 2):
if data_size := pair[0]:
id = next(current_id)
self.drive.append(Block(id, cursor_position, data_size))
cursor_position += data_size
if len(pair) == 2:
cursor_position += pair[1]
@override
def solve_p1(self) -> int:
compacted_drive = compact_drive(self.drive)
return hash_drive(compacted_drive)
@override
def solve_p2(self) -> int:
compacted_drive = compact_drive_without_fragmentation(self.drive)
return hash_drive(compacted_drive)
class TestDayNineSolver(TestCase):
def test(self):
solver = DayNineSolver("2333133121414131402\n")
self.assertEqual(solver.solve_p1(), 1928)
self.assertEqual(solver.solve_p2(), 2858)

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)