Привет, я создатель известного в узких кругах приложения 15 Puzzle для Android.

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

Игра "Пятнашки"

Классическая игра "Пятнашки" состоит из сетки 4x4, содержащей фишки с числами от 1 до 15 и одну пустую клетку:

Цель игры - перемещая фишки, расположить их в возрастающем порядке:

Перемещать можно только те фишки, которые находятся рядом с пустой клеткой (по диагонали нельзя). Решение может выглядеть так:

Генерация стартовых позиций

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

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

Так как у нас 16 клеток, а каждая клетка может иметь одно из 16 состояний (число или пустое), всего существует 16! (20 922 789 888 000) вариантов. Однако, только половина из них решаемы. Таким образом, имеем 16! / 2 (примерно 1013) стартовых позиций, из которых мы можем достичь целевую.

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

Набор предопределенных позиций

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

У такого решения есть проблема - нужно где-то хранить эти состояния. Если мы будем хранить 16! / 2 позиций в виде массива из 16 32-битных чисел, нам потребуется примерно 608 ТБ. А если мы еще захотим иметь разные размеры пазлов (3x3, 5x5 и т.д.), то места нужно будет еще больше.

Есть и другая проблема - как-то нужно сгенерировать 1013 позиций (и каждую из них как-то проверить, что она решаема). Можно создать, например, 105 стартовых состояний, но они в какой-то момент начнут повторяться.

Но если мы заранее можем сгенерировать пусть даже 105 состояний, может будем делать это "на лету"?

Случайные ходы

Вместо подготовки стартовых позиций, мы можем генерировать их по запросу. Алгоритм может быть таким:

  1. Начинаем с финальной позиции (здесь и далее 0 - пустая клетка):

     1   2   3   4
     5   6   7   8
     9  10  11  12
    13  14  15   0
    
  2. Выбираем случайную фишку, например, 6:

     1   2   3   4
     5  >6<  7   8
     9  10  11  12
    13  14  15  >0<
    
  3. Делая разрешенные ходы, перемещаем 0 на место 6:

     1   2   3   4
     5  >0<  6   7
     9  10  11   8
    13  14  15  12
    
  4. Повторяем шаги 2-3, пока не получим приемлемый результат

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

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

Средняя длина оптимального решения равна 52.59, то есть 150 итераций (повторений шагов 2-3 алгоритма) вполне достаточно.

Проблема с этим методом в том, что на каждой итерации алгоритма (где мы перемещаем 0 в выбранную клетку) в среднем мы будем делать ~2.67 операций обмена в массиве состояния (всего ~400 за 150 итераций). И хотя это не будет заметно для современных компьютеров (и телефонов), есть вариант получше.

Перемешивание с проверкой

Вариант получше - так же берем начальную позицию и перемешиваем. Для 4x4 перемешивание массива из 16 чисел можно совершить за 15 операций обмена, что намного лучше 400.

За 15 операций обмена (дополнительные 0.5 пока проигнорируем) средняя длина решения 53 хода, что почти на 1 ход "сложнее", чем метод 150 случайных ходов.

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

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

И такой способ есть.

Решаемость

Суть решаемости состоит в четности и инверсиях.

Первым шагом мы считаем количество инверсий в нашей позиции:

Для каждого числа k в позиции (слева направо, сверху вниз), считаем количество чисел, которые стоят после k и меньше этого k (кроме 0)

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

Давайте посмотрим, как меняется число инверсий, когда мы делаем ход. Например, в этой позиции 52 инверсии:

   8   4  12   3
  14   0   9  15
   7   2   5   1
  10  11  13   6
Подсчеты
   8   4  12   3  14   0   9  15   7   2   5   1  10  11  13   6  →  7
   ^   4  12   3  14   0   9  15   7   2   5   1  10  11  13   6  →  3 
       ^  12   3  14   0   9  15   7   2   5   1  10  11  13   6  →  9 
           ^   3  14   0   9  15   7   2   5   1  10  11  13   6  →  2 
               ^  14   0   9  15   7   2   5   1  10  11  13   6  →  9 
                   ^   0   9  15   7   2   5   1  10  11  13   6  →  5 
                           ^  15   7   2   5   1  10  11  13   6  →  8 
                               ^   7   2   5   1  10  11  13   6  →  4 
                                   ^   2   5   1  10  11  13   6  →  1 
                                       ^   5   1  10  11  13   6  →  1 
                                           ^   1  10  11  13   6  →  0 
                                               ^  10  11  13   6  →  1 
                                                   ^  11  13   6  →  1 
                                                       ^  13   6  →  1 
                                                           ^   6  →  0 
                                                               ^    ==
                                                                    52

Если мы делаем ход в горизонтальном направлении, то число инверсий не меняется:

   8   4  12   3
  14  >9< >0< 15
   7   2   5   1
  10  11  13   6

Так как мы не считаем 0, порядок чисел остается таким же.

Однако, ход по вертикали меняет количество инверсий. Переместив 4, получим 53:

   8  >0< 12   3
  14  >4<  9  15
   7   2   5   1
  10  11  13   6

Обратите внимание, что мы не только меняем количество инверсий, но и четность: было 52, стало 53.

Чтобы понять, почему число инверсий меняется, посмотрим на состояния до:

8   4  12   3  14   0  - остальные числа -

И после:

8   0  12   3  14   4  - остальные числа -

В виде таблицы:

Число

Числа меньше (стоящие до)

Числа меньше (стоящие после)

Изменение инверсий

8

3, 4

3, 4

0

4

3

-

-1

12

3

3, 4

+1

3

-

-

0

14

-

4

+1

Для пазлов с шириной в 4 клетки между 0 и перемещаемым числом будет всегда находиться 3 других числа. Так как 3 нечетное, никогда не возникнет ситуация, когда количество чисел "до" и количество чисел "после" будет одинаковым. Таким образом, возможны такие варианты:

  • Если 3 числа больше, чем перемещаемое число, то число инверсий уменьшается на 3

  • Если 2 числа больше, чем перемещаемое число, то число инверсий уменьшается на 1

  • Если 1 число больше, чем перемещаемое число, то число инверсий увеличивается на 1

  • Если нет чисел больше, чем перемещаемое число, то число инверсий увеличивается на 3

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

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

Таким образом, позиция будет решаемой в двух случаях:

  • Число инверсий четное и 0 в нечетной строке

  • Число инверсий нечетное и 0 в четной строке

Весь алгоритм для определения решаемости позиции:

  1. Посчитать количество инверсий

  2. Посмотреть на ширину пазла:

    • если ширина нечетная и число инверсий четное, то позиция решаемая (иначе - нерешаемая)

    • если ширина четная:

      1. Найти номер строки пустой клетки, считая снизу

      2. Посмотреть на число инверсий:

        • если число инверсий четное и номер строки пустой клетки нечетный, то позиция решаемая (иначе - нерешаемая)

        • если число инверсий нечетное и номер строки пустой клетки четный, то позиция решаемая (иначе - нерешаемая)

Пример:

  12  13  11   2
   4   5   3  14
   1   9  15   6
   8   7   0  10
  1. Посчитать количество инверсий - 52

  2. Ширина пазла четная, номер строки пустой клетки - 1

  3. Число инверсий четное (52), номер строки пустой клетки нечетный (1), поэтому позиция решаемая (в 57 ходов)

Что делать, если позиция нерешаемая? Как я уже говорил ранее, можно перемешивать массив чисел, пока не получим решаемую позицию. А можно воспользоваться небольшой хитростью:

Поменяв местами два самых больших числа (14 и 15 для 4x4), мы поменяем четность инверсий

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

Вот откуда берутся дополнительные 0.5 на графике - в 50% случаев мы сразу получим правильную позицию, а в остальных нам нужно будет сделать одну дополнительную операцию (16-ю), что в результате дает нам 15.5 операций обмена в среднем.

Расширение игры

Различные ширина и высота

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

Змейка, спираль и прочие

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

   1   2   3   4
   8   7   6   5
   9  10  11  12
   0  15  14  13

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

Для каждого числа k в позиции (слева направо, сверху вниз)

То есть мы обходим клетки в порядке чисел финальной позиции (o - начало, x - конец):

   o   →   →   →
   →   →   →   →
   →   →   →   →
   →   →   →   x

Но для "змейки" порядок другой:

   o   →   →   ↘
   ↙   ←   ←   ↙
   ↘   →   →   ↘
   x   ←   ←   ↙

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

И не нужно проверять ширину

Чтобы это понять, посмотрим, что происходит, когда мы делаем ход в змейке:

   1   2   3   4
   8   7   6   5
  >9<  10  11  12
  >0<  15  14  13

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

   -   -   -   -
   -   -   -   -
  >0<  10  11  12
  >9<  15  14  13

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

   1   2   3
   6  >0<  4
   7  >5<  8
   -   -   -
   6  >5<  -
   7  >0<  -

Как мы видим, четность инверсий не меняется.

Алгоритм прост:

  1. Обходим клетки в порядке, в котором числа находятся в финальной позиции, считаем количество инверсий

  2. Если число инверсий четное, позицию можно решить, иначе - нельзя

Для такой "спирали":

   o   →   →   ↘
   ↗   →   ↘   ↓
   ↑   x   ↙   ↓
   ↖   ←   ←   ↙

Алгоритм аналогичный. Также алгоритм будет работать для любой конфигурации, если мы можем нарисовать линию, не отрывая ручку от бумаги:

  ↗   ↘   x   o
  ↑   ↓   ↑   ↓
  ↑   ↘   ↗   ↓
  ↖   ←   ←   ↙

Случайное отсутствующее число

Есть еще одна интересная конфигурация пятнашек: вместо удаления 16 с поля, удаляем любое другое случайное число.

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

  12   8   7  15
   0   6   4   1
  10   9  13  11
   3  16  14   5

В этой позиции отсутствует число 2, 50 инверсий и пустая клетка стоит в третьей строке (помним, что считаем снизу), то есть, позиция решаемая. Однако, попробовав решить ее вы получите нечто такое (11 и 10 в обратном порядке):

   1   0   3   4
   5   6   7   8
   9  11<>10  12
  13  14  15  16 

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

Что тут можно сделать?

Универсальный алгоритм

Давайте обратим внимание на две вещи:

  1. Четность количества инверсий в стартовой и финальной позиции

  2. Четность номера строки пустой клетки в стартовой и финальной позиции

Для пазлов с нечетной шириной мы проверяем, совпадает ли четность числа инверсий для стартовой и финальной позиции.

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

Обобщая, алгоритм достижимости из стартовой позиции S финальной позиции G такой:

  1. Посчитать количество инверсий в G - I(G)

  2. Посчитать количество инверсий в S - I(S)

  3. Если:

    • ширина нечетная, а четность I(G) и I(S) совпадает, G достижимо из S (другими словами, S - решаемая позиция)

    • ширина четная:

      1. Найти номер строки1 пустой клетки в G - B(G)

      2. Найти номер строки пустой клетки в S - B(S)

      3. Если I(G):

        • четно, а четность I(S) и B(G) - B(S)2 совпадает, G достижимо из S

        • нечетно, а четность I(S) и B(G) - B(S) различается, G достижимо из S

  4. В прочих случаях G недостижимо из S

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

2 B(G) - B(S) можно заменить на B(G) + B(S), потому что нам нужна только четность

Hidden text

На самом деле, нам не нужны никакие дополнительные алгоритмы.

Можно просто делать отображение нужной нам позиции на позицию классических пятнашек.

Реализация алгоритма на Java:

/**
 * Подсчет количества инверсий в {@code list}
 */
int inversions(
    List<Integer> list
) {
    int inversions = 0;
    int size = list.size();
    for (int i = 0; i < size; i++) {
        int n = list.get(i);
        if (n <= 1) {
            // для 0 и 1 проверка не нужна
            continue;
        }
        for (int j = i + 1; j < size; j++) {
            int m = list.get(j);
            if (m > 0 && n > m) {
                inversions++;
            }
        }
    }
    return inversions;
}

/**
 * Проверка на то, что состояние {@code goal} достижимо из состояния
 * {@code start} для пазла шириной {@code width}
 */
boolean isSolvable(
    List<Integer> start,
    List<Integer> goal,
    int width
) {
    int startInversions = inversions(start);
    int goalInversions = inversions(goal);
    if (width % 2 == 0) {
        int goalZeroRow = goal.indexOf(0) / width;
        int startZeroRow = start.indexOf(0) / width;
        if (goalInversions % 2 == 0) {
            return startInversions % 2 == (goalZeroRow + startZeroRow) % 2;
        } else {
            return startInversions % 2 != (goalZeroRow + startZeroRow) % 2;
        }
    } else {
        // четность 'startInversions' и 'goalInversions' должна совпадать
        return startInversions % 2 == goalInversions % 2;
    }
}

Бонус: случайные цели

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

Заключение

Спасибо, что дочитали статью до конца. Надеюсь, вы теперь знаете чуть больше о пятняшках.

И, если хотите поиграть в саму игру, попробуйте мою реализацию на Android.

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


  1. Zara6502
    05.04.2024 17:53
    +4

    плюсы поставил, но я 15-ки не хотел играть уже лет в 6


  1. sinc
    05.04.2024 17:53
    +1

    Случайно перемещать пустую клетку не эффективно?


    1. Politura
      05.04.2024 17:53

      В статье про это написанно:

      Проблема с этим методом в том, что на каждой итерации алгоритма (где мы перемещаем 0 в выбранную клетку) в среднем мы будем делать ~2.67 операций обмена в массиве состояния (всего ~400 за 150 итераций). И хотя это не будет заметно для современных компьютеров (и телефонов), есть вариант получше.


    1. italankin Автор
      05.04.2024 17:53

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


      1. datacompboy
        05.04.2024 17:53
        +3

        Набор случайных ходов, стартовавших с финальной позиции всегда решаем. Вот только не всегда интересен, там главная проблема оценки сложности задания (можно сделать 100 случайных ходов а для решения потребуется 2)


        1. sinc
          05.04.2024 17:53

          можно сделать 100 случайных ходов а для решения потребуется 2

          Вот это очень интересный момент. Это сходу не совсем очевидно. Надо выяснить почему это может произойти.


          1. datacompboy
            05.04.2024 17:53
            +1

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

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


  1. Moog_Prodigy
    05.04.2024 17:53

    А не этот ли принцип положен в основу "Bird sort puzzle" на мобилках? Мне что-то игра так зашла, а на ПК я подобное не могу найти. Действительно, хоть сам пиши. Это все игры про сортировку птичек, переливание жидкостей и такое вот)


    1. italankin Автор
      05.04.2024 17:53

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


  1. lgorSL
    05.04.2024 17:53
    +1

    Поменяв местами два самых больших числа (14 и 15 для 4x4), мы поменяем четность инверсий

    Но ведь можно просто поменять местами любые две соседних ячейки (главное чтобы среди них пустой не было)


    1. italankin Автор
      05.04.2024 17:53

      Да, думаю можно и так. Вариант с 14 и 15, кажется, чуть проще в реализации, т. к. не нужно проверять наличие пустой клетки.
      Думаю, можно поменять любые два числа местами. Не проверял эту теорию для других конфигураций, но, кажется, должно и там работать.


  1. MaximArbuzov
    05.04.2024 17:53

    Можно немного поменять правила решения - нужно расставить первые 13 фишек по порядку, а 14 и 15 в любом порядке. Тогда все начальные комбинации будут решаемые.


    1. datacompboy
      05.04.2024 17:53
      +1

      Это некрасиво, а еще усложняет сборку -- заранее не знаешь, в какое место надо привести 14 и 15.


    1. sinc
      05.04.2024 17:53

      Совсем нет. Если 15 и 14 в финальной позиции поменять - решения нет.


  1. AnohinDen
    05.04.2024 17:53

    Для классических пятнашек есть алгоритм проще: Берём любые две ячейки и меняем местами. Решение есть только для чётного количества перестановок.


    1. italankin Автор
      05.04.2024 17:53

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


      1. Akina
        05.04.2024 17:53

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


        1. AnohinDen
          05.04.2024 17:53

          Для классических любые две.

          Причём и пустую тоже. Вот совсем непринципиально. В случайном порядке.

          Вопрос только в четности. Причём кажется даже размер поля не влияет.

          Давно было, писали как лабу, ещё на паскале. И тоже был вопрос в решаемости. Тогда ещё интернета толком не было. Тупо методом проб нашли решение.


          1. AnohinDen
            05.04.2024 17:53

            Вот с пустой кажись погорячился. Трогать её нельзя по этому алгоритму.

            Она вносит сумятицу.

            А вот размерность может быть любая, и желаемое расположение тоже произвольное, хоть зигзагом )

            Просто четность перестановок.

            Это исходит из самой сути игры.


            1. AnohinDen
              05.04.2024 17:53

              Пустую ячейку тоже можно трогать, но нужно обязательно соблюсти четность и для неё.


      1. AnohinDen
        05.04.2024 17:53

        Просто проверьте )))


      1. AnohinDen
        05.04.2024 17:53

        И не надо вообще никакой огород городить. Простите что сломал вам всю математику )


        1. AnohinDen
          05.04.2024 17:53

          И решаемость в 50% как бы намекает ;)


      1. AnohinDen
        05.04.2024 17:53

        Нет там вертикали или горизонтали. Есть только четность. Забудь что написано на костяшках это не принципиально. Восстановить можно только пройдя путь в обратную сторону. Отсюда и четность. Один раз идёшь вперёд, другой раз назад.

        2n перестановок.


    1. AnohinDen
      05.04.2024 17:53
      +1

      И доказывается ведь элементарно )))

      Если мы переставляем любые две фишки то решения нет, а если сделать это ещё раз то решение есть.

      Это как с 14 и 15. Представьте что именно их вы переставляли в обоих случаях.


  1. arpeggio
    05.04.2024 17:53

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


    1. 314159abc
      05.04.2024 17:53
      +1

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


      1. Akina
        05.04.2024 17:53
        +1

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


      1. arpeggio
        05.04.2024 17:53

        Именно такие у меня и были. Я прямо сильно удивился, узнав из статьи о несудимости половины позиций.


        1. Mes
          05.04.2024 17:53

          Такого быть не может. Если плитки можно вытаскивать и класть обратно в коробку в произвольном порядке, то с большим шансом можно столкнуться с вариантом расположения плиток 13, 15, 14 - это нерешаемый вариант


          1. Akina
            05.04.2024 17:53

            [offtop] Ну... 50% можно, конечно, называть большим шансом... но уж слишком он детерминированный, чтобы применять качественные оценки. [/offtop]


  1. Kahelman
    05.04.2024 17:53
    +2

    Если мы будем хранить 16! / 2 позиций в виде массива из 16 32-битных чисел, нам потребуется примерно 608 Т

    не слишком круто тратить 32 бита на кодирование?

    Чтобы число от 0 до 16 закодировать достаточно 5 бит.

    Массив 4x4 = 16 элементов 16x5 = 80 бит = 10 байт на позицию.

    Берем расскладку 1-15 (собранная),

    1,2,3,4

    5,6,7,8

    9,10,11,12

    13,14,15, 0

    если ее повернуть на 90 градусов влево, получим

    13,9,5,1

    14,10,6,2,

    15,11,7,3,

    0, 12,8,4

    т.е. существуют 4 вирианта отображения позиции,

    кодируем еще 3-мя битами.

    таким обазом у нас уходит 11 байт на все про все, плюс 7 бит на дополнительные флаги.

    (20922789888000/4) * 11 (байт на позицию)/(1024*1024*1024*1024) = ~52 TB

    Поскольку нам нужны только решаемые позиции, то их будет половина, т.е. надо 26 TB.

    Начальная оценка автора была 608 TB, мы уложились в 26 TB, что в 26 раз меньше первоначальной оценки.

    Из курса физики мы знаем, что значением в формуле можно пренебречь, если оно в 10 и более раз меньше остальных згачений, т.е. размером хранимых данных можно пренебречь и хранить все позиции в памяти :)

    Допустим что игрок собирает пятнашки за 1 секунду,

    считаем что он непрерывно играет 24x7*100 лет подряд, тогда нам надо сгенерировать

    60*24 * 365 *100 = 52560000 позиций,

    исходя из 11 байт на позицию получаем всего лишь 551 Mb. :)

    что по современным меркам вообще не размер.

    Дерзайте!

    P.S. для тех кто решит все 52560000 позиций, предложите бесплатную подписку на следующую версии игра с новыми 52560000 позициями :)


    1. italankin Автор
      05.04.2024 17:53
      +1

      А еще можно дополнительно отбрасывать зеркальные позиции, позиции с числами в обратном порядке (может еще какие-нибудь конфигурации найдутся) - примерно еще раз в 8 объем сократим.

      Но этот вопрос уже за рамками моей статьи :)


    1. Akina
      05.04.2024 17:53
      +1

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


    1. italankin Автор
      05.04.2024 17:53

      плюс 7 бит на дополнительные флаги

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

      Допустим что игрок собирает пятнашки за 1 секунду,

      Если мы не ищем оптимальные решения (а нам нужно найти хоть какое-то решение), то компьютер будет решать намного быстрее.

      На Ryzen 9 7900X у меня уходит около 5мс на позицию, т. е. `16!` позиций можно проверить за ~1200 тысяч дней. Если написать алгоритм решения на Rust (мой написан на Java), взять более мощный процессор, решать в несколько потоков (и/или распараллелить на несколько машин), то вполне можно уложиться в несколько лет.


    1. Viacheslav01
      05.04.2024 17:53

      Чтобы число от 0 до 16 закодировать достаточно 5 бит.

      Там числа от 0 до 15, достаточно 4 бит.


  1. alexejisma
    05.04.2024 17:53

    Существует и другой, как мне кажется более простой, способ проверять решаемость головоломки. Заключается он в том, чтобы вычислить некую характеристику для start и для goal и сравнить ее: если характеристика совпала, то существует "путь" от позиции start к позиции goal.

    Теперь непосредственно про вычисление характеристики.

    1. Необходимо обойти головоломку "змейкой", то есть обойти в следующем порядке:

       1  2  3  4
       8  7  6  5
       9 10 11 12
      16 15 14 13

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

    2. Выписать встретившиеся значения фишек, то есть выписать только ненулевые значения ячеек. Полученная набор чисел является перестановкой множества чисел от 1 до 15.

    Для приведенного автором статьи примера

      12 13 11  2
       4  5  3 14
       1  9 15  6
       8  7  0 10

     числа будут выписаны в следующем порядке: 12, 13, 11, 2, 14, 3, 5, 4, 1, 9, 15, 6, 10, 7, 8.

    3. Характеристикой является четность описанной в прошлом пункте перестановки. Как посчитать четность? Отсортировать массив, подсчитав количество swap'ов. Четностью перестановки будет четность количества swap'ов.

    Какова сложность данного алгоритма?

    По памяти: \mathcal{O}(m n).

    По времени: \mathcal{O}(m n).

    P.S. Может возникнуть вопрос почему сложность по времени , а не \mathcal{O}(m n). Ошибки здесь нету. Все дело в том, что в данной задаче массив имеет специфичный вид и за счет этого его можно отсортировать за линейное время. Кому интересно предлагаю самостоятельно разобраться почему это так, я же просто оставлю код сортировки массива, являющегося перестановкой множества \{ 0, \, 1, \, 2, \, \ldots, \, k-1 \}:

    for (int i = 0; i < perm.length; i++) {
        while (perm[i] != i) {
            // swap(a, i, j) меняет местами a[i] и a[j]
            swap(perm, i, perm[i]);
        }
    }

    Отмечу, что в отсортированном массиве perm[i] == i для всех i от 0 до perm.length и что именно это свойство является ключевым.


    1. datacompboy
      05.04.2024 17:53

      Не понял, и чем же это проще?


      1. alexejisma
        05.04.2024 17:53
        +1

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

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


        1. datacompboy
          05.04.2024 17:53

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


          1. Akina
            05.04.2024 17:53

            Так это обычная подстановка. Расчётное M на фишке соответствует реальному N.


    1. alexejisma
      05.04.2024 17:53

      Что-то я накосячил с оформлением формул в пост скриптуме. Я хотел написать ".. O(mn), а не O(mn log(mn))".