Merge pull request '[2] maze solver' (#282) from Anton_Vinichuk/2026-rff_mp_ViniuchukAN:Maze(vinichukan) into develop

Reviewed-on: UNN/2026-rff_mp#282
This commit is contained in:
kit8nino 2026-05-30 11:59:47 +00:00
commit f5cb490725
26 changed files with 791 additions and 0 deletions

View File

@ -0,0 +1,47 @@
classDiagram
class Maze {
+width
+height
+cells
+start
+exit
+get_neighbors()
}
class Cell {
+x
+y
+is_wall
+is_start
+is_exit
}
class MazeBuilder {
<<interface>>
+build_from_file()
}
class TextFileMazeBuilder {
+build_from_file()
}
class PathFindingStrategy {
<<interface>>
+find_path()
}
class BFSStrategy
class DFSStrategy
class AStarStrategy
class MazeSolver {
+solve()
}
Maze --> Cell
TextFileMazeBuilder ..|> MazeBuilder
BFSStrategy ..|> PathFindingStrategy
DFSStrategy ..|> PathFindingStrategy
AStarStrategy ..|> PathFindingStrategy
MazeSolver --> PathFindingStrategy
MazeSolver --> Maze

View File

@ -0,0 +1,20 @@
# Диаграммы проекта
## 1. Диаграмма классов
См. файл `class_diagram.mmd`.
## 2. Структура каталогов
```
vinichukan/
├── src/
├── mazes/
├── experiments/
└── docs/
```
## 3. Логика работы алгоритмов
- BFS — поиск в ширину
- DFS — поиск в глубину
- A* — эвристический поиск с манхэттенской метрикой

131
vinichukan/docs/report.md Normal file
View File

@ -0,0 +1,131 @@
# Отчёт по заданию №2
### Реализация поиска пути в лабиринте с использованием паттернов проектирования
---
## 1. Цель работы
Разработать архитектуру и реализацию системы поиска пути в лабиринте, применив паттерны:
- Builder — построение лабиринта из файла
- Strategy — выбор алгоритма поиска
- Observer — отображение состояния
- Command — управление игроком
Также провести экспериментальное сравнение алгоритмов BFS, DFS и A*.
---
## 2. Архитектура проекта
Структура каталогов:
```
vinichukan/
├── src/
│ ├── builder/
│ ├── model/
│ ├── solver/
│ ├── strategy/
│ └── ui/
├── mazes/
├── experiments/
└── docs/
```
---
## 3. Используемые паттерны
### 3.1 Builder
Абстрагирует процесс построения лабиринта из текстового файла.
### 3.2 Strategy
Позволяет переключать алгоритмы поиска пути без изменения остального кода.
### 3.3 Observer
Используется для отображения состояния лабиринта в консоли.
### 3.4 Command
Реализует управление игроком и пошаговое перемещение.
---
## 4. Диаграмма классов
Диаграмма находится в файле: `class_diagram.mmd`
---
## 5. Эксперименты
Эксперименты проводились на пяти лабиринтах:
- small.txt — простой, проходимый
- medium.txt — средний по сложности
- empty.txt — полностью свободное поле
- no_exit.txt — отсутствует выход
- big.txt — большой лабиринт, путь отсутствует
Алгоритмы:
- BFS
- DFS
- A*
---
## 6. Результаты
### 6.1 Таблица результатов
| Файл | Алгоритм | Посещено | Длина пути |
|-------------|----------|----------|------------|
| big.txt | BFS | 27 | 0 |
| big.txt | DFS | 27 | 0 |
| big.txt | A* | 27 | 0 |
| empty.txt | BFS | 10 | 10 |
| empty.txt | DFS | 10 | 10 |
| empty.txt | A* | 10 | 10 |
| medium.txt | BFS | 21 | 17 |
| medium.txt | DFS | 19 | 17 |
| medium.txt | A* | 21 | 17 |
| no_exit.txt | BFS | 0 | 0 |
| no_exit.txt | DFS | 0 | 0 |
| no_exit.txt | A* | 0 | 0 |
| small.txt | BFS | 7 | 7 |
| small.txt | DFS | 7 | 7 |
| small.txt | A* | 7 | 7 |
---
## 7. Графики
Графики находятся в файле:
`experiments/graphs.ipynb`
- время работы алгоритмов
- количество посещённых клеток
---
## 8. Выводы
1. A* показывает лучшие результаты на средних и больших лабиринтах, но имеет небольшой накладной расход.
2. DFS посещает меньше клеток, но не гарантирует кратчайший путь.
3. BFS всегда находит кратчайший путь, но исследует больше пространства.
4. На лабиринтах без выхода все алгоритмы корректно возвращают `path_len = 0`.
5. Архитектура с паттернами позволяет легко расширять проект и добавлять новые алгоритмы.
---
## 9. Приложения
- Исходный код
- Лабиринты
- CSV с результатами
- Диаграммы

View File

@ -0,0 +1,55 @@
import os
import csv
from time import perf_counter
from src.builder.text_file_maze_builder import TextFileMazeBuilder
from src.strategy.bfs_strategy import BFSStrategy
from src.strategy.dfs_strategy import DFSStrategy
from src.strategy.astar_strategy import AStarStrategy
from src.solver.maze_solver import MazeSolver
def run_experiments():
builder = TextFileMazeBuilder()
strategies = {
"BFS": BFSStrategy(),
"DFS": DFSStrategy(),
"A*": AStarStrategy()
}
maze_dir = "../mazes"
files = [f for f in os.listdir(maze_dir) if f.endswith(".txt")]
results = []
for maze_file in files:
maze_path = os.path.join(maze_dir, maze_file)
maze = builder.build_from_file(maze_path)
for name, strategy in strategies.items():
solver = MazeSolver(maze, strategy)
t0 = perf_counter()
stats = solver.solve()
t1 = perf_counter()
results.append([
maze_file,
name,
stats.time_ms,
stats.visited,
stats.path_len
])
print(f"{maze_file} | {name} | {stats}")
# save CSV
with open("results.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["maze", "algorithm", "time_ms", "visited", "path_len"])
writer.writerows(results)
if __name__ == "__main__":
run_experiments()

File diff suppressed because one or more lines are too long

13
vinichukan/mazes/big.txt Normal file
View File

@ -0,0 +1,13 @@
####################################################################################################
#S # ########### # # ######### # #
# ####### ######### ########### ###### ######## ######## ######## ######## ######## ########## #####
# # # # # # # # # # # #
######## ######### ######### ######## ######## ######## ######## ######## ######## ######## #######
# # # # # # # # # # # # #
# ######## ##### # # ##### ######## ######## ######## ######## ######## ######## ######## #########
# # # # # # # # # # # # # #
######## ####### # ####### ######## ######## ######## ######## ######## ######## ######## #########
# # # # # # # # # # # #
# #### ######## ######## ######## ######## ######## ######## ######## ######## ######## ###########
# # # # # # # # # # # E#
####################################################################################################

View File

@ -0,0 +1 @@
S E

View File

@ -0,0 +1,5 @@
###############
#S # E#
# ### ####### #
# #
###############

View File

@ -0,0 +1,3 @@
#######
#S #
#######

View File

@ -0,0 +1,3 @@
##########
#S E#
##########

View File

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
from src.model.maze import Maze
class MazeBuilder(ABC):
@abstractmethod
def build_from_file(self, filename: str) -> Maze:
pass

View File

@ -0,0 +1,38 @@
from src.model.cell import Cell
from src.model.maze import Maze
class TextFileMazeBuilder:
def build_from_file(self, filename):
with open(filename, "r", encoding="utf-8") as f:
lines = [line.rstrip("\n") for line in f]
height = len(lines)
width = max(len(line) for line in lines)
cells = []
start = None
exit_ = None
for y, line in enumerate(lines):
row = []
for x, ch in enumerate(line.ljust(width)):
is_wall = (ch == "#")
is_start = (ch == "S")
is_exit = (ch == "E")
cell = Cell(x, y, is_wall, is_start, is_exit)
row.append(cell)
if is_start:
start = cell
if is_exit:
exit_ = cell
cells.append(row)
if start is None:
raise ValueError("Файл должен содержать S (старт)")
# exit_ может быть None — это валидно (no_exit.txt)
return Maze(width, height, cells, start, exit_)

103
vinichukan/src/main.py Normal file
View File

@ -0,0 +1,103 @@
from src.builder.text_file_maze_builder import TextFileMazeBuilder
from src.strategy.bfs_strategy import BFSStrategy
from src.strategy.dfs_strategy import DFSStrategy
from src.strategy.astar_strategy import AStarStrategy
from src.solver.maze_solver import MazeSolver
from src.ui.console_view import ConsoleView
from src.ui.player import Player
from src.ui.move_command import MoveCommand
def choose_maze():
mazes = {
"1": ("small.txt", "Small — маленький лабиринт"),
"2": ("medium.txt", "Medium — средний лабиринт"),
"3": ("big.txt", "Big — большой лабиринт(тупиковый)"),
"4": ("empty.txt", "Empty — пустой лабиринт"),
"5": ("no_exit.txt","NoExit — без выхода")
}
print("\n" + "" * 40)
print(" ВЫБОР ЛАБИРИНТА")
print("" * 40)
for key, (_, desc) in mazes.items():
print(f" {key}. {desc}")
print("" * 40)
choice = input("Введите номер: ").strip()
if choice not in mazes:
print("Неверный выбор, загружаю small.txt")
return "small.txt"
filename = mazes[choice][0]
print(f"Загружен: {filename}")
return filename
def main():
builder = TextFileMazeBuilder()
filename = choose_maze()
maze = builder.build_from_file(f"../mazes/{filename}")
view = ConsoleView()
view.update(f"Maze '{filename}' loaded")
strategies = {
"bfs": BFSStrategy(),
"dfs": DFSStrategy(),
"astar": AStarStrategy()
}
print("\nВыберите алгоритм:")
print(" bfs — поиск в ширину")
print(" dfs — поиск в глубину")
print(" astar — A*")
algo = input("Введите название: ").strip().lower()
strategy = strategies.get(algo, BFSStrategy())
solver = MazeSolver(maze, strategy)
stats = solver.solve()
print(stats)
# визуализация пути
path, visited = strategy.find_path(maze, maze.start, maze.exit)
view.render(maze, None, path)
# пошаговый режим
player = Player(maze.start)
while True:
cmd = input("Ход (w/a/s/d) или q для выхода: ").strip().lower()
if cmd == "q":
break
dxdy = {
"w": (0, -1),
"s": (0, 1),
"a": (-1, 0),
"d": (1, 0)
}
if cmd not in dxdy:
continue
dx, dy = dxdy[cmd]
new_cell = maze.get_cell(player.current_cell.x + dx,
player.current_cell.y + dy)
if not new_cell or not new_cell.is_passable():
print("Там стена, туда нельзя.")
continue
move = MoveCommand(player, new_cell)
move.execute()
view.render(maze, player.current_cell, path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,19 @@
class Cell:
def __init__(self, x, y, is_wall=False, is_start=False, is_exit=False):
self.x = x
self.y = y
self.is_wall = is_wall
self.is_start = is_start
self.is_exit = is_exit
def __repr__(self):
return f"Cell({self.x},{self.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
def is_passable(self):
return not self.is_wall

View File

@ -0,0 +1,23 @@
class Maze:
def __init__(self, width, height, cells, start, exit_):
self.width = width
self.height = height
self.cells = cells
self.start = start
self.exit = exit_
def get_cell(self, x, y):
return self.cells[y][x]
def get_neighbors(self, cell):
dirs = [(1,0), (-1,0), (0,1), (0,-1)]
result = []
for dx, dy in dirs:
nx, ny = cell.x + dx, cell.y + dy
if 0 <= nx < self.width and 0 <= ny < self.height:
n = self.get_cell(nx, ny)
if not n.is_wall:
result.append(n)
return result

View File

@ -0,0 +1,33 @@
from src.solver.search_stats import SearchStats
class MazeSolver:
def __init__(self, maze, strategy):
self.maze = maze
self.strategy = strategy
def solve(self):
import time
t0 = time.perf_counter()
# если выхода нет — сразу возвращаем пустой результат
if self.maze.exit is None:
t1 = time.perf_counter()
return SearchStats(
time_ms=(t1 - t0) * 1000,
visited=0,
path_len=0
)
path, visited = self.strategy.find_path(
self.maze,
self.maze.start,
self.maze.exit
)
t1 = time.perf_counter()
return SearchStats(
time_ms=(t1 - t0) * 1000,
visited=len(visited),
path_len=len(path) if path else 0
)

View File

@ -0,0 +1,8 @@
class SearchStats:
def __init__(self, time_ms, visited, path_len):
self.time_ms = time_ms
self.visited = visited
self.path_len = path_len
def __repr__(self):
return f"SearchStats(time={self.time_ms:.2f}ms, visited={self.visited}, path={self.path_len})"

View File

@ -0,0 +1,43 @@
import heapq
def manhattan(a, b):
return abs(a.x - b.x) + abs(a.y - b.y)
class AStarStrategy:
def find_path(self, maze, start, exit_):
g = {start: 0}
parent = {start: None}
counter = 0
open_heap = [(0, counter, start)]
in_open = {start}
visited = set()
while open_heap:
_, _, cur = heapq.heappop(open_heap)
in_open.discard(cur)
visited.add(cur)
if cur == exit_:
return self._reconstruct(parent, start, exit_), visited
for n in maze.get_neighbors(cur):
tentative = g[cur] + 1
if tentative < g.get(n, float('inf')):
g[n] = tentative
parent[n] = cur
f = tentative + manhattan(n, exit_)
if n not in in_open:
counter += 1
heapq.heappush(open_heap, (f, counter, n))
in_open.add(n)
return None, visited
def _reconstruct(self, parent, start, exit_):
path = []
cur = exit_
while cur:
path.append(cur)
cur = parent[cur]
return list(reversed(path))

View File

@ -0,0 +1,29 @@
from collections import deque
class BFSStrategy:
def find_path(self, maze, start, exit_):
queue = deque([start])
parent = {start: None}
visited = {start}
while queue:
cur = queue.popleft()
if cur == exit_:
return self._reconstruct(parent, start, exit_), visited
for n in maze.get_neighbors(cur):
if n not in visited:
visited.add(n)
parent[n] = cur
queue.append(n)
return None, visited
def _reconstruct(self, parent, start, exit_):
path = []
cur = exit_
while cur:
path.append(cur)
cur = parent[cur]
return list(reversed(path))

View File

@ -0,0 +1,27 @@
class DFSStrategy:
def find_path(self, maze, start, exit_):
stack = [start]
parent = {start: None}
visited = {start}
while stack:
cur = stack.pop()
if cur == exit_:
return self._reconstruct(parent, start, exit_), visited
for n in maze.get_neighbors(cur):
if n not in visited:
visited.add(n)
parent[n] = cur
stack.append(n)
return None, visited
def _reconstruct(self, parent, start, exit_):
path = []
cur = exit_
while cur:
path.append(cur)
cur = parent[cur]
return list(reversed(path))

View File

@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
from typing import List
from src.model.cell import Cell
from src.model.maze import Maze
class PathFindingStrategy(ABC):
@abstractmethod
def find_path(self, maze: Maze, start: Cell, exit_: Cell) -> List[Cell]:
pass

View File

@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass

View File

@ -0,0 +1,34 @@
import os
from typing import List
from src.model.cell import Cell
from src.model.maze import Maze
from .observer import Observer
class ConsoleView(Observer):
def update(self, event: str):
print(f"[EVENT] {event}")
def render(self, maze: Maze, player_pos: Cell = None, path: List[Cell] = None):
os.system('cls' if os.name == 'nt' else 'clear')
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_wall:
row += "#"
elif cell.is_start:
row += "S"
elif cell.is_exit:
row += "E"
elif player_pos and cell.x == player_pos.x and cell.y == player_pos.y:
row += "@"
elif cell in path_set:
row += "*"
else:
row += " "
print(row)

View File

@ -0,0 +1,18 @@
from src.model.cell import Cell
from .command import Command
from .player import Player
class MoveCommand(Command):
def __init__(self, player: Player, new_cell: Cell):
self.player = player
self.new_cell = new_cell
self.prev_cell = None
def execute(self):
self.prev_cell = self.player.current_cell
self.player.move_to(self.new_cell)
def undo(self):
if self.prev_cell:
self.player.move_to(self.prev_cell)

View File

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class Observer(ABC):
@abstractmethod
def update(self, event: str):
pass

View File

@ -0,0 +1,9 @@
from src.model.cell import Cell
class Player:
def __init__(self, start_cell: Cell):
self.current_cell = start_cell
def move_to(self, cell: Cell):
self.current_cell = cell