Решаем задачи на иерархию сотрудников с помощью рекурсивных CTE в SQL. Разбираем LeetCode 3482, учимся считать команду и бюджет. Читайте и применяйте!
Иерархические задачи в SQL часто пугают, ведь они отличаются от обычных задач на агрегацию. Когда я впервые столкнулся с задачей LeetCode 3482, я пытался решить её с помощью известных мне конструкций: JOIN, подзапросов, GROUP BY и оконных функций. Но организация может иметь неизвестное количество уровней: сотрудник может управлять другим, тот — третьим, и так далее. Фиксированное количество JOIN может обработать только фиксированное количество уровней. Мне нужен был запрос, который мог бы двигаться по иерархии, пока не найдутся все сотрудники. Именно для этого и предназначены рекурсивные обобщённые табличные выражения (CTE).
В этой статье я объясню:
Моё решение прошло лучше, чем 99% принятых решений на MySQL на момент отправки. Процентили времени выполнения могут меняться, но главное — эта задача помогла мне понять, как работает рекурсивный SQL.
У нас есть таблица Employees с информацией о менеджере и зарплате каждого сотрудника. Пример данных:
+-------------+---------------+------------+--------+-------------+
| employee_id | employee_name | manager_id | salary | department |
+-------------+---------------+------------+--------+-------------+
| 1 | Alice | null | 12000 | Executive |
| 2 | Bob | 1 | 10000 | Sales |
| 3 | Charlie | 1 | 10000 | Engineering |
| 4 | David | 2 | 7500 | Sales |
| 5 | Eva | 2 | 7500 | Sales |
| 6 | Frank | 3 | 9000 | Engineering |
| 7 | Grace | 3 | 8500 | Engineering |
| 8 | Hank | 4 | 6000 | Sales |
| 9 | Ivy | 6 | 7000 | Engineering |
| 10 | Judy | 6 | 7000 | Engineering |
+-------------+---------------+------------+--------+-------------+Для каждого сотрудника нужно определить:
Полная команда включает прямых и косвенных подчинённых. Бюджет включает зарплату самого сотрудника и зарплаты всех прямых и косвенных подчинённых.
Прежде чем писать SQL, полезно преобразовать таблицу в дерево:
Alice (1)
├── Bob (2)
│ ├── David (4)
│ │ └── Hank (8)
│ └── Eva (5)
└── Charlie (3)
├── Frank (6)
│ ├── Ivy (9)
│ └── Judy (10)
└── Grace (7)Из этого дерева видно:
Полная команда Алисы включает всех сотрудников ниже неё. Команда Боба включает Дэвида, Еву и Хэнка. Команда Чарли включает Фрэнка, Грейс, Айви и Джуди. Это важно, потому что обычный self-join находит только одно поколение за раз. Например, один self-join может найти прямых подчинённых Боба — Дэвида и Еву, но не продолжит от Дэвида к Хэнку.
Self-join может найти прямых подчинённых:
SELECT manager.employee_id, report.employee_id AS report_id
FROM Employees AS manager
JOIN Employees AS report ON report.manager_id = manager.employee_id;Можно добавить ещё один JOIN, чтобы найти подчинённых второго уровня:
JOIN Employees AS second_level ON second_level.manager_id = report.employee_idНо тогда понадобится ещё один JOIN для третьего уровня, ещё для четвёртого и так далее. Такой подход предполагает, что мы заранее знаем максимальную глубину организации. Рекурсивный CTE не требует знания глубины: он многократно применяет одно и то же отношение, пока не найдутся все сотрудники.
Рекурсивный CTE состоит из двух частей:
WITH RECURSIVE cte_name AS (
-- Anchor query
UNION
-- Recursive query
)Anchor query создаёт начальные строки. Он отвечает на вопрос: «Где начинается рекурсия?»
Recursive query использует строки, уже созданные CTE, для поиска следующего набора строк. Он отвечает на вопрос: «Учитывая сотрудников, найденных на предыдущем шаге, кого посетить следующим?»
База данных продолжает выполнять рекурсивную часть для вновь сгенерированных строк. Рекурсия естественным образом останавливается, когда очередная итерация не даёт новых строк.
Вместо создания одной иерархии, начиная с CEO, я создаю отдельную иерархию для каждого сотрудника. Это означает: одна иерархия начинается с Алисы, другая — с Боба, третья — с Чарли, и так для каждого. Зачем? Потому что задача требует полную команду и бюджет для каждого сотрудника. Если построить только иерархию CEO, я буду знать, где все находятся глобально, но всё равно понадобится способ определить потомков каждого сотрудника. Построив иерархию для каждого сотрудника, все потомки конкретного сотрудника будут иметь одинаковый начальный employee_id. Затем можно сгруппировать по этому ID, чтобы вычислить размер команды и бюджет.
Anchor-запрос:
SELECT employee_id, employee_id AS reporter_id, 1 AS level, salary
FROM EmployeesПолный рекурсивный CTE начинается так:
WITH RECURSIVE t AS (
SELECT employee_id, employee_id AS reporter_id, 1 AS level, salary
FROM Employees
UNION
SELECT t.employee_id, e.employee_id, t.level + 1, e.salary
FROM t
JOIN Employees AS e ON e.manager_id = t.reporter_id
)Смысл колонок:
employee_id — сотрудник, для которого мы строим полную иерархию.reporter_id — текущий человек, достигнутый внутри этой иерархии.level — расстояние текущего человека от начального сотрудника, начальный сотрудник имеет уровень 1.salary — зарплата текущего reporter_id.Различие между employee_id и reporter_id — самая важная часть решения. employee_id остаётся фиксированным в пределах одной иерархии, а reporter_id меняется по мере движения вниз по рекурсии.
Для иерархии Боба строки концептуально выглядят так:
employee_id | reporter_id | level | salary
------------+-------------+-------+-------
2 | 2 | 1 | 10000
2 | 4 | 2 | 7500
2 | 5 | 2 | 7500
2 | 8 | 3 | 6000Значение 2 остаётся фиксированным в employee_id, потому что все четыре строки принадлежат иерархии Боба. reporter_id меняется по мере посещения Боба, Дэвида, Евы и Хэнка.
Рекурсивная часть:
SELECT t.employee_id, e.employee_id, t.level + 1, e.salary
FROM t
JOIN Employees AS e ON e.manager_id = t.reporter_idДля каждой текущей строки в t запрос ищет в Employees людей, у которых manager_id равен текущему reporter_id. Предположим, текущая строка в иерархии Боба:
employee_id = 2
reporter_id = 2
level = 1JOIN проверяет e.manager_id = 2 и находит Дэвида и Еву. Рекурсивный запрос создаёт строки:
2 | 4 | 2 | 7500
2 | 5 | 2 | 7500Ключевая деталь: рекурсия продолжается не для одной возвращённой строки, а для каждой строки, возвращённой итерацией. Если итерация возвращает несколько строк, каждая из них участвует в следующей рекурсивной итерации. Поэтому Дэвид проверяется на наличие подчинённых, и Ева тоже. Дэвид управляет Хэнком, поэтому его строка даёт:
2 | 8 | 3 | 6000Ева никим не управляет, её ветвь не даёт новых строк и завершается. Рекурсия продолжается независимо для каждой активной ветви, пока ни одна ветвь не сможет дать нового сотрудника. Это один из самых важных уроков: рекурсивный CTE расширяет каждую строку, возвращённую предыдущей итерацией, а не одну строку.
В anchor-запросе я установил оба ID одинаковыми: employee_id, employee_id AS reporter_id. Это включает каждого сотрудника в его собственную иерархию. Сначала это может показаться лишним, ведь сотрудник не является собственным подчинённым. Однако это упрощает расчёт бюджета. Для Боба требуемый бюджет:
Боб = 10000
Дэвид = 7500
Ева = 7500
Хэнк = 6000
----------------
Бюджет = 31000Поскольку Боб уже включён в свою иерархию, бюджет вычисляется одним выражением: SUM(t1.salary). Не нужно отдельно считать зарплаты подчинённых и потом прибавлять зарплату Боба. Размер команды не должен включать самого Боба. Поскольку его иерархия содержит четыре строки, но только три подчинённых, размер команды вычисляется как COUNT(*) - 1. Это полезный паттерн: иногда намеренное включение строки упрощает одно вычисление, а небольшая корректировка делает другое вычисление правильным.
После того как рекурсивный CTE сгенерировал все иерархии, следующий запрос вычисляет размер команды и бюджет для каждого сотрудника:
SELECT t1.employee_id, e.employee_name,
COUNT(*) - 1 AS team_size,
SUM(t1.salary) AS budget
FROM t AS t1
JOIN Employees AS e ON t1.employee_id = e.employee_id
GROUP BY t1.employee_id, e.employee_nameДля каждого начального employee_id:
COUNT(*) - 1 считает всех прямых и косвенных подчинённых.SUM(t1.salary) складывает зарплату начального сотрудника и всех потомков.Для Боба результат:
employee_id | employee_name | team_size | budget
------------+---------------+-----------+-------
2 | Bob | 3 | 31000Для Чарли иерархия содержит Чарли, Фрэнка, Грейс, Айви и Джуди:
Чарли = 10000
Фрэнк = 9000
Грейс = 8500
Айви = 7000
Джуди = 7000
----------------
Бюджет = 41500У Чарли четыре подчинённых, поэтому его агрегированные значения:
employee_id | employee_name | team_size | budget
------------+---------------+-----------+-------
3 | Charlie | 4 | 41500Рекурсивный CTE создаёт иерархию, начиная с каждого сотрудника. В результате значение level является относительным к начальному сотруднику. Например, допустимые строки в разных иерархиях:
employee_id | reporter_id | level
------------+-------------+------
1 | 4 | 3
2 | 4 | 2
4 | 4 | 1Все три строки относятся к Дэвиду как reporter_id = 4, но описывают его с разных начальных точек:
Для задачи требуется уровень сотрудника в иерархии компании, то есть расстояние от CEO. Поэтому нельзя использовать level из рекурсивного CTE напрямую. Нужно вычислить уровень отдельно, например, с помощью отдельного рекурсивного CTE, который начинается с CEO (у которого manager_id равен NULL), и распространяется вниз. В этом случае уровень будет абсолютным: CEO на уровне 1, его прямые подчинённые на уровне 2 и так далее. Это можно сделать так:
WITH RECURSIVE levels AS (
SELECT employee_id, 1 AS level
FROM Employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, l.level + 1
FROM levels l
JOIN Employees e ON e.manager_id = l.employee_id
)
SELECT * FROM levels;Затем этот уровень можно объединить с основным запросом, чтобы получить итоговый результат.
Некоторые пытаются решить эту задачу с помощью коррелированного подзапроса, который считает подчинённых на каждом уровне. Однако коррелированный подзапрос выполняется для каждой строки и не может легко распространяться на несколько уровней вглубь. Рекурсивный CTE специально предназначен для обхода дерева или графа. Он более эффективен и читаем, поскольку вся логика обхода находится в одном месте. В моём решении рекурсивный JOIN обходит иерархию за один проход, а затем агрегирует результаты, что даёт хорошую производительность.
Собираем всё вместе. Сначала рекурсивный CTE для иерархий, затем агрегация, затем отдельный CTE для уровней, и наконец объединение. Вот полный запрос:
WITH RECURSIVE t AS (
-- Начинаем с каждого сотрудника как с корня
SELECT employee_id, employee_id AS reporter_id, 1 AS level, salary
FROM Employees
UNION
-- Находим подчинённых
SELECT t.employee_id, e.employee_id, t.level + 1, e.salary
FROM t
JOIN Employees AS e ON e.manager_id = t.reporter_id
),
levels AS (
-- Вычисляем уровень от CEO
SELECT employee_id, 1 AS level
FROM Employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, l.level + 1
FROM levels l
JOIN Employees e ON e.manager_id = l.employee_id
)
SELECT e.employee_id, e.employee_name, l.level,
COUNT(t.reporter_id) - 1 AS team_size,
SUM(t.salary) AS budget
FROM Employees e
JOIN levels l ON e.employee_id = l.employee_id
LEFT JOIN t ON t.employee_id = e.employee_id
GROUP BY e.employee_id, e.employee_name, l.level
ORDER BY e.employee_id;Этот запрос для каждого сотрудника возвращает его уровень, размер команды (исключая его самого) и общий бюджет команды (включая его зарплату).
Теперь вы знаете, как распознать иерархическую задачу и как применить рекурсивный CTE. Попробуйте решить задачу LeetCode 3482 самостоятельно, используя описанный подход. Начните с простого: постройте рекурсивный CTE, который находит всех подчинённых для одного сотрудника, затем расширьте его на всех сотрудников. Это отличная практика для закрепления материала. Удачи!
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →