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