Files
hello-algo/ru/chapters/chapter_09.md
2026-03-25 16:54:42 +08:00

43 KiB
Raw Blame History

Графы

{width="3.5416732283464567in" height="4.583333333333333in"}

графы

Граф -- это нелинейная структура данных, состоящая из вершин и ребер. Граф G можно абстрактно представить как множество вершин V и множество ребер E. Ниже приведен пример графа, содержащего 5 вершин и 7 ребер:

V = {1, 2, 3, 4, 5}

E = {(1, 2), (1, 3), (1, 5), (2, 3), (2, 4), (2, 5), (4, 5)}

G = {V, E}.

Если рассматривать вершины как узлы, а ребра как ссылки (указатели), соединяющие узлы, то граф можно рассматривать как расширенный спи- сок. По сравнению с линейными отношениями (список) и отношени- ями разделения (дерево), сетевые отношения (граф) обладают боль- шей свободой и, следовательно, являются более сложными, как показано на рис. 9.1.

Рис. 9.1. Связь между списком, деревом и графом

  1. Основные типы и понятия графов

В зависимости от наличия направления у ребер графы делятся на неориенти- рованные и ориентированные, как показано на рис. 9.2.

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

  • В ориентированном графе ребро имеет направление. То есть ребра AB и AB независимы друг от друга, например отношения подписки--под- писчики.

Рис. 9.2. Ориентированный и неориентированный графы

Если все вершины связаны, то граф называется связным, иначе -- несвяз- ным, как показано на рис. 9.3.

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

рыми нет пути.

Рис. 9.3. Связный и несвязный графы

Можно также добавить к ребрам переменную «вес», получив взвешенный граф, как показано на рис. 9.4. Например, в мобильных играх, таких как Honor of Kings, система рассчитывает близость между игроками на основе времени совместной игры. Такую сеть близости можно представить в виде взвешенного графа.

Со структурой данных графа связаны следующие основные понятия.

  • Смежность: если между двумя вершинами существует ребро, они на- зываются смежными. На рис. 9.4 вершины, смежные с вершиной 1, -- это вершины 2, 3 и 5.

  • Путь: последовательность ребер от вершины A до вершины B называет- ся путем от A до B. На рис. 9.4 последовательность ребер 1-5-2-4 является путем от вершины 1 до вершины 4.

  • Степень: количество ребер, присоединенных к вершине. Для ориен- тированного графа входящая степень показывает, сколько ребер ведет к данной вершине, а исходящая степень показывает, сколько ребер вы- ходит из данной вершины.

Рис. 9.4. Взвешенный и невзвешенный графы

Представление графа

Графы можно представить с помощью матрицы смежности и списка смежно- сти. Рассмотрим пример с неориентированным графом.

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

Пусть количество вершин графа равно n, матрица смежности представля- ет граф в виде матрицы размером n×n, где каждая строка (столбец) соот- ветствует вершине, а элементы матрицы обозначают наличие ребра. Зна- чение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию.

Обозначим матрицу смежности как M, а список вершин как V. Тогда элемент матрицы M[i, j] = 1 указывает на наличие ребра между вершинами V[i] и V[j], в противном случае элемент матрицы M[i, j] = 0, см. рис. 9.5.

Рис. 9.5. Представление графа с помощью матрицы смежности

Матрица смежности обладает следующими свойствами.

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

  • Для неориентированного графа ребра в обоих направлениях эквива- лентны, поэтому матрица смежности симметрична относительно глав- ной диагонали.

  • Заменив элементы матрицы смежности с 1 и 0 на веса ребер, можно представить взвешенный граф.

Используя матрицу смежности для представления графа, можно напрямую обращаться к элементам матрицы для получения информации о ребрах, что делает операции добавления, удаления, поиска и изменения достаточно эф- фективными с временной сложностью O(1). Однако пространственная слож- ность матрицы составляет O(n2), что требует значительных затрат памяти.

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

Список смежности представляет граф с помощью n списков, где узлы списка представляют вершины. i-й список соответствует вершине i и содержит все смежные вершины (вершины, соединенные с данной вершиной). На рис. 9.6 показан пример графа, представленного с помощью списка смежности.

Рис. 9.6. Представление графа с помощью списка смежности

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

Как видно из рис. 9.6, структура списка смежности очень похожа на цепную адресацию в хеш-таблицах, поэтому можно использовать ана-

логичные методы для оптимизации эффективности. Например, если список длинный, его можно преобразовать в АВЛ-дерево или красно-черное дерево, чтобы повысить временную эффективность с O(n) до O(log n). Так- же можно преобразовать список в хеш-таблицу, чтобы снизить временную сложность до O(1).

Типичные сценарии применения графов

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

+---------------------+-----------------+-------------------------+------------------------------------------+ | | > Вершина | > Ребро | > Задача вычисления на графе | +=====================+=================+=========================+==========================================+ | > Социальные сети | > Пользователи | > Дружеские связи | > Рекомендации потенци- альных друзей | +---------------------+-----------------+-------------------------+------------------------------------------+ | > Линии метро | > Станции | > Связь между станциями | > Рекомендации по крат- чайшему маршруту | +---------------------+-----------------+-------------------------+------------------------------------------+ | > Солнечная система | > Небесные тела | > Взаимодействие грави- | > Расчет орбит планет | +---------------------+-----------------+-------------------------+------------------------------------------+

тации между телами

ОСнОвные Операции С графами

Основные операции с графами можно разделить на операции с ребрами и опе- рации с вершинами. В зависимости от способа представления (матрица смеж- ности или список смежности) реализация будет различаться.

Реализация на основе матрицы смежности

Ниже приведены операции для заданного неориентированного графа с коли- чеством вершин n. Способы реализации показаны на рис. 9.7.

  • Добавление или удаление ребра: достаточно изменить соответствую- щее ребро в матрице смежности за время O(1). Поскольку граф неориен- тированный, необходимо обновить ребра в обоих направлениях.

  • Добавление вершины: в конец матрицы смежности добавляется строка и столбец, которые заполняются нулями. Временная сложность равна O(n).

  • Удаление вершины: удаляется строка и столбец из матрицы смеж- ности. В худшем случае при удалении первой строки и столбца не- обходимо переместить (n 1)2 элементов влево вверх, что занимает время O(n2).

  • Инициализация: передается n вершин, инициализируется список вер- шин vertices длиной n за время O(n). Инициализируется матрица смеж- ности adjMat размером n×n за время O(n2).

Рис. 9.7. Инициализация матрицы смежности, добавление и удаление ребер и вершин. Шаги 1--3

Рис. 9.7. Окончание. Шаги 4--5

Ниже приведен код реализации графа на основе матрицы смежности.

# === File: graph_adjacency_matrix.py === class GraphAdjMat:

""" Класс неориентированного графа на основе матрицы смежности."""

def init (self, vertices: list[int], edges: list[list[int]]): """ Конструктор."""

# Список вершин, элемент представляет "значение вершины", индекс пред-

ставляет "индекс вершины".

self.vertices: list[int] = []

# Матрица смежности, индексы строк и столбцов соответствуют # "индексу вершины".

self.adj_mat: list[list[int]] = []

# Добавление вершин. for val in vertices:

self.add_vertex(val) # Добавление ребер.

# Обратите внимание: элементы edges представляют индексы вершин, # т. е. соответствуют индексам элементов vertices.

for e in edges: self.add_edge(e[0], e[1])

def size(self) -> int:

""" Получение количества вершин.""" return len(self.vertices)

def add_vertex(self, val: int): """ Добавление вершины.""" n = self.size()

# Добавление нового значения вершины в список вершин.

self.vertices.append(val)

# Добавление строки в матрицу смежности. new_row = [0] * n self.adj_mat.append(new_row)

# Добавление столбца в матрицу смежности.

for row in self.adj_mat: row.append(0)

def remove_vertex(self, index: int): """ Удаление вершины."""

if index >= self.size(): raise IndexError()

# Удаление вершины с индексом index из списка вершин.

self.vertices.pop(index)

# Удаление строки с индексом index из матрицы смежности. self.adj_mat.pop(index)

# Удаление столбца с индексом index из матрицы смежности.

for row in self.adj_mat: row.pop(index)

def add_edge(self, i: int, j: int): """ Добавление ребра."""

# Параметры i и j соответствуют индексам элементов vertices. # Обработка выхода за границы индексов и равенства.

if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: raise IndexError()

# В неориентированном графе матрица смежности симметрична

# относительно главной диагонали, т. е. (i, j) == (j, i). self.adj_mat[i][j] = 1

self.adj_mat[j][i] = 1

def remove_edge(self, i: int, j: int): """ Удаление ребра."""

# Параметры i и j соответствуют индексам элементов vertices.

# Обработка выхода за границы индексов и равенства.

if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: raise IndexError()

self.adj_mat[i][j] = 0

self.adj_mat[j][i] = 0

def print(self):

""" Печать матрицы смежности."""

print(" Список вершин =", self.vertices) print(" Матрица смежности =") print_matrix(self.adj_mat)

Реализация на основе списка смежности

Ниже приведены описания операций для неориентированного графа с общим количеством вершин n и ребер m. Способы реализации показаны на рис. 9.8.

  • Добавление ребра: достаточно добавить ребро в конец связного списка, соответствующего вершине за время O(1). Поскольку граф неориентиро- ванный, необходимо добавить ребра в обоих направлениях.

  • Удаление ребра: необходимо найти и удалить указанное ребро в связ- ном списке, соответствующем вершине, за время O(m). В неориентиро- ванном графе необходимо удалить ребра в обоих направлениях.

  • Добавление вершины: добавляется связный список в список смеж- ности, а новая вершина становится головным узлом списка. Требуется время O(1).

  • Удаление вершины: необходимо пройтись по всему списку смежности и удалить все ребра, содержащие указанную вершину. Требуется время O(n + m).

  • Инициализация: в списке смежности создается n вершин и 2m ребер за время O(n + m).

Рис. 9.8. Инициализация списка смежности, добавление и удаление ребер и вершин.

Шаг 1

Рис. 9.8. Продолжение. Шаг 2--4

Рис. 9.8. Окончание. Шаг 5

Ниже приведен код реализации списка смежности. По сравнению с рис. 9.8 код имеет следующие отличия:

  • для удобства добавления и удаления вершин, а также упрощения кода вместо связного списка используется список (динамический массив);

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

Кроме того, в списке смежности используется класс Vertex для представле- ния вершин. Это сделано потому, что если, как в случае с матрицей смежности, использовать индексы списка для различения различных вершин, то при уда- лении вершины с индексом i необходимо пройтись по всему списку смежно- сти и уменьшить на 1 все индексы, большие i, что крайне неэффективно. Если же каждая вершина является уникальным экземпляром класса Vertex, то после удаления одной вершины не требуется изменять другие вершины.

# === File: graph_adjacency_list.py === class GraphAdjList:

""" Класс неориентированного графа на основе списка смежности."""

def init (self, edges: list[list[Vertex]]): """ Конструктор."""

# Список смежности, ключ: вершина, значение: все смежные вершины данной

вершины.

self.adj_list = dict[Vertex, list[Vertex]]() # Добавление всех вершин и ребер.

for edge in edges: self.add_vertex(edge[0]) self.add_vertex(edge[1]) self.add_edge(edge[0], edge[1])

def size(self) -> int:

""" Получение количества вершин.""" return len(self.adj_list)

def add_edge(self, vet1: Vertex, vet2: Vertex): """ Добавление ребра."""

if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2: raise ValueError()

# Добавление ребра vet1 - vet2 self.adj_list[vet1].append(vet2) self.adj_list[vet2].append(vet1)

def remove_edge(self, vet1: Vertex, vet2: Vertex): """ Удаление ребра."""

if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2: raise ValueError()

# Удаление ребра vet1 - vet2. self.adj_list[vet1].remove(vet2) self.adj_list[vet2].remove(vet1)

def add_vertex(self, vet: Vertex): """ Добавление вершины."""

if vet in self.adj_list: return

# В списке смежности добавляется новый список. self.adj_list[vet] = []

def remove_vertex(self, vet: Vertex): """ Удаление вершины."""

if vet not in self.adj_list: raise ValueError()

# В списке смежности удаляется список, соответствующий вершине vet. self.adj_list.pop(vet)

# Обход списков других вершин, удаление всех ребер, содержащих vet. for vertex in self.adj_list:

if vet in self.adj_list[vertex]: self.adj_list[vertex].remove(vet)

def print(self):

""" Печать списка смежности.""" print(" Список смежности =") for vertex in self.adj_list:

tmp = [v.val for v in self.adj_list[vertex]] print(f"{vertex.val}: {tmp},")

Сравнение эффективности

Пусть дан граф с n вершинами и m ребрами. В табл. 9.2 приведено сравне- ние временной и пространственной сложности матрицы смежности и списка смежности.

Таблица 9.2. Сравнение матрицы и списка смежности

+---------------------------------------------------------------------------------------------------------------+ | > Операция Матрица Список смежности Список смежности смежности (связный список) (хеш-таблица) | +===========================+===========================+===========================+===========================+ | > Проверка смежности | > O(1) | > O(m) | > O(1) | +---------------------------+---------------------------+---------------------------+---------------------------+ | > Добавление ребра | > O(1) | > O(1) | > O(1) | +---------------------------+---------------------------+---------------------------+---------------------------+ | > Удаление ребра | > O(1) | > O(m) | > O(1) | +---------------------------+---------------------------+---------------------------+---------------------------+ | > Добавление вершины | > O(n) | > O(1) | > O(1) | +---------------------------+---------------------------+---------------------------+---------------------------+ | > Удаление вершины | > O(n²) | > O(n + m) | > O(n) | +---------------------------+---------------------------+---------------------------+---------------------------+ | > Занимаемое пространство | > O(n²) | > O(n + m) | > O(n + m) | +---------------------------+---------------------------+---------------------------+---------------------------+

Из табл. 9.2 видно, что временная и пространственная эффективность спи- ска смежности (хеш-таблица) наиболее оптимальна. Однако на практике опе- рации с ребрами в матрице смежности более эффективны, так как требуют лишь одного доступа или присвоения в массиве. В целом матрица смежности реализует принцип обмена пространства на время, тогда как список смежно- сти -- обмена времени на пространство.

Обход графа

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

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

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

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

Рис. 9.9. Обход графа в ширину

Реализация алгоритма

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

  1. добавить начальную вершину обхода startVet в очередь и начать цикл;

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

  3. повторять шаг 2, пока не будут посещены все вершины.

Чтобы избежать повторного обхода вершин, необходимо использовать хеш- множество visited для записи посещенных узлов.

# === File: graph_bfs.py ===

def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]: """ Обход в ширину."""

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

все смежные вершины текущей вершины.

# Последовательность обхода вершин. res = []

# Хеш-множество для записи уже посещенных вершин. visited = set[Vertex]([start_vet])

# Очередь для реализации поиска в ширину. que = deque[Vertex]([start_vet])

# Начало с вершины vet; цикл до тех пор, пока не будут посещены все вершины. while len(que) > 0:

vet = que.popleft() # Вершина извлекается из головы очереди. res.append(vet) # Запись посещенной вершины.

# Обход всех смежных вершин этой вершины. for adj_vet in graph.adj_list[vet]:

if adj_vet in visited:

continue # Пропуск уже посещенных вершин. que.append(adj_vet) # В очередь добавляются только

# непосещенные вершины. visited.add(adj_vet) # Отметка, что вершина была посещена.

# Возврат последовательности обхода вершин. return res

Код относительно абстрактен, рекомендуется обратиться к рис. 9.10 для более глубокого понимания.

Рис. 9.10. Этапы обхода графа в ширину. Шаг 1

Рис. 9.10. Продолжение. Шаги 2--3

Рис. 9.10. Продолжение. Шаги 4--5

Рис. 9.10. Продолжение. Шаги 6--7

Рис. 9.10. Продолжение. Шаги 8--9

Анализ сложности

Рис. 9.10. Окончание. Шаги 10--11

Временная сложность: все вершины будут добавляются в очередь и удаляют- ся из нее ровно один раз, что требует времени O(|V|). В процессе обхода смеж- ных вершин, поскольку граф неориентированный, все ребра будут посещены дважды, что занимает время O(2|E|). В целом требуется время O(|V| + |E|).

Пространственная сложность: список res, хеш-множество visited и количе- ство вершин в очереди que максимум равны |V|, что требует пространства O(|V|).

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

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

Рис. 9.11. Обход графа в глубину

Реализация алгоритма

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

# === File: graph_dfs.py ===

def dfs(graph: GraphAdjList, visited: set[Vertex], res: list[Vertex], vet: Ver- tex):

""" Вспомогательная функция для обхода в глубину.""" res.append(vet) # Запись посещенной вершины. visited.add(vet) # Пометка вершины как посещенной. # Обход всех смежных вершин текущей вершины.

for adjVet in graph.adj_list[vet]: if adjVet in visited:

continue # Пропуск уже посещенной вершины. # Рекурсивное посещение смежной вершины. dfs(graph, visited, res, adjVet)

def graph_dfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]: """ Обход в глубину."""

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

# Последовательность обхода вершин. res = []

# Хеш-множество для записи уже посещенных вершин.

visited = set[Vertex]()

dfs(graph, visited, res, start_vet) return res

Алгоритм обхода в глубину показан на рис. 9.12.

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

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

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

Рис. 9.12. Этапы обхода графа в глубину. Шаги 1--2

Рис. 9.12. Продолжение. Шаги 3--5

Рис. 9.12. Продолжение. Шаги 6--8

Рис. 9.12. Окончание. Шаги 9--11

Анализ сложности

Временная сложность: все вершины будут посещены один раз, что требует времени O(|V|). Все ребра будут посещены дважды, что требует времени O(2|E|). В целом требуется время O(|V| + |E|).

Пространственная сложность: список res и хеш-множество visited имеют максимум |V| вершин, максимальная глубина рекурсии равна |V|, следователь- но, требуется пространство O(|V|).

резюме

Ключевые моменты
  • Граф состоит из вершин и ребер, его можно задать как множество вер- шин и множество ребер.

  • По сравнению с линейными отношениями (список) и отношениями раз- деления (дерево), сетевые отношения (граф) обладают большей степе- нью свободы и, следовательно, более сложны.

  • Ребра ориентированного графа имеют направленность, в связном графе любые вершины достижимы, во взвешенном графе каждое ребро содер- жит переменную веса.

  • Матрица смежности использует матрицу для представления графа, каж- дая строка (столбец) представляет вершину, элементы матрицы пред- ставляют ребра. Значение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию. Матрица смежности эффектив- на в операциях добавления, удаления, поиска и изменения, но требует больше пространства.

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

  • Когда списки в списке смежности становятся слишком длинными, их можно преобразовать в красно-черное дерево или хеш-таблицу для по- вышения эффективности поиска.

9.4. Резюме ❖ 263

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

  • Графы используются для моделирования различных реальных систем, таких как социальные сети, линии метро и т. д.

  • Дерево является частным случаем графа, а обход дерева -- частным слу- чаем обхода графа.

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

  • Обход графа в глубину (DFS) -- это метод поиска, который сначала прохо- дит до конца, а затем отступает назад, когда дальнейшего пути нет, часто реализуется на основе рекурсии.

Вопросы и ответы

Вопрос. Путь -- это последовательность вершин или ребер?

Ответ. В разных языковых версиях «Википедии» определения различа- ются: в английской версии путь -- это последовательность ребер, а в русской версии путь -- это последовательность вершин. Приведем оригинальный текст английской версии: In graph theory, a path in a graph is a finite or infinite se- quence of edges which joins a sequence of vertices.

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

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

Вопрос. Существует ли в списке смежности требование к выбору порядка всех вершин, связанных с данной вершиной?

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

Глава 10