2026-rff_mp/SobolevNS/docs/report_02.md
2026-05-22 13:48:52 +03:00

365 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Отчёт по заданию 2. Поиск выхода из лабиринта с применением паттернов проектирования
## 1. Постановка задачи
Реализовать гибкую программу для загрузки лабиринта из файла, поиска пути от
старта до выхода с возможностью выбора алгоритма, текстовой визуализации
и экспериментального сравнения алгоритмов. В работе нужно применить
**не менее трёх паттернов GoF**, обосновать их выбор и продемонстрировать
преимущества такой архитектуры.
В проекте применено **четыре паттерна**: **Builder**, **Strategy**,
**Observer** и **Command**.
## 2. Диаграмма классов (упрощённая)
```mermaid
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 позволяет
переключать их в рантайме одной строкой:
```python
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.30.7 | 318433 | 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. Графики
![Время поиска](data/task2_maze/docs/data/plots/time_compare.png)
![Сколько клеток посетил алгоритм](data/task2_maze/docs/data/plots/visited_compare.png)
![Длина найденного пути](data/task2_maze/docs/data/plots/path_compare.png)
![weighted_choice: длина vs стоимость](data/task2_maze/docs/data/plots/weighted_choice_compare.png)
## 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'`. Что я получил с паттернами:
1. **Builder** - добавить новый формат лабиринта (JSON, графический PNG, генератор)
= новый класс, всё остальное не трогаем.
2. **Strategy** - добавить новый алгоритм (двунаправленный BFS, IDA\*) = новый
класс. `MazeSolver` не меняется.
3. **Observer** - `MazeSolver` ничего не знает про вывод. Я могу подключить
`ConsoleView` для интерактива и `CSVLogger` для эксперимента одновременно.
Эксперимент это и делает: подключает «тихого» наблюдателя.
4. **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).