From f7577f803cf8915ad9921693bcbc97dd92f1ff9c Mon Sep 17 00:00:00 2001 From: SerKin0 <71343548+SerKin0@users.noreply.github.com> Date: Sun, 24 May 2026 17:17:02 +0300 Subject: [PATCH] =?UTF-8?q?[2]=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE:=20-=20=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20Cell,=20M?= =?UTF-8?q?aze=20-=20=D0=90=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B8:=20BFS,=20DFS,=20Astar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skorohodovsa/task_2/source/models/base.py | 23 ++-- skorohodovsa/task_2/source/settings.py | 7 +- .../task_2/source/strategy/__init__.py | 11 ++ .../strategy.py => strategy/algorithms.py} | 37 +++++- skorohodovsa/task_2/source/strategy/astar.py | 46 ++++++++ skorohodovsa/task_2/source/strategy/bfs.py | 37 ++++++ skorohodovsa/task_2/source/strategy/dfs.py | 35 ++++++ skorohodovsa/task_2/source/strategy/solver.py | 93 +++++++++++++++ skorohodovsa/task_2/test/map/test_cell.py | 109 ++++++++++++++++- skorohodovsa/task_2/test/map/test_maze.py | 111 ++++++++++++++++++ 10 files changed, 484 insertions(+), 25 deletions(-) create mode 100644 skorohodovsa/task_2/source/strategy/__init__.py rename skorohodovsa/task_2/source/{algorithms/strategy.py => strategy/algorithms.py} (67%) create mode 100644 skorohodovsa/task_2/source/strategy/astar.py create mode 100644 skorohodovsa/task_2/source/strategy/bfs.py create mode 100644 skorohodovsa/task_2/source/strategy/dfs.py create mode 100644 skorohodovsa/task_2/source/strategy/solver.py diff --git a/skorohodovsa/task_2/source/models/base.py b/skorohodovsa/task_2/source/models/base.py index 863cb77..632d777 100644 --- a/skorohodovsa/task_2/source/models/base.py +++ b/skorohodovsa/task_2/source/models/base.py @@ -103,12 +103,12 @@ class Cell: Строковый символ, соответствующий текущему типу клетки. """ if self._is_wall: - return cell_mapping['wall'] + return cell_mapping["wall"] if self._is_start: - return cell_mapping['start'] + return cell_mapping["start"] if self._is_exit: - return cell_mapping['exit'] - return cell_mapping['empty'] + return cell_mapping["exit"] + return cell_mapping["empty"] def __str__(self) -> str: return self._get_type_cell() @@ -132,8 +132,7 @@ class Maze: """ self._width, self._height = size self._map: list[list[Cell]] = [ - [Cell(x, y) for x in range(self._width)] - for y in range(self._height) + [Cell(x, y) for x in range(self._width)] for y in range(self._height) ] def _check_point_in_map(self, x: int, y: int) -> bool: @@ -185,6 +184,10 @@ class Maze: return neighbors + @property + def shape(self) -> tuple[int, int]: + return self._height, self._width + def __getitem__(self, index: tuple[int, int]) -> Cell: """Возвращает клетку по индексу [row, col]. @@ -226,13 +229,13 @@ class Maze: if cell_type is None: raise ValueError(f"Символ '{value}' не соответствует ни одному типу клетки") - if cell_type == 'empty': + if cell_type == "empty": cell._clear_flags() else: setattr(cell, f"is_{cell_type}", True) def __str__(self) -> str: - return '\n'.join( - ''.join(str(self._map[y][x]) for x in range(self._width)) + return "\n".join( + "".join(str(self._map[y][x]) for x in range(self._width)) for y in range(self._height) - ) \ No newline at end of file + ) diff --git a/skorohodovsa/task_2/source/settings.py b/skorohodovsa/task_2/source/settings.py index 65da0c8..0a0f313 100644 --- a/skorohodovsa/task_2/source/settings.py +++ b/skorohodovsa/task_2/source/settings.py @@ -1,6 +1 @@ -cell_mapping = { - 'wall': '#', - 'empty': ' ', - 'start': 'S', - 'exit': 'E' -} \ No newline at end of file +cell_mapping = {"wall": "#", "empty": " ", "start": "S", "exit": "E"} diff --git a/skorohodovsa/task_2/source/strategy/__init__.py b/skorohodovsa/task_2/source/strategy/__init__.py new file mode 100644 index 0000000..0da8de2 --- /dev/null +++ b/skorohodovsa/task_2/source/strategy/__init__.py @@ -0,0 +1,11 @@ +from source.strategy.algorithms import PathFindingStrategy +from source.strategy.astar import AStarStrategy +from source.strategy.bfs import BFSStrategy +from source.strategy.dfs import DFSStrategy + +__all__ = [ + "PathFindingStrategy", + "BFSStrategy", + "DFSStrategy", + "AStarStrategy", +] \ No newline at end of file diff --git a/skorohodovsa/task_2/source/algorithms/strategy.py b/skorohodovsa/task_2/source/strategy/algorithms.py similarity index 67% rename from skorohodovsa/task_2/source/algorithms/strategy.py rename to skorohodovsa/task_2/source/strategy/algorithms.py index ea956f1..0c17a8d 100644 --- a/skorohodovsa/task_2/source/algorithms/strategy.py +++ b/skorohodovsa/task_2/source/strategy/algorithms.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections import deque from typing import Optional from source.models.base import Maze, Cell @@ -9,7 +8,9 @@ class PathFindingStrategy(ABC): """Интерфейс стратегии поиска пути в лабиринте.""" @abstractmethod - def find_path(self, maze: Maze, start: Cell, exit: Cell) -> list[Cell]: + def find_path( + self, maze: Maze, start: Cell = None, exit: Cell = None + ) -> list[Cell]: """Найти путь от start до exit. Args: @@ -22,7 +23,27 @@ class PathFindingStrategy(ABC): Пустой список, если путь не найден. """ - def _reconstruct_path(came_from: dict[Cell, Optional[Cell]], end: Cell) -> list[Cell]: + def _find_start(self, maze: Maze) -> Optional[Cell]: + row, col = maze.shape + + for y in range(row): + for x in range(col): + if maze[y, x].is_start: + return maze[y, x] + return None + + def _find_exit(self, maze: Maze) -> Optional[Cell]: + row, col = maze.shape + + for y in range(row): + for x in range(col): + if maze[y, x].is_exit: + return maze[y, x] + return None + + def _reconstruct_path( + self, came_from: dict[Cell, Optional[Cell]], end: Cell + ) -> list[Cell]: """Восстанавливает путь от старта до end, идя по came_from в обратном порядке. Args: @@ -44,6 +65,10 @@ class PathFindingStrategy(ABC): path.reverse() return path -class BFS(PathFindingStrategy): - def find_path(self, maze: Maze, start: Cell, exit: Cell) -> list[Cell]: - pass \ No newline at end of file + + + + + + + diff --git a/skorohodovsa/task_2/source/strategy/astar.py b/skorohodovsa/task_2/source/strategy/astar.py new file mode 100644 index 0000000..ba07fce --- /dev/null +++ b/skorohodovsa/task_2/source/strategy/astar.py @@ -0,0 +1,46 @@ +import heapq +from typing import Optional + +from source.models.base import Cell, Maze +from source.strategy.algorithms import PathFindingStrategy + + +def _manhattan(a: Cell, b: Cell) -> int: + """Манхэттенское расстояние между двумя клетками.""" + return abs(a.x - b.x) + abs(a.y - b.y) + + +class AStarStrategy(PathFindingStrategy): + """Алгоритм A* с манхэттенской эвристикой.""" + + def find_path(self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None) -> list[Cell]: + if start is None: + start = self._find_start(maze) + if exit is None: + exit = self._find_exit(maze) + + g_score: dict[Cell, int] = {start: 0} + came_from: dict[Cell, Optional[Cell]] = {start: None} + + counter = 0 + open_heap: list[tuple[int, int, Cell]] = [ + (_manhattan(start, exit), counter, start) + ] + + while open_heap: + _, _, current = heapq.heappop(open_heap) + + if current is exit: + return self._reconstruct_path(came_from, exit) + + for neighbor in maze.get_neighbors(current.x, current.y): + tentative_g = g_score[current] + 1 + + if tentative_g < g_score.get(neighbor, float("inf")): + g_score[neighbor] = tentative_g + came_from[neighbor] = current + f = tentative_g + _manhattan(neighbor, exit) + counter += 1 + heapq.heappush(open_heap, (f, counter, neighbor)) + + return [] \ No newline at end of file diff --git a/skorohodovsa/task_2/source/strategy/bfs.py b/skorohodovsa/task_2/source/strategy/bfs.py new file mode 100644 index 0000000..39e6595 --- /dev/null +++ b/skorohodovsa/task_2/source/strategy/bfs.py @@ -0,0 +1,37 @@ +from collections import deque +from typing import Optional + +from source.models.base import Cell, Maze +from source.strategy.algorithms import PathFindingStrategy + + +class BFSStrategy(PathFindingStrategy): + """Поиск в ширину (Breadth-First Search). + + Гарантирует кратчайший путь по количеству шагов. + Сложность: O(V + E) по времени и памяти. + """ + + def find_path( + self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None + ) -> list[Cell]: + if start is None: + start = self._find_start(maze) + if exit is None: + exit = self._find_exit(maze) + + came_from: dict[Cell, Optional[Cell]] = {start: None} + queue: deque[Cell] = deque([start]) + + while queue: + current = queue.popleft() + + if current is exit: + return self._reconstruct_path(came_from, exit) + + for neighbor in maze.get_neighbors(current.x, current.y): + if neighbor not in came_from: + came_from[neighbor] = current + queue.append(neighbor) + + return [] \ No newline at end of file diff --git a/skorohodovsa/task_2/source/strategy/dfs.py b/skorohodovsa/task_2/source/strategy/dfs.py new file mode 100644 index 0000000..c24a1e2 --- /dev/null +++ b/skorohodovsa/task_2/source/strategy/dfs.py @@ -0,0 +1,35 @@ +from typing import Optional + +from source.models.base import Maze, Cell +from source.strategy.algorithms import PathFindingStrategy + + +class DFSStrategy(PathFindingStrategy): + """Поиск в глубину (Depth-First Search). + + Находит путь, но не гарантирует кратчайший. + """ + + def find_path( + self, maze: Maze, start: Optional[Cell] = None, exit: Optional[Cell] = None + ) -> list[Cell]: + if start is None: + start = self._find_start(maze) + if exit is None: + exit = self._find_exit(maze) + + came_from: dict[Cell, Optional[Cell]] = {start: None} + stack: list[Cell] = [start] + + while stack: + current = stack.pop() + + if current is exit: + return self._reconstruct_path(came_from, exit) + + for neighbor in maze.get_neighbors(current.x, current.y): + if neighbor not in came_from: + came_from[neighbor] = current + stack.append(neighbor) + + return [] \ No newline at end of file diff --git a/skorohodovsa/task_2/source/strategy/solver.py b/skorohodovsa/task_2/source/strategy/solver.py new file mode 100644 index 0000000..eb17b64 --- /dev/null +++ b/skorohodovsa/task_2/source/strategy/solver.py @@ -0,0 +1,93 @@ +import time +from dataclasses import dataclass + +from source.models.base import Maze, Cell +from source.strategy import PathFindingStrategy + + +@dataclass +class SearchStats: + """Статистика выполнения поиска пути. + + Attributes: + elapsed_ms: Время выполнения в миллисекундах. + visited_count: Количество посещённых клеток. + path_length: Длина найденного пути (0 если путь не найден). + path: Найденный путь — список клеток от старта до выхода. + """ + elapsed_ms: float + visited_count: int + path_length: int + path: list[Cell] + + def __str__(self) -> str: + return ( + f"Время: {self.elapsed_ms:.3f} мс | " + f"Посещено клеток: {self.visited_count} | " + f"Длина пути: {self.path_length}" + ) + + +class MazeSolver: + """Оркестратор поиска пути в лабиринте. + + Принимает лабиринт и стратегию поиска, выполняет поиск + и возвращает результат вместе со статистикой выполнения. + + Example: + solver = MazeSolver(maze, BFSStrategy()) + stats = solver.solve() + print(stats) + + solver.set_strategy(AStarStrategy()) + stats = solver.solve() + """ + + def __init__(self, maze: Maze, strategy: PathFindingStrategy) -> None: + """Инициализирует солвер с лабиринтом и стратегией поиска. + + Args: + maze: Объект лабиринта. + strategy: Стратегия поиска пути. + """ + self._maze = maze + self._strategy = strategy + + def set_strategy(self, strategy: PathFindingStrategy) -> None: + """Заменяет текущую стратегию поиска. + + Args: + strategy: Новая стратегия поиска пути. + """ + self._strategy = strategy + + def solve( + self, + start: Cell = None, + exit: Cell = None, + ) -> SearchStats: + """Выполняет поиск пути и собирает статистику. + + Если start или exit не переданы явно, стратегия найдёт + их самостоятельно по флагам is_start / is_exit в лабиринте. + + Args: + start: Стартовая клетка (опционально). + exit: Конечная клетка (опционально). + + Returns: + Объект SearchStats с временем выполнения, количеством + посещённых клеток и длиной найденного пути. + """ + t_start = time.perf_counter() + path = self._strategy.find_path(self._maze, start, exit) + t_end = time.perf_counter() + + elapsed_ms = (t_end - t_start) * 1000 + + return SearchStats( + elapsed_ms=elapsed_ms, + visited_count=len(path), + path_length=len(path), + path=path, + ) \ No newline at end of file diff --git a/skorohodovsa/task_2/test/map/test_cell.py b/skorohodovsa/task_2/test/map/test_cell.py index b25d10e..31d7714 100644 --- a/skorohodovsa/task_2/test/map/test_cell.py +++ b/skorohodovsa/task_2/test/map/test_cell.py @@ -1,8 +1,111 @@ import pytest import sys import os -import copy -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "./../../models"))) +from source.models.base import Cell -from base import Cell \ No newline at end of file +class TestCellCreation: + """Тесты создания клетки и начальных значений.""" + + def test_coordinates_are_set(self): + cell = Cell(3, 7) + assert cell.x == 3 + assert cell.y == 7 + + def test_default_flags_are_false(self): + cell = Cell(0, 0) + assert cell.is_wall is False + assert cell.is_start is False + assert cell.is_exit is False + + def test_create_wall(self): + cell = Cell(0, 0, is_wall=True) + assert cell.is_wall is True + + def test_create_start(self): + cell = Cell(0, 0, is_start=True) + assert cell.is_start is True + + def test_create_exit(self): + cell = Cell(0, 0, is_exit=True) + assert cell.is_exit is True + + +class TestCellIsPassable: + """Тесты метода is_possible.""" + + def test_empty_cell_is_passable(self): + cell = Cell(0, 0) + assert cell.is_possible() is True + + def test_wall_is_not_passable(self): + cell = Cell(0, 0, is_wall=True) + assert cell.is_possible() is False + + def test_start_cell_is_passable(self): + cell = Cell(0, 0, is_start=True) + assert cell.is_possible() is True + + def test_exit_cell_is_passable(self): + cell = Cell(0, 0, is_exit=True) + assert cell.is_possible() is True + + +class TestCellFlagsAreMutuallyExclusive: + """Тесты взаимного исключения флагов.""" + + def test_set_wall_clears_start(self): + cell = Cell(0, 0, is_start=True) + cell.is_wall = True + assert cell.is_start is False + assert cell.is_wall is True + + def test_set_wall_clears_exit(self): + cell = Cell(0, 0, is_exit=True) + cell.is_wall = True + assert cell.is_exit is False + assert cell.is_wall is True + + def test_set_start_clears_wall(self): + cell = Cell(0, 0, is_wall=True) + cell.is_start = True + assert cell.is_wall is False + assert cell.is_start is True + + def test_set_start_clears_exit(self): + cell = Cell(0, 0, is_exit=True) + cell.is_start = True + assert cell.is_exit is False + assert cell.is_start is True + + def test_set_exit_clears_wall(self): + cell = Cell(0, 0, is_wall=True) + cell.is_exit = True + assert cell.is_wall is False + assert cell.is_exit is True + + def test_set_exit_clears_start(self): + cell = Cell(0, 0, is_start=True) + cell.is_exit = True + assert cell.is_start is False + assert cell.is_exit is True + + def test_unset_wall_does_not_clear_others(self): + # снятие флага (False) не должно трогать остальные + cell = Cell(0, 0, is_wall=True) + cell.is_wall = False + assert cell.is_start is False + assert cell.is_exit is False + + +class TestCellStr: + """Тесты строкового представления клетки.""" + + def test_str_returns_string(self): + cell = Cell(0, 0) + assert isinstance(str(cell), str) + + def test_repr_contains_coordinates(self): + cell = Cell(4, 9) + assert "4" in repr(cell) + assert "9" in repr(cell) \ No newline at end of file diff --git a/skorohodovsa/task_2/test/map/test_maze.py b/skorohodovsa/task_2/test/map/test_maze.py index e69de29..5b7e111 100644 --- a/skorohodovsa/task_2/test/map/test_maze.py +++ b/skorohodovsa/task_2/test/map/test_maze.py @@ -0,0 +1,111 @@ +import pytest +import random + +random.seed("РФ СЛФ!") + +from source.models.base import Cell, Maze +from source.settings import cell_mapping + + +class TestMaze: + + def test_default_size(self): + """Проверка размеров лабиринта со значениями по умолчанию""" + maze = Maze() + row, col = maze.shape + assert row == 10 + assert col == 10 + + def test_custom_size(self): + """Проверка размеров лабиринта с заданными размерами""" + maze = Maze(size=(7, 3)) + assert maze._width == 7 + assert maze._height == 3 + + def test_all_cells_empty_on_init(self): + """Проверка создания пустого лабиринта с заданными размерами""" + maze = Maze(size=(3, 3)) + for y in range(3): + for x in range(3): + cell = maze.get_cell(x, y) + assert not cell.is_wall + assert not cell.is_start + assert not cell.is_exit + + def test_get_cell_valid(self): + """Проверка получения объекта Cell из лабиринта функцией `get_cell()`""" + maze = Maze(size=(5, 5)) + assert isinstance(maze.get_cell(2, 3), Cell) + + def test_get_cell_out_of_bounds(self): + """Проверка неправильных указанных индексов лабиринта""" + maze = Maze(size=(5, 5)) + assert maze.get_cell(-1, 0) is None + assert maze.get_cell(0, -1) is None + assert maze.get_cell(5, 0) is None + assert maze.get_cell(0, 5) is None + + def test_center_has_four_neighbors(self): + """Проверка нахождения соседей""" + maze = Maze(size=(5, 5)) + assert len(maze.get_neighbors(2, 2)) == 4 + + def test_corner_has_two_neighbors(self): + """Проверка нахождения соседей, когда указанное поле в углу лабиринта""" + maze = Maze(size=(5, 5)) + assert len(maze.get_neighbors(0, 0)) == 2 + + def test_wall_excluded_from_neighbors(self): + """Проверка что стена не попадает в список соседей""" + maze = Maze(size=(5, 5)) + maze[1, 2] = cell_mapping['wall'] + assert all(not n.is_wall for n in maze.get_neighbors(2, 2)) + + def test_setitem_wall(self): + """Проверка установки стены через оператор []""" + maze = Maze(size=(5, 5)) + maze[0, 0] = cell_mapping['wall'] + assert maze[0, 0].is_wall is True + + def test_setitem_start(self): + """Проверка установки старта через оператор []""" + maze = Maze(size=(5, 5)) + maze[0, 0] = cell_mapping['start'] + assert maze[0, 0].is_start is True + + def test_setitem_exit(self): + """Проверка установки выхода через оператор []""" + maze = Maze(size=(5, 5)) + maze[0, 0] = cell_mapping['exit'] + assert maze[0, 0].is_exit is True + + def test_setitem_empty_clears_flags(self): + """Проверка сброса флагов клетки при установке пустого типа""" + maze = Maze(size=(5, 5)) + maze[0, 0] = cell_mapping['wall'] + maze[0, 0] = cell_mapping['empty'] + assert not maze[0, 0].is_wall + + def test_getitem_out_of_bounds_raises(self): + """Проверка выброса IndexError при обращении к клетке вне границ лабиринта""" + maze = Maze(size=(5, 5)) + with pytest.raises(IndexError): + _ = maze[10, 10] + + def test_setitem_invalid_symbol_raises(self): + """Проверка выброса ValueError при установке неизвестного символа""" + maze = Maze(size=(5, 5)) + with pytest.raises(ValueError): + maze[0, 0] = "?" + + def test_str_lines_match_height(self): + """Проверка что количество строк в строковом представлении совпадает с высотой""" + maze = Maze(size=(4, 6)) + print(str(maze).splitlines()) + assert len(str(maze).splitlines()) == 6 + + def test_str_line_length_matches_width(self): + """Проверка что длина каждой строки в строковом представлении совпадает с шириной""" + maze = Maze(size=(5, 3)) + for line in str(maze).strip().splitlines(): + assert len(line) == 5 \ No newline at end of file