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