Restructure solver modules with classes and unittest suites
This commit is contained in:
69
puzzles/1.py
69
puzzles/1.py
@@ -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)
|
||||
|
133
puzzles/2.py
133
puzzles/2.py
@@ -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)
|
||||
|
86
puzzles/3.py
86
puzzles/3.py
@@ -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)
|
||||
|
155
puzzles/4.py
155
puzzles/4.py
@@ -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)
|
||||
|
206
puzzles/5.py
206
puzzles/5.py
@@ -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)
|
||||
|
260
puzzles/6.py
260
puzzles/6.py
@@ -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)
|
||||
|
136
puzzles/7.py
136
puzzles/7.py
@@ -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)
|
||||
|
199
puzzles/8.py
199
puzzles/8.py
@@ -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)
|
||||
|
355
puzzles/9.py
355
puzzles/9.py
@@ -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
12
puzzles/_solver.py
Normal 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: ...
|
82
solve.py
82
solve.py
@@ -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)
|
||||
|
Reference in New Issue
Block a user