2026-rff_mp/soninrv/docs/data/lab2/main.py
2026-05-25 03:13:08 +03:00

611 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
maze_solver.py — Поиск выхода из лабиринта.
Паттерны GoF: Builder, Strategy, Observer, Command.
Алгоритмы: BFS, DFS, A*, Dijkstra.
Запуск:
python maze_solver.py --solve mazes/small_10x10.txt --algo bfs
python maze_solver.py --walk mazes/small_10x10.txt
python maze_solver.py --experiment
"""
from __future__ import annotations
import argparse
import csv
import heapq
import os
import statistics
import sys
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
sys.setrecursionlimit(100_000)
# ЭТАП 1. МОДЕЛЬ ЛАБИРИНТА — Cell, Maze
@dataclass
class Cell:
"""Одна клетка лабиринта."""
x: int
y: int
is_wall: bool = False
is_start: bool = False
is_exit: bool = False
weight: int = 1 # 1=асфальт 2=песок 3=болото
def is_passable(self) -> bool:
return not self.is_wall
def __repr__(self) -> str:
if self.is_wall: return "#"
if self.is_start: return "S"
if self.is_exit: return "E"
return " "
# heapq требует сравнения объектов
def __lt__(self, other: Cell) -> bool:
return (self.x, self.y) < (other.x, other.y)
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):
self.width = width
self.height = height
self._cells: List[List[Cell]] = [
[Cell(x, y) for x in range(width)] for y in range(height)
]
self.start: Optional[Cell] = None
self.exit: Optional[Cell] = None
def get_cell(self, x: int, y: int) -> Optional[Cell]:
if 0 <= x < self.width and 0 <= y < self.height:
return self._cells[y][x]
return None
def get_neighbors(self, cell: Cell) -> List[Cell]:
"""Четыре соседа — только проходимые, в пределах границ."""
result = []
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
nb = self.get_cell(cell.x + dx, cell.y + dy)
if nb and nb.is_passable():
result.append(nb)
return result
# ЭТАП 2. ПАТТЕРН BUILDER — загрузка лабиринта из файла
_WEIGHT_MAP = {' ': 1, '.': 2, '~': 3}
class MazeBuilder(ABC):
"""Интерфейс строителя лабиринта."""
@abstractmethod
def build_from_file(self, filename: str) -> Maze: ...
@abstractmethod
def build_from_string(self, text: str) -> Maze: ...
class TextFileMazeBuilder(MazeBuilder):
"""
Строит Maze из текстового файла:
# — стена S — старт E — выход
' '— путь (w=1) . — песок (w=2) ~ — болото (w=3)
"""
def build_from_file(self, filename: str) -> Maze:
return self.build_from_string(Path(filename).read_text(encoding="utf-8"))
def build_from_string(self, text: str) -> Maze:
lines = text.splitlines()
while lines and not lines[-1].strip():
lines.pop()
if not lines:
raise ValueError("Пустой лабиринт")
height = len(lines)
width = max(len(l) for l in lines)
maze = Maze(width, height)
for y, line in enumerate(lines):
for x, ch in enumerate(line):
cell = maze.get_cell(x, y)
if cell is None:
continue
if ch == '#':
cell.is_wall = True
elif ch == 'S':
cell.is_start = True
maze.start = cell
elif ch == 'E':
cell.is_exit = True
maze.exit = cell
else:
cell.weight = _WEIGHT_MAP.get(ch, 1)
for x in range(len(line), width): # дополнить стенами
c = maze.get_cell(x, y)
if c:
c.is_wall = True
if maze.start is None:
raise ValueError("Нет стартовой клетки 'S'")
if maze.exit is None:
raise ValueError("Нет выходной клетки 'E'")
return maze
# ЭТАП 3. ПАТТЕРН STRATEGY — алгоритмы поиска пути
def _reconstruct(parent: Dict[Cell, Optional[Cell]], end: Cell) -> List[Cell]:
"""Восстановить путь от старта до end по словарю предшественников."""
path, node = [], end
while node is not None:
path.append(node)
node = parent.get(node)
path.reverse()
return path
class PathFindingStrategy(ABC):
"""Интерфейс стратегии поиска пути."""
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def find_path(
self, maze: Maze, start: Cell, exit_cell: Cell
) -> Tuple[List[Cell], int]:
"""Возвращает (path, visited_count). path пуст если пути нет."""
...
class BFSStrategy(PathFindingStrategy):
"""Поиск в ширину — гарантирует кратчайший путь (по числу шагов)."""
@property
def name(self) -> str:
return "BFS"
def find_path(self, maze, start, exit_cell):
queue = deque([start])
parent: Dict[Cell, Optional[Cell]] = {start: None}
visited = 0
while queue:
cur = queue.popleft()
visited += 1
if cur == exit_cell:
return _reconstruct(parent, exit_cell), visited
for nb in maze.get_neighbors(cur):
if nb not in parent:
parent[nb] = cur
queue.append(nb)
return [], visited
class DFSStrategy(PathFindingStrategy):
"""Поиск в глубину — не гарантирует кратчайший путь."""
@property
def name(self) -> str:
return "DFS"
def find_path(self, maze, start, exit_cell):
stack = [start]
parent: Dict[Cell, Optional[Cell]] = {start: None}
visited = 0
while stack:
cur = stack.pop()
visited += 1
if cur == exit_cell:
return _reconstruct(parent, exit_cell), visited
for nb in maze.get_neighbors(cur):
if nb not in parent:
parent[nb] = cur
stack.append(nb)
return [], visited
class AStarStrategy(PathFindingStrategy):
"""A* с манхэттенской эвристикой — направленный поиск, учитывает веса."""
@property
def name(self) -> str:
return "A*"
@staticmethod
def _h(a: Cell, b: Cell) -> int:
return abs(a.x - b.x) + abs(a.y - b.y)
def find_path(self, maze, start, exit_cell):
heap: List[Tuple[int, int, Cell]] = [(0, 0, start)]
g: Dict[Cell, int] = {start: 0}
parent: Dict[Cell, Optional[Cell]] = {start: None}
visited = 0
while heap:
_, g_cur, cur = heapq.heappop(heap)
visited += 1
if cur == exit_cell:
return _reconstruct(parent, exit_cell), visited
if g_cur > g.get(cur, float('inf')):
continue
for nb in maze.get_neighbors(cur):
new_g = g_cur + nb.weight
if new_g < g.get(nb, float('inf')):
g[nb] = new_g
parent[nb] = cur
heapq.heappush(heap, (new_g + self._h(nb, exit_cell), new_g, nb))
return [], visited
class DijkstraStrategy(PathFindingStrategy):
"""Дейкстра — оптимален для взвешенных графов, без эвристики."""
@property
def name(self) -> str:
return "Dijkstra"
def find_path(self, maze, start, exit_cell):
dist: Dict[Cell, int] = {start: 0}
parent: Dict[Cell, Optional[Cell]] = {start: None}
heap: List[Tuple[int, Cell]] = [(0, start)]
visited = 0
while heap:
d, cur = heapq.heappop(heap)
visited += 1
if cur == exit_cell:
return _reconstruct(parent, exit_cell), visited
if d > dist.get(cur, float('inf')):
continue
for nb in maze.get_neighbors(cur):
new_d = d + nb.weight
if new_d < dist.get(nb, float('inf')):
dist[nb] = new_d
parent[nb] = cur
heapq.heappush(heap, (new_d, nb))
return [], visited
# ПАТТЕРН OBSERVER
@dataclass
class SearchStats:
time_ms: float
visited_cells: int
path_length: int
strategy_name: str
def __str__(self) -> str:
return (f"[{self.strategy_name}] "
f"время={self.time_ms:.2f} мс "
f"посещено={self.visited_cells} "
f"длина пути={self.path_length}")
class Observer(ABC):
@abstractmethod
def update(self, event: dict) -> None: ...
class ConsoleView(Observer):
"""Выводит события и рисует лабиринт в консоли."""
def update(self, event: dict) -> None:
kind = event.get("type")
if kind == "maze_loaded":
print(f"[Лабиринт загружен] {event['width']}×{event['height']}")
elif kind == "search_start":
print(f"[Поиск] алгоритм={event['strategy']}")
elif kind == "path_found":
print(f"[Готово] {event['stats']}")
elif kind == "no_path":
print("[Результат] Путь не найден!")
def render(
self,
maze: Maze,
player_pos: Optional[Cell] = None,
path: Optional[List[Cell]] = None,
) -> None:
path_set = set(path) if path else set()
for y in range(maze.height):
row = []
for x in range(maze.width):
cell = maze.get_cell(x, y)
if cell is None:
row.append("?")
elif player_pos and cell == player_pos:
row.append("@")
elif cell.is_wall:
row.append("")
elif cell.is_start:
row.append("S")
elif cell.is_exit:
row.append("E")
elif cell in path_set:
row.append("*")
elif cell.weight == 3:
row.append("~")
elif cell.weight == 2:
row.append(".")
else:
row.append(" ")
print("".join(row))
print()
# ЭТАП 4. ОРКЕСТРАТОР MazeSolver
class MazeSolver:
"""
Связывает Maze + PathFindingStrategy + список Observer.
Паттерны: Strategy (алгоритм подключается снаружи),
Observer (уведомления при завершении).
"""
def __init__(self, maze: Maze, strategy: PathFindingStrategy):
self.maze = maze
self._strategy = strategy
self._observers: List[Observer] = []
self._last_path: List[Cell] = []
def add_observer(self, obs: Observer) -> None:
self._observers.append(obs)
def set_strategy(self, strategy: PathFindingStrategy) -> None:
self._strategy = strategy
def _notify(self, event: dict) -> None:
for obs in self._observers:
obs.update(event)
def solve(self) -> SearchStats:
if not self.maze.start or not self.maze.exit:
raise ValueError("Лабиринт не содержит старт или выход")
self._notify({"type": "search_start", "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
self._last_path = path
stats = SearchStats(elapsed_ms, visited, len(path), self._strategy.name)
self._notify({"type": "path_found" if path else "no_path", "stats": stats})
return stats
@property
def last_path(self) -> List[Cell]:
return self._last_path
# ЭТАП 5. ПАТТЕРН COMMAND — пошаговое управление игроком
_DIRECTIONS = {'W': (0, -1), 'S': (0, 1), 'A': (-1, 0), 'D': (1, 0)}
class Command(ABC):
@abstractmethod
def execute(self) -> bool: ...
@abstractmethod
def undo(self) -> None: ...
class Player:
def __init__(self, cell: Cell):
self.current_cell = cell
def move_to(self, cell: Cell) -> None:
self.current_cell = cell
class MoveCommand(Command):
"""Перемещение игрока в направлении direction (W/A/S/D)."""
def __init__(self, player: Player, direction: str, maze: Maze,
observers: Optional[List[Observer]] = None):
self._player = player
self._direction = direction.upper()
self._maze = maze
self._observers = observers or []
self._prev: Optional[Cell] = None
def execute(self) -> bool:
dx, dy = _DIRECTIONS.get(self._direction, (0, 0))
target = self._maze.get_cell(
self._player.current_cell.x + dx,
self._player.current_cell.y + dy,
)
if target and target.is_passable():
self._prev = self._player.current_cell
self._player.move_to(target)
for obs in self._observers:
obs.update({"type": "move", "cell": target})
return True
return False
def undo(self) -> None:
if self._prev:
self._player.move_to(self._prev)
self._prev = None
class CommandHistory:
"""Стек выполненных команд (undo/redo)."""
def __init__(self):
self._stack: List[Command] = []
def execute(self, cmd: Command) -> bool:
ok = cmd.execute()
if ok:
self._stack.append(cmd)
return ok
def undo(self) -> bool:
if self._stack:
self._stack.pop().undo()
return True
return False
# ЭТАП 6. ЭКСПЕРИМЕНТАЛЬНАЯ ЧАСТЬ
def run_experiment(
maze_files: List[Tuple[str, str]],
strategies: List[PathFindingStrategy],
repeats: int = 7,
out_csv: str = "results.csv",
) -> None:
builder = TextFileMazeBuilder()
rows = [["maze", "strategy", "run", "time_ms", "visited_cells", "path_length"]]
for maze_name, maze_file in maze_files:
print(f"\n=== {maze_name} ===")
maze = builder.build_from_file(maze_file)
print(f" Размер: {maze.width}×{maze.height}")
for strategy in strategies:
solver = MazeSolver(maze, strategy)
times, visits, lengths = [], [], []
for run in range(1, repeats + 1):
stats = solver.solve()
times.append(stats.time_ms)
visits.append(stats.visited_cells)
lengths.append(stats.path_length)
rows.append([maze_name, strategy.name, run,
round(stats.time_ms, 4),
stats.visited_cells,
stats.path_length])
print(f" {strategy.name:10} | "
f"t={statistics.mean(times):.3f} мс | "
f"посещено={statistics.mean(visits):.0f} | "
f"путь={statistics.mean(lengths):.0f}")
os.makedirs(os.path.dirname(os.path.abspath(out_csv)), exist_ok=True)
with open(out_csv, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(rows)
print(f"\nСохранено: {out_csv}")
# CLI
_ALGO_MAP = {
"bfs": BFSStrategy(),
"dfs": DFSStrategy(),
"astar": AStarStrategy(),
"dijkstra": DijkstraStrategy(),
}
_MAZES = [
("small_10x10", "small_10x10.txt"),
("medium_50x50", "medium_50x50.txt"),
("large_100x100", "large_100x100.txt"),
("empty_30x30", "empty_30x30.txt"),
("no_exit_20x20", "no_exit_20x20.txt"),
("weighted_40x40", "weighted_40x40.txt"),
]
def cmd_solve(maze_file: str, algo: str) -> None:
view = ConsoleView()
maze = TextFileMazeBuilder().build_from_file(maze_file)
view.update({"type": "maze_loaded", "width": maze.width, "height": maze.height})
strategy = _ALGO_MAP.get(algo.lower())
if not strategy:
print(f"Неизвестный алгоритм '{algo}'. Доступны: {', '.join(_ALGO_MAP)}")
return
solver = MazeSolver(maze, strategy)
solver.add_observer(view)
solver.solve()
view.render(maze, path=solver.last_path)
def cmd_walk(maze_file: str) -> None:
view = ConsoleView()
maze = TextFileMazeBuilder().build_from_file(maze_file)
player = Player(maze.start)
history = CommandHistory()
solver = MazeSolver(maze, BFSStrategy())
solver.add_observer(view)
solver.solve()
path = solver.last_path
print("W=вверх S=вниз A=влево D=вправо Z=отмена Q=выход")
while True:
view.render(maze, player_pos=player.current_cell, path=path)
if player.current_cell == maze.exit:
print("Вы достигли выхода!")
break
key = input("Ход: ").strip().upper()
if key == "Q":
break
elif key == "Z":
if not history.undo():
print("Нечего отменять.")
elif key in _DIRECTIONS:
if not history.execute(MoveCommand(player, key, maze, [view])):
print("Туда нельзя — стена.")
else:
print("Неизвестная клавиша.")
def cmd_experiment() -> None:
run_experiment(_MAZES, list(_ALGO_MAP.values()))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Maze solver")
parser.add_argument("--solve", metavar="FILE", help="Решить лабиринт")
parser.add_argument("--algo", metavar="ALGO", default="bfs",
help="bfs | dfs | astar | dijkstra")
parser.add_argument("--walk", metavar="FILE", help="Ручное управление (WASD)")
parser.add_argument("--experiment", action="store_true",
help="Запустить замеры и сохранить CSV")
args = parser.parse_args()
if args.solve:
cmd_solve(args.solve, args.algo)
elif args.walk:
cmd_walk(args.walk)
elif args.experiment:
cmd_experiment()
else:
parser.print_help()