diff --git a/Ezhovnd/maze_all.py b/Ezhovnd/maze_all.py new file mode 100644 index 0000000..5005a58 --- /dev/null +++ b/Ezhovnd/maze_all.py @@ -0,0 +1,825 @@ +""" +maze_all.py — Полный проект лабиринта со всеми паттернами: +Builder, Strategy, Observer, Command. + +Содержит все модули: model, builder, strategy, solver, generator, experiment, main. +""" + +from __future__ import annotations +import abc +import csv +import heapq +import json +import os +import random +import sys +import time +from collections import deque +from dataclasses import dataclass, field +from typing import Optional + +# ============================================================================ +# model.py — Модель лабиринта (классы Cell и Maze) +# ============================================================================ + +class Cell: + """Одна клетка лабиринта.""" + + WALL = '#' + PASS = ' ' + START = 'S' + EXIT = 'E' + SWAMP = 'W' # болото — вес 3 + SAND = 'N' # песок — вес 2 + ROAD = 'R' # асфальт — вес 1 (то же что PASS) + + WEIGHTS = {WALL: 0, PASS: 1, ROAD: 1, START: 1, EXIT: 1, SAND: 2, SWAMP: 3} + + def __init__(self, x: int, y: int, symbol: str = PASS): + self.x = x + self.y = y + self.symbol = symbol + + @property + def is_wall(self) -> bool: + return self.symbol == self.WALL + + @property + def is_start(self) -> bool: + return self.symbol == self.START + + @property + def is_exit(self) -> bool: + return self.symbol == self.EXIT + + @property + def weight(self) -> int: + return self.WEIGHTS.get(self.symbol, 1) + + def is_passable(self) -> bool: + return not self.is_wall + + def __repr__(self): + return f"Cell({self.x},{self.y},'{self.symbol}')" + + def __hash__(self): + return hash((self.x, self.y)) + + def __eq__(self, other): + return isinstance(other, Cell) and self.x == other.x and self.y == other.y + + +class Maze: + """Двумерный массив клеток + метаданные.""" + + def __init__(self, width: int, height: int, cells: list[list[Cell]], + start: Optional[Cell] = None, exit_: Optional[Cell] = None): + self.width = width + self.height = height + self._cells = cells # cells[y][x] + self.start = start + self.exit = exit_ + + def get_cell(self, x: int, y: int) -> Cell: + return self._cells[y][x] + + def get_neighbors(self, cell: Cell) -> list[Cell]: + """Четыре соседа (если в пределах карты и не стена).""" + neighbors = [] + for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)): + nx, ny = cell.x + dx, cell.y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + nb = self._cells[ny][nx] + if nb.is_passable(): + neighbors.append(nb) + return neighbors + + def render(self, path: set = None, visited: set = None, + player_pos: Optional[Cell] = None) -> str: + """Возвращает строковое представление для консоли.""" + path = path or set() + visited = visited or set() + lines = [] + for y in range(self.height): + row = [] + for x in range(self.width): + cell = self._cells[y][x] + if player_pos and cell == player_pos: + row.append('@') + elif cell in path and not cell.is_start and not cell.is_exit: + row.append('·') + elif cell in visited and not cell.is_start and not cell.is_exit: + row.append('░') + else: + row.append(cell.symbol if cell.symbol != ' ' else '.') + lines.append(''.join(row)) + return '\n'.join(lines) + + +# ============================================================================ +# builder.py — Паттерн Builder +# ============================================================================ + +class MazeBuilder(abc.ABC): + """Интерфейс строителя лабиринта.""" + + @abc.abstractmethod + def build_from_file(self, filename: str) -> Maze: + ... + + @abc.abstractmethod + def build_empty(self, width: int, height: int) -> Maze: + """Создать лабиринт без стен.""" + ... + + +class TextFileMazeBuilder(MazeBuilder): + """ + Строитель для текстовых файлов: + # — стена + ' ' или '.' — проход (вес 1) + S — старт + E — выход + W — болото (вес 3) + N — песок (вес 2) + R — асфальт (вес 1) + """ + + def build_from_file(self, filename: str) -> Maze: + with open(filename, encoding='utf-8') as f: + lines = f.read().splitlines() + return self._parse_lines(lines) + + def build_from_string(self, text: str) -> Maze: + return self._parse_lines(text.splitlines()) + + def _parse_lines(self, lines: list[str]) -> Maze: + if not lines: + raise ValueError("Файл лабиринта пуст") + + height = len(lines) + width = max(len(line) for line in lines) + + cells: list[list[Cell]] = [] + start = exit_ = None + + for y, line in enumerate(lines): + row = [] + for x in range(width): + ch = line[x] if x < len(line) else ' ' + if ch == '.': + ch = ' ' # нормализация + cell = Cell(x, y, ch) + if cell.is_start: + start = cell + if cell.is_exit: + exit_ = cell + row.append(cell) + cells.append(row) + + if start is None: + raise ValueError("В лабиринте не найдена клетка 'S' (старт)") + if exit_ is None: + raise ValueError("В лабиринте не найдена клетка 'E' (выход)") + + return Maze(width, height, cells, start, exit_) + + def build_empty(self, width: int, height: int) -> Maze: + """Лабиринт без стен: рамка из стен, внутри проходы.""" + cells = [] + start = exit_ = None + for y in range(height): + row = [] + for x in range(width): + if x == 0 or x == width - 1 or y == 0 or y == height - 1: + ch = Cell.WALL + else: + ch = Cell.PASS + cell = Cell(x, y, ch) + row.append(cell) + cells.append(row) + # Старт и выход + cells[1][1].symbol = Cell.START + start = cells[1][1] + cells[height - 2][width - 2].symbol = Cell.EXIT + exit_ = cells[height - 2][width - 2] + return Maze(width, height, cells, start, exit_) + + +class JsonMazeBuilder(MazeBuilder): + """ + Альтернативный строитель — JSON-формат. + Демонстрирует расширяемость паттерна Builder без изменения клиента. + + Формат: + { + "width": 5, "height": 5, + "cells": ["#####", "#S E#", "#####"] + } + """ + + def build_from_file(self, filename: str) -> Maze: + with open(filename, encoding='utf-8') as f: + data = json.load(f) + lines = data['cells'] + text_builder = TextFileMazeBuilder() + return text_builder._parse_lines(lines) + + def build_empty(self, width: int, height: int) -> Maze: + return TextFileMazeBuilder().build_empty(width, height) + + +# ============================================================================ +# strategy.py — Паттерн Strategy (алгоритмы поиска пути) +# ============================================================================ + +class PathFindingStrategy(abc.ABC): + """Интерфейс стратегии поиска пути.""" + + @abc.abstractmethod + def find_path(self, maze: Maze, start: Cell, exit_: Cell + ) -> tuple[list[Cell], int]: + """ + Возвращает (path, visited_count). + path — список клеток от start до exit_ включительно, + или [] если пути нет. + visited_count — количество клеток, посещённых во время поиска. + """ + ... + + def name(self) -> str: + return self.__class__.__name__ + + +def _reconstruct(parent: dict, end: Cell) -> list[Cell]: + """Восстановить путь по словарю parent.""" + path, node = [], end + while node is not None: + path.append(node) + node = parent.get(node) + path.reverse() + return path + + +class BFSStrategy(PathFindingStrategy): + """Поиск в ширину — гарантирует кратчайший путь (по количеству шагов).""" + + def find_path(self, maze: Maze, start: Cell, exit_: Cell): + queue = deque([start]) + parent = {start: None} + visited = 0 + + while queue: + cell = queue.popleft() + visited += 1 + if cell == exit_: + return _reconstruct(parent, exit_), visited + for nb in maze.get_neighbors(cell): + if nb not in parent: + parent[nb] = cell + queue.append(nb) + + return [], visited + + def name(self): return "BFS" + + +class DFSStrategy(PathFindingStrategy): + """Поиск в глубину — быстрый, но путь не оптимален.""" + + def find_path(self, maze: Maze, start: Cell, exit_: Cell): + stack = [start] + parent = {start: None} + visited = 0 + + while stack: + cell = stack.pop() + visited += 1 + if cell == exit_: + return _reconstruct(parent, exit_), visited + for nb in maze.get_neighbors(cell): + if nb not in parent: + parent[nb] = cell + stack.append(nb) + + return [], visited + + def name(self): return "DFS" + + +class AStarStrategy(PathFindingStrategy): + """ + A* с манхэттенской эвристикой — оптимальный и быстрый. + Учитывает веса клеток (болото, песок и т.д.). + """ + + def _h(self, cell: Cell, goal: Cell) -> int: + return abs(cell.x - goal.x) + abs(cell.y - goal.y) + + def find_path(self, maze: Maze, start: Cell, exit_: Cell): + # (f, g, cell) + heap = [(self._h(start, exit_), 0, id(start), start)] + g_score = {start: 0} + parent = {start: None} + visited = 0 + + while heap: + f, g, _, cell = heapq.heappop(heap) + visited += 1 + if cell == exit_: + return _reconstruct(parent, exit_), visited + if g > g_score.get(cell, float('inf')): + continue # устаревшая запись + for nb in maze.get_neighbors(cell): + new_g = g + nb.weight + if new_g < g_score.get(nb, float('inf')): + g_score[nb] = new_g + parent[nb] = cell + f_val = new_g + self._h(nb, exit_) + heapq.heappush(heap, (f_val, new_g, id(nb), nb)) + + return [], visited + + def name(self): return "A*" + + +class DijkstraStrategy(PathFindingStrategy): + """ + Алгоритм Дейкстры — оптимален для взвешенных клеток. + На равновесных лабиринтах совпадает с BFS. + """ + + def find_path(self, maze: Maze, start: Cell, exit_: Cell): + heap = [(0, id(start), start)] + dist = {start: 0} + parent = {start: None} + visited = 0 + + while heap: + d, _, cell = heapq.heappop(heap) + visited += 1 + if cell == exit_: + return _reconstruct(parent, exit_), visited + if d > dist.get(cell, float('inf')): + continue + for nb in maze.get_neighbors(cell): + new_d = d + nb.weight + if new_d < dist.get(nb, float('inf')): + dist[nb] = new_d + parent[nb] = cell + heapq.heappush(heap, (new_d, id(nb), nb)) + + return [], visited + + def name(self): return "Dijkstra" + + +# Реестр всех стратегий (используется в экспериментальной части) +ALL_STRATEGIES = [BFSStrategy(), DFSStrategy(), AStarStrategy(), DijkstraStrategy()] + + +# ============================================================================ +# solver.py — Паттерны Observer и Command, класс MazeSolver +# ============================================================================ + +# ───────────────────────────────────────────── +# Observer +# ───────────────────────────────────────────── + +class Observer(abc.ABC): + @abc.abstractmethod + def update(self, event: str, payload: dict) -> None: ... + + +class Subject: + """Миксин: любой класс, у которого есть наблюдатели.""" + def __init__(self): + self._observers: list[Observer] = [] + + def add_observer(self, obs: Observer): + self._observers.append(obs) + + def remove_observer(self, obs: Observer): + self._observers.remove(obs) + + def notify(self, event: str, payload: dict = None): + for obs in self._observers: + obs.update(event, payload or {}) + + +class ConsoleView(Observer): + """ + Консольный наблюдатель: печатает события и рендерит лабиринт. + """ + def update(self, event: str, payload: dict) -> None: + if event == 'maze_loaded': + maze: Maze = payload['maze'] + print(f"\n[ConsoleView] Лабиринт загружен ({maze.width}×{maze.height})") + print(maze.render()) + + elif event == 'search_started': + print(f"\n[ConsoleView] Начат поиск алгоритмом: {payload['strategy']}") + + elif event == 'path_found': + maze: Maze = payload['maze'] + path: list[Cell] = payload['path'] + stats = payload['stats'] + path_set = set(path) + print(f"\n[ConsoleView] Путь найден! Длина: {stats.path_length}, " + f"посещено: {stats.visited_cells}, " + f"время: {stats.elapsed_ms:.2f} мс") + print(maze.render(path=path_set)) + + elif event == 'no_path': + print("\n[ConsoleView] Путь не найден.") + + elif event == 'player_moved': + maze: Maze = payload['maze'] + player = payload['player'] + path_set = set(payload.get('path', [])) + print(f"\n[ConsoleView] Игрок переместился → ({player.position.x}, {player.position.y})") + print(maze.render(path=path_set, player_pos=player.position)) + + elif event == 'undo': + print(f"[ConsoleView] Отмена хода. Игрок → ({payload['position'].x}, {payload['position'].y})") + + +class FileLogObserver(Observer): + """Наблюдатель-логгер: записывает события в файл.""" + def __init__(self, filepath: str): + self._path = filepath + + def update(self, event: str, payload: dict) -> None: + with open(self._path, 'a', encoding='utf-8') as f: + f.write(f"[{event}] {payload.get('strategy','')}" + f" visited={payload.get('stats', None) and payload['stats'].visited_cells}\n") + + +# ───────────────────────────────────────────── +# SearchStats +# ───────────────────────────────────────────── + +@dataclass +class SearchStats: + strategy_name: str + elapsed_ms: float + visited_cells: int + path_length: int + + +# ───────────────────────────────────────────── +# MazeSolver — оркестратор +# ───────────────────────────────────────────── + +class MazeSolver(Subject): + """ + Принимает лабиринт и стратегию, выполняет поиск, собирает статистику, + уведомляет наблюдателей. + """ + def __init__(self, maze: Maze, strategy: PathFindingStrategy): + super().__init__() + self._maze = maze + self._strategy = strategy + + def set_strategy(self, strategy: PathFindingStrategy): + self._strategy = strategy + + def load_maze(self, maze: Maze): + self._maze = maze + self.notify('maze_loaded', {'maze': maze}) + + def solve(self) -> tuple[SearchStats, list[Cell]]: + self.notify('search_started', {'strategy': self._strategy.name()}) + + t0 = time.perf_counter() + path, visited = self._strategy.find_path( + self._maze, self._maze.start, self._maze.exit) + elapsed_ms = (time.perf_counter() - t0) * 1000 + + stats = SearchStats( + strategy_name=self._strategy.name(), + elapsed_ms=elapsed_ms, + visited_cells=visited, + path_length=len(path), + ) + + if path: + self.notify('path_found', {'maze': self._maze, 'path': path, 'stats': stats}) + else: + self.notify('no_path', {'maze': self._maze, 'stats': stats}) + + return stats, path + + +# ───────────────────────────────────────────── +# Command +# ───────────────────────────────────────────── + +class Command(abc.ABC): + @abc.abstractmethod + def execute(self) -> None: ... + @abc.abstractmethod + def undo(self) -> None: ... + + +class Player: + """Хранит текущую позицию игрока.""" + def __init__(self, position: Cell): + self.position = position + + +class MoveCommand(Command): + """ + Перемещение игрока в клетку target. + Undo возвращает на previous_position. + """ + def __init__(self, player: Player, target: Cell, + solver: 'MazeSolver', path: list[Cell] = None): + self._player = player + self._target = target + self._previous = player.position + self._solver = solver + self._path = path or [] + + def execute(self): + self._player.position = self._target + self._solver.notify('player_moved', { + 'maze': self._solver._maze, + 'player': self._player, + 'path': self._path, + }) + + def undo(self): + self._player.position = self._previous + self._solver.notify('undo', {'position': self._previous}) + + +class CommandHistory: + """Стек выполненных команд для поддержки undo.""" + def __init__(self): + self._stack: list[Command] = [] + + def execute(self, cmd: Command): + cmd.execute() + self._stack.append(cmd) + + def undo(self): + if self._stack: + self._stack.pop().undo() + else: + print("[CommandHistory] Нечего отменять.") + + +# ============================================================================ +# generator.py — Генерация тестовых лабиринтов +# ============================================================================ + +def generate_maze(width: int, height: int, seed: int = 42, + weighted: bool = False) -> Maze: + """ + Генерирует лабиринт алгоритмом DFS (Recursive Backtracker). + width/height — размеры (лучше нечётные для красивого рисунка). + """ + rng = random.Random(seed) + + # Начальный массив — всё стены + cells = [[Cell(x, y, Cell.WALL) for x in range(width)] + for y in range(height)] + + def carve(x, y): + cells[y][x].symbol = Cell.PASS + directions = [(0, 2), (0, -2), (2, 0), (-2, 0)] + rng.shuffle(directions) + for dx, dy in directions: + nx, ny = x + dx, y + dy + if 0 <= nx < width and 0 <= ny < height: + if cells[ny][nx].symbol == Cell.WALL: + # Убрать стену между (x,y) и (nx,ny) + cells[y + dy // 2][x + dx // 2].symbol = Cell.PASS + carve(nx, ny) + + sys.setrecursionlimit(width * height + 100) + carve(1, 1) + + # Старт и выход + cells[1][1].symbol = Cell.START + cells[height - 2][width - 2].symbol = Cell.EXIT + start = cells[1][1] + exit_ = cells[height - 2][width - 2] + + # Взвешенные клетки (болото, песок) + if weighted: + for y in range(height): + for x in range(width): + if cells[y][x].symbol == Cell.PASS: + r = rng.random() + if r < 0.05: + cells[y][x].symbol = Cell.SWAMP + elif r < 0.15: + cells[y][x].symbol = Cell.SAND + + return Maze(width, height, cells, start, exit_) + + +def generate_no_exit(width: int, height: int, seed: int = 42) -> Maze: + """Лабиринт без выхода — выход заблокирован стенами.""" + maze = generate_maze(width, height, seed) + ex = maze.exit + # Окружить выход стенами + for dx, dy in ((0,-1),(0,1),(-1,0),(1,0)): + nx, ny = ex.x + dx, ex.y + dy + if 0 <= nx < maze.width and 0 <= ny < maze.height: + maze._cells[ny][nx].symbol = Cell.WALL + return maze + + +def save_maze(maze: Maze, filepath: str): + """Сохранить лабиринт в текстовый файл.""" + lines = [] + for y in range(maze.height): + row = ''.join(maze._cells[y][x].symbol for x in range(maze.width)) + lines.append(row) + with open(filepath, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + + +def make_empty_maze(width: int, height: int) -> Maze: + """Лабиринт-поле без внутренних стен (только рамка).""" + return TextFileMazeBuilder().build_empty(width, height) + + +# ============================================================================ +# experiment.py — Экспериментальная часть +# ============================================================================ + +REPEATS = 7 +OUT_CSV = os.path.join(os.path.dirname(__file__), 'docs/data/maze_results.csv') +MAZE_DIR = os.path.join(os.path.dirname(__file__), 'docs/data/mazes') + + +def run_bench(maze, strategy, repeats=REPEATS): + times, visited_list, path_lens = [], [], [] + for _ in range(repeats): + solver = MazeSolver(maze, strategy) + stats, path = solver.solve() + times.append(stats.elapsed_ms) + visited_list.append(stats.visited_cells) + path_lens.append(stats.path_length) + return { + 'time_ms_mean': statistics.mean(times), + 'time_ms_std': statistics.stdev(times) if len(times) > 1 else 0, + 'visited_mean': statistics.mean(visited_list), + 'path_length': path_lens[0], # детерминировано + } + + +def run_experiment(): + os.makedirs(MAZE_DIR, exist_ok=True) + + # Определяем лабиринты + configs = [ + ('small_10x10', generate_maze(11, 11, seed=1)), + ('medium_50x50', generate_maze(51, 51, seed=2)), + ('large_100x100', generate_maze(101, 101, seed=3)), + ('empty_50x50', make_empty_maze(52, 52)), + ('no_exit_20x20', generate_no_exit(21, 21, seed=4)), + ('weighted_50x50', generate_maze(51, 51, seed=5, weighted=True)), + ] + + # Сохраним лабиринты в файлы + for name, maze in configs: + save_maze(maze, os.path.join(MAZE_DIR, f'{name}.txt')) + + rows = [['Лабиринт', 'Алгоритм', 'Время_мс', 'Посещено_клеток', 'Длина_пути', + 'Стд_время_мс']] + + for maze_name, maze in configs: + print(f'\n=== {maze_name} ({maze.width}×{maze.height}) ===') + for strategy in ALL_STRATEGIES: + print(f' [{strategy.name()}]', end=' ', flush=True) + res = run_bench(maze, strategy) + print(f"time={res['time_ms_mean']:.3f}ms visited={res['visited_mean']:.0f} " + f"path={res['path_length']}") + rows.append([ + maze_name, + strategy.name(), + round(res['time_ms_mean'], 4), + round(res['visited_mean'], 1), + res['path_length'], + round(res['time_ms_std'], 4), + ]) + + with open(OUT_CSV, 'w', newline='', encoding='utf-8') as f: + csv.writer(f).writerows(rows) + print(f'\nРезультаты сохранены: {OUT_CSV}') + + return rows + + +# ============================================================================ +# main.py — Точка входа +# ============================================================================ + +STRATEGIES = { + '1': BFSStrategy(), + '2': DFSStrategy(), + '3': AStarStrategy(), + '4': DijkstraStrategy(), +} + +STRATEGY_LABELS = { + '1': 'BFS (поиск в ширину)', + '2': 'DFS (поиск в глубину)', + '3': 'A* (эвристический)', + '4': 'Dijkstra (взвешенный)', +} + + +def choose_strategy() -> PathFindingStrategy: + print("\nВыберите алгоритм поиска:") + for k, label in STRATEGY_LABELS.items(): + print(f" {k}. {label}") + choice = input("Ваш выбор [1-4, Enter=3]: ").strip() or '3' + return STRATEGIES.get(choice, AStarStrategy()) + + +def interactive_walk(solver: MazeSolver, path: list, maze): + """Пошаговое прохождение найденного пути командами.""" + if not path: + print("Путь не найден, пошаговый режим недоступен.") + return + + player = Player(maze.start) + history = CommandHistory() + step = 0 + + print("\n=== Пошаговый режим ===") + print("n — следующий шаг по пути | u — отмена | q — выход") + print(maze.render(path=set(path), player_pos=player.position)) + + while True: + cmd_input = input("\nКоманда: ").strip().lower() + if cmd_input == 'q': + break + elif cmd_input == 'n': + step += 1 + if step < len(path): + cmd = MoveCommand(player, path[step], solver, path) + history.execute(cmd) + else: + print("Вы достигли выхода! 🎉") + elif cmd_input == 'u': + step = max(0, step - 1) + history.undo() + else: + print("Неизвестная команда.") + + +def demo_auto(size: int = 21): + """Автоматическая демонстрация всех паттернов на сгенерированном лабиринте.""" + print("=" * 60) + print(" Демонстрация: Поиск выхода из лабиринта") + print("=" * 60) + + # Builder + print("\n[Builder] Генерация лабиринта...") + maze = generate_maze(size, size, seed=77) + + # Observer + Strategy + strategy = choose_strategy() + solver = MazeSolver(maze, strategy) + view = ConsoleView() + solver.add_observer(view) + + solver.notify('maze_loaded', {'maze': maze}) + + stats, path = solver.solve() + + print(f"\n[MazeSolver] Статистика:") + print(f" Алгоритм: {stats.strategy_name}") + print(f" Время: {stats.elapsed_ms:.3f} мс") + print(f" Посещено клеток: {stats.visited_cells}") + print(f" Длина пути: {stats.path_length}") + + # Command / пошаговый режим + choice = input("\nЗапустить пошаговый режим? [y/N]: ").strip().lower() + if choice == 'y': + interactive_walk(solver, path, maze) + + # Strategy swap demo + print("\n[Strategy] Смена алгоритма на BFS без изменения кода...") + solver.set_strategy(BFSStrategy()) + stats2, _ = solver.solve() + print(f" BFS: {stats2.elapsed_ms:.3f} мс, посещено: {stats2.visited_cells}") + + +# ============================================================================ +# Запуск +# ============================================================================ + +if __name__ == '__main__': + import sys + if len(sys.argv) > 1 and sys.argv[1] == 'experiment': + run_experiment() + else: + demo_auto() \ No newline at end of file diff --git a/Ezhovnd/maze_benchmark.png b/Ezhovnd/maze_benchmark.png new file mode 100644 index 0000000..09a4226 Binary files /dev/null and b/Ezhovnd/maze_benchmark.png differ diff --git a/Ezhovnd/maze_report.md b/Ezhovnd/maze_report.md new file mode 100644 index 0000000..0001da7 --- /dev/null +++ b/Ezhovnd/maze_report.md @@ -0,0 +1,279 @@ +# Задание 2 — Поиск выхода из лабиринта (ООП + паттерны GoF) + +## 1. Описание задачи + +Разработать расширяемую программу поиска пути в лабиринте с возможностью смены алгоритма, визуализации и пошагового управления. Применить минимум 3 паттерна GoF. + +--- + +## 2. Применённые паттерны GoF + +### 2.1 Builder (Строитель) — `builder.py` + +**Проблема:** Создание `Maze` — многошаговый процесс: чтение файла → парсинг символов → валидация (есть ли S и E) → расстановка координат. Если делать всё в конструкторе `Maze` — нарушение Single Responsibility. + +**Решение:** Интерфейс `MazeBuilder` с методом `build_from_file()`. Реализации: +- `TextFileMazeBuilder` — текстовый формат (`#`, пробел, `S`, `E`, `W`, `N`) +- `JsonMazeBuilder` — JSON-формат (для демонстрации расширяемости) + +**Преимущество:** Добавление нового формата (бинарный, XML) не требует изменения `Maze`, `MazeSolver` или стратегий. + +### 2.2 Strategy (Стратегия) — `strategy.py` + +**Проблема:** Алгоритмы поиска (BFS, DFS, A*, Dijkstra) взаимозаменяемы по интерфейсу, но различаются реализацией. Жёсткая связь с конкретным алгоритмом нарушает Open/Closed Principle. + +**Решение:** Интерфейс `PathFindingStrategy` с методом `find_path(maze, start, exit_)`. `MazeSolver` хранит ссылку на стратегию и вызывает `strategy.find_path()` — не зная, какой именно алгоритм работает. Метод `set_strategy()` меняет алгоритм в рантайме. + +**Преимущество:** Новый алгоритм (IDA*, Bidirectional BFS) — это один новый класс без изменения остального кода. + +### 2.3 Observer (Наблюдатель) — `solver.py` + +**Проблема:** `MazeSolver` не должен знать о способе отображения. Консоль, GUI, файловый лог — разные задачи. + +**Решение:** `MazeSolver` наследует `Subject` (хранит список `_observers`, метод `notify()`). При событиях (`maze_loaded`, `path_found`, `no_path`, `player_moved`) уведомляет всех наблюдателей. Реализации: `ConsoleView`, `FileLogObserver`. + +**Преимущество:** GUI-интерфейс добавляется как новый `Observer` без изменения `MazeSolver`. + +### 2.4 Command (Команда) — `solver.py` + +**Проблема:** Пошаговое перемещение игрока должно поддерживать отмену (undo). + +**Решение:** Интерфейс `Command` с методами `execute()` / `undo()`. `MoveCommand` хранит предыдущую позицию. `CommandHistory` — стек команд. Undo — `pop()` + `cmd.undo()`. + +**Преимущество:** Любое новое действие (телепортация, открытие двери) реализуется как отдельная команда, не меняя `Player` или `MazeSolver`. + +--- + +## 3. Диаграмма классов (Mermaid) + +```mermaid +classDiagram + %% ── Model ────────────────────────────────── + class Cell { + +int x, y + +str symbol + +bool is_wall + +bool is_start + +bool is_exit + +int weight + +is_passable() bool + } + + class Maze { + +int width, height + +Cell start, exit + +get_cell(x,y) Cell + +get_neighbors(cell) list + +render(path, visited, player) str + } + Maze "1" *-- "many" Cell + + %% ── Builder ───────────────────────────────── + class MazeBuilder { + <> + +build_from_file(filename) Maze + +build_empty(w, h) Maze + } + class TextFileMazeBuilder { + +build_from_file(filename) Maze + +build_from_string(text) Maze + +build_empty(w, h) Maze + } + class JsonMazeBuilder { + +build_from_file(filename) Maze + +build_empty(w, h) Maze + } + MazeBuilder <|.. TextFileMazeBuilder + MazeBuilder <|.. JsonMazeBuilder + TextFileMazeBuilder ..> Maze : creates + JsonMazeBuilder ..> Maze : creates + + %% ── Strategy ──────────────────────────────── + class PathFindingStrategy { + <> + +find_path(maze, start, exit) tuple + +name() str + } + class BFSStrategy { +find_path() tuple } + class DFSStrategy { +find_path() tuple } + class AStarStrategy { +find_path() tuple } + class DijkstraStrategy { +find_path() tuple } + PathFindingStrategy <|.. BFSStrategy + PathFindingStrategy <|.. DFSStrategy + PathFindingStrategy <|.. AStarStrategy + PathFindingStrategy <|.. DijkstraStrategy + + %% ── Observer ──────────────────────────────── + class Observer { + <> + +update(event, payload) + } + class Subject { + -observers list + +add_observer(obs) + +notify(event, payload) + } + class ConsoleView { +update(event, payload) } + class FileLogObserver { +update(event, payload) } + Observer <|.. ConsoleView + Observer <|.. FileLogObserver + Subject "1" o-- "many" Observer + + %% ── MazeSolver ────────────────────────────── + class MazeSolver { + -Maze maze + -PathFindingStrategy strategy + +set_strategy(strategy) + +load_maze(maze) + +solve() tuple + } + MazeSolver --|> Subject + MazeSolver --> PathFindingStrategy : uses + MazeSolver --> Maze : uses + + %% ── Command ───────────────────────────────── + class Command { + <> + +execute() + +undo() + } + class MoveCommand { + -Player player + -Cell target, previous + +execute() + +undo() + } + class CommandHistory { + -stack list + +execute(cmd) + +undo() + } + class Player { +Cell position } + Command <|.. MoveCommand + CommandHistory o-- Command + MoveCommand --> Player +``` + +--- + +## 4. Структура файлов + +``` +maze/ +├── model.py — Cell, Maze +├── builder.py — MazeBuilder, TextFileMazeBuilder, JsonMazeBuilder +├── strategy.py — PathFindingStrategy, BFS, DFS, A*, Dijkstra +├── solver.py — Observer, Subject, ConsoleView, MazeSolver, Command, Player +├── generator.py — Генератор лабиринтов (DFS-backtracker) +├── experiment.py — Экспериментальная часть, запись CSV +├── main.py — Интерактивная демонстрация +└── docs/ + └── data/ + ├── maze_results.csv + ├── maze_benchmark.png + ├── path_lengths.png + └── mazes/ — текстовые файлы лабиринтов +``` + +--- + +## 5. Результаты экспериментов + +### 5.1 Таблица результатов (N = 7 повторов, среднее) + +| Лабиринт | Алгоритм | Время (мс) | Посещено | Длина пути | +|---|---|---:|---:|---:| +| Маленький 11×11 | BFS | 0.077 | 37 | 29 | +| Маленький 11×11 | DFS | 0.059 | 29 | 29 | +| Маленький 11×11 | A* | 0.111 | 29 | 29 | +| Маленький 11×11 | Dijkstra | 0.115 | 37 | 29 | +| Средний 51×51 | BFS | 2.228 | 755 | 405 | +| Средний 51×51 | DFS | 0.935 | 423 | 405 | +| Средний 51×51 | A* | 2.343 | 663 | 405 | +| Средний 51×51 | Dijkstra | 2.528 | 755 | 405 | +| Большой 101×101 | BFS | 3.299 | 1533 | 993 | +| Большой 101×101 | DFS | 2.289 | 1061 | 993 | +| Большой 101×101 | A* | 5.948 | 1515 | 993 | +| Большой 101×101 | Dijkstra | 5.137 | 1533 | 993 | +| Пустой 52×52 | BFS | 5.859 | 2500 | 99 | +| Пустой 52×52 | DFS | 3.518 | 1275 | 1275 | +| Пустой 52×52 | A* | 10.803 | 2500 | 99 | +| Пустой 52×52 | Dijkstra | 10.227 | 2500 | 99 | +| Без выхода 21×21 | BFS | 0.213 | 105 | 0 | +| Без выхода 21×21 | DFS | 0.220 | 105 | 0 | +| Без выхода 21×21 | A* | 0.361 | 105 | 0 | +| Без выхода 21×21 | Dijkstra | 0.336 | 105 | 0 | +| Взвешенный 51×51 | BFS | 0.722 | 335 | 221 | +| Взвешенный 51×51 | DFS | 0.518 | 221 | 221 | +| Взвешенный 51×51 | A* | 0.970 | 278 | 221 | +| Взвешенный 51×51 | Dijkstra | 1.091 | 337 | 221 | + +### 5.2 Графики + +![Benchmark](data/maze_benchmark.png) + +![Path Lengths](data/path_lengths.png) + +--- + +## 6. Анализ алгоритмов + +### BFS (поиск в ширину) +- **Гарантирует кратчайший путь** по количеству шагов. +- Посещает все клетки на расстоянии k до нахождения выхода на расстоянии k+1. +- На пустом лабиринте (максимум свободного пространства) посещает **все 2500 клеток** — это худший сценарий по памяти (хранит всю «волну»). +- Практически эквивалентен Dijkstra для равновесных лабиринтов. + +### DFS (поиск в глубину) +- **Самый быстрый по времени** на лабиринтах с длинными коридорами. +- На правильно построенном лабиринте (DFS-backtracker) коридоры длинные → DFS «угадывает» направление и находит путь, посетив меньше клеток. +- На пустом поле даёт **огромный зигзагообразный путь** (1275 клеток вместо оптимального 99) — классическая проблема DFS. +- **Не гарантирует оптимальность**, зато экономит память (O(глубина) вместо O(ширина)). + +### A* (с манхэттенской эвристикой) +- На лабиринтах с коридорами посещает **меньше клеток**, чем BFS, но при этом тоже гарантирует оптимальный путь. +- На пустом поле ведёт себя хуже BFS по времени из-за накладных расходов на приоритетную очередь (heapq). +- Манхэттенская эвристика работает хуже на лабиринтах с длинными обходами препятствий: оценка расстояния «по прямой» слишком оптимистична. +- **Выигрывает** когда карта большая и путь очевиден по направлению (прямые коридоры к выходу). + +### Dijkstra +- На равновесных лабиринтах идентичен BFS, но медленнее из-за приоритетной очереди. +- **Незаменим на взвешенных картах**: учитывает клетки с весом 2 (песок) и 3 (болото) и находит **минимальный суммарный вес** пути, а не минимальное число шагов. +- На взвешенном лабиринте Dijkstra и A* могут выдать более короткий (по весу) путь, чем BFS, при одинаковой длине в клетках. + +### Без выхода +- Все алгоритмы обошли все 105 достижимых клеток и вернули пустой путь — корректная обработка. +- Время идентично для всех алгоритмов (доминирует полный обход, а не структура поиска). + +--- + +## 7. Выводы: ООП и паттерны + +### Что дали паттерны + +**Builder** отделил детали парсинга от модели. Без него клиент сам читал бы файл и вручную собирал `Maze` — хрупкий, нерасширяемый код. С Builder: добавление JSON-формата заняло 10 строк. + +**Strategy** позволил сравнить 4 алгоритма, не меняя `MazeSolver`. Переключение алгоритма — одна строка `solver.set_strategy(new_algo)`. Без паттерна потребовался бы if/elif на 4 ветки в каждом методе. + +**Observer** полностью развязал логику поиска и отображение. `MazeSolver` не знает, куда идут уведомления. Добавление `FileLogObserver` — новый класс из 5 строк, нулевые изменения в `MazeSolver`. + +**Command** дал возможность реализовать undo за счёт хранения предыдущего состояния в объекте команды. Без паттерна — ручное сохранение/восстановление переменных. + +### Что было бы сложно без ООП и паттернов + +| Задача | Без паттернов | С паттернами | +|---|---|---| +| Добавить новый алгоритм | Правка MazeSolver + if/elif | Новый класс, наследующий интерфейс | +| Добавить формат файла | Правка загрузчика | Новый Builder | +| Добавить GUI | Вставка GUI-кода в логику поиска | Новый Observer | +| Отмена шага игрока | Ручное сохранение переменной | Command.undo() | + +### Рекомендации по выбору алгоритма + +| Задача | Рекомендация | +|---|---| +| Гарантированно кратчайший путь, равные веса | **BFS** | +| Быстро найти *какой-нибудь* путь (поиск с препятствиями) | **DFS** | +| Большая карта, путь по направлению к цели | **A\*** | +| Взвешенная карта (болото, песок, дорога) | **Dijkstra** или **A\*** с весовой эвристикой | +| Нужна гарантия оптимальности на взвешенном графе | **Dijkstra** | diff --git a/Ezhovnd/maze_results.csv b/Ezhovnd/maze_results.csv new file mode 100644 index 0000000..665053d --- /dev/null +++ b/Ezhovnd/maze_results.csv @@ -0,0 +1,25 @@ +Лабиринт,Алгоритм,Время_мс,Посещено_клеток,Длина_пути,Стд_время_мс +small_10x10,BFS,0.0772,37,29,0.0122 +small_10x10,DFS,0.0588,29,29,0.0044 +small_10x10,A*,0.1112,29,29,0.029 +small_10x10,Dijkstra,0.1155,37,29,0.0135 +medium_50x50,BFS,2.228,755,405,1.6518 +medium_50x50,DFS,0.9346,423,405,0.0899 +medium_50x50,A*,2.3432,663,405,0.125 +medium_50x50,Dijkstra,2.5281,755,405,0.1245 +large_100x100,BFS,3.2987,1533,993,0.148 +large_100x100,DFS,2.2887,1061,993,0.0737 +large_100x100,A*,5.9477,1515,993,1.2811 +large_100x100,Dijkstra,5.1375,1533,993,0.1435 +empty_50x50,BFS,5.8593,2500,99,0.1605 +empty_50x50,DFS,3.5185,1275,1275,0.1171 +empty_50x50,A*,10.8025,2500,99,0.0902 +empty_50x50,Dijkstra,10.227,2500,99,0.1552 +no_exit_20x20,BFS,0.213,105,0,0.0434 +no_exit_20x20,DFS,0.2204,105,0,0.049 +no_exit_20x20,A*,0.3615,105,0,0.0489 +no_exit_20x20,Dijkstra,0.3361,105,0,0.033 +weighted_50x50,BFS,0.7224,335,221,0.069 +weighted_50x50,DFS,0.5183,221,221,0.0912 +weighted_50x50,A*,0.97,278,221,0.0444 +weighted_50x50,Dijkstra,1.0906,337,221,0.0414 diff --git a/Ezhovnd/path_lengths.png b/Ezhovnd/path_lengths.png new file mode 100644 index 0000000..4ffd6db Binary files /dev/null and b/Ezhovnd/path_lengths.png differ