В иерархических структурах регулярно возникает потребность определить ближайшего общего предка в дереве, он же наименьший общий предок (Lowest (Least) Common Ancestor).
Правда, "классические" алгоритмы для решения этой задачи работают лишь с парой узлов (раз, два, три, четыре), а мы, используя всю мощь PostgreSQL, будем решать задачу сразу для нескольких узлов.

Очевидно, что раз у нас речь идет о "деревьях" и "путях", мы никуда не денемся в нашем запросе от рекурсии. Поэтому, для дерева с картинки начало запроса будет выглядеть примерно так:
WITH RECURSIVE tree(id, pid) AS ( VALUES (1, NULL) , (2, 1) , (3, 1) , (4, 2) , (5, 2) , (6, 5) , (7, 5) )
Наивный алгоритм
Самый простой вариант, который не требует придумывания какого-то сложного алгоритма - "физически" построить пути от каждого исходного узла до корня, а затем выделить у них общую часть. Последний элемент в этой части и будет искомым LCA:

В нашем примере мы получим следующие пути:
1 - 2 - 4 1 - 2 - 5 - 6 1 - 2 - 5 - 7
Воспользуемся для их генерации рекурсией, формируя массивы из пройденных элементов:
, path AS ( SELECT ARRAY[id] p FROM tree WHERE id = ANY('{4,6,7}'::integer[]) -- отбираем исходные узлы UNION ALL SELECT array_prepend(tree.pid, p) -- новый элемент добавляем в начало FROM path , tree WHERE tree.id = path.p[1] AND -- "шагаем" от последнего добавленного узла tree.pid IS NOT NULL -- выше корня не идем )
Список узлов мы тут сразу передали строковым представлением массива, а не через IN или ARRAY, чтобы запрос легко параметризовался без необходимости "клеить" его текст.
Однако, рекурсия нам вернет вообще все пути, которые мы прошли - между каждой парой узлов:
{4} {6} {7} {2,4} {5,6} {5,7} {1,2,4} {2,5,6} {2,5,7} {1,2,5,6} {1,2,5,7}
А нам нужны только те, которые ведут в "корень" - то есть самые длинные для каждого из стартовых элементов. Оставим только их, "материализовав" длину массива с помощью LATERAL, чтобы не переписывать ее определение несколько раз:
, path2root AS ( SELECT DISTINCT ON(p[ln]) -- уникализируем по последнему (исходному) элементу p , ln FROM path , LATERAL array_length(path.p, 1) ln -- однократно вычисляем длину массива-пути ORDER BY p[ln] , ln DESC -- оставляем самые длинные )
p | ln {1,2,4} | 3 {1,2,5,6} | 4 {1,2,5,7} | 4
Ага, что-то уже начинает просматриваться!
Теперь нам осталось всего лишь найти, до какой максимальной позиции все префиксы массивов еще совпадают. Для этого переберем все индексы от 1 до длины кратчайшего из путей (логично, что общий путь не может быть длиннее кратчайшего):
, pos AS ( SELECT max(i) FROM generate_series( 1 , ( SELECT min(ln) FROM path2root ) -- длина кратчайшего пути ) i WHERE ( SELECT count(DISTINCT p[1:i]) -- путь-префикс FROM path2root ) = 1 -- "один разный" - то есть все совпадают )
Осталось лишь узнать, что за элемент стоит на этой позиции у любой из записей - раз префиксы у них всех одинаковы, то и искомый LCA - последний элемент общего префикса в любой из них:
SELECT p[(TABLE pos)] FROM path2root LIMIT 1;
Полный текст запроса
WITH RECURSIVE tree(id, pid) AS ( VALUES (1, NULL) , (2, 1) , (3, 1) , (4, 2) , (5, 2) , (6, 5) , (7, 5) ) , path AS ( SELECT ARRAY[id] p FROM tree WHERE id = ANY('{4,6,7}'::integer[]) UNION ALL SELECT array_prepend(tree.pid, p) FROM path , tree WHERE tree.id = path.p[1] AND tree.pid IS NOT NULL ) , path2root AS ( SELECT DISTINCT ON(p[ln]) p , ln FROM path , LATERAL array_length(path.p, 1) ln ORDER BY p[ln] , ln DESC ) , pos AS ( SELECT max(i) FROM generate_series( 1 , ( SELECT min(ln) FROM path2root ) ) i WHERE ( SELECT count(DISTINCT p[1:i]) FROM path2root ) = 1 ) SELECT p[(TABLE pos)] FROM path2root LIMIT 1;
Счетчик посещений
Как-то не особо легко и просто получилось у нас в предыдущем варианте...
Но нам ведь и необязательно все эти префиксы строить и получать - нам всего-то и надо, что узнать, на каком первом узле сошлись все пути к корню. Или, если определить формально, для какого узла при максимальном количестве сходящихся путей их суммарная длина минимальна:

Очевидно, что именно в LCA должны сойтись все пути, и большее значение недостижимо (откуда бы взять их еще больше?), а с каждым следующим шагом "вверх" их суммарная длина будет только увеличиваться на их количество.
Модифицируем нашу рекурсию, чтобы при проходе через узел учитывать длину пройденного пути, каждый из которых идентифицируется узлом-источником:
, path AS ( SELECT id src -- откуда вышли , id dst -- докуда уже дошли , 0 ln -- за сколько шагов FROM tree WHERE id = ANY('{4,6,7}'::integer[]) UNION ALL SELECT src , tree.pid , ln + 1 -- увеличиваем путь на 1 пройденное ребро FROM path , tree WHERE tree.id = path.dst AND -- шагаем от последнего посещенного узла tree.pid IS NOT NULL )
src | dst | ln 4 | 4 | 0 6 | 6 | 0 7 | 7 | 0 -- три исходных узла, а дальше - результат рекурсии 4 | 2 | 1 6 | 5 | 1 7 | 5 | 1 4 | 1 | 2 6 | 2 | 2 7 | 2 | 2 6 | 1 | 3 7 | 1 | 3
Теперь осталось найти узел, для которого выполнится описанное выше условие. К счастью, возможности SQL позволяют нам сделать это "в одно действие":
SELECT dst -- ищем среди всех достигнутых узлов FROM path GROUP BY 1 -- группируем по тому же dst ORDER BY count(src) DESC -- максимум количества узлов-источников , sum(ln) -- минимум суммы пройденных путей LIMIT 1;
Полный текст запроса
WITH RECURSIVE tree(id, pid) AS ( VALUES (1, NULL) , (2, 1) , (3, 1) , (4, 2) , (5, 2) , (6, 5) , (7, 5) ) , path AS ( SELECT id src , id dst , 0 ln FROM tree WHERE id = ANY('{4,6,7}'::integer[]) UNION ALL SELECT src , tree.pid , ln + 1 FROM path , tree WHERE tree.id = path.dst AND tree.pid IS NOT NULL ) SELECT dst FROM path GROUP BY 1 ORDER BY count(src) DESC , sum(ln) LIMIT 1;
Ну, уже существенно проще!
PostgreSQL 14 вступает в дело
А еще проще - можно написать?.. А почему бы и нет!
Для этого достаточно заставить PostgreSQL самостоятельно считать пройденный нами по дереву путь и его длину, воспользовавшись появившейся с версии 14 возможностью определять порядок поиска в рекурсивных запросах.
Плюсом, мы можем спокойно избавиться от условия "не ходить выше корня", поскольку лишний NULL-элемент в пути для второго алгоритма никакой погоды не делает:
, path AS ( SELECT id src , id dst FROM tree WHERE id = ANY('{4,6,7}'::integer[]) UNION ALL SELECT src , tree.pid FROM path , tree WHERE tree.id = path.dst ) SEARCH DEPTH FIRST BY dst SET path
Форма SEARCH DEPTH FIRST BY dst SET path означает, что к результату будет автоматически добавлен столбец path типа record[], в который будет помещаться "цепочка" проходимых dst:
src | dst | path 4 | 4 | {(4)} 6 | 6 | {(6)} 7 | 7 | {(7)} 4 | 2 | {(4),(2)} 6 | 5 | {(6),(5)} 7 | 5 | {(7),(5)} 4 | 1 | {(4),(2),(1)} 6 | 2 | {(6),(5),(2)} 7 | 2 | {(7),(5),(2)} 4 | | {(4),(2),(1),()} 6 | 1 | {(6),(5),(2),(1)} 7 | 1 | {(7),(5),(2),(1)} 6 | | {(6),(5),(2),(1),()} 7 | | {(7),(5),(2),(1),()}
А когда у нас есть уже готовый "путь", длину-то мы от него и сами можем взять:
SELECT dst FROM path GROUP BY 1 ORDER BY count(src) DESC , sum(array_length(path, 1)) LIMIT 1;
Итого, весь запрос, который необходим для поиска LCA, сжался до следующего вида:
WITH RECURSIVE tree(id, pid) AS ( VALUES (1, NULL) , (2, 1) , (3, 1) , (4, 2) , (5, 2) , (6, 5) , (7, 5) ) , path AS ( SELECT id src , id dst FROM tree WHERE id = ANY('{4,6,7}'::integer[]) UNION ALL SELECT src , tree.pid FROM path , tree WHERE tree.id = path.dst ) SEARCH DEPTH FIRST BY dst SET path SELECT dst FROM path GROUP BY 1 ORDER BY count(src) DESC , sum(array_length(path, 1)) LIMIT 1;
Правда, тут стоит заметить, что формирование и хранение массивов в памяти - достаточно затратная процедура. Поэтому если у вас сотни исходных узлов и дерево глубиной в десятки уровней - могут быть проблемы. Не злоупотребляйте!
