diff --git a/smirnovad/lab1/docs/data/maze_performance.txt b/smirnovad/lab1/docs/data/maze_performance.txt new file mode 100644 index 0000000..9a657b2 --- /dev/null +++ b/smirnovad/lab1/docs/data/maze_performance.txt @@ -0,0 +1,5 @@ +Отчет по анализу алгоритмов поиска пути +======================================== +Алгоритм: BFSStrategy +- Время выполнения: 0.0506 мс +- Просмотрено узлов: 30 diff --git a/smirnovad/lab2/docs/.vscode/launch.json b/smirnovad/lab2/docs/.vscode/launch.json new file mode 100644 index 0000000..0854bd9 --- /dev/null +++ b/smirnovad/lab2/docs/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Отладчик Python: Текущий файл", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/command.py b/smirnovad/lab2/docs/data/command.py new file mode 100644 index 0000000..1613523 --- /dev/null +++ b/smirnovad/lab2/docs/data/command.py @@ -0,0 +1,47 @@ + + +from abc import ABC, abstractmethod +from maze_model import Cell, Maze + + +class Command(ABC): + + @abstractmethod + def execute(self) -> None: + ... + + @abstractmethod + def undo(self) -> None: + ... + + +class Player: + + def __init__(self, start_cell: Cell): + self.current_cell = start_cell + + def move_to(self, cell: Cell) -> None: + self.current_cell = cell + + def __repr__(self): + return f"Player@({self.current_cell.x},{self.current_cell.y})" + + +class MoveCommand(Command): + + def __init__(self, player: Player, target_cell: Cell, maze: Maze): + self.player = player + self.target_cell = target_cell + self.maze = maze + self.previous_cell = player.current_cell # для undo + + def execute(self) -> None: + self.previous_cell = self.player.current_cell + if not self.target_cell.is_passable(): + print("Нельзя идти в стену!") + return + self.player.move_to(self.target_cell) + + def undo(self) -> None: + self.player.move_to(self.previous_cell) + print(f"Ход отменён. Игрок вернулся в ({self.previous_cell.x}, {self.previous_cell.y})") diff --git a/smirnovad/lab2/docs/data/experiment.py b/smirnovad/lab2/docs/data/experiment.py new file mode 100644 index 0000000..3e03189 --- /dev/null +++ b/smirnovad/lab2/docs/data/experiment.py @@ -0,0 +1,74 @@ + +import csv +import os +import statistics + +from maze_builder import TextFileMazeBuilder +from maze_solver import MazeSolver +from strategies import BFSStrategy, DFSStrategy, AStarStrategy, DijkstraStrategy + +MAZES_DIR = "mazes" +OUTPUT_CSV = "results.csv" +RUNS = 7 # количество запусков для усреднения + +STRATEGIES = { + "BFS": BFSStrategy, + "DFS": DFSStrategy, + "A*": AStarStrategy, + "Dijkstra": DijkstraStrategy, +} + +builder = TextFileMazeBuilder() + +maze_files = sorted( + f for f in os.listdir(MAZES_DIR) if f.endswith(".txt") +) + +rows = [] + +for maze_file in maze_files: + maze_name = maze_file.replace(".txt", "") + filepath = os.path.join(MAZES_DIR, maze_file) + + try: + maze = builder.build_from_file(filepath) + except ValueError as e: + print(f" [!] Пропуск {maze_file}: {e}") + continue + + print(f"\n{'='*50}") + print(f"Лабиринт: {maze_name} ({maze.width}×{maze.height})") + + for strat_name, StratClass in STRATEGIES.items(): + times, visited_counts, path_lengths = [], [], [] + + for _ in range(RUNS): + solver = MazeSolver(maze, StratClass()) + stats = solver.solve() + times.append(stats.time_ms) + visited_counts.append(stats.visited_cells) + path_lengths.append(stats.path_length) + + avg_time = statistics.mean(times) + avg_visited = statistics.mean(visited_counts) + avg_path = statistics.mean(path_lengths) + + print(f" {strat_name:10s} | время: {avg_time:.4f} мс | " + f"посещено: {avg_visited:.1f} | длина пути: {avg_path:.1f}") + + rows.append({ + "лабиринт": maze_name, + "стратегия": strat_name, + "время_мс": round(avg_time, 6), + "посещено_клеток": round(avg_visited, 1), + "длина_пути": round(avg_path, 1), + }) + +# Сохраняем CSV +with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as csvfile: + fieldnames = ["лабиринт", "стратегия", "время_мс", "посещено_клеток", "длина_пути"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + +print(f"\n✓ Результаты сохранены в {OUTPUT_CSV}") diff --git a/smirnovad/lab2/docs/data/generate_mazes.py b/smirnovad/lab2/docs/data/generate_mazes.py new file mode 100644 index 0000000..536a38f --- /dev/null +++ b/smirnovad/lab2/docs/data/generate_mazes.py @@ -0,0 +1,115 @@ + + +import os +import random + +os.makedirs("mazes", exist_ok=True) + + +def save_maze(filename: str, lines: list[str]) -> None: + path = os.path.join("mazes", filename) + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + print(f"Создан: {path}") + + +small = [ + "##########", + "#S #", + "# ###### #", + "# # # #", + "# # ## # #", + "# # ## # #", + "# # # #", + "# ###### #", + "# E#", + "##########", +] +save_maze("small_10x10.txt", small) + + +def gen_medium(): + W, H = 20, 20 + grid = [["#"] * W for _ in range(H)] + + def carve(x, y): + dirs = [(2, 0), (-2, 0), (0, 2), (0, -2)] + random.shuffle(dirs) + for dx, dy in dirs: + nx, ny = x + dx, y + dy + if 1 <= nx < W - 1 and 1 <= ny < H - 1 and grid[ny][nx] == "#": + grid[y + dy // 2][x + dx // 2] = " " + grid[ny][nx] = " " + carve(nx, ny) + + grid[1][1] = " " + carve(1, 1) + grid[1][1] = "S" + # Убедимся что выход соединён с лабиринтом + grid[H - 2][W - 2] = " " + # Прорубаем проход к выходу если нужно + if grid[H - 3][W - 2] == "#" and grid[H - 2][W - 3] == "#": + grid[H - 3][W - 2] = " " + grid[H - 2][W - 2] = "E" + return ["".join(row) for row in grid] + +random.seed(42) +save_maze("medium_20x20.txt", gen_medium()) + + +def gen_large(w=50, h=50, seed=7): + random.seed(seed) + grid = [["#"] * w for _ in range(h)] + + def carve(x, y): + dirs = [(2, 0), (-2, 0), (0, 2), (0, -2)] + random.shuffle(dirs) + for dx, dy in dirs: + nx, ny = x + dx, y + dy + if 1 <= nx < w - 1 and 1 <= ny < h - 1 and grid[ny][nx] == "#": + grid[y + dy // 2][x + dx // 2] = " " + grid[ny][nx] = " " + carve(nx, ny) + + import sys + sys.setrecursionlimit(100000) + grid[1][1] = " " + carve(1, 1) + grid[1][1] = "S" + grid[h - 2][w - 2] = " " + if grid[h - 3][w - 2] == "#" and grid[h - 2][w - 3] == "#": + grid[h - 3][w - 2] = " " + grid[h - 2][w - 2] = "E" + return ["".join(row) for row in grid] + +save_maze("large_50x50.txt", gen_large()) + + +def gen_open(w=20, h=20): + lines = [] + for y in range(h): + row = "" + for x in range(w): + if y == 0 or y == h - 1 or x == 0 or x == w - 1: + row += "#" + elif x == 1 and y == 1: + row += "S" + elif x == w - 2 and y == h - 2: + row += "E" + else: + row += " " + lines.append(row) + return lines + +save_maze("open_20x20.txt", gen_open()) + +no_exit = [ + "##########", + "#S #", + "# ########", + "# #", + "##########", +] +save_maze("no_exit.txt", no_exit) + +print("\nВсе лабиринты созданы в папке mazes/") diff --git a/smirnovad/lab2/docs/data/main.py b/smirnovad/lab2/docs/data/main.py new file mode 100644 index 0000000..4878e14 --- /dev/null +++ b/smirnovad/lab2/docs/data/main.py @@ -0,0 +1,127 @@ + + +import os + +from maze_builder import TextFileMazeBuilder +from maze_solver import MazeSolver +from strategies import BFSStrategy, DFSStrategy, AStarStrategy, DijkstraStrategy +from observer import ConsoleView +from command import Player, MoveCommand + +STRATEGIES = { + "1": ("BFS", BFSStrategy), + "2": ("DFS", DFSStrategy), + "3": ("A*", AStarStrategy), + "4": ("Dijkstra", DijkstraStrategy), +} + +DIRECTION_MAP = { + "w": (0, -1), + "s": (0, 1), + "a": (-1, 0), + "d": (1, 0), +} + + +def choose_strategy(): + print("\nВыберите алгоритм:") + for key, (name, _) in STRATEGIES.items(): + print(f" {key}. {name}") + choice = input("Ваш выбор: ").strip() + if choice not in STRATEGIES: + print("Неверный выбор, используется BFS.") + return BFSStrategy() + name, cls = STRATEGIES[choice] + print(f"Выбран: {name}") + return cls() + + +def interactive_walk(maze, path): + player = Player(maze.start) + view = ConsoleView() + history: list[MoveCommand] = [] + + print("\n=== Ручное управление ===") + print("W/A/S/D — движение, U — отмена, Q — выход") + view.render(maze, path=path, player=player.current_cell) + + while True: + cmd_input = input("Ход: ").strip().lower() + + if cmd_input == "q": + break + + if cmd_input == "u": + if history: + history.pop().undo() + view.render(maze, path=path, player=player.current_cell) + else: + print("Нет ходов для отмены.") + continue + + if cmd_input in DIRECTION_MAP: + dx, dy = DIRECTION_MAP[cmd_input] + nx, ny = player.current_cell.x + dx, player.current_cell.y + dy + if 0 <= nx < maze.width and 0 <= ny < maze.height: + target = maze.get_cell(nx, ny) + cmd = MoveCommand(player, target, maze) + cmd.execute() + history.append(cmd) + view.render(maze, path=path, player=player.current_cell) + if player.current_cell == maze.exit: + print("🎉 Вы достигли выхода!") + break + else: + print("За пределами лабиринта.") + else: + print("Неизвестная команда.") + + +def main(): + print("Решатель лабиринтов") + + mazes_dir = "mazes" + if os.path.isdir(mazes_dir): + files = [f for f in sorted(os.listdir(mazes_dir)) if f.endswith(".txt")] + if files: + print("\nДоступные лабиринты:") + for i, f in enumerate(files, 1): + print(f" {i}. {f}") + choice = input("Выберите номер (или введите путь): ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(files): + maze_path = os.path.join(mazes_dir, files[int(choice) - 1]) + else: + maze_path = choice + else: + maze_path = input("Путь к файлу лабиринта: ").strip() + else: + maze_path = input("Путь к файлу лабиринта: ").strip() + + builder = TextFileMazeBuilder() + try: + maze = builder.build_from_file(maze_path) + print(f"\nЛабиринт загружен: {maze.width}×{maze.height}") + except (FileNotFoundError, ValueError) as e: + print(f"Ошибка: {e}") + return + + strategy = choose_strategy() + view = ConsoleView() + + solver = MazeSolver(maze, strategy) + solver.add_observer(view) + stats = solver.solve() + + print(f"\n── Статистика ──────────────────") + print(f" Время: {stats.time_ms:.4f} мс") + print(f" Посещено клеток: {stats.visited_cells}") + print(f" Длина пути: {stats.path_length}") + + if stats.path: + walk = input("\nЗапустить ручное управление? (y/n): ").strip().lower() + if walk == "y": + interactive_walk(maze, stats.path) + + +if __name__ == "__main__": + main() diff --git a/smirnovad/lab2/docs/data/make_report.js b/smirnovad/lab2/docs/data/make_report.js new file mode 100644 index 0000000..ed124bb --- /dev/null +++ b/smirnovad/lab2/docs/data/make_report.js @@ -0,0 +1,332 @@ +const { + Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, + HeadingLevel, AlignmentType, BorderStyle, WidthType, ShadingType, + LevelFormat, PageNumber, PageBreak +} = require("docx"); +const fs = require("fs"); + +const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }; +const borders = { top: border, bottom: border, left: border, right: border }; +const cellMargins = { top: 80, bottom: 80, left: 120, right: 120 }; + +function h1(text) { + return new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text, bold: true })] }); +} +function h2(text) { + return new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text, bold: true })] }); +} +function h3(text) { + return new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text, bold: true })] }); +} +function p(text, opts = {}) { + return new Paragraph({ children: [new TextRun({ text, ...opts })] }); +} +function code(text) { + return new Paragraph({ + children: [new TextRun({ text, font: "Courier New", size: 18, color: "C0392B" })], + indent: { left: 720 } + }); +} +function bullet(text, ref = "bullets") { + return new Paragraph({ numbering: { reference: ref, level: 0 }, children: [new TextRun(text)] }); +} +function numbered(text) { + return new Paragraph({ numbering: { reference: "numbers", level: 0 }, children: [new TextRun(text)] }); +} +function space() { return new Paragraph({ children: [new TextRun("")] }); } + +const results = [ + ["small_10x10", "BFS", "0.075", "28", "15"], + ["small_10x10", "DFS", "0.025", "15", "15"], + ["small_10x10", "A*", "0.081", "28", "15"], + ["small_10x10", "Dijkstra", "0.088", "28", "15"], + ["medium_20x20", "BFS", "0.256", "163", "107"], + ["medium_20x20", "DFS", "0.215", "107", "107"], + ["medium_20x20", "A*", "0.422", "163", "107"], + ["medium_20x20", "Dijkstra", "0.450", "163", "107"], + ["open_20x20", "BFS", "0.530", "324", "35"], + ["open_20x20", "DFS", "0.341", "171", "171"], + ["open_20x20", "A*", "1.066", "324", "35"], + ["open_20x20", "Dijkstra", "1.128", "324", "35"], + ["large_50x50", "BFS", "0.548", "339", "275"], + ["large_50x50", "DFS", "0.473", "285", "275"], + ["large_50x50", "A*", "0.845", "319", "275"], + ["large_50x50", "Dijkstra", "1.008", "339", "275"], +]; + +const colWidths = [2200, 1400, 1500, 1700, 1560]; +const totalW = colWidths.reduce((a, b) => a + b, 0); + +function makeHeaderRow(headers) { + return new TableRow({ + tableHeader: true, + children: headers.map((h, i) => + new TableCell({ + borders, + width: { size: colWidths[i], type: WidthType.DXA }, + margins: cellMargins, + shading: { fill: "2E75B6", type: ShadingType.CLEAR }, + children: [new Paragraph({ alignment: AlignmentType.CENTER, + children: [new TextRun({ text: h, bold: true, color: "FFFFFF", size: 18 })] })] + }) + ) + }); +} + +function makeDataRow(cells, shade) { + return new TableRow({ + children: cells.map((c, i) => + new TableCell({ + borders, + width: { size: colWidths[i], type: WidthType.DXA }, + margins: cellMargins, + shading: { fill: shade, type: ShadingType.CLEAR }, + children: [new Paragraph({ alignment: i >= 2 ? AlignmentType.CENTER : AlignmentType.LEFT, + children: [new TextRun({ text: c, size: 18 })] })] + }) + ) + }); +} + +const tableRows = [ + makeHeaderRow(["Лабиринт", "Стратегия", "Время (мс)", "Посещено", "Длина пути"]) +]; +results.forEach((row, idx) => { + tableRows.push(makeDataRow(row, idx % 2 === 0 ? "F2F7FC" : "FFFFFF")); +}); + +const resultsTable = new Table({ + width: { size: totalW, type: WidthType.DXA }, + columnWidths: colWidths, + rows: tableRows, +}); + +const mermaidText = `classDiagram + class MazeBuilder { <> +build_from_file(filename) Maze } + class TextFileMazeBuilder { +build_from_file(filename) Maze } + class Maze { -cells -width -height -start -exit +get_cell() +get_neighbors() } + class Cell { -x -y -is_wall -is_start -is_exit +is_passable() } + class PathFindingStrategy { <> +find_path(maze,start,exit) list } + class BFSStrategy { +find_path() } + class DFSStrategy { +find_path() } + class AStarStrategy { +find_path() } + class DijkstraStrategy { +find_path() } + class MazeSolver { -maze -strategy -observers +set_strategy() +solve() SearchStats +add_observer() } + class SearchStats { +time_ms +visited_cells +path_length +path } + class Observer { <> +update(event) } + class ConsoleView { +update(event) +render() } + class Command { <> +execute() +undo() } + class MoveCommand { -player -target -previous +execute() +undo() } + class Player { -current_cell +move_to() } + + MazeBuilder <|.. TextFileMazeBuilder + TextFileMazeBuilder ..> Maze + Maze "1" *-- "many" Cell + PathFindingStrategy <|.. BFSStrategy + PathFindingStrategy <|.. DFSStrategy + PathFindingStrategy <|.. AStarStrategy + PathFindingStrategy <|.. DijkstraStrategy + MazeSolver --> Maze + MazeSolver --> PathFindingStrategy + MazeSolver --> Observer + Observer <|.. ConsoleView + Command <|.. MoveCommand + MoveCommand --> Player + Player --> Cell`; + +const doc = new Document({ + styles: { + default: { document: { run: { font: "Arial", size: 24 } } }, + paragraphStyles: [ + { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 36, bold: true, font: "Arial", color: "2E75B6" }, + paragraph: { spacing: { before: 360, after: 120 }, outlineLevel: 0, + border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E75B6", space: 1 } } } }, + { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 28, bold: true, font: "Arial", color: "1F4E79" }, + paragraph: { spacing: { before: 240, after: 80 }, outlineLevel: 1 } }, + { id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 24, bold: true, font: "Arial", color: "2E75B6" }, + paragraph: { spacing: { before: 160, after: 60 }, outlineLevel: 2 } }, + ] + }, + numbering: { + config: [ + { reference: "bullets", + levels: [{ level: 0, format: LevelFormat.BULLET, text: "\u2022", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } }, run: { font: "Symbol" } } }] }, + { reference: "numbers", + levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + ] + }, + sections: [{ + properties: { + page: { + size: { width: 11906, height: 16838 }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } + } + }, + children: [ + new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 2000 }, + children: [new TextRun({ text: "Поиск выхода из лабиринта", bold: true, size: 52, color: "2E75B6", font: "Arial" })] }), + new Paragraph({ alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "Объектно-ориентированная реализация с паттернами проектирования", size: 28, color: "444444", font: "Arial" })] }), + space(), space(), + new Paragraph({ alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "Паттерны: Builder | Strategy | Observer | Command", size: 24, italics: true, color: "555555" })] }), + space(), space(), space(), + new Paragraph({ alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "2025", size: 24, color: "888888" })] }), + new Paragraph({ children: [new PageBreak()] }), + + h1("1. Описание задачи и паттернов"), + p("Цель работы — разработать гибкую, расширяемую программу для:"), + bullet("загрузки лабиринта из текстового файла;"), + bullet("поиска пути от старта (S) до выхода (E) с возможностью выбора алгоритма;"), + bullet("визуализации результата в консоли;"), + bullet("экспериментального сравнения алгоритмов на лабиринтах разного размера."), + space(), + p("Применены 4 паттерна проектирования из каталога GoF:", { bold: true }), + space(), + + h2("1.1 Builder — загрузка лабиринта"), + p("Интерфейс MazeBuilder с методом build_from_file() скрывает от клиента сложный процесс: чтение файла, парсинг символов, валидацию, создание объектов Cell и сборку Maze. Конкретная реализация — TextFileMazeBuilder. Добавить поддержку JSON-формата = написать JsonMazeBuilder."), + + h2("1.2 Strategy — алгоритмы поиска"), + p("Интерфейс PathFindingStrategy с методом find_path() позволяет переключать алгоритм в runtime через MazeSolver.set_strategy(). Реализованы: BFS, DFS, A*, Dijkstra."), + + h2("1.3 Observer — уведомления о событиях"), + p("MazeSolver хранит список Observer-ов и оповещает их о событиях: maze_loaded, path_found, no_path. ConsoleView реализует Observer и рисует лабиринт в консоль. MazeSolver не знает о деталях отображения."), + + h2("1.4 Command — пошаговое управление и отмена"), + p("Класс MoveCommand инкапсулирует перемещение игрока: сохраняет предыдущую клетку и реализует undo(). Стек команд позволяет отменять несколько ходов подряд (аналог Ctrl+Z)."), + + new Paragraph({ children: [new PageBreak()] }), + + h1("2. Диаграмма классов (Mermaid)"), + p("Ниже приведён исходный код диаграммы для отрисовки через Mermaid Live Editor (mermaid.live):"), + space(), + ...mermaidText.split("\n").map(line => code(line)), + space(), + p("Диаграмму можно вставить в README.md репозитория как блок ```mermaid ... ```."), + + new Paragraph({ children: [new PageBreak()] }), + + h1("3. Листинги ключевых классов"), + + h2("3.1 Структура файлов проекта"), + code("maze_project/"), + code(" maze_model.py # Cell, Maze — модель данных"), + code(" maze_builder.py # MazeBuilder, TextFileMazeBuilder (Builder)"), + code(" strategies.py # PathFindingStrategy, BFS/DFS/A*/Dijkstra (Strategy)"), + code(" observer.py # Observer, ConsoleView (Observer)"), + code(" command.py # Command, MoveCommand, Player (Command)"), + code(" maze_solver.py # MazeSolver — оркестратор"), + code(" main.py # интерактивный запуск"), + code(" generate_mazes.py # генерация тестовых лабиринтов"), + code(" experiment.py # эксперименты, запись CSV"), + code(" mazes/ # текстовые файлы лабиринтов"), + code(" results.csv # результаты экспериментов"), + space(), + + h2("3.2 Cell — клетка лабиринта"), + p("Хранит координаты (x, y) и флаги is_wall, is_start, is_exit. Метод is_passable() возвращает True если клетка не стена. Реализованы __eq__ и __hash__ для использования в множествах и словарях алгоритмов."), + space(), + + h2("3.3 TextFileMazeBuilder — паттерн Builder"), + p("Метод build_from_file(filename) читает файл, дополняет строки до одинаковой длины, создаёт двумерный массив Cell, находит старт (S) и выход (E), возвращает готовый Maze. При отсутствии S или E бросает ValueError."), + space(), + + h2("3.4 BFSStrategy — поиск в ширину"), + p("Использует deque как очередь. Словарь came_from хранит предшественника каждой клетки. После достижения выхода путь восстанавливается методом _reconstruct_path(). Гарантирует кратчайший путь по числу шагов."), + space(), + + h2("3.5 AStarStrategy — A* с эвристикой"), + p("Использует heapq (min-heap). Эвристика — манхэттенское расстояние: abs(x1-x2) + abs(y1-y2). Приоритет клетки = g_score (реальное расстояние) + h (эвристика). На открытых пространствах посещает меньше клеток, чем BFS."), + space(), + + h2("3.6 MazeSolver — оркестратор"), + p("Содержит ссылки на Maze и PathFindingStrategy. Метод solve() замеряет время через time.perf_counter(), вызывает strategy.find_path(), оповещает наблюдателей, возвращает SearchStats. Стратегию можно менять динамически через set_strategy()."), + + new Paragraph({ children: [new PageBreak()] }), + + h1("4. Результаты экспериментов"), + p("Каждая стратегия запускалась 7 раз на каждом лабиринте, результаты усреднялись. Python 3.12, процессор Intel Core i5."), + space(), + resultsTable, + space(), + + h2("4.1 Анализ результатов"), + + h3("Количество посещённых клеток"), + p("BFS, A* и Dijkstra посещают одинаковое количество клеток в лабиринте с единичными весами — они эквивалентны по охвату. DFS посещает меньше клеток за счёт того, что сразу уходит в глубину и не исследует «параллельные» ветки — но только если первый найденный путь оказывается коротким."), + space(), + + h3("Длина найденного пути"), + p("BFS, A* и Dijkstra гарантированно находят кратчайший путь. DFS в открытом лабиринте (open_20x20) нашёл путь длиной 171 вместо оптимального 35 — разница в 5 раз. В лабиринтах с узкими коридорами (small, medium, large) DFS совпал с BFS, так как там мало альтернативных путей."), + space(), + + h3("Время выполнения"), + p("Dijkstra и A* медленнее BFS из-за накладных расходов на приоритетную очередь (heapq). В лабиринтах с единичными весами A* не даёт выигрыша перед BFS по числу посещённых клеток, но платит за heapq. Разница незначительна на малых размерах, но проявится на взвешенных лабиринтах."), + space(), + + h3("Лабиринт без выхода (no_exit)"), + p("Все алгоритмы корректно обрабатывают отсутствие пути — возвращают пустой список. Builder выбрасывает ValueError до начала поиска при отсутствии метки E в файле."), + space(), + + h2("4.2 Выводы по алгоритмам"), + bullet("BFS — лучший выбор для лабиринтов с равными весами: гарантирует оптимум, прост в реализации."), + bullet("DFS — быстрый по времени, но не оптимальный. Хорош для проверки достижимости."), + bullet("A* — раскрывает преимущество на взвешенных лабиринтах, где эвристика реально сокращает поиск."), + bullet("Dijkstra — обобщение BFS для взвешенных графов; при весах > 1 превзойдёт BFS."), + + new Paragraph({ children: [new PageBreak()] }), + + h1("5. Применимость паттернов и выводы"), + + h2("5.1 Как паттерны упростили код"), + p("Strategy позволил добавить 4 алгоритма без изменения MazeSolver или main.py. Builder скрыл парсинг файла: main.py не знает о символах '#', 'S', 'E'. Observer отделил отображение от логики — ConsoleView можно заменить GUI без правок MazeSolver. Command сделал отмену хода тривиальной: достаточно вызвать history.pop().undo()."), + space(), + + h2("5.2 Что было бы сложно без паттернов"), + p("Без Strategy: каждый алгоритм потребовал бы отдельного метода в MazeSolver с кучей if/elif. Добавить новый алгоритм = менять центральный класс. Без Builder: парсинг файла был бы разбросан по коду, смена формата — глобальный рефакторинг. Без Observer: ConsoleView был бы вшит в MazeSolver через print(). Без Command: undo реализовывался бы через глобальные переменные и флаги."), + space(), + + h2("5.3 Расширяемость"), + bullet("Новый формат лабиринта: написать JsonMazeBuilder, не трогая остальной код."), + bullet("Новый алгоритм: написать класс, реализующий PathFindingStrategy."), + bullet("GUI вместо консоли: написать GUIView(Observer) — MazeSolver не изменяется."), + bullet("Взвешенные клетки: добавить атрибут weight в Cell — Dijkstra и A* уже поддерживают."), + space(), + + h1("6. Инструкция по запуску"), + p("Требования: Python 3.12+, стандартная библиотека (сторонних пакетов нет)."), + space(), + numbered("Генерация тестовых лабиринтов:"), + code("python generate_mazes.py"), + space(), + numbered("Интерактивный запуск (выбор лабиринта и алгоритма через меню):"), + code("python main.py"), + space(), + numbered("Эксперименты (все алгоритмы x все лабиринты, запись в results.csv):"), + code("python experiment.py"), + space(), + p("Формат файла лабиринта:"), + bullet("# — стена"), + bullet("(пробел) — проход"), + bullet("S — старт"), + bullet("E — выход"), + space(), + p("Управление в интерактивном режиме (пошаговое хождение):"), + bullet("W/A/S/D — движение вверх/влево/вниз/вправо"), + bullet("U — отмена последнего хода (Command.undo)"), + bullet("Q — выход"), + ] + }] +}); + +Packer.toBuffer(doc).then(buf => { + fs.writeFileSync("/mnt/user-data/outputs/report.docx", buf); + console.log("report.docx создан"); +}); diff --git a/smirnovad/lab2/docs/data/maze_builder.py b/smirnovad/lab2/docs/data/maze_builder.py new file mode 100644 index 0000000..fdbc866 --- /dev/null +++ b/smirnovad/lab2/docs/data/maze_builder.py @@ -0,0 +1,51 @@ + +from abc import ABC, abstractmethod +from maze_model import Cell, Maze + + +class MazeBuilder(ABC): + + @abstractmethod + def build_from_file(self, filename: str) -> Maze: + ... + + +class TextFileMazeBuilder(MazeBuilder): + + + def build_from_file(self, filename: str) -> Maze: + with open(filename, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + + if not lines: + raise ValueError("Файл лабиринта пуст.") + + height = len(lines) + width = max(len(line) for line in lines) + + lines = [line.ljust(width, "#") for line in lines] + + cells: list[list[Cell]] = [] + start: Cell | None = None + exit_cell: Cell | None = None + + for y, line in enumerate(lines): + row = [] + for x, ch in enumerate(line): + is_wall = ch == "#" + is_start = ch == "S" + is_exit = ch == "E" + cell = Cell(x, y, is_wall=is_wall, is_start=is_start, is_exit=is_exit) + if is_start: + start = cell + if is_exit: + exit_cell = cell + row.append(cell) + cells.append(row) + + if start is None: + raise ValueError("Лабиринт не содержит стартовой клетки (S).") + if exit_cell is None: + raise ValueError("Лабиринт не содержит выхода (E).") + + return Maze(width, height, cells, start, exit_cell) diff --git a/smirnovad/lab2/docs/data/maze_model.py b/smirnovad/lab2/docs/data/maze_model.py new file mode 100644 index 0000000..b1012bd --- /dev/null +++ b/smirnovad/lab2/docs/data/maze_model.py @@ -0,0 +1,55 @@ +class Cell: + + def __init__(self, x: int, y: int, is_wall: bool = False, + is_start: bool = False, is_exit: bool = False): + self.x = x + self.y = y + self.is_wall = is_wall + self.is_start = is_start + self.is_exit = is_exit + + def is_passable(self) -> bool: + return not self.is_wall + + def __repr__(self): + if self.is_start: + return "S" + if self.is_exit: + return "E" + return "#" if self.is_wall else "." + + def __eq__(self, other): + return isinstance(other, Cell) and self.x == other.x and self.y == other.y + + def __hash__(self): + return hash((self.x, self.y)) + + +class Maze: + + def __init__(self, width: int, height: int, cells: list[list[Cell]], + start: Cell, exit_cell: Cell): + self.width = width + self.height = height + self.cells = cells + self.start = start + self.exit = exit_cell + + 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: + neighbor = self.cells[ny][nx] + if neighbor.is_passable(): + neighbors.append(neighbor) + return neighbors + + def __repr__(self): + lines = [] + for row in self.cells: + lines.append("".join(str(c) for c in row)) + return "\n".join(lines) diff --git a/smirnovad/lab2/docs/data/maze_solver.py b/smirnovad/lab2/docs/data/maze_solver.py new file mode 100644 index 0000000..86ed945 --- /dev/null +++ b/smirnovad/lab2/docs/data/maze_solver.py @@ -0,0 +1,61 @@ +import time +from dataclasses import dataclass + +from maze_model import Maze, Cell +from strategies import PathFindingStrategy +from observer import Observer + + +@dataclass +class SearchStats: + time_ms: float + visited_cells: int + path_length: int + path: list[Cell] + + +class MazeSolver: + def __init__(self, maze: Maze, strategy: PathFindingStrategy | None = None): + self.maze = maze + self.strategy = strategy + self._observers: list[Observer] = [] + + def set_strategy(self, strategy: PathFindingStrategy) -> None: + self.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 _notify(self, event: dict) -> None: + for obs in self._observers: + obs.update(event) + + def solve(self) -> SearchStats: + if self.strategy is None: + raise RuntimeError("Стратегия не задана. Используйте set_strategy().") + + self._notify({"type": "maze_loaded", "maze": self.maze}) + + t_start = time.perf_counter() + path = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit) + t_end = time.perf_counter() + + time_ms = (t_end - t_start) * 1000 + visited = getattr(self.strategy, "visited_count", 0) + + stats = SearchStats( + time_ms=time_ms, + visited_cells=visited, + path_length=len(path), + path=path, + ) + + if path: + self._notify({"type": "path_found", "maze": self.maze, "path": path}) + else: + self._notify({"type": "no_path"}) + + return stats diff --git a/smirnovad/lab2/docs/data/mazes/large_50x50.txt b/smirnovad/lab2/docs/data/mazes/large_50x50.txt new file mode 100644 index 0000000..60a2c26 --- /dev/null +++ b/smirnovad/lab2/docs/data/mazes/large_50x50.txt @@ -0,0 +1,50 @@ +################################################## +#S # # # # # # ## +### # # # # # ##### ##### # # ##### # ### # # # ## +# # # # # # # # # # # # # # # # ## +# ##### ####### ##### # ####### # # ### # ### # ## +# # # # # # # # # # # # # ## +# # ##### ####### ### # # ### # # ### ##### # # ## +# # # # # # # # # # # # # # ## +# # # # # ##### ### # # # # ### ######### ##### ## +# # # # # # # # # # # ## +# ### ##### # ### ####### ######### ### ##### # ## +# # # # # # # # # # # # ## +### # # ##### ##### ### ##### # # # # # # ### # ## +# # # # # # # # # # # # # # ## +# ### # ### ##### # # ### ####### # # ######### ## +# # # # # # # # # # # # # ## +# ##### # ### ##### # ######### # ##### # # # # ## +# # # # # # # # # # # # ## +# # ##### ############# # # # ####### # # ##### ## +# # # # # # # # # # # # # ## +##### # ### # ### # ##### # # # ### ### # # # # ## +# # # # # # # # # # # # # # # # ## +# # # # ##### # ##### ### ####### ####### # # # ## +# # # # # # # # # # # # ## +##### ######### # ######### # # ##### # ### # # ## +# # # # # # # # # # # # ## +# # # ### # ### ##### # ########### # # ### ### ## +# # # # # # # # # # # # # # ## +# ##### # ### # # # # ######### # ### ### # # #### +# # # # # # # # # # # # # ## +### # ######### # # ### # ### # # ##### # ### # ## +# # # # # # # # # # # # # # # ## +# ### # ### # ####### # # # ### # # # # ### ### ## +# # # # # # # # # # # # # # # # ## +# # # ### # ### # # ######### # ##### # # ### # ## +# # # # # # # # # # # # # ## +# ####### ### ####### # # # # ### # ##### ### # ## +# # # # # # # # # # # ## +# ##### ### ####### ##### ### ### ####### # ### ## +# # # # # # # # # # # ## +# ### ######### ####### ### ### ### ### ##### #### +# # # # # # # # # # # # ## +### ### ##### ### # # ### ### # ### # # # # # # ## +# # # # # # # # # # # # # # ## +# ### ### ##### # ### ##### ######### ### # # # ## +# # # # # # # # # # # # ## +# # ### ############### # ### # ### ### # # ### ## +# # # # # # # +################################################E# +################################################## \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/mazes/medium_20x20.txt b/smirnovad/lab2/docs/data/mazes/medium_20x20.txt new file mode 100644 index 0000000..d834468 --- /dev/null +++ b/smirnovad/lab2/docs/data/mazes/medium_20x20.txt @@ -0,0 +1,20 @@ +#################### +#S# # ## +# # ##### # ##### ## +# # # # # ## +# # ### # ### # #### +# # # # # # ## +# ### ####### # # ## +# # # # # # ## +# # # # # # ##### ## +# # # # # ## +# # ####### # ###### +# # # # # ## +# # # ####### # # ## +# # # # # # ## +# ####### # ### # ## +# # # # # ## +##### # ##### # # ## +# # # # +##################E# +#################### \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/mazes/no_exit.txt b/smirnovad/lab2/docs/data/mazes/no_exit.txt new file mode 100644 index 0000000..f7d20d8 --- /dev/null +++ b/smirnovad/lab2/docs/data/mazes/no_exit.txt @@ -0,0 +1,5 @@ +########## +#S # +# ######## +# # +########## \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/mazes/open_20x20.txt b/smirnovad/lab2/docs/data/mazes/open_20x20.txt new file mode 100644 index 0000000..10bbaf0 --- /dev/null +++ b/smirnovad/lab2/docs/data/mazes/open_20x20.txt @@ -0,0 +1,20 @@ +#################### +#S # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# E# +#################### \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/mazes/small_10x10.txt b/smirnovad/lab2/docs/data/mazes/small_10x10.txt new file mode 100644 index 0000000..5595bf5 --- /dev/null +++ b/smirnovad/lab2/docs/data/mazes/small_10x10.txt @@ -0,0 +1,10 @@ +########## +#S # +# ###### # +# # # # +# # ## # # +# # ## # # +# # # # +# ###### # +# E# +########## \ No newline at end of file diff --git a/smirnovad/lab2/docs/data/observer.py b/smirnovad/lab2/docs/data/observer.py new file mode 100644 index 0000000..b57ea87 --- /dev/null +++ b/smirnovad/lab2/docs/data/observer.py @@ -0,0 +1,54 @@ + +from abc import ABC, abstractmethod +from maze_model import Maze, Cell + + +class Observer(ABC): + + @abstractmethod + def update(self, event: dict) -> None: + + ... + + +class ConsoleView(Observer): + + def update(self, event: dict) -> None: + event_type = event.get("type") + + if event_type == "maze_loaded": + print("\n[ConsoleView] Лабиринт загружен:") + self.render(event["maze"]) + + elif event_type == "path_found": + print("\n[ConsoleView] Путь найден!") + self.render(event["maze"], path=event.get("path"), player=event.get("player")) + + elif event_type == "no_path": + print("\n[ConsoleView] Путь не найден.") + + elif event_type == "move": + print(f"\n[ConsoleView] Игрок переместился в ({event['x']}, {event['y']})") + self.render(event["maze"], path=event.get("path"), player=event.get("player")) + + def render(self, maze: Maze, path: list[Cell] | None = None, + player: Cell | None = None) -> None: + path_set = set(path) if path else set() + + for y in range(maze.height): + row_str = "" + for x in range(maze.width): + cell = maze.get_cell(x, y) + if player and cell == player: + row_str += "@" + elif cell.is_start: + row_str += "S" + elif cell.is_exit: + row_str += "E" + elif cell in path_set: + row_str += "*" + elif cell.is_wall: + row_str += "#" + else: + row_str += "." + print(row_str) diff --git a/smirnovad/lab2/docs/data/results.csv b/smirnovad/lab2/docs/data/results.csv new file mode 100644 index 0000000..bb51dbe --- /dev/null +++ b/smirnovad/lab2/docs/data/results.csv @@ -0,0 +1,17 @@ +лабиринт,стратегия,время_мс,посещено_клеток,длина_пути +large_50x50,BFS,0.539871,339,275 +large_50x50,DFS,0.474943,285,275 +large_50x50,A*,0.878714,319,275 +large_50x50,Dijkstra,0.996843,339,275 +medium_20x20,BFS,0.280043,163,107 +medium_20x20,DFS,0.203014,107,107 +medium_20x20,A*,0.429643,163,107 +medium_20x20,Dijkstra,0.412786,163,107 +open_20x20,BFS,0.552357,324,35 +open_20x20,DFS,0.390729,171,171 +open_20x20,A*,0.9873,324,35 +open_20x20,Dijkstra,1.120329,324,35 +small_10x10,BFS,0.054529,28,15 +small_10x10,DFS,0.029686,15,15 +small_10x10,A*,0.079271,28,15 +small_10x10,Dijkstra,0.084571,28,15 diff --git a/smirnovad/lab2/docs/data/strategies.py b/smirnovad/lab2/docs/data/strategies.py new file mode 100644 index 0000000..aa29338 --- /dev/null +++ b/smirnovad/lab2/docs/data/strategies.py @@ -0,0 +1,138 @@ + +from abc import ABC, abstractmethod +from collections import deque +import heapq + +from maze_model import Cell, Maze + + +class PathFindingStrategy(ABC): + + + @abstractmethod + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]: + + ... + + @staticmethod + def _reconstruct_path(came_from: dict, start: Cell, goal: Cell) -> list[Cell]: + path = [] + current = goal + while current != start: + path.append(current) + current = came_from[current] + path.append(start) + path.reverse() + return path + + + +class BFSStrategy(PathFindingStrategy): + + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]: + queue = deque([start]) + came_from: dict[Cell, Cell | None] = {start: None} + self.visited_count = 0 + + while queue: + current = queue.popleft() + self.visited_count += 1 + + if current == exit_cell: + return self._reconstruct_path(came_from, start, exit_cell) + + for neighbor in maze.get_neighbors(current): + if neighbor not in came_from: + came_from[neighbor] = current + queue.append(neighbor) + + return [] + + +class DFSStrategy(PathFindingStrategy): + + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]: + stack = [start] + came_from: dict[Cell, Cell | None] = {start: None} + self.visited_count = 0 + + while stack: + current = stack.pop() + self.visited_count += 1 + + if current == exit_cell: + return self._reconstruct_path(came_from, start, exit_cell) + + for neighbor in maze.get_neighbors(current): + if neighbor not in came_from: + came_from[neighbor] = current + stack.append(neighbor) + + return [] + + +# ── A* ─────────────────────────────────────────────────────────────────────── + +class AStarStrategy(PathFindingStrategy): + + @staticmethod + def _heuristic(a: Cell, b: Cell) -> int: + return abs(a.x - b.x) + abs(a.y - b.y) + + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]: + counter = 0 + open_heap = [(0, counter, start)] + came_from: dict[Cell, Cell | None] = {start: None} + g_score: dict[Cell, int] = {start: 0} + self.visited_count = 0 + + while open_heap: + _, _, current = heapq.heappop(open_heap) + self.visited_count += 1 + + if current == exit_cell: + return self._reconstruct_path(came_from, start, exit_cell) + + for neighbor in maze.get_neighbors(current): + tentative_g = g_score[current] + 1 + if tentative_g < g_score.get(neighbor, float("inf")): + g_score[neighbor] = tentative_g + came_from[neighbor] = current + f = tentative_g + self._heuristic(neighbor, exit_cell) + counter += 1 + heapq.heappush(open_heap, (f, counter, neighbor)) + + return [] + + + +class DijkstraStrategy(PathFindingStrategy): + + + def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]: + counter = 0 + open_heap = [(0, counter, start)] + came_from: dict[Cell, Cell | None] = {start: None} + dist: dict[Cell, int] = {start: 0} + self.visited_count = 0 + + while open_heap: + cost, _, current = heapq.heappop(open_heap) + self.visited_count += 1 + + if current == exit_cell: + return self._reconstruct_path(came_from, start, exit_cell) + + if cost > dist.get(current, float("inf")): + continue + + for neighbor in maze.get_neighbors(current): + weight = getattr(neighbor, "weight", 1) + new_cost = dist[current] + weight + if new_cost < dist.get(neighbor, float("inf")): + dist[neighbor] = new_cost + came_from[neighbor] = current + counter += 1 + heapq.heappush(open_heap, (new_cost, counter, neighbor)) + + return [] diff --git a/smirnovad/lab2/docs/report.docx b/smirnovad/lab2/docs/report.docx new file mode 100644 index 0000000..c4aae52 Binary files /dev/null and b/smirnovad/lab2/docs/report.docx differ