Графы и деревья в Python: алгоритмы и практика программирования
Для кого эта статья:
- Разработчики, изучающие структуры данных и алгоритмы
- Студенты и специалисты, обучающиеся программированию на Python
Аналитики и инженеры, работающие с графами и деревьями в бизнес-приложениях
Структуры данных — фундамент эффективного программирования, а графы и деревья — их элитный класс. Несмотря на мощь этих инструментов, многие разработчики обходят их стороной из-за кажущейся сложности. Пора это исправить! В этом руководстве мы препарируем графы и деревья до базовых компонентов, реализуем их на Python и покажем, как решать типичные задачи — от построения социальных связей до оптимизации маршрутов. Никакой абстрактной теории — только рабочий код и практические примеры. 🚀
Хотите превратить теорию в практические навыки? Обучение Python-разработке от Skypro включает углубленное изучение структур данных, включая графы и деревья, с фокусом на реальные проекты. Вы не просто поймете концепции — вы научитесь применять их для решения бизнес-задач, от анализа данных до построения рекомендательных систем. Инвестиция в структурированное понимание алгоритмов окупается многократно в карьере разработчика.
Основы графов и деревьев в Python: теория и структуры
Графы и деревья — не просто математические абстракции, а мощные инструменты моделирования связей. Граф состоит из вершин (узлов) и ребер (связей между узлами). Формально это пара G = (V, E), где V — множество вершин, а E — множество ребер. 📊
Графы классифицируются по нескольким критериям:
- Направленность: ориентированные (digraph) и неориентированные
- Вес: взвешенные и невзвешенные
- Связность: связные и несвязные
- Цикличность: ациклические и циклические
Дерево — особый вид графа: связный, ациклический, где любые два узла соединены ровно одним путем. Корневое дерево имеет выделенный узел (корень), от которого строится иерархия.
В Python существует несколько способов представления графов:
| Структура | Достоинства | Недостатки | Применение |
|---|---|---|---|
| Матрица смежности | Быстрая проверка наличия ребра O(1) | Высокое потребление памяти O(V²) | Плотные графы |
| Список смежности | Экономия памяти O(V+E) | Проверка наличия ребра O(E) | Разреженные графы |
| Список ребер | Простота реализации | Неэффективность операций | Простые алгоритмы |
Для представления графа с помощью матрицы смежности используем двумерный массив:
# Матрица смежности для неориентированного графа
adjacency_matrix = [
[0, 1, 1, 0],
[1, 0, 1, 1],
[1, 1, 0, 1],
[0, 1, 1, 0]
]
Список смежности можно реализовать через словарь словарей (для взвешенного графа) или словарь списков:
# Список смежности для взвешенного ориентированного графа
adjacency_list = {
'A': {'B': 2, 'C': 1},
'B': {'C': 3, 'D': 2},
'C': {'D': 4},
'D': {}
}
Выбор структуры критически влияет на производительность алгоритмов. Для разреженных графов (где |E| << |V|²) список смежности существенно экономит память.

Реализация графов на Python: от простого к сложному
Александр Новиков, инженер по машинному обучению
Мы столкнулись с задачей анализа взаимодействий в крупной IT-компании. Нужно было выявить ключевых сотрудников и оптимизировать коммуникационные потоки. Традиционные методы не справлялись с масштабом.
"Когда я впервые попытался смоделировать организационные связи через табличное представление, анализ превратился в кошмар", — вспоминаю те дни. Затем я применил графовый подход:
"Мы представили каждого сотрудника как узел, а взаимодействия как рёбра с весами, отражающими частоту коммуникаций. Реализовав граф через список смежности в Python, мы смогли применить алгоритм центральности по посредничеству (betweenness centrality) и выявили 7 сотрудников, через которых проходило 80% важной информации. Реорганизация команд вокруг этих узлов сократила время принятия решений на 34%".
Именно тогда я понял: графы — это не абстрактная теория, а мощный инструмент для решения реальных бизнес-задач.
Начнем с простейшей реализации неориентированного невзвешенного графа через список смежности:
class Graph:
def __init__(self):
self.graph = {}
def add_vertex(self, vertex):
if vertex not in self.graph:
self.graph[vertex] = []
def add_edge(self, v1, v2):
if v1 in self.graph and v2 in self.graph:
self.graph[v1].append(v2)
self.graph[v2].append(v1) # Для неориентированного графа
def print_graph(self):
for vertex in self.graph:
print(f"{vertex} -> {' '.join(map(str, self.graph[vertex]))}")
# Пример использования
g = Graph()
for i in range(5):
g.add_vertex(i)
g.add_edge(0, 1)
g.add_edge(0, 4)
g.add_edge(1, 2)
g.add_edge(1, 3)
g.add_edge(1, 4)
g.add_edge(2, 3)
g.add_edge(3, 4)
g.print_graph()
Для реализации взвешенного ориентированного графа модифицируем код:
class WeightedDigraph:
def __init__(self):
self.graph = {}
def add_vertex(self, vertex):
if vertex not in self.graph:
self.graph[vertex] = {}
def add_edge(self, from_v, to_v, weight=1):
if from_v in self.graph and to_v in self.graph:
self.graph[from_v][to_v] = weight
def print_graph(self):
for vertex in self.graph:
edges = [f"{dest}({weight})" for dest, weight in self.graph[vertex].items()]
print(f"{vertex} -> {' '.join(edges)}")
# Пример использования
g = WeightedDigraph()
vertices = ['A', 'B', 'C', 'D', 'E']
for v in vertices:
g.add_vertex(v)
g.add_edge('A', 'B', 6)
g.add_edge('A', 'D', 1)
g.add_edge('B', 'C', 5)
g.add_edge('B', 'E', 2)
g.add_edge('C', 'E', 5)
g.add_edge('D', 'E', 1)
g.print_graph()
Для более сложных задач полезно реализовать дополнительные методы:
class AdvancedGraph:
def __init__(self, directed=False):
self.graph = {}
self.directed = directed
def add_vertex(self, vertex):
if vertex not in self.graph:
self.graph[vertex] = {}
def add_edge(self, from_v, to_v, weight=1):
if from_v not in self.graph:
self.add_vertex(from_v)
if to_v not in self.graph:
self.add_vertex(to_v)
self.graph[from_v][to_v] = weight
if not self.directed:
self.graph[to_v][from_v] = weight
def get_vertices(self):
return list(self.graph.keys())
def get_edges(self):
edges = []
for from_v in self.graph:
for to_v, weight in self.graph[from_v].items():
# Для неориентированного графа избегаем дублирования
if self.directed or from_v <= to_v:
edges.append((from_v, to_v, weight))
return edges
def get_neighbors(self, vertex):
return list(self.graph.get(vertex, {}).keys())
def has_edge(self, from_v, to_v):
return from_v in self.graph and to_v in self.graph[from_v]
При разработке графовых алгоритмов важно учитывать их временную и пространственную сложность:
| Операция | Матрица смежности | Список смежности |
|---|---|---|
| Добавление вершины | O(V²) | O(1) |
| Добавление ребра | O(1) | O(1) |
| Удаление вершины | O(V²) | O(V+E) |
| Удаление ребра | O(1) | O(E) |
| Проверка смежности | O(1) | O(deg(v)) |
Для специфических задач можно расширить базовый класс графа. Например, для социального графа:
class SocialGraph(AdvancedGraph):
def __init__(self):
super().__init__(directed=False)
self.user_data = {}
def add_user(self, user_id, name, age=None):
self.add_vertex(user_id)
self.user_data[user_id] = {'name': name, 'age': age}
def add_friendship(self, user1, user2, strength=1):
self.add_edge(user1, user2, strength)
def get_friends(self, user_id):
return self.get_neighbors(user_id)
def get_friend_recommendations(self, user_id, max_depth=2):
"""Находит друзей друзей для рекомендаций"""
visited = {user_id}
current = set(self.get_friends(user_id))
visited.update(current)
recommendations = set()
for _ in range(max_depth – 1):
next_level = set()
for friend in current:
friends_of_friend = set(self.get_friends(friend))
next_level.update(friends_of_friend)
recommendations.update(next_level – visited)
visited.update(next_level)
current = next_level
return recommendations
Деревья в Python: представление и основные операции
Дерево — частный случай графа с иерархической структурой. Бинарное дерево ограничивает число потомков до двух, что делает его особенно эффективным для поиска и сортировки. 🌳
Базовая реализация узла бинарного дерева:
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
На основе этого класса построим бинарное дерево поиска (BST), где для каждого узла все значения в левом поддереве меньше, а в правом — больше значения узла:
class BinarySearchTree:
def __init__(self):
self.root = None
def insert(self, value):
if not self.root:
self.root = TreeNode(value)
return
self._insert_recursive(self.root, value)
def _insert_recursive(self, node, value):
if value < node.value:
if node.left is None:
node.left = TreeNode(value)
else:
self._insert_recursive(node.left, value)
else:
if node.right is None:
node.right = TreeNode(value)
else:
self._insert_recursive(node.right, value)
def search(self, value):
return self._search_recursive(self.root, value)
def _search_recursive(self, node, value):
if node is None or node.value == value:
return node
if value < node.value:
return self._search_recursive(node.left, value)
return self._search_recursive(node.right, value)
def inorder_traversal(self):
result = []
self._inorder_recursive(self.root, result)
return result
def _inorder_recursive(self, node, result):
if node:
self._inorder_recursive(node.left, result)
result.append(node.value)
self._inorder_recursive(node.right, result)
Для реализации более сложных деревьев, таких как AVL или красно-черное дерево, потребуется добавить балансировку. Вот пример реализации АВЛ-дерева:
class AVLNode(TreeNode):
def __init__(self, value):
super().__init__(value)
self.height = 1
class AVLTree(BinarySearchTree):
def _get_height(self, node):
if not node:
return 0
return node.height
def _get_balance(self, node):
if not node:
return 0
return self._get_height(node.left) – self._get_height(node.right)
def _update_height(self, node):
if not node:
return
node.height = 1 + max(self._get_height(node.left), self._get_height(node.right))
def _rotate_right(self, y):
x = y.left
T2 = x.right
x.right = y
y.left = T2
self._update_height(y)
self._update_height(x)
return x
def _rotate_left(self, x):
y = x.right
T2 = y.left
y.left = x
x.right = T2
self._update_height(x)
self._update_height(y)
return y
def insert(self, value):
self.root = self._insert_recursive(self.root, value)
def _insert_recursive(self, node, value):
# Стандартная вставка BST
if not node:
return AVLNode(value)
if value < node.value:
node.left = self._insert_recursive(node.left, value)
else:
node.right = self._insert_recursive(node.right, value)
# Обновляем высоту текущего узла
self._update_height(node)
# Проверяем баланс и выполняем необходимые повороты
balance = self._get_balance(node)
# Случай левого-левого перекоса
if balance > 1 and value < node.left.value:
return self._rotate_right(node)
# Случай правого-правого перекоса
if balance < -1 and value >= node.right.value:
return self._rotate_left(node)
# Случай левого-правого перекоса
if balance > 1 and value >= node.left.value:
node.left = self._rotate_left(node.left)
return self._rotate_right(node)
# Случай правого-левого перекоса
if balance < -1 and value < node.right.value:
node.right = self._rotate_right(node.right)
return self._rotate_left(node)
return node
Операции с деревьями часто требуют рекурсивного мышления. Рассмотрим основные типы обхода дерева:
- Inorder (левый-корень-правый): обходит сначала левое поддерево, затем корень, затем правое поддерево
- Preorder (корень-левый-правый): сначала корень, затем левое поддерево, затем правое поддерево
- Postorder (левый-правый-корень): сначала левое поддерево, затем правое поддерево, затем корень
- Level order: обход по уровням сверху вниз, слева направо
def preorder_traversal(self):
result = []
self._preorder_recursive(self.root, result)
return result
def _preorder_recursive(self, node, result):
if node:
result.append(node.value) # Сначала корень
self._preorder_recursive(node.left, result)
self._preorder_recursive(node.right, result)
def postorder_traversal(self):
result = []
self._postorder_recursive(self.root, result)
return result
def _postorder_recursive(self, node, result):
if node:
self._postorder_recursive(node.left, result)
self._postorder_recursive(node.right, result)
result.append(node.value) # Корень в конце
def level_order_traversal(self):
if not self.root:
return []
result = []
queue = [self.root]
while queue:
node = queue.pop(0)
result.append(node.value)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
Екатерина Волкова, ведущий аналитик данных
Наш отдел столкнулся с проблемой оптимизации системы категорий товаров в крупном интернет-магазине. Плоская структура категорий превратилась в хаос — товары дублировались, поиск давал неточные результаты, а рекомендательная система просто не работала.
"На совещании я предложила переосмыслить структуру как иерархическое дерево, но коллеги скептически отнеслись к идее, опасаясь сложной миграции", — рассказывает Екатерина. — "Я решила провести proof-of-concept на Python".
В течение выходных я разработала прототип, используя деревья:
"Каждая категория стала узлом, товары хранились в листьях, а навигация осуществлялась через обход дерева. Я использовала алгоритм LCA (Lowest Common Ancestor) для нахождения ближайшего общего предка, что позволило создавать динамические подборки связанных товаров."
Когда в понедельник я продемонстрировала прототип, показав 30% ускорение поиска и 45% улучшение точности рекомендаций, команда была впечатлена. Через месяц мы полностью перешли на новую систему категорий, а мой подход с использованием деревьев в Python стал частью корпоративного стандарта.
Алгоритмы обхода графов и их Python-реализации
Обход графа — фундаментальная операция, на основе которой строятся более сложные алгоритмы. Два основных подхода: поиск в глубину (DFS) и поиск в ширину (BFS). 🔍
Реализация DFS:
def dfs(graph, start_vertex):
visited = set()
def dfs_recursive(vertex):
visited.add(vertex)
print(vertex, end=' ')
for neighbor in graph[vertex]:
if neighbor not in visited:
dfs_recursive(neighbor)
dfs_recursive(start_vertex)
# Нерекурсивная реализация DFS
def dfs_iterative(graph, start_vertex):
visited = set()
stack = [start_vertex]
while stack:
vertex = stack.pop()
if vertex not in visited:
visited.add(vertex)
print(vertex, end=' ')
# Добавляем соседей в обратном порядке,
# чтобы порядок обхода совпадал с рекурсивным
for neighbor in reversed(graph[vertex]):
if neighbor not in visited:
stack.append(neighbor)
Реализация BFS:
from collections import deque
def bfs(graph, start_vertex):
visited = set([start_vertex])
queue = deque([start_vertex])
while queue:
vertex = queue.popleft()
print(vertex, end=' ')
for neighbor in graph[vertex]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
Сравнение алгоритмов обхода графа:
| Характеристика | DFS | BFS |
|---|---|---|
| Структура данных | Стек (или рекурсия) | Очередь |
| Применение | Топологическая сортировка, поиск циклов, компонент связности | Кратчайший путь (невзвешенный), уровневый обход |
| Сложность по времени | O(V+E) | O(V+E) |
| Сложность по памяти | O(V) в худшем случае | O(V) в худшем случае |
На основе BFS можно реализовать алгоритм поиска кратчайшего пути в невзвешенном графе:
def shortest_path(graph, start, end):
if start not in graph or end not in graph:
return None
if start == end:
return [start]
visited = {start}
queue = deque([(start, [start])])
while queue:
current, path = queue.popleft()
for neighbor in graph[current]:
if neighbor not in visited:
new_path = path + [neighbor]
if neighbor == end:
return new_path
visited.add(neighbor)
queue.append((neighbor, new_path))
return None # Путь не найден
Алгоритм Дейкстры для поиска кратчайшего пути во взвешенном графе:
import heapq
def dijkstra(graph, start, end):
# Расстояния до всех вершин
distances = {vertex: float('infinity') for vertex in graph}
distances[start] = 0
# Предыдущие вершины для восстановления пути
previous = {vertex: None for vertex in graph}
# Приоритетная очередь [(расстояние, вершина)]
priority_queue = [(0, start)]
while priority_queue:
current_distance, current_vertex = heapq.heappop(priority_queue)
# Если достигли конечной вершины – завершаем
if current_vertex == end:
path = []
while current_vertex:
path.append(current_vertex)
current_vertex = previous[current_vertex]
return path[::-1] # Переворачиваем путь
# Если уже нашли кратчайший путь до вершины – пропускаем
if current_distance > distances[current_vertex]:
continue
# Проверяем соседей текущей вершины
for neighbor, weight in graph[current_vertex].items():
distance = current_distance + weight
# Если нашли более короткий путь
if distance < distances[neighbor]:
distances[neighbor] = distance
previous[neighbor] = current_vertex
heapq.heappush(priority_queue, (distance, neighbor))
return None # Путь не найден
Для поиска остовного дерева минимального веса используется алгоритм Прима или Краскала. Вот реализация алгоритма Прима:
def prim(graph):
# Начинаем с произвольной вершины
start_vertex = next(iter(graph))
# Множество вершин, включенных в MST
mst_vertices = {start_vertex}
# Ребра в MST (вершина_1, вершина_2, вес)
mst_edges = []
# Продолжаем, пока все вершины не будут включены в MST
while len(mst_vertices) < len(graph):
min_edge = None
# Для каждой вершины в MST
for v in mst_vertices:
# Проверяем все исходящие ребра
for neighbor, weight in graph[v].items():
if neighbor not in mst_vertices:
# Если это ребро легче предыдущего – обновляем
if min_edge is None or weight < min_edge[2]:
min_edge = (v, neighbor, weight)
if min_edge is None:
break # Граф несвязный
u, v, _ = min_edge
mst_vertices.add(v)
mst_edges.append(min_edge)
return mst_edges
Для определения компонент сильной связности в ориентированном графе применяется алгоритм Косарайю:
def kosaraju(graph):
# Шаг 1: Выполнить DFS на оригинальном графе для определения порядка завершения
visited = set()
finish_order = []
def dfs_finish_time(vertex):
visited.add(vertex)
for neighbor in graph[vertex]:
if neighbor not in visited:
dfs_finish_time(neighbor)
finish_order.append(vertex)
for vertex in graph:
if vertex not in visited:
dfs_finish_time(vertex)
# Шаг 2: Транспонировать граф (обратить направление всех рёбер)
transposed = {vertex: [] for vertex in graph}
for vertex in graph:
for neighbor in graph[vertex]:
transposed[neighbor].append(vertex)
# Шаг 3: Выполнить DFS на транспонированном графе в порядке убывания времени завершения
visited.clear()
components = []
def dfs_component(vertex, component):
visited.add(vertex)
component.append(vertex)
for neighbor in transposed[vertex]:
if neighbor not in visited:
dfs_component(neighbor, component)
for vertex in reversed(finish_order):
if vertex not in visited:
current_component = []
dfs_component(vertex, current_component)
components.append(current_component)
return components
Библиотеки для работы с графами: NetworkX, igraph и другие
Реализация графов с нуля — ценный опыт, но в реальных проектах эффективнее использовать специализированные библиотеки. Рассмотрим наиболее популярные инструменты. 📚
NetworkX — самая популярная Python-библиотека для создания, манипулирования и анализа графов:
import networkx as nx
import matplotlib.pyplot as plt
# Создание графа
G = nx.Graph() # Неориентированный граф
# Или: G = nx.DiGraph() # Ориентированный граф
# Добавление узлов и рёбер
G.add_nodes_from([1, 2, 3, 4, 5])
G.add_edges_from([(1, 2), (1, 3), (2, 3), (2, 4), (3, 5), (4, 5)])
# Анализ графа
print(f"Количество узлов: {G.number_of_nodes()}")
print(f"Количество рёбер: {G.number_of_edges()}")
print(f"Степени узлов: {dict(G.degree())}")
# Алгоритмы
print(f"Центральность по посредничеству: {nx.betweenness_centrality(G)}")
print(f"Кратчайший путь от 1 до 5: {nx.shortest_path(G, 1, 5)}")
# Визуализация
nx.draw(G, with_labels=True, node_color='lightblue',
node_size=500, font_weight='bold', font_size=14)
plt.show()
igraph — высокопроизводительная библиотека с поддержкой C:
from igraph import Graph
import matplotlib.pyplot as plt
# Создание графа
g = Graph(directed=False)
g.add_vertices(5) # Добавляем 5 вершин с индексами 0-4
g.add_edges([(0, 1), (0, 2), (1, 2), (1, 3), (2, 4), (3, 4)])
# Установка атрибутов
g.vs["name"] = ["A", "B", "C", "D", "E"] # Имена вершин
g.vs["color"] = ["red", "green", "blue", "yellow", "purple"]
g.es["weight"] = [1, 2, 3, 1, 4, 2] # Веса рёбер
# Анализ
print(f"Плотность графа: {g.density()}")
print(f"Диаметр графа: {g.diameter()}")
print(f"Кластерный коэффициент: {g.transitivity_undirected()}")
# Визуализация
layout = g.layout("kk") # Используем алгоритм Kamada-Kawai
fig, ax = plt.subplots()
g.plot(layout=layout, target=ax)
plt.show()
PyGraphviz — обёртка для Graphviz:
import pygraphviz as pgv
# Создание графа
G = pgv.AGraph(directed=True)
# Добавление узлов с атрибутами
G.add_node("A", color="red", shape="circle")
G.add_node("B", color="blue", shape="box")
G.add_node("C", color="green", shape="triangle")
# Добавление рёбер с атрибутами
G.add_edge("A", "B", weight=2, label="связь AB", color="red")
G.add_edge("A", "C", weight=1, label="связь AC", style="dashed")
G.add_edge("B", "C", weight=3, label="связь BC", penwidth=2.0)
# Установка общих атрибутов
G.graph_attr["rankdir"] = "LR"
G.node_attr["style"] = "filled"
# Сохранение и рендеринг
G.write("example.dot") # Сохранение в формате DOT
G.draw("example.png", prog="dot") # Рендеринг изображения
Сравнение основных библиотек для работы с графами:
| Библиотека | Преимущества | Недостатки | Оптимальное применение |
|---|---|---|---|
| NetworkX | Простой API, богатый функционал, хорошая документация | Низкая производительность на больших графах | Исследование, прототипирование, малые/средние графы |
| igraph | Высокая производительность, поддержка большого числа алгоритмов | Сложнее в использовании, менее pythonic API | Большие графы, когда важна скорость |
| PyGraphviz | Мощные возможности визуализации, поддержка DOT | Фокус на визуализации, не на алгоритмах | Создание визуальных представлений графов |
| graph-tool | Очень высокая производительность (C++/OpenMP) | Сложная установка, крутая кривая обучения | Вычислительно-интенсивные задачи на больших графах |
Для специфических задач существуют специализированные библиотеки:
- SNAP (Stanford Network Analysis Platform): для анализа больших сетей
- NetworKit: для анализа массивных сетей с миллионами узлов и ребер
- GraphFrames: для обработки графов на Apache Spark
- DGL (Deep Graph Library): для графовых нейронных сетей
- PyTorch Geometric: для графовых нейронных сетей с PyTorch
Пример использования NetworkX для анализа социального графа:
import networkx as nx
import matplotlib.pyplot as plt
import community as community_louvain
import numpy as np
# Создаем случайный граф, имитирующий социальную сеть
G = nx.barabasi_albert_graph(100, 5) # 100 узлов, каждый подключается к 5 существующим
# Вычисляем метрики центральности
degree_cent = nx.degree_centrality(G)
betweenness_cent = nx.betweenness_centrality(G)
closeness_cent = nx.closeness_centrality(G)
# Выявляем сообщества с помощью алгоритма Louvain
partition = community_louvain.best_partition(G)
communities = {}
for node, community_id in partition.items():
if community_id not in communities:
communities[community_id] = []
communities[community_id].append(node)
print(f"Обнаружено {len(communities)} сообществ")
for comm_id, nodes in communities.items():
print(f"Сообщество {comm_id}: {len(nodes)} узлов")
# Визуализация с учетом сообществ
pos = nx.spring_layout(G, seed=42)
plt.figure(figsize=(12, 8))
# Цвета для разных сообществ
cmap = plt.cm.get_cmap("tab20", max(partition.values()) + 1)
node_colors = [cmap(partition[node]) for node in G.nodes()]
# Размер узла зависит от центральности
node_size = [5000 * degree_cent[node] for node in G.nodes()]
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=node_size, alpha=0.8)
nx.draw_networkx_edges(G, pos, alpha=0.2)
plt.axis("off")
plt.tight_layout()
plt.show()
Эффективная работа с графами и деревьями требует не просто знания алгоритмов, но и умения выбирать правильные инструменты. Python предоставляет обширный арсенал как для реализации с нуля, так и для использования готовых высокооптимизированных решений. Помните: выбор структуры данных определяет сложность алгоритма, а выбор библиотеки — производительность решения. Освоив представленные концепции, вы сможете моделировать сложные взаимосвязи — от маршрутизации в сетях до рекомендательных систем и анализа социальных взаимодействий.