SQL HowTo: генерируем лабиринты (алгоритм Прима и геометрические типы)

в 5:40, , рубрики: postgresql, sql, sql tips and tricks, алгоритм Прима, Алгоритмы, Блог компании Тензор, Занимательные задачки, лабиринтостроительство
Пример сгенерированного дерева для лабиринта 21x21
Пример сгенерированного дерева для лабиринта 21x21

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

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

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

Алгоритм Прима (упрощенный)

Алгоритм Прима используется для создания минимального остовного дерева в неориентированном графе. В нашей упрощенной версии вершинам графа будут соответствовать узлы координатной сетки, а ребра вести к 4 соседям (слева, справа, сверху и снизу) и обратно - то есть все ребра будут равновесными, а граф - неориентированным.

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

Исходный координатный граф и пример лабиринта по дереву в нем
Исходный координатный граф и пример лабиринта по дереву в нем

Алгоритм, стартующий со случайного узла, состоит из двух шагов:

  • для всех уже достигнутых вершин дерева выбираем ребра, которые ведут в еще не достигнутые вершины

  • выбираем любое из них случайным образом и добавляем вместе с новой вершиной к дереву

Шаги алгоритма Прима
Шаги алгоритма Прима

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

Достаточно анализировать только "фронт"
Достаточно анализировать только "фронт"

А теперь - на SQL!

Очевидно, что наш алгоритм подразумевает цикличность, что на SQL соответствует рекурсивному запросу - будьте осторожны при их использовании!

Договоримся, что наш лабиринт будет квадратным и сначала зададим его "радиус":

-- задаем "радиус" лабиринта
WITH RECURSIVE sz(r) AS (
  VALUES(10)
)

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

-- границы лабиринта
, box AS (
  SELECT
    box( -- прямоугольник по противоположным углам
      point(-r, -r)
    , point(+r, +r)
    )
  FROM
    sz
)

Теперь определимся с рекурсивным циклом.

На каждом шаге нам понадобится знать уже достигнутые узлы дерева, "фронт" волны и выбранное ребро. В нашем случае каждый узел описывается как точка с двумя координатами, поэтому мы можем использовать тип point для его хранения, а ребро будет отрезком - тип lseg.

На стартовом шаге мы "вбрасываем" в лабиринт случайную точку с координатами [-r .. +r] и составляем из нее стартовое дерево и текущий "фронт":

-- цикл выбора ребер
, T AS (
  SELECT
    ARRAY[p]::point[] points    -- уже достигнутые точки
  , ARRAY[p]::point[] wavefront -- фронт "волны"
  , NULL::lseg edge             -- выбранное ребро
  FROM
    sz
  , LATERAL
      point( -- случайная стартовая точка
        (random() * 2 * r)::integer - r
      , (random() * 2 * r)::integer - r
      ) p
UNION ALL
  -- ... Hic sunt dracones
  WHERE
    array_length(T.points, 1) = 1 OR -- стартовый шаг
    T.edge IS NOT NULL               -- продолжаем, пока можно выбрать ребро
)

И "шагать" в цикле мы будем, пока будут находиться подходящие ребра. Поскольку в нашем квадратном лабиринте (2 * r + 1) ^ 2 узлов и один из них мы уже выбрали, то для достижения остальных нам потребуется ровно на единицу меньше шагов.

Here be dragons

Основной шаг нашего алгоритма будет выглядеть так:

  • для каждой точки "фронта" волны сформировали lseg-ребра до "соседей"

    FROM
      unnest(T.wavefront) s -- для каждой точки фронта
    , LATERAL ( -- формируем все 4 возможных ребра
        VALUES
          (lseg(s, point(s[0] - 1, s[1])))
        , (lseg(s, point(s[0] + 1, s[1])))
        , (lseg(s, point(s[0], s[1] - 1)))
        , (lseg(s, point(s[0], s[1] + 1)))
      ) X(e)
  • оставляем только те ребра, где целевая точка d лежит в границах лабиринта и еще не принадлежит дереву

    , LATERAL ( -- получаем "целевые" точки
        SELECT e[1] d
      ) Y
    WHERE
      d <@ (TABLE box) AND -- целевая точка должна находиться в границах лабиринта
      d <> ALL(T.points)   -- и не должна быть достигнута ранее
  • все оставшиеся ребра группируем в массив, а из их исходных точек формируем "фронт" для следующего шага

    Нам совсем не нужно, чтобы в массиве "плодились" одинаковые точки, поэтому тут пришлось пойти на хитрость, преобразовав для уникализации тип point в text (поскольку оператор point = point не реализован, из-за чего и DISTINCT выдает ошибку), и сразу обратно полученный text[] в point[]:

    SELECT
      array_agg(e) edges -- набор все полученные ребра
    , array_agg(DISTINCT s::text)::point[] wavefront -- новый фронт - все возможные "источники" предыдущего шага
  • остается лишь выбрать из набора произвольное ребро, а его целевую точку добавить к дереву и фронту

    SELECT
      T.points || X.edge[1]    -- "хвост" выбранного ребра добавляем к достигнутым точкам
    , X.wavefront || X.edge[1] -- ... и к фронту
    , X.edge
    FROM
      T
    , LATERAL (
        SELECT
          Z.wavefront
        , Z.edges[
            (random() * (array_length(Z.edges, 1) - 1))::integer + 1
          ] edge -- выбираем случайное ребро из набора
        FROM
          (
    -- ...
          ) Z
      ) X

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

Собственно, на этом - все, в T.edge находятся искомые ребра дерева.

Полная генерация дерева
-- цикл выбора ребер
, T AS (
  SELECT
    ARRAY[p]::point[] points    -- уже достигнутые точки
  , ARRAY[p]::point[] wavefront -- фронт "волны"
  , NULL::lseg edge             -- выбранное ребро
  FROM
    sz
  , LATERAL
      point( -- случайная стартовая точка
        (random() * 2 * r)::integer - r
      , (random() * 2 * r)::integer - r
      ) p
UNION ALL
  SELECT
    T.points || X.edge[1]    -- "хвост" выбранного ребра добавляем к достигнутым точкам
  , X.wavefront || X.edge[1] -- ... и к фронту
  , X.edge
  FROM
    T
  , LATERAL (
      SELECT
        Z.wavefront
      , Z.edges[
          (random() * (array_length(Z.edges, 1) - 1))::integer + 1
        ] edge -- выбираем случайное ребро из набора
      FROM
        (
          SELECT
            array_agg(e) edges -- набор все полученные ребра
          , array_agg(DISTINCT s::text)::point[] wavefront -- новый фронт - все возможные "источники" предыдущего шага
          FROM
            unnest(T.wavefront) s -- для каждой точки фронта
          , LATERAL ( -- формируем все 4 возможных ребра
              VALUES
                (lseg(s, point(s[0] - 1, s[1])))
              , (lseg(s, point(s[0] + 1, s[1])))
              , (lseg(s, point(s[0], s[1] - 1)))
              , (lseg(s, point(s[0], s[1] + 1)))
            ) X(e)
          , LATERAL ( -- получаем "целевые" точки
              SELECT e[1] d
            ) Y
          WHERE
            d <@ (TABLE box) AND -- целевая точка должна находиться в границах лабиринта
            d <> ALL(T.points)   -- и не должна быть достигнута ранее
        ) Z
    ) X
  WHERE
    array_length(T.points, 1) = 1 OR -- стартовый шаг
    T.edge IS NOT NULL               -- продолжаем, пока можно выбрать ребро
)

Делаем красиво

Но мало просто сгенерировать дерево в памяти - хочется хоть как-то (а лучше красиво!) его увидеть, чтобы проверить себя.

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

Удвоенная координатная сетка
Удвоенная координатная сетка

То есть узлы окажутся в точках с обеими четными координатами, горизонтальные ребра - с нечетным X, вертикальные - с нечетным Y. Причем для соблюдения пропорций вертикальные ребра будем выводить одним символом '|', а горизонтальные - двумя '--' (или '---' - тут все зависит от используемого вами в консоли шрифта):

Соблюдение пропорций V/H в консоли
Соблюдение пропорций V/H в консоли

Для начала приведем набор отображаемых ребер (узлы-то заведомо будут все) в удобный вид. Для этого с помощью функции point(lseg) находим центр отрезка, масштабируем его координаты с коэффициентом 2 оператором * point(scale, rotate) , чтобы превратить их в целочисленные, и проверяем "вертикальность" отрезка оператором ?| lseg:

-- приводим ребра в удобный вид
, edges AS (
  SELECT
    point(edge) * point(2, 0) cx2 -- удваиваем координаты центра ребра
  , CASE
      WHEN ?| edge THEN '|' -- вертикальный отрезок
      ELSE '--'
    END v -- определяем его положение (вертикально/горизонтально)
  FROM
    T
  WHERE
    edge IS NOT NULL
)

Теперь генерируем и заполняем координатную сетку в соответствии с описанными выше правилами, используя оператор совпадения ~= для проверки равенства точек сетки и центра отрезка:

-- заполняем координатную сетку
, map AS (
  SELECT
    m.*
  , CASE
      WHEN x % 2 = 0 AND y % 2 = 0 THEN '+'
      WHEN x % 2 = 0 THEN coalesce(v, ' ')
      ELSE coalesce(v, '  ')
    END v
  FROM
    ( -- удвоенная координатная сетка
      SELECT
        x
      , y
      FROM
        sz
      , generate_series(-r * 2, r * 2) x
      , generate_series(-r * 2, r * 2) y
    ) m
  LEFT JOIN
    edges e
      ON point(x, y) ~= cx2 -- point = point
)

И, наконец, собираем итоговый построчный вывод:

-- рисуем картинку
SELECT
  string_agg(v, '' ORDER BY x) maze
FROM
  map
GROUP BY
  y
ORDER BY
  y;

Вот теперь - все!

 +   +---+---+   +
 |           |   |
 +---+---+---+---+
 |           |
 +   +   +---+---+
     |       |
 +---+---+---+---+
     |       |
 +---+   +---+---+
Нарисуй свой лабиринт!
-- задаем "радиус" лабиринта
WITH RECURSIVE sz(r) AS (
  VALUES(10)
)
-- границы лабиринта
, box AS (
  SELECT
    box( -- прямоугольник по противоположным углам
      point(-r, -r)
    , point(+r, +r)
    )
  FROM
    sz
)
-- цикл выбора ребер
, T AS (
  SELECT
    ARRAY[p]::point[] points    -- уже достигнутые точки
  , ARRAY[p]::point[] wavefront -- фронт "волны"
  , NULL::lseg edge             -- выбранное ребро
  FROM
    sz
  , LATERAL
      point( -- случайная стартовая точка
        (random() * 2 * r)::integer - r
      , (random() * 2 * r)::integer - r
      ) p
UNION ALL
  SELECT
    T.points || X.edge[1]    -- "хвост" выбранного ребра добавляем к достигнутым точкам
  , X.wavefront || X.edge[1] -- ... и к фронту
  , X.edge
  FROM
    T
  , LATERAL (
      SELECT
        Z.wavefront
      , Z.edges[
          (random() * (array_length(Z.edges, 1) - 1))::integer + 1
        ] edge -- выбираем случайное ребро из набора
      FROM
        (
          SELECT
            array_agg(e) edges -- набор все полученные ребра
          , array_agg(DISTINCT s::text)::point[] wavefront -- новый фронт - все возможные "источники" предыдущего шага
          FROM
            unnest(T.wavefront) s -- для каждой точки фронта
          , LATERAL ( -- формируем все 4 возможных ребра
              VALUES
                (lseg(s, point(s[0] - 1, s[1])))
              , (lseg(s, point(s[0] + 1, s[1])))
              , (lseg(s, point(s[0], s[1] - 1)))
              , (lseg(s, point(s[0], s[1] + 1)))
            ) X(e)
          , LATERAL ( -- получаем "целевые" точки
              SELECT e[1] d
            ) Y
          WHERE
            d <@ (TABLE box) AND -- целевая точка должна находиться в границах лабиринта
            d <> ALL(T.points)   -- и не должна быть достигнута ранее
        ) Z
    ) X
  WHERE
    array_length(T.points, 1) = 1 OR -- стартовый шаг
    T.edge IS NOT NULL               -- продолжаем, пока можно выбрать ребро
)
-- приводим ребра в удобный вид
, edges AS (
  SELECT
    point(edge) * point(2, 0) cx2 -- удваиваем координаты центра ребра
  , CASE
      WHEN ?| edge THEN '|' -- вертикальный отрезок
      ELSE '--'
    END v -- определяем его положение (вертикально/горизонтально)
  FROM
    T
  WHERE
    edge IS NOT NULL
)
-- заполняем координатную сетку
, map AS (
  SELECT
    m.*
  , CASE
      WHEN x % 2 = 0 AND y % 2 = 0 THEN '+'
      WHEN x % 2 = 0 THEN coalesce(v, ' ')
      ELSE coalesce(v, '  ')
    END v
  FROM
    ( -- удвоенная координатная сетка
      SELECT
        x
      , y
      FROM
        sz
      , generate_series(-r * 2, r * 2) x
      , generate_series(-r * 2, r * 2) y
    ) m
  LEFT JOIN
    edges e
      ON point(x, y) ~= cx2 -- point = point
)
-- рисуем картинку
SELECT
  string_agg(v, '' ORDER BY x) maze
FROM
  map
GROUP BY
  y
ORDER BY
  y;

Автор: Боровиков Кирилл

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js