""" Лабораторная работа: Применение паттернов проектирования Этапы 1-6: Модель лабиринта, Builder, Strategy, MazeSolver, Observer/Command, эксперименты """ import time import csv import random from collections import deque from typing import List, Tuple, Dict, Set, Optional import heapq from dataclasses import dataclass from abc import ABC, abstractmethod # ============================================================ # Этап 1. Модель лабиринта # ============================================================ class Cell: """Клетка лабиринта.""" def __init__(self, x: int, y: int, is_wall: bool = False, weight: int = 1): self.x = x self.y = y self.is_wall = is_wall self.is_start = False self.is_exit = False self.weight = weight # для взвешенных лабиринтов def is_passable(self) -> bool: return not self.is_wall 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)) def __repr__(self): return f"Cell({self.x},{self.y})" class Maze: """Лабиринт, содержащий сетку клеток.""" def __init__(self, width: int, height: int): self.width = width self.height = height self.cells = [[Cell(x, y) for x in range(width)] for y in range(height)] self.start_cell = None self.exit_cell = None def get_cell(self, x: int, y: int) -> Cell: if 0 <= x < self.width and 0 <= y < self.height: return self.cells[y][x] raise IndexError("Координаты вне границ лабиринта") 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: n = self.cells[ny][nx] if n.is_passable(): neighbors.append(n) return neighbors def set_start(self, x: int, y: int): cell = self.get_cell(x, y) cell.is_start = True self.start_cell = cell def set_exit(self, x: int, y: int): cell = self.get_cell(x, y) cell.is_exit = True self.exit_cell = cell def copy(self): """Создаёт глубокую копию лабиринта (для взвешенных вариантов).""" new_maze = Maze(self.width, self.height) for y in range(self.height): for x in range(self.width): orig = self.cells[y][x] new_maze.cells[y][x] = Cell(x, y, orig.is_wall, orig.weight) if orig.is_start: new_maze.set_start(x, y) if orig.is_exit: new_maze.set_exit(x, y) return new_maze # ============================================================ # Этап 2. Builder для загрузки из текстового файла # ============================================================ class MazeBuilder(ABC): @abstractmethod def build_from_file(self, filename: str) -> Maze: pass class TextFileMazeBuilder(MazeBuilder): """Строитель лабиринта из текстового файла.""" def build_from_file(self, filename: str) -> Maze: with open(filename, 'r', encoding='utf-8') as f: lines = [line.rstrip('\n') for line in f] if not lines: raise ValueError("Файл пуст") height = len(lines) width = max(len(line) for line in lines) grid = [] for y, line in enumerate(lines): row = [] for x in range(width): ch = line[x] if x < len(line) else ' ' row.append(ch) grid.append(row) maze = Maze(width, height) start_found = exit_found = False for y in range(height): for x in range(width): ch = grid[y][x] cell = maze.get_cell(x, y) if ch == '#': cell.is_wall = True elif ch == 'S': if start_found: raise ValueError("Обнаружено несколько стартовых клеток 'S'") cell.is_start = True maze.start_cell = cell start_found = True elif ch == 'E': if exit_found: raise ValueError("Обнаружено несколько выходных клеток 'E'") cell.is_exit = True maze.exit_cell = cell exit_found = True elif ch != ' ': raise ValueError(f"Недопустимый символ '{ch}' в позиции ({x},{y})") if not start_found: raise ValueError("Отсутствует стартовая клетка 'S'") if not exit_found: raise ValueError("Отсутствует выходная клетка 'E'") return maze # ============================================================ # Этап 3. Стратегии поиска пути (возвращают путь и число посещённых) # ============================================================ class PathFindingStrategy(ABC): @abstractmethod def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> Tuple[List[Cell], int]: """Возвращает (путь, количество посещённых клеток).""" pass class BFSStrategy(PathFindingStrategy): """Поиск в ширину – гарантирует кратчайший путь.""" def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> Tuple[List[Cell], int]: if start == exit_cell: return [start], 1 queue = deque([start]) visited = {start} parent = {start: None} visited_count = 1 while queue: cur = queue.popleft() if cur == exit_cell: path = [] while cur is not None: path.append(cur) cur = parent[cur] path.reverse() return path, visited_count for nb in maze.get_neighbors(cur): if nb not in visited: visited.add(nb) visited_count += 1 parent[nb] = cur queue.append(nb) return [], visited_count class DFSStrategy(PathFindingStrategy): """Поиск в глубину – быстрый, но не гарантирует кратчайший путь.""" def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> Tuple[List[Cell], int]: if start == exit_cell: return [start], 1 stack = [(start, [start])] visited = set() visited_count = 0 while stack: cur, path = stack.pop() if cur in visited: continue visited.add(cur) visited_count += 1 if cur == exit_cell: return path, visited_count for nb in maze.get_neighbors(cur): if nb not in visited: stack.append((nb, path + [nb])) return [], visited_count class AStarStrategy(PathFindingStrategy): """А* с эвристикой Манхэттенского расстояния.""" def _heuristic(self, a: Cell, b: Cell) -> float: return abs(a.x - b.x) + abs(a.y - b.y) def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> Tuple[List[Cell], int]: if start == exit_cell: return [start], 1 open_set = [] counter = 0 heapq.heappush(open_set, (0, counter, start)) g_score = {start: 0} f_score = {start: self._heuristic(start, exit_cell)} parent = {start: None} visited_count = 1 while open_set: _, _, cur = heapq.heappop(open_set) if cur == exit_cell: path = [] while cur is not None: path.append(cur) cur = parent[cur] path.reverse() return path, visited_count for nb in maze.get_neighbors(cur): move_cost = nb.weight tentative = g_score[cur] + move_cost if nb not in g_score or tentative < g_score[nb]: parent[nb] = cur g_score[nb] = tentative f_score[nb] = tentative + self._heuristic(nb, exit_cell) counter += 1 heapq.heappush(open_set, (f_score[nb], counter, nb)) visited_count += 1 return [], visited_count class DijkstraStrategy(PathFindingStrategy): """Алгоритм Дейкстры для взвешенных графов.""" def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> Tuple[List[Cell], int]: if start == exit_cell: return [start], 1 pq = [] counter = 0 heapq.heappush(pq, (0, counter, start)) dist = {start: 0} parent = {start: None} visited_count = 1 while pq: cur_dist, _, cur = heapq.heappop(pq) if cur_dist > dist.get(cur, float('inf')): continue if cur == exit_cell: path = [] while cur is not None: path.append(cur) cur = parent[cur] path.reverse() return path, visited_count for nb in maze.get_neighbors(cur): new_dist = cur_dist + nb.weight if new_dist < dist.get(nb, float('inf')): dist[nb] = new_dist parent[nb] = cur counter += 1 heapq.heappush(pq, (new_dist, counter, nb)) visited_count += 1 return [], visited_count # ============================================================ # Этап 4. MazeSolver (оркестратор) # ============================================================ @dataclass class SearchStats: path_length: int visited_cells: int time_ms: float class MazeSolver: """Оркестратор: управляет лабиринтом и стратегией поиска.""" def __init__(self, maze: Maze, strategy: PathFindingStrategy): self.maze = maze self.strategy = strategy def set_strategy(self, strategy: PathFindingStrategy): self.strategy = strategy def solve(self) -> SearchStats: start_time = time.perf_counter() path, visited = self.strategy.find_path(self.maze, self.maze.start_cell, self.maze.exit_cell) end_time = time.perf_counter() return SearchStats(len(path), visited, (end_time - start_time) * 1000.0) # ============================================================ # Этап 5. Observer и Command (визуализация и пошаговое управление) # ============================================================ class Observer(ABC): @abstractmethod def update(self, event: str, data: dict = None): pass class ConsoleView(Observer): """Отображает лабиринт, позицию игрока и найденный путь.""" def __init__(self): self.last_maze = None self.last_player_pos = None self.last_path = None def update(self, event: str, data: dict = None): if event == "maze_loaded": self.last_maze = data["maze"] self.render() elif event == "player_moved": self.last_maze = data["maze"] self.last_player_pos = data["player_pos"] self.render() elif event == "path_found": self.last_path = data["path"] self.render() elif event == "clear_path": self.last_path = None self.render() def render(self): if self.last_maze is None: print("Нет лабиринта для отображения") return maze = self.last_maze player = self.last_player_pos path_set = set(self.last_path) if self.last_path else set() for y in range(maze.height): row = [] for x in range(maze.width): cell = maze.get_cell(x, y) if player and cell == player: row.append('@') elif cell == maze.start_cell: row.append('S') elif cell == maze.exit_cell: row.append('E') elif cell in path_set and cell.is_passable(): row.append('*') elif cell.is_wall: row.append('#') else: row.append(' ') print(''.join(row)) print() class Player: """Игрок, перемещающийся по лабиринту.""" def __init__(self, start_cell: Cell): self.position = start_cell def move_to(self, new_cell: Cell): self.position = new_cell class Command(ABC): @abstractmethod def execute(self) -> bool: pass @abstractmethod def undo(self): pass class MoveCommand(Command): """Команда перемещения игрока.""" def __init__(self, player: Player, maze: Maze, direction: str): self.player = player self.maze = maze self.direction = direction self.prev_position = player.position self.new_position = None def execute(self) -> bool: dx, dy = 0, 0 if self.direction == 'W': dy = -1 elif self.direction == 'S': dy = 1 elif self.direction == 'A': dx = -1 elif self.direction == 'D': dx = 1 else: return False nx = self.player.position.x + dx ny = self.player.position.y + dy try: target = self.maze.get_cell(nx, ny) if target.is_passable(): self.new_position = target self.player.move_to(target) return True except IndexError: pass return False def undo(self): if self.prev_position: self.player.move_to(self.prev_position) class GameController: """Управляет игрой, наблюдателями и командами.""" def __init__(self, maze: Maze): self.maze = maze self.player = Player(maze.start_cell) self.observers = [] self.command_stack = [] def attach(self, observer: Observer): self.observers.append(observer) def detach(self, observer: Observer): self.observers.remove(observer) def notify(self, event: str, data: dict = None): for obs in self.observers: obs.update(event, data or {}) def load_maze(self, maze: Maze): self.maze = maze self.player = Player(maze.start_cell) self.notify("maze_loaded", {"maze": maze}) def find_path(self, strategy: PathFindingStrategy) -> List[Cell]: solver = MazeSolver(self.maze, strategy) stats = solver.solve() print(f"Длина пути: {stats.path_length}, посещено: {stats.visited_cells}, время: {stats.time_ms:.3f} мс") path, _ = strategy.find_path(self.maze, self.maze.start_cell, self.maze.exit_cell) self.notify("path_found", {"path": path}) return path def clear_path(self): self.notify("clear_path", {}) def execute_command(self, cmd: Command): if cmd.execute(): self.command_stack.append(cmd) self.notify("player_moved", {"maze": self.maze, "player_pos": self.player.position}) def undo(self): if self.command_stack: cmd = self.command_stack.pop() cmd.undo() self.notify("player_moved", {"maze": self.maze, "player_pos": self.player.position}) # ============================================================ # Этап 6. Генераторы тестовых лабиринтов # ============================================================ def generate_simple_maze(width: int, height: int) -> Maze: """Маленький лабиринт с простым путём.""" maze = Maze(width, height) for y in range(height): for x in range(width): maze.cells[y][x].is_wall = True x, y = 0, 0 path = [(x, y)] while x < width - 1 or y < height - 1: if x < width - 1 and (y == height - 1 or random.random() < 0.5): x += 1 else: if y < height - 1: y += 1 path.append((x, y)) for px, py in path: maze.cells[py][px].is_wall = False maze.set_start(0, 0) maze.set_exit(width - 1, height - 1) return maze def generate_with_dead_ends(width: int, height: int) -> Maze: """Средний лабиринт с гарантированным путём и множеством тупиков.""" maze = Maze(width, height) for y in range(height): for x in range(width): maze.cells[y][x].is_wall = True x, y = 0, 0 main_path = [] while x < width - 1 or y < height - 1: main_path.append((x, y)) if x < width - 1 and (y == height - 1 or random.random() < 0.6): x += 1 else: if y < height - 1: y += 1 else: x += 1 main_path.append((width - 1, height - 1)) for px, py in main_path: maze.cells[py][px].is_wall = False num_dead_ends = int(width * height * 0.08) for _ in range(num_dead_ends): base_x, base_y = random.choice(main_path) directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] random.shuffle(directions) for dx, dy in directions: nx, ny = base_x + dx, base_y + dy if 0 <= nx < width and 0 <= ny < height and maze.cells[ny][nx].is_wall: length = random.randint(2, 4) for step in range(length): if 0 <= nx < width and 0 <= ny < height and maze.cells[ny][nx].is_wall: maze.cells[ny][nx].is_wall = False nx += dx ny += dy else: break break maze.set_start(0, 0) maze.set_exit(width - 1, height - 1) return maze def generate_complex_maze(width: int, height: int) -> Maze: """Большой лабиринт с гарантированным путём и высокой запутанностью.""" maze = Maze(width, height) for y in range(height): for x in range(width): maze.cells[y][x].is_wall = True x, y = 0, 0 main_path = [] while x < width - 1 or y < height - 1: main_path.append((x, y)) if x < width - 1 and (y == height - 1 or random.random() < 0.7): x += 1 else: if y < height - 1: y += 1 else: x += 1 main_path.append((width - 1, height - 1)) for px, py in main_path: maze.cells[py][px].is_wall = False num_branches = int(width * height * 0.12) for _ in range(num_branches): base_x, base_y = random.choice(main_path) directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] random.shuffle(directions) for dx, dy in directions: nx, ny = base_x + dx, base_y + dy if 0 <= nx < width and 0 <= ny < height and maze.cells[ny][nx].is_wall: length = random.randint(1, 5) branch = [] for step in range(length): if 0 <= nx < width and 0 <= ny < height and maze.cells[ny][nx].is_wall: maze.cells[ny][nx].is_wall = False branch.append((nx, ny)) nx += dx ny += dy else: break if random.random() < 0.3 and len(branch) >= 2: bx, by = branch[-1] for ddx, ddy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: nnx, nny = bx + ddx, by + ddy if (0 <= nnx < width and 0 <= nny < height and maze.cells[nny][nnx].is_wall and random.random() < 0.5): maze.cells[nny][nnx].is_wall = False break maze.set_start(0, 0) maze.set_exit(width - 1, height - 1) return maze def generate_empty_maze(width: int, height: int) -> Maze: """Пустой лабиринт без стен.""" maze = Maze(width, height) for y in range(height): for x in range(width): maze.cells[y][x].is_wall = False maze.set_start(0, 0) maze.set_exit(width - 1, height - 1) return maze def generate_no_exit_maze(width: int, height: int) -> Maze: """Лабиринт без выхода (выход окружён стенами).""" maze = generate_empty_maze(width, height) ex, ey = width - 1, height - 1 for dx, dy in [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0), (-1, -1), (-1, 1), (1, -1), (1, 1)]: nx, ny = ex + dx, ey + dy if 0 <= nx < width and 0 <= ny < height: if not (nx == 0 and ny == 0): maze.cells[ny][nx].is_wall = True maze.cells[ey][ex].is_wall = False maze.set_exit(ex, ey) return maze # ============================================================ # Экспериментальная часть # ============================================================ def run_experiment(maze: Maze, strategies: List[Tuple[str, PathFindingStrategy]], runs: int = 5) -> List[dict]: """Запускает эксперимент на одном лабиринте и возвращает усреднённые результаты.""" results = [] for name, strategy in strategies: times = [] visited_counts = [] path_lengths = [] for _ in range(runs): solver = MazeSolver(maze, strategy) stats = solver.solve() times.append(stats.time_ms) visited_counts.append(stats.visited_cells) path_lengths.append(stats.path_length) avg_time = sum(times) / runs variance = sum((t - avg_time) ** 2 for t in times) / runs std_time = variance ** 0.5 results.append({ 'maze_type': '', 'strategy': name, 'avg_time_ms': avg_time, 'std_time_ms': std_time, 'avg_visited': sum(visited_counts) / runs, 'avg_path_len': sum(path_lengths) / runs, 'path_found': all(l > 0 for l in path_lengths) }) return results def save_results_to_csv(results: List[dict], filename: str): """Сохраняет результаты в CSV с разделителем ';' для совместимости с Excel.""" if not results: return with open(filename, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=results[0].keys(), delimiter=';') writer.writeheader() for row in results: row_copy = {} for k, v in row.items(): if isinstance(v, float): row_copy[k] = f"{v:.6f}".replace(',', '.') else: row_copy[k] = v writer.writerow(row_copy) # ============================================================ # Взвешенные лабиринты (опциональное задание) # ============================================================ def assign_weights_random(maze: Maze, weights: List[Tuple[float, int]]) -> Maze: """Присваивает веса клеткам согласно вероятностям.""" for y in range(maze.height): for x in range(maze.width): if not maze.cells[y][x].is_wall: r = random.random() cum = 0 for prob, w in weights: cum += prob if r < cum: maze.cells[y][x].weight = w break return maze def weighted_experiment(): """Дополнительный эксперимент со взвешенными клетками.""" print("\n=== ВЗВЕШЕННЫЕ ЛАБИРИНТЫ (опциональное задание) ===") maze = generate_with_dead_ends(30, 30) assign_weights_random(maze, [(0.8, 1), (0.15, 3), (0.05, 2)]) strategies = [ ("A* (манхэттен)", AStarStrategy()), ("Dijkstra", DijkstraStrategy()) ] print("Лабиринт 30x30 со взвешенными клетками (болото 3, песок 2, асфальт 1)") results = run_experiment(maze, strategies, runs=10) for r in results: print(f"{r['strategy']:15} | Время: {r['avg_time_ms']:.2f} мс | " f"Посещено: {r['avg_visited']:.0f} | Длина пути: {r['avg_path_len']:.0f}") # Сравнение с BFS bfs = BFSStrategy() path_bfs, _ = bfs.find_path(maze, maze.start_cell, maze.exit_cell) if path_bfs: cost_bfs = sum(cell.weight for cell in path_bfs) print(f"BFS нашёл путь длиной {len(path_bfs)} клеток, стоимость = {cost_bfs}") path_dijkstra, _ = DijkstraStrategy().find_path(maze, maze.start_cell, maze.exit_cell) if path_dijkstra: cost_dijkstra = sum(cell.weight for cell in path_dijkstra) print(f"Dijkstra нашёл путь длиной {len(path_dijkstra)} клеток, стоимость = {cost_dijkstra}") path_astar, _ = AStarStrategy().find_path(maze, maze.start_cell, maze.exit_cell) if path_astar: cost_astar = sum(cell.weight for cell in path_astar) print(f"A* нашёл путь длиной {len(path_astar)} клеток, стоимость = {cost_astar}") # ============================================================ # Демонстрация работы Observer и Command (по желанию) # ============================================================ def demo_observer_command(): """Демонстрирует паттерны Observer и Command.""" print("\n=== ДЕМОНСТРАЦИЯ OBSERVER И COMMAND ===") maze = generate_simple_maze(10, 10) controller = GameController(maze) view = ConsoleView() controller.attach(view) print("Лабиринт загружен:") controller.load_maze(maze) print("Поиск пути с помощью BFS:") controller.find_path(BFSStrategy()) input("Нажмите Enter для пошагового управления...") controller.clear_path() print("\nУправление: W/A/S/D - движение, Z - отмена, Q - выход") while True: cmd = input("> ").upper().strip() if cmd == 'Q': break elif cmd == 'Z': controller.undo() elif cmd in ('W', 'A', 'S', 'D'): move_cmd = MoveCommand(controller.player, controller.maze, cmd) controller.execute_command(move_cmd) else: print("Неизвестная команда") # ============================================================ # Основной эксперимент # ============================================================ def main(): """Основной эксперимент: сравнение стратегий на различных лабиринтах.""" print("=== ЗАПУСК ЭКСПЕРИМЕНТОВ ===") strategies = [ ("BFS", BFSStrategy()), ("DFS", DFSStrategy()), ("A*", AStarStrategy()), ("Dijkstra", DijkstraStrategy()) ] # Генерация тестовых лабиринтов maze_definitions = { "small_10x10_simple": generate_simple_maze(10, 10), "medium_50x50_deadends": generate_with_dead_ends(50, 50), "large_100x100_complex": generate_complex_maze(100, 100), "empty_50x50": generate_empty_maze(50, 50), "no_exit_50x50": generate_no_exit_maze(50, 50) } all_results = [] for maze_name, maze in maze_definitions.items(): print(f"\nЗапуск на лабиринте: {maze_name} ({maze.width}x{maze.height})") results = run_experiment(maze, strategies, runs=5) for r in results: r['maze_type'] = maze_name all_results.append(r) # Вывод промежуточных результатов for r in results: print(f" {r['strategy']:8} | Время: {r['avg_time_ms']:7.2f}±{r['std_time_ms']:.2f} мс | " f"Посещено: {r['avg_visited']:7.0f} | Длина пути: {r['avg_path_len']:5.0f}") # Сохранение результатов save_results_to_csv(all_results, "experiment_results.csv") print("\nРезультаты сохранены в experiment_results.csv") # Вывод сводной таблицы print("\n" + "=" * 100) print("СВОДНАЯ ТАБЛИЦА РЕЗУЛЬТАТОВ") print("=" * 100) print(f"{'Лабиринт':<25} | {'Стратегия':<10} | {'Время (мс)':<15} | {'Посещено':<10} | {'Длина пути':<10}") print("-" * 100) for r in all_results: print(f"{r['maze_type']:<25} | {r['strategy']:<10} | {r['avg_time_ms']:>8.2f} ± {r['std_time_ms']:<5.2f} | " f"{r['avg_visited']:>8.0f} | {r['avg_path_len']:>8.0f}") # ============================================================ # Запуск # ============================================================ if __name__ == "__main__": main() # Раскомментируйте для демонстрации: # demo_observer_command() # weighted_experiment()