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

View File

@@ -1,63 +1,88 @@
from itertools import islice
from typing import Iterator
from dataclasses import dataclass
from functools import cached_property
from itertools import pairwise
from textwrap import dedent
from typing import Iterator, override
from unittest import TestCase
from more_itertools import ilen
test_input = """
7 6 4 2 1
1 2 7 8 9
9 7 6 2 1
1 3 2 4 5
8 6 4 4 1
1 3 6 7 9
"""
test_solution_p1 = 2
test_solution_p2 = 4
from puzzles._solver import Solver
def solve_p1(puzzle_input: str) -> int:
reports = _parse_reports(puzzle_input)
delta_reports = (_deltas(report) for report in reports)
safe_delta_reports = filter(_is_gradual_monotonic, delta_reports)
return ilen(safe_delta_reports)
@dataclass
class Report:
levels: tuple[int, ...]
@cached_property
def deltas(self) -> tuple[int, ...]:
return tuple(b - a for a, b in pairwise(self.levels))
def is_gradual_monotonic(self) -> bool:
sign = 1 if self.deltas[0] > 0 else -1
return all(
delta != 0 and delta / abs(delta) == sign and abs(delta) < 4
for delta in self.deltas
)
def permute_dampened_variations(self) -> Iterator["Report"]:
return (
Report(self.levels[:i] + self.levels[i + 1 :])
for i in range(0, len(self.levels) + 1)
)
def solve_p2(puzzle_input: str) -> int:
reports = _parse_reports(puzzle_input)
dampened_report_collections = (_dampen_permutations(report) for report in reports)
delta_report_collections = (
(_deltas(report) for report in report_collection)
for report_collection in dampened_report_collections
)
safe_report_collections = filter(
lambda delta_report_collection: any(
_is_gradual_monotonic(delta_report)
for delta_report in delta_report_collection
),
delta_report_collections,
)
return ilen(safe_report_collections)
class DayTwoSolver(Solver):
reports: list[Report]
@override
def __init__(self, puzzle_input: str):
self.reports = list(
Report(tuple(int(level) for level in line.split(" ")))
for line in puzzle_input.strip().splitlines()
)
@override
def solve_p1(self) -> int:
safe_reports = filter(Report.is_gradual_monotonic, self.reports)
return ilen(safe_reports)
@override
def solve_p2(self) -> int:
safe_reports = filter(
lambda report: any(
report_variation.is_gradual_monotonic()
for report_variation in report.permute_dampened_variations()
),
self.reports,
)
return ilen(safe_reports)
def _parse_reports(puzzle_input: str) -> Iterator[tuple[int, ...]]:
lines = puzzle_input.strip().splitlines()
return (tuple(int(level) for level in line.split(" ")) for line in lines)
def _deltas(report: tuple[int, ...]) -> tuple[int, ...]:
pairs = zip(report, islice(report, 1, None))
return tuple(b - a for a, b in pairs)
def _is_gradual_monotonic(delta_report: tuple[int]) -> bool:
sign = 1 if delta_report[0] > 0 else -1
return all(
delta != 0 and delta / abs(delta) == sign and abs(delta) < 4
for delta in delta_report
)
def _dampen_permutations(report: tuple[int, ...]) -> Iterator[tuple[int, ...]]:
yield report
yield from (report[:i] + report[i + 1 :] for i in range(0, len(report)))
class TestDayNSolver(TestCase):
def test(self):
solver = DayTwoSolver(
dedent(
"""
7 6 4 2 1
1 2 7 8 9
9 7 6 2 1
1 3 2 4 5
8 6 4 4 1
1 3 6 7 9
"""
)
)
self.assertListEqual(
solver.reports,
[
Report((7, 6, 4, 2, 1)),
Report((1, 2, 7, 8, 9)),
Report((9, 7, 6, 2, 1)),
Report((1, 3, 2, 4, 5)),
Report((8, 6, 4, 4, 1)),
Report((1, 3, 6, 7, 9)),
],
)
self.assertEqual(solver.solve_p1(), 2)
self.assertEqual(solver.solve_p2(), 4)

View File

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

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 = """
MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX
"""
from more_itertools import sliding_window
test_solution_p1 = 18
test_solution_p2 = 9
from puzzles._solver import Solver
Line = tuple[str, ...]
Grid = tuple[Line, ...]
def solve_p1(puzzle_input: str) -> int:
word_grid = _parse_world_grid(puzzle_input)
return sum(_count_xmas(line) for line in _scan_word_grid(word_grid))
class DayFourSolver(Solver):
grid: Grid
@override
def __init__(self, puzzle_input: str):
self.grid = tuple(tuple(row) for row in puzzle_input.strip().splitlines())
def solve_p2(puzzle_input: str) -> int:
word_grid = _parse_world_grid(puzzle_input)
squares = _three_squares(word_grid)
return sum(1 for square in squares if _has_cross_mas(square))
def _parse_world_grid(puzzle_input: str) -> tuple[tuple[str, ...], ...]:
return tuple(tuple(row) for row in puzzle_input.strip().splitlines())
def _scan_word_grid(
char_grid: tuple[tuple[str, ...], ...]
) -> Iterator[tuple[str, ...]]:
yield from (row for row in char_grid)
yield from (tuple(col) for col in zip(*char_grid))
yield from (
_diagonal(char_grid, i) for i in range(-len(char_grid) + 1, len(char_grid[0]))
)
yield from (
_diagonal(tuple(reversed(char_grid)), i)
for i in range(-len(char_grid) + 1, len(char_grid[0]))
)
def _diagonal(grid: tuple[tuple[str, ...], ...], offset=0) -> tuple[str, ...]:
return tuple(
grid[i][i + offset] for i in range(len(grid)) if 0 <= i + offset < len(grid)
)
def _count_xmas(line: tuple[str, ...]) -> int:
return sum(
1
for i in range(len(line) - 3)
if line[i : i + 4] in (("X", "M", "A", "S"), ("S", "A", "M", "X"))
)
ThreeSquare = tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]]
def _three_squares(word_grid: tuple[tuple[str, ...], ...]) -> Iterator[ThreeSquare]:
yield from (
(
word_grid[i][j : j + 3],
word_grid[i + 1][j : j + 3],
word_grid[i + 2][j : j + 3],
@override
def solve_p1(self) -> int:
targets = (("X", "M", "A", "S"), ("S", "A", "M", "X"))
return sum(
1
for line in self.scan_lines()
for window in sliding_window(line, 4)
if window in targets
)
@override
def solve_p2(self) -> int:
targets = (("M", "A", "S"), ("S", "A", "M"))
return sum(
1
for square in self.scan_squares(3)
if (square[0][0], square[1][1], square[2][2]) in targets
and (square[0][2], square[1][1], square[2][0]) in targets
)
def scan_lines(self) -> Iterator[Line]:
yield from self.grid
for col in zip(*self.grid):
yield tuple(col)
for i in range(-len(self.grid) + 1, len(self.grid[0])):
yield self.diagonal(i)
yield self.diagonal(i, inverse=True)
def scan_squares(self, size=3) -> Iterator[Grid]:
yield from (
(
self.grid[i][j : j + size],
self.grid[i + 1][j : j + size],
self.grid[i + 2][j : j + size],
)
for i in range(len(self.grid) - size + 1)
for j in range(len(self.grid[0]) - size + 1)
)
def diagonal(self, offset=0, inverse=False) -> Line:
return tuple(
self.grid[i][len(self.grid[0]) - i - offset - 1 if inverse else i + offset]
for i in range(len(self.grid))
if 0 <= i + offset < len(self.grid)
)
for i in range(len(word_grid) - 2)
for j in range(len(word_grid[0]) - 2)
)
def _has_cross_mas(square: ThreeSquare):
diag_1 = (square[0][0], square[1][1], square[2][2])
diag_2 = (square[0][2], square[1][1], square[2][0])
return (diag_1 == ("M", "A", "S") or diag_1 == ("S", "A", "M")) and (
diag_2 == ("M", "A", "S") or diag_2 == ("S", "A", "M")
)
class TestDayFourSolver(TestCase):
def test(self):
solver = DayFourSolver(
dedent(
"""
MMMSXXMASM
MSAMXMSMSA
AMXSXMAAMM
MSAMASMSMX
XMASAMXAMM
XXAMMXXAMA
SMSMSASXSS
SAXAMASAAA
MAMMMXMMMM
MXMXAXMASX
"""
)
)
self.assertEqual(
solver.grid[0],
("M", "M", "M", "S", "X", "X", "M", "A", "S", "M"),
)
self.assertEqual(solver.solve_p1(), 18)
self.assertEqual(solver.solve_p2(), 9)

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

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
test_solution_p2 = 6
def solve_p1(puzzle_input: str) -> int:
map_info = _parse_map(puzzle_input)
traversal_analysis = _traverse_map(map_info)
unique_locations = set((a, b) for a, b, _ in traversal_analysis.visited)
return len(unique_locations)
def solve_p2(puzzle_input: str) -> int:
map_info = _parse_map(puzzle_input)
added_obstacles_candidates = set()
for i, line in enumerate(map_info.map):
for j, cell in enumerate(line):
if not cell:
map_info.map[i][j] = True
if _traverse_map(map_info).stuck:
added_obstacles_candidates.add((i, j))
map_info.map[i][j] = False
# Remove guard location from candidates
added_obstacles_candidates.discard((map_info.guard[:2]))
return len(added_obstacles_candidates)
class MapInfo(NamedTuple):
map: list[list[bool]]
guard: tuple[int, int, str]
def _parse_map(puzzle_input: str) -> MapInfo:
puzzle_map = []
guard = None
for x, line in enumerate(puzzle_input.strip().split("\n")):
if not line:
continue
line_list = list()
for y, cell in enumerate(line):
if cell in "^v<>":
guard = (x, y, cell)
line_list.append(cell == "#")
puzzle_map.append(line_list)
assert guard is not None, "Guard not found in map"
assert all(
len(line) == len(puzzle_map[0]) for line in puzzle_map
), "Map is not rectangular"
return MapInfo(puzzle_map, guard)
class TraversalAnalysis(NamedTuple):
visited: set[tuple[int, int]]
stuck: bool
_rotation_map = {
"^": ">",
"v": "<",
"<": "^",
">": "v",
direction_map = {
"^": (-1, 0),
"v": (1, 0),
"<": (0, -1),
">": (0, 1),
}
def _traverse_map(map_info: MapInfo) -> TraversalAnalysis:
x, y, direction = map_info.guard
visited = set()
visited.add((x, y, direction))
class GuardPosition(NamedTuple):
x: int
y: int
direction: tuple[int, int]
while True:
# Determine next cell
match direction:
case "^":
next_x, next_y = x - 1, y
case "v":
next_x, next_y = x + 1, y
case "<":
next_x, next_y = x, y - 1
case ">":
next_x, next_y = x, y + 1
# Guard patrols out of bounds
if (
next_x < 0
or next_x >= len(map_info.map)
or next_y < 0
or next_y >= len(map_info.map[0])
):
break
def move_position(position: GuardPosition) -> GuardPosition:
return GuardPosition(
position.x + position.direction[0],
position.y + position.direction[1],
position.direction,
)
# Turn right if an obstacle is encountered
if map_info.map[next_x][next_y]:
direction = _rotation_map[direction]
# Otherwise, move to next cell
else:
x, y = next_x, next_y
if (x, y, direction) in visited:
def rotate_position(position: GuardPosition) -> GuardPosition:
return GuardPosition(
position.x,
position.y,
(position.direction[1], -position.direction[0]),
)
class TestGuardPosition(TestCase):
def test(self):
guard = GuardPosition(0, 0, (0, 1))
self.assertEqual(move_position(guard), GuardPosition(0, 1, (0, 1)))
self.assertEqual(rotate_position(guard), GuardPosition(0, 0, (1, 0)))
class TraversalAnalysis(NamedTuple):
visited: set[GuardPosition]
stuck: bool
def unique_locations(traversal_analysis: TraversalAnalysis) -> set[tuple[int, int]]:
return set((p.x, p.y) for p in traversal_analysis.visited)
class TestTraversalAnalysis(TestCase):
def test(self):
analysis = TraversalAnalysis(
{GuardPosition(0, 0, (0, 1)), GuardPosition(2, 5, (1, 0))},
False,
)
self.assertEqual(
unique_locations(analysis),
{(0, 0), (2, 5)},
)
class DaySixSolver(Solver):
puzzle_map: list[list[bool]]
guard_origin: GuardPosition
@override
def __init__(self, puzzle_input: str):
self.puzzle_map = []
guard_origin = None
for x, line in enumerate(puzzle_input.strip().split("\n")):
line_list = list()
for y, cell in enumerate(line):
if cell in direction_map:
guard_origin = GuardPosition(x, y, direction_map[cell])
line_list.append(cell == "#")
self.puzzle_map.append(line_list)
if guard_origin is None:
raise ValueError("Guard not found in map")
self.guard_origin = guard_origin
if not all(len(line) == len(self.puzzle_map[0]) for line in self.puzzle_map):
raise ValueError("Map is not rectangular")
@override
def solve_p1(self) -> int:
traversal_analysis = self.traverse_map()
return len(unique_locations(traversal_analysis))
@override
def solve_p2(self) -> int:
candidate_locations = unique_locations(self.traverse_map())
candidate_locations.discard((self.guard_origin.x, self.guard_origin.y))
verified_locations = set()
for x, y in candidate_locations:
if not self.puzzle_map[x][y]:
self.puzzle_map[x][y] = True
if self.traverse_map().stuck:
verified_locations.add((x, y))
self.puzzle_map[x][y] = False
return len(verified_locations)
def out_of_bounds(self, guard_position: GuardPosition) -> bool:
return (
guard_position.x < 0
or guard_position.x >= len(self.puzzle_map)
or guard_position.y < 0
or guard_position.y >= len(self.puzzle_map[0])
)
def traverse_map(self) -> TraversalAnalysis:
position = self.guard_origin
visited = set({position})
while True:
next_position = move_position(position)
if self.out_of_bounds(next_position):
return TraversalAnalysis(visited, False)
# Obstacle is encountered
if self.puzzle_map[next_position.x][next_position.y]:
next_position = rotate_position(position)
# Guard is stuck
if next_position in visited:
return TraversalAnalysis(visited, True)
visited.add((x, y, direction))
return TraversalAnalysis(visited, False)
position = next_position
visited.add(position)
class TestDaySixSolver(TestCase):
def test(self):
solver = DaySixSolver(
dedent(
"""
....#.....
.........#
..........
..#.......
.......#..
..........
.#..^.....
........#.
#.........
......#...
"""
)
)
self.assertEqual(solver.guard_origin, GuardPosition(6, 4, (-1, 0)))
self.assertEqual(solver.solve_p1(), 41)
self.assertEqual(solver.solve_p2(), 6)

View File

@@ -1,46 +1,10 @@
from functools import partial
from math import log
from typing import Callable, Iterator, NamedTuple
from textwrap import dedent
from typing import Callable, Iterator, NamedTuple, override
from unittest import TestCase
test_input = """
190: 10 19
3267: 81 40 27
83: 17 5
156: 15 6
7290: 6 8 6 15
161011: 16 10 13
192: 17 8 14
21037: 9 7 18 13
292: 11 6 16 20
"""
test_solution_p1 = 3749
test_solution_p2 = 11387
def solve_p1(puzzle_input: str) -> int:
equations = _parse_equations(puzzle_input)
calibration_guard = partial(
_is_calibrated,
[
int.__add__,
int.__mul__,
],
)
return sum(equation.target for equation in filter(calibration_guard, equations))
def solve_p2(puzzle_input: str) -> int:
equations = _parse_equations(puzzle_input)
calibration_guard = partial(
_is_calibrated,
[
int.__add__,
int.__mul__,
_concat_ints,
],
)
return sum(equation.target for equation in filter(calibration_guard, equations))
from puzzles._solver import Solver
class Equation(NamedTuple):
@@ -48,36 +12,98 @@ class Equation(NamedTuple):
factors: tuple[int, ...]
def _parse_equations(puzzle_input: str) -> Iterator[Equation]:
for line in puzzle_input.strip().split("\n"):
if line:
result_string, _, factors_string = line.partition(": ")
yield Equation(
int(result_string),
tuple(map(int, factors_string.split(" "))),
)
Operator = Callable[[int, int], int]
def _is_calibrated(operators: list[Operator], equation: Equation) -> bool:
def _eval_permutations(factors: tuple[int, ...]) -> Iterator[int]:
def is_calibrated(operators: list[Operator], equation: Equation) -> bool:
def eval_permutations(factors: tuple[int, ...]) -> Iterator[int]:
assert len(factors) > 0
tail = factors[-1]
if len(factors) == 1:
yield tail
return
for head in _eval_permutations(factors[:-1]):
for head in eval_permutations(factors[:-1]):
if head > equation.target:
continue
for operator in operators:
yield operator(head, tail)
return any(
result == equation.target for result in _eval_permutations(equation.factors)
result == equation.target for result in eval_permutations(equation.factors)
)
def _concat_ints(a: int, b: int) -> int:
def concat_ints(a: int, b: int) -> int:
return 10 ** int(log(b, 10) + 1) * a + b
class TestEquation(TestCase):
def test_is_calibrated(self):
equation = Equation(190, (10, 19))
self.assertTrue(is_calibrated([int.__add__, int.__mul__], equation))
self.assertFalse(is_calibrated([int.__add__], equation))
def test_concat_ints(self):
self.assertEqual(concat_ints(1, 2), 12)
self.assertEqual(concat_ints(12, 345), 12345)
class DaySevenSolver(Solver):
equations: list[Equation]
@override
def __init__(self, puzzle_input: str):
self.equations = []
for line in puzzle_input.strip().split("\n"):
result_string, _, factors_string = line.partition(": ")
self.equations.append(
Equation(
int(result_string),
tuple(map(int, factors_string.split(" "))),
)
)
@override
def solve_p1(self) -> int:
return sum(
equation.target
for equation in filter(
partial(is_calibrated, [int.__add__, int.__mul__]),
self.equations,
)
)
@override
def solve_p2(self) -> int:
return sum(
equation.target
for equation in filter(
partial(is_calibrated, [int.__add__, int.__mul__, concat_ints]),
self.equations,
)
)
class TestDaySevenSolver(TestCase):
def test(self):
solver = DaySevenSolver(
dedent(
"""
190: 10 19
3267: 81 40 27
83: 17 5
156: 15 6
7290: 6 8 6 15
161011: 16 10 13
192: 17 8 14
21037: 9 7 18 13
292: 11 6 16 20
"""
)
)
self.assertEqual(
solver.equations[0],
Equation(190, (10, 19)),
)
self.assertEqual(solver.solve_p1(), 3749)
self.assertEqual(solver.solve_p2(), 11387)

View File

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

View File

@@ -1,26 +1,166 @@
from itertools import islice, pairwise
from typing import NamedTuple
from itertools import count, pairwise
import math
from typing import Iterable, NamedTuple, override
from unittest import TestCase
from more_itertools import chunked, sliced
from more_itertools import chunked, ilen
test_input = """
2333133121414131402
"""
test_solution_p1 = 1928
test_solution_p2 = 2858
from puzzles._solver import Solver
def solve_p1(puzzle_input: str) -> int:
blocks = _parse_blocks(puzzle_input)
compacted_blocks = _compact_blocks(blocks)
return _hash_blocks(compacted_blocks)
class LinkedList[T]:
__head: "LinkedListNode[T] | None"
__tail: "LinkedListNode[T] | None"
__slots__ = ("__head", "__tail")
@property
def head(self) -> "LinkedListNode[T] | None":
if not self.__head and self.__tail:
self.__head = self.__tail
while self.__head and self.__head.left:
self.__head = self.__head.left
return self.__head
@property
def tail(self) -> "LinkedListNode[T] | None":
if not self.__tail and self.__head:
self.__tail = self.__head
while self.__tail and self.__tail.right:
self.__tail = self.__tail.right
return self.__tail
def __init__(self, *values: Iterable[T]) -> None:
self.__head = None
self.__tail = None
for value in values:
self.append(value)
def append(self, value: T) -> None:
if tail := self.tail:
self.__tail = LinkedListNode(value, tail, None)
tail.right = self.__tail
else:
self.__tail = LinkedListNode(value, None, None)
def prepend(self, value: T) -> None:
if head := self.head:
self.__head = LinkedListNode(value, None, head)
head.left = self.__head
else:
self.__head = LinkedListNode(value, None, None)
def pop_tail(self) -> T | None:
if (tail := self.tail) is None:
raise IndexError("pop from empty LinkedList")
if tail.left:
tail.left.right = None
self.__tail = tail.left
tail.left = None
return tail.value
def pop_head(self) -> T:
if (head := self.head) is None:
raise IndexError("pop from empty LinkedList")
if head.right:
head.right.left = None
self.__head = head.right
head.right = None
return head.value
def copy(self) -> "LinkedList[T]":
return LinkedList(*self)
def __iter__(self) -> Iterable[T]:
node = self.head
while node is not None:
yield node.value
node = node.right
def __reversed__(self) -> Iterable[T]:
node = self.tail
while node is not None:
yield node.value
node = node.left
def __len__(self) -> int:
return ilen(self)
def __str__(self) -> str:
return f"LinkedList({', '.join(map(str, self))})"
def solve_p2(puzzle_input: str) -> int:
blocks = _parse_blocks(puzzle_input)
compacted_blocks = _compact_blocks_no_fragmentation(blocks)
return _hash_blocks(compacted_blocks)
class LinkedListNode[T]:
value: T
left: "LinkedListNode[T] | None"
right: "LinkedListNode[T] | None"
__slots__ = ("value", "left", "right")
def __init__(
self,
value: T,
left: "LinkedListNode[T] | None",
right: "LinkedListNode[T] | None",
) -> None:
self.value = value
self.left = left
self.right = right
def insert_left(self, value: T) -> None:
node = LinkedListNode(value, self.left, self)
if self.left:
self.left.right = node
self.left = node
def insert_right(self, value: T) -> None:
node = LinkedListNode(value, self, self.right)
if self.right:
self.right.left = node
self.right = node
def pop(self) -> T:
if self.left:
self.left.right = self.right
if self.right:
self.right.left = self.left
return self.value
class TestLinkedList(TestCase):
def test(self):
linked_list = LinkedList(1, 2, 3, 4, 5)
self.assertEqual(linked_list.head.value, 1)
self.assertEqual(linked_list.tail.value, 5)
self.assertListEqual(list(linked_list), [1, 2, 3, 4, 5])
self.assertListEqual(list(reversed(linked_list)), [5, 4, 3, 2, 1])
self.assertEqual(len(linked_list), 5)
self.assertEqual(linked_list.pop_tail(), 5)
self.assertEqual(linked_list.tail.value, 4)
self.assertEqual(linked_list.pop_head(), 1)
self.assertEqual(linked_list.head.value, 2)
linked_list.append(6)
self.assertEqual(linked_list.tail.value, 6)
linked_list.prepend(7)
self.assertEqual(linked_list.head.value, 7)
self.assertListEqual(list(linked_list), [7, 2, 3, 4, 6])
middle = linked_list.tail.left.left
self.assertEqual(middle.value, 3)
middle.insert_left(8)
self.assertListEqual(list(linked_list), [7, 2, 8, 3, 4, 6])
middle.insert_right(9)
self.assertListEqual(list(linked_list), [7, 2, 8, 3, 9, 4, 6])
self.assertEqual(middle.pop(), 3)
self.assertListEqual(list(linked_list), [7, 2, 8, 9, 4, 6])
class Block(NamedTuple):
@@ -29,110 +169,133 @@ class Block(NamedTuple):
size: int
def _parse_blocks(puzzle_input: str) -> list[Block]:
blocks: list[Block] = []
cursor_position = 0
current_id = 0
def compact_drive(drive: LinkedList[Block]) -> LinkedList[Block]:
if not len(drive):
return drive
snapshot = map(int, puzzle_input.strip())
for pair in chunked(snapshot, 2):
if data_size := pair[0]:
blocks.append(Block(current_id, cursor_position, data_size))
cursor_position += data_size
current_id += 1
if len(pair) == 2:
cursor_position += pair[1]
drive = drive.copy()
moving = drive.pop_tail()
cursor = drive.head
assert cursor is not None
return blocks
while cursor.right is not None:
a = cursor.value
b = cursor.right.value
free_space = b.position - a.position - a.size
if not free_space:
cursor = cursor.right
continue
def _compact_blocks(blocks: list[Block]) -> list[Block]:
assert blocks
blocks = blocks.copy()
insertion = Block(
moving.id,
a.position + a.size,
min(free_space, moving.size),
)
compacted_blocks: list[Block] = []
current_position = 0
moving_block = blocks.pop()
cursor.insert_right(insertion)
cursor = cursor.right
while blocks:
next_block = blocks.pop(0)
free_space = next_block.position - current_position
while free_space:
assert moving_block.size > 0
allocation = min(free_space, moving_block.size)
compacted_blocks.append(
Block(
moving_block.id,
current_position,
allocation,
)
if insertion.size < moving.size:
moving = Block(
moving.id,
moving.position,
moving.size - insertion.size,
)
current_position += allocation
free_space -= allocation
if moving_block.size > allocation:
moving_block = Block(
moving_block.id,
moving_block.position + allocation,
moving_block.size - allocation,
)
elif blocks:
moving_block = blocks.pop()
else:
break
else:
moving = drive.pop_tail()
compacted_blocks.append(next_block)
current_position += next_block.size
compacted_blocks.append(
cursor.insert_right(
Block(
moving_block.id,
current_position,
moving_block.size,
moving.id,
cursor.value.position + cursor.value.size,
moving.size,
)
)
return compacted_blocks
return drive
def _compact_blocks_no_fragmentation(blocks: list[Block]) -> list[Block]:
assert blocks
blocks = blocks.copy()
def compact_drive_without_fragmentation(drive: LinkedList[Block]) -> LinkedList[Block]:
if not len(drive):
return drive
compacted_blocks: list[Block] = []
drive = drive.copy()
moving = drive.tail
while len(blocks) > 1:
moving_block = blocks[-1]
for (_, a), (i, b) in pairwise(enumerate(blocks)):
while moving:
cursor = drive.head
while cursor != moving:
a = cursor.value
b = cursor.right.value
free_space = b.position - a.position - a.size
if free_space >= moving_block.size:
modified_block = Block(
moving_block.id,
a.position + a.size,
moving_block.size,
if free_space >= moving.value.size:
moving.pop()
cursor.insert_right(
Block(
moving.value.id,
a.position + a.size,
moving.value.size,
)
)
blocks.pop()
blocks.insert(i, modified_block)
break
else:
blocks.pop()
compacted_blocks.append(moving_block)
compacted_blocks.append(blocks[0])
cursor = cursor.right
return list(sorted(compacted_blocks, key=lambda block: block.position))
moving = moving.left
return drive
def _print_blocks(blocks: list[Block]) -> str:
def hash_drive(drive: LinkedList[Block]) -> int:
return sum(
block.id * block.size * (block.position * 2 + block.size - 1) // 2
for block in drive
)
def print_drive(drive: LinkedList[Block]) -> None:
snapshot = []
for a, b in pairwise(blocks):
for a, b in pairwise(drive):
snapshot.append(str(a.id) * a.size)
snapshot.append("." * (b.position - a.position - a.size))
snapshot.append(str(blocks[-1].id) * blocks[-1].size)
snapshot.append(str(drive.tail.value.id) * drive.tail.value.size)
print("".join(snapshot))
def _hash_blocks(blocks: list[Block]) -> int:
return sum(
block.id * pos
for block in blocks
for pos in range(block.position, block.position + block.size)
)
class DayNineSolver(Solver):
drive: LinkedList[Block]
@override
def __init__(self, puzzle_input: str):
self.drive = LinkedList()
snapshot = map(int, puzzle_input.strip())
cursor_position = 0
current_id = count()
for pair in chunked(snapshot, 2):
if data_size := pair[0]:
id = next(current_id)
self.drive.append(Block(id, cursor_position, data_size))
cursor_position += data_size
if len(pair) == 2:
cursor_position += pair[1]
@override
def solve_p1(self) -> int:
compacted_drive = compact_drive(self.drive)
return hash_drive(compacted_drive)
@override
def solve_p2(self) -> int:
compacted_drive = compact_drive_without_fragmentation(self.drive)
return hash_drive(compacted_drive)
class TestDayNineSolver(TestCase):
def test(self):
solver = DayNineSolver("2333133121414131402\n")
self.assertEqual(solver.solve_p1(), 1928)
self.assertEqual(solver.solve_p2(), 2858)

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