Пример сгенерированного дерева для лабиринта 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;

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


  1. inververs
    29.11.2021 14:07

    Высший пилотаж. А простые числа тоже возможно выводить?


    1. Kilor Автор
      29.11.2021 17:41

      Выводить - куда? В смысле, скатерть Улама?