☕ Распространенные алгоритмы и структуры данных в JavaScript: графы
Граф – сложная нелинейная структура данных, отображающая связи между разными объектами. Разбираемся, как ее представить и как с ней работать в JavaScript.
Если хочешь подтянуть свои знания, загляни на наш курс «Алгоритмы и структуры данных», на котором ты:
- углубишься в решение практических задач;
- узнаешь все про сложные алгоритмы, сортировки, сжатие данных и многое другое.
Ты также будешь на связи с преподавателем и другими студентами.
В итоге будешь браться за сложные проекты и повысишь чек за свою работу 🙂
Продолжаем изучать популярные алгоритмы и структуры данных и их реализацию на JavaScript. В предыдущей статье мы разобрались с деревьями – нелинейными иерархическими структурами. В этой копнем еще глубже и познакомимся со старшими братьями деревьев – графами.
Другие статьи цикла:
- Часть 1. Основные понятия и работа с массивами
- Часть 2. Стеки, очереди и связные списки
- Часть 3. Деревья
- Часть 5. Объекты и хеширование
- Часть 6. Полезные алгоритмы для веб-разработки
Количество вершин определяет порядок графа, а количество ребер – его размер.
Если две вершины графа соединены одним ребром, они называются соседними, или смежными. В графе также могут быть отдельно стоящие вершины, не связанные с другими, они называются изолированными. Количество ребер, исходящих из вершины, определяют ее степень связности.
Значение и применение структуры
Основная задача графа – отображать связи (ребра) между разными сущностями (вершины). Самый простой пример – карта метро, на которой станции являются вершинами графа, а связывающие их перегоны – ребрами.
Деревья, которые мы разбирали в предыдущей части цикла, также являются графами.
С помощью графов можно представить связи между пользователями социальной сети (подписки) или ссылки между разными страницами одного сайта (перелинковка). В таких графах ребра будут иметь направление – пользователь А подписан на пользователя Б, а не наоборот. Это ориентированные графы.
В настоящее время это очень востребованная структура данных, так как она позволяет работать с большими объемами плохо структурированной информации. На графах, например, основываются разнообразные системы рекомендаций и ранжирования контента.
Способы представления графов в JavaScript
Итак, зачем нужны графы, разобрались, но остается неясным, как их использовать. В JavaScript мы не можем просто нарисовать несколько вершин и соединить их ребрами – нужен какой-то способ представления.
Список смежности
При его составлении для ориентированного графа важно учитывать направление ребер.
Для первого (неориентированного) графа список смежности будет выглядеть так:
Мы представили его в виде JavaScript-объекта с четырьмя свойствами, каждое из которых соответствует вершине графа. В качестве значений – массивы смежных вершин:
- из вершины 1 можно попасть в 2 и 4;
- из вершины 2 можно попасть в 1, 3 и 4;
- из вершины 3 можно попасть в 2 и 4;
- из вершины 4 можно попасть в 1, 2, 3.
Ориентированный граф выглядит почти так же, но список смежности у него другой:
Из вершины 3 не выходит ни одно ребро, поэтому из нее нельзя никуда попасть – ее массив смежных вершин пуст.
Матрица смежности
Связь определяется построчно. Первая строка соответствует вершине 1, нужно определить, есть ли путь из нее в вершины, представленные столбцами таблицы.
У неориентированного графа матрица смежности симметрична, а у ориентированного – нет.
На JavaScript граф можно представить в виде двумерного массива:
Сравнение способов
Список смежности – более компактный способ представления графа, он требует меньше памяти и особенно удобен для "разреженных" графов, у которых немного ребер.
В то же время матрица – более наглядная и удобная структура, позволяющая быстро определить, есть ли связь между двумя вершинами. Для "плотных" графов с большим количеством ребер матрица обычно выгоднее, чем список.
Например, операция проверки наличия связи между двумя вершинами a и b в матрице занимает константное время – достаточно лишь проверить элемент graph[a][b]
. В списке смежности же это будет сложнее – потребуется поиск элемента b в массиве graph[a]
.
Помимо списка и матрицы смежности можно использовать и более абстрактные представления, например, хранить отдельно множество вершин и отдельно множество ребер.
Реализация на JavaScript
Напишем класс для самого простого неориентированного графа с хранением данных в виде списка смежности.
Основные методы графа:
addVertex
– добавление новой вершины;addEdge
– добавление нового ребра.
Так как граф неориентированный, новое ребро является двусторонней связью, поэтому мы добавляем новые записи в списки обеих смежных вершин. Для ориентированного графа нужно лишь немного изменить метод addEdge, чтобы новая связь шла лишь в одном направлении.
Основные алгоритмы на графах
Мы разберем лишь две востребованные в программировании задачи: обход графа (в т.ч. поиск в графе) и нахождение кратчайшего пути между двумя вершинами.
Обход графа
Как и в случае с деревьями, у нас есть два способа обхода графов с разным порядком перебора вершин: поиск в ширину и поиск в глубину. Принципиально они не отличаются от алгоритмов обхода деревьев.
Создадим тестовый граф для демонстрации работы алгоритмов:
Перенесем его представление в JavaScript:
Поиск в глубину (depth-first search)
Алгоритм поиска в глубину движется последовательно по ветвям графа от начала до конца.
Когда мы разбирали этот алгоритм на примере деревьев, то использовали его рекурсивную реализацию – функция поиска для одного узла вызывала сама себя для его дочерних узлов. Это, вероятно, более очевидный и понятный путь, но он может приводить к переполнению стека вызовов для больших графов. Поэтому сейчас для разнообразия мы рассмотрим итеративную реализацию поиска в глубину.
Кроме того, нужно отмечать вершины, которые уже были посещены, чтобы не перебирать их повторно. В дереве нам не требовалось делать это, но графы имеют более сложную структуру, в которой могут встречаться циклы.
Перебор может начинаться с любой вершины графа. Стартовую вершину сразу же отмечаем как посещенную и добавляем ее в стек, а дальше начинается собственно алгоритм:
- Берем вершину с конца стека. В соответствии с описанием структуры данных стек это будет вершина, добавленная в него последней.
- Получаем все ее смежные вершины.
- Отбрасываем те вершины, которые уже были посещены ранее.
- Остальные отмечаем как посещенные, добавляем в стек и возвращаемся к пункту 1.
Таким образом мы будем двигаться в глубину ветки, обрабатывая сначала "более глубокие" вершины, а когда в ней закончатся непосещенные вершины, просто перейдем к следующей ветке.
На JavaScript это может выглядеть так:
Несколько важных моментов:
- Для хранения посещенных вершин мы используем не массив, а объект, так как наличие свойства в объекте проверить проще, чем наличие элемента в массиве.
- При добавлении смежных вершин в стек, мы используем метод
reverse
, то есть добавляем их в обратном порядке. Это нужно для того, чтобы перебор веток происходил в том же порядке, в котором они были добавлены. - После того, как стек опустел, мы дополнительно проверяем все вершины графа на предмет посещенности. Это необходимо, так как в графе могут быть изолированные вершины или подграфы, например, в нашем тестовом графе такая вершина есть. Непосещенные вершины обрабатываем. Если в графе нет изолированных фрагментов, то эту часть можно опустить.
Запустим этот метод для нашего тестового графа:
Порядок перебора вершин:
Поиск в глубину напоминает поиск пути в лабиринте, алгоритм идет по ветке, пока не упрется в "стену", после этого он возвращается назад.
Поиск в ширину (breadth-first-search)
Алгоритм поиска в ширину сначала перебирает все смежные вершины, затем смежные вершины смежных вершин и так далее по уровням.
Первый узел, в которого начинается обход, отмечается как посещенный и помещается в очередь. Далее совершаем следующие действия:
- Берем вершину с начала очереди. В соответствии с описанием структуры данных очередь это будет вершина, добавленная в нее самой первой.
- Получаем все ее смежные вершины.
- Отбрасываем те вершины, которые уже были посещены ранее.
- Остальные отмечаем как посещенные, добавляем в стек и возвращаемся к пункту 1.
Обратите внимание, алгоритм абсолютно такой же, как и алгоритм поиска в глубину – разница только в порядке извлечения узлов. Вот так выбор простейшей структуры данных (стека или очереди) влияет на результат выполнения задачи.
На JavaScript поиск в ширину выглядит примерно так:
В этой реализации мы не переворачиваем массив смежных вершин, так как из очереди они и так будут извлекаться в правильном порядке.
После перебора основного дерева обязательно повторяем процедуру для всех изолированных вершин.
Запустим этот метод для нашего тестового графа:
Порядок перебора вершин:
Рисунок поиска в ширину напоминает круги на воде – сначала исследуются вершины в границах маленького круга, затем он увеличивается.
Поиск кратчайшего пути
Одной из базовых задач при работе с графами является определение кратчайшего пути между двумя вершинами. Такие алгоритмы очень востребованы, например, в картографических сервисах (Google maps) для прокладывания маршрута между двумя точками.
Вариантов постановки этой задачи очень много, мы начнем с самого простого.
Представьте, что вам требуется построить маршрут, проходящий через наименьшее количество станций или населенных пунктов.
Например, в этом графе из точки A в точку G можно попасть тремя разными способами. Как выяснить, какой из них самый быстрый?
Сначала создадим JavaScript-представление этой структуры:
Для решения нашей задачи подойдет уже знакомый нам алгоритм поиска в ширину с некоторыми модификациями.
Необходимо по ходу работы алгоритма сохранять дополнительную информацию, чтобы затем восстановить путь из исходной вершины в искомую. Мы будем сохранять расстояние каждой вершины от исходной, а также для каждой вершины – предыдущую вершину, из которой мы пришли.
Обработка изолированных фрагментов графа здесь не требуется. Между изолированными подграфами пути нет, ни кратчайшего, ни какого-либо другого, мы и так это увидим.
Опробуем обновленный алгоритм:
В свойстве distance
хранятся кратчайшие расстояния от вершины А до всех остальных вершин графа. Выходит, что до вершины G можно добраться всего за три шага. А восстановить этот путь поможет свойство previous
, которое хранит всю цепочку шагов:
Запустим алгоритм поиска:
Взвешенные графы
Зачастую нам интересен не только факт наличия связи между двумя сущностями (смежность вершин, наличие ребра между ними), но и некоторые характеристики этой связи.
Для примера возьмем карту метро. Перегоны между станциями отличаются расстоянием.
В этом графе каждому ребру (перегон) соответствует число, равное времени в пути в минутах между смежными станциями. Это число называют весом ребра, а сам граф взвешенным.
Представление взвешенного графа
Для взвешенного графа необходимо хранить не только факт связи, но и вес ребра, соединяющего две вершины.
Попробуем перенести в JavaScript вот такой граф:
Если мы используем матрицу смежности, то веса можно подставлять вместо единиц и нулей (наличие или отсутствие связи).
A | B | C | D | E | F | G | |
A | 0 | 2 | 1 | 0 | 0 | 0 | 0 |
B | 0 | 0 | 0 | 0 | 0 | 7 | 0 |
C | 0 | 0 | 0 | 5 | 2 | 0 | 0 |
D | 0 | 0 | 0 | 0 | 0 | 2 | 0 |
E | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
F | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
G | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
В списках смежности нужно поставить в соответствие каждой смежной вершине вес ребра, ведущего к ней. Для этого мы можем использовать, например, вложенные массивы:
Первым элементом в массиве идет сама смежная вершина, вторым – длина пути до нее (вес ребра).
Или можно представить то же самое с помощью объектов JavaScript:
Дальше мы будем использовать именно такое представление.
Кратчайший путь во взвешенном графе
Задача поиска кратчайшего пути во взвешенном графе встречается еще чаще, чем в невзвешенном. Например, для построения самого быстрого маршрута между двумя станциями метро или для сетевых протоколов маршрутизации. И здесь все намного интереснее. Мы не можем полагаться на количество ребер между вершинами, а значит, поиск в ширину нам не поможет.
Для поиска кратчайшего пути во взвешенных графах существует множество алгоритмов. Некоторые из них могут работать даже с отрицательными весами. Мы рассмотрим один из самых популярных.
Алгоритм Дейкстры
Он похож на поиск в ширину – мы начинаем со стартовой вершины и перебираем ее смежные вершины. На каждом шаге мы рассчитываем все возможные пути до вершины и выбираем среди них самый короткий.
Изначально полагается, что путь до каждой вершины неизвестен и равен бесконечности, кроме пути до стартовой вершины, который равен нулю.
На каждом шаге выбирается самая близкая из известных вершин. На первом шаге это стартовая вершина, так как о других мы просто ничего не знаем.
Для выбранной вершины мы можем получить ее смежные вершины и расстояния до них (вес ребер). А зная расстояние от стартовой вершины до текущей и расстояния от текущей вершины до ее смежных вершин, мы можем рассчитать и расстояния от стартовой вершины до смежных.
Текущая вершина отмечается как посещенная и в дальнейших манипуляциях не участвует.
Затем все действия повторяются: мы выбираем самую близкую из известных вершин, не считая посещенных. Теперь известных вершин стало больше, поэтому есть из чего выбирать. Находим смежные вершины и обновляем расстояния до них.
Если расстояние для какой-то вершины уже было определено на предыдущих шагах, мы должны сравнить его с новым и выбрать минимальное.
Чтобы восстановить кратчайший путь до нужной вершины, мы дополнительно должны сохранять предыдущую вершину в этом пути, как мы уже делали для невзвешенного графа.
Реализация алгоритма Дейкстры на JavaScript
Для реализации алгоритма Дейкстры на потребуется вспомогательная функция для поиска ближайшей вершины из известных:
Она принимает объект distances
, в котором каждой вершине соответствует известное на данный момент расстояние, и объект visited
, в котором отмечены посещенные вершины.
Сам алгоритм выглядит так:
Запустим алгоритм для нашего взвешенного графа:
Кратчайший путь от A до G составляет 5 и проходит через вершины ACEFG (Алгоритм восстановления пути по объекту previous
рассмотрен выше).
Заключение
В современном информационном мире графы – одна из важнейших структур данных, которая позволяет отобразить связи между объектами. Одновременно это одна из самых сложных структур. Существует множество разновидностей графов: кроме взвешенных и ориентированных графов, с которыми мы познакомились, существуют, например, псевдографы (графы, в структуре которых содержатся петли). Соответственно, существует и множество алгоритмов, работающих с графами.
Мы разобрали практически все популярные структуры данных, которые могут пригодиться JavaScript-разработчику в работе или на собеседовании: массивы, связные списки, стеки и очереди, а также деревья и графы.
В следующей статье цикла мы поговорим про еще одну очень важную структуру – хеш-таблицу.