Антон Тмур

Наставник курса "Алгоритмы и структуры данных"

В алгоритмических задачах на графах мы часто используем четыре известных алгоритма. Два из них — это алгоритмы обхода графа: Поиск в ширину и Поиск в глубину. Ещё два, A* и алгоритм Дейкстры, используются для нахождения оптимального пути внутри графа от одного узла до другого.

Я покажу, что все эти четыре алгоритма — это по сути один и тот же алгоритм. И вся разница между ними — только в разных структурах данных, используемых для хранения непосещённых узлов графа.

Задачка для примера

Сначала рассмотрим задачу поиска пути в небольшом простом графе:

Задача поиска пути в графе
Задача поиска пути в графе

Двигаясь от узла к узлу, мы хотим найти путь от start до target.

Самое очевидное, что мы можем сделать, — это использовать один из известных алгоритмов обхода графа: поиск в глубину или в ширину. Опишем типовой алгоритм поиска пути между двумя узлами в общем виде.

Тот самый один алгоритм

Мы начинаем с узла start и можем двигаться по рёбрам графа от узла к узлу. На каждой итерации алгоритма у нас есть некоторый узел, который мы называем текущим, и именно на нём сосредоточено наше внимание. Мы исследуем его соседей, то есть узлы, к которым потенциально можем перейти на следующей итерации. 

Помимо текущего узла нам нужно где-то хранить ещё не посещённые узлы, куда можно перейти на следующей итерации. Это узлы, которые мы уже встречали как соседей, но не рассматривали в качестве текущего. Хранить такие узлы будем в специальном хранилище, которое назовём nodes_storage.

Для текущего узла всегда проверяем, является ли он целевым. Если да, значит мы прибыли: путь от start до target найден, и алгоритм поиска может быть прерван. В противном случае мы должны рассмотреть узлы, соседние с текущим, и добавить их в хранилище nodes_storage.

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

  • белый —  мы ещё не посещали и даже не встречали этот узел;

  • серый —  мы встречали этот узел в качестве соседа и уже добавили его в nodes_storage, но ещё не посещали его;

  • чёрный  —  этот узел посещён и больше не нуждается в анализе.

Изначально все узлы белые. Если мы встречаем узел как соседа, мы окрашиваем его в серый. И только когда мы посетили узел, обработали всех его соседей, и он больше не нужен — красим его в чёрный цвет.

Псевдокод алгоритма
Псевдокод алгоритма

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

Переходим к коду

Когда мы описали алгоритм в общих чертах, пришло время погрузиться в код — напишем этот алгоритм на языке Python.

Пока мы не будем прямо указывать структуру данных для хранения непосещённых узлов, назвав её AbstractNodeStorageClass. Нам нужно только знать, что этот класс содержит три метода: insert  —  для добавления узла в хранилище, get_first  —  для извлечения одного элемента из хранилища и is_empty  — для проверки, не опустело ли хранилище.

Получается вот такой код:

def find_path(
        graph: Graph,
        start_node: int,
        target_node: int,
        nodes_storage_structure_name: AbstractNodeStorageClass,
    ) -> bool:
    """
        Универсальный алгоритм обхода графа в поисках пути между 
        начальным (start) и целевым (target) узлами, используя 
        структуру графа и вспомогательную структуру nodes_storage.
        Возвращает True, если путь найден.
    """

    # красим все узлы в белый
    color = ['white'] * graph.number_of_nodes()  
    # в начале поиска расстояние до всех узлов кроме начального равны ∞
    dist = [float('Inf')] * graph.number_of_nodes()
    dist[start_node] = 0

    # положим start_node внутрь nodes_storage
    nodes_storage.insert(start_node)

    # цикл пока внутри nodes_storage есть узлы
    while not nodes_storage.is_empty():
        current_node = nodes_storage.get_first()

        if current_node == target_node:
            # конец поиска, целевой узел найден, а значит и путь до него
            return True

        # возьмём всех соседей текущего узла
        neighbours = list(graph.adj[current_node])
        for node_to_go in neighbours:
            # если этот узел встречается впервые
            if color[node_to_go] == 'white':
                # красим его в серый
                color[node_to_go] = 'grey'   

                # обновляем расстояние от стартового узла
                dist[node_to_go] = dist[current_node] + \
                  graph.get_edge_data(node_to_go, current_node)['weight']
                
                # добавляем узел в nodes_storage
                nodes_storage.insert(node_to_go)  
            else:
                # иначе нам нужно решить конфликт дублирования (два пути 
                # к одному узлу). Выбираем из двух путей более короткий.
                weight_from_current_node = graph.get_edge_data(node_to_go, current_node)['weight']
                if dist[current_node] + weight_from_current_node < dist[node_to_go]:
                    dist[node_to_go] = dist[current_node] + weight_from_current_node

        # красим текущий узел в чёрный, он нам больше не интересен
        color[current_node] = 'black'
    return False

Вот и всё! Это единственный алгоритм, который нам понадобится. Подставляя разные структуры данных в качестве хранилища, мы получим разные классические алгоритмы поиска пути в графе.

Поиск в глубину

Если мы используем стек в качестве хранилища непосещённых узлов, то описанный выше алгоритм превращается в Поиск в глубину.

Давайте проверим. Возьмём класс, который реализует хранение непосещённых узлов по принципу стека. То есть метод get_first возвращает элемент, добавленный последним:

Код стека
class Stack(AbstractNodeStorageClass):
    """
        Стек работает по принципу LIFO - первым возвращается 
        последний добавленный элемент.
    """
    def __init__(self):
        self.nodes = []

    def get_first(self):
        return self.nodes.pop()

    def insert(self, node_number):
        self.nodes.append(node_number)

    def is_empty(self):
        return len(self.nodes) == 0

Результат использования этого класса показан ниже. На каждой итерации мы копаем как можно глубже в поисках целевого узла.

Поиск пути с помощью поиска в глубину.
Поиск пути с помощью поиска в глубину.

Чтобы поиграть в этой анимации с кнопками управления, жми сюда.

Поиск в ширину

Чтобы выполнить Поиск в ширину, нужно вместо стека использовать очередь:

Код очереди
class Queue(AbstractNodeStorageClass):
    """
        Очередь работает по принципу FIFO - первым возвращается 
        элемент, добавленный раньше всех.
    """

    def __init__(self):
        self.nodes = []

    def get_first(self):
        return self.nodes.pop(0)

    def insert(self, node_number):
        self.nodes.append(node_number)

    def empty(self):
        return len(self.nodes) == 0

Единственная разница со стеком заключается в том, что первый элемент берётся с помощью функции pop(0) вместо того, чтобы брать последний элемент с помощью pop(). Результат работы алгоритма с такой структурой показан ниже.

Поиск пути с помощью поиска в ширину
Поиск пути с помощью поиска в ширину

Чтобы поиграть в этой анимации с кнопками управления, жми сюда.

Теперь мы проходим по всем узлам, расположенным на одной горизонтали, т. е. перебираем узлы как можно шире. Также обратите внимание, как зелёный узел target проходит через очередь от входа до выхода.

Алгоритм Дейкстры

Изначально алгоритм Дейкстры был разработан для нахождения кратчайшего пути к заданному узлу (и одновременно ко всем остальным), начиная с начального узла start. Мы можем применить его к нашей задаче поиска пути между двумя узлами.

Но перед этим, чтобы сделать задачу интереснее, укажем для каждого ребра графа его длину:

Граф тот же, но теперь его рёбра имеют разную длину.
Граф тот же, но теперь его рёбра имеют разную длину.

Это означает, что  расстояние между узлами 0 и 1 в три раза больше, чем расстояние между узлами 0 и 2.

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

Основное отличие этой очереди от обычной состоит в том, что в методе get_first мы выбираем узел с наименьшим расстоянием от начального узла. В Python есть уже готовая реализация очереди с приоритетом внутри встроенной библиотеки heapq:

Код очереди с приоритетом
class DijkstraQueue(AbstractNodeStorageClass):
    """
        Очередь с приоритетом. Внутри метода get_first() выбирается
        узел с минимальным расстоянием distance от начального узла.
    """

    def __init__(self, distances):
        self.nodes = []
        self.distances = distances

    def get_first(self):
        closest_node_distance, closest_node = heappop(self.nodes)
        return closest_node

    def insert(self, element):
        heappush(self.nodes, (self.distances[element], element))

    def is_empty(self):
        return len(self.nodes) == 0

Результат алгоритма на такой структуре данных выглядит следующим образом:

Поиск кратчайшего пути с помощью алгоритма Дейкстры.
Поиск кратчайшего пути с помощью алгоритма Дейкстры.

Чтобы поиграть в этой анимации с кнопками управления, жми сюда.

Интересно, что в трёх случаях мы получили три разных пути, хотя и действовали в рамках одного графа и с помощью одного алгоритма. Изменялась только структура данных.

Усложним задачу до лабиринта

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

Сразу посмотрим, как решает эту задачу алгоритм Дейкстры. Не меняем ни алгоритм, ни структуру данных из предыдущего пункта — только граф.

Поиск пути в лабиринте с помощью алгоритма Дейкстры.
Поиск пути в лабиринте с помощью алгоритма Дейкстры.

Чтобы поиграть в этой анимации с кнопками управления, жми сюда.

Мы видим, как алгоритм последовательно перебирает узлы. При этом каждый следующий узел выбирается по принципу приоритетной очереди — как ближайший к начальному узлу start.

Используя этот подход, алгоритм перебирает почти все узлы в лабиринте. Но интуитивно кажется, что мы можем добраться до финиша быстрее, если будем целиться в сторону конечного узла target. Именно такие рассуждения привели к созданию алгоритма A*.

Алгоритм А*

Алгоритм А* учитывает как расстояние до начального узла, так и примерную оценку расстояния до целевого узла. Эта приблизительная оценка эвристически рассчитывается как евклидово расстояние от выбранного узла до целевого. Единственное изменение, которое нам нужно сделать, — это вычисление приоритета узла в очереди приоритетов. Вот код:

Код очереди с приоритетом (А*)
class AStarQueue(AbstractNodeStorageClass):
    """
        Очередь с приоритетом для алгоритма А*.
        В методе get_first() выбирается узел, для которого минимальна 
        сумма расстояния до начального узла и оценки расстояния
        (оценивается евклидова норма) до конечного узла.
    """

    def __init__(self, graph, distances, goal_node):
        self.nodes = []
        self.graph = graph
        self.x_goal, self.y_goal = graph.nodes[goal_node]['position']
        self.distances = distances

    def calc_heuristic(self, node):
        x_node, y_node = self.graph.nodes[node]['position']
        estimated_distance_to_goal = sqrt(
            (x_node - self.x_goal) ** 2 +
            (y_node - self.y_goal) ** 2
        )
        return estimated_distance_to_goal

    def get_first(self):
        optimal_node_value, optimal_node = heappop(self.nodes)
        return optimal_node

    def insert(self, element):
        heappush(self.nodes,
                 (self.distances[element] + 
                  self.calc_heuristic(element), element)
                 )

    def is_empty(self):
        return len(self.nodes) == 0

Здесь мы видим дополнительный метод calc_heuristic(), оценивающий расстояние до целевого узла. Теперь отдадим структуру данных алгоритму и получим следующий результат:

Чтобы поиграть в этой анимации с кнопками управления, жми сюда.

Обратите внимание, что благодаря использованию эвристики алгоритм A* быстрее находит правильный ответ — всего за 80 шагов вместо 203.

Вместо заключения

В случае с поиском пути в графе можно применять четыре разных классических алгоритма, которые на самом деле один — разница только в структуре данных. Наглядно это показано в таблице:

Иногда нам кажется, что алгоритмы и структуры данных - это два разных раздела computer science, но на практике они очень тесно связаны, и описанное выше - яркое тому подтверждение. На курсе "Алгоритмы и структуры данных" в Яндекс Практикуме мы стараемся не только научить людей писать эффективные алгоритмы, но и показать эту важную связь.

Если вам понравилась статья, вы можете посмотреть полный туториал и поиграться с кодом в google colab, либо посмотреть весь код на github.

Комментарии (20)


  1. SadOcean
    15.12.2022 00:49
    +2

    Все же не правильно говорить, что отличие только в структуре данных - в Дейкстры и А* используется дополнительная информация (в принципе она может быть добавлена и в алгоритм в ширину как количество переходов).
    Но да, безусловно все методы можно обощить до одного алгоритма с разными стратегиями обхода.

    Я бы сказал, что Дейкстра - частный случай обхода в ширину с оптимизацией по начальному пути.
    А А* - частный случай обхода в ширину с оптимизацией по начальному и предполагаемому пути.


    1. Deosis
      15.12.2022 07:07
      +5

      Можно сказать и наоборот:

      Обход в ширину - частный случай алгоритма Дейкстры со всеми весами равными 1.


      1. domix32
        15.12.2022 11:35

        Да в принципе обходы в ширину/глубину можно и по взвешеннему графу запускать, просто оптимальность падает.


      1. SadOcean
        16.12.2022 00:22

        Да, в этом смысле тоже верно.


    1. domix32
      15.12.2022 11:33

      Да по тому же принципу автор мог сказать, что стек от очереди не отличаются - оба какие-то секвенции хранят.


  1. mayorovp
    15.12.2022 09:04
    +2

    К сожалению, не всё так просто, и у алгоритмов есть несколько отличий, которые автор не учёл. Начну с поиска в глубину. Алгоритм автора жадно кладёт в стек (и отмечает "серыми") всех соседей посещённой вершины, в то время как настоящий алгоритм делает это строго по одной. Да и "чёрным" автор красит вершину слишком рано, настоящий алгоритм делает это только с полностью обработанными вершинами...


    Это не простая придирка, это всё приводит к неправильному порядку обхода. Так, на картинке из поста


    Картинка


    должен быть переход из 11 в 7, но его нет.


    К поиску в ширину претензий нет.




    А вот алгоритм Дейкстры снова некорректный: автор забыл обновлять приоритеты после обновления веса вершины. На картинке это не заметно, поскольку автор хитро построил путь из "единичек" до целевого узла, да и альтернативных путей там маловато. Но вот в таком графе:


    Картинка


    алгоритм при попытке найти путь между вершинами 1 и 4 выберет прямой переход стоимостью 5 вместо пути 1-5-3-4 стоимостью 4, потому что у вершины 3 окажется приоритет 11.


    1. atmur Автор
      15.12.2022 09:49

      1. По поводу поиска в глубину. Формулировка "настоящий алгоритм делает это строго по одной" не совсем ясна. Что значит "настоящий алгоритм"? Обратимся например к википедии и увидим: "Кладём в стек всех её белых соседок в порядке, обратном порядку обхода (если таковой важен)."

      2. Перехода из 11 в 7 быть не должно. Ровно по той же причине, почему нет перехода в узел 1. Ведь они лежат ниже в стеке чем узла, добавленные позднее.

      3. По поводу алгоритма Дейкстры. Действительно, массив self.distances будет обновляться, т.к. передан в класс по ссылке, а веса внутри элементов кучи останутся старыми.


      1. mayorovp
        15.12.2022 11:30

        Настоящий DFS — рекурсивный. Нерекурсивные варианты возможны, но если они обходят вершины в другом порядке — они уже не могут называться поиском/обходом в грлубину.


        Перехода из 11 в 7 быть не должно. Ровно по той же причине, почему нет перехода в узел 1. Ведь они лежат ниже в стеке чем узла, добавленные позднее.

        Это вы объясняете с точки зрения своей реализации. Но в DFS переход из 11 в 7 быть обязан.


        1. atmur Автор
          15.12.2022 11:59

          Настоящий DFS — рекурсивный

          Не уверен, что это так. По крайней мере никогда не встречал такого утверждения, что рекурсивный - настоящий, а итеративный - ненастоящий.


          1. mayorovp
            15.12.2022 14:45

            Итеративный тоже может быть настоящим. Но он должен обходить вершины в том же порядке что и рекурсивный.


            1. atmur Автор
              15.12.2022 16:25

              Я правда никогда не встречал такого термина "настоящий DFS". Поделитесь, пожалуйста, если где-то кроме этой беседы он присутствует.

              Возможно, если бы нам важен был порядок обхода, то имело бы место такое замечание. И нужно бы было, как говорит Википедия развернуть порядок вершин при укладывании их в стек. Но мы здесь решаем простую задачу - хотим понять, есть ли путь от одной вершины до другой. Поэтому порядок обхода нам не важен.

              Можно представить себе, что в списке соседей каждого узла соседи лежат в обратном порядке, или при выборе соседей мы разворачиваем получаемый список, и тогда мы пойдём по-другому. Так, как вам кажется "по-настоящему".


              1. mayorovp
                15.12.2022 18:48

                Я правда никогда не встречал такого термина "настоящий DFS". Поделитесь, пожалуйста, если где-то кроме этой беседы он присутствует.

                Потому что есть просто DFS, а есть другие алгоритмы, которые DFS не являются. Прилагательное "настоящий" я использую чтобы различить тот алгоритм, который является DFS, и тот алгоритм, который вы считаете DFS.


                Но мы здесь решаем простую задачу — хотим понять, есть ли путь от одной вершины до другой. Поэтому порядок обхода нам не важен.

                Прежде всего, вы здесь сравниваете алгоритмы, и показываете их реализацию.


                К слову, DFS вообще редко используется именно для поиска пути. Основное использование DFS — топологическая сортировка, и вот там порядок обхода как раз важен (собственно, он и является выходом алгоритма).


                Можно представить себе, что в списке соседей каждого узла соседи лежат в обратном порядке, или при выборе соседей мы разворачиваем получаемый список, и тогда мы пойдём по-другому. Так, как вам кажется "по-настоящему".

                Ну да, ваш граф такой, что и правда можно придумать такой порядок выбора соседей при котором ваш алгоритм даст такой же результат что и DFS. Но требуется-то девать верный результат на любых исходных данных, а не на конкретных!


              1. Vsevak
                15.12.2022 21:02

                У Седжвика DFS определяется как рекурсивный алгоритм, и затем строятся аналогичные итеративные алгоритмы. Алгоритм из статьи, когда порядок посещения узлов не соответствует DFS, обсуждается как не являющийся поиском в глубину, но заслуживающий внимания как раз по причине, вынесенной в заголовок статьи.

                У Кормена к ключевым свойствам DFS относятся свойства, происходящие из рекурсивной структуры вызовов. В том числе, моменты открытия и закрытия вершин (покраски в серый и черный) должны образовывать скобочную структуру. Как @mayorovp и написал, в статье это происходит в нарушенном порядке.

                На практике где-то конечно разница может быть не важна, но раз разговор зашел про настоящий, то есть строгие критерии.


                1. wataru
                  15.12.2022 21:26

                  На практике где-то конечно разница может быть не важна, но раз разговор зашел про настоящий, то есть строгие критерии.

                  Важна эта разница. Алгоритм из статьи нельзя использовать для поиска двусвязных компонент в графе.


                  1. atmur Автор
                    15.12.2022 23:34

                    конечно же можно.

                    Если граф имеет две компоненты связности, то мы просто не попадём из одной компоненты связности в другую, т.к. ходим только по рёбрам.

                    И ещё раз повторю. Мы здесь решаем другую задачу - поиск пути в графе.


                    1. wataru
                      16.12.2022 00:27

                      Нет, вы этим вашим алгоритмом не сможете найти компоненты двусвязности. Ибо алгоритм поиска этих компонент использует фишку, что ребра могут идти только к потомкам (и их можно игнорировать) или к предку (и это можно использовать для нахождения самого высокого предка в текущей компоненте двусвязности.


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


                      В этой ветке разговор о разнице между "настоящим" алгоритмом и вашим. И она есть. Даже если вашим алгоритмом тоже можно найти произвольный путь в графе.


      1. wataru
        15.12.2022 19:58
        +1

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


        Именно это свойство позволяет использовать DFS при поиске мостов, точек сочленения и всяких двусвязных компонент в графах.


        В вашей реализации этого свойства нет. Переход из 11 в 7 не произойдет никогда, потому что 7 уже в стеке и серого цвета. Но если прехода нет, то алгоритм перейдет в 7 из 4, а ребро 11-7 будет как раз между двумя ветками дерева.


        Но это можно исправить. Не надо красить вершины в серый цвет при запихивании в стек. В этом случае вершины в стеке будут много раз, поэтому при доставании надо проверить, а не черная ли вершина. Другой вариант, надо для каждой вершины хранить состояние — какой ее сосед еще не просмотрен. Брать из стека. Если еще не все дети просмотрены, сдвигать состояние и запихивать и эту вершину и ее соседа.


        Такие реализации идентичны и в обходе в ширину и в обходе в глубину. Но вот дейкстру с A* на этот глобус уже натянуть не получится.


  1. cadovvl
    15.12.2022 13:17
    +1

    Помнится, давным-давно, с десяток лет назад, читал в какой-то книге (вроде BGL book) что алгоритм Прима и Краскала - это один алгоритм с минимальными отличиями. И им удалось сделать одну абстракцию, которую можно было compile-time флагами переключить с одной реализации на другую.

    Я тогда подумал, что абстракция с таким функционалом могла бы выглядеть так:

    void msp(/*...*/) {
    #ifndef USE_KRUSKAL
      _prim(/*...*/);
    #else   
      _kruskal(/*...*/);
    #endif // USE_KRUSKAL
    }



    Формально, то же самое, что сделали они... Но где же та грань, между "это один и тот же алгоритм, просто с разными настройками" и "я просто выбираю более подходящий алгоритм из списка решающих мою задачу".

    А этой грани не существует, из-за отсутствия точного определения. Как и у вас: эдак можно любую программу назвать просто еще одной разновидностью алгоритма: на входе сигнал, на выходе сигнал, внутри только чет поменяли.

    Но спасибо, что в очередной раз привлекаете внимание к алгоритмам: в любом случае приятно освежить в памяти.



    1. wataru
      15.12.2022 20:02

      Разница в количестве дублированного кода.


      Практической пользы, правда, тут мало. Такие преобразования алгоритмов, чтобы основная логика была одинакова, вряд ли сделают их читабельнее.


      Но зато можно получить эстетическое удовлетворение от осознания, что суть у этих алгоримов одинаковая.


      1. cadovvl
        15.12.2022 21:12

        Звучит как разновидность самоублажения.
        Хотя конкретно эту мотивацию я понять могу.