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

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



Введение



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

Как правило, граф обозначают как набор вершин и рёбер \inline G = (V,E), где число рёбер может быть задано \inline m, а вершин числом \inline n.


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


Алгоритм Дейкстры может найти кратчайший путь между вершинами \inline s и \inline t в графе, только если существует хотя бы один путь между этими вершинами. Если это условие не выполняется, то алгоритм отработает корректно, вернув значение "бесконечность" для пары несвязанных вершин.


Условие неотрицательности весов рёбер крайне важно и от него нельзя просто избавиться. Не получится свести задачу к решаемой алгоритмом Дейкстры, прибавив наибольший по модулю вес ко всем рёбрам. Это может изменить оптимальный маршрут. На рисунке видно, что в первом случае оптимальный путь между \inline a и \inline d (сумма рёбер на пути наименьшая) изменяется при такой манипуляции. В оригинале путь проходит через \inline a \rightarrow b \rightarrow c \rightarrow d, а после добавления семёрки ко всем рёбрам, оптимальный путь проходит через \inline a \rightarrow c \rightarrow d.



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


Свести задачу к решаемой BFS можно, но если заменить все рёбра неединичной длины \inline n рёбрами длины \inline 1, то граф очень разрастётся, и это приведёт к огромному числу действий при вычислении оптимального маршрута.


Чтобы этого избежать предлагается использовать алгоритм Дейкстры. Опишем его:



Инициализация:


  • Задаём множество \inline X = \{s\}, состоящее из исходной вершины.
  • Массив длин кратчайших путей \inline A, в котором изначально есть \inline A[s] = 0, кратчайших путь от вершины до себя самой.

Основный цикл алгоритма:


  • Пока все вершины не исследованы (или формально \inline X \neq V), повторяем:
    • Среди всех рёбер в графе \inline (v,w) таких, что \inline v \in X, а \inline w \notin X, выбираем одно, которое минимизирует сумму: \inline A[v] + l_{vw}.
    • Добавяем эту вершину \inline w в \inline X.
    • Задаём \inline A[w] равным \inline A[v] + l_\{vw\}.


В итоге исполнения этого алгоритма, массив \inline A будет содержать все оптимальные пути, исходящие из \inline s.



Примеры работы



image

Рассмотрим граф выше, в нём будем искать пути от \inline a до всего остального.


Первый шаг алгоритма определит, что кратчайший путь до \inline b проходит по направлению синей стрелки и зафиксирует кратчайший путь. Второй шаг рассмотрит, все возможные варианты \inline A[v] + l_{vw} и окажется, что оптимальный вариант двигаться вдоль красной стрелки, поскольку \inline 5 меньше, чем \inline 3 + 3 = 6 и \inline 3 + 6 = 9. Добавляется длина кратчайшего пути до \inline c. И наконец, третьим шагом, когда три вершины \inline a,b,c уже лежат в \inline X, остается рассмотреть только два ребра и выбрать, лежащее вдоль зеленой стрелки.



Теперь рассмотрим граф с отрицательными весами, упомянутый выше. Напомню, алгоритм Дейкстры на таком графе может работать некорректно.

image

Первым шагом отбирается ребро вдоль синей стрелки, поскольку это ребро наименьшего веса из исходной вершины. Затем выбирается ребро \inline c \rightarrow d. Это зафиксирует навсегда неверный путь от \inline a к \inline d, в то время как оптимальный путь проходит через центр с отрицательным весом. Последним шагом, будет добавлена вершина \inline b.



Оценка сложности алгоритма



К этому моменту мы разобрали сам алгоритм, ограничения, накладываемые на его работу и ряд примеров его применения. Давайте упомянем какова вычислительная сложность этого алгоритма, поскольку это пригодится нам для решения задач, ради которых затевалась эта статья.
Базовый подход, основанный на циклах, предполагает проход по всем рёбрам каждого узла, что приводит к сложности \inline \theta(mn).


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


Что еще можно сказать о куче:


  • это сбалансированное бинарное дерево,
  • ключ текущего узла всегда меньше, либо равен ключей дочерних узлов.

Интересную задачу с использованием куч я разбирал ранее в этом посте.

Используя кучу в алгоритме Дейкстры, где в качестве ключей используются расстояния от вершины в неисследованной части графа (в алгоритме это \inline V-X), до ближайшей вершины в уже покрытом (это множество вершин \inline X), можно сократить вычислительную сложность до \inline O(m\log(n)). Доказательство справедливости этих оценок я оставляю за пределами этой статьи.


Далее перейдём к разбору задач!



Задача №1



Будем называть узким местом пути в графе ребро максимальной длины в этом пути. Путём с минимальным узким местом назовём такой путь между вершинами s и t, что не существует другого пути s \rightarrow t, чьё узкое место меньше по длине. Требуется построить алгоритм, который вычисляет путь с минимальным узким местом для двух данных вершин в графе. Асимптотическая сложность такого алгоритма должна быть O(m\log{n})



Решение



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


В случае классической задачи, поиска пути минимальной длины между двумя вершинами графа, мы поддерживаем в каждой посещенной алгоритмом вершине графа минимальную длину пути до этой вершины. Здесь стоит оговориться, что будем именовать множество X посещенными вершинами, а V - X часть графа, для которой еще нужно найти величину пути или узкого места.


В отличии от классического алгоритма, решение этой задачи должно поддерживать величину актуального узкого места пути, приводящего в вершину v \in X. А при добавлении новой вершины из V - X, мы должны смотреть не увеличивает ли ребро (v,u_1) величину узкого места пути, которое теперь приводит в u_1.
Если ребро (v, u_1) увеличивает узкое место, то лучше рассмотреть вершину u_2, ребро (v, u_2) до которой легче (v,u_1). Поиск неувеличивающих узкое место ребёр нужно осуществлять не только среди соседей определенного узла v, но и среди всех v \in X, поскольку отдавая предпочтение вершине, путь в которую имеет наименьшее узкое место в данный момент, мы гарантируем, что мы не ухудшаем ситуацию для других вершин.


Последнее можно проиллюстрировать примером: если путь, оканчивающийся в вершине p имеет узкое место величины 3, и есть вершина q с ребром (p,q) веса 4, и r с ребром (p,r) веса 5, то предпочтение отдаётся q, алгоритм даст верный результат в обоих случая, если существует (q,r) веса 3 или веса 10.



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

A(u) = \min_{v \in X}\left(\max \left[A(v), w\left(v,u\right)\right]\right), \, u \in V - X


Стоит пояснить, что поиск по v \in X осуществляется, только для существующих связей (v,u), а w(v,u) - это вес ребра (v,u).



image

Задача №2



Предлагается решить более практическую задачу. Пусть \inline n городов, между ними существуют пути, заданные массивом edges[i] = [city_a, city_b, distance_ab], а также дано целое число mileage.


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


Стоит отметить, что граф неориентированый, т.е. по пути между городами можно двигаться в обе стороны, а длина пути между городами a и c может быть получена как сумма длин путей a -> b и b -> c, если есть маршрут a -> b -> c



Решение



С решением данной проблемы нам поможет алгоритм Дейкстры и описанная выше реализация с помощью кучи. Поставщиком этой структуры данных станет библиотека heapq в Python.



Будем использовать алгоритм Дейкстры для того, чтобы подсчитать количество соседних городов, расстояние до которых меньше mileage, для каждого из \inline n городов. Соберем количества соседей в в одном месте и найдем минимум из них.


Поскольку наш граф неориентированный, то из любой его вершины \inline s можно добраться до произвольной вершины \inline t. Будем использовать алгоритм Дейкстры для того, чтобы для каждого из городов в графе построить кратчайшие пути до всех остальных городов, мы это уже умеем делать в теории. И чтобы, оптимизировать этот процесс, будем в его течении сразу отвергать пути, которые превышают mileage, а не делать постфактум, когда все пути получены.



Давайте опишем функцию решения:

def least_reachable_city(n, edges, mileage):
        """
        входные параметры:
            n --- количество городов,
            edges --- тройки (a, b, distance_ab),
            mileage --- максимально допустимое расстояние между городами 
            для соседства
        """
        # заполняем список смежности (adjacency list), в нашем случае это 
        # словарь, в котором ключи это города, а значения --- пары 
        # (<другой_город>, <расстояние_до_него>)

        graph = {}
        for u, v, w in edges:
            if graph.get(u, None) is None:
                graph[u] = [(v, w)]
            else:
                graph[u].append((v, w))
            if graph.get(v, None) is None:
                graph[v] = [(u, w)]
            else:
                graph[v].append((u, w))
        
        # локально объявим функцию, которая будет считать кратчайшие пути в 
        # графе от вершины, до всех вершин, удовлетворяющих условию
        def num_reachable_neighbors(city):
            # создаем кучу, из одного элемента с парой, задающей нулевую 
            # длину пути до самого исходного города
            heap = [(0, city)]
            # и массив, содержащий города и кратчайшие 
            # расстояния до них от исходного
            distances = {}
            # затем, пока куча не пуста, извлекаем ближайший 
            # от посещенных городов город
            while heap:
                currDist, neighb = heapq.heappop(heap)
                # если кратчайшее ребро ведет к городу, где мы уже знаем 
                # оптимальный маршрут, то завершаем итерацию
                if neighb in distances:
                    continue
                # в остальных случаях, и если сосед не является отправным 
                # городом, мы добавляем новую запись в массив кратчайших расстояний
                if neighb != city:    
                    distances[neighb] = currDist
                # обрабатываем всех смежных городов с соседом, добавляя их в кучу 
                # но только если: а) до них еще не известен кратчайший маршрут и б) путь до них через neighb не выходит за пределы mileage
                for node, d in graph[neighb]:
                    if node in distances:
                        continue
                    if currDist + d <= mileage:
                        heapq.heappush(heap, (currDist + d, node))
            # возвращаем количество городов, прошедших проверку
            return len(distances)
        
        # выполним поиск соседей для каждого из городов
        cities_neighbors = {num_reachable_neighbors(city): city for city in range(n)}
        # вернём номер города, у которого наименьшее число соседей
        # в пределах досигаемости
        return cities_neighbors[min(cities_neighbors)]


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

Заключение



Алгоритм Дейкстры это мощный инструмент в мире работы с графами, область применения его крайне широка. С его помощью можно оценить даже целесообразность добавления новой ветки метро, новой дороги или маршрута в компьютерной сети. Он прост в исполнении и интуитивно понятен, как другие жадные (greedy) алгоритмы. Вычислительная сложность решений задач с его помощью зачастую не выше \inline O(m \log(n)). При некоторых условиях может достигать линейной сложности (существует алгоритм линейной сложности, решающий первую задачу, при условии, что граф неориентированный).


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



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


Заключение



[1] Условия задач взяты из книги «Algorithms Illuminated: Part 2: Graph Algorithms and Data Structures» от Tim Roughgarden,
[2] и с сайта leetcode.com.
[3] Решения авторские.

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


  1. Alexandroppolus
    24.01.2022 17:49
    +4

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

    Свести задачу к решаемой BFS можно, но если заменить все рёбра неединичной длины n рёбрами длины 1

    Можно и проще - в BFS вместо очереди использовать приоритетную очередь (т.е. упомянутую в статье кучу), будет "поиск по критерию стоимости", разновидность BFS


    1. dkosolobov
      25.01.2022 12:14
      +1

      Это же и будет алгоритм Дейкстры. По крайней мере тот же вариант, который описан в статье в коде (в текстовом описании алгоритм в статье немного отличается от варианта в коде). Есть еще два варианта Дейкстры: 1 не помещать повторно в кучу то, что там уже находится, а просто выполнять heapifyUp для такого узла из кучи (минусы: куча с heapifyUp есть не в каждой библиотеке и ее придется реализовать самому; поддерживать указатели на узлы из кучи не так просто и ресурсозатратно; плюсы: используем О(n) памяти под кучу, а не О(m)), и 2 вообще изначально положить в кучу все узлы (минусы и плюсы в таком варианте те же)