Осторожно, спойлеры! Не читайте, пока хотите решить задачу самостоятельно.

В этой челлендж-серии статей, начатой с прошлогоднего эвента, попробуем использовать PostgreSQL как среду для решения задач Advent of Code 2025.

Возможно, SQL не самый подходящий для этого язык, зато мы рассмотрим его различные возможности, о которых вы могли и не подозревать.


Оригинальная постановка задачи и ее перевод:

Advent of Code 2025, Day 8: Playground

--- День 8: Детская площадка ---

Обладая новыми знаниями в области обслуживания телепортов, вы уверенно ступаете на отремонтированную площадку телепорта.

Вы материализуетесь на незнакомой телепортационной площадке и оказываетесь в огромном подземном пространстве, где находится гигантская игровая площадка!

На другой стороне игровой площадки группа эльфов занимается установкой масштабного рождественского декора. С помощью тщательной такелажа они подвесили большое количество небольших электрических распределительных коробок.

Их план состоит в том, чтобы соединить распределительные коробки длинными гирляндами. Большинство распределительных коробок не подают электричество; однако, когда две распределительные коробки соединены гирляндой, электричество может проходить между ними.

Эльфы пытаются выяснить, какие распределительные коробки нужно соединить, чтобы электричество дошло до каждой из них. У них даже есть список всех положений распределительных коробок в трехмерном пространстве (ваш ввод в головоломку).

Например:

162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689

В этом списке указано расположение 20 распределительных коробок, по одной на каждую линию. Каждое положение указано в X,Y,Zкоординатах. Таким образом, первая распределительная коробка в списке находится по адресу X=162Y=817Z=812.

Чтобы сэкономить на гирляндах, эльфы хотели бы сосредоточиться на соединении пар распределительных коробок, расположенных как можно ближе друг к другу по прямой линии. В этом примере двумя наиболее близкими друг к другу распределительными коробками являются 162,817,812и 425,690,689.

Соединив эти две распределительные коробки вместе, поскольку между ними может протекать электрический ток, они становятся частью одной цепи. После их соединения образуется единая цепь, содержащая две распределительные коробки, а остальные 18 распределительных коробок остаются в своих собственных индивидуальных цепях.

Теперь две ближайшие друг к другу распределительные коробки, которые еще не соединены напрямую, это 162,817,812и 431,825,988. После их соединения, поскольку 162,817,812уже соединена с другой распределительной коробкой, остается одна цепь, содержащая три распределительные коробки, и еще 17 цепей, каждая из которых содержит одну распределительную коробку.

Следующие две распределительные коробки, которые необходимо соединить, это 906,360,560и 805,96,715. После их соединения получается цепь, содержащая 3 распределительные коробки, цепь, содержащая 2 распределительные коробки, и 15 цепей, каждая из которых содержит одну распределительную коробку.

Следующие две распределительные коробки — это 431,825,988и 425,690,689. Поскольку эти две распределительные коробки уже находились в одной цепи , ничего не происходит!

Этот процесс продолжается некоторое время, и эльфы обеспокоены тем, что им не хватает удлинительных кабелей для всех этих цепей. Они хотели бы узнать, какой мощности будут эти цепи.

После выполнения десяти кратчайших соединений остается 11 цепей: одна цепь, содержащая 5 распределительных коробок, одна цепь, содержащая 4 распределительные коробки, две цепи, содержащие по 2 распределительные коробки каждая, и семь цепей, каждая из которых содержит одну распределительную коробку. Перемножив размеры трех самых больших цепей (5, 4 и одной из цепей размером 2), получаем 40.

В вашем списке много распределительных коробок; соедините вместе 1000 пар распределительных коробок, расположенных ближе всего друг к другу. После этого, что получится, если перемножить размеры трех самых больших цепей?

--- Часть вторая ---

Эльфы были правы; у них определенно не хватает удлинительных кабелей. Вам придется соединять распределительные коробки между собой, пока все они не окажутся в одной большой цепи.

Продолжая приведенный выше пример, первое соединение, которое приводит к образованию единой цепи из всех распределительных коробок, находится между распределительными коробками в точках 216,146,977и 117,168,530. Эльфам необходимо знать, как далеко эти распределительные коробки находятся от стены, чтобы выбрать правильный удлинительный кабель; умножение координат X этих двух распределительных коробок ( 216и 117) дает 25272.

Продолжайте соединять ближайшие неподключенные пары распределительных коробок, пока все они не окажутся в одной цепи . Что получится, если перемножить коо��динаты X последних двух распределительных коробок, которые нужно соединить?

Часть 1

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

Сначала приведем в приемлемый вид исходные данные - преобразуем информацию о распределительных коробках в наборы их координат:

WITH RECURSIVE boxes AS(
  SELECT
    idb
  , line[1]::bigint x
  , line[2]::bigint y
  , line[3]::bigint z
  FROM
    regexp_matches($$
162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689
    $$, '(\d+),(\d+),(\d+)', 'g')
      WITH ORDINALITY T(line, idb)
)

Пронумеруем в порядке возрастания расстояния все пары соединительных коробок, для определенности принимая, что ID первой в каждой паре меньше ID второй, и оставим только первые 10 пар, которые нас и просят соединить:

, lines AS (
  SELECT
    row_number() OVER(
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) idl -- нумерация по возрастанию расстояния
  , s.idb src
  , d.idb dst
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
  ORDER BY
    idl
  LIMIT 10 -- оставляем только первые 10 самых коротких связей
)
idl | src | dst
  1 |   1 |  20
  2 |   1 |   8
  3 |   3 |  14
  4 |   8 |  20
  5 |  18 |  19
  6 |  10 |  13
  7 |  12 |  17
  8 |   3 |   9
  9 |  15 |  20
 10 |   3 |  19

Теперь нам надо связать между собой все эти линии. Но вместо того, чтобы пытаться "прицепить" каждую следующую к уже имеющимся, поступим наоборот - предположим, что у нас сначала есть все эти 10 отдельных линий, и попробуем найти среди них те, которые уже имеют точки пересечения.

Давайте сначала простроим алгоритм шага рекурсии на основе стартового состояния, а уже потом сделаем на его основе рекурсивную CTE.

Во-первых, нам понадобится "сохранить" внутри рекурсивного шага "вход", чтобы иметь возможность многократного обращения к нему:

, r AS (
  SELECT
    0 i
  , ARRAY[src, dst] chain -- стартовый шаг рекурсии - все линии как отдельные цепочки
  FROM
    lines
)
, T AS ( -- сохраняем "вход" рекурсии для многократного обращения
  TABLE r
)

Вторым шагом для каждой имеющейся цепочки соберем воедино все пересекающиеся с ней (понятно, что она сама тоже войдет в этот набор).

Заметим, что для пары пересекающихся цепочек объединенная будет сгенерирована дважды, поэтому оставим из них какую-то одну с помощью DISTINCT - ради этого мы их и упорядочивали при объединении:

, chains_pre AS (
  SELECT DISTINCT         -- оставляем по единственному экземпляру каждой цепочки
    T1.i + 1 i
  , ARRAY(                -- собираем сразу все пересекающиеся с текущей
      SELECT DISTINCT
        id
      FROM
        T T2
      , unnest(chain) id
      WHERE
        T2.chain && T1.chain
      ORDER BY
        1
    ) chain
  FROM
    T T1
)
i | chain
1 | {1,8,15,20}    -- {1,20} + {1,8} + {8,20} + {15,20}
1 | {1,8,20}       -- {1,8}  + {8,20}
1 | {3,9,14,18,19} -- {3,19} + {3,14} + {18,19} + {3,9}
1 | {3,9,14,19}    -- {3,14} + {3,9} + {3,19}
1 | {3,18,19}      -- {18,19} + {3,19}
1 | {10,13}
1 | {12,17}

Давайте оставим только те цепочки, которые не входят в какую-то из объединенных - то есть отсеются все, соединившиеся хоть с кем-то:

, chains_new AS (
  SELECT
    i
  , chain
  FROM
    chains_pre X
  WHERE
    NOT EXISTS(
      SELECT
        NULL
      FROM
        chains_pre Y
      WHERE
        X.chain <> Y.chain AND -- есть несовпадающая цепочка,
        X.chain <@ Y.chain     -- включающая эту
      LIMIT 1
    )
)
i | chain
1 | {1,8,15,20}
1 | {3,9,14,18,19}
1 | {10,13}
1 | {12,17}

Остается аккуратно собрать шаг рекурсии и условие ее продолжения - пока количество цепочек убывает, то есть хоть какая-то с какой-то объединились:

, r AS (
  SELECT
    0 i
  , ARRAY[src, dst] chain -- стартовый шаг рекурсии - все линии как отдельные цепочки
  FROM
    lines
UNION ALL
  (
    WITH T AS ( -- сохраняем "вход" рекурсии для многократного обращения
      TABLE r
    )
    , chains_pre AS (
      SELECT DISTINCT         -- оставляем по единственному экземпляру каждой цепочки
        T1.i + 1 i
      , ARRAY(                -- собираем сразу все пересекающиеся с текущей
          SELECT DISTINCT
            id
          FROM
            T T2
          , unnest(chain) id
          WHERE
            T2.chain && T1.chain
          ORDER BY
            1
        ) chain
      FROM
        T T1
    )
    , chains_new AS (
      SELECT
        i
      , chain
      FROM
        chains_pre X
      WHERE
        NOT EXISTS(
          SELECT
            NULL
          FROM
            chains_pre Y
          WHERE
            X.chain <> Y.chain AND -- есть несовпадающая цепочка,
            X.chain <@ Y.chain     -- включающая эту
          LIMIT 1
        )
    )
    SELECT
      *
    FROM
      chains_new
    WHERE
      (SELECT count(*) FROM chains_new) < (SELECT count(*) FROM T) -- цепочки убывают
  )
)

В примере потребуется всего лишь один шаг, чтобы дойти до состояния, когда никакие из цепочек не могут быть объединены:

i | chain
0 | {1,20}
0 | {1,8}
0 | {3,14}
0 | {8,20}
0 | {18,19}
0 | {10,13}
0 | {12,17}
0 | {3,9}
0 | {15,20}
0 | {3,19}
1 | {1,8,15,20}
1 | {3,9,14,18,19}
1 | {10,13}
1 | {12,17}

Остается лишь найти 3 самые длинные цепочки с последнего шага рекурсии и перемножить их длины:

SELECT
  exp(sum(ln(len)))::bigint -- произведение через сумму логарифмов
FROM
  (
    SELECT
      array_length(chain, 1) len
    FROM
      r
    WHERE
      i = (SELECT max(i) FROM r) -- последний шаг рекурсии
    ORDER BY
      len DESC                   -- самые длинные цепочки
    LIMIT 3
  ) T;
Итоговый запрос решает задачу примерно за пару секунд
WITH RECURSIVE boxes AS(
  SELECT
    idb
  , line[1]::bigint x
  , line[2]::bigint y
  , line[3]::bigint z
  FROM
    regexp_matches($$
162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689
    $$, '(\d+),(\d+),(\d+)', 'g')
      WITH ORDINALITY T(line, idb)
)
, lines AS (
  SELECT
    row_number() OVER(
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) idl -- нумерация по возрастанию расстояния
  , s.idb src
  , d.idb dst
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
  ORDER BY
    idl
  LIMIT 10 -- оставляем только первые 10 самых коротких связей
)
, r AS (
  SELECT
    0 i
  , ARRAY[src, dst] chain -- стартовый шаг рекурсии - все линии как отдельные цепочки
  FROM
    lines
UNION ALL
  (
    WITH T AS ( -- сохраняем "вход" рекурсии для многократного обращения
      TABLE r
    )
    , chains_pre AS (
      SELECT DISTINCT         -- оставляем по единственному экземпляру каждой цепочки
        T1.i + 1 i
      , ARRAY(                -- собираем сразу все пересекающиеся с текущей
          SELECT DISTINCT
            id
          FROM
            T T2
          , unnest(chain) id
          WHERE
            T2.chain && T1.chain
          ORDER BY
            1
        ) chain
      FROM
        T T1
    )
    , chains_new AS (
      SELECT
        i
      , chain
      FROM
        chains_pre X
      WHERE
        NOT EXISTS(
          SELECT
            NULL
          FROM
            chains_pre Y
          WHERE
            X.chain <> Y.chain AND -- есть несовпадающая цепочка,
            X.chain <@ Y.chain     -- включающая эту
          LIMIT 1
        )
    )
    SELECT
      *
    FROM
      chains_new
    WHERE
      (SELECT count(*) FROM chains_new) < (SELECT count(*) FROM T) -- цепочки убывают
  )
)
SELECT
  exp(sum(ln(len)))::bigint -- произведение через сумму логарифмов
FROM
  (
    SELECT
      array_length(chain, 1) len
    FROM
      r
    WHERE
      i = (SELECT max(i) FROM r) -- последний шаг рекурсии
    ORDER BY
      len DESC                   -- самые длинные цепочки
    LIMIT 3
  ) T;

Часть 2

Во второй части нас просят найти последнюю пару коробок, при соединении которых все из них окажутся связаны, поэтому алгоритм из первой части уже не подходит.

Сначала немного изменим хранение "ближайших" линий - сразу сделаем из них "цепочки":

, lines AS (
  SELECT
    row_number() OVER(
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) idl -- нумерация по возрастанию расстояния
  , ARRAY[s.idb, d.idb] line -- сразу цепочка из пары ближайших коробок
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
  ORDER BY
    idl
)

А дальше реализуем простое моделирование - то есть вот прямо будем сохранять образовавшиеся после присоединения каждой следующей линии на новом шаге рекурсии цепочки в виде массива. Поскольку каждая цепочка - сама по себе массив, то будем преобразовывать их в текстовые строки.

На каждом шаге у нас происходит следующее:

  • сохраняются все цепочки, которые не имеют с добавляемой ничего общего

  • добавляется сама входящая цепочка, если она не сцепляется ни с кем из существующих

  • а если с кем-то зацепление возникает - объединяем их все "в одну кучу"

  • когда первая (и тогда уже единственная) цепочка включает все коробки - пора останавливаться

Вот так это выглядит на SQL:

, r AS (
  SELECT
    0 i
  , '{}'::text[] chains -- стартовый шаг рекурсии - пустой набор цепочек
UNION ALL
  SELECT
    i + 1
  , T.chains
  FROM
    r
  JOIN
    lines
      ON idl = i + 1
  , LATERAL (
      WITH chains AS ( -- восстанавливаем список цепочек из массива строк
        SELECT
          string_to_array(chain, ',')::bigint[] chain
        FROM
          unnest(chains) chain
      )
      , chk AS ( -- есть ли среди существующих цепочек пересекающаяся с добавляемой?
        SELECT
          EXISTS(
            SELECT
              NULL
            FROM
              chains
            WHERE
              chain && line
            LIMIT 1
          )
      )
      SELECT
        array_agg(array_to_string(chain, ',')) chains -- преобразуем список цепочек в массив строк
      FROM
        (
          SELECT
            chain           -- все непересекающиеся с добавляемой цепочки
          FROM
            chains
          WHERE
            NOT(chain && line)
        UNION ALL
          SELECT
            line chain      -- входящая цепочка, ...
          WHERE
            NOT (TABLE chk) -- ... если она ни с кем не сцепляется
        UNION ALL
          SELECT
            ARRAY(          -- объединенная цепочка, ...
              SELECT DISTINCT
                id
              FROM
                chains
              , unnest(chain || line) id
              WHERE
                chain && line
              ORDER BY
                id
            )
          WHERE
            (TABLE chk)     -- ... когда есть с кем зацепиться
       ) T
    ) T
  WHERE
    array_length(r.chains, 1) IS NULL OR -- стартовый шаг
    array_length(string_to_array(r.chains[1], ','), 1) < (SELECT count(*) FROM boxes) -- пока не соединились все коробки
)

Для нашего примера это даст такую последовательность формирования цепочек, если вывести их вместе с добавляемой линией:

 i | line    | chains
 1 | {1,20}  | {"1,20"}
 2 | {1,8}   | {"1,8,20"}
 3 | {3,14}  | {"1,8,20"
               ,"3,14"}
 4 | {8,20}  | {"3,14"
               ,"1,8,20"}
 5 | {18,19} | {"3,14"
               ,"1,8,20"
               ,"18,19"}
 6 | {10,13} | {"3,14"
               ,"1,8,20"
               ,"18,19"
               ,"10,13"}
 7 | {12,17} | {"3,14"
               ,"1,8,20"
               ,"18,19"
               ,"10,13"
               ,"12,17"}
 8 | {3,9}   | {"1,8,20"
               ,"18,19"
               ,"10,13"
               ,"12,17"
               ,"3,9,14"}
 9 | {15,20} | {"18,19"
               ,"10,13"
               ,"12,17"
               ,"3,9,14"
               ,"1,8,15,20"}
10 | {3,19}  | {"10,13"
               ,"12,17"
               ,"1,8,15,20"
               ,"3,9,14,18,19"}
11 | {4,20}  | {"10,13"
               ,"12,17"
               ,"3,9,14,18,19"
               ,"1,4,8,15,20"}
12 | {5,7}   | {"10,13"
               ,"12,17"
               ,"3,9,14,18,19"
               ,"1,4,8,15,20"
               ,"5,7"}
13 | {5,13}  | {"12,17"
               ,"3,9,14,18,19"
               ,"1,4,8,15,20"
               ,"5,7,10,13"}
14 | {5,6}   | {"12,17"
               ,"3,9,14,18,19"
               ,"1,4,8,15,20"
               ,"5,6,7,10,13"}
15 | {7,18}  | {"12,17"
               ,"1,4,8,15,20"
               ,"3,5,6,7,9,10,13,14,18,19"}
16 | {4,8}   | {"12,17"
               ,"3,5,6,7,9,10,13,14,18,19"
               ,"1,4,8,15,20"}
17 | {9,20}  | {"12,17"
               ,"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"}
18 | {1,10}  | {"12,17"
               ,"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"}
19 | {12,16} | {"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"
               ,"12,16,17"}
20 | {14,19} | {"12,16,17"
               ,"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"}
21 | {6,9}   | {"12,16,17"
               ,"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"}
22 | {1,15}  | {"12,16,17"
               ,"1,3,4,5,6,7,8,9,10,13,14,15,18,19,20"}
23 | {9,17}  | {"1,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
24 | {2,6}   | {"1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
25 | {10,20} | {"1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
26 | {6,15}  | {"1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
27 | {9,16}  | {"1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
28 | {16,17} | {"1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20"}
29 | {11,13} | {"1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20"}

Остается лишь перемножить X-координаты входящих в последнюю добавленную линию коробок:

SELECT
  exp(sum(ln(x)))::bigint
FROM
  boxes
WHERE
  idb = ANY((
    SELECT
      line
    FROM
      lines
    WHERE
      idl = (SELECT max(i) FROM r)
  )::bigint[]);
Итоговый запрос
WITH RECURSIVE boxes AS(
  SELECT
    idb
  , line[1]::bigint x
  , line[2]::bigint y
  , line[3]::bigint z
  FROM
    regexp_matches($$
162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689
    $$, '(\d+),(\d+),(\d+)', 'g')
      WITH ORDINALITY T(line, idb)
)
, lines AS (
  SELECT
    row_number() OVER(
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) idl -- нумерация по возрастанию расстояния
  , ARRAY[s.idb, d.idb] line
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
  ORDER BY
    idl
)
, r AS (
  SELECT
    0 i
  , '{}'::text[] chains -- стартовый шаг рекурсии - пустой набор цепочек
UNION ALL
  SELECT
    i + 1
  , T.chains
  FROM
    r
  JOIN
    lines
      ON idl = i + 1
  , LATERAL (
      WITH chains AS ( -- восстанавливаем список цепочек из массива строк
        SELECT
          string_to_array(chain, ',')::bigint[] chain
        FROM
          unnest(chains) chain
      )
      , chk AS ( -- есть ли среди существующих цепочек пересекающаяся с добавляемой?
        SELECT
          EXISTS(
            SELECT
              NULL
            FROM
              chains
            WHERE
              chain && line
            LIMIT 1
          )
      )
      SELECT
        array_agg(array_to_string(chain, ',')) chains -- преобразуем список цепочек в массив строк
      FROM
        (
          SELECT
            chain           -- все непересекающиеся с добавляемой цепочки
          FROM
            chains
          WHERE
            NOT(chain && line)
        UNION ALL
          SELECT
            line chain      -- входящая цепочка, ...
          WHERE
            NOT (TABLE chk) -- ... если она ни с кем не сцепляется
        UNION ALL
          SELECT
            ARRAY(          -- объединенная цепочка, ...
              SELECT DISTINCT
                id
              FROM
                chains
              , unnest(chain || line) id
              WHERE
                chain && line
              ORDER BY
                id
            )
          WHERE
            (TABLE chk)     -- ... когда есть с кем зацепиться
       ) T
    ) T
  WHERE
    array_length(r.chains, 1) IS NULL OR -- стартовый шаг
    array_length(string_to_array(r.chains[1], ','), 1) < (SELECT count(*) FROM boxes) -- пока не соединились все коробки
)
SELECT
  exp(sum(ln(x)))::bigint
FROM
  boxes
WHERE
  idb = ANY((
    SELECT
      line
    FROM
      lines
    WHERE
      idl = (SELECT max(i) FROM r)
  )::bigint[]);

Bonus

Несложно заметить, в данном случае массу времени из почти 14 минут у нас занимают обращения к CTE lines и преобразование строк в массивы и обратно:

Давайте попробуем ускорить весь этот процесс - откажемся от пробегов по CTE и от преобразований в строки.

Массив вместо CTE

Заменим CTE на массив - для это необходимо внести правки всего в 3 местах:

, lines AS (
  SELECT
    array_agg(ARRAY[s.idb, d.idb]::text
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) lines -- порядок по возрастанию расстояния
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
)
...
  FROM
    r
  , LATERAL (
      SELECT
        lines[i + 1]::bigint[] line -- получаем i-ю линию
      FROM
        lines
    ) l
...
SELECT
  exp(sum(ln(x)))::bigint
FROM
  boxes
WHERE
  idb = ANY((
    SELECT
      lines[(SELECT max(i) FROM r)]
    FROM
      lines
  )::bigint[]);

Такая нехитрая замена сразу дает потрясающий эффект - ускорение в 15 раз!

Расширяем work_mem

Но почему у нас по-прежнему присутствует масса чтений временных файлов с диска? А потому что нам не хватило памяти на сортировку всех связей между каждой парой коробок (все-таки их почти 500K):

Sort Method: external merge  Disk: 40112kB

Нам понадобилось на сортировку ~40MB. Давайте увеличим эту память с запасом:

SET work_mem = '128MB';

И мы ускорили запрос еще в 2 раза!

Оптимизации с массивом и памятью
SET work_mem = '128MB';
-- explain (analyze, buffers, costs off)
WITH RECURSIVE boxes AS(
  SELECT
    idb
  , line[1]::bigint x
  , line[2]::bigint y
  , line[3]::bigint z
  FROM
    regexp_matches($$
162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689
    $$, '(\d+),(\d+),(\d+)', 'g')
      WITH ORDINALITY T(line, idb)
)
, lines AS (
  SELECT
    array_agg(ARRAY[s.idb, d.idb]::text
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) lines -- порядок по возрастанию расстояния
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
)
, r AS (
  SELECT
    0 i
  , '{}'::text[] chains -- стартовый шаг рекурсии - пустой набор цепочек
UNION ALL
  SELECT
    i + 1
  , T.chains
  FROM
    r
  , LATERAL (
      SELECT
        lines[i + 1]::bigint[] line -- получаем i-ю линию
      FROM
        lines
    ) l
  , LATERAL (
      WITH chains AS ( -- восстанавливаем список цепочек из массива строк
        SELECT
          string_to_array(chain, ',')::bigint[] chain
        FROM
          unnest(chains) chain
      )
      , chk AS ( -- есть ли среди существующих цепочек пересекающаяся с добавляемой?
        SELECT
          EXISTS(
            SELECT
              NULL
            FROM
              chains
            WHERE
              chain && line
            LIMIT 1
          )
      )
      SELECT
        array_agg(array_to_string(chain, ',')) chains -- преобразуем список цепочек в массив строк
      FROM
        (
          SELECT
            chain           -- все непересекающиеся с добавляемой цепочки
          FROM
            chains
          WHERE
            NOT(chain && line)
        UNION ALL
          SELECT
            line chain      -- входящая цепочка, ...
          WHERE
            NOT (TABLE chk) -- ... если она ни с кем не сцепляется
        UNION ALL
          SELECT
            ARRAY(          -- объединенная цепочка, ...
              SELECT DISTINCT
                id
              FROM
                chains
              , unnest(chain || line) id
              WHERE
                chain && line
              ORDER BY
                id
            )
          WHERE
            (TABLE chk)     -- ... когда есть с кем зацепиться
       ) T
    ) T
  WHERE
  i < 1 and
    array_length(r.chains, 1) IS NULL OR -- стартовый шаг
    array_length(string_to_array(r.chains[1], ','), 1) < (SELECT count(*) FROM boxes) -- пока не соединились все коробки
)
SELECT
  exp(sum(ln(x)))::bigint
FROM
  boxes
WHERE
  idb = ANY((
    SELECT
      lines[(SELECT max(i) FROM r)]
    FROM
      lines
  )::bigint[]);

Отказ от преобразований в текст

Осталось сделать последнее усилие - избавиться от постоянного преобразования на каждом шаге накопленных цепочек в текстовое представление и обратно. Для этого будем вместо элементов массива генерировать непосредственно строки самой CTE r.

Для этого нам всего лишь надо заменить процедуру формирования CTE chains и немного подкорректировать условие продолжения рекурсии:

, r AS (
  SELECT
    0 i
  , '{}'::bigint[] chain -- стартовый шаг рекурсии - все линии как отдельные цепочки
UNION ALL
  (
    WITH chains AS ( -- сохраняем входящий в шаг рекурсии набор цепочек
      TABLE r
    )
    SELECT
      i + 1
    , T.chain
    FROM
      (
        SELECT
          i
        , lines[i + 1]::bigint[] line      -- получаем i-ю линию как пару bigint
        FROM
          lines
        , (SELECT i FROM chains LIMIT 1) i -- извлекаем номер шага
      ) l
    , LATERAL (
        WITH chk AS ( -- есть ли среди существующих цепочек пересекающаяся с добавляемой?
          SELECT
            EXISTS(
              SELECT
                NULL
              FROM
                chains
              WHERE
                chain && line
              LIMIT 1
            )
        )
        SELECT
          *
        FROM
          (
            SELECT
              chain           -- все непересекающиеся с добавляемой цепочки
            FROM
              chains
            WHERE
              chain <> '{}' AND
              NOT(chain && line)
          UNION ALL
            SELECT
              line chain      -- входящая цепочка, ...
            WHERE
              NOT (TABLE chk) -- ... если она ни с кем не сцепляется
          UNION ALL
            SELECT
              ARRAY(          -- объединенная цепочка, ...
                SELECT DISTINCT
                  id
                FROM
                  chains
                , unnest(chain || line) id
                WHERE
                  chain && line
                ORDER BY
                  id
              )
            WHERE
              (TABLE chk)     -- ... когда есть с кем зацепиться
          ) T
        WHERE
          coalesce(array_length((SELECT chain FROM chains LIMIT 1), 1), 0) < (SELECT count(*) FROM boxes) -- пока не соединились все коробки
      ) T
  )
)
 i | chain
 0 | {}
 1 | {1,20}
 2 | {1,8,20}
 3 | {1,8,20}
 3 | {3,14}
 4 | {3,14}
 4 | {1,8,20}
 5 | {3,14}
 5 | {1,8,20}
 5 | {18,19}
 6 | {3,14}
 6 | {1,8,20}
 6 | {18,19}
 6 | {10,13}
 7 | {3,14}
 7 | {1,8,20}
 7 | {18,19}
 7 | {10,13}
 7 | {12,17}
 8 | {1,8,20}
 8 | {18,19}
 8 | {10,13}
 8 | {12,17}
 8 | {3,9,14}
 9 | {18,19}
 9 | {10,13}
 9 | {12,17}
 9 | {3,9,14}
 9 | {1,8,15,20}
10 | {10,13}
10 | {12,17}
10 | {1,8,15,20}
10 | {3,9,14,18,19}
11 | {10,13}
11 | {12,17}
11 | {3,9,14,18,19}
11 | {1,4,8,15,20}
12 | {10,13}
12 | {12,17}
12 | {3,9,14,18,19}
12 | {1,4,8,15,20}
12 | {5,7}
13 | {12,17}
13 | {3,9,14,18,19}
13 | {1,4,8,15,20}
13 | {5,7,10,13}
14 | {12,17}
14 | {3,9,14,18,19}
14 | {1,4,8,15,20}
14 | {5,6,7,10,13}
15 | {12,17}
15 | {1,4,8,15,20}
15 | {3,5,6,7,9,10,13,14,18,19}
16 | {12,17}
16 | {3,5,6,7,9,10,13,14,18,19}
16 | {1,4,8,15,20}
17 | {12,17}
17 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
18 | {12,17}
18 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
19 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
19 | {12,16,17}
20 | {12,16,17}
20 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
21 | {12,16,17}
21 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
22 | {12,16,17}
22 | {1,3,4,5,6,7,8,9,10,13,14,15,18,19,20}
23 | {1,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
24 | {1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
25 | {1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
26 | {1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
27 | {1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
28 | {1,2,3,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20}
29 | {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20}

Теперь каждая запись представляет отдельную цепочку на соответствующем шаге рекурсивного соединения.

Эта модификация дает нам не настолько большое ускорение, как предыдущие, но еще почти 20% удается отыграть:

Итоговый запрос со всеми оптимизациями
SET work_mem = '128MB';

WITH RECURSIVE boxes AS(
  SELECT
    idb
  , line[1]::bigint x
  , line[2]::bigint y
  , line[3]::bigint z
  FROM
    regexp_matches($$
162,817,812
57,618,57
906,360,560
592,479,940
352,342,300
466,668,158
542,29,236
431,825,988
739,650,466
52,470,668
216,146,977
819,987,18
117,168,530
805,96,715
346,949,466
970,615,88
941,993,340
862,61,35
984,92,344
425,690,689
    $$, '(\d+),(\d+),(\d+)', 'g')
      WITH ORDINALITY T(line, idb)
)
, lines AS (
  SELECT
    array_agg(ARRAY[s.idb, d.idb]::text
      ORDER BY sqrt(
        pow((s.x - d.x), 2) +
        pow((s.y - d.y), 2) +
        pow((s.z - d.z), 2)
      )
    ) lines -- порядок по возрастанию расстояния
  FROM
    boxes s
  JOIN
    boxes d
      ON s.idb < d.idb -- из (id1, id2) и (id2, id1) оставляем только первую
)
, r AS (
  SELECT
    0 i
  , '{}'::bigint[] chain -- стартовый шаг рекурсии - все линии как отдельные цепочки
UNION ALL
  (
    WITH chains AS ( -- сохраняем входящий в шаг рекурсии набор цепочек
      TABLE r
    )
    SELECT
      i + 1
    , T.chain
    FROM
      (
        SELECT
          i
        , lines[i + 1]::bigint[] line      -- получаем i-ю линию как пару bigint
        FROM
          lines
        , (SELECT i FROM chains LIMIT 1) i -- извлекаем номер шага
      ) l
    , LATERAL (
        WITH chk AS ( -- есть ли среди существующих цепочек пересекающаяся с добавляемой?
          SELECT
            EXISTS(
              SELECT
                NULL
              FROM
                chains
              WHERE
                chain && line
              LIMIT 1
            )
        )
        SELECT
          *
        FROM
          (
            SELECT
              chain           -- все непересекающиеся с добавляемой цепочки
            FROM
              chains
            WHERE
              chain <> '{}' AND
              NOT(chain && line)
          UNION ALL
            SELECT
              line chain      -- входящая цепочка, ...
            WHERE
              NOT (TABLE chk) -- ... если она ни с кем не сцепляется
          UNION ALL
            SELECT
              ARRAY(          -- объединенная цепочка, ...
                SELECT DISTINCT
                  id
                FROM
                  chains
                , unnest(chain || line) id
                WHERE
                  chain && line
                ORDER BY
                  id
              )
            WHERE
              (TABLE chk)     -- ... когда есть с кем зацепиться
          ) T
        WHERE
          coalesce(array_length((SELECT chain FROM chains LIMIT 1), 1), 0) < (SELECT count(*) FROM boxes) -- пока не соединились все коробки
      ) T
  )
)
SELECT
  exp(sum(ln(x)))::bigint
FROM
  boxes
WHERE
  idb = ANY((
    SELECT
      lines[(SELECT max(i) FROM r)]
    FROM
      lines
  )::bigint[]);

В общем-то, у нас осталось последнее "слабое звено" - обращения к массиву lines из 500K элементов, которые... совсем небыстрые (4711 обращений заняли 17683.342ms, или 3.75ms на каждое!).

Но тут пора остановиться и идти готовить салатики. С наступающим Новым годом!