This commit is contained in:
Smirnovvs 2026-09-04 17:37:01 +00:00
parent b5f5372e4f
commit 0fe5753b41
5 changed files with 421 additions and 0 deletions

View File

@ -0,0 +1,64 @@
import argparse
import csv
from pathlib import Path
from statistics import mean
from generate_mazes import generate_all
from maze_app import AStarStrategy, BFSStrategy, DFSStrategy, MazeSolver, TextFileMazeBuilder
STRATEGIES = (BFSStrategy, DFSStrategy, AStarStrategy)
def run_experiment(repeats=7, maze_dir="mazes", output_dir="docs/data"):
generate_all(maze_dir)
builder = TextFileMazeBuilder()
rows = []
for maze_path in sorted(Path(maze_dir).glob("*.txt")):
maze = builder.build_from_file(maze_path)
for strategy_type in STRATEGIES:
for run in range(1, repeats + 1):
stats = MazeSolver(maze, strategy_type()).solve()
rows.append({
"maze": maze_path.stem,
"strategy": stats.strategy,
"run": run,
"time_ms": stats.time_ms,
"visited_cells": stats.visited_cells,
"path_length": stats.path_length,
"path_found": bool(stats.path),
})
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
raw_path = output / "maze_results_raw.csv"
with raw_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
groups = {}
for row in rows:
groups.setdefault((row["maze"], row["strategy"]), []).append(row)
summary = []
for (maze_name, strategy), values in groups.items():
summary.append({
"maze": maze_name,
"strategy": strategy,
"mean_time_ms": mean(row["time_ms"] for row in values),
"mean_visited_cells": mean(row["visited_cells"] for row in values),
"path_length": values[0]["path_length"],
"path_found": values[0]["path_found"],
})
summary_path = output / "maze_results_summary.csv"
with summary_path.open("w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=summary[0].keys())
writer.writeheader()
writer.writerows(summary)
return raw_path, summary_path
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Сравнение алгоритмов поиска пути")
parser.add_argument("--repeats", type=int, default=7)
args = parser.parse_args()
print("Результаты:", *run_experiment(args.repeats), sep="\n")

View File

@ -0,0 +1,52 @@
"""Генерация воспроизводимых тестовых лабиринтов."""
import random
from pathlib import Path
def obstacle_maze(width, height, wall_probability, seed):
rng = random.Random(seed)
grid = [["#" if x in (0, width - 1) or y in (0, height - 1) else " " for x in range(width)] for y in range(height)]
for y in range(1, height - 1):
for x in range(1, width - 1):
if rng.random() < wall_probability:
grid[y][x] = "#"
# Оставляем гарантированный путь по верхней и правой внутренним границам.
for x in range(1, width - 1):
grid[1][x] = " "
for y in range(1, height - 1):
grid[y][width - 2] = " "
grid[1][1], grid[height - 2][width - 2] = "S", "E"
return "\n".join("".join(row) for row in grid) + "\n"
def empty_maze(width=50, height=50):
return obstacle_maze(width, height, 0, 1)
def blocked_maze(width=30, height=30):
lines = empty_maze(width, height).splitlines()
grid = [list(line) for line in lines]
exit_y, exit_x = height - 2, width - 2
grid[exit_y - 1][exit_x] = "#"
grid[exit_y][exit_x - 1] = "#"
return "\n".join("".join(row) for row in grid) + "\n"
def generate_all(output_dir="mazes"):
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
maps = {
"small_10x10.txt": obstacle_maze(10, 10, 0.12, 10),
"medium_50x50.txt": obstacle_maze(50, 50, 0.28, 50),
"large_100x100.txt": obstacle_maze(100, 100, 0.32, 100),
"empty_50x50.txt": empty_maze(),
"no_path_30x30.txt": blocked_maze(),
}
for filename, content in maps.items():
(output / filename).write_text(content, encoding="utf-8")
return list(maps)
if __name__ == "__main__":
print("Созданы файлы:", ", ".join(generate_all()))

View File

@ -0,0 +1,24 @@
import argparse
from maze_app import AStarStrategy, BFSStrategy, ConsoleView, DFSStrategy, MazeSolver, TextFileMazeBuilder
STRATEGIES = {"bfs": BFSStrategy, "dfs": DFSStrategy, "astar": AStarStrategy}
def main():
parser = argparse.ArgumentParser(description="Поиск выхода из лабиринта")
parser.add_argument("maze", nargs="?", default="mazes/small_10x10.txt")
parser.add_argument("--algorithm", choices=STRATEGIES, default="bfs")
args = parser.parse_args()
maze = TextFileMazeBuilder().build_from_file(args.maze)
view = ConsoleView()
solver = MazeSolver(maze, STRATEGIES[args.algorithm]())
solver.attach(view)
stats = solver.solve()
print(view.render(maze, stats.path))
print(f"Время: {stats.time_ms:.4f} мс; посещено: {stats.visited_cells}; длина пути: {stats.path_length}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,246 @@
"""Модель лабиринта и алгоритмы поиска с паттернами Builder, Strategy, Observer."""
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from heapq import heappop, heappush
from itertools import count
from pathlib import Path
from time import perf_counter
@dataclass(frozen=True)
class Cell:
x: int
y: int
is_wall: bool = False
is_start: bool = False
is_exit: bool = False
def is_passable(self):
return not self.is_wall
class Maze:
def __init__(self, cells):
if not cells or not cells[0]:
raise ValueError("Лабиринт не может быть пустым")
self.cells = cells
self.height = len(cells)
self.width = len(cells[0])
self.start = next((cell for row in cells for cell in row if cell.is_start), None)
self.exit = next((cell for row in cells for cell in row if cell.is_exit), None)
def get_cell(self, x, y):
if 0 <= x < self.width and 0 <= y < self.height:
return self.cells[y][x]
return None
def get_neighbors(self, cell):
neighbors = []
for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
neighbor = self.get_cell(cell.x + dx, cell.y + dy)
if neighbor is not None and neighbor.is_passable():
neighbors.append(neighbor)
return neighbors
class MazeBuilder(ABC):
@abstractmethod
def build_from_file(self, filename):
pass
class TextFileMazeBuilder(MazeBuilder):
SYMBOLS = {"#", " ", "S", "E"}
def build_from_file(self, filename):
lines = Path(filename).read_text(encoding="utf-8").splitlines()
if not lines or not lines[0] or any(len(line) != len(lines[0]) for line in lines):
raise ValueError("Строки лабиринта должны иметь одинаковую ненулевую длину")
unknown = {character for line in lines for character in line} - self.SYMBOLS
if unknown:
raise ValueError(f"Недопустимые символы: {sorted(unknown)}")
if sum(line.count("S") for line in lines) != 1 or sum(line.count("E") for line in lines) != 1:
raise ValueError("Лабиринт должен содержать ровно один старт S и один выход E")
cells = []
for y, line in enumerate(lines):
cells.append([
Cell(x, y, symbol == "#", symbol == "S", symbol == "E")
for x, symbol in enumerate(line)
])
return Maze(cells)
class PathFindingStrategy(ABC):
name = "Неизвестно"
def __init__(self):
self.visited_count = 0
@abstractmethod
def find_path(self, maze, start, exit_cell):
pass
@staticmethod
def restore_path(parent, start, exit_cell):
if exit_cell not in parent:
return []
path, current = [], exit_cell
while current is not None:
path.append(current)
current = parent[current]
path.reverse()
return path if path and path[0] == start else []
class BFSStrategy(PathFindingStrategy):
name = "BFS"
def find_path(self, maze, start, exit_cell):
queue = deque([start])
parent = {start: None}
visited = 0
while queue:
current = queue.popleft()
visited += 1
if current == exit_cell:
break
for neighbor in maze.get_neighbors(current):
if neighbor not in parent:
parent[neighbor] = current
queue.append(neighbor)
self.visited_count = visited
return self.restore_path(parent, start, exit_cell)
class DFSStrategy(PathFindingStrategy):
name = "DFS"
def find_path(self, maze, start, exit_cell):
stack = [start]
parent = {start: None}
visited = 0
while stack:
current = stack.pop()
visited += 1
if current == exit_cell:
break
for neighbor in maze.get_neighbors(current):
if neighbor not in parent:
parent[neighbor] = current
stack.append(neighbor)
self.visited_count = visited
return self.restore_path(parent, start, exit_cell)
class AStarStrategy(PathFindingStrategy):
name = "A*"
@staticmethod
def heuristic(first, second):
return abs(first.x - second.x) + abs(first.y - second.y)
def find_path(self, maze, start, exit_cell):
order = count()
queue = [(self.heuristic(start, exit_cell), next(order), start)]
distance = {start: 0}
parent = {start: None}
closed = set()
while queue:
_, _, current = heappop(queue)
if current in closed:
continue
closed.add(current)
if current == exit_cell:
break
for neighbor in maze.get_neighbors(current):
new_distance = distance[current] + 1
if new_distance < distance.get(neighbor, float("inf")):
distance[neighbor] = new_distance
parent[neighbor] = current
priority = new_distance + self.heuristic(neighbor, exit_cell)
heappush(queue, (priority, next(order), neighbor))
self.visited_count = len(closed)
return self.restore_path(parent, start, exit_cell)
@dataclass
class SearchStats:
strategy: str
time_ms: float
visited_cells: int
path_length: int
path: list = field(repr=False)
class Observer(ABC):
@abstractmethod
def update(self, event):
pass
class ConsoleView(Observer):
def __init__(self, verbose=True):
self.verbose = verbose
self.events = []
def update(self, event):
self.events.append(event)
if self.verbose:
print(event["message"])
def render(self, maze, path=None):
path_cells = set(path or [])
rows = []
for row in maze.cells:
symbols = []
for cell in row:
if cell.is_start:
symbols.append("S")
elif cell.is_exit:
symbols.append("E")
elif cell.is_wall:
symbols.append("#")
elif cell in path_cells:
symbols.append(".")
else:
symbols.append(" ")
rows.append("".join(symbols))
return "\n".join(rows)
class MazeSolver:
def __init__(self, maze, strategy):
self.maze = maze
self.strategy = strategy
self.observers = []
def set_strategy(self, strategy):
self.strategy = strategy
def attach(self, observer):
if observer not in self.observers:
self.observers.append(observer)
def notify(self, event):
for observer in self.observers:
observer.update(event)
def solve(self):
self.notify({"type": "search_started", "message": f"Запущен алгоритм {self.strategy.name}"})
started = perf_counter()
path = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit)
elapsed_ms = max((perf_counter() - started) * 1000, 1e-9)
stats = SearchStats(
strategy=self.strategy.name,
time_ms=elapsed_ms,
visited_cells=self.strategy.visited_count,
path_length=max(len(path) - 1, 0),
path=path,
)
event_type = "path_found" if path else "path_not_found"
message = f"{self.strategy.name}: путь длиной {stats.path_length}" if path else f"{self.strategy.name}: путь не найден"
self.notify({"type": event_type, "message": message, "stats": stats})
return stats

View File

@ -0,0 +1,35 @@
import csv
from pathlib import Path
import matplotlib.pyplot as plt
def create_plot(csv_path="docs/data/maze_results_summary.csv", output_path="docs/data/maze_performance.png"):
with Path(csv_path).open(encoding="utf-8-sig") as file:
rows = list(csv.DictReader(file))
mazes = sorted({row["maze"] for row in rows})
strategies = ["BFS", "DFS", "A*"]
colors = {"BFS": "#4472C4", "DFS": "#ED7D31", "A*": "#70AD47"}
figure, axes = plt.subplots(2, 1, figsize=(12, 9))
positions = range(len(mazes))
width = 0.24
for index, strategy in enumerate(strategies):
selected = [next(row for row in rows if row["maze"] == maze and row["strategy"] == strategy) for maze in mazes]
offsets = [position + (index - 1) * width for position in positions]
axes[0].bar(offsets, [float(row["mean_time_ms"]) for row in selected], width, label=strategy, color=colors[strategy])
axes[1].bar(offsets, [float(row["mean_visited_cells"]) for row in selected], width, label=strategy, color=colors[strategy])
for axis, ylabel, title in zip(axes, ["Время, мс", "Посещено клеток"], ["Среднее время поиска", "Объём исследования лабиринта"]):
axis.set_xticks(list(positions), mazes, rotation=20, ha="right")
axis.set_ylabel(ylabel)
axis.set_title(title)
axis.grid(axis="y", alpha=0.25)
axis.legend()
figure.suptitle("Сравнение стратегий поиска (средние значения)")
figure.tight_layout()
figure.savefig(output_path, dpi=180)
plt.close(figure)
return Path(output_path)
if __name__ == "__main__":
print(f"График сохранён: {create_plot()}")