add maze_solver

This commit is contained in:
SobolevNS 2026-05-22 13:42:42 +03:00
parent 82e988c965
commit ae812457fb
6 changed files with 570 additions and 0 deletions

View 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",
]

View 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}).")

View 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()

View 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)

View 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

View 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,
}