From ae812457fb41a6239f62aadd3c3b802ed27dab85 Mon Sep 17 00:00:00 2001 From: SobolevNS Date: Fri, 22 May 2026 13:42:42 +0300 Subject: [PATCH] add maze_solver --- .../data/task2_maze/maze_solver/__init__.py | 18 ++ .../data/task2_maze/maze_solver/builder.py | 92 +++++++++ .../data/task2_maze/maze_solver/command.py | 87 +++++++++ .../docs/data/task2_maze/maze_solver/model.py | 92 +++++++++ .../data/task2_maze/maze_solver/solver.py | 102 ++++++++++ .../data/task2_maze/maze_solver/strategies.py | 179 ++++++++++++++++++ 6 files changed, 570 insertions(+) create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/__init__.py create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/builder.py create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/command.py create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/model.py create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/solver.py create mode 100644 SobolevNS/docs/data/task2_maze/maze_solver/strategies.py diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/__init__.py b/SobolevNS/docs/data/task2_maze/maze_solver/__init__.py new file mode 100644 index 0000000..b97570a --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/__init__.py @@ -0,0 +1,18 @@ +"""Пакет maze_solver.""" +from .model import Cell, Maze +from .builder import MazeBuilder, TextFileMazeBuilder +from .strategies import ( + PathFindingStrategy, BFSStrategy, DFSStrategy, + AStarStrategy, DijkstraStrategy, STRATEGIES, +) +from .solver import MazeSolver, Observer, ConsoleView, SearchStats +from .command import Player, Command, MoveCommand, CommandHistory + +__all__ = [ + "Cell", "Maze", + "MazeBuilder", "TextFileMazeBuilder", + "PathFindingStrategy", "BFSStrategy", "DFSStrategy", + "AStarStrategy", "DijkstraStrategy", "STRATEGIES", + "MazeSolver", "Observer", "ConsoleView", "SearchStats", + "Player", "Command", "MoveCommand", "CommandHistory", +] diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/builder.py b/SobolevNS/docs/data/task2_maze/maze_solver/builder.py new file mode 100644 index 0000000..f8f0dc8 --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/builder.py @@ -0,0 +1,92 @@ +""" +maze_solver/builder.py - паттерн Builder для создания лабиринтов. + +Зачем Builder: процесс построения лабиринта сложный (чтение файла, парсинг, +валидация символов, простановка флагов, поиск старта и выхода). Builder +изолирует эти подробности от клиента; для нового формата (JSON, бинарный) +достаточно реализовать ещё один builder с тем же интерфейсом. +""" + +from abc import ABC, abstractmethod +from .model import Cell, Maze + + +class MazeBuilder(ABC): + """Абстрактный билдер лабиринта.""" + + @abstractmethod + def build_from_file(self, filename) -> Maze: + """Возвращает готовый Maze.""" + + +class TextFileMazeBuilder(MazeBuilder): + """Билдер из текстового формата. + + Символы: + '#' - стена + ' ' - проход (вес 1) + 'S' - старт (проходим) + 'E' - выход (проходим) + '.' - асфальт (вес 1) - то же, что пробел + ',' - песок (вес 2) + '~' - болото (вес 3) + + Лишние пробельные символы в начале/конце файла игнорируются, + но внутри строки пробелы значимы (это проходы). + """ + + WEIGHT_MAP = {'.': 1, ',': 2, '~': 3} + + def build_from_file(self, filename) -> Maze: + with open(filename, encoding="utf-8") as f: + raw = f.read().splitlines() + + # отбрасываем пустые строки в конце - частая мелочь + while raw and raw[-1] == "": + raw.pop() + if not raw: + raise ValueError(f"Файл лабиринта {filename!r} пуст.") + + height = len(raw) + width = max(len(line) for line in raw) + + # выравниваем строки по ширине пробелами (если строки разной длины) + lines = [line.ljust(width, '#') for line in raw] + + maze = Maze(width, height) + start_count = 0 + exit_count = 0 + + for y, line in enumerate(lines): + for x, ch in enumerate(line): + cell = self._parse_char(x, y, ch) + maze.grid[y][x] = cell + if cell.is_start: + maze.start = cell + start_count += 1 + if cell.is_exit: + maze.exit_ = cell + exit_count += 1 + + # валидация + if start_count != 1: + raise ValueError( + f"В лабиринте {filename!r} ожидался ровно 1 'S', нашли {start_count}.") + if exit_count != 1: + raise ValueError( + f"В лабиринте {filename!r} ожидался ровно 1 'E', нашли {exit_count}.") + + return maze + + def _parse_char(self, x, y, ch): + if ch == '#': + return Cell(x, y, is_wall=True) + if ch == 'S': + return Cell(x, y, is_start=True, weight=1) + if ch == 'E': + return Cell(x, y, is_exit=True, weight=1) + if ch in self.WEIGHT_MAP: + return Cell(x, y, weight=self.WEIGHT_MAP[ch]) + if ch == ' ': + return Cell(x, y, weight=1) + raise ValueError(f"Неизвестный символ {ch!r} в позиции ({x},{y}).") diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/command.py b/SobolevNS/docs/data/task2_maze/maze_solver/command.py new file mode 100644 index 0000000..e5e99dd --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/command.py @@ -0,0 +1,87 @@ +""" +maze_solver/command.py - паттерн Command. + +Player хранит текущую клетку. MoveCommand двигает игрока в выбранном +направлении и помнит предыдущую позицию для undo. Менеджер CommandHistory +держит стек выполненных команд. +""" + +from abc import ABC, abstractmethod + + +class Player: + """Игрок в лабиринте.""" + + def __init__(self, cell): + self.cell = cell + + @property + def x(self): return self.cell.x + + @property + def y(self): return self.cell.y + + +class Command(ABC): + @abstractmethod + def execute(self): ... + @abstractmethod + def undo(self): ... + + +class MoveCommand(Command): + """Команда перемещения игрока на одну клетку. + + direction: одна из 'W','A','S','D' (вверх, влево, вниз, вправо). + """ + + DELTAS = { + 'W': (0, -1), + 'S': (0, 1), + 'A': (-1, 0), + 'D': (1, 0), + } + + def __init__(self, maze, player, direction): + self.maze = maze + self.player = player + self.direction = direction.upper() + self._prev_cell = None + self._executed = False + + def execute(self): + if self.direction not in self.DELTAS: + return False + dx, dy = self.DELTAS[self.direction] + target = self.maze.get_cell(self.player.x + dx, self.player.y + dy) + if target is None or not target.is_passable(): + return False + self._prev_cell = self.player.cell + self.player.cell = target + self._executed = True + return True + + def undo(self): + if not self._executed: + return False + self.player.cell = self._prev_cell + self._executed = False + return True + + +class CommandHistory: + """Стек выполненных команд (для общего undo).""" + + def __init__(self): + self._stack = [] + + def do(self, cmd): + if cmd.execute(): + self._stack.append(cmd) + return True + return False + + def undo(self): + if not self._stack: + return False + return self._stack.pop().undo() diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/model.py b/SobolevNS/docs/data/task2_maze/maze_solver/model.py new file mode 100644 index 0000000..75de9cf --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/model.py @@ -0,0 +1,92 @@ +""" +maze_solver/model.py - модель лабиринта (этап 1, без паттернов). +""" + +class Cell: + """Клетка лабиринта. + + Атрибуты: + x, y - координаты + is_wall - стена ли + is_start - стартовая клетка + is_exit - клетка выхода + weight - стоимость прохода (по умолчанию 1, для взвешенного режима >1) + """ + __slots__ = ("x", "y", "is_wall", "is_start", "is_exit", "weight") + + def __init__(self, x, y, is_wall=False, is_start=False, is_exit=False, weight=1): + self.x = x + self.y = y + self.is_wall = is_wall + self.is_start = is_start + self.is_exit = is_exit + self.weight = weight + + def is_passable(self): + return not self.is_wall + + def __repr__(self): + return f"Cell({self.x},{self.y},wall={self.is_wall})" + + +class Maze: + """Лабиринт как двумерный массив клеток. + + Атрибуты: + width, height - размеры + grid - список списков клеток [y][x] + start, exit_ - ссылки на клетки старта и выхода (могут быть None при ошибке) + """ + + def __init__(self, width, height): + self.width = width + self.height = height + self.grid = [[Cell(x, y, is_wall=True) for x in range(width)] + for y in range(height)] + self.start = None + self.exit_ = None + + def get_cell(self, x, y): + if 0 <= x < self.width and 0 <= y < self.height: + return self.grid[y][x] + return None + + def get_neighbors(self, cell): + """Соседи (вверх, вниз, влево, вправо), только проходимые и в пределах поля.""" + out = [] + for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)): + nb = self.get_cell(cell.x + dx, cell.y + dy) + if nb is not None and nb.is_passable(): + out.append(nb) + return out + + def render_text(self, path=None, player=None): + """Возвращает текстовое представление лабиринта. + + '#' стена, ' ' проход, 'S' старт, 'E' выход, + '.' клетка пути, '@' игрок. + """ + path_set = set() + if path: + for c in path: + path_set.add((c.x, c.y)) + + lines = [] + for y in range(self.height): + row = [] + for x in range(self.width): + cell = self.grid[y][x] + ch = ' ' + if cell.is_wall: + ch = '#' + elif cell.is_start: + ch = 'S' + elif cell.is_exit: + ch = 'E' + elif (x, y) in path_set: + ch = '.' + if player is not None and player.x == x and player.y == y: + ch = '@' + row.append(ch) + lines.append("".join(row)) + return "\n".join(lines) diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/solver.py b/SobolevNS/docs/data/task2_maze/maze_solver/solver.py new file mode 100644 index 0000000..c3af08d --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/solver.py @@ -0,0 +1,102 @@ +""" +maze_solver/solver.py - оркестратор MazeSolver + паттерн Observer. + +MazeSolver знает лабиринт и текущую стратегию (Strategy). Перед поиском +он уведомляет наблюдателей (Observer) о старте, после поиска - о результате. +""" + +import time +from abc import ABC, abstractmethod + + +# ---------- Observer ---------- + +class Observer(ABC): + """Интерфейс наблюдателя.""" + + @abstractmethod + def update(self, event): + """event - dict с ключом 'type' и сопровождающими данными.""" + + +class ConsoleView(Observer): + """Простой текстовый наблюдатель.""" + + def __init__(self, verbose=True): + self.verbose = verbose + + def update(self, event): + if not self.verbose: + return + t = event["type"] + if t == "maze_loaded": + m = event["maze"] + print(f"[ConsoleView] лабиринт {m.width}x{m.height} загружен") + elif t == "search_start": + print(f"[ConsoleView] старт поиска: {event['strategy']}") + elif t == "search_end": + stats = event["stats"] + print(f"[ConsoleView] поиск окончен: путь={stats['path_length']}, " + f"посещено={stats['visited']}, время={stats['elapsed_ms']:.3f} мс") + elif t == "move": + print(f"[ConsoleView] игрок -> ({event['x']},{event['y']})") + elif t == "path_found": + print("[ConsoleView] путь найден") + elif t == "no_path": + print("[ConsoleView] пути нет") + + +# ---------- MazeSolver ---------- + +class SearchStats(dict): + """Простой dict-подобный контейнер статистики поиска.""" + pass + + +class MazeSolver: + def __init__(self, maze, strategy=None): + self.maze = maze + self.strategy = strategy + self._observers = [] + + def set_strategy(self, strategy): + self.strategy = strategy + + def attach(self, observer): + self._observers.append(observer) + + def detach(self, observer): + self._observers.remove(observer) + + def _notify(self, event): + for obs in self._observers: + obs.update(event) + + def solve(self): + if self.strategy is None: + raise RuntimeError("Стратегия не задана") + if self.maze.start is None or self.maze.exit_ is None: + raise RuntimeError("В лабиринте нет старта или выхода") + + self._notify({"type": "search_start", "strategy": self.strategy.name}) + + t0 = time.perf_counter() + result = self.strategy.find_path(self.maze, + self.maze.start, + self.maze.exit_) + elapsed = (time.perf_counter() - t0) * 1000.0 + + path = result["path"] + stats = SearchStats( + strategy=self.strategy.name, + elapsed_ms=elapsed, + visited=result["visited"], + path_length=len(path), + path=path, + ) + self._notify({"type": "search_end", "stats": stats}) + if path: + self._notify({"type": "path_found"}) + else: + self._notify({"type": "no_path"}) + return stats diff --git a/SobolevNS/docs/data/task2_maze/maze_solver/strategies.py b/SobolevNS/docs/data/task2_maze/maze_solver/strategies.py new file mode 100644 index 0000000..c170bb9 --- /dev/null +++ b/SobolevNS/docs/data/task2_maze/maze_solver/strategies.py @@ -0,0 +1,179 @@ +""" +maze_solver/strategies.py - паттерн Strategy. + +Каждая стратегия реализует один и тот же интерфейс PathFindingStrategy +с методом find_path(maze, start, exit_), возвращающим: + {'path': [Cell, ...], 'visited': int} + +Стратегии не модифицируют сам лабиринт. +""" + +from abc import ABC, abstractmethod +from collections import deque +import heapq + + +# ---------- интерфейс стратегии ---------- + +class PathFindingStrategy(ABC): + name = "Strategy" + + @abstractmethod + def find_path(self, maze, start, exit_): + """Возвращает dict с ключами 'path' (list[Cell]) и 'visited' (int). + Если пути нет - path = [].""" + + +# ---------- общая утилита: восстановление пути ---------- + +def _reconstruct(parents, start, end): + """Восстанавливает путь по словарю предшественников {(x,y): Cell|None}.""" + path = [] + cur = end + while cur is not None: + path.append(cur) + cur = parents.get((cur.x, cur.y)) + path.reverse() + if path and path[0] is start: + return path + return [] + + +# ---------- BFS ---------- + +class BFSStrategy(PathFindingStrategy): + """Поиск в ширину. Гарантирует кратчайший путь по числу шагов + (когда веса всех клеток равны).""" + name = "BFS" + + def find_path(self, maze, start, exit_): + queue = deque([start]) + parents = {(start.x, start.y): None} + visited = 1 + + while queue: + cell = queue.popleft() + if cell is exit_: + return {"path": _reconstruct(parents, start, exit_), + "visited": visited} + for nb in maze.get_neighbors(cell): + key = (nb.x, nb.y) + if key not in parents: + parents[key] = cell + visited += 1 + queue.append(nb) + return {"path": [], "visited": visited} + + +# ---------- DFS ---------- + +class DFSStrategy(PathFindingStrategy): + """Поиск в глубину. Не гарантирует кратчайший путь, но прост и быстр.""" + name = "DFS" + + def find_path(self, maze, start, exit_): + stack = [start] + parents = {(start.x, start.y): None} + visited = 1 + + while stack: + cell = stack.pop() + if cell is exit_: + return {"path": _reconstruct(parents, start, exit_), + "visited": visited} + for nb in maze.get_neighbors(cell): + key = (nb.x, nb.y) + if key not in parents: + parents[key] = cell + visited += 1 + stack.append(nb) + return {"path": [], "visited": visited} + + +# ---------- A* ---------- + +def _manhattan(a, b): + return abs(a.x - b.x) + abs(a.y - b.y) + + +class AStarStrategy(PathFindingStrategy): + """A*-поиск с манхэттенской эвристикой. Учитывает вес клеток (weight).""" + name = "A*" + + def find_path(self, maze, start, exit_): + # f = g + h; в куче храним (f, tie, cell) + g_score = {(start.x, start.y): 0} + parents = {(start.x, start.y): None} + tie = 0 + heap = [(_manhattan(start, exit_), tie, start)] + visited = 0 + closed = set() + + while heap: + f, _, cell = heapq.heappop(heap) + key = (cell.x, cell.y) + if key in closed: + continue + closed.add(key) + visited += 1 + + if cell is exit_: + return {"path": _reconstruct(parents, start, exit_), + "visited": visited} + + for nb in maze.get_neighbors(cell): + nb_key = (nb.x, nb.y) + tentative_g = g_score[key] + nb.weight + if tentative_g < g_score.get(nb_key, float("inf")): + g_score[nb_key] = tentative_g + parents[nb_key] = cell + tie += 1 + heapq.heappush(heap, + (tentative_g + _manhattan(nb, exit_), tie, nb)) + return {"path": [], "visited": visited} + + +# ---------- Дейкстра ---------- + +class DijkstraStrategy(PathFindingStrategy): + """Дейкстра - оптимальный путь с учётом веса клеток. + На немодифицированном лабиринте (все веса = 1) совпадает с BFS.""" + name = "Dijkstra" + + def find_path(self, maze, start, exit_): + dist = {(start.x, start.y): 0} + parents = {(start.x, start.y): None} + tie = 0 + heap = [(0, tie, start)] + visited = 0 + closed = set() + + while heap: + d, _, cell = heapq.heappop(heap) + key = (cell.x, cell.y) + if key in closed: + continue + closed.add(key) + visited += 1 + + if cell is exit_: + return {"path": _reconstruct(parents, start, exit_), + "visited": visited} + + for nb in maze.get_neighbors(cell): + nb_key = (nb.x, nb.y) + nd = d + nb.weight + if nd < dist.get(nb_key, float("inf")): + dist[nb_key] = nd + parents[nb_key] = cell + tie += 1 + heapq.heappush(heap, (nd, tie, nb)) + return {"path": [], "visited": visited} + + +STRATEGIES = { + "BFS": BFSStrategy, + "DFS": DFSStrategy, + "A*": AStarStrategy, + "Dijkstra": DijkstraStrategy, +}