19 KiB
Отчёт по заданию 2. Поиск выхода из лабиринта с применением паттернов проектирования
1. Постановка задачи
Реализовать гибкую программу для загрузки лабиринта из файла, поиска пути от старта до выхода с возможностью выбора алгоритма, текстовой визуализации и экспериментального сравнения алгоритмов. В работе нужно применить не менее трёх паттернов GoF, обосновать их выбор и продемонстрировать преимущества такой архитектуры.
В проекте применено четыре паттерна: Builder, Strategy, Observer и Command.
2. Диаграмма классов (упрощённая)
classDiagram
class Cell {
+int x, y
+bool is_wall, is_start, is_exit
+int weight
+is_passable() bool
}
class Maze {
+int width, height
+Cell start, exit_
+get_cell(x,y) Cell
+get_neighbors(cell) List~Cell~
+render_text(path, player) str
}
class MazeBuilder {
<<abstract>>
+build_from_file(filename) Maze
}
class TextFileMazeBuilder {
+build_from_file(filename) Maze
}
class PathFindingStrategy {
<<abstract>>
+name : str
+find_path(maze, start, exit_) dict
}
class BFSStrategy
class DFSStrategy
class AStarStrategy
class DijkstraStrategy
class MazeSolver {
-Maze maze
-PathFindingStrategy strategy
-List~Observer~ observers
+set_strategy(s)
+attach(o)
+solve() SearchStats
}
class Observer {
<<abstract>>
+update(event)
}
class ConsoleView
class Command {
<<abstract>>
+execute()
+undo()
}
class MoveCommand
class CommandHistory
class Player
Maze "1" o-- "*" Cell
MazeBuilder <|-- TextFileMazeBuilder
TextFileMazeBuilder ..> Maze : creates
PathFindingStrategy <|-- BFSStrategy
PathFindingStrategy <|-- DFSStrategy
PathFindingStrategy <|-- AStarStrategy
PathFindingStrategy <|-- DijkstraStrategy
MazeSolver --> Maze
MazeSolver --> PathFindingStrategy
MazeSolver --> Observer
Observer <|-- ConsoleView
Command <|-- MoveCommand
CommandHistory o-- Command
MoveCommand --> Player
MoveCommand --> Maze
3. Паттерны и их обоснование
3.1. Builder - TextFileMazeBuilder
Что делает. Принимает имя файла, читает его, проверяет символы, ставит
координаты, создаёт Cell-объекты, находит S и E, валидирует
(ровно один старт и один выход) и возвращает готовый Maze.
Зачем нужен. Конструирование лабиринта - это многошаговый процесс:
парсинг + валидация + расстановка флагов + поддержка взвешенных клеток
(, песок, ~ болото, . асфальт). Если положить всё это в конструктор
Maze, класс получится «толстым» и неудобным для расширения.
Что даёт. Чтобы добавить новый формат (например, JSON или бинарный),
достаточно реализовать ещё один класс с тем же интерфейсом
MazeBuilder.build_from_file. Остальной код не меняется.
3.2. Strategy - PathFindingStrategy
Что делает. Объявляет единый интерфейс find_path(maze, start, exit_).
Имеет четыре реализации: BFSStrategy, DFSStrategy, AStarStrategy,
DijkstraStrategy. Возвращают одинаковую структуру:
{'path': [Cell, ...], 'visited': int}.
Зачем нужен. Все четыре алгоритма решают одну задачу, но с разными компромиссами (скорость vs оптимальность vs учёт весов). Strategy позволяет переключать их в рантайме одной строкой:
solver.set_strategy(AStarStrategy())
без вмешательства в код решателя или модели лабиринта.
Что даёт. Чтобы добавить, скажем, двунаправленный BFS, нужно лишь
написать новый класс - ни MazeSolver, ни Maze ничего не узнают
о нововведении.
3.3. Observer - MazeSolver уведомляет ConsoleView
Что делает. MazeSolver хранит список наблюдателей и шлёт им события:
maze_loaded, search_start, search_end, path_found, no_path.
ConsoleView подписывается и пишет в консоль.
Зачем нужен. Решатель не должен знать, кто и как показывает
лабиринт пользователю. Можно подключить (или отключить) сразу несколько
наблюдателей - например, ConsoleView для отладки и CSVLogger
для эксперимента - не меняя MazeSolver.
3.4. Command - MoveCommand с undo через CommandHistory
Что делает. MoveCommand инкапсулирует один шаг игрока: сохраняет
предыдущую позицию, перемещает игрока в новое место. Метод undo
возвращает игрока обратно. CommandHistory ведёт стек выполненных команд
(общий undo).
Зачем нужен. Ручное прохождение лабиринта = последовательность шагов,
каждый из которых должен быть откатываемым. Pattern Command даёт это
естественно и расширяемо: завтра можно добавить MacroCommand
(серия ходов) и redo - стек повторов.
4. Этап 1-5: реализация
4.1. Алгоритмы
| Алгоритм | Структура данных | Учитывает веса? | Гарантирует кратчайший путь? |
|---|---|---|---|
| BFS | очередь (deque) |
нет | да, по числу шагов |
| DFS | стек (list) |
нет | нет |
| A* | приоритетная очередь (heapq), эвристика - манхэттенское расстояние |
да | да (если эвристика допустимая) |
| Dijkstra | приоритетная очередь | да | да |
Все четыре пишут предшественников в словарь parents, и в конце путь
восстанавливается общей функцией _reconstruct(...).
4.2. Демонстрация (фрагмент вывода demo.py)
=== Builder: загружаем small_10x10.txt ===
[ConsoleView] лабиринт 10x10 загружен
...
=== Strategy: пробуем все 4 алгоритма ===
[ConsoleView] старт поиска: BFS
[ConsoleView] поиск окончен: путь=16, посещено=34, время=0.046 мс
--- BFS путь длиной 16 ---
##########
#S.......#
# ######.#
# #.#
###### #.#
# # #.#
# ## # #.#
# # #.#
# ##### .E
##########
=== Command: пройдёмся вручную и сделаем undo ===
стартовая позиция: (1,1)
move D: ok -> (2,1)
move D: ok -> (3,1)
move D: ok -> (4,1)
move D: ok -> (5,1)
Откатываем 2 хода (undo, undo):
теперь игрок в (3,1)
5. Этап 6. Экспериментальная часть
5.1. Подготовка лабиринтов
| Файл | Размер | Описание |
|---|---|---|
small_10x10.txt |
10×10 | ручной с простым путём |
medium_51x51.txt |
51×51 | сгенерированный (DFS-карвер), тупики |
large_101x101.txt |
101×101 | сгенерированный (DFS-карвер), запутанный |
empty_30x30.txt |
30×30 | пустая комната - нет внутренних стен |
nopath_15x15.txt |
15×15 | выход замурован - пути нет |
weighted_31x31.txt |
31×31 | перфектный лабиринт + взвешенные клетки |
weighted_choice.txt |
21×13 | есть выбор маршрута: через болото (короче) или вокруг (дешевле) |
Все лабиринты генерирует generate_mazes.py (+ ручной generate_weighted_choice.py).
DFS-карвер реализован итеративно - для 101×101 рекурсивный вариант ловит
RecursionError.
5.2. Замеры
Для каждой пары (лабиринт × стратегия) запускали solve() 7 раз,
усредняли время. Для пути и числа посещённых клеток между запусками
изменений нет (алгоритмы детерминированы) - фиксируем одно значение.
Полные результаты - в data/results.csv.
Сводная таблица (средние значения)
| Лабиринт | Стратегия | t, мс | посещено | длина пути | стоимость |
|---|---|---|---|---|---|
| small_10x10 | BFS | 0.043 | 34 | 16 | 16 |
| DFS | 0.015 | 18 | 16 | 16 | |
| A* | 0.043 | 27 | 16 | 16 | |
| Dijkstra | 0.044 | 33 | 16 | 16 | |
| medium_51x51 | BFS | 0.50 | 524 | 353 | 353 |
| DFS | 0.34 | 379 | 353 | 353 | |
| A* | 0.69 | 421 | 353 | 353 | |
| Dijkstra | 0.74 | 523 | 353 | 353 | |
| large_101x101 | BFS | 2.08 | 2143 | 1265 | 1265 |
| DFS | 1.35 | 1443 | 1265 | 1265 | |
| A* | 3.61 | 1831 | 1265 | 1265 | |
| Dijkstra | 3.36 | 2139 | 1265 | 1265 | |
| empty_30x30 | BFS | 0.79 | 784 | 55 | 55 |
| DFS | 0.47 | 784 | 379 | 379 | |
| A* | 1.53 | 784 | 55 | 55 | |
| Dijkstra | 1.34 | 784 | 55 | 55 | |
| nopath_15x15 | все | ≈0.2 | 165 | 0 | 0 |
| weighted_31x31 | все | 0.3–0.7 | 318–433 | 265 | 391 |
| weighted_choice | BFS | 0.18 | 189 | 19 | 29 |
| DFS | 0.03 | 55 | 19 | 29 | |
| A* | 0.26 | 117 | 25 | 25 | |
| Dijkstra | 0.36 | 209 | 25 | 25 |
5.3. Графики
6. Анализ результатов
6.1. На «обычных» перфектных лабиринтах путь единственный
В лабиринтах, построенных DFS-карвером (medium_51x51, large_101x101,
weighted_31x31), между любыми двумя клетками существует ровно один путь.
Поэтому все четыре алгоритма находят его одинаковой длины (353, 1265, 265).
Различаются только время и число посещённых клеток - это и есть мера
«работы» алгоритма.
- DFS - самый быстрый и обходит меньше всего клеток. Ему «везёт»: на перфектном лабиринте он не возвращается, пока не упрётся в тупик.
- BFS - обходит чуть больше, потому что развивает фронт во всех направлениях.
- A* и Dijkstra дороже по времени из-за
heapq, но A* экономит посещения благодаря эвристике (на large_101x101: 1831 у A* vs 2143 у BFS).
6.2. На пустом лабиринте - главная разница между BFS/A*/Dijkstra и DFS
empty_30x30 - это комната 28×28 проходимых клеток. Кратчайший путь между
противоположными углами - ровно 55 шагов.
- BFS, A*, Dijkstra находят его (длина = 55).
- DFS находит путь длиной 379 - он петляет по краям комнаты, потому что «жадно» идёт в первое попавшееся направление и никогда не возвращается, пока не упрётся.
Этот результат хорошо иллюстрирует: DFS быстр, но даёт плохой путь на открытых пространствах. Если важна оптимальность - DFS не подходит.
6.3. На взвешенном лабиринте с альтернативами - победа Dijkstra и A*
Лабиринт weighted_choice (21×13): открытая комната, в центре - болото 5×5
(вес 3 за каждую клетку). Между стартом слева и выходом справа есть два
маршрута:
- «прямо через болото» - короче в клетках, но каждая болотная клетка стоит 3;
- «вокруг болота» - длиннее в клетках, но каждая стоит 1.
Результаты:
- BFS и DFS: путь 19 клеток, стоимость 29 (3 болотные × 3 = 9 «лишних» единиц).
- A* и Dijkstra: путь 25 клеток, но стоимость 25 - на 4 единицы дешевле, потому что они учитывают вес клетки.
Это и есть классическое преимущество взвешенных алгоритмов: если шаги стоят по-разному (болото, песок, бездорожье), Dijkstra/A* находят оптимальный путь, а BFS/DFS - нет.
6.4. На лабиринте без выхода
nopath_15x15: все алгоритмы обходят все 165 проходимых клеток и возвращают
пустой путь. Время одинаковое - это, по сути, полный обход. Этот тест
показывает, что все четыре стратегии корректно обрабатывают случай
отсутствия пути (важная проверка).
6.5. Время поиска ≠ качество пути
Иерархия по скорости стабильна: DFS < BFS < Dijkstra ≲ A*. Но «быстрее»
не значит «лучше»: на empty_30x30 DFS быстрее всех в 2 раза, но его путь
в 7 раз длиннее оптимального. На взвешенном лабиринте - BFS быстрее A*,
но даёт более дорогой путь.
Вывод по алгоритмам:
| Когда подходит | Что выбрать |
|---|---|
| Минимум числа шагов на одинаковых клетках | BFS |
| Нужно быстро найти хоть какой-то путь | DFS |
| Взвешенный граф, есть хорошая эвристика | A* (быстрее Dijkstra) |
| Взвешенный граф, эвристики нет | Dijkstra |
7. Чем помогли паттерны
Без паттернов код бы выглядел как один большой скрипт с if maze_format == 'txt'
и if algorithm == 'bfs'. Что я получил с паттернами:
- Builder - добавить новый формат лабиринта (JSON, графический PNG, генератор) = новый класс, всё остальное не трогаем.
- Strategy - добавить новый алгоритм (двунаправленный BFS, IDA*) = новый
класс.
MazeSolverне меняется. - Observer -
MazeSolverничего не знает про вывод. Я могу подключитьConsoleViewдля интерактива иCSVLoggerдля эксперимента одновременно. Эксперимент это и делает: подключает «тихого» наблюдателя. - Command - ручное прохождение и
undoполучаются естественно. Расширить доredo- добавить второй стек.
Что было бы сложно изменить без паттернов:
- Сменить алгоритм поиска в рантайме без
if/elif-простыни. - Добавить второй вид визуализации (например, GUI) без затрагивания решателя.
- Поддержать сразу два формата лабиринта.
8. Выводы
- Реализованы четыре алгоритма поиска пути и четыре паттерна проектирования.
- Эксперимент подтвердил классические свойства алгоритмов: DFS быстрый, но не оптимальный; BFS оптимален по шагам; A*/Dijkstra оптимальны по стоимости; A* быстрее Dijkstra при наличии хорошей эвристики.
- Особенно выпукло разница видна на двух «диагностических» лабиринтах:
empty_30x30(DFS даёт «уродский» путь в 7 раз длиннее) иweighted_choice(BFS/DFS режут через болото, Dijkstra/A* обходят). - Паттерны Builder/Strategy/Observer/Command превратили проект из «скрипта» в расширяемое приложение. Новый формат, новый алгоритм или новый вид визуализации добавляется без правки существующего кода (принцип Open/Closed).



