Графы: всегда на связи!

В этой главе мы исследуем:

  1. Свойства индексированной приоритетной очереди — и больше новых типов данных изобретать в нашей книге не станем.

  2. Граф как набор вершин и соединяющих из рёбер. В ориентированном графе рёбра имеют направления, во взвешенном — числовые веса.

  3. Обход графа вглубину с помощью стека, хранящего план разведки.

  4. Обход графа вширину, при котором план разведки хранится в очереди. Если выходной узел достижим из входного, обход вглубину вернёт кратчайшее расстояние между ними.

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

  6. Топологическую сортировку ориентированного графа, которая строит линеаризацию его узлов — такую последовательность, в которой одна вершина всегда встречается раньше другой, если между ними есть дуга.

  7. Задачу поиска кратчайшего пути из входной вершины взвешенного графа ко всем остальным.

  8. Задачу полного поиска кратчайших путей из каждой вершины взвешенного графа к каждой.

В графе удобно хранить полезную информацию

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

Решения каждой из этих задач можно построить в виде алгоритма на графах. Математики уже сотни лет изучают графы, потому что смоделировать связи между данными зачастую не менее важно, чем формализовать сами данные. Граф описывает данные как набор узлов, соединённых рёбрами. Любую связь между двумя элементами данных u и v можно представить ребром e = (u, v). Самих рёбер может быть сколько угодно. Таким способом моделируются самые разные структуры из обыденной жизни; некоторые из них представлены на иллюстрации 7-1. Химические связи между атомами углерода и водорода в структурной формуле молекулы пропана образуют неориентированный граф. Мобильное приложение может показать, как проехать по Нью-Йорку с учётом одностороннего движения на некоторых улицах — с помощью ориентированного графа. Взвешенный граф подходит для атласа автодорог Новой Англии, в котором отмечены расстояния между столицами штатов. Немного арифметики, и становится понятно, что кратчайший путь из Хартфорда, штат Коннектикут, до Бангора, штат Мэн, — 278 миль.

Граф как структура данных — это набор из N различных узлов, каждый из которых снабжён уникальным идентификатором1. В граф можно добавить ребро, которое соединяет два различных узла, u и v. Ребро записывается в виде пары (u, v), при этом узлы u и v называются вершинами ребра или смежными узлами.

Иллюстрация 7-1. Различные данные в виде графов
Иллюстрация 7-1. Различные данные в виде графов

Ориентированный граф — движение по улицам Нью-Йорка

Взвешенный граф — автодорожный атлас

пропан (C₃H₈)

Неориентированный граф — структурная формула молекулы

Граф на иллюстрации 7-2 содержит 12 различных узлов и 12 рёбер. Представим, что узел — это остров, а ребро — мост между двумя островами. С острова B2 можно добраться до острова C2, а с острова C2 — до островов B2 И C3. При этом добраться напрямую с острова B2 до острова B3 нельзя. Сначала надо пройти по мосту между B2 и C2, затем — по мосту между C2 и C3, и только потом — по мосту между C3 и B3. Если поизучать схему островов и мостов, можно подобрать цепочку мостов от любого острова «B» до любого острова «C», а вот с островов «A» на остров «B» не попасть, хотя какие-то мосты есть и между островами «A».

Иллюстрация 7-2. Неориентированный граф с 12 вершинами и 12 рёбрами
Иллюстрация 7-2. Неориентированный граф с 12 вершинами и 12 рёбрами

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

Путь — это последовательность рёбер, которая начинается в начальной вершине, а заканчивается в конечной. В простом случае каждое ребро — это путь между вершинами этого ребра, но бывает и так, что ребра между двумя узлами нет, а путь — есть. На иллюстрации 7-2 путь между узлами B2 и B3 — это цепочка рёбер (B2, C2), (C2, C3) и (C3, B3).

Каждое следующее ребро пути должно начинаться тем узлом, в котором заканчивалось предыдущее, поэтому путь можно представить последовательностью посещаемых узлов, например, [B2, C2, C3, B3]. От B2 к B3 есть путь и подлиннее — [B2, C2, C3, C4, B4, B3]. Если путь начинается и заканчивается одним и тем же услом, например, [C4, C5, B5, B4, C4] — это цикл. Узел v называется достижимым из другого узла, u, если существует путь из u в v. Некоторые узлы графа могут быть недостижимы друг из друга, например, граф на иллюстрации 7-2 не имеет пути из A2 в B2. Такой граф называется несвязным, а если между любыми двумя вершинами есть путь, то это связный граф.

Мы изучим три типа графов:

Неориентированный граф

Если ребро соединяет вершины u и v, u считается смежной с v, а v — смежной с u. Можно считать такое ребро мостом, проходимым в обе стороны.

Ориентированный граф

Каждое ребро (u, v) соединяет вершину u с вершиной v, и это значит, что v — смежная с u, но необязательно наоборот. Получается, что ребро имеет направление от u к v, в котором можно было бы пройти по соответствующему мосту (но не обратно).

Взвешенный граф

Ребро, соединяющее вершины u и v имеет дополнительное числовое поле — вес — и записывается как (u, v, вес). Граф при этом может быть ориентированным, а может и не быть. Это поле отражает некоторую измеряемую характеристику связи u и v. Это может быть, например, стоимость проезда или расстояние в милях между городами, которым соответствуют узлы u и `v.

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

Для нашил алгоритмов понадобится достаточно сложная структура данных, моделирующая абстракцию графа. Хорошо бы, чтобы с соответствующим объектом-графом можно было делать следующее:

Среди стандартных библиотек Python есть модуль, реализующий некоторый алгоритм на графах, но он решает ровно одну прикладную задачу и не обладает почти никакими нужными нам свойствами. Нам понадобится куда более мощная сторонняя библиотека для работы с графами, NetworkX (https://networkx.org). Библиотека разрабатывается открыто и распространяется под свободной лицензией. Нам не придётся изобретать колесо: модуль networkx предоставляет великое множество готовых алгоритмов на графах, а вдобавок с этим модулем прекрасно сочетаются библиотеки для визуализации графов. В примере 7-1 показана программа, в которой строится и используется граф с иллюстрации 7-2, и результаты её вывода.

   1 import networkx as nx
   2 G = nx.Graph()                                          # 1
   3 G.add_node('A2')                                        # 2
   4 G.add_nodes_from(['A3', 'A4', 'A5'])                    # 3
   5 G.add_edge('A2', 'A3')                                  # 4
   6 G.add_edges_from([('A3', 'A4'), ('A4', 'A5')])          # 5
   7 
   8 for i in range(2, 6):
   9   G.add_edge(f"B{i}", f"C{i}")                          # 6
  10   if 2 < i < 5:
  11     G.add_edge(f"B{i}", f"B{i+1}")
  12   if i < 5:
  13     G.add_edge(f"C{i}", f"C{i+1}")
  14 
  15 print(G.number_of_nodes(), 'nodes.')                    # 7
  16 print(G.number_of_edges(), 'edges.')
  17 print('adjacent nodes to C3:', list(G['C3']))           # 8
  18 print('edges adjacent to C3:', list(G.edges('C3')))     # 9

12 nodes.
12 edges.
adjacent nodes to C3: ['C2', 'B3', 'C4']
edges adjacent to C3: [('C3', 'C2'), ('C3', 'B3'), ('C3', 'C4')]
  1. Создадим новый граф с помощью nx.Graph().

  2. Узел может хранить любой константный объект Python (кроме None). Хорошая идея — хранить строки.

  3. Метод .add_nodes_from() добавляет сразу несколько узлов.

  4. Добавим ребро между узлами u и v с помощью метода .add_edge(u, v).

  5. Добавим несколько рёбер с помощью .add_edges_from().

  6. Если вершина добавляемого ребра в графе пока отсутствует, соответствующий узел заводится автоматически.

  7. Мы можем узнать количество узлов и рёбер графа.

  8. Мы можем получить список смежных с v узлов при помощи просто конструкции G[v].

  9. Мы можем получить список исходящих из v рёбер при помощи G.edges(v).

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

Обход лабиринта вглубину

Как написать программу, которая находит выход из лабиринта с иллюстрации 7-3? Лабиринт размером 3 на 5 состоит из 15 комнат, вход в него — сверху, выход — снизу. Перемещаться по лабиринту можно только вертикально или горизонтально между теми комнатами, которые не разделяет стена. Первым делом надо смоделировать лабиринт с помощью неориентированного графа из 15 узлов, каждый из которых, с меткой (строка, столбец), будет обозначать соответствующую комнату лабиринта. Например, вход в лабиринт — это (0, 2), а выход(2, 2). Затем в эту модель надо добавить рёбра: если между узлами u и v нет стены, в графе должно быть ребро (u, v). Иллюстрация совмещает лабиринт с получившимся графом, оттого особенно заметно, что комнаты лабиринта в точности соответствуют узлам графа.

Так что найти путь между входным узлом (0, 2) и выходным (2, 2) — это то же самое, что пройти исходный лабиринт. Мы посмотрим, как проходить любой такой лабиринт независимо от размеров. Если самому проходить лабиринт, начинаешь исследовать то один, то другой путь, отбрасывая тупиковые, пока наконец один из путей не окажется от входа к выходу. Это, возможно, неочевидно, но человек имеет перед компьютером важное преимущество: он видит лабиринт как целое, и может принимать решения руководствуясь ощущениями, близко ли к выходу лежит предполагаемый путь. Представим себе, что мы угодили в такой лабиринт2), и можем видеть только комнаты, напрямую связанные с той, в которой стоим. В таком положении подход к решению должен быть совсем другим.

Иллюстрация 7-3. Моделирование прямоугольного лабиринта графом
Иллюстрация 7-3. Моделирование прямоугольного лабиринта графом

Вход

Выход

Нам нужен способ проходить лабиринт, пользуясь соответствующим неориентированным графом, как это сделано на иллюстрации 7-4. Случайное порождение таких графов — задача сама по себе интересная3.

На входе — это узел (0, 2) — мы видим три смежных узла, к каждому из которых ведёт ребро; можно пойти на восток к (0, 3), но запомнить при этом два других направления, (0, 1) и (1, 2) как возможные альтернативы исследования. Узел (0, 3) также имеет три смежных, но мы помним, откуда пришли, так что не возвращаемся туда, где уже были. Из оставшихся двух выбираем, например, южный, (1, 3), а (0, 4) запоминаем как альтернативный. Вот мы и прошли по маршруту, отмеченному на иллюстрации 7-2 серым, — и упёрлись в тупик.

Узел (1, 3) — это тупик, потому что когда мы туда попадаем, мы не видим ни одного смежного не посещённого нами узла. Что делать теперь? На иллюстрации 7-4 кружками обведены узлы, которые были нам доступны, но мы туда не пошли: возможно, если мы вернёмся к одному из этих ранее отмеченных узлов и продолжим обход с него, нам улыбнётся удача?

Иллюстрация 7-4. Обходили лабиринт и упёрлись в тупик
Иллюстрация 7-4. Обходили лабиринт и упёрлись в тупик

Вход

Выход

Как превратить наши соображения в алгоритм? Вот примерный его план:

Хотелось бы, чтобы по окончании алгоритма мы могли легко построить путь от входного узла до любого достижимого. Результатом работы алгоритма должна быть структура данных с достаточны для решения этой задачи количеством информации. Обычно создают ассоциативный массив node_from[], в котором в ячейке node_from[v] лежит None, если узел v не достижим из входного узла, или узел u, из которого мы попали в v, когда исследовали лабиринт. Для лабиринта с иллюстрации 7-4 node_from[(1, 3)] будет хранить узел (0, 3).

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

Перед обедом в столовой вы наверняка берёте верхний поднос из стопки подносов. Абстракция «стек» имитирует поведение такой стопки. В стеке предусмотрено два действия: «положить значение на стек», .append(значение)4, которая добавляет значение на вершину стека, и «снять значение со стека», .pop(), которая атомарно выполняет двойное действие: удаляет значение с вершины стека и возвращает это значение. Эту абстракцию также называют LIFO, по первым буквам словосочетания «Last In, First Out», то есть «последний добавляемый [элемент на стек] первым снимается [с него]».

С этого момента я вынужден снова переписать часть примеров и изменить изложение, потому что автор снова демонстрирует «как не надо делать», изобретая искусственную структуру данных вместо уже имеющейся. -- FrBrGeorge 2023-01-18 14:49:44

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

Алгоритм обхода вглубину хранит в стеке план разведки — узлы, которые были доступны, но ещё не исследованы при обходе лабиринта. В примере 7-3 приведена возможная реализация такого алгоритма. Слово «вглубь» в названии происходит от того, что в алгоритме мы пытаемся сперва сделать очередной шаг, как если бы выход был в соседней комнате, а другие смежные узлы оставляем на потом.

Обход лабиринта начинается во входном узле src, который тут же помечается как посещённый (то есть в marked[src] записывается True) и сразу кладётся на стек как текущее окончание маршрута. Всё время работы цикла while в стеке находятся уже посещённые узлы, которые встретились нам на пути к текущему. В цикле узлы снимаются по одному из стека, после чего все не посещённые доселе смежные с данным узлы кладутся обратно на стек — их ещё предстоит исследовать.

   1 def dfs_search(G, src):                 # 1
   2   node_from, stack = {}, [src]          # 2
   3   marked = {src: True}                  # 3
   4 
   5   while stack:                          # 4
   6     v = stack.pop()                     # 5
   7     for w in G[v]:
   8       if not w in marked:
   9         node_from[w] = v                # 6
  10         marked[w] = True                # 7
  11         stack.append(w)
  12 
  13   return node_from                      # 8
  1. Обойдём граф G начиная с входного узла src вглубину.

  2. В node_from[w] будет храниться узел, из которого мы попали в w — это история обхода, вплоть до src (которого в ней нет). На вершине стека — единственный узел, входной.

  3. Во входном узле мы уже побывали, так что он единственный помечен как посещённый.

  4. Если стек непуст, значит обход не завершён: есть ещё неисследованные узлы.

  5. Следующий узел для проверки, v, лежит на вершине стека.

  6. Просмотрим все узлы w, смежные с v, и выберем из них ещё не посещённые. Запомним, что каждый такой w доступен из v.

  7. Отметим w как посещённый (чтобы не исследовать потом повторно) и положим его на стек.

  8. Возвратим историю обхода: для каждого исследованного нами узла v в ней хранится узел, из которого мы попали в этот v, когда начали обход в стартовом узле src.

На иллюстрации 7-5 показан обход вглубину с отображение состояния стека на каждом проходе цикла while. Выделенный на вершине стека узел — это комната, которую мы в данный момент исследуем, остальные узлы на стеке — это комнаты, которые мы исследуем в будущем. Что не даёт обходу вглубину бесцельно и бесконечно обшаривать одни и те же соседние комнаты? Каждый узел, который мы кладём на стек, помечается как уже исследованный, а это означает, что вторично на стек мы его никогда не положим. В цикле for (с переменной w) мы ищем ещё не помеченные смежные с v узлы, помечаем их и кладём на стек, чтобы потом исследовать.

Иллюстрация 7-5. Обход вглубь найдёт любую комнату, доступную из входной.
Иллюстрация 7-5. Обход вглубь найдёт любую комнату, доступную из входной.

Вход

Содержимое node_from[]

Выход

Содержимое стека →

Время →

Обход сначала упирается в тупик на (1, 3), но немедленно откатывается до (0, 4) и продолжается дальше. Иллюстрация 7-5 показывает момент, когда выход уже найден, но обход продолжается, потому что не узлы, достижимые из входного, помечены, и стек не пуст.

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

Полученная история обхода вглубь показана на иллюстрации 7-5 в правой части: это содержимое словаря node_from[]. Словарь этот можно назвать деревом, потому что стрелки не образуют циклы. По содержимому этого словаря можно восстановить путь от входа (0, 2) до любого достижимого узла в графе, если идти от конца пути. Например, node_from[(0, 0)] = (1, 0), то есть предпоследний узел на пути к от`(0, 2) к (0, 0) — это (0, 1).

Вообще-то хранящийся в истории обхода путь от входа к выходу далеко не самый короткий: он состоит из шести переходов. Обход вглубину не обязан выдавать кратчайшие пути, но зато непременно найдёт путь до каждого узла, если до него вообще можно добраться от входа. Функция path_to() в примере 7-4 вычисляет по данному node_from[] путь от входа src к произвольному узлу target в виде последовательности узлов, пользуясь тем, что node_from[v] всегда содержит узел, которые на пути от src до v был предпоследним.

   1 def path_to(node_from, src, target):    # 1
   2   if not target in node_from:
   3     raise ValueError('Unreachable')     # 7
   4 
   5   path, v = [], target                  # 2
   6   while v != src:
   7     path.append(v)                      # 3
   8     v = node_from[v]                    # 4
   9 
  10   path.append(src)                      # 5
  11   path.reverse()                        # 6
  12   return path
  1. Для того, чтобы найти путь из src в target (если он есть), достаточно знать историю обхода.

  2. Переменная v будет проходить по обратному пути от target до src.

  3. Пока v не добралось до src, добавляем v в обратный путь.

  4. Продолжаем идти назад, теперь v — это предыдущий узел в пути, node_from[v].

  5. Цикл заканчивается, когда мы доходим до src. Сам src для полноты картины надо добавить в path.

  6. Переставляем элементы path в обратном порядке, превращая обратный путь в прямой, и возвращаем его5.

  7. Сразу проверим, что target достижим из src, то есть содержится в node_from[].

Функция path_to() строит обратную последовательность узлов — от target до src — а затем просто обращает её, и получается искомый путь от src до target. Если попытаться построить путь до недостижимого узла, path_to() породит исключение ValueError.

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

Другой способ обхода: вширину

Обход лабиринта вширину устроен таким образом, что комнаты нам встречаются в порядке их удалённости от входа. Возьмём тот же лабиринт, что и на иллюстрации 7-3, и надпишем на каждой комнате кратчайшее расстояние от входа. Если нам удастся придумать, как сделать это достаточно эффективно, мы и получим обход вширину, при котором вычисляется кратчайший (состоящий из наименьшего количества рёбер) путь от входного узла до любого достижимого. Например, из иллюстрации 7-6 очевидно, что кратчайший путь от входа к выходу пролегает всего через три узла.

Иллюстрация 7-6. "Обход вширину определяет кратчайший путь до каждой вершины
Иллюстрация 7-6. "Обход вширину определяет кратчайший путь до каждой вершины

Вход

Выход

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

Продолжая аналогию: обход вглубину — оптимистичен, ибо ожидает выхода за первым же поворотом, а обход вширину предпочитает порядок, и просматривает все узлы, расстояние до которых от входа на один шаг больше. Это даёт ещё четыре узла (на иллюстрации 7-6 помечены двойкой) — они от входа в двух шагах. Если продолжать исследовать узлы таким манером, то после того, как в свою очередь мы рассмотрим все узлы, что в дух шагах от входа, мы найдём четыре узла, до которых от входа три шага. Процесс будет повторяться до тех пор, пока мы не побываем во всех достижимых от входа узлах.

Для обхода вширину нам понадобится особая структура данных, которая гарантировала бы нам, что узел, находящийся на расстоянии d + 1 от входа, попадётся нам не ранее всех узлов, находящихся от входа на расстоянии d. Такую структуру данных мы знаем — это очередь, описанная нами в главе 4, ибо в ней поддерживается дисциплина «первым вошёл, первым вышел» (FIFO, First In First Out): узлы, добавленные в очередь сначала, непременно будут рассмотрены первыми. Программа из примера 5-7 практически такая же, что и для обхода вглубину, за исключением того, что используется очередь, и в очереди хранится пространство активного поиска, то есть набор узлов, которые в данный момент необходимо исследовать.

   1 def bfs_search(G, src):         # 1
   2   marked = {src: True}          # 2
   3   node_from = {}                # 3
   4 
   5   q = Queue()
   6   q.enqueue(src)                # 4
   7 
   8   while not q.is_empty():       # 5
   9     v = q.dequeue()
  10     for w in G[v]:
  11       if not w in marked:
  12         node_from[w] = v        # 6
  13         marked[w] = True        # 7
  14         q.enqueue(w)
  15 
  16   return node_from              # 8
  1. Функция обхода графа G вширину начиная с входного узел src.

  2. В словаре marked мы храним уже исследованные узлы — вход уже там.

  3. В словаре node_from будет храниться история обхода: для каждого узла w в node_from[w] содержится узел, из которого мы попали в w при обходе, т. е. очередной узел по дороге обратно к src/.

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

  5. Если наш обход вширину не завершён, снимем из очереди очередной исследуемый узел, v.

  6. Перебираем все смежные с v и притом не помеченные узлы w. Запоминаем, что путь до них лежит через v.

  7. Ставим w в конец очереди (плана разведки) и помечаем его, чтобы не исследовать повторно.

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

История обхода вширину содержит узлы в порядке их удаления от входа, поэтому любой воссозданный по этой истории путь до любого достижимого узла окажется кратчайшим6. Воссоздать путь из src до любого достижимого оттуда узла можно с помощью всё той же функции path_to(). На иллюстрации 7-7 хорошо видно, как методично исследуется граф при обходе вширину.

Мы используем очередь для хранения плана разведки в порядке удаления от входа, и узлы на иллюстрации помечены разными оттенками в зависимости от расстояния до входного узла. Если мы упираемся в тупик, новых узлов в очередь не добавляется. Заметим, что выходной узел (2, 2) мы добавляем в очередь внутри цикла for, но иллюстрация сделана в момент, когда этот узел снимается из очереди во внешнем цикле while. К этому времени все узлы, расстояние до которых меньше двух шагов, мы уже просмотрели, а самый последний узел в очереди уже в трёх шагах от входа.

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

Иллюстрация 7-7. Обход вширину даёт кратчайший путь
Иллюстрация 7-7. Обход вширину даёт кратчайший путь

Вход

Содержимое очереди

↓ Время

Выход

Содержимое node_from[]

При направленном обходе мы будем просматривать узлы в порядке их приближения к входу. Чтобы определить «приближение», надо понять, как измерять расстояние до узла. Нам подойдёт т. н. расстояние городских кварталов7, иначе называемое манхэттенским расстоянием или прямоугольной метрикой. Если мы посчитаем, сколько в сумме рядов и столбцов разделяют два узла, мы и получим манхэттенское расстояние. Например, узел (2, 0) в левом нижнем углу нашего лабиринта окажется по этой метрике в четырёх шагах от узла (0, 2), потому что и про вертикали он отстоит на две колонки, и по горизонтали — на два столбца.

Из трёх узлов, смежных с (0, 2) в направленном обходе мы должны сначала исследовать (1, 2), потому что он всего в шаге от выхода, (2, 2), а два других узла — оба от выхода в трёх шагах (согласно прямоугольной метрике). Получается, что для этого алгоритма узлы должны храниться так, чтобы всякий раз можно было получить узел, ближайший к выходу.

Проще всего применить тут приоритетную очередь с порядком возрастания — или уже реализованную нами в четвёртой главе приоритетную очередь с порядком убывания, но в качестве приоритета использовать отрицательное число, равное по модулю манхеттэнскому расстоянию от узла до выхода. Таким образом получится, что два узла — u на расстоянии десяти шагов от выхода и v на расстоянии пяти — будут помещены в приоритетную очередь как (u, -10) и (v, -5), и следовательно приоритет узла v, который ближе к выходу, окажется больше. Пример 7-6 похож на аналогичные функции для обхода вглубину и обхода вширину, но план разведки — узлы, которые нужно исследовать — будет храниться в приоритетной очереди.

   1 def guided_search(G, src, target):              # 1
   2   from ch04.heap import PQ
   3   marked, node_from = {src: True}, {}           # 2
   4 
   5   pq = PQ(G.number_of_nodes())                  # 3
   6   pq.enqueue(src, -distance_to(src, target))    # 4
   7 
   8   while not pq.is_empty():                      # 5
   9     v = pq.dequeue()
  10     for w in G.neighbors(v):
  11       if not w in marked:
  12         node_from[w] = v                        # 6
  13         marked[w] = True
  14         pq.enqueue(w, -distance_to(w, target))  # 7
  15 
  16   return node_from                              # 8
  17 
  18 def distance_to(from_cell, to_cell):
  19   return abs(from_cell[0] - to_cell[0]) + abs(from_cell[1] - to_cell[1])
  1. Функция обхода графа G начиная с входного узел src по направлению к заранее известному выходу.

  2. В словаре marked будем хранить уже просмотренные узлы. Вход уже там. Историю обхода — в словаре node_from (напомним, что в node_from[w] хранится узел, который был предыдущим на пути от входа в w).

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

  4. Поместим вход, src, в приоритетную очередь — с него и начнётся исследование. Приоритет — отрицательное число, по модулю равное расстоянию до выхода, target.

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

  6. Запоминаем в истории обхода, что до каждого непомеченного узла w, смежного с v, мы дошли из v.

  7. Ставим w в приоритетную очередь. Он займёт там подобающее место с приоритетом, равным отрицательному манхеттэнскому расстоянию до входа; пометим этот узел.

  8. Возвратим историю обхода, в которой для каждого узла v хранится предыдущий (при обходе, началом из узла src).

Толику искусственного интеллекта направленному обходу придаёт функция distance_to(), которая вычисляет манхэттенское расстояние между двумя узлами.

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

Иллюстрация 7-8. Сравнение обхода лабиринта вглубину, вширину и по направлению в выходу
Иллюстрация 7-8. Сравнение обхода лабиринта вглубину, вширину и по направлению в выходу

Обход вширину

Обход вглубину

Направленный обход

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

Все наши алгоритмы обхода использовали словарь marked, узлы из которого не надо исследовать, и в результате в любой из N узлов графа мы заглядываем не больше раза. Тогда можно предположить, что сложность любого из них — O(N), но чтобы это доказать, надо оценить производительность различных действий внутри цикла. Операции push() и pop() над стеком имеют константную сложность. Осталось только понять, как работает цикл for w in G[v], в котором перебираются все смежные с v узлы. Тут всё несколько сложнее: необходимо знать, как именно хранятся рёбра в графе. Есть два способа хранить рёбра (оба показаны на иллюстрации 7-9): матрица смежности и список смежности.

Матрица смежности

Двумерный массив M размером N × N, хранящий N² булевских значений. Все узлы нумеруются, то есть каждому узлу u ставится в соответствие число uidx в диапазоне от 0 до N. Если существует ребро из u в v, значение M[uidx][vidx] — истинно. На иллюстрации значение True (истина) обозначено закрашенной клеткой. С помощью матрицы смежности мы можем найти все узлы, смежные с данным, за N шагов, так как для этого необходимо проверить одну полную строку матрицы. Соответственно, независимо от действительного количества смежных узлов для каждого узла лабиринта сложность такого действия будет O(N). Поскольку сам цикл while проходит N итераций, а внутренний цикл сам по себе имеет сложность O(N), выходит, что наше исходное предположение неверно, и сложность всего алгоритма — квадратичная, O(N²).

Список смежности

Ассоциативный массив, ключом которого выступает узел графа, а хранится в нём список узлов, смежных с соответствующим узлом. МЫ помним, что поиск в словаре по ключу имеет константную сложность, а поиск в списке — линейную, так что время выборки всех вершин, смежных с данной, будет прямо пропорционально степени вершины, то есть количеству исходящих из неё рёбер. Порядок перечисления рёбер не определён, он зависит о порядка их добавления в граф. На иллюстрации 7-9 для каждого узла представлен список рёбер, при этом у некоторых узлов смежных мало, а у некоторых — больше.

Иллюстрация 7-9. Как выглядят матрица смежности и список смежности
Иллюстрация 7-9. Как выглядят матрица смежности и список смежности

Матрица смежности

Список смежности

Я слегка исправил картинку — убрал оттуда представление связного списка -- FrBrGeorge 2023-01-24 17:23:08

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

   1 while stack:
   2   v = stack.pop()
   3   for w in G[v]:
   4     if not w in marked:
   5       marked[w] = True
   6       stack.append(w)
   7       ...

Мы помним, что каждый узел v добавляется на стек не более раза. Это значит, что оператор if выполняется для каждого смежного с v узла. Возьмём простейший граф из двух узлов, u и v, и одного ребра (u, v) — уже на нём if выполнится дважды: сначала при поиске вершин, смежных с u, а затем — при поиске вершин, смежных с v. В общем, на ненаправленных графах этот if будет выполняться 2·E раз, где E — это общее число рёбер в графе.

Если учесть все действия — вызовы .append() / .pop(), которых может быть не более N и 2·E раз выполненный оператор if, можно смело утверждать, что быстродействие алгоритмов, использующих список смежности, равно O(N + E), где N — это число узлов, а E — число рёбер графа. Утверждение верно и для обхода вширину, в котором используется не стек, а очередь, но производительность оказывается такой же.

В каком-то смысле все наши оценки совпадают: в самом деле, неориентированный граф с N узлами может содержать не более E = N · (N - 1) / 2 рёбер9. Так что независимо от того, используется ли для хранения графа матрица смежности или список смежности, обход графа с достаточно большим количеством рёбер потребует порядка N · (N - 1) / 2 действий в наихудшем случае, то есть сложность окажется O(N²).

А вот направленный обход для хранения узлов использует приоритетную очередь с порядком — расстоянием до выхода. Операции enqueue() и dequeue() в наихудшем случае имеют сложность O(log N). Сами методы вызываются N раз, и каждое ребро просматривается дважды, так что быстродействие направленного обхода в худшем случае оказывается O(N log N + E).

Ориентированные графы

Графы можно использовать для представления данных в задачах, где связь между узлами — односторонняя (обычно обозначается стрелкой). Дуга — ориентированное ребро — (u, v) соединяет u с v, и это значит, что вершина v смежна с u, но и только: дуга не делает вершину u смежной с v. В ориентированном ребре (u, v) вершина u называется, естественно, началом дуги, а v — её концом. В графе на иллюстрации 7-10 есть дуга (B3, C3), но нет дуги (C3, B3).

Иллюстрация 7-10. Пример ориентированного графа с 12 узлами и 14 рёбрами
Иллюстрация 7-10. Пример ориентированного графа с 12 узлами и 14 рёбрами

Ориентированный граф (или орграф) — можно обходить как вглубину, так и вширину, единственное отличие — в том, что дуга (u, v) означает, что вершина v смежна с u, а для того, чтобы u была смежна с v, нужно, чтобы в графе была также и дуга (v, u). Многие алгоритмы на оргафах можно упростить, если реализовать их рекурсивно, как в примере 7-8. Можно видеть, что эта программа заимствует основные компоненты из нерекурсивной реализации.

   1 def dfs_search(G, src):         # 1
   2   marked, node_from = {}, {}    # 2
   3 
   4   def dfs(v):                   # 3
   5     marked[v] = True            # 4
   6     for w in G[v]:
   7       if not w in marked:
   8         node_from[w] = v        # 5
   9         dfs(w)                  # 6
  10 
  11   dfs(src)                      # 7
  12   return node_from              # 8
  1. Функция обхода графа G вглубь начиная со входного узла src.

  2. Словарь marked хранит уже посещённые узлы, словарь node_from — историю обхода графа функцией dfs(): в node_from[w] содержится предыдущей узел в пути от src до w.

  3. Рекурсивная функция, при каждом вызове которой обход продолжается с непомеченного узла v.

  4. Пометим узел v как посещённый.

  5. Для каждого непомеченного смежного с v узла w запоминаем, что дойти до него можно было от v.

  6. Рекурсивный вызов: продолжим исследование с непомеченного узла w, а после возврата из вызова выберем очередной узел w в цикле for.

  7. Стартовый рекурсивный вызов dfs() с параметром — входным узлом src.

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

Обратите внимание на то, что в рекурсивном алгоритме нет выделенного стека, в котором хранится план разведки. В действительности такой стек задан неявно — это последовательность контекстов рекурсивных вызовов функции dfs(), в каждом из которых есть локальные переменные u и v, а также список G[v].

Каждый контекст dfs(v) в стеке рекурсивных вызовов содержит собственную локальную переменную v, которая является частью активного пространства поиска. Основание рекурсии в dfs(v) наступает, когда у узла v больше нет непомеченных смежных узлов w, при этом ничего не происходит, и контекст после возврата уничтожается. Для каждого непомеченного смежного узла w происходит рекурсивный вызов dfs(w). После возврата из рекурсивного вызова цикл for продолжается: возможно, есть ещё непомеченные смежные с v узлы, которые надо будет исследовать с помощью dfs().

image Мы в нашей книге уже отмечали, что глубина рекурсии в Python ограничена 1000 вызовами, так что с помощью алгоритмов, в которых использование рекурсии не оправдано, мы можем обрабатывать только небольшие объёмы данных. Например, в прямоугольный лабиринт размером 50 × 50 состоит из 2500 узлов, и наш обход вглубь явно превысит ограничение на глубину рекурсии. Если заменить стек рекурсивных вызовов настоящим стеком, в котором хранится то же, что было в локальных переменных рекурсивной функции (в нашем случае — пространство активного поиска), проблема исчезнет. Увы, текст программы при этом становится более сложным и менее читаемым, так что в этой главе мы будем использовать «неоправданный» рекурсивный вариант.

Исходный текст функции, которая обходит граф вглубь с использованием стека вместо рекурсии можно найти в файле ch07/search.py репозитория10.

С помощью орграфов можно моделировать объекты из самых разных предметных областей, и для этих объектов решать бесчисленное множество задач. На иллюстрации 7-11 представлена электронная таблица, ячейки которой однозначно определяются названием столбца аи номером строки. В ячейке B3 записана единица, то есть B3 равно 1. Слева таблица изображена так, как её видит пользователь, а затем в виде чисел, ссылок на другие ячейки или формул, которые в действительности записаны в ячейках. Формула в ячейке может использовать константы или содержимое других ячеек, которое, в свою очередь, тоже может вычисляться по формуле. Формат представления формул — инфиксный, такие формулы мы видели в главе 6. Например, в ячейке A4 записана формула «=(A3 + 1)». В ячейке A3 записано «=(A2 + 1)», что равно 1, потому что A2 равно 0 — а значит, A4 равно 2. В репозитории к этой книге есть программа, реализующая отношения в этой таблице.

Иллюстрация 7-11. Пример электронной таблицы, соответствующей ориентированному графу
Иллюстрация 7-11. Пример электронной таблицы, соответствующей ориентированному графу

В первой строке электронная таблица содержит заголовки столбцов — N, FibN и Sn. Во всех остальных строках в столбце A содержится первые семь целых неотрицательных чисел N, в столбце B — первые семь чисел Фибоначчи11. В каждой строке столбца C записана частичная сумма первых N чисел Фибоначчи (например, в C7 хранится 12, что равно 0+1+1+2+3+5). В правой части иллюстрации 7-11 изображён орграф, отражающий зависимость ячеек друг от друга. Например, дуга между A2 и A3 означает, что для вычисления A3 надо знать значение A2, то есть, во-первых, если изменится A2, то изменится и A3, и, во-вторых, перед тем, как вычислять A3, необходимо сначала вычислить A2.

Ячейки электронной таблицы не могут содержать взаимных ссылок: например, если в ячейке C2 написано =B2, то с ячейке B2 не должно быть =C2 — значение таких ячеек вычислить нельзя, это считается ошибкой. В орграфах подобная ситуация называется контуром. Контур — это последовательность дуг, которая начинается и заканчивается в некотором узле u. Любая система, работающая с электронными таблицами, должна проверить, что в ней нет контуров — а значит, ячейки без взаимных ссылок можно вычитать без ошибок. Вернёмся к иллюстрации 7-11. Обрабатывать ячейки, в которых записаны числа, не нужно. К моменту вычисления A4 значение A3 должно быть уже известно (а к моменту вычисления A5 нужно знать, соответственно, значения обеих ячеек). Зависимость между ячейками в столбцах B и C — посложнее, а значит и порядок их вычисления определить не так легко, не говоря уже о том, чтобы обнаружить в них контур.

Для аккуратной работы приложению-электронной таблице нужно завести орграф, в котором будут отражены зависимости ячеек друг от друга. Если пользователь заменяет в какой-то ячейке формулу числом, приложение должно удалить соответствующие дуги из графа. Если число на формулу — добавить новые дуги, моделирующий зависимость текущей ячейки от упомянутых в формуле. Если формулу на формулу — часть дуг надо удалить, часть добавить. Например, в таблице на иллюстрации 7-11 можно ошибочно создать контур, если в ячейку B2 вписать формулу =C5. Это приведёт к добавлению в орграф новой дуги C5 → B2, что породит несколько контуров; вот один из них: [B2, B4, B5, C5, B2].

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

image Обходя орграф вглубь, мы можем наткнуться на помеченный узел, даже если это граф из трёх узлов и контуров в нём нет. Если запустить dfs() с узла a, то обход вглубь встретит сначала b, а затем c, зайдя в тупик. Откатившись обратно к a, мы обратимся к следующему смежному узлу, c, а он уже помечен, хотя контуров в графе нет. ../images-233-149.png

Поиск контура в графе — алгоритм, который, в отличие от других алгоритмов в этой главе, не начинает исследование с какого-то конкретного входного узла, потому что должен найти контур в любом месте графа. Как показано в примере 7-9, для этого придётся исследовать многие (возможно, все) узлы графа. Словарь marked теперь хранит состояние узла: если marked[v] равно 1, узел помечен и присутствует в пространстве активного поиска (плане разведки), а если 2 — помечен, но в плане разведки его уже нет. При рекурсивном обходе вглубь пространством активного поиска (планом разведки) является стек контекстов вызовов, доступа к которому у нас нет. Поэтому информацию о том, есть ли данное v в одном из таких контекстов мы будем хранить в словаре marked: если marked[v] равно 1, узел помечен и присутствует в плане разведки, а если 2 — помечен, но в плане разведки его уже нет.

   1 def has_cycle(DG):
   2       marked = {}
   3 
   4       def dfs(v):                               # 1
   5           marked[v] = 1                         # 2
   6           for w in DG[v]:
   7               state = marked.get(w, 0)          # 3
   8               if state == 1:                    # 4
   9                   return True
  10               if state == 0 and dfs(w):         # 5
  11                   return True
  12           marked[v] = 2                         # 6
  13           return False
  14 
  15       for v in DG.nodes():                      # 7
  16           if v not in marked and dfs(v):        # 8
  17               return True
  18       return False
  1. Функция dfs(v) обходит граф DG вглубину, начиная с узла v.

  2. Узел v помечен как посещённый и добавлен в план разведки (значение 1 в словаре marked).

  3. Состояние каждого узла w, смежного с данным v: 0 — не помечен, 1 — помечен и есть в плане разведки, 2 — помечен и уже разведан. Помечаем v и

  4. Если смежный узел w помечен и и при этом есть в плане разведки, то это по определению контур, можно сразу возвращать True.

  5. Если смежный узел w ещё не помечен, рекурсивно вызываем dfs(w), и если там нашёлся контур, возвращает True.

  6. Если контура пока не найдено, изменяем состояние узла v на «помечен, в плане разведки уже не присутствует» (значение 2).

  7. Обход вглубину нужно проделать из каждого узла в нашем орграфе — мало ли, где найдётся контур.

  8. Достаточно искать не из каждого узла, а только из каждого ещё не помеченного. Если dfs(v) на таком узле v выдаст True — значит, найден контур, и надо возвращать True.

С каждым рекурсивным вызовом dfs() граф исследуется всё больше до тех пор, пока всё узлы не окажутся помеченными — даже если они не являются вершинами дуг.

Определить состав самого контура тоже несложно — этому посвящено одно из тренировочных упражнений в конце главы, в котором требуется дописать has_cycle() таким образом, чтобы оно составляло и возвращало первый найденный в орграфе контур. На иллюстрации 7-12 показан рекурсивный вызов dfs(). Каждый исследуемый узел сначала помечается единицей — в знак того, что он определён как посещённый, и при этом находится в пространстве активного поиска, — а на выходе из рекурсивного вызова этот узел помечается двойкой, это признак посещённого узла, которого уже нет в плане разведки. Пометки — это содержимое словаря marked, но на иллюстрации соответствующая пометка для наглядности проставлена прямо на узле и выделена фоном. История на иллюстрации заканчивается тем, что в процессе обхода обнаруживается контур [a, b, d, a]: мы просматриваем узлы, смежные с d, и среди них оказывается узел a, помеченный двойкой — а это значит, что он присутствует в плане разведки. Иными словами, двигаясь из узла a по дугам, мы снова попали в a, что в точности соответствует определению контура. Мы уже определили контур, и возвращаем True, однако некоторые узлы всё ещё помечены двойкой, то есть остаются в (уже не актуальном) плане разведки — потому что рекурсивный вызов не доработал до конца.

Иллюстрация 7-12. Как найти контур, обходя орграф вглубину
Иллюстрация 7-12. Как найти контур, обходя орграф вглубину

Я переделал эту иллюстрацию, потому что переписал пример, стиль которого был чересчур неряшливым -- FrBrGeorge 2023-02-01 14:25:30

Предположим, контуров в электронной таблице нет. Теперь важно определить порядок, в котором нужно вычислять зависящие друг от друга ячейки. В старой таблице с иллюстрации 7-11 ячейки с константами (например, A1), вообще не надо вычислять, и они на порядок не влияют. Ячейка B4 содержит формулу, в которой явно упомянуты B2 и B3, так что их надо посчитать раньше, чем B4. Вот один из возможный вариантов линеаризации зависимостей в таблице:

B2, C2, B3, C3, B4, C4, B5, C5, A2, A3, A4, A5

Такой порядок может быть результатом работы функции topological_sort(), приведённой в примере 7-10. Топологическая сортировка похожа на только что рассмотренный нами алгоритм поиска контуров — в нём тоже используется рекурсивная природа обхода вглубину. Всякий раз перед выходом dfs(v) из рекурсивного вызова, когда все достижимые из v узлы помечены, то есть dfs() уже обошла все узлы, «лежащие за v», можно добавить этот v в историю обхода, как узел, все зависимости которого уже удовлетворены. Вычислять ячейки электронной таблицы следует с конца этой истории.

   1 def topological_sort(DG):
   2   marked, postorder = {}, []    # 1
   3 
   4   def dfs(v):                   # 2
   5     marked[v] = True            # 3
   6     for w in DG[v]:
   7       if not w in marked:
   8         dfs(w)                  # 4
   9     postorder.append(v)         # 5
  10 
  11   for v in DG.nodes():
  12     if not v in marked:         # 6
  13       dfs(v)
  14 
  15   return reversed(postorder)    # 7
  1. В списке postorder хранится история обхода — линеаризация узлов графа в обратном порядке.

  2. Функция dfs(v) выполняет обход графа DG вглубину, начиная с узла v.

  3. В словаре marked отмечается, что узел уже посещён.

  4. Вызовем dfs(w) для всех непомеченных смежных с v узлов w.

  5. В этом месте алгоритма функция dfs(v) уже исследовала все узлы, которые (рекурсивно) зависят от v — самое время добавить v в историю обода.

  6. Запустим dfs(v) для каждого ещё не помеченного узла v из графа DG. Заметим, что dfs() сам помечает вершины, так что каждый новый вызов обрабатывает отдельное подмножество узлов DG.

  7. История обхода содержит линеаризацию в обратном порядке, так что обратим её и вернём.

В этой функции практически всё повторяет поиск контура, кроме того, что вместо явного хранения плана разведки в ней ведётся история обхода. Исследуя эту функцию уже известным нам методом, мы увидим, что каждый узел исследуется с помощью dfs() только раз, а внутренний оператор if выполняется по разу для каждой дуги графа. Добавление в список имеет константную сложность (см. таблицу 6-1), что даёт нам линейную оценку производительности топологической сортировки O(N + E), где N — это количество узлов в графе, а E — количество дуг.

Иллюстрация 7-13. Как работает топологическая сортировка путем обхода вглубину
Иллюстрация 7-13. Как работает топологическая сортировка путем обхода вглубину

На иллюстрации 7-13 видно, что как только очередной вызов dfs() из примера 7-10 заканчивается, в списке postorder содержатся все узлы, зависимости которых уже удовлетворены; эти узлы мы перечислили в виде квадратов с пунктирными границами. Под конец работы функции topological_sort() этот список — в обратном порядке — возвращается функцией. После того, как приложение загрузило электронную таблицу, оно должно пересчитать содержимое ячеек — и порядок подсчёта может указать топологическая сортировка.

Взвешенные графы

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

image Стэнфордская Большая Коллекция Сетевых Данных (https://oreil.ly/hXqcg) содержит множество наборов данных, например, о связях в социальных сетях. В библиотеке TSPLIB (https://oreil.ly/MdMWm) собраны наборы данных для т. н. «задачи коммивояжёра» (traveling salesman problem, TSP). Программисты десятилетиями изучают методы решения этой задачи, так что примеров накопилось немало. Большая коллекция графов есть на сайте «Карты Путешествий» (https://oreil.ly/qWYsr). Отдельную благодарность хотелось бы выразить Джеймсу Тереско за любезно предоставленную таблицу дорог штата Массачусетс (https://oreil.ly/wlEy2).

По таблице дорожных участков штата Массачусетс можно сконструировать граф, в котором каждый узел — это путевая точка (остановка), однозначно определяемая координатами: парой чисел, широтой и долготой. Например, в таблице есть остановка на пересечении шоссе I-90 и I-93 в район Бостона.

Широта этой точки (угол на север из центра Земли от экватора) равна 42.34642, а долгота (угол на запад от Гринвичского меридиана) равна –71.060308. Ребро меду двумя узлами — это участок дороги, а вес ребра — это длина пути между остановками в милях. Если соединить все остановки дорогами, получится схема, представленная на иллюстрации 7-14.

Иллюстрация 7-14. Схема дорожной сети штата Массачусетс
Иллюстрация 7-14. Схема дорожной сети штата Массачусетс

широта = 42.34642

долгота = –71.060308

Как теперь определить кратчайшее — в милях — расстояние от самой западной остановки (на границе со штатом Нью-Йорк) до крайнего востока штата — остановки на Тресковом Мысе? Попробуем обход вширину. Результат показан на иллюстрации серой полужирной линией, его длина — 236.5 миль. Пусть состоит из 99 рёбер, проходит через ту самую остановку на перекрёстке I-90/I-93 в Бостоне, и это самый короткий путь от старта до финиша — если считать количество остановок (или рёбер графа). А вот будет ли этот кратчайшим, если измерять пройденное расстояние? Похоже, что нет.

Мы знаем, что обход вглубину не находит кратчайшего пути: на иллюстрации 7-15 мы видим найденный с его помощью извилистый путь, он имеет длину аж в 485.2 мили и содержит 267 рёбер. Направленный обход (на иллюстрации не показан12) сделал неправильный выбор где-то в самом начале пути и нашёл путь из 141 ребра длиной в 245.2 мили. А вот выделенный чёрным на иллюстрации 7-14 кратчайший путь, в котором 210 рёбер, но длина которого всего 210.1 мили, был найден с помощью алгоритма Дейкстры — им-то мы сейчас и займёмся.

Иллюстрация 7-15. Очень длинный путь, найденный при обходе вглубину
Иллюстрация 7-15. Очень длинный путь, найденный при обходе вглубину

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

Эдсгер Вибе Дейкстра — нидерландский учёный, один из создателей академического базиса компьютерной науки и отменный программист, автор уникальных несколько алгоритмов, столь же впечатляющих, сколь и изящных, носят его имя. Алгоритм поиска кратчайшего суммарного пути от заданного узла ко всем достижимым узлам во взвешенном графе так и называется — алгоритм Дейкстры, а класс задач, им решаемых — «нахождение кратчайшего пути из заданной точки». Несколько позже мы рассмотрим пример, в котором по заданному графу (или орграфу) с неотрицательными весами рёбер13, алгоритм Дейкстры заполняет два словаря: dist_to[], где хранится кратчайшее суммарное расстояние от входного узла до данного, и edge_to[], содержащий историю обхода.

На иллюстрации 7-16 приведён простейший взвешенный орграф. Вес дуги (a, b) равен 6, дуги (a, c) — 10, а (b, c) — 2. Поэтому кратчайшим путём от a до c оказывается не прямой, длиной 10, а окольный — из двух дуг суммарной длины 8.

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

Иллюстрация 7-16. Суммарная длина кратчайшего пути из a в c — 8
Иллюстрация 7-16. Суммарная длина кратчайшего пути из a в c — 8

Для работы с графом алгоритму Дейкстры нужна особая структура данных — индексированная приоритетная очередь с порядком возрастания. Индексированная приоритетная очередь — это некоторое расширение функций приоритетной очереди, которую мы изучали в главе 4. В обеих структурах каждому хранимому значению ставится в соответствие приоритет. И там, и там для хранения используется массив из N элементов — в нашем случае это количество узлов в графе. В отличие от очереди с порядком убывания, которую мы рассмотрели в четвёртой главе, метод .dequeue() снимает из начала очереди элемент с наименьшим приоритетом.

Далее в этом разделе идёт постоянная путаница в стиле: «наивысший приоритет, то есть наименьшее числовое значение приоритета», зачем автор сделал так — не знаю, бороться с этим времени уже нет. -- FrBrGeorge 2023-02-03 15:15:40

Самое важное дополнение индексированной очереди — это метод .decrease_priority(значение, приоритет). Он понижает текущий приоритет значения до более низкого приоритета. В результате соответствующий элемент продвигается ближе к началу очереди, обгоняя другие элементы с бо́льшими значениями приоритета. Для обычной приоритетной очереди, которую мы изучали ранее, такая операция была бы довольно неэффективной потому что для поиска конкретного значения (которому надо понизить приоритет) пришлось бы просматривать всю очередь, затратив на это O(N) действий.

image Обычно в индексированной приоритетной очереди в качестве значений используются целые числа от 0 до N - 1. Приоритеты при этом хранятся в обычном массиве, а доступ к ним происходит просто по значению-индексу. Но в Python мы можем использовать словарь — тогда значения могут быть любыми константами.

Как видно из примера 7-11, класс IndexedMinPQ устроен и работает почти так же, как приоритетная очередь на основе кучи, которую мы исследовали в четвёртой главе. Вместо того, чтобы хранить объекты типа Entry, мы заведём два списка: .values[n] будет хранить значения элементов, а .priorities[n] — их приоритеты. Методы .swim() и .sink() такие же, как в очереди на основе кучи (подробнее см. в примерах 4-2 и 4-3), так что мы их не приводим. Главное отличие — словарь .location, который хранит информацию, обратную списку values: в .location[v] находится индекс значения элемента v в списке .values (и его приоритета в списке .priorities)14. Дополнительная информация помогает нам не искать значение по всей очереди, а получать его индекс за константное (с учётом возможного масштабирования) время O(1) (о том, как при этом используется хеширование, мы говорили в главе 3).

Метод .swap() нужно доработать, потому что помимо обмена местами двух элементов очереди, нужно также менять местами соответствующие значения в списке priorities и обновлять содержимое словаря location, чтобы в IndexedMinPQ сохранялась способность быстрого поиска любого значения.

   1 class IndexedMinPQ:
   2   def less(self, i, j):
   3     return self.priorities[i] > self.priorities[j]                      # 1
   4 
   5   def swap(self, i, j):
   6     self.values[i], self.values[j] = self.values[j], self.values[i]     # 2
   7     self.priorities[i], self.priorities[j] = self.priorities[j], self.priorities[i]
   8 
   9     self.location[self.values[i]] = i                                   # 3
  10     self.location[self.values[j]] = j
  11 
  12   def __init__(self, size):
  13     self.N          = 0
  14     self.size       = size
  15     self.values     = [None] * (size+1)                                 # 4
  16     self.priorities = [None] * (size+1)
  17     self.location   = {}                                                # 5
  18 
  19   def __contains__(self, v):                                            # 6
  20     return v in self.location
  21 
  22   def enqueue(self, v, p):
  23     self.N += 1
  24     self.values[self.N], self.priorities[self.N] = v, p                 # 7
  25     self.location[v] = self.N                                           # 8
  26     self.swim(self.N)
  1. Поскольку мы строим очередь с порядком возрастания, более высокий приоритет (и положение ближе к началу очереди) будет у элемента j с меньшим числовым значением приоритета, чем у элемента i.

  2. В методе .swap() поменяем местами значения и приоритеты двух элементов, i-го и j-го.

  3. Там же в методе .swap() обновим соответствующие значения в словаре сохранённых индексов location.

  4. Для каждого из N элементов в массиве values будет под соответствующим индексом лежать значение, а в массиве priorities — приоритет.

  5. В словаре location хранятся индексы элементов очереди в массивах values и priorities.

  6. В отличие от обыкновенной приоритетной очереди, индексированная позволяет проверить, есть ли в ней элемент v за (в среднем) константное время O(1) путём поиска в словаре location.

  7. Ещё одно небольшое исправление — в методе .enqueue(). Чтобы добавить пару (v, p) в очередь, v записывается в values[N], а p — в priorities[N], где N — индекс очередной свободной ячейки.

  8. Также в методе .enqueue() нужно запомнить индекс v, и только после этого выполнить .swim(), который восстановит порядок очереди, если тот нарушился.

Как это и полагается в куче, метод .enqueue() сначала добавляет значение v и его приоритет p в конец массивов, соответственно, values[] и priorities[]. Кроме того, в IndexedMinPQ заявлен быстрый поиск элемента, и в словарь location нужно записать, что значение v лежит под индексом N (напомним, что в куче для простоты вычислений массивы индексируются с единицы). Затем вызывается метод .swim(), который восстанавливает возможно утраченный порядок IndexedMinPQ.

Итак, словарь location нужен для быстрого поиска индекса любого хранящегося в очереди значения. Метод .decrease_priority(), представленный в примере 7-12, переставляет данное значение ближе к началу очереди IndexedMinPQ путём замены приоритета. Единственное требование: новый приоритет может быть только меньше исходного (как мы помним, это означает более высокий приоритет), после чего элемент «всплывает» на своё место.

   1 def decrease_priority(self, v, lower_priority):
   2   idx = self.location[v]                        # 1
   3   if lower_priority >= self.priorities[idx]:    # 2
   4      raise RuntimeError('Wrong priority')
   5 
   6   self.priorities[idx] = lower_priority         # 3
   7   self.swim(idx)                                # 4
  1. Найдём индекс элемента v в хранилище

  2. Если lower_priority в действительности не меньше текущего приоритета priorities[idx], сгенерируем исключение RuntimeError.

  3. Уменьшим приоритет v.

  4. При необходимости восстановим порядок в очереди, позволив элементу «всплыть» на своё место.

Метод .dequeue() удаляет значение с наименьшим (то есть наивысшим) приоритетом из начала очереди. Реализация этого метода в IndexedMinPQ несколько сложнее уже виденной нами, потому что дополнительно надо обновлять словарь location — как показано в примере 7-13.

   1 def dequeue(self):
   2   min_value = self.values[1]                            # 1
   3 
   4   self.values[1] = self.values[self.N]                  # 2
   5   self.priorities[1] = self.priorities[self.N]
   6   self.location[self.values[1]] = 1
   7 
   8   self.values[self.N] = self.priorities[self.N] = None  # 3
   9   del self.location[min_value]                          # 4
  10 
  11   self.N -= 1                                           # 5
  12   self.sink(1)
  13   return min_value                                      # 6
  1. Запомним min_value — значение с наивысшим приоритетом.

  2. Перенесём последнее в хранилище значение (с индексом N) в начало хранилища (с индексом 1) и соответственно обновим location, хранящий индексы в соответствии со значениями.

  3. Заметём следы существования удалённого min_value в хранилище.

  4. Удалим также и индекс min_value в словаре location.

  5. Уменьшим на единицу количество элементов в очереди, а затем вызовем .sink(1), дабы восстановить порядок.

  6. Вернём значение, приоритет которого был наивысшим (у этого значения была наименьшая величина приоритета).

В структуре данных IndexedMinPQ постоянно поддерживается следующее свойство: если значение v есть а приоритетной очереди, то location[v] содержит индекс idx соответствующего элемента в хранилище, то есть values[idx] — это v, а priorities[idx] — его приоритет, p.

Индексированная приоритетная очередь используется в алгоритме Дейкстры для вычисления длины наименьшего пути от заданного входного узла src до любого узла орграфа. В процессе обхода составляется словарь dist_to[v], в котором хранится минимальное вычисленное на данный момент расстояние от src до любого узла v в графе. Если узел недостижим из src, в словаре хранится некоторое актуально бесконечное значение. По мере продвижения обхода мы ищем такие пары u и v, дуга между которыми имеет вес wt, что dist_to[u] + wt < dist_to[v] — иными словами, расстояние от src до v больше, чем суммарное расстояние от src до u, а оттуда — до v по дуге (u, v).

Последовательный просмотр узлов в поисках таких укорачивающих расстояние дуг делает алгоритм Дейкстры похожим на обход вширину, где порядок вершим в очереди просмотра зависит от количества шагов до входа. Результаты уже проделанного поиска хранятся в dist_to[v], а остальные узлы добавляются в индексированную очередь сообразно с приоритетом, который равен текущему удалению узла от src. Вначале известно только что dist_to[src] — это 0 (потому что src и есть вход), а остальные расстояния бесконечны. В очереди IndexedMinPQ в этом время находятся все узлы, и у всех у них приоритет бесконечен, кроме нулевого приоритета входного узла src.

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

   1 from math import inf
   2 
   3 def dijkstra_sp(G, src):
   4   N = G.number_of_nodes()
   5 
   6   dist_to = {v:inf for v in G.nodes()}          # 1
   7   dist_to[src] = 0
   8 
   9   impq = IndexedMinPQ(N)                        # 2
  10   for v, dist in dist_to.items():
  11     impq.enqueue(v, dist)
  12 
  13   def relax(e):
  14     n, v, weight = e[0], e[1], e[2][WEIGHT]     # 5
  15     if dist_to[n] + weight < dist_to[v]:        # 6
  16       dist_to[v] = dist_to[n] + weight          # 7
  17       edge_to[v] = e                            # 8
  18       impq.decrease_priority(v, dist_to[v])     # 9
  19 
  20   edge_to = {}                                  # 3
  21   while not impq.is_empty():
  22     n = impq.dequeue()                          # 4
  23     for e in G.edges(n, data=True):
  24       relax(e)
  25 
  26   return (dist_to, edge_to)
  1. В словаре dist_to расстояние до каждого узла равно inf15, а расстояние до src — нулю.

  2. Поставим все узлы в очередь impq с соответствующими приоритетами.

  3. Каждый элемент edge_to[v] будет хранить дугу, которая привела кратчайшим путём к v.

  4. Возьмём из очереди узел n, путь до которого от src кратчайший. Просмотрим все исходящие из него дуги e вида (n, v, weight) (чтобы получить дуги с весами, надо передать методу networkx.Graph.edges() дополнительный параметр data=True). Для каждого v проверим, не короче ли путь до него через n нынешнего кратчайшего пути.

  5. Дуга — это последовательность вида узел n, узел v, данные data, где (n, v) — это собственно дуга, а её вес — одна из составляющих объекта data.

  6. Если путь от src до v оказался длиннее, чем суммарный путь от src до n и далее от n до v, значит, мы нашли новый кратчайший путь.

  7. Обновим информацию о кратчайшем пути до v.

  8. Запишем дугу e (от n до v) в edge_to[v]: именно она привела нас в v кратчайшим путём.

  9. Самый важный момент: уменьшим значение приоритета в impq до длины нового кратчайшего пути. При этом в цикле while будем продолжать выбирать узел с наименьшим вычисленным расстоянием от src.

На иллюстрации 7-17 показаны первые три шага цикла while из примера 7-14. Каждый узел n хранится в IndexedMinPQ с приоритетом, равным наименьшему вычисленному расстоянию dist_to[n] (оно показано в небольшой пунктирной рамке рядом с каждым узлом). На очередном проходе while мы удаляем узел n из impq и проверяем, нет ли более короткого пути из src в некоторый узел v через n. Назовём этот процесс сокращением контура. Поскольку кратчайшее расстояние служит приоритетом IndexedMinPQ, мы можем быть уверены, что в алгоритме Дейкстры путь до каждого снятого из начала очереди impq элемента действительно кратчайший.

Иллюстрация 7-17. Обход небольшого графа алгоритмом Дейкстры
Иллюстрация 7-17. Обход небольшого графа алгоритмом Дейкстры

Состояние обхода в dist_to

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

Новое состояние dist_to

# impq содержит [a, b, c, d]

# impq содержит [b, d, c]

Что следует учитывать при оценке быстродействия алгоритма Дейкстры?

Время постановки в очередь всех узлов

Вначале в очередь добавляются все N узлов: src с приоритетом 0 и N - 1 оставшийся узел с бесконечным приоритетом. При добавлении узлов с бесконечным приоритетом .swim() не делает ничего, потому что условие порядка и так выполнено, и единственный раз при добавлении src узел с приоритетом 0 «всплывает» в начало очереди не более, чем за логарифмическое время. Быстродействие этого шага, следовательно, O(N).

Время выборки всех узлов из очереди

В процессе обхода элементы снимаются из начала очереди по одному. Мы помним, что impq — это двоичная куча, и сложность операции .dequeue() в ней логарифмическая (O(log N)), стало быть, сложность снятия всех N элементов можно оценить как O(N log N).

Время перебора всех дуг графа

Быстродействие перебора всех рёбер графа зависит от того, как они в нём смоделированы. Если с помощью матрицы смежности, на полный перебор потребуется порядка O(N²) действий. Если с помощью списка смежности — то порядка O(N + E).

Время сокращения контуров

Функция relax() вызывается для каждой дуги графа. Если при этом вычисленная длина кратчайшего пути уменьшилась, в вызове relax() может встретиться вызов .decrease_priority(). Этот метод в свою очередь вызывает метод двоичной кучи .swim(), сложность которого O(log N). Следовательно, общее время, затраченное на вызов relax() для каждой дуги будет O(E log N), где E — количество дуг в графе.

Если хранить дуги в списке смежности, алгоритм Дейкстры имеет сложность порядка O((E + N) log N), а если в матрице смежности — порядка O(N²). С ростом графа использование матрицы смежности довольно быстро становится неэффективным.

В процессе обхода алгоритмом Дейкстры создаются два словаря: dist_to[v], в котором записан суммарный вес — кратчайший вычисленный путь от src до каждого v, и edge_to[v], в котором хранится последняя на кратчайшем пути из src в v дуга (u, v). Из edge_to сравнительно несложно восстановить полный путь от src до произвольного v. Механизм восстановления очень похож на тот, что мы видели в примере 7-4, но edge_to[], как это показано вы примере 7-15, нужно проходить от конца к началу.

   1 def edges_path_to(edge_to, src, target):        # 1
   2   if not target in edge_to:
   3     raise ValueError('Unreachable')             # 7
   4 
   5   path = []
   6   v = target                                    # 2
   7   while v != src:
   8     path.append(v)                              # 3
   9     v = edge_to[v][0]                           # 4
  10 
  11   path.append(src)                              # 5
  12   path.reverse()                                # 6
  13   return path
  1. Восстановим путь от src до произвольного target с помощью edge_to.

  2. Начнём с конца пути, target.

  3. Пока мы не добрались до входного узла src, добавим v в список path, в котором в обратном порядке, начиная с target, строится путь от src до target.

  4. Нулевой элемент дуги (u, v), хранящейся в edge_to[v], — это u, предыдущий узел на пути. Подставим его на место v.

  5. Мы вышли из цикла while, когда добрались до src, так что его надо добавить в путь отдельно.

  6. Переставляем элементы path в обратном порядке — теперь там содержится путь от src до target.

  7. Если узел target отсутствует в edge_to[], значит, из src он не достижим.

Алгоритм Дейкстры работает только с неотрицательными весами. Вес может быть отрицательным, например, если граф моделирует финансовые транзакции, которые предусматривают и платежи, и возврат денег. С отрицательным весом дуги алгоритм Дейкстры, как мы видим на иллюстрации 7-18, может и не справиться.

Иллюстрация 7-18. Неудачно присвоенный отрицательный вес дуги нарушает работу алгоритма Дейкстры
Иллюстрация 7-18. Неудачно присвоенный отрицательный вес дуги нарушает работу алгоритма Дейкстры

Состояние обхода в dist_to

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

Новое состояние dist_to

# impq содержит [a, b, c, d]

# impq содержит [c, b, d]

# impq содержит [d, b]

На иллюстрации 7-18 мы видим, как алгоритм Дейкстры обрабатывает три узла из impq. Остаётся узел b. Кратчайший путь от a до d уже вычислен, и на третьем шаге мы снимаем d из impq. Поэтому когда на следующем шаге (которого на иллюстрации уже нет), мы снимем b из impq и сократим дугу (b, d), обновить внезапно образовавшееся кратчайшее расстояние до d уже не догадаемся: этого узла нет в плане разведки. Очевидно, проблема с отрицательными весами — в том, что уже пройденный путь может уменьшиться после добавления в него ещё одной дуги, но алгоритм Дейкстры не умеет «возвращаться» и пересматривать уже вычисленные кратчайшие пути.

Алгоритм Дейкстры может не отследить дугу с отрицательным весом, потому что мы предполагаем, что при добавлении очередной дуги в некоторый путь его длина не уменьшается. Алгориртм Беллмана-Форда лишён этого недостатка, он может находить кратчайшие пути из входного узла в графе до произвольного, даже если веса рёбер имеют разные знаки. Но есть и исключение: если в графе присутствует отрицательный контур (то есть контур, суммарный вес которого отрицателен) любой путь, включающий вершину этого контура, можно сделать меньше, чем любое наперёд взятое число — достаточно пройтись по нему несколько раз! На иллюстрации 7-19 показаны два графа, в каждом из которых по две положительные и две отрицательные дуги. В том, что слева, нет отрицательного контура: начнём обход с a, и сперва пройдём единственную дугу (a, b) с весом 1. Если потом пройтись один раз по контуру b → d → c → b (суммарный вес которого 1), общая длина a → b → d → c → b будет равна 2, так что кратчайшая дистанция от a останется равной 1. А вот в графе справа присутствует отрицательный контур b → d → c → b, суммарная длина которого равна -2. Таки образом, мы можем сделать путь от a до b равным любому нечётному отрицательному числу, просто пройдясь по этому контуру соответствующее число раз. Например, вес пути a → b → d → c → b → d → c  → b равен -3.

Иллюстрация 7-19. Два графа с отрицательным весом дуг, только один из которых содержит отрицательный контур
Иллюстрация 7-19. Два графа с отрицательным весом дуг, только один из которых содержит отрицательный контур

Нет отрицательного контура

Есть отрицательный контур

длина a → b → d → c → b равна 1

длина a → b → d → c → b равна -1 

длина a → b → d → c → b → d → c → b равна -3

Алгоритм Беллмана-Форда находит кратчайшее расстояние от входного узла до любого другого, применяя совсем другой подход, в котором годятся и отрицательные веса дуг. Реализация этого алгоритма приведена в примере 7-16, многое в ней взято из алгоритма Дейкстры. К счастью нам не надо сначала проверять граф на наличие отрицательных контуров (как это приходилось делать перед топологической сортировкой: в процессе обхода графа алгоритмом Беллмана-Форда отрицательный контур определится сам — в этом случае порождается исключение.

   1 from math import inf
   2 
   3 def bellman_ford(G, src):
   4   dist_to = {v: inf for v in G.nodes()}               # 1
   5   dist_to[src] = 0
   6   edge_to = {}                                        # 2
   7 
   8   def relax(e):
   9     u, v, weight = e[0], e[1], e[2][WEIGHT]
  10     if dist_to[u] + weight < dist_to[v]:              # 5
  11       dist_to[v] = dist_to[u] + weight                # 6
  12       edge_to[v] = e                                  # 7
  13       return True                                     $ 8
  14     return False
  15 
  16   for i in range(G.number_of_nodes()):                # 3
  17     for e in G.edges(data=True):                      # 4
  18       if relax(e):
  19         if i == G.number_of_nodes() – 1:              # 5
  20           raise RuntimeError('Negative Cycle exists in graph.')
  21 
  22   return (dist_to, edge_to)
  1. В словаре dist_to будет храниться текущий кратчайший путь. Поначалу там бесконечность для всех узлов, а для src — ноль.

  2. В edge_to[v] хранится дуга, по которой поиск привёл нас в v.

  3. Цикл на N проходов (по количеству узлов графа)

  4. Цикл по всем дугам графа (u, v). Используем ту же функцию relax(), что и в алгоритме Дейкстры — сокращаем вес пути от src до v, если суммарный вес аналогичного пути через u оказывается меньше.

  5. Если путь до u и вес дуги в сумме меньше текущего значения кратчайшего пути до v, мы нашли более короткий путь до v.

  6. Запишем это значение на место старого.

  7. Запомним дугу, которая привела нас в v кратчайшим путём.

  8. Если relax() вернул True, мы нашли более короткий путь до v.

  9. В алгоритме Беллмана-Форда мы N раз перебираем все E дуг. Если на последнем проходе выясняется, что какая-то дуга e всё ещё укорачивает какой-то путь, значит, мы нашли отрицательный контур.

Почему этот алгоритм работает? Просто потому, что в графе их N узлов в самом длинном пути не может быть больше N - 1 узла. Так что если в цикле for переменная i прошла N - 1 итерацию, попытавшись сократить все дуги графа, то даже если единственный путь состоял из всех дуг одновременно, мы и его уже проверили. Значит, больше не должно остаться дуг, позволяющих сократить уже отмеренное расстояние. Поэтому мы продлили цикл ещё на один проход: если и на N-ом шаге можно сократить какую-то дугу, значит, происходит это посредством отрицательного контура.

Полный поиск кратчайших путей

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

Идея подсчитать все возможные кратчайшие расстояния от произвольного u до произвольного v кажется запредельно трудоёмкой. В самом деле, даже в простейшем графе с иллюстрации 7-20 на поиск кратчайшего расстояния между одними только d и c нужно порядочно времени, сколько же его уйдёт для исследования всех пар узлов! Между d и c есть дуга с весом 7, но путь d → b → a → c оказывается короче: его вес равен 6.

Иллюстрация 7-20. Граф, на котором мы будем вычислять кратчайшие пути
Иллюстрация 7-20. Граф, на котором мы будем вычислять кратчайшие пути

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

Снова пришлось немного подправить не вполне Python-овский стиль автора. -- FrBrGeorge 2023-02-09 19:56:38

  1. В словаре dist_to[u, v] будем хранить вычисленный кратчайший путь между двумя узлами, u и v. Пока путь из u в v не подсчитан, dist_to[u, v] бесконечно.

  2. В словаре node_from[u, v] будем записывать последний узел на вычисленном кратчайшем пути от u до v. Это поможет нам восстановить весь кратчайший путь от u до v.

Что предлагается делать.

Начнём с того, что занесём в dist_to[u, v] вес дуги (u, v), если такая есть, а если нет — бесконечность для разных u и v и ноль, когда u и v совпадают. Для каждой дуги (u, v) запишем в node_from[u, v] информацию о том, что это последняя (она же первая) дуга на кратчайшем пути из u в v, и стало быть, в v этим путём мы попали из u. На иллюстрации 7-21 показаны словари node_from и dist_to в виде квадратных таблиц: по вертикали идёт первый узел пары, по горизонтали — второй. Для заполнения таблиц мы использовали граф с иллюстрации 7-16.

"Иллюстрация 7-21. Что хранится в node_from и dist_to при полном поиске кратчайших путей
"Иллюстрация 7-21. Что хранится в node_from и dist_to при полном поиске кратчайших путей

Теперь выберем узел b в качестве t и проверим, можно ли найти два таких узла, u и v, чтобы сумма расстояний от u до t и от t до v была меньше вычисленного кратчайшего пути dist_to[u, v]. В нашем небольшом примере dist_to[a, b] равно 6, а dist_to[b, c] равно 2, так что кратчайший путь от a до c лежит именно через b — теперь он равен 8. Вдобавок нужно обновить node_from[a, c], потому что предыдущий узел на кратчайшем пути теперь b. Принцип тот же, что и в алгоритме Дейкстры, когда мы сокращаем контур.

Здесь и далее — недосмотр редактора: вместо dist_to используется нигде не описанное dist; я исправил. Кроме того, в параграфе выше описывается несуществующая картинка, которую мне пришлось нарисовать. -- FrBrGeorge 2023-02-11 10:59:19

Как мы видим, словарь node_from[u, v] выполняет ту же функцию, что и аналогичный node_from[] в предыдущих алгоритмах: хранит последний узел на кратчайшем пути до v, только путь задаётся не один, от входного узла, а много — от любого узла в графе.

Мы поочерёдно просматриваем все узлы в цикле с переменной t и перебираем все пары узлов u и v в надежде на то, что расстояние между ними можно сократить с помощью этого t (как мы это сделали двумя параграфами раньше). Как только увидим, что dist_to[u, t] + dist_to[y, v] меньше dist_to[u, v], сокращаем значение dist_to[u, v] до этой суммы, а заодно обновляем node_from[u, v] — теперь там должен быть узел на пути от t, node_from[t, v].

Иными словами, поскольку мы уже вычислили dist_to[t, v], мы знаем, что node_from[t, v] содержит последний узел на кратчайшем пути от t к v — а значит, он будет последним и на новом кратчайшем пути от u к v, который идет через t, и следует приравнять node_from[u, v] к node_from[t, v]. Когда мы будем восстанавливать полный путь, мы пройдём вспять от v к этому t, а в node_from[u, t] будет лежать предыдущий узел на кратчайшем пути от u.

Все наши соображения выглядят не слишком простыми, потому что в самом алгоритме нет выделенной части, которая, скажем, вычисляет кратчайший путь от конкретного u к конкретному v. Мы храним не всю информацию: вычисляемые в процессе работы пути не обязаны быть кратчайшими — мы можем пересчитать их позже. Возьмём граф с иллюстрации 7-20 и заполним наши структуры его информацией о кратчайших путях; результат изображён на иллюстрации 7-22. В таблице dist_to записаны кратчайшие пути между двумя узлами, её координаты соответствуют первому и второму узлам в паре. Например, в строке, помеченной как a, хранятся кратчайшие дистанции от узла a до всех остальных узлов графа. В частности, dist_to[a, c] равно 3, потому что кратчайший путь от a до c — это a → c, то есть единственная дуга (a, c) с весом 3. Кратчайший путь от а до d, dist_to[a, d] — это a → b → d с суммарным весом 9.

Иллюстрация 7-22. Какими получаются dist_to, node_from и полные кратчайшие пути для графас иллюстрации 7-20
Иллюстрация 7-22. Какими получаются dist_to, node_from и полные кратчайшие пути для графас иллюстрации 7-20

Кратчайшие пути

Решение задачи полного поиска кратчайших путей — словари node_from и dist_to. На иллюстрации 7-22 приведены сами эти пути: по вертикали — u, по горизонтали — v, на пересечении — полный путь от u до v. Становится понятнее, откуда такие значения в dist_to, при этом предпоследний узел в полном кратчайшем пути от u до v — выделен серым. Нетрудно заметить, что выделенный узел соответствует node_from[u, v].

Рассмотрим кратчайший путь от d до c: d → b → a → c. Он состоит из пути от d до a и финальной дуги (a, c). Таким образом наши два словаря подсказывают рекурсивное решение задачи: последний узел на пути из d в c — это a, который как раз и хранится в  node_from[d, c].

Продолжая в том же духе, обнаруживаем, что кратчайший путь от d к a идёт веред b, и именно b записан в node_from[d, a].

Алгоритм Флойда-Уоршелла

Задачу полного поиска кратчайших путей и идею её решения мы обсудили, теперь рассмотрим формализацию этой идеи: алгоритм Флойда-Уроршелла. Напомним: идея была в том, чтобы найти такие три узла u, v и t, чтобы окольный путь из u в v через t был короче прямого.

В примере 7-17 алгоритм Флойда-Уроршелла для начала заполняет node_from[] и dist_to[] данными из списка дуг — это часть исходного графа. На иллюстрации 7-23 представлен результат инициализации обоих словарей.

Иллюстрация 7-23. Инициализация dist_to[] и node_from[] данными из графа G
Иллюстрация 7-23. Инициализация dist_to[] и node_from[] данными из графа G

В node_from[u, v] записано либо None (на иллюстрации — прочерк), либо u. В dist_to[u, v] лежит либо 0, если u — это v, либо вес дуги (u, v), если такая есть, а если её нет, то бесконечность (на иллюстрации — INF)16.

   1 from math import inf
   2 
   3 def floyd_warshall(G):
   4     dist_to = {(u, v): 0 if u is v else inf for u in G.nodes() for v in G.nodes()}
   5     node_from = {(u, v): None for u in G.nodes() for v in G.nodes()}    # 1
   6     for u, v, data in G.edges(data=True):                               # 2
   7         dist_to[u, v] = data[WEIGHT]                                    # 3
   8         node_from[u, v] = u                                             # 4
   9 
  10     for t in G.nodes():
  11         for u in G.nodes():
  12             for v in G.nodes():
  13                 new_len = dist_to[u, t] + dist_to[t, v]                 # 5
  14                 if new_len < dist_to[u, v]:
  15                     dist_to[u, v] = new_len                             # 6
  16                     node_from[u, v] = node_from[t, v]
  17 
  18     return (dist_to, node_from)                                         # 7
  1. Создадим словари по всем возможным парам узлов. Заполняем dist_to нулями для путей из узла в тот же узел, и inf для путей между разными узлами, потому что мы ещё не знаем, есть ли эти пути. По той же причине заполняем node_from значением None.

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

  3. Изначальная длина пути из u в v — это вес дуги (u, v).

  4. Поскольку пока кратчайший путь из u в v совпадает с дугой (u, v), последний узел на пути к v — это u.

  5. Вычислим длину обходного пути u → t → v.

  6. Если обходной путь короче предыдущей вычисленной длины, запомним новую длину dist_to[u, v] и обновим последний узел на пути от u к v, теперь node_from[u, v] совпадает с node_from[t, v].

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

Сам алгоритм оказался на удивление компактным. Сначала мы заполняем node_from и dist_to в предположении, что никакой узел не достижим ниоткуда, кроме себя самого. Затем добавляем в них сведения о дугах: вес пути и начальную вершину. Внешний цикл по t перебирает узлы, через которые мы будем пытаться строить обходные пути в паре узлов, два внутренних перебирают узлы этой пары — u и v. Мы проверяем, не короче ли путь u → t → v текущего пути u → v. По мере перебора узлов мы рассмотрим все улучшения, и в конце концов найдём настоящий ответ.

Скажем, t — это a. Тогда во внутренних циклах по u и v в какой-то момент окажется, что u = b, а v = c. Проверяя, не короче ли путь от b к c через a, мы обнаружим, что dist_to[b, c] на данный момент бесконечно (см. иллюстрацию 7-23), а dist_to[b, a] = 2 и dist_to[a, c] = 3, и 5 явно короче бесконечности. Обновляем dist_to[b, c], а заодно и записываем a в node_from[b, c] в знак того, что в только что найденном коротком пути от b к c предпоследний узел — это a.

Ещё один способ понять работу алгоритма — это посмотреть, как именно меняются значения dist_to[u, v]. До первого прохода внешним циклом dist_to содержит только прямые пути из u в v, без возможности пойти в обход. Допустим, на первом проходе t стало равно a. Тогда под конец этого прохода в dist_to[u, v] будет лежать длина кратчайшего пути из u в v, в котором может присутствовать a. Например, найдётся более короткий путь b → a → c из b до c.

На втором проходе t станет равно b, и мы найдём все случаи, когда уже имеющийся путь длиннее, чем возможный путь через b. Например, кратчайший путь между d и c был длиной 7 — по длине соответствующей дуги в графе. Но теперь мы посчитали обходной путь через b: сначала от d к b (длиной 1), затем от b к c (длиной 5), и он оказался короче — равен 6. На иллюстрации 7-24 приведён результат работы алгоритма Флойда-Уоршелла после прохода циклом первых двух узлов — a и b.

Иллюстрация 7-24. Как изменились node_from и dist_to после проверки a и b
Иллюстрация 7-24. Как изменились node_from и dist_to после проверки a и b

image Интересно, а почему мы не проверяем, что t, u и v не равны друг другу? Во-первых, dist_to[u, u] уже равно нулю, так что на результат эта проверка не повлияет. Во-вторых, заметного прироста производительности она не даст, а вот читать и понимать программу без неё легче.

В результате алгоритм Флойда-Уоршелла не потребовал каких-то особенных структур данных, и просто последовательно перебрал все N³ троек (t, u, v) из узлов графа.

  1. Во время инициализации node_from и dist_to запоминаются все кратчайшие пути от u к v, состоящие из одной дуги.

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

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

  4. На k-м шаге рассматриваются пути, в которых, помимо начального и конечного узлов могут встречаться первые k элементов из списка узлов, а рёбер — не более k + 1.

После того, как внешний цикл по t переберёт все N узлов, в словарях окажутся сведения о кратчайших путях между любыми узлами, причём в них могут встречаться любые узлы графа и вплоть до N + 1 дуги. В действительности путь из N вершин не может содержать больше N - 1 дуги, так что упомянутых пределов хватит на то, чтобы подсчитать настоящие кратчайшие расстояния для любых пар u и v в графе.

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

   1   def all_pairs_path_to(node_from, src, target):        # 1
   2     if node_from[src, target] is None:
   3       raise ValueError('Unreachable')                   # 7
   4 
   5     path, v = [], target                                # 2
   6     while v != src:
   7       path.append(v)                                    # 3
   8       v = node_from[src, v]                             # 4
   9 
  10     path.append(src)                                    # 5
  11     path.reverse()                                      # 6
  12     return path
  1. Чтобы построить весь путь от src до target , функции нужен словарь node_from.

  2. Начнём с конца — с target.

  3. Пока не добрались до начала, то есть пока v не совпадает с src, добавляем v в список path (узлы пути от src до target в обратном порядке).

  4. Отступаем на шаг: теперь v — это предыдущий узел на пути, который мы взяли из node_from[src, v].

  5. Когда мы добираемся до src, цикл завершается, остаётся только добавить в список сам src.

  6. Восстанавливаем порядок узлов в пути, меняя обратный на прямой.

  7. Если node_from[src, target] так и остался равным None, значит target не достижим из src.

Заключение

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

  1. Связный ли граф? Обойдём его вглубину и посмотрим, все ли узлы затронул обход.

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

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

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

  5. Можно ли решить ту же задачу — поиск кратчайшего расстояния из входного узла до любого другого, — если веса дуг в графе имеют разные знаки, но отрицательных контуров в нём нет? Применим алгоритм Беллмана-Форда.

  6. Как эффективно решить задачу поиска всех кратчайших расстояний (суммарных весов дуг в путях) во взвешенном графе от любого узла к любому другому? Воспользуемся алгоритмом Флойда-Уоршелла, который составит таблицы, в которых для каждой пары узлов будет храниться кратчайшее расстояние и предпоследних узел в кратчайшем пути, — по этим таблицам можно будет восстановить полный путь.

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

Тренировочные задания

  1. Хоть это и не рекомендуется, обход вглубину можно написать в виде рекурсивной функции17. Как всегда, получится ещё более читаемый текст программы, но большие графы обрабатывать этим алгоритмом будет невыгодно — а на Python, глубина рекурсии в котором ограничена тысячью вызовами, просто невозможно. Однако не небольших лабиринтах такое решение работать будет, и посмотреть на это стоит. Доработайте пример 7-19 так, чтобы пространство активного поиска содержалось в явно заданном стеке, а было распределено по контекстам рекурсивного вызова внутренней функции dfs(). Достаточно вызывать dfs() на каждом помеченном узле, и откатываться к предыдущему вызову для просмотра ещё не исследованных направлений.

    •    1         def dfs_search_recursive(G, src):
         2           marked = {}
         3           node_from = {}
         4 
         5             def dfs(v):
         6               """Допишите эту рекурсивную функцию."""
         7 
         8             dfs(src)
         9             return node_from
      
      • Пример 7-19. Допишите рекурсивную реализацию поиска вглубину

  2. Функцию path_to(), которая строит полный путь для обхода вширину и обхода вглубину тоже можно написать рекурсивно (с теми же ограничениями, что и в предыдущем задании). Реализуйте рекурсивную генератор-функцию path_to_recursive(node_from, src, target), которая с помощью yield порождает последовательность узлов на пути от src до target.

  3. Напишите функцию recover_cycle(G), которая определяет, есть ли контур в орграфе G, и возвращает его (или None, если контура нет).

  4. Напишите функцию recover_negative_cycle(G), которая дополняет алгоритм Беллмана-Форда, отыскивая отрицательный контур. Сконструируйте своё исключение NegativeCycleError, унаследовав его от RuntimeError — это исключение будет порождаться, когда обнаружится, что некоторый путь можно сокращать постоянно. Возьмите последнюю дугу этого пути и постарайтесь найти весь цикл. Передайте цикл в качестве параметра порождаемого исключения18 .

  5. Подберите такой простой орграф из N=5 узлов, чтобы алгоритму Беллмана-Форда для вычисления кратчайшего пути от определённого узла понадобилось 4 прохода. Для простоты веса всех дуг пускай будут единичными. Подсказка: результат зависит от порядка добавления дуг в граф. В частности, в алгоритме Беллмана-Форда дуги перебираются в том порядка, в каком их возвращает  G.edges().

  6. Исследуйте эффективность всех трёх вариантов обхода — вглубину, вширину и направленного для случайного лабиринта размером N × N. Для этого (1) останавливайте обход как только выход найден, и (2) отмечайте количество помеченных узлов.

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

    Затем создайте наихудший случай для направленного обхода, в котором он работает почти так же медленно, как обход вширину. На иллюстрации 7-25 приведён пример довольно «плохого» лабиринта размером 15×15: стены в нём образуют своеобразное «корыто», которое не даёт добраться до выхода. Направленный поиск сначала безрезультатно обшаривает все (N - 2)² ячейки «корыта», и только затем переваливает через стенку и обходным путём добирается до выходного узла. В файле ch07/maze.py репозитория есть класс Maze с методом .initialize(), который заполняет весь лабиринт стенками и формирует соответствующие структуры давнных, останется только удалить большую часть вертикальных и горизонтальных стен (они хранятся в массивах .south_wall[] и .east_wall[] соответственно).

    • Вариант наихудшего случая для направленного обхода
      Вариант наихудшего случая для направленного обхода
  7. Не содержащий контуров орграф называется сетью. Алгоритм Дейсктры в худшем случае показывает производительность O((E + N) log N), но на сети он работает быстрее и может отыскать кратчайшие пути из заданного узла за O(E + N) действий. С помощью топологической сортировки построим линеаризацию вершин графа, и пройдёмся по ним в линеаризованном порядке, сокращая каждую дугу, исходящую из очередной вершины. Сконструируйте достаточное количество сетей в виде квадратных решёток и экспериментально подтвердите оценку сложности. Для простоты веса всех дуг будем считать единичными. Например, длина кратчайшего пути из узла 1 к узлу 16 в решётке с иллюстрации 7-26 равна 6.

    • Иллюстрация 7-26. Орграф без контуров — сеть — на котором поиск кратчайших путей из заданного узла работает быстрее
      Иллюстрация 7-26. Орграф без контуров — сеть — на котором поиск кратчайших путей из заданного узла работает быстрее
  8. Некоторые водители не пользуются платными дорогами — такими, как I-90 в Массачусетсе. В нашем графе дорог Массачусетса ребро (u, v) принадлежит трассе I-90, если метки обоих узлов содержат маркировку 'I-90'. Изо всех 2826 рёбер 51 штука принадлежит I-90. Удалите эти рёбра из графа и высчитайте кратчайшее расстояние из самой западной точки штата в центр Бостона (точка, обведённая кружком на иллюстрации 7-14, помеченная как I-90@134&I-93@20&MA3@20(93)&US1@I-93(20), потому что там встречаются шесть дорог). Если ехать и по платным дорогам, путешествие занимает 72 ребра суммарной длиной в 136.2 мили. Если же не заезжать на I-90, количество рёбер увеличится до 104, а длина — до 139.5 мили. Напишите программу, которая получает все эти результаты и создаёт файл с изображением «бесплатного» пути.


  1. Вслед за модулем networkx мы будем называть узлами элементы графа, а вершинами ребра — два узла, которые это ребро соединяет (прим. автора). (1)

  2. Это может быть настоящий кукурузный лабиринт! Самый большой кукурузный лабиринт в мире — Корн Патч Пампкинс в Диксоне, штат Калифорния — занимает площадь 63 акра, и проходить его можно часами (прим. автора). (2)

  3. См., например, программу ch07.maze в репозитории (прим. автора). (3)

  4. В теории операция «положить значение на стек» обычно называется «push», но мы будем пользоваться методами типа list, который реализует абстракцию стека в Python. (4)

  5. Этот шаг может показаться лишним. Зачем добавлять элементы в конец списка, получая обратный путь, а затем обращать его, если можно было просто вставлять элементы в начало списка, ведь тогда прямой путь получился бы сразу? Мы уже упоминали о том, что действия с концом динамического массива типа list имеют константную, сложность, а действия с началом — линейную. Каким бы малым ни было количество таких действий, лучше никогда не заменять append() на insert(0). (5)

  6. В принципе, может быть несколько путей одинаковой кратчайшей длины, и единственный найденный при обходе вширину путь в любом случае будет не длиннее возможных альтернатив (прим. автора). (6)

  7. Названное так потому, что в городе с прямоугольной планировкой невозможно двигаться по диагонали — только по вертикали и по горизонтали (прим. автора). (7)

  8. Наихудший случай можно подобрать практически к любому направленному обходу. Если взять лабиринт, в котором есть всего одна развилка: два смежных со входом узла, и ближайший к выходу из них ведёт в тупик, направленный обход с прямоугольной метрикой осмотрит весь граф целиком. (8)

  9. И снова здравствуйте, треугольные числа! (прим. автора) (9)

  10. Напомним об «логарифмическом» принципе при выборе рекурсивного алгоритма: если в наихудшем случае можно ожидать, что на наборе данных размера N мы получаем глубину рекурсии порядка O(N), мы в действительности написали обычный цикл, и лучше заместить рекурсию стеком; если же глубина рекурсии имеет меньший порядок (например, O(log N) или даже константу), то можно смело пользоваться рекурсивным алгоритмом. (10)

  11. Мы помним из главы 5, что это 0, 1, 1, 2, 3, 5 и 8 — последовательность, в которой каждый следующий член равен сумме двух предыдущих (прим. автора). (11)

  12. Скорее всего, в качестве метрики автор применял расстояние на плоскости, или же ортодромию; мы этого не знаем, но сути дела это не меняет: алгоритм всё равно неподходящий. (12)

  13. Нулевые веса в алгоритме Дейкстры разрешены. Для учёта весов с разным знаком нужно использовать другой, алгоритм Беллмана—Форда — его мы тоже разберём позже (прим. автора). (13)

  14. Стоит заметить, что в нашем варианте мы оставляем без внимания динамическую природу list: сразу создаём хранилище размера size+1 и не пользуемся .pop()/.append(), что, как и в приоритетной очереди из четвёртой главы, позволяет считать эти списки массивами. (14)

  15. В стандартном модуле math есть такое удобное значение, которое и вправду больше любого допустимого числа Python. (15)

  16. В тексте функции, мы, как и раньше, используем inf из математической библиотеки Python. (16)

  17. И вширину тоже можно, и тоже не рекомендуется (17)

  18. Примерно так: raise RuntimeError('Negative Cycle exists in graph.', cycle). (18)

FrBrGeorge/Books/LearningAlgorythms/07_Graphs_only_connect (последним исправлял пользователь FrBrGeorge 2023-04-03 16:47:21)