Шаг 26.
Задачи ComputerScience на Python.
Задачи поиска. Прохождение лабиринта. Поиск в глубину. Алгоритм DFS

    На этом шаге мы приведем реализацию алгоритма поиска в глубину.

    Понадобится учесть еще один момент, прежде чем можно будет приступить к реализации DFS. Требуется класс Node, с помощью которого мы станем отслеживать переход из одного состояния в другое (или из одного места лабиринта в другое) во время поиска. Node можно представить как обертку вокруг состояния. В случае прохождения лабиринта эти состояния имеют тип MazeLocation. Будем считать Node состоянием, унаследованным от parent. Кроме того, для класса Node мы определим свойства cost и heuristic и реализуем метод __lt__(), чтобы использовать его позже в алгоритме А* (файл generic_search.py):

class Node(Generic[T]):
    def __init__(self, state: T, parent: Optional[Node], cost: float = 0.0,
               heuristic: float = 0.0) -> None:
        self.state: T = state
        self.parent: Optional[Node] = parent
        self.cost: float = cost
        self.heuristic: float = heuristic

    def __lt__(self, other: Node) -> bool:
        return (self.cost + self.heuristic) < (other.cost + other.heuristic)


Тип Optional указывает, что переменная может ссылаться на значение параметризованного типа или на None.


Строка
  from __future__ import annotations 
в верхней части файла позволяет объектам Node ссылаться на самих себя в аннотациях типов своих методов. Без нее нам пришлось бы заключить аннотацию типа в кавычки в виде строки (например, 'Node'). В будущих версиях Python импорт annotations будет не нужен. Подробнее об этом читайте в РЕР 563 Postponed Evaluation of Annotations, https://peps.python.org/pep-0563/.

    В процессе поиска в глубину нужно отслеживать две структуры данных:

До тех пор пока в frontier остаются состояния, DFS будет продолжать проверять, являются ли они целью поиска (найдя искомое состояние, DFS остановит поиск и возвратит его), и добавлять их наследников в frontier. Также алгоритм будет помечать все просмотренные состояния как explored, чтобы поиск не ходил по кругу и уже изученные состояния не становились наследниками. Если frontier окажется пустым, это будет означать, что объектов для поиска не осталось (файл generic_search.py):
def dfs(initial: T, goal_test: Callable[[T], bool],
        successors: Callable[[T], List[T]]) -> Optional[Node[T]]:
    # frontier - то, что нам нужно проверить
    frontier: Stack[Node[T]] = Stack()
    frontier.push(Node(initial, None))
    # explored - то, где мы уже были
    explored: Set[T] = {initial}
    # продолжаем, пока есть что просматривать
    while not frontier.empty:
        current_node: Node[T] = frontier.pop()
        current_state: T = current_node.state
        # если мы нашли искомое, заканчиваем
        if goal_test(current_state):
            return current_node
        # проверяем, куда можно двинуться дальше и что мы еще не исследовали
        for child in successors(current_state):
            if child in explored:  # пропустить состояния, которые уже исследовали
                continue
            explored.add(child)
            frontier.push(Node(child, current_node))
    return None  # все проверили, пути к целевой точке не нашли

    Если dfs() завершается успешно, то возвращается Node, в котором инкапсулировано искомое состояние. Для того чтобы восстановить путь от начала до целевой ячейки, нужно двигаться в обратном направлении от этого Node к его предкам, используя свойство parent (файл generic_search.py):

def node_to_path(node: Node[T]) -> List[T]:
    path: List[T] = [node.state]
    # двигаемся назад, от конца к началу
    while node.parent is not None:
        node = node.parent
        path.append(node.state)
    path.reverse()
    return path

    В целях отображения полезно будет разметить лабиринт с указанием успешного пути, начального и конечного положений. Также хорошо иметь возможность удалить путь, чтобы можно было применить разные алгоритмы поиска для одного и того же лабиринта. Для этого в исходном файле, где описан класс Maze, добавим следующие два метода:

    def mark(self, path: List[MazeLocation]):
        for maze_location in path:
            self._grid[maze_location.row][maze_location.column] = Cell.PATH
        self._grid[self.start.row][self.start.column] = Cell.START
        self._grid[self.goal.row][self.goal.column] = Cell.GOAL

    def clear(self, path: List[MazeLocation]):
        for maze_location in path:
            self._grid[maze_location.row][maze_location.column] = Cell.EMPTY
        self._grid[self.start.row][self.start.column] = Cell.START
        self._grid[self.goal.row][self.goal.column] = Cell.GOAL

    Это было долгое путешествие, но оно подходит к копну - мы готовы пройти по лабиринту.

if __name__ == "__main__":
    # Тестирование DFS
    m: Maze = Maze()
    print(m)
    solution1: Optional[Node[MazeLocation]] = dfs(m.start, m.goal_test, m.successors)
    if solution1 is None:
        print("Поиском в глубину решение не найдено!")
    else:
        path1: List[MazeLocation] = node_to_path(solution1)
        m.mark(path1)
        print(m)
        m.clear(path1)
Архив со всеми измененными файлами можно взять здесь.

    Успешное решение будет выглядеть примерно так:


Рис.1. Результат работы приложения

    Звездочки обозначают путь от начальной до конечной точки, который нашла функция поиска в глубину. Помните: поскольку все лабиринты генерируются случайным образом, не для каждого из них существует решение.

    На следующем шаге мы рассмотрим поиск в ширину.




Предыдущий шаг Содержание Следующий шаг