diff --git a/lomakinae/docs/02_report.md b/lomakinae/docs/02_report.md new file mode 100644 index 0000000..1a252a1 --- /dev/null +++ b/lomakinae/docs/02_report.md @@ -0,0 +1,349 @@ +# Отчёт. Задание 2 - Поиск выхода из лабиринта + +## Цель + +Разработать расширяемую программу для загрузки лабиринта из файла и поиска пути от старта до выхода с +возможностью выбора алгоритма. Выполнить экспериментальное сравнение алгоритмов BFS, DFS и A\* на картах +различной сложности. Применить минимум 3 паттерна проектирования GoF. + +## 1. Описание задачи и выбранные паттерны + +Программный комплекс построен на принципах объектно-ориентированного программирования. + +Применены три паттерна проектирования GoF: + +1. **Builder (Строитель)** - изолирует логику парсинга текстовых файлов и валидации структуры лабиринта + (ровно один старт `S` и один выход `E`). Добавление нового формата (JSON, бинарный) требует только нового наследника `MazeBuilder`. +2. **Strategy (Стратегия)** - позволяет динамически менять алгоритм поиска пути (BFS, DFS, A\*) без модификации кода. Новый + алгоритм добавляется наследованием от `PathFindingStrategy`. +3. **Facade (Фасад)** - предоставляет единую точку входа `MazeTestingFacade.run_full_diagnostic()` для последовательного запуска подсистемы + бенчмарков и генерации графиков. + +### Диаграмма классов (Mermaid) + +```mermaid +classDiagram + class Maze { + +int width + +int height + +List~List~Cell~~ cells + +Cell start + +Cell exit + +get_cell(x, y): Cell + +set_cell(x, y, cell_type) + +get_neighbors(cell): List~Cell~ + } + + class Cell { + +int x + +int y + +bool is_wall + +bool is_start + +bool is_exit + +is_passable(): bool + } + + class MazeBuilder { + <> + +build_from_file(filename): Maze + } + + class TextFileMazeBuilder { + +build_from_file(filename): Maze + } + + class PathFindingStrategy { + <> + +int visited_count + +find_path(maze, start, exit_cell): List~Cell~ + +reconstruct_path(came_from, exit_cell): List~Cell~ + } + + class BFSStrategy + class DFSStrategy + class AStarStrategy + + class SearchStats { + +float time_ms + +int visited_cells + +int path_length + } + + class MazeSolver { + +Maze maze + +PathFindingStrategy strategy + +set_strategy(strategy) + +solve(): SearchStats + } + + class MazeTestingFacade { + +run_full_diagnostic() + } + + MazeBuilder <|.. TextFileMazeBuilder + MazeBuilder --> Maze : создает + PathFindingStrategy <|.. BFSStrategy + PathFindingStrategy <|.. DFSStrategy + PathFindingStrategy <|.. AStarStrategy + MazeSolver --> PathFindingStrategy : использует + MazeSolver --> Maze : содержит + Maze *-- Cell : содержит + MazeTestingFacade --> MazeSolver : оркестрирует +``` + +## 2. Листинги ключевых классов + +### Builder - загрузка лабиринта из файла (`src/builder.py`) + +```python +class MazeBuilder(ABC): + @abstractmethod + def build_from_file(self, filename: str) -> Maze: + pass + + +class TextFileMazeBuilder(MazeBuilder): + def build_from_file(self, filename: str) -> Maze: + with open(filename, "r", encoding="utf-8") as f: + lines = [line.rstrip("\n") for line in f.readlines()] + height = len(lines) + width = max(len(line) for line in lines) if height > 0 else 0 + + start_count = 0 + exit_count = 0 + maze = Maze(width, height) + + for y, line in enumerate(lines): + for x, ch in enumerate(line): + if ch == "#": + maze.set_cell(x, y, "wall") + elif ch == "S": + maze.set_cell(x, y, "start") + start_count += 1 + elif ch == "E": + maze.set_cell(x, y, "exit") + exit_count += 1 + else: + maze.set_cell(x, y, "path") + + if start_count != 1 or exit_count != 1: + raise ValueError(f"S={start_count}, E={exit_count}") + return maze +``` + +### Strategy - алгоритмы поиска пути (`src/strategies.py`) + +```python +class PathFindingStrategy(ABC): + def __init__(self): + self.visited_count = 0 + + @abstractmethod + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> List[Cell]: + pass + + def reconstruct_path(self, came_from: dict, exit_cell: Cell) -> List[Cell]: + path = [] + current = exit_cell + while current is not None: + path.append(current) + current = came_from.get(current) + path.reverse() + return path + + +class BFSStrategy(PathFindingStrategy): + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> List[Cell]: + queue = deque([start]) + came_from = {start: None} + visited = {start} + + while queue: + current = queue.popleft() + if current == exit_cell: + self.visited_count = len(visited) + return self.reconstruct_path(came_from, exit_cell) + for neighbor in maze.get_neighbors(current): + if neighbor not in visited: + visited.add(neighbor) + came_from[neighbor] = current + queue.append(neighbor) + self.visited_count = len(visited) + return [] + + +class AStarStrategy(PathFindingStrategy): + def heuristic(self, cell: Cell, exit_cell: Cell) -> int: + return abs(cell.x - exit_cell.x) + abs(cell.y - exit_cell.y) + + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> List[Cell]: + heap = [] + counter = 0 + start_f = self.heuristic(start, exit_cell) + heapq.heappush(heap, (start_f, counter, start)) + counter += 1 + came_from = {} + g_score = {start: 0} + f_score = {start: start_f} + visited = set() + + while heap: + current_f, _, current = heapq.heappop(heap) + visited.add(current) + + if current == exit_cell: + self.visited_count = len(visited) + return self.reconstruct_path(came_from, exit_cell) + if current_f > f_score.get(current, float("inf")): + continue + for neighbor in maze.get_neighbors(current): + tentative_g = g_score[current] + 1 + if tentative_g < g_score.get(neighbor, float("inf")): + came_from[neighbor] = current + g_score[neighbor] = tentative_g + new_f = tentative_g + self.heuristic(neighbor, exit_cell) + f_score[neighbor] = new_f + heapq.heappush(heap, (new_f, counter, neighbor)) + counter += 1 + self.visited_count = len(visited) + return [] +``` + +### Оркестратор (`src/solver.py`) + +```python +class SearchStats(NamedTuple): + time_ms: float + visited_cells: int + path_length: int + + +class MazeSolver: + def __init__(self, maze: Maze): + self.maze = maze + self.strategy = None + + def set_strategy(self, strategy: PathFindingStrategy): + self.strategy = strategy + + def solve(self) -> SearchStats: + if self.strategy is None: + raise ValueError("Strategy not set") + start_time = time.perf_counter() + path = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit) + end_time = time.perf_counter() + time_ms = (end_time - start_time) * 1000 + return SearchStats( + time_ms=time_ms, + visited_cells=self.strategy.visited_count, + path_length=len(path), + ) +``` + +### Facade - точка входа (`src/facade.py` и `main.py`) + +```python +# src/facade.py +class MazeTestingFacade: + def run_full_diagnostic(self): + run_benchmarks() + generate_plots() + +# main.py +from src.facade import MazeTestingFacade + +def main(): + facade = MazeTestingFacade() + facade.run_full_diagnostic() +``` + +## 3. Результаты экспериментов + +### Параметры эксперимента + +| Параметр | Значение | +| --------- | -------------------------------------------------------------- | +| Итераций | 10 запусков на каждый лабиринт | +| Лабиринты | maze_no_exit, maze_empty, maze_10x10, maze_50x50, maze_100x100 | +| Алгоритмы | BFS (ширина), DFS (глубина), A\* (манхэттенская эвристика) | +| Метрики | Время выполнения (мс), посещенные клетки, длина пути | + +Тесты проводились на 5 разных лабиринтах. Так как основные лабиринты (10x10, 50x50, 100x100) имеют только +один верный путь, итоговая длина маршрута у всех алгоритмов совпадает. Сравниваем время работы и количество +проверенных клеток. + +**1. Маленький лабиринт (10x10)** +Все алгоритмы отработали быстрее 0.05 мс. Алгоритм A\* оказался наиболее точным, так как посетил всего 18 клеток. +BFS проверил 33 клетки, а DFS проверил 32 клетки. + +**2. Средний лабиринт (50x50)** +Здесь BFS обошел больше всего пространства (1046 клеток) и работал дольше всех (1.31 мс). В данном тесте DFS удачно +выбрал направление и проверил меньше клеток (231 клетка за 0.25 мс). Алгоритм A\* показал стабильный результат, +посетив 414 клеток за 1.05 мс. + +**3. Большой лабиринт (100x100)** +На большой карте заметно преимущество A\*. Он нашел выход за 0.98 мс, посетив всего 380 клеток. BFS пришлось проверить +почти весь лабиринт (2319 клеток), на что ушло 3.15 мс. DFS справился за 0.77 мс и проверил 662 клетки. + +**4. Пустой лабиринт (без стен)** +В пустом пространстве все алгоритмы посетили одинаковое количество клеток (90 шт.). Однако DFS повел себя неоптимально +и построил длинный зигзагообразный маршрут в 54 шага. BFS и A\* нашли прямой и кратчайший путь всего за 18 шагов. + +**5. Лабиринт без выхода** +Все алгоритмы корректно обработали тупик и завершили работу без ошибок. Так как выхода нет, им пришлось проверить +абсолютно все доступные клетки лабиринта (по 16 клеток у каждого). Длина пути у всех составила 0. Дольше всех из-за +расчета эвристики работал A\* (0.036 мс), а BFS и DFS справились за 0.02 мс. + +### Графики + +![plot](data/02/benchmark_charts.png) + +--- + +## 4. Анализ эффективности алгоритмов и применимость паттернов + +### Масштабирование по размеру лабиринта + +1. **A\*** - самый эффективный на больших картах. Благодаря Манхэттенской эвристике он учитывает направление + к выходу и минимизирует лишние шаги. На лабиринте 100х100 он проверил всего 380 клеток (против 2319 у BFS). + Минус - тратит чуть больше времени на мелких картах из-за математических расчетов. + +2. **BFS (Поиск в ширину)** - выполняет избыточное исследование графа во все стороны. Из-за этого на карте 100х100 + он посетил 2319 клеток, что увеличило время выполнения до 3.15 мс (худший результат по скорости). + +3. **DFS (Поиск в глубину)** - работает на простом стеке, поэтому у него минимальные накладные + расходы по времени (всего 0.77 мс на большой карте). Однако он не ищет оптимальный путь: на пустом лабиринте + `maze_empty` вместо прямой линии в 18 шагов он построил ломаный зигзаг в 54 шага. + +4. **Обработка тупиков (`maze_no_exit`)** - все алгоритмы успешно прошли стресс-тест. При отсутствии выхода они просто + обходят 100% доступных клеток и корректно возвращают пустой путь. + +### Применимость паттернов + +Паттерн **Strategy** позволил реализовать систему бенчмарков итерацией по массиву стратегий: `solver.set_strategy(strat)`. +Без него пришлось бы использовать `if-elif` внутри решателя, что нарушило бы принцип открытости-закрытости (OCP). Добавление +нового алгоритма (например, Дейкстры) не требует изменений в существующем коде. + +Паттерн **Builder** полностью инкапсулировал работу с файловой системой. Переход на другой формат хранения (JSON, бинарный) +требует только создания нового наследника `MazeBuilder` без изменения остальной системы. + +Паттерн **Facade** скрыл последовательность вызовов `run_benchmarks()` и `generate_plots()` за единственным методом +`run_full_diagnostic()`. Код `main.py` сведен к двум строкам и не зависит от деталей оркестрации подсистем. + +--- + +## 5. Выводы + +Для реальных задач: + +- Экспериментально подтверждено, что поиск ($A^*$) с использованием Манхэттенской + эвристики превосходит слепые методы (BFS, DFS) на больших лабиринтах. + +- Поиск в глубину непригоден для пустых пространств (строит крайне неоптимальные зигзагообразные пути) + и сильно зависит от порядка обхода соседей, но не требует хранения большого объёма данных в памяти. + +- Тестирование на изолированном лабиринте ("без выхода") доказало отказоустойчивость реализованных стратегий - алгоритмы + корректно завершают работу после полного исчерпания пространства состояний. + +Применение паттернов GoF (Builder, Strategy, Facade) обеспечило модульность и расширяемость системы. Добавление нового алгоритма, +формата файлов или этапа диагностики не затрагивает существующий код, что соответствует принципам SOLID.