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

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

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


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

Advent of Code 2024, Day 16: Reindeer Maze

--- День 16: Лабиринт оленей ---

Пришло время для Олимпиады оленей! В этом году главным событием станет Лабиринт оленей, где олени соревнуются за наименьшее количество очков.

Вы и Историки прибываете на поиски Шефа прямо в тот момент, когда событие вот-вот начнется. Не помешало бы немного понаблюдать, правда?

Олени начинают на стартовой плитке (отмеченной S) лицом на восток и должны достичь конечной плитки (отмеченной E). Они могут двигаться вперед на одну плитку за раз (увеличивая свой счет на 1 очко), но никогда не врезаются в стену (#). Они также могут поворачиваться по часовой стрелке или против часовой стрелки на 90 градусов за раз (увеличивая свой счет на 1000 очков).

Чтобы найти лучшее место для сидения, вы начинаете с того, что берете карту (ваши входные данные для головоломки) в ближайшем киоске. Например:

###############
#.......#....E#
#.#.###.#.###.#
#.....#.#...#.#
#.###.#####.#.#
#.#.#.......#.#
#.#.#####.###.#
#...........#.#
###.#.#####.#.#
#...#.....#.#.#
#.#.#.###.#.#.#
#.....#...#.#.#
#.###.#.#.#.#.#
#S..#.....#...#
###############

В этом лабиринте есть много путей, но выбор любого из лучших путей принесет вам всего 7036. Этого можно достичь, сделав 36 шагов вперед и повернувшись на 90 градусов 7 раз:


###############
#.......#....E#
#.#.###.#.###^#
#.....#.#...#^#
#.###.#####.#^#
#.#.#.......#^#
#.#.#####.###^#
#..>>>>>>>>v#^#
###^#.#####v#^#
#>>^#.....#v#^#
#^#.#.###.#v#^#
#^....#...#v#^#
#^###.#.#.#v#^#
#S..#.....#>>^#
###############

Вот второй пример:

#################
#...#...#...#..E#
#.#.#.#.#.#.#.#.#
#.#.#.#...#...#.#
#.#.#.#.###.#.#.#
#...#.#.#.....#.#
#.#.#.#.#.#####.#
#.#...#.#.#.....#
#.#.#####.#.###.#
#.#.#.......#...#
#.#.###.#####.###
#.#.#...#.....#.#
#.#.#.#####.###.#
#.#.#.........#.#
#.#.#.#########.#
#S#.............#
#################

В этом лабиринте лучшие пути стоят 11048 очков; следование одному из таких путей будет выглядеть так:

#################
#...#...#...#..E#
#.#.#.#.#.#.#.#^#
#.#.#.#...#...#^#
#.#.#.#.###.#.#^#
#>>v#.#.#.....#^#
#^#v#.#.#.#####^#
#^#v..#.#.#>>>>^#
#^#v#####.#^###.#
#^#v#..>>>>^#...#
#^#v###^#####.###
#^#v#>>^#.....#.#
#^#v#^#####.###.#
#^#v#^........#.#
#^#v#^#########.#
#S#>>^..........#
#################

Обратите внимание, что показанный выше путь включает в себя один поворот на 90 градусов в качестве самого первого движения, поворачивая оленя с востока на север.

Внимательно проанализируйте свою карту. Какую наименьшую оценку может получить олень?

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

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

Каждая клетка без стены (S., или E) снабжена местами для сидения по краям. Хотя определение того, какая из этих плиток будет лучшим местом для сидения, зависит от целого ряда факторов (насколько удобны сиденья, как далеко находятся ванные комнаты, есть ли колонна, закрывающая обзор и т. д.), самым важным фактором является то, находится ли плитка на одном из лучших путей через лабиринт. Если вы сядете в другом месте, вы пропустите все действие!

Итак, вам нужно будет определить, какие плитки являются частью наилучшего пути через лабиринт, включая плитки S и E.

В первом примере есть 45 таких плиток (отмеченны O), которые являются частью по крайней мере одного из различных наилучших путей через лабиринт:

###############
#.......#....O#
#.#.###.#.###O#
#.....#.#...#O#
#.###.#####.#O#
#.#.#.......#O#
#.#.#####.###O#
#..OOOOOOOOO#O#
###O#O#####O#O#
#OOO#O....#O#O#
#O#O#O###.#O#O#
#OOOOO#...#O#O#
#O###.#.#.#O#O#
#O..#.....#OOO#
###############

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

#################
#...#...#...#..O#
#.#.#.#.#.#.#.#O#
#.#.#.#...#...#O#
#.#.#.#.###.#.#O#
#OOO#.#.#.....#O#
#O#O#.#.#.#####O#
#O#O..#.#.#OOOOO#
#O#O#####.#O###O#
#O#O#..OOOOO#OOO#
#O#O###O#####O###
#O#O#OOO#..OOO#.#
#O#O#O#####O###.#
#O#O#OOOOOOO..#.#
#O#O#O#########.#
#O#OOO..........#
#################

Проанализируйте карту еще раз. Сколько плиток входят в состав хотя бы одного из лучших путей через лабиринт?

Часть 1

Давайте начнем решение с очевидной части - уже привычного по предыдущим задачам серии преобразования входных данных в матрицу:

WITH RECURSIVE matrix AS MATERIALIZED (
  SELECT
    array_agg(regexp_split_to_array(line[1], '')) m
  FROM
    regexp_matches(
      $$
###############
#.......#....E#
#.#.###.#.###.#
#.....#.#...#.#
#.###.#####.#.#
#.#.#.......#.#
#.#.#####.###.#
#...........#.#
###.#.#####.#.#
#...#.....#.#.#
#.#.#.###.#.#.#
#.....#...#.#.#
#.###.#.#.#.#.#
#S..#.....#...#
###############
$$
    , '(?:^|[\r\n])(#[^\r\n]*#)(?=$|[\r\n])'
    , 'g'
    )
      WITH ORDINALITY y(line, y)
)

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

Модель попыток движения
Модель попыток движения
, r AS (
  SELECT
    x
  , y
  , '(1,0)'::point d -- вначале олень стоит "на восток", то есть вправо
  , 0 score
  FROM
    matrix
  , generate_subscripts(m, 1) y
  , generate_subscripts(m, 2) x
  WHERE
    m[y][x] = 'S'    -- находим стартовую клетку
UNION ALL
  SELECT
    T.*
  FROM
    r
  , matrix
  , LATERAL (
      SELECT
        pn[0]::integer x
      , pn[1]::integer y
      , dn d
      , r.score + 1 + 1000 * (v[1] <> 0)::integer -- при повороте - +1000
      FROM
        point(x, y) p
      , LATERAL ( -- "шагаем" в трех направлениях сразу
          SELECT
            v            -- вектор изменения направления
          , d * v dn     -- новое направление
          , p + d * v pn -- новая позиция
          FROM
            unnest(ARRAY[
              '(0, -1)' -- поворот налево
            , '(+1, 0)' -- движение прямо - сохранение текущего направления
            , '(0, +1)' -- поворот направо
            ]::point[]) v
        ) v
      WHERE
        m[pn[1]::integer][pn[0]::integer] <> '#' -- в новой позиции не должно быть стены
    ) T
  WHERE
    m[r.y][r.x] <> 'E'
) CYCLE x, y SET is_cycle USING path -- блокируем зачикливание

... и найдем среди всех приводящих в конечную точку минимальную стоимость:

SELECT
  min(score)
FROM
  r
, matrix
WHERE
  m[y][x] = 'E';

В принципе, это вполне нормальное решение для небольших лабиринтов, а вот для больших... В общем, даже за час оно не досчитывается.

Оптимизируем перебор узлов

У алгоритма выше ровно три проблемы:

  • в каждой точке ветвления, откуда можно пойти дальше несколькими способами, количество путей умножается на 2 или 3 - то есть возрастает экспоненциально

  • в рамках защиты от зацикливания CYCLE ... USING path в этом столбце PostgreSQL записывает все-все клетки, которые мы прошли, даже если они находились "в тоннеле", и никакого ветвления там быть не могло

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

Во-первых, заметим, что при таких величинах (1 за шаг "прямо" и 1000 за поворот) мы можем свести задачу к "за какое минимальное количество поворотов" можно достигнуть конечной точки.

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

###############
#X.X...X#X...X#
#.#.###.#.###.#
#X.X.X#.#X.X#.#
#.###.#####.#.#
#.#.#X...X.X#.#
#.#.#####.###.#
#X.X.X...X.X#.#
###.#.#####.#.#
#X.X#X...X#.#.#
#.#.#.###.#.#.#
#X.X.X#X.X#.#.#
#.###.#.#.#.#.#
#X..#X.X.X#X.X#
###############

Как можно заметить, таких точек существенно меньше всех доступных для прохода.

Давайте отберем сначала начальную и конечную точки, даже если в них нет разветвлений, все стены, и проходы вне "тоннеля" (то есть есть свободная клетка и слева или справа, и сверху или снизу).

Отбросим все "стеновые" клетки, сосчитав их количество слева и сверху до каждой интересующей нас проходимой клетки:

, poi AS MATERIALIZED (
  SELECT
    x
  , y
  , grpx
  , grpy
  FROM
    (
      SELECT
        *
      , sum(wall::integer) OVER(PARTITION BY x ORDER BY y) grpx -- группа по X
      , sum(wall::integer) OVER(PARTITION BY y ORDER BY x) grpy -- группа по Y
      FROM
        (
          SELECT
            x
          , y
          , m[y][x] = '#' wall
          FROM
            matrix
          , generate_subscripts(m, 1) y
          , generate_subscripts(m, 2) x
          WHERE
            m[y][x] IN ('S', 'E', '#') OR -- отбираем старт, финиш и все стены
            (
              m[y][x] = '.' AND -- проходные клетки отбираем только вне тоннеля
              (m[y][x - 1] <> '#' OR m[y][x + 1] <> '#') AND
              (m[y - 1][x] <> '#' OR m[y + 1][x] <> '#')
            )
        ) T
    ) T
  WHERE
    NOT wall -- стены нам не нужны
)
группы по X     | группы по Y
###############   ###############
#1.1...1#2...2#   #1.1...1#1...1#
#.#.###.#.###.#   #.#.###.#.###.#
#1.1.1#.#3.3#.#   #1.1.2#.#1.2#.#
#.###.#####.#.#   #.###.#####.#.#
#.#.#3...3.3#.#   #.#.#2...2.2#.#
#.#.#####.###.#   #.#.#####.###.#
#1.1.1...1.1#.#   #1.2.3...2.3#.#
###.#.#####.#.#   ###.#.#####.#.#
#1.1#2...2#.#.#   #2.2#3...3#.#.#
#.#.#.###.#.#.#   #.#.#.###.#.#.#
#1.1.1#2.2#.#.#   #2.2.3#5.3#.#.#
#.###.#.#.#.#.#   #.###.#.#.#.#.#
#1..#2.2.2#2.2#   #2..#3.5.3#3.1#
###############   ###############

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

Соберем для каждой клетки массивы координат клеток, куда из нее можно попасть по той же горизонтальной/вертикальной линии, не пересекая стен (исключив ее саму функцией array_remove, конечно):

, stepline AS MATERIALIZED (
  SELECT
    x sx
  , y sy
  , array_remove(array_agg(x) OVER(PARTITION BY y, grpy), x) _tx
  , array_remove(array_agg(y) OVER(PARTITION BY x, grpx), y) _ty
  FROM
    poi
)
sx | sy | _tx         | _ty
 2 |  2 | {4,8}       | {8,4}
 2 |  4 | {4,6}       | {2,8}
 2 |  8 | {12,4,6,10} | {2,4}
 2 | 10 | {4}         | {12,14}
 2 | 12 | {6,4}       | {10,14}
 2 | 14 | {}          | {10,12}     -- отсюда некуда ходить по горизонтали
 4 |  2 | {2,8}       | {4}
 4 |  4 | {2,6}       | {2}
 4 |  8 | {12,2,6,10} | {12,10}
 4 | 10 | {2}         | {12,8}
 4 | 12 | {6,2}       | {8,10}
 6 |  4 | {4,2}       | {6}
 6 |  6 | {10,12}     | {4}
 6 |  8 | {12,4,2,10} | {12,14,10}
 6 | 10 | {10}        | {12,14,8}
 6 | 12 | {2,4}       | {14,8,10}
 6 | 14 | {10,8}      | {12,8,10}
 8 |  2 | {2,4}       | {}          -- а отсюда - по вертикали
 8 | 12 | {10}        | {14}
 8 | 14 | {10,6}      | {12}
10 |  2 | {14}        | {4}
10 |  4 | {12}        | {2}
10 |  6 | {12,6}      | {8}
10 |  8 | {12,4,2,6}  | {6}
10 | 10 | {6}         | {14,12}
10 | 12 | {8}         | {14,10}
10 | 14 | {6,8}       | {10,12}
12 |  4 | {10}        | {6}
12 |  6 | {10,6}      | {4}
12 |  8 | {4,2,6,10}  | {14}
12 | 14 | {14}        | {8}
14 |  2 | {10}        | {14}
14 | 14 | {12}        | {2}

Сформируем теперь все возможные прямые отрезки пути, вычислим их "стоимость" и результирующее направление движения от точки к точке (чтобы в дальнейшем было удобнее работать с ними, превратим координаты клеток в text):

, segline AS MATERIALIZED (
  SELECT
    point(sx, sy)::text s        -- текстовое представление координат ячеек
  , point(tx, ty)::text t
  , CASE                         -- направление движения
      WHEN tx > sx THEN '(1,0)'  -- вправо
      WHEN tx < sx THEN '(-1,0)' -- влево
      WHEN ty > sy THEN '(0,1)'  -- вниз
      WHEN ty < sy THEN '(0,-1)' -- вверх
    END d
  , abs(tx - sx) + abs(ty - sy) score -- "стоимость" сегмента - разность координат
  FROM
    (
      SELECT
        sx
      , sy
      , unnest(_tx) tx -- возможные шаги по горизонтали
      , sy ty
      FROM
        stepline
    UNION ALL
      SELECT
        sx
      , sy
      , sx sy
      , unnest(_ty) ty -- возможные шаги по вертикали
      FROM
        stepline
    ) T
)

Для наших путей важно не только "где" мы стоим, но и "как" - то есть в какую сторону смотрим. Давайте соберем все возможные пары состояний, которые могут быть достигнуты "за одно действие" - либо поворот "за 1000", либо перемещение по прямой к какой-то из соседних узловых клеток:

, dir AS MATERIALIZED (
  SELECT
    unnest(ARRAY[ -- направления "смотрения"
      '(1,0)'  -- вправо
    , '(0,1)'  -- вниз
    , '(-1,0)' -- влево
    , '(0,-1)' -- вверх
    ]::point[]) d
)
, onestep AS MATERIALIZED (
  SELECT
    point(x, y)::text s
  , ds.d::text ds
  , point(x, y)::text t
  , dt.d::text dt
  , 1000 score -- поворот на месте "за 1000"
  FROM
    poi
  , dir ds
  , dir dt
  WHERE
    dt.d[0] <> ds.d[0] AND -- это поворот, не разворот на 180
    dt.d[1] <> ds.d[1]
UNION ALL
  SELECT
    s
  , d ds
  , t
  , d dt
  , score     -- шаг по прямой к соседнему узлу
  FROM
    segline
)

Чтобы не обращаться каждый раз к матрице, вычислим заранее за один проход позиции начальной и конечной клеток, воспользовавшись min(...) FILTER(...) для определения каждой из координат:

, init AS MATERIALIZED (
  SELECT
    point(
      min(x) FILTER(WHERE m[y][x] = 'S')
    , min(y) FILTER(WHERE m[y][x] = 'S')
    )::text s -- координаты стартовой клетки
  , point(
      min(x) FILTER(WHERE m[y][x] = 'E')
    , min(y) FILTER(WHERE m[y][x] = 'E')
    )::text e -- координаты конечной клетки
  FROM
    matrix
  , generate_subscripts(m, 1) y
  , generate_subscripts(m, 2) x
  WHERE
    m[y][x] IN ('S', 'E')
)

Теперь у нас все готово к формированию рекурсивного шага:

  1. Если на предыдущем шаге мы не шли прямо, а поворачивались, то второй раз делать это снова не имеет смысла - запомним признак поворота в столбце turn.

  2. Вместо перебора вообще всех путей мы на каждом шаге будем уникализировать их в разрезе последнего достигнутого состояния.

  3. После вычисления всех новых путей с помощью bool_or(p = e) OVER() проставляем сразу на всех записях шага признак финальности.

, r AS (
  SELECT
    e                         -- протаскиваем целевую точку сквозь все шаги
  , s p                       -- текущая точка - стартовая
  , '(1,0)' d                 -- исходно смотрим направо
  , 0::bigint score           -- накопленная стоимость пути
  , FALSE turn                -- признак поворота на предыдущем шаге
  , ARRAY[s || ':(1,0)'] path -- уже достигнутый путь
  , FALSE fin                 -- признак достижения конечной точки на данном шаге
  FROM
    init
UNION ALL
  (
    WITH tmp AS (
      SELECT DISTINCT ON (state) -- уникализируем пути по достигнутому состоянию
        e
      , os.t p                   -- новая достигнутая точка
      , os.dt d                  -- новое направление
      , r.score + os.score score -- "стоимость" уже пройденного пути
      , os.t = os.s turn         -- признак поворота
      , r.path || state path
      FROM
        r
      JOIN
        onestep os
          ON (os.s, os.ds) = (r.p, r.d)
      , LATERAL (
          SELECT os.t || ':' || os.dt state -- состояние - это "куда пришли" + "куда смотрим"
        ) T
      WHERE
        p <> e AND             -- пока не пришли куда надо
        state <> ALL(path) AND -- исключаем зацикливание пути
        ( -- если на предыдущем шаге поворачивались, то на этом надо "шагать"
          NOT turn OR
          os.t <> os.s
        ) AND
        NOT fin                -- выход из рекурсии - достижение конечной точки
      ORDER BY
        state, r.score + os.score
    )
    SELECT
      *
    , bool_or(p = e) OVER() fin -- признак достижения конечной точки
    FROM
      tmp
  )
)

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

WITH RECURSIVE matrix AS MATERIALIZED (
  SELECT
    array_agg(regexp_split_to_array(line[1], '')) m
  FROM
    regexp_matches(
      $$
###############
#.......#....E#
#.#.###.#.###.#
#.....#.#...#.#
#.###.#####.#.#
#.#.#.......#.#
#.#.#####.###.#
#...........#.#
###.#.#####.#.#
#...#.....#.#.#
#.#.#.###.#.#.#
#.....#...#.#.#
#.###.#.#.#.#.#
#S..#.....#...#
###############
$$
    , '(?:^|[\r\n])(#[^\r\n]*#)(?=$|[\r\n])'
    , 'g'
    )
      WITH ORDINALITY y(line, y)
)
, poi AS MATERIALIZED (
  SELECT
    x
  , y
  , grpx
  , grpy
  FROM
    (
      SELECT
        *
      , sum(wall::integer) OVER(PARTITION BY x ORDER BY y) grpx -- группа по X
      , sum(wall::integer) OVER(PARTITION BY y ORDER BY x) grpy -- группа по Y
      FROM
        (
          SELECT
            x
          , y
          , m[y][x] = '#' wall
          FROM
            matrix
          , generate_subscripts(m, 1) y
          , generate_subscripts(m, 2) x
          WHERE
            m[y][x] IN ('S', 'E', '#') OR -- отбираем старт, финиш и все стены
            (
              m[y][x] = '.' AND -- проходные клетки отбираем только вне тоннеля
              (m[y][x - 1] <> '#' OR m[y][x + 1] <> '#') AND
              (m[y - 1][x] <> '#' OR m[y + 1][x] <> '#')
            )
        ) T
    ) T
  WHERE
    NOT wall -- стены нам не нужны
)
, stepline AS MATERIALIZED (
  SELECT
    x sx
  , y sy
  , array_remove(array_agg(x) OVER(PARTITION BY y, grpy), x) _tx
  , array_remove(array_agg(y) OVER(PARTITION BY x, grpx), y) _ty
  FROM
    poi
)
, segline AS MATERIALIZED (
  SELECT
    point(sx, sy)::text s        -- текстовое представление координат ячеек
  , point(tx, ty)::text t
  , CASE                         -- направление движения
      WHEN tx > sx THEN '(1,0)'  -- вправо
      WHEN tx < sx THEN '(-1,0)' -- влево
      WHEN ty > sy THEN '(0,1)'  -- вниз
      WHEN ty < sy THEN '(0,-1)' -- вверх
    END d
  , abs(tx - sx) + abs(ty - sy) score -- "стоимость" сегмента - разность координат
  FROM
    (
      SELECT
        sx
      , sy
      , unnest(_tx) tx -- возможные шаги по горизонтали
      , sy ty
      FROM
        stepline
    UNION ALL
      SELECT
        sx
      , sy
      , sx sy
      , unnest(_ty) ty -- возможные шаги по вертикали
      FROM
        stepline
    ) T
)
, dir AS MATERIALIZED (
  SELECT
    unnest(ARRAY[ -- направления "смотрения"
      '(1,0)'  -- вправо
    , '(0,1)'  -- вниз
    , '(-1,0)' -- влево
    , '(0,-1)' -- вверх
    ]::point[]) d
)
, onestep AS MATERIALIZED (
  SELECT
    point(x, y)::text s
  , ds.d::text ds
  , point(x, y)::text t
  , dt.d::text dt
  , 1000 score -- поворот на месте "за 1000"
  FROM
    poi
  , dir ds
  , dir dt
  WHERE
    dt.d[0] <> ds.d[0] AND -- это поворот, не разворот на 180
    dt.d[1] <> ds.d[1]
UNION ALL
  SELECT
    s
  , d ds
  , t
  , d dt
  , score     -- шаг по прямой к соседнему узлу
  FROM
    segline
)
, init AS MATERIALIZED (
  SELECT
    point(
      min(x) FILTER(WHERE m[y][x] = 'S')
    , min(y) FILTER(WHERE m[y][x] = 'S')
    )::text s -- координаты стартовой клетки
  , point(
      min(x) FILTER(WHERE m[y][x] = 'E')
    , min(y) FILTER(WHERE m[y][x] = 'E')
    )::text e -- координаты конечной клетки
  FROM
    matrix
  , generate_subscripts(m, 1) y
  , generate_subscripts(m, 2) x
  WHERE
    m[y][x] IN ('S', 'E')
)
, r AS (
  SELECT
    e                         -- протаскиваем целевую точку сквозь все шаги
  , s p                       -- текущая точка - стартовая
  , '(1,0)' d                 -- исходно смотрим направо
  , 0::bigint score           -- накопленная стоимость пути
  , FALSE turn                -- признак поворота на предыдущем шаге
  , ARRAY[s || ':(1,0)'] path -- уже достигнутый путь
  , FALSE fin                 -- признак достижения конечной точки на данном шаге
  FROM
    init
UNION ALL
  (
    WITH tmp AS (
      SELECT DISTINCT ON (state) -- уникализируем пути по достигнутому состоянию
        e
      , os.t p                   -- новая достигнутая точка
      , os.dt d                  -- новое направление
      , r.score + os.score score -- "стоимость" уже пройденного пути
      , os.t = os.s turn         -- признак поворота
      , r.path || state path
      FROM
        r
      JOIN
        onestep os
          ON (os.s, os.ds) = (r.p, r.d)
      , LATERAL (
          SELECT os.t || ':' || os.dt state -- состояние - это "куда пришли" + "куда смотрим"
        ) T
      WHERE
        p <> e AND             -- пока не пришли куда надо
        state <> ALL(path) AND -- исключаем зацикливание пути
        ( -- если на предыдущем шаге поворачивались, то на этом надо "шагать"
          NOT turn OR
          os.t <> os.s
        ) AND
        NOT fin                -- выход из рекурсии - достижение конечной точки
      ORDER BY
        state, r.score + os.score
    )
    SELECT
      *
    , bool_or(p = e) OVER() fin -- признак достижения конечной точки
    FROM
      tmp
  )
)
SELECT
  min(score)
FROM
  r
WHERE
  p = e;

Результат мы получаем всего за 33.5 секунды:

Часть 2

Во второй части нас просят подсчитать количество клеток, через которые проходят все пути с (одинаковой) минимальной стоимостью.

Заметим, что любой альтернативный путь той же стоимости должен на каком-то из шагов "сливаться" (попадать в ту же точку с тем же направлением и той же накопленной стоимостью) что и любой найденный нами минимальный путь.

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

, path AS (
  SELECT
    i
  , split_part(path[i - 1], ':', 1) s  -- из какой точки шли
  , split_part(path[i - 1], ':', 2) ds -- из какого направления
  , split_part(path[i], ':', 1) t      -- в какую точку
  , split_part(path[i], ':', 2) dt     -- в какое направление
  FROM
    (
      SELECT
        path -- произвольный путь минимальной стоимости
      FROM
        r
      WHERE
        fin AND
        p = e
      ORDER BY
        score
      LIMIT 1
    )
  , generate_subscripts(path, 1) i
)

Для второго примера из условия (17x17) результат будет выглядеть так:

 i | s       | ds     | t       | dt
 1 |         |        | (2,16)  | (1,0)
 2 | (2,16)  | (1,0)  | (2,16)  | (0,-1)
 3 | (2,16)  | (0,-1) | (2,6)   | (0,-1)
 4 | (2,6)   | (0,-1) | (2,6)   | (1,0)
 5 | (2,6)   | (1,0)  | (4,6)   | (1,0)
 6 | (4,6)   | (1,0)  | (4,6)   | (0,1)
 7 | (4,6)   | (0,1)  | (4,16)  | (0,1)
 8 | (4,16)  | (0,1)  | (4,16)  | (1,0)
 9 | (4,16)  | (1,0)  | (6,16)  | (1,0)
10 | (6,16)  | (1,0)  | (6,16)  | (0,-1)
11 | (6,16)  | (0,-1) | (6,14)  | (0,-1)
12 | (6,14)  | (0,-1) | (6,14)  | (1,0)
13 | (6,14)  | (1,0)  | (12,14) | (1,0)
14 | (12,14) | (1,0)  | (12,14) | (0,-1)
15 | (12,14) | (0,-1) | (12,12) | (0,-1)
16 | (12,12) | (0,-1) | (12,12) | (1,0)
17 | (12,12) | (1,0)  | (14,12) | (1,0)
18 | (14,12) | (1,0)  | (14,12) | (0,-1)
19 | (14,12) | (0,-1) | (14,10) | (0,-1)
20 | (14,10) | (0,-1) | (14,10) | (1,0)
21 | (14,10) | (1,0)  | (16,10) | (1,0)
22 | (16,10) | (1,0)  | (16,10) | (0,-1)
23 | (16,10) | (0,-1) | (16,2)  | (0,-1)
два альтернативных лучших пути
#################  #################
#...#...#...#..E#  #...#...#...#..E#
#.#.#.#.#.#.#.#^#  #.#.#.#.#.#.#.#^#
#.#.#.#...#...#^#  #.#.#.#...#...#^#
#.#.#.#.###.#.#^#  #.#.#.#.###.#.#^#
#A>A#.#.#.....#^#  #B>B#.#.#.....#^#
#^#v#.#.#.#####^#  #^#v#.#.#.#####^#
#^#v..#.#.#....^#  #^#v..#.#.#B>>>B#
#^#v#####.#.###^#  #^#v#####.#^###.#
#^#v#.......#A>A#  #^#v#..B>>>B#...#
#^#v###.#####^###  #^#v###^#####.###
#^#v#...#..A>A#.#  #^#v#B>B#.....#.#
#^#v#.#####^###.#  #^#v#^#####.###.#
#^#v#A>>>>>A..#.#  #^#v#^........#.#
#^#v#^#########.#  #^#v#^#########.#
#S#A>A..........#  #S#B>B..........#
#################  #################

Заметим, что даже в примере альтернативные пути "сливаются" в точке (последняя B в правом пути), которая не является узловой для первого из них. А это значит, что нам необходимо восстановить не только узловые точки, но и все промежуточные состояния с накопленной "стоимостью":

, pathex AS (
  SELECT
    p
  , d
  , sum(stepscore) OVER(ORDER BY i) score -- вычисляем накопительный итог
  FROM
    (
      SELECT DISTINCT ON (p, d)   -- уникализируем состояния
        *
      FROM
        (
          SELECT
            row_number() OVER() i -- нумеруем состояния
          , point(x, y)::text p
          , dt d
          , CASE
              WHEN s IS NULL THEN 0
              WHEN s = t THEN 1000
              ELSE 1
            END stepscore         -- стоимость шага
          FROM
            path
          , generate_series((coalesce(s, t)::point)[0]::integer, (t::point)[0]::integer, coalesce(nullif((ds::point)[0]::integer, 0), 1)) x -- генерируем шаги по X
          , generate_series((coalesce(s, t)::point)[1]::integer, (t::point)[1]::integer, coalesce(nullif((ds::point)[1]::integer, 0), 1)) y -- генерируем шаги по Y
        ) T
      ORDER BY
        p, d, i
    ) T
)

В точках поворота у нас возникают дубль-значения состояний, мы избавились от них с помощью DISTINCT.

Теперь цепочка состояний нашего пути выглядит так:

p      | d      | score
(2,16) | (1,0)  |    0
(2,16) | (0,-1) | 1000
(2,15) | (0,-1) | 1001
(2,14) | (0,-1) | 1002
(2,13) | (0,-1) | 1003
(2,12) | (0,-1) | 1004
(2,11) | (0,-1) | 1005
(2,10) | (0,-1) | 1006
(2,9)  | (0,-1) | 1007
(2,8)  | (0,-1) | 1008
(2,7)  | (0,-1) | 1009
(2,6)  | (0,-1) | 1010
(2,6)  | (1,0)  | 2010
(3,6)  | (1,0)  | 2011
...

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

, paths AS (
  SELECT
    path
  FROM
    r
  WHERE
    (path[array_length(path, 1)], score) IN ( -- состояние и стоимость в последней точке совпадают с нашими
      SELECT
        p || ':' || d
      , score
      FROM
        pathex
    )
)

Остается лишь повторить операцию по превращению каждого такого пути в набор точек и узнать количество уникальных среди них:

SELECT
  count(DISTINCT p) -- подсчитываем количество уникальных клеток
FROM
  paths
, LATERAL ( -- превращаем каждый путь между узлами в набор проходимых клеток
    SELECT
      point(x, y)::text p
    FROM
      (
        SELECT
          split_part(path[i - 1], ':', 1) s
        , split_part(path[i - 1], ':', 2) ds
        , split_part(path[i], ':', 1) t
        , split_part(path[i], ':', 2) dt
        FROM
          generate_subscripts(path, 1) i
      ) T
    , generate_series((coalesce(s, t)::point)[0]::integer, (t::point)[0]::integer, coalesce(nullif((ds::point)[0]::integer, 0), 1)) x
    , generate_series((coalesce(s, t)::point)[1]::integer, (t::point)[1]::integer, coalesce(nullif((ds::point)[1]::integer, 0), 1)) y
  ) T;

Итого, полный вид итогового запроса для второй части:

-- explain (analyze, costs off)
WITH RECURSIVE matrix AS MATERIALIZED (
  SELECT
    array_agg(regexp_split_to_array(line[1], '')) m
  FROM
    regexp_matches(
      $$
#################
#...#...#...#..E#
#.#.#.#.#.#.#.#.#
#.#.#.#...#...#.#
#.#.#.#.###.#.#.#
#...#.#.#.....#.#
#.#.#.#.#.#####.#
#.#...#.#.#.....#
#.#.#####.#.###.#
#.#.#.......#...#
#.#.###.#####.###
#.#.#...#.....#.#
#.#.#.#####.###.#
#.#.#.........#.#
#.#.#.#########.#
#S#.............#
#################
$$
    , '(?:^|[\r\n])(#[^\r\n]*#)(?=$|[\r\n])'
    , 'g'
    )
      WITH ORDINALITY y(line, y)
)
, poi AS MATERIALIZED (
  SELECT
    x
  , y
  , grpx
  , grpy
  FROM
    (
      SELECT
        *
      , sum(wall::integer) OVER(PARTITION BY x ORDER BY y) grpx -- группа по X
      , sum(wall::integer) OVER(PARTITION BY y ORDER BY x) grpy -- группа по Y
      FROM
        (
          SELECT
            x
          , y
          , m[y][x] = '#' wall
          FROM
            matrix
          , generate_subscripts(m, 1) y
          , generate_subscripts(m, 2) x
          WHERE
            m[y][x] IN ('S', 'E', '#') OR -- отбираем старт, финиш и все стены
            (
              m[y][x] = '.' AND -- проходные клетки отбираем только вне тоннеля
              (m[y][x - 1] <> '#' OR m[y][x + 1] <> '#') AND
              (m[y - 1][x] <> '#' OR m[y + 1][x] <> '#')
            )
        ) T
    ) T
  WHERE
    NOT wall -- стены нам не нужны
)
, stepline AS MATERIALIZED (
  SELECT
    x sx
  , y sy
  , array_remove(array_agg(x) OVER(PARTITION BY y, grpy), x) _tx
  , array_remove(array_agg(y) OVER(PARTITION BY x, grpx), y) _ty
  FROM
    poi
)
, segline AS MATERIALIZED (
  SELECT
    point(sx, sy)::text s        -- текстовое представление координат ячеек
  , point(tx, ty)::text t
  , CASE                         -- направление движения
      WHEN tx > sx THEN '(1,0)'  -- вправо
      WHEN tx < sx THEN '(-1,0)' -- влево
      WHEN ty > sy THEN '(0,1)'  -- вниз
      WHEN ty < sy THEN '(0,-1)' -- вверх
    END d
  , abs(tx - sx) + abs(ty - sy) score -- "стоимость" сегмента - разность координат
  FROM
    (
      SELECT
        sx
      , sy
      , unnest(_tx) tx -- возможные шаги по горизонтали
      , sy ty
      FROM
        stepline
    UNION ALL
      SELECT
        sx
      , sy
      , sx sy
      , unnest(_ty) ty -- возможные шаги по вертикали
      FROM
        stepline
    ) T
)
, dir AS MATERIALIZED (
  SELECT
    unnest(ARRAY[ -- направления "смотрения"
      '(1,0)'  -- вправо
    , '(0,1)'  -- вниз
    , '(-1,0)' -- влево
    , '(0,-1)' -- вверх
    ]::point[]) d
)
, onestep AS MATERIALIZED (
  SELECT
    point(x, y)::text s
  , ds.d::text ds
  , point(x, y)::text t
  , dt.d::text dt
  , 1000 score -- поворот на месте "за 1000"
  FROM
    poi
  , dir ds
  , dir dt
  WHERE
    dt.d[0] <> ds.d[0] AND -- это поворот, не разворот на 180
    dt.d[1] <> ds.d[1]
UNION ALL
  SELECT
    s
  , d ds
  , t
  , d dt
  , score     -- шаг по прямой к соседнему узлу
  FROM
    segline
)
, init AS MATERIALIZED (
  SELECT
    point(
      min(x) FILTER(WHERE m[y][x] = 'S')
    , min(y) FILTER(WHERE m[y][x] = 'S')
    )::text s -- координаты стартовой клетки
  , point(
      min(x) FILTER(WHERE m[y][x] = 'E')
    , min(y) FILTER(WHERE m[y][x] = 'E')
    )::text e -- координаты конечной клетки
  FROM
    matrix
  , generate_subscripts(m, 1) y
  , generate_subscripts(m, 2) x
  WHERE
    m[y][x] IN ('S', 'E')
)
, r AS (
  SELECT
    e                         -- протаскиваем целевую точку сквозь все шаги
  , s p                       -- текущая точка - стартовая
  , '(1,0)' d                 -- исходно смотрим направо
  , 0::bigint score           -- накопленная стоимость пути
  , FALSE turn                -- признак поворота на предыдущем шаге
  , ARRAY[s || ':(1,0)'] path -- уже достигнутый путь
  , FALSE fin                 -- признак достижения конечной точки на данном шаге
  FROM
    init
UNION ALL
  (
    WITH tmp AS (
      SELECT DISTINCT ON (state) -- уникализируем пути по достигнутому состоянию
        e
      , os.t p                   -- новая достигнутая точка
      , os.dt d                  -- новое направление
      , r.score + os.score score -- "стоимость" уже пройденного пути
      , os.t = os.s turn         -- признак поворота
      , r.path || state path
      FROM
        r
      JOIN
        onestep os
          ON (os.s, os.ds) = (r.p, r.d)
      , LATERAL (
          SELECT os.t || ':' || os.dt state -- состояние - это "куда пришли" + "куда смотрим"
        ) T
      WHERE
        p <> e AND             -- пока не пришли куда надо
        state <> ALL(path) AND -- исключаем зацикливание пути
        ( -- если на предыдущем шаге поворачивались, то на этом надо "шагать"
          NOT turn OR
          os.t <> os.s
        ) AND
        NOT fin                -- выход из рекурсии - достижение конечной точки
      ORDER BY
        state, r.score + os.score
    )
    SELECT
      *
    , bool_or(p = e) OVER() fin -- признак достижения конечной точки
    FROM
      tmp
  )
)
, path AS (
  SELECT
    split_part(path[i - 1], ':', 1) s  -- из какой точки шли
  , split_part(path[i - 1], ':', 2) ds -- из какого направления
  , split_part(path[i], ':', 1) t      -- в какую точку
  , split_part(path[i], ':', 2) dt     -- в какое направление
  FROM
    (
      SELECT
        path -- произвольный путь минимальной стоимости
      FROM
        r
      WHERE
        fin AND
        p = e
      ORDER BY
        score
      LIMIT 1
    )
  , generate_subscripts(path, 1) i
)
, pathex AS (
  SELECT
    p
  , d
  , sum(stepscore) OVER(ORDER BY i) score -- вычисляем накопительный итог
  FROM
    (
      SELECT DISTINCT ON (p, d)   -- уникализируем состояния
        *
      FROM
        (
          SELECT
            row_number() OVER() i -- нумеруем состояния
          , point(x, y)::text p
          , dt d
          , CASE
              WHEN s IS NULL THEN 0
              WHEN s = t THEN 1000
              ELSE 1
            END stepscore         -- стоимость шага
          FROM
            path
          , generate_series((coalesce(s, t)::point)[0]::integer, (t::point)[0]::integer, coalesce(nullif((ds::point)[0]::integer, 0), 1)) x -- генерируем шаги по X
          , generate_series((coalesce(s, t)::point)[1]::integer, (t::point)[1]::integer, coalesce(nullif((ds::point)[1]::integer, 0), 1)) y -- генерируем шаги по Y
        ) T
      ORDER BY
        p, d, i
    ) T
)
, paths AS (
  SELECT
    path
  FROM
    r
  WHERE
    (path[array_length(path, 1)], score) IN ( -- состояние и стоимость в последней точке совпадают с нашими
      SELECT
        p || ':' || d
      , score
      FROM
        pathex
    )
)
SELECT
  count(DISTINCT p) -- подсчитываем количество уникальных клеток
FROM
  paths
, LATERAL ( -- превращаем каждый путь между узлами в набор проходимых клеток
    SELECT
      point(x, y)::text p
    FROM
      (
        SELECT
          split_part(path[i - 1], ':', 1) s
        , split_part(path[i - 1], ':', 2) ds
        , split_part(path[i], ':', 1) t
        , split_part(path[i], ':', 2) dt
        FROM
          generate_subscripts(path, 1) i
      ) T
    , generate_series((coalesce(s, t)::point)[0]::integer, (t::point)[0]::integer, coalesce(nullif((ds::point)[0]::integer, 0), 1)) x
    , generate_series((coalesce(s, t)::point)[1]::integer, (t::point)[1]::integer, coalesce(nullif((ds::point)[1]::integer, 0), 1)) y
  ) T;

Добавив поиск альтернативных сегментов, мы сделали запрос хоть и в 1.5 раза дольше, но все равно меньше минуты:

Комментарии (4)


  1. Alexandroppolus
    13.02.2025 12:57

    заметим, что при таких величинах (1 за шаг "прямо" и 1000 за поворот) мы можем свести задачу к "за какое минимальное количество поворотов" можно достигнуть конечной точки.

    Контрпример к данному поинту можно легко нарисовать на карте размером 6х520, а в условии, кажется, не было каких-то конкретных ограничений:

    Карта
    ######
    ##.#.#
    ##.#.#
    ##.#.#
    ------ ещё куча строк "##.#.#"
    ##.#.#
    ##.#.#
    #.E#S#
    #.##.#
    #....#
    ######

    В целом первая задача выглядит как на поиск кратчайших путей в графе и может быть решена Дейкстрой или UCS. В графе будет по 4 вершины на каждую клетку лабиринта, по количеству направлений (значения N, S, W, E - куда смотрит олень, вставший на клетку), причем, например, ребро между вершинами (i, j, N) и (i, j, W) имеет длину 1000 (это поворот на 90 гр), между вершинами (i, j, N) и (i, j, S) ребра нет, ибо бессмысленно, а ребро между (i, j, N) и (i, j-1, N) будет длиной 1. Мы стартуем из (i[S], j[S], E), смотрим кратчайшие пути до 4 вершин, относящихся к конечному квадратику. Всего ребер будет O(n), где n-количество клеток, и должно отработать за O(n * ln(n))


    1. Kilor Автор
      13.02.2025 12:57

      Контрпример к данному поинту можно легко нарисовать на карте размером 6х520

      Очевидно, что можно, и это определенное допущение - только в реальном кейсе AoC дана карта 141x141, для которой мы такое можем себе позволить.

      должно отработать за O(n * ln(n))

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


      1. Alexandroppolus
        13.02.2025 12:57

        в реальном кейсе AoC дана карта 141x141

        Это они зря. Недоработочка!

        Ещё удивляет время выполнения первого запроса - 33 сек. Я просто скопировал его сюда и всё отлетело менее чем за секунду. Вы как запускали?


        1. Kilor Автор
          13.02.2025 12:57

          33 секунды - это для той самой "реальной" карты со стороной 141 - в контексте у плана есть полный запрос.