Когда люди слышат «граф», они часто представляют социальные сети или маршруты на карте. Но граф — это прежде всего структура данных: узлы (nodes) и связи (edges) между ними. А раз это структура, по ней можно вычислять — так же, как мы вычисляем по таблицам, только гибче.
Главная идея: узлы и связи описывают «что с чем связано», а код описывает «что с этим делать». Структура отделена от логики расчёта.
Пример 1. Таблица → граф вычислений
Представьте простую таблицу:
A B C
5 3 =A1+B1
В графе это выглядит так:
(5)───┐
├──(+)──(8)
(3)───┘
где:
- (5) и (3) — узлы-данные (исходные значения)
- (+) — узел-операция (сложение)
- (8) — узел-результат
- Рёбра со стрелками — «операнд → операция → результат»
Если формула сложнее: =A1*2 + B1^2
┌──(×2)──┐
(5)───┤ ├──(+)──(19)
└──(^2)──┘
(3)───┘
Каждая операция — отдельный узел. Мы можем обойти граф от результата к исходным данным и вычислить значение. Код на Python:
def compute(node, graph):
"""Рекурсивный обход графа вычислений"""
if node['type'] == 'value':
return node['value']
op = node['operation']
args = [compute(child, graph) for child in node['inputs']]
if op == '+': return sum(args)
if op == '*': return args[0] * args[1]
if op == '^2': return args[0] ** 2
Что здесь граф даёт нового? В таблице формула «зашита» в ячейку — её нельзя переиспользовать. В графе узел-операция может быть входом для нескольких других узлов. Один и тот же расчёт используется многократно, и структура наглядна.
Пример 2. Себестоимость изделия из компонентов
Теперь ближе к производству. У нас есть изделие, которое собирается из компонентов. Компоненты могут быть купленными или тоже произведёнными.
Структура графа
Связь «входит_в» говорит: из чего состоит изделие. Каждый узел-компонент хранит свои атрибуты: вес, стоимость, количество.
[Стол] ── себестоимость = ?
/ | \
входит_в / | \ входит_в
/ | \
[Столешница] [Ножка1] [Ножка2] ...
│ │
│ ├── [Сталь 2кг] ← цена за кг: 300
│ ├── [Работа 1ч] ← ставка: 1000
├── [Доска 3кг] ← цена за кг: 200
├── [Лак 0.5л] ← цена за л: 500
└── [Работа 2ч] ← ставка: 1000
Расчёт суммы весов и себестоимости
Код обходит граф «снизу вверх» и агрегирует:
def aggregate_cost(node_id, graph):
"""Суммирует стоимость узла и всех его потомков"""
node = graph[node_id]
total = node.get('cost', 0) # собственная стоимость (если покупной)
for child_id in node['contains']: # рёбра "входит_в"
child_node = graph[child_id]
qty = child_node.get('quantity', 1)
total += qty * aggregate_cost(child_id, graph)
return total
def aggregate_weight(node_id, graph):
"""Суммирует вес всех компонентов"""
node = graph[node_id]
total = node.get('weight', 0)
for child_id in node['contains']:
qty = child_node.get('quantity', 1)
total += qty * aggregate_weight(child_id, graph)
return total
Что мы можем делать через граф:
| Что считаем | Как |
|---|---|
| Сумма весов | Обход в глубину + суммирование weight × quantity |
| Сумма стоимостей | Обход + суммирование cost × quantity |
| Средняя цена компонента | total_cost / total_weight |
| Доля каждого компонента | component_cost / total_cost |
| Что изменится, если заменить материал | Поменять узел — пересчёт автоматически |
| Себестоимость партии | Умножить на количество изделий |
Почему граф, а не таблица?
| Таблица | Граф |
|---|---|
| Данные в ячейках, формула в ячейке | Данные — узлы, операции — узлы, связи — поток |
| Изменение структуры = переделывать лист | Добавил узел/связь — структура изменилась |
| Повторное использование формулы — копипаст | Один узел-операция — много входов |
| Вложенность «входит в» неочевидна | «Входит_в» — это явное ребро, по нему можно итерировать |
| Расчёт — встроенный | Расчёт — код, который программируем под задачу |
| Дата, ФИО, справочники дублируются в каждой строке | Хранение без дублирования — значение один раз, остальное связи |
| JOIN нескольких таблиц — рост нагрузки с каждой связью | Обход по ребру — O(1) на шаг |
| Пересчёт всей таблицы при изменении | Предрасчёт с dirty-флагами: только изменённые ветки |
Граф эффективнее по хранению
В реляционных базах одна и та же дата, ФИО или название материала повторяются в тысячах строк. Чтобы избежать дублирования, вводят справочники (таблицы-словари), но тогда каждый запрос требует JOIN.
JOIN — операция в SQL, которая соединяет строки из двух таблиц по общему полю. Например, таблица Заказы (колонка id_клиента) и таблица Клиенты (колонка id). Чтобы получить ФИО клиента в каждом заказе, нужен JOIN:
SELECT Заказы.номер, Клиенты.фио
FROM Заказы
JOIN Клиенты ON Заказы.id_клиента = Клиенты.id
Проблема: если у вас 5 уровней вложенности (Заказ → Товар → Категория → Поставщик → Адрес), нужны 4 JOIN-а. Каждый JOIN сопоставляет миллионы строк — база строит декартово произведение, потом фильтрует. Чем больше JOIN-ов, тем экспоненциально тяжелее запрос.
В графе значение хранится один раз, а всё остальное — связи к нему. Дата «2026-07-09» — один узел. На него ссылаются рёбрами все заявки, накладные, операции, где эта дата фигурирует. Поиск «всех заказов за эту дату» — обход по одному ребру от узла-даты, а не JOIN двух таблиц.
Граф снижает вычислительную нагрузку: предрасчёт и кэширование
Когда себестоимость изделия считается рекурсивно обходом графа, каждый запрос — это O(N) операций, где N — число узлов. Для больших сборок (тысячи компонентов) это может быть дорого.
Решение — хранить результат прямо в узле:
def ensure_cost_fresh(node_id, graph):
"""Пересчитывает себестоимость узла, если изменились компоненты"""
node = graph[node_id]
# Если узел помечен как устаревший — пересчитать
if node.get('dirty'):
total = node.get('cost', 0)
for child_id in node.get('contains', []):
ensure_cost_fresh(child_id, graph)
child = graph[child_id]
total += child['quantity'] * child['cached_cost']
node['cached_cost'] = total
node['dirty'] = False
return node['cached_cost']
Логика:
- При изменении любого компонента он помечается флагом dirty («устарел»)
- При запросе себестоимости код обновляет только «грязные» узлы на пути к корню
- Остальные данные выдаются мгновенно из кэша
Запрос себестоимости сложного изделия в 10 000 узлов после одного изменения: вместо 10 000 операций — 20–30 (только по пути от изменённого узла до корня).
Структура и код — разные уровни (и это ключ к надёжности)
Важно понимать разделение:
- Граф (структура) — узлы и связи. Кто с кем связан, какие атрибуты у узлов (вес, цена, количество). Это данные.
- Код (обработка) — алгоритмы обхода, суммирования, фильтрации. Это поведение.
Один и тот же граф можно обрабатывать разными алгоритмами:
- Посчитать себестоимость
- Найти критический компонент (без которого изделие не собрать)
- Вычислить экологический след (CO₂ на каждом компоненте)
- Построить план закупок (какие компоненты и в каком количестве нужны)
Меняется только код обработки. Структура остаётся той же.
Но есть и второй, не менее важный класс кода: код анализа и контроля целостности модели. Он не считает себестоимость, а проверяет:
- Нет ли циклов в структуре «входит_в» (изделие не может содержать само себя)
- Все ли обязательные атрибуты заполнены
- Нет ли «висящих» узлов без связей
- Соответствует ли модель спецификации
Эти проверки — отдельные маленькие функции, каждая решает одну задачу:
def has_cycles(node_id, graph, visited=None):
"""Проверка: нет ли цикла в составе изделия"""
if visited is None: visited = set()
if node_id in visited: return True
visited.add(node_id)
for child_id in graph[node_id].get('contains', []):
if has_cycles(child_id, graph, visited):
return True
visited.remove(node_id)
return False
def validate_attributes(node_id, graph, required=['weight', 'cost', 'quantity']):
"""Проверка: все ли поля заполнены"""
node = graph[node_id]
missing = [f for f in required if f not in node or node[f] is None]
return missing # пустой список — всё в порядке
К чему это ведёт: маленькие задачи = высокая надёжность
Код работы с графом — это, как правило, короткие функции (10–30 строк). Каждая делает ровно одну вещь: обойти, посчитать, отфильтровать, проверить.
Это меняет подход к разработке:
- Маленькие задачи — вы не пишете монолит на 500 строк. Вы говорите: «напиши функцию, которая найдёт все узлы-компоненты дороже 1000 рублей» или «проверь, что в структуре нет циклов». Это легко сформулировать.
- Вайб-кодинг с высокой надёжностью — такие задачи идеально подходят для ИИ-ассистентов. Вы даёте описание графа и небольшую задачу — ассистент генерирует компактный код, который легко проверить глазами за 10 секунд. Маленькая функция очевидно правильна или очевидно нет.
- Простота верификации — для графа естественно писать тесты. Фиксируете маленький граф (3–5 узлов), подаёте на вход функции, проверяете результат. Если граф маленький, тестовых случаев мало, и покрыть их легко.
- Разделение компетенций — архитектор описывает структуру графа (какие узлы, связи, атрибуты). Разработчик пишет код обработки. Аналитик проверяет целостность. Каждый работает со своим уровнем абстракции.
Итог
Граф — это не «страшная математика». Это просто способ сказать: «Вот данные, вот как они связаны, а теперь я напишу код, который пройдёт по этим связям и посчитает то, что мне нужно».
Таблицы хороши для плоских данных. Графы — для данных, где важны связи: состав изделия, цепочка вычислений, маршрут, зависимость. И главное — обработку графа вы пишете сами, под свою задачу, а не подстраиваетесь под возможности табличного процессора.
А когда структура отделена от кода, открывается главное преимущество: структуру можно параллельно проверять, анализировать и поддерживать — другими маленькими функциями. Каждая функция тривиальна, вероятность ошибки минимальна. Это позволяет делегировать написание таких функций ИИ, получая работающий код за секунды с высокой уверенностью в его корректности. Граф — это не усложнение, а способ сделать вычисления прозрачными, модульными и проверяемыми.