Цифровая трансформация Статья · 12 мин

Графы как вычислительная модель: как считать сложные системы без таблиц

Граф — это не страшная математика, а гибкая структура данных для вычислений, где узлы и связи заменяют тысячи строк таблиц.

Когда люди слышат «граф», они часто представляют социальные сети или маршруты на карте. Но граф — это прежде всего структура данных: узлы (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 (только по пути от изменённого узла до корня).

Структура и код — разные уровни (и это ключ к надёжности)

Важно понимать разделение:

  1. Граф (структура) — узлы и связи. Кто с кем связан, какие атрибуты у узлов (вес, цена, количество). Это данные.
  2. Код (обработка) — алгоритмы обхода, суммирования, фильтрации. Это поведение.

Один и тот же граф можно обрабатывать разными алгоритмами:

  • Посчитать себестоимость
  • Найти критический компонент (без которого изделие не собрать)
  • Вычислить экологический след (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 строк). Каждая делает ровно одну вещь: обойти, посчитать, отфильтровать, проверить.

Это меняет подход к разработке:

  1. Маленькие задачи — вы не пишете монолит на 500 строк. Вы говорите: «напиши функцию, которая найдёт все узлы-компоненты дороже 1000 рублей» или «проверь, что в структуре нет циклов». Это легко сформулировать.
  2. Вайб-кодинг с высокой надёжностью — такие задачи идеально подходят для ИИ-ассистентов. Вы даёте описание графа и небольшую задачу — ассистент генерирует компактный код, который легко проверить глазами за 10 секунд. Маленькая функция очевидно правильна или очевидно нет.
  3. Простота верификации — для графа естественно писать тесты. Фиксируете маленький граф (3–5 узлов), подаёте на вход функции, проверяете результат. Если граф маленький, тестовых случаев мало, и покрыть их легко.
  4. Разделение компетенций — архитектор описывает структуру графа (какие узлы, связи, атрибуты). Разработчик пишет код обработки. Аналитик проверяет целостность. Каждый работает со своим уровнем абстракции.

Итог

Граф — это не «страшная математика». Это просто способ сказать: «Вот данные, вот как они связаны, а теперь я напишу код, который пройдёт по этим связям и посчитает то, что мне нужно».

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

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

Хотите попробовать графовый подход в ваших расчётах?

Проведём мастер-класс по моделированию себестоимости, сборок и цепочек поставок на графах.