Как стать автором
Обновить
153.03
Тензор
Разработчик системы СБИС

SQL HowTo: ближайший общий предок в дереве (LCA)

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров4.4K

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

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

Для узлов 4, 6 и 7 ближайшим общим предком будет 2
Для узлов 4, 6 и 7 ближайшим общим предком будет 2

Очевидно, что раз у нас речь идет о "деревьях" и "путях", мы никуда не денемся в нашем запросе от рекурсии. Поэтому, для дерева с картинки начало запроса будет выглядеть примерно так:

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;

Правда, тут стоит заметить, что формирование и хранение массивов в памяти - достаточно затратная процедура. Поэтому если у вас сотни исходных узлов и дерево глубиной в десятки уровней - могут быть проблемы. Не злоупотребляйте!

Теги:
Хабы:
Всего голосов 13: ↑13 и ↓0+13
Комментарии4

Публикации

Информация

Сайт
sbis.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия