From 3fa79f06c3539ca159fd4d9774de001b10831c99 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 19 May 2026 22:39:51 +0300 Subject: [PATCH] [2] Task 2 --- shahovaa/zadanie 2/.gitignore | 4 + shahovaa/zadanie 2/README.md | 67 ++++++ shahovaa/zadanie 2/data/mazes/empty.txt | 50 +++++ shahovaa/zadanie 2/data/mazes/large.txt | 100 +++++++++ shahovaa/zadanie 2/data/mazes/medium.txt | 50 +++++ shahovaa/zadanie 2/data/mazes/no_exit.txt | 30 +++ shahovaa/zadanie 2/data/mazes/small.txt | 10 + shahovaa/zadanie 2/main.py | 94 ++++++++ shahovaa/zadanie 2/maze_solver/__init__.py | 34 +++ shahovaa/zadanie 2/maze_solver/builders.py | 75 +++++++ shahovaa/zadanie 2/maze_solver/commands.py | 79 +++++++ shahovaa/zadanie 2/maze_solver/models.py | 81 +++++++ shahovaa/zadanie 2/maze_solver/observers.py | 40 ++++ shahovaa/zadanie 2/maze_solver/solver.py | 57 +++++ shahovaa/zadanie 2/maze_solver/strategies.py | 150 +++++++++++++ .../zadanie 2/reports/charts/empty_time.svg | 28 +++ .../reports/charts/empty_visited.svg | 28 +++ .../zadanie 2/reports/charts/large_time.svg | 28 +++ .../reports/charts/large_visited.svg | 28 +++ .../zadanie 2/reports/charts/medium_time.svg | 28 +++ .../reports/charts/medium_visited.svg | 28 +++ .../zadanie 2/reports/charts/no_exit_time.svg | 28 +++ .../reports/charts/no_exit_visited.svg | 28 +++ .../zadanie 2/reports/charts/small_time.svg | 28 +++ .../reports/charts/small_visited.svg | 28 +++ shahovaa/zadanie 2/reports/report.md | 208 ++++++++++++++++++ shahovaa/zadanie 2/reports/results.csv | 21 ++ shahovaa/zadanie 2/scripts/__init__.py | 1 + shahovaa/zadanie 2/scripts/generate_mazes.py | 126 +++++++++++ shahovaa/zadanie 2/scripts/run_experiments.py | 194 ++++++++++++++++ shahovaa/zadanie 2/tests/__init__.py | 1 + shahovaa/zadanie 2/tests/test_solver.py | 64 ++++++ 32 files changed, 1816 insertions(+) create mode 100644 shahovaa/zadanie 2/.gitignore create mode 100644 shahovaa/zadanie 2/README.md create mode 100644 shahovaa/zadanie 2/data/mazes/empty.txt create mode 100644 shahovaa/zadanie 2/data/mazes/large.txt create mode 100644 shahovaa/zadanie 2/data/mazes/medium.txt create mode 100644 shahovaa/zadanie 2/data/mazes/no_exit.txt create mode 100644 shahovaa/zadanie 2/data/mazes/small.txt create mode 100644 shahovaa/zadanie 2/main.py create mode 100644 shahovaa/zadanie 2/maze_solver/__init__.py create mode 100644 shahovaa/zadanie 2/maze_solver/builders.py create mode 100644 shahovaa/zadanie 2/maze_solver/commands.py create mode 100644 shahovaa/zadanie 2/maze_solver/models.py create mode 100644 shahovaa/zadanie 2/maze_solver/observers.py create mode 100644 shahovaa/zadanie 2/maze_solver/solver.py create mode 100644 shahovaa/zadanie 2/maze_solver/strategies.py create mode 100644 shahovaa/zadanie 2/reports/charts/empty_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/empty_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/large_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/large_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/medium_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/medium_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/no_exit_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/no_exit_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/small_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/small_visited.svg create mode 100644 shahovaa/zadanie 2/reports/report.md create mode 100644 shahovaa/zadanie 2/reports/results.csv create mode 100644 shahovaa/zadanie 2/scripts/__init__.py create mode 100644 shahovaa/zadanie 2/scripts/generate_mazes.py create mode 100644 shahovaa/zadanie 2/scripts/run_experiments.py create mode 100644 shahovaa/zadanie 2/tests/__init__.py create mode 100644 shahovaa/zadanie 2/tests/test_solver.py diff --git a/shahovaa/zadanie 2/.gitignore b/shahovaa/zadanie 2/.gitignore new file mode 100644 index 0000000..218a8cd --- /dev/null +++ b/shahovaa/zadanie 2/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/shahovaa/zadanie 2/README.md b/shahovaa/zadanie 2/README.md new file mode 100644 index 0000000..0b6d252 --- /dev/null +++ b/shahovaa/zadanie 2/README.md @@ -0,0 +1,67 @@ +# Поиск выхода из лабиринта + +Объектно-ориентированная реализация поиска пути в лабиринте с паттернами GoF: +Builder, Strategy, Observer и Command. + +## Что реализовано + +- модель `Cell` и `Maze`; +- загрузка лабиринта из текстового файла через `TextFileMazeBuilder`; +- стратегии поиска пути: BFS, DFS, A*, Дейкстра; +- `MazeSolver`, который измеряет время, число посещенных клеток и длину пути; +- консольный `Observer` для сообщений и отрисовки; +- `MoveCommand` и `Player` для ручного режима с undo; +- генератор тестовых лабиринтов; +- экспериментальный скрипт, CSV и SVG-графики; +- отчет: `reports/report.md`. + +## Формат лабиринта + +```text +# - стена + - проход +S - старт +E - выход +2, 3, ~ - проходимые клетки с увеличенным весом +``` + +Все строки в файле лабиринта должны иметь одинаковую длину. + +## Запуск + +```bash +python3 scripts/generate_mazes.py +python3 main.py --maze data/mazes/small.txt --strategy astar --render +``` + +Доступные стратегии: + +```text +bfs +dfs +astar +dijkstra +``` + +Ручной режим с командами `W/A/S/D`, undo через `Z`: + +```bash +python3 main.py --maze data/mazes/small.txt --manual +``` + +## Эксперименты + +```bash +python3 scripts/run_experiments.py +``` + +Скрипт перегенерирует лабиринты, запускает каждую стратегию 10 раз и сохраняет: + +- `reports/results.csv`; +- SVG-графики в `reports/charts/`. + +## Проверка + +```bash +python3 -m unittest +``` diff --git a/shahovaa/zadanie 2/data/mazes/empty.txt b/shahovaa/zadanie 2/data/mazes/empty.txt new file mode 100644 index 0000000..c83b6cd --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/empty.txt @@ -0,0 +1,50 @@ +################################################## +#S # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# E# +################################################## diff --git a/shahovaa/zadanie 2/data/mazes/large.txt b/shahovaa/zadanie 2/data/mazes/large.txt new file mode 100644 index 0000000..901f0a6 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/large.txt @@ -0,0 +1,100 @@ +#################################################################################################### +#S # # # # # # # # # # # ## +### # # ### # # # ####### # # # ### ######### ### # ##### # ############### ### # # # ### ##### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ########### # # # # ##### ### # ### ##### ################### # # # # ##### ####### # ### ##### ## +# # # # # # # # # # # # # # # # # # # # # # # ## +# ### ##### ### ### ##### ##### ### ### ##### ####### # # ### ##### ##### # ### # # # # # # # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +##### # ######### ### # ### # ### ### # # ####### # ### # # # # # ### # ##### # # # ### ##### # #### +# # # # # # # # # # # # # # # # # # # # # # # ## +# ##### ##### ########### # ####### ##### ######### # ### # # ##### ####### ### # # # ### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# # # ##### # # # # ### # # # # ### # ##### ### ### ########### # ##### ### # ### # ### # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # # ####### # # ####### ####### ### ##### # ############### ### # # # # ####### # # ###### +# # # # # # # # # # # # # # # # # # # # # # # # ## +##### # # # ##### # # ####### ########### # ### # ### # # # ####### ### # ####### # ### # # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # ## +# # ### ##### # ############# # # # ### ### # ########### ####### # ### # ######### # ### # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### ####### ### # ### # # ##### ### ##### ####### # # ### ### # ######### # # ### ### # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ####### ####### ### # ### ##### # # # # # ##### ##### ### ### # # # ### # ### ### ##### # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ######### # # # ### ##### ### # ### ### # ##### # ####### ### ##### # # # ##### # ########### # ## +# # # # # # # E# # # # # # # # # # # # # # # # ## +### ##### # # ####### ### # ### ### ##### ### # # # ### # # ####### # ### ######### # ##### ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ##### ####### # # ##### ######### # # ### ##### # ##### ### # # ####### # # ### # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +####### # # ### ### ### # # # # ############# ##### # # # ##### ### ### ### # # ##### # # # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # # ### ### ### ########### # # # ##### # ##### ### ### # ##### ##### # # # # # # # ###### +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ##### # ### ### ########### ############# # # # # # # # ### # ### ##### # # # ##### # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ##### # # # ### # # ### # # # # # ####### ### # ##### # ### # ####### # # # # # ### # # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ##### # # # # # ##### ########### # ########### # # ##### ### ##### ### # # # ####### ######### ## +# # # # # # # # # # # # # # # # # # # # ## +### ##### # # ##### ##### ########### ##### ### # # ##### ### # # ######### ####### # # ######### ## +# # # # # # # # # # # # # # # # # # # # # ## +# ##### ### ######### # ### ##### ### # # # ####### ####### # ####### ### # # ####### # # ##### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### ### # ##### # # ### # # ### ### # # # ####### ### # # ##### # ### ### # # ### ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # ####### # ### # ### # ############### # ##### # # ##### # ### # # ######### ##### ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # ### # # # # ### ##### # ####### ### # ######### # # # # # ##### # # ######### # # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # ## +# ### ####### ########### ######### ##### # ### # # ### ####### # ##### ####### # ### ### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ######### # # ##### # ### ##### ### # ##### # # # # ### ### ### # ### # ### ### # # # ### # # #### +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # # ### # ##### # ##### ######### # # # ### ### # # ### ### # ##### ########### # ### ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # # # ### ##### # # # ### ### # # # # ### ############# ### # # ### ########### ##### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # ## +# ### ############### # ### # # ##### ##### ### ######### ############# # ####### ##### # # # ###### +# # # # # # # # # # # # # # # # # # # ## +### ############# # # # # ####### ##### # ####### ######### # ### ######### # # ##### ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### # # # # # ######### ####### ### ### # # ### # ### ### # ### ### # ### ##### # # ### ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### # # ##### # ############### ### ##### # ### ####### # # ### # # ### # # ##### # # # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # # # # # # ##### # ####### # ##### # ### ##### ### # # ##### # # ##### # ##### # # # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # ################### ########### # # ### # ### ##### # # # # ########### ##### ### # # # # ## +# # # # # # # # # # # # # # # # # # # # ## +# ### ##### ### ######### ########### ### # ########### ### # # ### # # # # # ######### # ### ###### +# # # # # # # # # # # # # # # # # # # # # # # ## +### # # ##### ### ### # # # # ####### # ##### # ##### # # ### ### ######### ####### # # ##### # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # # ### # ### # # ##### ### ##### # ##### # ### # # # # ### # # ### # ############# ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # ### # # # # # ####### # # # ### ####### # ######### # ### # ### # # ### ##### # # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # ######### # ####### # ######### ### # # # ### ### # ##### ### # ##### # ##### # ### ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### # # ##### # ##### # # # ####### ### # ### ### ##### # # # ### # ### # ### # # ######### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### ### ####### ### # # ### ####### # ### ### ##### # # # ### ### # ### # ######### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### ##### # ### # ### ####### ################# ### # # # ##### ### # # # # ### ####### ###### +# # # # # # # # # # # # # # # # # # # # # ## +##### ##### ##### ##### # ####### # ##### # ### ##### # ### # # ### ########### # # # # # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # ####### # # # ####### # # # ##### ### ##### ##### ### ########### ### # # # ##### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # # # # ##### ##### # ### # # # ########### ######### ### ########### ### ##### # ### # ###### +# # # # # # # # # # # # # # # # # # # # # ## +### ##### ##### ##### # # # ##### ##### # ################# # ### # ### ######### # # ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # ##### ### ### # # # # # ### ############# ##### # ##### ##### # # ### # ### # # # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # ##### # ### # ### # ##### ####### # ### # ####### ### ##### # # # # # ##### # # # ### ## +# # # # # # # # # # # # # ## +#################################################################################################### +#################################################################################################### diff --git a/shahovaa/zadanie 2/data/mazes/medium.txt b/shahovaa/zadanie 2/data/mazes/medium.txt new file mode 100644 index 0000000..33b95a7 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/medium.txt @@ -0,0 +1,50 @@ +################################################## +#S # # E# # # ## +### # ##### # # ########### ### # # # ### ##### ## +# # # # # # # # # # # # ## +# ### ### # ### ############### ### # # ### # # ## +# # # # # # # # # # # ## +### ### ##### # ##### ### # # # # ##### # ##### ## +# # # # # # # # # # # # # # # # ## +# ### ### # # ### # ### ### # ### # # ### # ###### +# # # # # # # # # # # # # ## +# # ##### ##### # # # # ####### ### ########### ## +# # # # # # # # # # # # ## +####### # # ##### # # # # ### ### # # ### ######## +# # # # # # # # # # # # ## +# ### ##### ##### # # ##### # # ############### ## +# # # # # # # # # # # # # ## +# # ### # ### # ### ### # ### ### # # ##### # #### +# # # # # # # # # # # # # # ## +# ### ########### ### # ### ### # # ### # # ### ## +# # # # # # # # # # # # ## +# # ### # ##### # # ######### # # # # # ####### ## +# # # # # # # # # # # # # ## +# ######### # # # ### ##### # # ##### ### ##### ## +# # # # # # # # # # # # ## +# ##### # # # ### ##### # ######### ### ##### # ## +# # # # # # # # # # # # # ## +# # ##### # ### # # # ########### ### # # ### # ## +# # # # # # # # # # # # # # # ## +# ### # ### # ##### # # ### # ######### # # ###### +# # # # # # # # # # # ## +### # # # ########### ### ############### ##### ## +# # # # # # # # # # # ## +# ##### ##### # ### ### # # ### # ### # # # # # ## +# # # # # # # # # # # # # # ## +# ####### # # # # ####### ### ##### ### ##### # ## +# # # # # # # # # # # # # # ## +# ##### ### # ### # ### # # # # # ##### # # ### ## +# # # # # # # # # # # # ## +######### # ######### ####### ########### # # # ## +# # # # # # # # ## +# ####### ##### # # ####### ######### # ####### ## +# # # # # # # # # # # # ## +### # # ### # # # ##### # # # ##### ### # # ###### +# # # # # # # # # # # # # ## +# ############# # # # ##### ######### ### # # # ## +# # # # # # # # # # # # # # # ## +# ##### # # # ##### # # # ### # # # ### # # # # ## +# # # # # # # # # ## +################################################## +################################################## diff --git a/shahovaa/zadanie 2/data/mazes/no_exit.txt b/shahovaa/zadanie 2/data/mazes/no_exit.txt new file mode 100644 index 0000000..28c6043 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/no_exit.txt @@ -0,0 +1,30 @@ +############################## +#S############################ +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################E# +############################## diff --git a/shahovaa/zadanie 2/data/mazes/small.txt b/shahovaa/zadanie 2/data/mazes/small.txt new file mode 100644 index 0000000..f14cd33 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/small.txt @@ -0,0 +1,10 @@ +########## +#S #E# +# #### # # +# # # # +# # #### # +# # # +# ###### # +# # +######## # +########## diff --git a/shahovaa/zadanie 2/main.py b/shahovaa/zadanie 2/main.py new file mode 100644 index 0000000..62f5f6b --- /dev/null +++ b/shahovaa/zadanie 2/main.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import argparse + +from maze_solver import ( + AStarStrategy, + BFSStrategy, + ConsoleView, + DFSStrategy, + DijkstraStrategy, + Direction, + MazeSolver, + MoveCommand, + Player, + TextFileMazeBuilder, +) + + +STRATEGIES = { + "bfs": BFSStrategy, + "dfs": DFSStrategy, + "astar": AStarStrategy, + "dijkstra": DijkstraStrategy, +} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Find a path through a text maze.") + parser.add_argument("--maze", default="data/mazes/small.txt") + parser.add_argument( + "--strategy", + choices=sorted(STRATEGIES), + default="astar", + help="Path-finding algorithm.", + ) + parser.add_argument("--render", action="store_true", help="Print maze with path.") + parser.add_argument( + "--manual", + action="store_true", + help="Manual W/A/S/D mode with Z undo and Q quit.", + ) + args = parser.parse_args() + + maze = TextFileMazeBuilder().build_from_file(args.maze) + strategy = STRATEGIES[args.strategy]() + solver = MazeSolver(maze, strategy) + view = ConsoleView() + solver.add_observer(view) + + stats = solver.solve() + print( + f"Summary: strategy={stats.strategy_name}, time={stats.time_ms:.3f} ms, " + f"visited={stats.visited_cells}, path_length={stats.path_length}" + ) + + if args.render: + print(view.render(maze, path=stats.path)) + + if args.manual: + run_manual_mode(maze, view) + + +def run_manual_mode(maze, view: ConsoleView) -> None: + player = Player.at_start(maze) + history: list[MoveCommand] = [] + + while True: + print(view.render(maze, player_position=player.current_cell)) + if player.current_cell == maze.exit: + print("Exit reached.") + return + + key = input("Move W/A/S/D, undo Z, quit Q: ").strip().lower() + if key == "q": + return + if key == "z": + if history: + history.pop().undo() + continue + + try: + command = MoveCommand(player, Direction.from_key(key)) + except ValueError as exc: + print(exc) + continue + + if command.execute(): + history.append(command) + else: + print("Move blocked.") + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/maze_solver/__init__.py b/shahovaa/zadanie 2/maze_solver/__init__.py new file mode 100644 index 0000000..ce72ea5 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/__init__.py @@ -0,0 +1,34 @@ +from .builders import MazeBuilder, TextFileMazeBuilder +from .commands import Direction, MoveCommand, Player +from .models import Cell, Maze +from .observers import ConsoleView, Event, Observer +from .solver import MazeSolver, SearchStats +from .strategies import ( + AStarStrategy, + BFSStrategy, + DFSStrategy, + DijkstraStrategy, + PathFindingStrategy, + PathResult, +) + +__all__ = [ + "AStarStrategy", + "BFSStrategy", + "Cell", + "ConsoleView", + "DFSStrategy", + "DijkstraStrategy", + "Direction", + "Event", + "Maze", + "MazeBuilder", + "MazeSolver", + "MoveCommand", + "Observer", + "PathFindingStrategy", + "PathResult", + "Player", + "SearchStats", + "TextFileMazeBuilder", +] diff --git a/shahovaa/zadanie 2/maze_solver/builders.py b/shahovaa/zadanie 2/maze_solver/builders.py new file mode 100644 index 0000000..1316ae3 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/builders.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path + +from .models import Cell, Maze + + +class MazeBuilder(ABC): + @abstractmethod + def build_from_file(self, filename: str | Path) -> Maze: + raise NotImplementedError + + def buildFromFile(self, filename: str | Path) -> Maze: + return self.build_from_file(filename) + + +class TextFileMazeBuilder(MazeBuilder): + WALL = "#" + START = "S" + EXIT = "E" + PASSAGES = {" ", "."} + WEIGHTS = {"1": 1, "2": 2, "3": 3, "~": 3} + + def build_from_file(self, filename: str | Path) -> Maze: + path = Path(filename) + rows = path.read_text(encoding="utf-8").splitlines() + + if not rows: + raise ValueError(f"Maze file is empty: {path}") + + width = len(rows[0]) + if width == 0: + raise ValueError("Maze width must be greater than zero") + if any(len(row) != width for row in rows): + raise ValueError("All maze rows must have the same width") + + cells: list[list[Cell]] = [] + start: Cell | None = None + exit: Cell | None = None + + for y, row in enumerate(rows): + cell_row: list[Cell] = [] + for x, char in enumerate(row): + cell = self._create_cell(x, y, char) + if cell.is_start: + if start is not None: + raise ValueError("Maze must contain exactly one start cell") + start = cell + if cell.is_exit: + if exit is not None: + raise ValueError("Maze must contain exactly one exit cell") + exit = cell + cell_row.append(cell) + cells.append(cell_row) + + if start is None: + raise ValueError("Maze must contain a start cell marked with 'S'") + if exit is None: + raise ValueError("Maze must contain an exit cell marked with 'E'") + + return Maze(cells, start, exit) + + def _create_cell(self, x: int, y: int, char: str) -> Cell: + if char == self.WALL: + return Cell(x=x, y=y, is_wall=True, symbol=char) + if char == self.START: + return Cell(x=x, y=y, is_start=True, symbol=char) + if char == self.EXIT: + return Cell(x=x, y=y, is_exit=True, symbol=char) + if char in self.PASSAGES: + return Cell(x=x, y=y, symbol=" ") + if char in self.WEIGHTS: + return Cell(x=x, y=y, weight=self.WEIGHTS[char], symbol=char) + raise ValueError(f"Unsupported maze symbol {char!r} at ({x}, {y})") diff --git a/shahovaa/zadanie 2/maze_solver/commands.py b/shahovaa/zadanie 2/maze_solver/commands.py new file mode 100644 index 0000000..e93341c --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/commands.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + +from .models import Cell, Maze + + +class Direction(Enum): + UP = (0, -1) + RIGHT = (1, 0) + DOWN = (0, 1) + LEFT = (-1, 0) + + @classmethod + def from_key(cls, key: str) -> "Direction": + mapping = { + "w": cls.UP, + "d": cls.RIGHT, + "s": cls.DOWN, + "a": cls.LEFT, + } + try: + return mapping[key.lower()] + except KeyError as exc: + raise ValueError("Use W/A/S/D for movement") from exc + + +@dataclass +class Player: + maze: Maze + current_cell: Cell + + @classmethod + def at_start(cls, maze: Maze) -> "Player": + return cls(maze=maze, current_cell=maze.start) + + def move_to(self, cell: Cell) -> None: + if not cell.is_passable(): + raise ValueError("Player cannot move into a wall") + self.current_cell = cell + + +class Command(ABC): + @abstractmethod + def execute(self) -> bool: + raise NotImplementedError + + @abstractmethod + def undo(self) -> bool: + raise NotImplementedError + + +class MoveCommand(Command): + def __init__(self, player: Player, direction: Direction) -> None: + self.player = player + self.direction = direction + self.previous_cell: Cell | None = None + self.executed = False + + def execute(self) -> bool: + dx, dy = self.direction.value + current = self.player.current_cell + target = self.player.maze.get_cell(current.x + dx, current.y + dy) + if target is None or not target.is_passable(): + return False + + self.previous_cell = current + self.player.move_to(target) + self.executed = True + return True + + def undo(self) -> bool: + if not self.executed or self.previous_cell is None: + return False + self.player.move_to(self.previous_cell) + self.executed = False + return True diff --git a/shahovaa/zadanie 2/maze_solver/models.py b/shahovaa/zadanie 2/maze_solver/models.py new file mode 100644 index 0000000..e6523a1 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/models.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Cell: + x: int + y: int + is_wall: bool = False + is_start: bool = False + is_exit: bool = False + weight: int = 1 + symbol: str = " " + + def is_passable(self) -> bool: + return not self.is_wall + + def isPassable(self) -> bool: + return self.is_passable() + + +class Maze: + def __init__(self, cells: list[list[Cell]], start: Cell, exit: Cell) -> None: + if not cells or not cells[0]: + raise ValueError("Maze must contain at least one cell") + + width = len(cells[0]) + if any(len(row) != width for row in cells): + raise ValueError("Maze rows must have equal width") + + self.cells = cells + self.height = len(cells) + self.width = width + self.start = start + self.exit = exit + + def get_cell(self, x: int, y: int) -> Cell | None: + if 0 <= x < self.width and 0 <= y < self.height: + return self.cells[y][x] + return None + + def getCell(self, x: int, y: int) -> Cell | None: + return self.get_cell(x, y) + + def get_neighbors(self, cell: Cell) -> list[Cell]: + neighbors: list[Cell] = [] + for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)): + neighbor = self.get_cell(cell.x + dx, cell.y + dy) + if neighbor is not None and neighbor.is_passable(): + neighbors.append(neighbor) + return neighbors + + def getNeighbors(self, cell: Cell) -> list[Cell]: + return self.get_neighbors(cell) + + def to_text(self, path: list[Cell] | None = None, player: Cell | None = None) -> str: + path_cells = {(cell.x, cell.y) for cell in path or []} + lines: list[str] = [] + + for row in self.cells: + chars: list[str] = [] + for cell in row: + position = (cell.x, cell.y) + if player is not None and position == (player.x, player.y): + chars.append("@") + elif cell.is_start: + chars.append("S") + elif cell.is_exit: + chars.append("E") + elif cell.is_wall: + chars.append("#") + elif position in path_cells: + chars.append(".") + elif cell.weight > 1: + chars.append(str(cell.weight)) + else: + chars.append(" ") + lines.append("".join(chars)) + + return "\n".join(lines) diff --git a/shahovaa/zadanie 2/maze_solver/observers.py b/shahovaa/zadanie 2/maze_solver/observers.py new file mode 100644 index 0000000..3fecea2 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/observers.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .models import Cell, Maze + + +@dataclass(frozen=True) +class Event: + event_type: str + payload: dict[str, Any] = field(default_factory=dict) + + +class Observer(ABC): + @abstractmethod + def update(self, event: Event) -> None: + raise NotImplementedError + + +class ConsoleView(Observer): + def update(self, event: Event) -> None: + if event.event_type == "search_started": + print(f"Search started: {event.payload['strategy']}") + elif event.event_type in {"path_found", "path_not_found"}: + stats = event.payload["stats"] + print( + f"{event.event_type}: strategy={stats.strategy_name}, " + f"time={stats.time_ms:.3f} ms, visited={stats.visited_cells}, " + f"path_length={stats.path_length}" + ) + + def render( + self, + maze: Maze, + player_position: Cell | None = None, + path: list[Cell] | None = None, + ) -> str: + return maze.to_text(path=path, player=player_position) diff --git a/shahovaa/zadanie 2/maze_solver/solver.py b/shahovaa/zadanie 2/maze_solver/solver.py new file mode 100644 index 0000000..e95d6e9 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/solver.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass + +from .models import Cell, Maze +from .observers import Event, Observer +from .strategies import PathFindingStrategy + + +@dataclass(frozen=True) +class SearchStats: + strategy_name: str + time_ms: float + visited_cells: int + path_length: int + path: list[Cell] + + +class MazeSolver: + def __init__(self, maze: Maze, strategy: PathFindingStrategy) -> None: + self.maze = maze + self.strategy = strategy + self._observers: list[Observer] = [] + + def set_strategy(self, strategy: PathFindingStrategy) -> None: + self.strategy = strategy + + def setStrategy(self, strategy: PathFindingStrategy) -> None: + self.set_strategy(strategy) + + def add_observer(self, observer: Observer) -> None: + self._observers.append(observer) + + def remove_observer(self, observer: Observer) -> None: + self._observers.remove(observer) + + def solve(self) -> SearchStats: + self._notify(Event("search_started", {"strategy": self.strategy.name})) + started_at = time.perf_counter() + result = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit) + elapsed_ms = (time.perf_counter() - started_at) * 1000 + + stats = SearchStats( + strategy_name=self.strategy.name, + time_ms=elapsed_ms, + visited_cells=result.visited_count, + path_length=len(result.path), + path=result.path, + ) + event_name = "path_found" if result.path else "path_not_found" + self._notify(Event(event_name, {"stats": stats})) + return stats + + def _notify(self, event: Event) -> None: + for observer in self._observers: + observer.update(event) diff --git a/shahovaa/zadanie 2/maze_solver/strategies.py b/shahovaa/zadanie 2/maze_solver/strategies.py new file mode 100644 index 0000000..739b7ec --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/strategies.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import heapq +from abc import ABC, abstractmethod +from collections import deque +from dataclasses import dataclass +from itertools import count + +from .models import Cell, Maze + + +@dataclass(frozen=True) +class PathResult: + path: list[Cell] + visited_count: int + + +class PathFindingStrategy(ABC): + name = "abstract" + + @abstractmethod + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + raise NotImplementedError + + def findPath(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + return self.find_path(maze, start, exit) + + +class BFSStrategy(PathFindingStrategy): + name = "BFS" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + queue: deque[Cell] = deque([start]) + parents: dict[Cell, Cell | None] = {start: None} + visited = {start} + + while queue: + current = queue.popleft() + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + if neighbor not in visited: + visited.add(neighbor) + parents[neighbor] = current + queue.append(neighbor) + + return PathResult([], len(visited)) + + +class DFSStrategy(PathFindingStrategy): + name = "DFS" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + stack = [start] + parents: dict[Cell, Cell | None] = {start: None} + visited = {start} + + while stack: + current = stack.pop() + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in reversed(maze.get_neighbors(current)): + if neighbor not in visited: + visited.add(neighbor) + parents[neighbor] = current + stack.append(neighbor) + + return PathResult([], len(visited)) + + +class DijkstraStrategy(PathFindingStrategy): + name = "Dijkstra" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + tie_breaker = count() + heap: list[tuple[int, int, Cell]] = [(0, next(tie_breaker), start)] + distances: dict[Cell, int] = {start: 0} + parents: dict[Cell, Cell | None] = {start: None} + visited: set[Cell] = set() + + while heap: + current_distance, _, current = heapq.heappop(heap) + if current in visited: + continue + visited.add(current) + + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + new_distance = current_distance + neighbor.weight + if new_distance < distances.get(neighbor, 10**12): + distances[neighbor] = new_distance + parents[neighbor] = current + heapq.heappush(heap, (new_distance, next(tie_breaker), neighbor)) + + return PathResult([], len(visited)) + + +class AStarStrategy(PathFindingStrategy): + name = "A*" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + tie_breaker = count() + start_heuristic = _manhattan(start, exit) + heap: list[tuple[int, int, int, Cell]] = [ + (start_heuristic, start_heuristic, next(tie_breaker), start) + ] + g_score: dict[Cell, int] = {start: 0} + parents: dict[Cell, Cell | None] = {start: None} + visited: set[Cell] = set() + + while heap: + _, _, _, current = heapq.heappop(heap) + if current in visited: + continue + visited.add(current) + + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + tentative_score = g_score[current] + neighbor.weight + if tentative_score < g_score.get(neighbor, 10**12): + g_score[neighbor] = tentative_score + parents[neighbor] = current + heuristic = _manhattan(neighbor, exit) + priority = tentative_score + heuristic + heapq.heappush( + heap, + (priority, heuristic, next(tie_breaker), neighbor), + ) + + return PathResult([], len(visited)) + + +def _reconstruct_path(parents: dict[Cell, Cell | None], end: Cell) -> list[Cell]: + path: list[Cell] = [] + current: Cell | None = end + while current is not None: + path.append(current) + current = parents[current] + path.reverse() + return path + + +def _manhattan(first: Cell, second: Cell) -> int: + return abs(first.x - second.x) + abs(first.y - second.y) diff --git a/shahovaa/zadanie 2/reports/charts/empty_time.svg b/shahovaa/zadanie 2/reports/charts/empty_time.svg new file mode 100644 index 0000000..8a21a08 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/empty_time.svg @@ -0,0 +1,28 @@ + + +Пустой 50x50: среднее время, мс + + + +0.00 + +1.01 + +2.02 + +3.03 + +4.04 + +2.89 +BFS + +0.14 +DFS + +0.24 +A* + +4.04 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/empty_visited.svg b/shahovaa/zadanie 2/reports/charts/empty_visited.svg new file mode 100644 index 0000000..0133eaf --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/empty_visited.svg @@ -0,0 +1,28 @@ + + +Пустой 50x50: посещенные клетки + + + +0.00 + +576.00 + +1152.00 + +1728.00 + +2304.00 + +2304.00 +BFS + +187.00 +DFS + +95.00 +A* + +2304.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/large_time.svg b/shahovaa/zadanie 2/reports/charts/large_time.svg new file mode 100644 index 0000000..775b0b0 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/large_time.svg @@ -0,0 +1,28 @@ + + +Большой 100x100: среднее время, мс + + + +0.00 + +2.16 + +4.33 + +6.49 + +8.66 + +5.30 +BFS + +2.50 +DFS + +8.66 +A* + +7.15 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/large_visited.svg b/shahovaa/zadanie 2/reports/charts/large_visited.svg new file mode 100644 index 0000000..08114dd --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/large_visited.svg @@ -0,0 +1,28 @@ + + +Большой 100x100: посещенные клетки + + + +0.00 + +1200.25 + +2400.50 + +3600.75 + +4801.00 + +4801.00 +BFS + +2155.00 +DFS + +4791.00 +A* + +4800.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/medium_time.svg b/shahovaa/zadanie 2/reports/charts/medium_time.svg new file mode 100644 index 0000000..2e4caff --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/medium_time.svg @@ -0,0 +1,28 @@ + + +Средний 50x50: среднее время, мс + + + +0.00 + +0.50 + +1.00 + +1.51 + +2.01 + +1.28 +BFS + +0.91 +DFS + +2.01 +A* + +1.70 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/medium_visited.svg b/shahovaa/zadanie 2/reports/charts/medium_visited.svg new file mode 100644 index 0000000..6dd8bd2 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/medium_visited.svg @@ -0,0 +1,28 @@ + + +Средний 50x50: посещенные клетки + + + +0.00 + +287.75 + +575.50 + +863.25 + +1151.00 + +1151.00 +BFS + +784.00 +DFS + +1133.00 +A* + +1151.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_time.svg b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg new file mode 100644 index 0000000..8cf0299 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg @@ -0,0 +1,28 @@ + + +Без пути 30x30: среднее время, мс + + + +0.00 + +0.00 + +0.00 + +0.00 + +0.00 + +0.00 +BFS + +0.00 +DFS + +0.00 +A* + +0.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg new file mode 100644 index 0000000..2e3b4a6 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg @@ -0,0 +1,28 @@ + + +Без пути 30x30: посещенные клетки + + + +0.00 + +0.25 + +0.50 + +0.75 + +1.00 + +1.00 +BFS + +1.00 +DFS + +1.00 +A* + +1.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/small_time.svg b/shahovaa/zadanie 2/reports/charts/small_time.svg new file mode 100644 index 0000000..57896e1 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/small_time.svg @@ -0,0 +1,28 @@ + + +Маленький 10x10: среднее время, мс + + + +0.00 + +0.02 + +0.03 + +0.05 + +0.07 + +0.04 +BFS + +0.03 +DFS + +0.07 +A* + +0.06 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/small_visited.svg b/shahovaa/zadanie 2/reports/charts/small_visited.svg new file mode 100644 index 0000000..cadf1bc --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/small_visited.svg @@ -0,0 +1,28 @@ + + +Маленький 10x10: посещенные клетки + + + +0.00 + +9.25 + +18.50 + +27.75 + +37.00 + +37.00 +BFS + +24.00 +DFS + +31.00 +A* + +37.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/report.md b/shahovaa/zadanie 2/reports/report.md new file mode 100644 index 0000000..a2f2fe9 --- /dev/null +++ b/shahovaa/zadanie 2/reports/report.md @@ -0,0 +1,208 @@ +# Отчет по заданию: поиск выхода из лабиринта + +## 1. Описание задачи и выбранных паттернов + +Цель работы - реализовать расширяемую программу для загрузки лабиринта из файла, +поиска пути от старта `S` до выхода `E`, визуализации результата и сравнения +алгоритмов на лабиринтах разной сложности. + +В проекте реализованы четыре паттерна GoF: + +| Паттерн | Где реализован | Зачем нужен | +|---|---|---| +| Builder | `MazeBuilder`, `TextFileMazeBuilder` | Изолирует парсинг и валидацию файла от остального приложения. | +| Strategy | `PathFindingStrategy`, `BFSStrategy`, `DFSStrategy`, `AStarStrategy`, `DijkstraStrategy` | Позволяет менять алгоритм поиска без изменения `MazeSolver`. | +| Observer | `Observer`, `ConsoleView`, события `search_started`, `path_found`, `path_not_found` | Отделяет вычисления от отображения в консоли. | +| Command | `Command`, `MoveCommand`, `Player` | Инкапсулирует ход игрока и поддерживает отмену хода. | + +Диаграмма классов: + +```mermaid +classDiagram + class Cell { + +int x + +int y + +bool is_wall + +bool is_start + +bool is_exit + +int weight + +is_passable() bool + } + + class Maze { + +list cells + +int width + +int height + +Cell start + +Cell exit + +get_cell(x, y) Cell + +get_neighbors(cell) list + +to_text(path, player) str + } + + class MazeBuilder { + <> + +build_from_file(filename) Maze + } + + class TextFileMazeBuilder { + +build_from_file(filename) Maze + } + + class PathFindingStrategy { + <> + +find_path(maze, start, exit) PathResult + } + + class BFSStrategy + class DFSStrategy + class AStarStrategy + class DijkstraStrategy + + class SearchStats { + +str strategy_name + +float time_ms + +int visited_cells + +int path_length + +list path + } + + class MazeSolver { + +set_strategy(strategy) + +add_observer(observer) + +solve() SearchStats + } + + class Observer { + <> + +update(event) + } + + class ConsoleView { + +update(event) + +render(maze, player_position, path) str + } + + class Command { + <> + +execute() bool + +undo() bool + } + + class MoveCommand + class Player + + MazeBuilder <|.. TextFileMazeBuilder + MazeBuilder --> Maze : creates + PathFindingStrategy <|.. BFSStrategy + PathFindingStrategy <|.. DFSStrategy + PathFindingStrategy <|.. AStarStrategy + PathFindingStrategy <|.. DijkstraStrategy + MazeSolver --> PathFindingStrategy : uses + MazeSolver --> Maze : uses + MazeSolver --> Observer : notifies + Observer <|.. ConsoleView + Command <|.. MoveCommand + MoveCommand --> Player + Player --> Cell +``` + +## 2. Ключевые классы + +Основные файлы проекта: + +| Файл | Назначение | +|---|---| +| `maze_solver/models.py` | Классы `Cell` и `Maze`, поиск соседей, текстовая отрисовка. | +| `maze_solver/builders.py` | Интерфейс Builder и загрузка лабиринта из `.txt`. | +| `maze_solver/strategies.py` | BFS, DFS, A* и Дейкстра. | +| `maze_solver/solver.py` | Оркестратор поиска и сбор статистики. | +| `maze_solver/observers.py` | Observer и консольное представление. | +| `maze_solver/commands.py` | Command, игрок и undo перемещения. | +| `main.py` | CLI для запуска поиска и ручного режима. | +| `scripts/run_experiments.py` | Замеры и построение SVG-графиков. | + +Пример запуска: + +```bash +python3 main.py --maze data/mazes/small.txt --strategy astar --render +``` + +## 3. Результаты экспериментов + +Для каждого лабиринта и каждой стратегии выполнено 10 запусков. В таблице указаны +средние значения. Длина пути считается в клетках, включая старт и выход. + +| Лабиринт | Стратегия | Время, мс | Посещено клеток | Длина пути | Путь найден | +|---|---:|---:|---:|---:|---| +| Маленький 10x10 | BFS | 0.0423 | 37.0 | 20.0 | да | +| Маленький 10x10 | DFS | 0.0273 | 24.0 | 22.0 | да | +| Маленький 10x10 | A* | 0.0677 | 31.0 | 20.0 | да | +| Маленький 10x10 | Dijkstra | 0.0551 | 37.0 | 20.0 | да | +| Средний 50x50 | BFS | 1.2769 | 1151.0 | 709.0 | да | +| Средний 50x50 | DFS | 0.9106 | 784.0 | 709.0 | да | +| Средний 50x50 | A* | 2.0089 | 1133.0 | 709.0 | да | +| Средний 50x50 | Dijkstra | 1.7041 | 1151.0 | 709.0 | да | +| Большой 100x100 | BFS | 5.2983 | 4801.0 | 1685.0 | да | +| Большой 100x100 | DFS | 2.5044 | 2155.0 | 1685.0 | да | +| Большой 100x100 | A* | 8.6574 | 4791.0 | 1685.0 | да | +| Большой 100x100 | Dijkstra | 7.1532 | 4800.0 | 1685.0 | да | +| Пустой 50x50 | BFS | 2.8927 | 2304.0 | 95.0 | да | +| Пустой 50x50 | DFS | 0.1404 | 187.0 | 95.0 | да | +| Пустой 50x50 | A* | 0.2374 | 95.0 | 95.0 | да | +| Пустой 50x50 | Dijkstra | 4.0408 | 2304.0 | 95.0 | да | +| Без пути 30x30 | BFS | 0.0015 | 1.0 | 0.0 | нет | +| Без пути 30x30 | DFS | 0.0013 | 1.0 | 0.0 | нет | +| Без пути 30x30 | A* | 0.0016 | 1.0 | 0.0 | нет | +| Без пути 30x30 | Dijkstra | 0.0018 | 1.0 | 0.0 | нет | + +CSV с результатами сохранен в `reports/results.csv`. + +Графики: + +| Лабиринт | Время | Посещенные клетки | +|---|---|---| +| Маленький | ![](charts/small_time.svg) | ![](charts/small_visited.svg) | +| Средний | ![](charts/medium_time.svg) | ![](charts/medium_visited.svg) | +| Большой | ![](charts/large_time.svg) | ![](charts/large_visited.svg) | +| Пустой | ![](charts/empty_time.svg) | ![](charts/empty_visited.svg) | +| Без пути | ![](charts/no_exit_time.svg) | ![](charts/no_exit_visited.svg) | + +## 4. Анализ эффективности + +BFS гарантирует кратчайший путь в невзвешенном лабиринте. Это видно на маленьком +лабиринте: BFS, A* и Дейкстра нашли путь длиной 20, а DFS нашел более длинный путь +длиной 22. Недостаток BFS - широкий фронт поиска, из-за чего в пустом лабиринте он +посетил все 2304 доступные клетки. + +DFS не гарантирует кратчайший путь, но часто работает быстро, потому что уходит +глубоко по одному направлению. На маленьком лабиринте это дало путь хуже оптимального. +На сгенерированных идеальных лабиринтах путь между двумя клетками единственный, поэтому +DFS, BFS, A* и Дейкстра получили одинаковую длину пути. + +A* использует манхэттенскую эвристику. На пустом лабиринте он посетил только 95 клеток, +то есть фактически прошел по оптимальному маршруту. В запутанных идеальных лабиринтах +эвристика помогает слабее: прямое направление к выходу часто упирается в стены, поэтому +A* посещает почти столько же клеток, сколько BFS, а из-за приоритетной очереди тратит +больше времени. + +Дейкстра в невзвешенном лабиринте по результату близок к BFS, но работает медленнее +из-за приоритетной очереди. Его преимущество проявляется при взвешенных клетках. +В проекте Builder уже поддерживает символы `2`, `3` и `~` как клетки с повышенной +стоимостью прохода, поэтому Дейкстру и A* можно использовать для дополнительного +сравнения на взвешенных картах. + +Лабиринт "Без пути" проверяет корректную обработку отсутствия решения: стратегии +возвращают пустой путь, а `MazeSolver` фиксирует длину 0. + +## 5. Выводы + +ООП позволило разделить предметную модель, загрузку данных, алгоритмы и интерфейс. +Паттерн Builder делает формат входного файла заменяемым: можно добавить JSON-builder, +не меняя `Maze` и стратегии. Strategy позволяет добавлять новые алгоритмы без правок +в `MazeSolver`. Observer отделяет вычисления от вывода, а Command показывает, как +инкапсулировать пользовательские действия и поддержать undo. + +Без этих паттернов код быстро стал бы монолитным: парсинг файла, поиск, статистика, +печать и ручное управление оказались бы в одном месте. Тогда добавление нового формата, +алгоритма или режима отображения требовало бы менять уже работающую логику. diff --git a/shahovaa/zadanie 2/reports/results.csv b/shahovaa/zadanie 2/reports/results.csv new file mode 100644 index 0000000..2acb085 --- /dev/null +++ b/shahovaa/zadanie 2/reports/results.csv @@ -0,0 +1,21 @@ +лабиринт,стратегия,время_мс,посещено_клеток,длина_пути,путь_найден,запусков +Маленький 10x10,BFS,0.0423,37.0,20.0,да,10 +Маленький 10x10,DFS,0.0273,24.0,22.0,да,10 +Маленький 10x10,A*,0.0677,31.0,20.0,да,10 +Маленький 10x10,Dijkstra,0.0551,37.0,20.0,да,10 +Средний 50x50,BFS,1.2769,1151.0,709.0,да,10 +Средний 50x50,DFS,0.9106,784.0,709.0,да,10 +Средний 50x50,A*,2.0089,1133.0,709.0,да,10 +Средний 50x50,Dijkstra,1.7041,1151.0,709.0,да,10 +Большой 100x100,BFS,5.2983,4801.0,1685.0,да,10 +Большой 100x100,DFS,2.5044,2155.0,1685.0,да,10 +Большой 100x100,A*,8.6574,4791.0,1685.0,да,10 +Большой 100x100,Dijkstra,7.1532,4800.0,1685.0,да,10 +Пустой 50x50,BFS,2.8927,2304.0,95.0,да,10 +Пустой 50x50,DFS,0.1404,187.0,95.0,да,10 +Пустой 50x50,A*,0.2374,95.0,95.0,да,10 +Пустой 50x50,Dijkstra,4.0408,2304.0,95.0,да,10 +Без пути 30x30,BFS,0.0015,1.0,0.0,нет,10 +Без пути 30x30,DFS,0.0013,1.0,0.0,нет,10 +Без пути 30x30,A*,0.0016,1.0,0.0,нет,10 +Без пути 30x30,Dijkstra,0.0018,1.0,0.0,нет,10 diff --git a/shahovaa/zadanie 2/scripts/__init__.py b/shahovaa/zadanie 2/scripts/__init__.py new file mode 100644 index 0000000..05aaea9 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper scripts for maze generation and experiments.""" diff --git a/shahovaa/zadanie 2/scripts/generate_mazes.py b/shahovaa/zadanie 2/scripts/generate_mazes.py new file mode 100644 index 0000000..d403792 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/generate_mazes.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import random +from collections import deque +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MAZE_DIR = ROOT / "data" / "mazes" + + +def main() -> None: + generate_all() + + +def generate_all() -> None: + MAZE_DIR.mkdir(parents=True, exist_ok=True) + _write("small.txt", _small_maze()) + _write("medium.txt", _perfect_maze(50, 50, seed=2026)) + _write("large.txt", _perfect_maze(100, 100, seed=2027)) + _write("empty.txt", _empty_maze(50, 50)) + _write("no_exit.txt", _no_path_maze(30, 30)) + + +def _write(filename: str, rows: list[str]) -> None: + (MAZE_DIR / filename).write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def _small_maze() -> list[str]: + return [ + "##########", + "#S #E#", + "# #### # #", + "# # # #", + "# # #### #", + "# # #", + "# ###### #", + "# #", + "######## #", + "##########", + ] + + +def _empty_maze(width: int, height: int) -> list[str]: + grid = _bordered_grid(width, height, fill=" ") + grid[1][1] = "S" + grid[height - 2][width - 2] = "E" + return _to_rows(grid) + + +def _no_path_maze(width: int, height: int) -> list[str]: + grid = [["#" for _ in range(width)] for _ in range(height)] + grid[1][1] = "S" + grid[height - 2][width - 2] = "E" + return _to_rows(grid) + + +def _perfect_maze(width: int, height: int, seed: int) -> list[str]: + if width < 5 or height < 5: + raise ValueError("Maze must be at least 5x5") + + randomizer = random.Random(seed) + grid = [["#" for _ in range(width)] for _ in range(height)] + start = (1, 1) + stack = [start] + grid[start[1]][start[0]] = " " + + while stack: + x, y = stack[-1] + candidates = [] + for dx, dy in ((0, -2), (2, 0), (0, 2), (-2, 0)): + nx, ny = x + dx, y + dy + if 1 <= nx < width - 1 and 1 <= ny < height - 1 and grid[ny][nx] == "#": + candidates.append((nx, ny, dx, dy)) + + if not candidates: + stack.pop() + continue + + nx, ny, dx, dy = randomizer.choice(candidates) + grid[y + dy // 2][x + dx // 2] = " " + grid[ny][nx] = " " + stack.append((nx, ny)) + + exit_x, exit_y = _farthest_open_cell(grid, start) + grid[start[1]][start[0]] = "S" + grid[exit_y][exit_x] = "E" + return _to_rows(grid) + + +def _farthest_open_cell(grid: list[list[str]], start: tuple[int, int]) -> tuple[int, int]: + queue = deque([start]) + distances = {start: 0} + farthest = start + + while queue: + x, y = queue.popleft() + if distances[(x, y)] > distances[farthest]: + farthest = (x, y) + + for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)): + nx, ny = x + dx, y + dy + if (nx, ny) not in distances and grid[ny][nx] != "#": + distances[(nx, ny)] = distances[(x, y)] + 1 + queue.append((nx, ny)) + + return farthest + + +def _bordered_grid(width: int, height: int, fill: str) -> list[list[str]]: + grid = [[fill for _ in range(width)] for _ in range(height)] + for x in range(width): + grid[0][x] = "#" + grid[height - 1][x] = "#" + for y in range(height): + grid[y][0] = "#" + grid[y][width - 1] = "#" + return grid + + +def _to_rows(grid: list[list[str]]) -> list[str]: + return ["".join(row) for row in grid] + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/scripts/run_experiments.py b/shahovaa/zadanie 2/scripts/run_experiments.py new file mode 100644 index 0000000..46bd475 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/run_experiments.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import csv +import statistics +import sys +from collections import defaultdict +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from maze_solver import ( # noqa: E402 + AStarStrategy, + BFSStrategy, + DFSStrategy, + DijkstraStrategy, + MazeSolver, + TextFileMazeBuilder, +) +from scripts.generate_mazes import generate_all # noqa: E402 + + +MAZES = [ + ("small", "Маленький 10x10", ROOT / "data" / "mazes" / "small.txt"), + ("medium", "Средний 50x50", ROOT / "data" / "mazes" / "medium.txt"), + ("large", "Большой 100x100", ROOT / "data" / "mazes" / "large.txt"), + ("empty", "Пустой 50x50", ROOT / "data" / "mazes" / "empty.txt"), + ("no_exit", "Без пути 30x30", ROOT / "data" / "mazes" / "no_exit.txt"), +] + +STRATEGIES = [BFSStrategy, DFSStrategy, AStarStrategy, DijkstraStrategy] +REPORTS_DIR = ROOT / "reports" +CHARTS_DIR = REPORTS_DIR / "charts" + + +def main(runs: int = 10) -> None: + generate_all() + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + CHARTS_DIR.mkdir(parents=True, exist_ok=True) + + rows = _run_experiments(runs) + _write_csv(rows) + _write_charts(rows) + print(f"Wrote {REPORTS_DIR / 'results.csv'}") + print(f"Wrote SVG charts to {CHARTS_DIR}") + + +def _run_experiments(runs: int) -> list[dict[str, object]]: + builder = TextFileMazeBuilder() + rows: list[dict[str, object]] = [] + + for maze_key, maze_name, maze_path in MAZES: + maze = builder.build_from_file(maze_path) + for strategy_type in STRATEGIES: + measurements = [] + for _ in range(runs): + stats = MazeSolver(maze, strategy_type()).solve() + measurements.append(stats) + + avg_time = statistics.fmean(item.time_ms for item in measurements) + avg_visited = statistics.fmean(item.visited_cells for item in measurements) + avg_path = statistics.fmean(item.path_length for item in measurements) + found = measurements[-1].path_length > 0 + rows.append( + { + "key": maze_key, + "лабиринт": maze_name, + "стратегия": measurements[-1].strategy_name, + "время_мс": f"{avg_time:.4f}", + "посещено_клеток": f"{avg_visited:.1f}", + "длина_пути": f"{avg_path:.1f}", + "путь_найден": "да" if found else "нет", + "запусков": runs, + } + ) + + return rows + + +def _write_csv(rows: list[dict[str, object]]) -> None: + csv_path = REPORTS_DIR / "results.csv" + headers = [ + "лабиринт", + "стратегия", + "время_мс", + "посещено_клеток", + "длина_пути", + "путь_найден", + "запусков", + ] + with csv_path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow({header: row[header] for header in headers}) + + +def _write_charts(rows: list[dict[str, object]]) -> None: + grouped: dict[str, list[dict[str, object]]] = defaultdict(list) + for row in rows: + grouped[str(row["key"])].append(row) + + for maze_key, group in grouped.items(): + title = str(group[0]["лабиринт"]) + _write_bar_chart( + CHARTS_DIR / f"{maze_key}_time.svg", + title=f"{title}: среднее время, мс", + rows=group, + metric="время_мс", + color="#2f6fbb", + ) + _write_bar_chart( + CHARTS_DIR / f"{maze_key}_visited.svg", + title=f"{title}: посещенные клетки", + rows=group, + metric="посещено_клеток", + color="#2f8f5b", + ) + + +def _write_bar_chart( + path: Path, + title: str, + rows: list[dict[str, object]], + metric: str, + color: str, +) -> None: + width = 780 + height = 360 + left = 72 + right = 28 + top = 54 + bottom = 58 + chart_width = width - left - right + chart_height = height - top - bottom + values = [float(row[metric]) for row in rows] + max_value = max(values) if values else 1.0 + max_value = max_value or 1.0 + bar_area = chart_width / len(rows) + bar_width = min(96, bar_area * 0.58) + + parts = [ + f'', + '', + f'{_escape(title)}', + f'', + f'', + ] + + for tick in range(5): + ratio = tick / 4 + y = height - bottom - ratio * chart_height + value = max_value * ratio + parts.append( + f'' + ) + parts.append( + f'{value:.2f}' + ) + + for index, row in enumerate(rows): + value = float(row[metric]) + ratio = value / max_value + bar_height = ratio * chart_height + x = left + index * bar_area + (bar_area - bar_width) / 2 + y = height - bottom - bar_height + label = str(row["стратегия"]) + parts.append( + f'' + ) + parts.append( + f'{value:.2f}' + ) + parts.append( + f'{_escape(label)}' + ) + + parts.append("") + path.write_text("\n".join(parts), encoding="utf-8") + + +def _escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/tests/__init__.py b/shahovaa/zadanie 2/tests/__init__.py new file mode 100644 index 0000000..a76b1eb --- /dev/null +++ b/shahovaa/zadanie 2/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the maze solver project.""" diff --git a/shahovaa/zadanie 2/tests/test_solver.py b/shahovaa/zadanie 2/tests/test_solver.py new file mode 100644 index 0000000..9fd5267 --- /dev/null +++ b/shahovaa/zadanie 2/tests/test_solver.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from maze_solver import ( + AStarStrategy, + BFSStrategy, + Direction, + MazeSolver, + MoveCommand, + Player, + TextFileMazeBuilder, +) + + +SIMPLE_MAZE = """\ +####### +#S E# +# ### # +# # +#######""" + + +class MazeSolverTest(unittest.TestCase): + def build_maze(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "maze.txt" + path.write_text(SIMPLE_MAZE, encoding="utf-8") + return TextFileMazeBuilder().build_from_file(path) + + def test_builder_reads_start_exit_and_neighbors(self) -> None: + maze = self.build_maze() + + self.assertEqual((maze.start.x, maze.start.y), (1, 1)) + self.assertEqual((maze.exit.x, maze.exit.y), (5, 1)) + self.assertTrue(maze.get_cell(2, 1).is_passable()) + self.assertFalse(maze.get_cell(0, 0).is_passable()) + + def test_bfs_and_astar_find_shortest_path(self) -> None: + maze = self.build_maze() + + bfs_stats = MazeSolver(maze, BFSStrategy()).solve() + astar_stats = MazeSolver(maze, AStarStrategy()).solve() + + self.assertEqual(bfs_stats.path_length, 5) + self.assertEqual(astar_stats.path_length, 5) + self.assertEqual(bfs_stats.path[0], maze.start) + self.assertEqual(bfs_stats.path[-1], maze.exit) + + def test_move_command_can_execute_and_undo(self) -> None: + maze = self.build_maze() + player = Player.at_start(maze) + command = MoveCommand(player, Direction.RIGHT) + + self.assertTrue(command.execute()) + self.assertEqual((player.current_cell.x, player.current_cell.y), (2, 1)) + self.assertTrue(command.undo()) + self.assertEqual(player.current_cell, maze.start) + + +if __name__ == "__main__": + unittest.main()