В практике разработки на платформе 1С часто встречается задача разузлования или поиска всех связанных элементов. Классический пример: справочник ссылается сам на себя через реквизит, образуя ориентированный граф или цепочку вида Э1 → Э2 → Э3 → ... → ЭN. Подобные структуры применяются при описании спецификаций номенклатуры, технологических карт, этапов бизнес-процессов или маршрутов согласования.
Перед разработчиком встает вопрос: можно ли получить всю цепочку подчиненности одним запросом 1С, как обойти возможные зацикливания и какой подход обеспечит максимальное быстродействие на больших объемах данных? Разберем эту задачу детально, сопоставим теоретические возможности языка запросов и эффективные алгоритмы обработки данных в оперативной памяти.
Выясним причину, почему классический «один запрос» сталкивается с фундаментальными трудностями. Язык запросов 1С не поддерживает стандартные рекурсивные обобщенные табличные выражения (конструкции вида WITH RECURSIVE), которые присутствуют в некоторых СУБД. Конструкции языка В ИЕРАРХИИ и ИТОГИ ПО ... ИЕРАРХИЯ работают исключительно со стандартным системным реквизитом Родитель в иерархических справочниках. Если связь реализована через произвольный реквизит (например, Элемент) или отношение представляет собой ориентированный граф (у одного узла может быть несколько родителей и потомков), стандартная иерархия платформы оказывается неприменимой.
Рассмотрим математический подход, позволяющий решить задачу на стороне СУБД с помощью формирования пакетного запроса. Этот алгоритм строит транзитивное замыкание графа методом возведения матрицы смежности в степень за счет последовательного удвоения длины пути. За $k$ итераций соединения таблицы самой с собой мы можем охватить путь длиной до $2^k$ узлов.
Разберем по шагам программное формирование такого пакетного запроса:
Посмотрим на пример реализации данного алгоритма:
// Определяем максимальную длину цепочки связей
ЗапросКоличество = Новый Запрос(
"ВЫБРАТЬ КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Справочник.Ссылка) КАК ЧислоВершин
|ИЗ Справочник.Справочник КАК Справочник"
);
РезультатКоличество = ЗапросКоличество.Выполнить().Выгрузить();
МаксимальнаяДлинаПути = РезультатКоличество[0].ЧислоВершин;
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = Новый МенеджерВременныхТаблиц;
// Формируем начальный набор связей (длина пути = 1)
Пролог =
"ВЫБРАТЬ РАЗЛИЧНЫЕ
| Справочник.Ссылка КАК НачалоДуги,
| Справочник.Ссылка КАК КонецДуги
|ПОМЕСТИТЬ ЗамыканияДлины1
|ИЗ
| Справочник.Справочник КАК Справочник
|ОБЪЕДИНИТЬ
|ВЫБРАТЬ
| Справочник.Ссылка,
| Справочник.Элемент
|ИЗ
| Справочник.Справочник КАК Справочник
|;
|";
// Шаблон итерации: соединение пар для удвоения длины пути
Рефрен =
"ВЫБРАТЬ РАЗЛИЧНЫЕ
| ПерваяДуга.НачалоДуги,
| ВтораяДуга.КонецДуги
|ПОМЕСТИТЬ ЗамыканияДлины#2
|ИЗ
| ЗамыканияДлины#1 КАК ПерваяДуга
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ ЗамыканияДлины#1 КАК ВтораяДуга
| ПО ПерваяДуга.КонецДуги = ВтораяДуга.НачалоДуги
|;
|УНИЧТОЖИТЬ ЗамыканияДлины#1
|;
|";
Эпилог =
"ВЫБРАТЬ
| Дуга.КонецДуги КАК Элемент
|ИЗ
| ЗамыканияДлины#2 КАК Дуга
|ГДЕ
| Дуга.НачалоДуги = &Ссылка";
Запрос.Текст = Пролог;
МаксимальнаяДлинаЗамыканий = 1;
Пока МаксимальнаяДлинаЗамыканий < МаксимальнаяДлинаПути Цикл
ТекстИтерации = СтрЗаменить(Рефрен, "#1", Формат(МаксимальнаяДлинаЗамыканий, "ЧГ=0"));
ТекстИтерации = СтрЗаменить(ТекстИтерации, "#2", Формат(2 * МаксимальнаяДлинаЗамыканий, "ЧГ=0"));
Запрос.Текст = Запрос.Текст + ТекстИтерации;
МаксимальнаяДлинаЗамыканий = 2 * МаксимальнаяДлинаЗамыканий;
КонецЦикла;
Запрос.Текст = Запрос.Текст + СтрЗаменить(Эпилог, "#2", Формат(МаксимальнаяДлинаЗамыканий, "ЧГ=0"));
Запрос.УстановитьПараметр("Ссылка", ИсходныйЭлемент);
Результат = Запрос.Выполнить().Выгрузить();
Проанализируем эффективность этого решения. Декларативно код выглядит компактно, однако при большом числе вершин алгоритм соединения временных таблиц в базе данных порождает колоссальную нагрузку. В наихудшем случае сложность внутренних соединений растет как $O(N^4)$, а постоянная запись промежуточных таблиц во временную базу СУБД (TempDB) приводит к дисковым задержкам. Данный способ подходит только для небольших графов (до нескольких сотен вершин).
Рассмотрим альтернативный и значительно более производительный способ. Вместо генерации множества запросов или тяжелых соединений на стороне СУБД мы выполняем ровно один простой запрос, получающий весь список связей, выгружаем его в память и индексируем.
Разберем по шагам алгоритм последовательного обхода дерева/графа в ширину (BFS):
Ссылка и Элемент.ТаблицаЗначений и строим индекс по исходному полю с помощью метода Таблица.Индексы.Добавить(). Поиск строк через НайтиСтроки() по индексированной колонке отрабатывает практически мгновенно.СписокЗначений или массив в качестве очереди обхода и хранилища результата.Посмотрим на пример кода:
Запрос = Новый Запрос(
"ВЫБРАТЬ
| Справочник.Ссылка КАК Ссылка,
| Справочник.Элемент КАК Элемент
|ИЗ
| Справочник.Справочник КАК Справочник
|ГДЕ
| Справочник.Элемент <> ЗНАЧЕНИЕ(Справочник.Справочник.ПустаяСсылка)"
);
Дуги = Запрос.Выполнить().Выгрузить();
Дуги.Индексы.Добавить("Ссылка");
Результат = Новый СписокЗначений;
Результат.Добавить(ИсходныйЭлемент);
Отбор = Новый Структура("Ссылка");
Указатель = 0;
Пока Указатель < Результат.Количество() Цикл
ТекущийУзел = Результат[Указатель].Значение;
Отбор.Ссылка = ТекущийУзел;
НайденныеДуги = Дуги.НайтиСтроки(Отбор);
Для Каждого СтрДуга Из НайденныеДуги Цикл
// Контроль зацикливания: добавляем только те узлы, которых еще нет в цепочке
Если Результат.НайтиПоЗначению(СтрДуга.Элемент) = Неопределено Тогда
Результат.Добавить(СтрДуга.Элемент);
КонецЕсли;
КонецЦикла;
Указатель = Указатель + 1;
КонецЦикла;
Такой подход обрабатывает графы в сотни тысяч узлов за считанные секунды, так как основная работа выполняется в оперативной памяти без повторных обращений к СУБД.
В реальных задачах разузлования (например, при расчете состава изделий в производстве) граф часто представляет собой ориентированный ациклический граф (DAG). Один и тот же полуфабрикат или узел может входить в состав десятка различных сборок на разных уровнях иерархии. Если раскручивать каждую ветку «в лоб», возникает комбинаторный взрыв, и число просматриваемых путей стремительно растет.
Проанализируем оптимизацию: чтобы избежать повторного обхода одних и тех же ветвей, необходимо запоминать уже рассчитанный результат разузлования каждого узла (мемоизация). Для ускорения поиска и контроля циклов вместо линейного поиска следует использовать объект Соответствие, обеспечивающий время поиска $O(1)$.
Посмотрим, как организовать процедуру обхода с контролем зацикливания:
// СтекТекущейВетки - Соответствие: хранит вершины текущего пути для отслеживания циклов
// КэшРазузлования - Соответствие: Ключ - Узел, Значение - Таблица материалов
Функция РазузловатьВетку(ТекущийУзел, ТаблицаСвязей, СтекТекущейВетки, КэшРазузлования)
// Проверка зацикливания в текущей ветке
Если СтекТекущейВетки[ТекущийУзел] <> Неопределено Тогда
ВызватьИсключение "Обнаружен цикл в графе для узла: " + Строка(ТекущийУзел);
КонецЕсли;
// Проверка наличия уже готового результата в кэше
Если КэшРазузлования[ТекущийУзел] <> Неопределено Тогда
Возврат КэшРазузлования[ТекущийУзел];
КонецЕсли;
СтекТекущейВетки.Вставить(ТекущийУзел, Истина);
СтрокиПодчиненных = ТаблицаСвязей.НайтиСтроки(Новый Структура("Владелец", ТекущийУзел));
ИтоговыйСостав = Новый ТаблицаЗначений;
ИтоговыйСостав.Колонки.Добавить("Номенклатура");
ИтоговыйСостав.Колонки.Добавить("Количество");
Для Каждого Стр Из СтрокиПодчиненных Цикл
// Рекурсивно получаем состав подчиненного элемента
СоставДочернего = РазузловатьВетку(Стр.Номенклатура, ТаблицаСвязей, СтекТекущейВетки, КэшРазузлования);
Если СоставДочернего.Количество() = 0 Тогда
// Листовой элемент (материал)
Нов = ИтоговыйСостав.Добавить();
Нов.Номенклатура = Стр.Номенклатура;
Нов.Количество = Стр.Количество;
Иначе
// Добавляем узлы с учетом коэффициента вхождения
Для Каждого СтрПодч Из СоставДочернего Цикл
Нов = ИтоговыйСостав.Добавить();
Нов.Номенклатура = СтрПодч.Номенклатура;
Нов.Количество = СтрПодч.Количество * Стр.Количество;
КонецЦикла;
КонецЕсли;
КонецЦикла;
// Сворачиваем одинаковые материалы
ИтоговыйСостав.Свернуть("Номенклатура", "Количество");
// Снимаем узел со стека текущего пути и помещаем в глобальный кэш
СтекТекущейВетки.Удалить(ТекущийУзел);
КэшРазузлования.Вставить(ТекущийУзел, ИтоговыйСостав);
Возврат ИтоговыйСостав;
КонецФункции
Если задача разузлования является регулярной операцией в системе, имеет смысл пересмотреть способ хранения связей:
Предок, Потомок и ресурсом Глубина. Все связи пересчитываются один раз при записи элемента или спецификации. В этом случае получение всего дерева подчиненности любой глубины решается тривиальным запросом: ГДЕ Предок = &ВыбранныйУзел.