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

19 KiB
Raw Blame History

Отчёт по заданию 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.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. Графики

Время поиска

Сколько клеток посетил алгоритм

Длина найденного пути

weighted_choice: длина vs стоимость

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).