Merge pull request '[2] lab2' (#350) from BrychkinKA/2026-rff_mp:task2 into develop

Reviewed-on: UNN/2026-rff_mp#350
This commit is contained in:
IvanBoy 2026-05-30 11:40:27 +00:00
commit 5e3f0b10e7
30 changed files with 793 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,135 @@
# Отчёт по заданию №2
### Реализация поиска пути в лабиринте с использованием паттернов проектирования
---
## 1. Цель работы
Разработать архитектуру и реализацию системы поиска пути в лабиринте, применив паттерны:
- Builder — построение лабиринта из файла
- Strategy — выбор алгоритма поиска
- Observer — отображение состояния
- Command — управление игроком
Также провести экспериментальное сравнение алгоритмов BFS, DFS и A\*.
---
## 2. Архитектура проекта
Структура каталогов:
```
BrychkinKA/
├── 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/plot_graphs.py`
- время работы алгоритмов
- количество посещённых клеток
---
## 8. Выводы
1. A\* показывает лучшие результаты на средних и больших лабиринтах, но имеет небольшой накладной расход.
2. DFS посещает меньше клеток, но не гарантирует кратчайший путь.
3. BFS всегда находит кратчайший путь, но исследует больше пространства.
4. На лабиринтах без выхода все алгоритмы корректно возвращают `path_len = 0`.
5. Архитектура с паттернами позволяет легко расширять проект и добавлять новые алгоритмы.
---
## 9. Приложения
- Исходный код
- Лабиринты
- CSV с результатами
- Диаграммы

View File

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

View File

@ -0,0 +1,65 @@
import os
import sys
import csv
from time import perf_counter
# Добавляем корневую папку BrychkinKA в sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
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()
}
# Папка с лабиринтами относительно корня
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
maze_dir = os.path.join(root_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}")
# Сохраняем results.csv в папку experiments
output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.csv")
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["maze", "algorithm", "time_ms", "visited", "path_len"])
writer.writerows(results)
print(f"\nРезультаты сохранены в {output_path}")
if __name__ == "__main__":
run_experiments()

View File

@ -0,0 +1,76 @@
import csv
import matplotlib.pyplot as plt
import os
def plot_results():
# Определяем правильный путь к results.csv
script_dir = os.path.dirname(os.path.abspath(__file__))
csv_path = os.path.join(script_dir, "results.csv")
results = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
row['time_ms'] = float(row['time_ms'])
row['visited'] = int(row['visited'])
row['path_len'] = int(row['path_len'])
results.append(row)
mazes = sorted(set(r['maze'] for r in results))
algorithms = sorted(set(r['algorithm'] for r in results))
x_labels = []
for m in mazes:
for a in algorithms:
x_labels.append(f"{m.replace('.txt','')}\n{a}")
# График 1: Время выполнения
plt.figure(figsize=(12, 6))
times = []
for m in mazes:
for a in algorithms:
val = [r['time_ms'] for r in results if r['maze'] == m and r['algorithm'] == a]
times.append(val[0] if val else 0)
plt.bar(x_labels, times)
plt.ylabel("Время (мс)")
plt.title("Сравнение времени выполнения алгоритмов")
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.savefig(os.path.join(script_dir, "plot_time.png"), dpi=150)
plt.close()
print("Сохранён: experiments/plot_time.png")
# График 2: Посещённые клетки
plt.figure(figsize=(12, 6))
visited_list = []
for m in mazes:
for a in algorithms:
val = [r['visited'] for r in results if r['maze'] == m and r['algorithm'] == a]
visited_list.append(val[0] if val else 0)
plt.bar(x_labels, visited_list)
plt.ylabel("Посещено клеток")
plt.title("Сравнение количества посещённых клеток")
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.savefig(os.path.join(script_dir, "plot_visited.png"), dpi=150)
plt.close()
print("Сохранён: experiments/plot_visited.png")
# График 3: Длина пути
plt.figure(figsize=(12, 6))
path_list = []
for m in mazes:
for a in algorithms:
val = [r['path_len'] for r in results if r['maze'] == m and r['algorithm'] == a]
path_list.append(val[0] if val else 0)
plt.bar(x_labels, path_list)
plt.ylabel("Длина пути")
plt.title("Сравнение длины найденного пути")
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.savefig(os.path.join(script_dir, "plot_path.png"), dpi=150)
plt.close()
print("Сохранён: experiments/plot_path.png")
if __name__ == "__main__":
plot_results()

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,16 @@
maze,algorithm,time_ms,visited,path_len
big.txt,BFS,0.14230050146579742,27,0
big.txt,DFS,0.1100003719329834,27,0
big.txt,A*,0.23249909281730652,27,0
empty.txt,BFS,0.07219985127449036,10,10
empty.txt,DFS,0.046100467443466187,10,10
empty.txt,A*,0.08819997310638428,10,10
medium.txt,BFS,0.09160116314888,21,17
medium.txt,DFS,0.07379986345767975,19,17
medium.txt,A*,0.15410035848617554,21,17
no_exit.txt,BFS,0.0007003545761108398,0,0
no_exit.txt,DFS,0.0027008354663848877,0,0
no_exit.txt,A*,0.0001993030309677124,0,0
small.txt,BFS,0.06789900362491608,7,7
small.txt,DFS,0.03989972174167633,7,7
small.txt,A*,0.09530037641525269,7,7
1 maze algorithm time_ms visited path_len
2 big.txt BFS 0.14230050146579742 27 0
3 big.txt DFS 0.1100003719329834 27 0
4 big.txt A* 0.23249909281730652 27 0
5 empty.txt BFS 0.07219985127449036 10 10
6 empty.txt DFS 0.046100467443466187 10 10
7 empty.txt A* 0.08819997310638428 10 10
8 medium.txt BFS 0.09160116314888 21 17
9 medium.txt DFS 0.07379986345767975 19 17
10 medium.txt A* 0.15410035848617554 21 17
11 no_exit.txt BFS 0.0007003545761108398 0 0
12 no_exit.txt DFS 0.0027008354663848877 0 0
13 no_exit.txt A* 0.0001993030309677124 0 0
14 small.txt BFS 0.06789900362491608 7 7
15 small.txt DFS 0.03989972174167633 7 7
16 small.txt A* 0.09530037641525269 7 7

13
BrychkinKA/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,36 @@
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 (старт)")
return Maze(width, height, cells, start, exit_)

101
BrychkinKA/src/main.py Normal file
View File

@ -0,0 +1,101 @@
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,32 @@
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,10 @@
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass

View File

@ -0,0 +1,33 @@
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,17 @@
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,6 @@
from abc import ABC, abstractmethod
class Observer(ABC):
@abstractmethod
def update(self, event: str):
pass

View File

@ -0,0 +1,8 @@
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