Правила игры очень просты: надо построить цепочку слов от начального (МУХА) до конечного (СЛОН), на каждом шаге меняя только одну букву. При этом могут использоваться только русские 4-буквенные нарицательные существительные в начальной форме: например, слова БАЗА, НОЧЬ, САНИ допускаются, а слова ЛИТЬ, ХОТЯ, РУКУ, НОЧИ, САНЯ, ОСЛО, АБВГ, ФЦНМ — нет.

Эта игра под названием «Дублеты» приобрела известность благодаря Льюису Кэрроллу — не только автору книг про Алису, но ещё и замечательному математику. В марте 1879 года он начал раз в неделю публиковать в журнале «Ярмарка тщеславия» по три задания в форме броских фраз: «Turn POOR into RICH» — «Преврати бедного в богатого», «Evolve MAN from APE» — «Выведи человека из обезьяны», «Make TEA HOT» — «Сделай чай горячим». В том же году он выпустил брошюру «Дублеты», подробно описал в ней правила и предложил читателям попрактиковаться на нескольких десятках примеров.

Александр Пиперски, "Из мухи — слона", «Квантик» №2, 2019 и №3, 2019

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

Загружаем словарь

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

CREATE TABLE dict AS
SELECT
  btrim(word) word    -- зачищаем пробелы с обеих сторон
FROM
  regexp_split_to_table($$
абажур
абажурчик
абаз
абазин
-- ...
  $$, E'[\r\n]') word -- делим текст на строки
WHERE
  word !~ '^\s*$';    -- загружаем только непустые строки

Формируем пары

Создадим таблицу, которая будет содержать возможные на основании загруженного словаря переходы "в одну букву".

Для этого нам понадобится проиндексировать словарь для быстрого поиска по LIKE:

CREATE EXTENSION pg_trgm;
CREATE EXTENSION btree_gin;

CREATE INDEX ON dict USING gin(
  length(word)      -- для этого - btree_gin
, word gin_trgm_ops -- для этого - pg_trgm
);

Здесь мы использовали расширение pg_trgm для поддержки индексного поиска по триграммам (что, в том числе, дает быстрый поиск по шаблонам и регулярным выражениям) и btree_gin для использования скаляров в gin-индексах:

CREATE TABLE pair AS
WITH src AS (
  SELECT
    word
  , array_agg( -- 'муха' -> {'_уха','м_ха','му_а','мух_'}
      substr(word, 1, i - 1) || '_' || substr(word, i + 1)
    ) patterns -- массив всех возможных LIKE-шаблонов замены одной буквы
  FROM
    dict
  , LATERAL
      generate_series(1, length(word)) i -- перебираем номера букв слова
  GROUP BY
    1
)
SELECT
  src.word src
, dst.word dst
FROM
  src
, LATERAL (
    SELECT
      word
    FROM
      dict
    WHERE
      length(dict.word) = length(src.word) AND -- ищем только в той же длине
      dict.word LIKE ANY(src.patterns) AND     -- одновременно по набору шаблонов
      dict.word <> src.word                    -- исключая исходное слово
  ) dst;

Правильный индекс позволил нам сформировать все пары возможных переходов для 51 тысячи слов всего лишь чуть дольше, чем за минуту [посмотреть план на explain.tensor.ru].

При этом мы получили в таблице сразу как пару (муха, муза), так и "зеркальную" ей (муза, муха) - поэтому нам будет достаточно лишь одного индекса (src, dst):

CREATE UNIQUE INDEX ON pair(src, dst);

Запускаем "волны"

Сначала запомним в CTE, что во что нам надо превратить:

WITH RECURSIVE param(src, dst) AS (
  VALUES('муха', 'слон')
)

Запускаем "волну" от источника к цели:

, s2d AS (
  SELECT
    0 i             -- счетчик шага
  , ARRAY[src] path -- уже пройденный волной путь
  , ARRAY[src] diff -- "прирост" на новом шаге
  FROM
    param
UNION ALL
  SELECT
    i + 1
  , T.path || dst$
  , dst$
  FROM
    s2d T
  , LATERAL (
      SELECT
        array_agg(dst) dst$
      FROM
        pair
      WHERE
        src = ANY(T.path) AND -- для всех слов-оснований добавляем слова-следствия
        dst <> ALL(T.path)    -- которые еще не принадлежат пути
    ) X
  WHERE
    (SELECT dst FROM param) <> ALL(path) AND -- останавливаемся, дойдя до цели
    i < 100                                  -- не более чем за 100 шагов
)

Теперь в обратную сторону - от цели к источнику:

, d2s AS (
  SELECT
    (SELECT max(i) FROM s2d) i -- мы уже знаем, сколько шагов понадобится
  , ARRAY[dst] path
  , ARRAY[dst] diff
  FROM
    param
UNION ALL
  SELECT
    i - 1 -- счетчик теперь идет "вниз"
  , T.path || dst$
  , dst$
  FROM
    d2s T
  , LATERAL (
      SELECT
        array_agg(dst) dst$
      FROM
        pair
      WHERE
        src = ANY(T.path) AND
        dst <> ALL(T.path)
    ) X
  WHERE
    (SELECT src FROM param) <> ALL(path) AND
    i > 0
)

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

SELECT
  ARRAY(                -- свернули обратно в массив
    SELECT unnest(X.diff)    -- развернули первый массив
  INTERSECT ALL           -- нашли пересечение наборов
    SELECT unnest(Y.diff)    -- развернули второй массив
  ) path
FROM
  s2d X
JOIN
  d2s Y
    USING(i);

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

муха - мура - фура - фара - фарт - фаут - паут - плут - плот - клот - клон - слон
Запрос целиком
WITH RECURSIVE param(src, dst) AS (
  VALUES('муха', 'слон')
)
, s2d AS (
  SELECT
    0 i
  , ARRAY[src] path
  , ARRAY[src] diff
  FROM
    param
UNION ALL
  SELECT
    i + 1
  , T.path || dst$
  , dst$
  FROM
    s2d T
  , LATERAL (
      SELECT
        array_agg(dst) dst$
      FROM
        pair
      WHERE
        src = ANY(T.path) AND
        dst <> ALL(T.path)
    ) X
  WHERE
    (SELECT dst FROM param) <> ALL(path) AND
    i < 100
)
, d2s AS (
  SELECT
    (SELECT max(i) FROM s2d) i
  , ARRAY[dst] path
  , ARRAY[dst] diff
  FROM
    param
UNION ALL
  SELECT
    i - 1
  , T.path || dst$
  , dst$
  FROM
    d2s T
  , LATERAL (
      SELECT
        array_agg(dst) dst$
      FROM
        pair
      WHERE
        src = ANY(T.path) AND
        dst <> ALL(T.path)
    ) X
  WHERE
    (SELECT src FROM param) <> ALL(path) AND
    i > 0
)
SELECT
  ARRAY(
    SELECT
      unnest(X.diff)
  INTERSECT ALL
    SELECT
      unnest(Y.diff)
  ) path
FROM
  s2d X
JOIN
  d2s Y
    USING(i);

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


  1. yellow_askhat
    16.11.2021 14:07
    +4

    круть!!!!!


  1. leotrubach
    17.11.2021 00:49

    Помню в детстве в книге по математике я видел другую последовательность слов, не столь оптимальную как ваша. Отложилось в памяти: муха - мура - тура - тара - кара - каре - кафе - кафр - каюр - каюк - крюк- урюк - урок - срок - сток - стон - слон


  1. LaRN
    17.11.2021 09:14

    Можно немного короче:

    плот - клот - клон - слон

    Вот так:

    плот - слот - слон


    1. Kilor Автор
      17.11.2021 09:19

      Можно. Но конкретно в этом словаре почему-то отсутствует слово "слот".


      1. savostin
        17.11.2021 12:13

        Подозреваю потому, что это технический/игровой термин.


        1. Kilor Автор
          17.11.2021 12:23
          +1

          А чем тогда "клот" лучше?

          "клот - То же, что: клотик"

          "клотик - Деревянный или металлический шар с роликами для пропускания тросов, надеваемый на верх мачты или флагштока."


          1. savostin
            17.11.2021 12:27

            а ни чем...


  1. LaRN
    18.11.2021 09:04

    Поясните плиз, как отсекается циклы типа

    муха-муза-муха

    Вроде по коду нет проверки что слово уже есть в маршруте.

    А в индексе есть зеркальные элементы:

    муха-муза и муза-муха, те теоретически циклы возможны.


    1. Kilor Автор
      18.11.2021 09:54

      Вот это условие исключает повторное внесение узла в уже пройденный волной путь:

      dst <> ALL(T.path) -- которые еще не принадлежат пути

      Соответственно, каждый узел целевого пути может находиться только в единственном diff'е.