На сайте, наверное, с десяток реализаций головоломки Пятнашки. Реализовать просто, процесс залипательный, особенно если сделать тактильно, чтобы можно было мышкой перемещать костяшки на пустое место, и не по одной, а по две или три сразу, как в материальных пятнашках. Но у меня возник вдруг другой вопрос. А как собрать ее оптимально, за минимальное количество ходов из любого начального состояния. Есть у меня такое хобби, изучать разные занимательные механики и алгоритмы. Что ж, я поизучал вопрос и решил поделиться изысканиями. В общем, это не так просто.
📌 Лонгрид-предупреждение: Материал получился длинный, поэтому тут только преамбула и, в основном, теория.
1. Любое ли начальное состояние решаемо?
Всего существует 16! (20 922 789 888 000) способов расставить 16 элементов по клеткам. И только половина из них решаема.
Каждый, кто рассыпал в детстве костяшки пятнашек и собирал хаотично заново, знает, что нет, не каждое. В некоторых случаях последние две костяшки никак не собираются правильно, как ни крути, всегда идет 15, затем 14.
И так вопрос. Как из начального состояния определить, решается ли оно. Для начала соберем всю доску пятнашек в плоский одномерный массив. Второй ряд, поставим после первого, третий после второго и т.д.
Вот так будет выглядеть окончательно собранная головоломка, 0 - это пустая клетка:
[1,3,4,5,6,7,8,9,10,11,12,13,14,15,0]
Вот так - некий случайный расклад:
[15,2,11,7,4,3,5,1,10,14,0,6,12,9,13,8]
Решаемость проверяется через понятие инверсии. Инверсия - это пара элементов с нарушенным порядком, если порядок основной по возрастанию. В первом случае, в собранной головоломке - инверсий нет, если поле 0 мы не учитываем.
Если мы двигаем костяшку влево-вправо, это равносильно тому что мы двигаем 0 в массиве вправо-влево. При этом количество инверсий не меняется (0, напомню, мы не учитываем). А если мы двигаем костяшку вверх-вниз, то это равнозначно тому, что мы меняем местами 0 и элемент с индексом ±4. Это меняет количество инверсий на ±3. То есть - четность количества инверсий меняется. Но при этом меняется строка пустой клетки на ±1. То есть - четность строки пустой ячейки меняется. То есть - сумма количества инверсий и номера строки пустой ячейки не меняется.
Если мы всяко разно хаотично мешаем пятнашки только передвигая очередную костяшку в пределах поля на место пустой ячейки, не вынимая ее руками, то всегда получим только разрешимое состояние.
Для правильного расположения количество инверсий ноль, номер строки пустой ячейки - четный. То есть сумма тоже четная.
Соответственно, если в произвольном раскладе сумма количества инверсий и номера строки пустой ячейки - четное число - то расклад решаемый, иначе - нет.
Количество раскладов - это количество перестановок чисел от 0 до 15, то есть 16!. Ровно половина из них решаема.
Вот функция проверки:
Функция РешениеСуществует(Поле)
Инверсии = 0;
Плоский = Новый Массив(Поле);
НулевойИндекс = Поле.Найти(0);
Плоский.Удалить(НулевойИндекс);
Для i = 0 По Плоский.Количество() - 1 Цикл
Для j = i + 1 По Плоский.Количество() - 1 Цикл
Если Плоский[i] > Плоский[j] Тогда
Инверсии = Инверсии + 1;
КонецЕсли;
КонецЦикла;
КонецЦикла;
НомерСтрокиПустойЯчейки = 1 + Цел(НулевойИндекс / 4);
Инверсии = Инверсии + НомерСтрокиПустойЯчейки;
Возврат Инверсии % 2 = 0;
КонецФункции
Но это все преамбула. Самое интересное - это путь решения.
Наверное, каждый понимал, что хотя головоломку очень просто интуитивно собрать, тем не менее, мы действуем не оптимально. Перегоняем клетки по одной, при этом не смотрим на другие и в конце, бывает, мучаемся с перестановкой последних цифр.
2. Как же математически оптимально собрать пятнашку?
На гифке видно, как алгоритм "понимает", что перед тем как собирать 1-2-3 нужно 13-14-15 выстроить в правильном порядке и не нарушить его, сдвигая другие. Хотя на самом деле, ничего он не понимает, просто выстроил оптимальный маршрут и двигается по нему.

Как же построить этот маршрут?
На помощь приходит, как обычно в таких задачах, теория графов. Построим мысленно граф, в котором каждая вершина - это вариант размещения костяшек в коробке. А дуги соединяют смежные вершины, которые отличаются перестановкой одной клетки. Находим в графе наше начальное состояние, потом поиском в глубину или в ширину находим все пути к решению и выбираем минимальный. Все просто!
Ну да, только при поиске в ширину мы быстро исчерпаем всю мыслимую память, а в глубину - не дождемся окончания поиска. Но это только если мы будем двигаться вслепую, по всем дугам.
3. Эвристика
Чтобы не блуждать в темноте, введем оценочную функцию состояния, эвристику. Грубо прикинем, сколько примерно минимально ходов нужно. Например, костяшка "1" у нас на второй строке в третьем столбце. Чтобы переместить ее в начальную клетку нужно переместить на одну строку вверх и 2 клетки влево. На самом деле, конечно, больше. Но точно не меньше. То есть, рассчитаем манхэттенское расстояние (сумму пути по горизонтали и по вертикали) для каждой костяшки до ее места в поле. Пустую клетку не считаем, она и так встанет куда надо. Если мы сложим все такие манхэттенские расстояния - то получим грубую нижнюю оценку нашей позиции. То есть скорее всего нам потребуется больше ходов, но точно не меньше.
Чтобы немного уточнить оценку, введем еще учет линейных конфликтов. Если две костяшки стоят в одном столбце или строке, причем в своем столбце-строке, в том где они и должны быть. Но при этом их порядок перепутан. То манхэттенское расстояние даст черезчур оптимистично малую оценку. Нам же придется убирать одну фишку в сторону, а потом возвращать обратно. Для всех таких обнаруженных пар, добавим к оценке еще два балла.
Функция ОценкаСостояния(ПолеИгры)
МанхэттенскоеРасстояние = 0;
Для НомерСтроки = 0 По 3 Цикл
Для НомерСтолбца = 0 По 3 Цикл
ЗначЯчейки = ПолеИгры[НомерСтроки * 4 + НомерСтолбца];
Если ЗначЯчейки = 0 Тогда
Продолжить;
КонецЕсли;
МанхэттенскоеРасстояние = МанхэттенскоеРасстояние
+ АБС((ЗначЯчейки - 1) % 4 - НомерСтолбца)
+ АБС(Цел((ЗначЯчейки - 1) / 4) - НомерСтроки);
КонецЦикла;
КонецЦикла;
ЛинейныйКонфликт = 0;
// Проверка конфликтов в строках
Для НомерСтроки = 0 По 3 Цикл
Для НомерСтолбца = 0 По 3 Цикл
ЗначЯчейки = ПолеИгры[НомерСтроки * 4 + НомерСтолбца];
Если ЗначЯчейки = 0 ИЛИ Цел((ЗначЯчейки - 1) / 4) <> НомерСтроки Тогда
Продолжить;
КонецЕсли;
Для НомерСтолбца2 = НомерСтолбца + 1 По 3 Цикл
ЗначЯчейки2 = ПолеИгры[НомерСтроки * 4 + НомерСтолбца2];
Если ЗначЯчейки2 = 0 ИЛИ Цел((ЗначЯчейки2 - 1) / 4) <> НомерСтроки Тогда
Продолжить;
КонецЕсли;
Если ЗначЯчейки > ЗначЯчейки2 Тогда
ЛинейныйКонфликт = ЛинейныйКонфликт + 2;
КонецЕсли;
КонецЦикла;
КонецЦикла;
КонецЦикла;
// Проверка конфликтов в столбцах
Для НомерСтолбца = 0 По 3 Цикл
Для НомерСтроки = 0 По 3 Цикл
ЗначЯчейки = ПолеИгры[НомерСтроки * 4 + НомерСтолбца];
Если ЗначЯчейки = 0 ИЛИ (ЗначЯчейки - 1) % 4 <> НомерСтолбца Тогда
Продолжить;
КонецЕсли;
Для НомерСтроки2 = НомерСтроки + 1 По 3 Цикл
ЗначЯчейки2 = ПолеИгры[НомерСтроки2 * 4 + НомерСтолбца];
Если ЗначЯчейки2 = 0 ИЛИ (ЗначЯчейки2 - 1) % 4 <> НомерСтолбца Тогда
Продолжить;
КонецЕсли;
Если ЗначЯчейки > ЗначЯчейки2 Тогда
ЛинейныйКонфликт = ЛинейныйКонфликт + 2;
КонецЕсли;
КонецЦикла;
КонецЦикла;
КонецЦикла;
Возврат МанхэттенскоеРасстояние + ЛинейныйКонфликт;
КонецФункции
4. Метод информированного поиска
4.1. Алгоритм А*
Чем плоха эвристика? Тем что она очень оптимистична, особенно на начальных этапах. Она не убывает монотонно при приближении к цели и не возрастает при отдалении от нее. Если бы это было так, то была бы не эвристика, это было бы решение задачи. Просто иди в сторону меньшей оценки и быстро придешь к цели.
Если мы будем двигаться в сторону уменьшения оценки, то скорее всего не раз зайдем в тупик, из которого любой ход будет только усложнять последующие ходы и оценка вдруг начнет возрастать.
Для решения задачи придумали первый алгоритм информированного поиска в графе. Он называется A* (A-star, A со звездой). Звезду ему дали за то, что он математически лучший.
Авторы алгоритма доказали его оптимальность: никакой другой алгоритм, использующий ту же самую эвристику, не сможет исследовать меньше вершин, чем A*, и при этом гарантированно найти кратчайший путь.
Алгоритм A* был разработан в 1968 году тремя исследователями из Стэнфордского исследовательского института (Stanford Research Institute, SRI): Питером Хартом (Peter Hart), Нильсом Нильсоном (Nils Nilsson) и Бертрамом Рафаэлем (Bertram Raphael).
История создания:
Зачем он понадобился: Ученые работали над проектом Shakey — одним из первых мобильных роботов с элементами искусственного интеллекта. Роботу требовалось самостоятельно прокладывать оптимальный путь в помещении с препятствиями.
Эволюция подхода:
Сначала Нильс Нильсон предложил использовать эвристику (оценку расстояния до цели), но она не учитывала уже пройденный путь, из-за чего робот мог выбирать длинные траектории.
Бертрам Рафаэль предложил суммировать пройденный путь и эвристику.
Питер Харт математически доказал оптимальность этого метода и ввел понятия допустимости и согласованности эвристических функций.
Откуда имя: В своей научной статье авторы назвали базовый метод просто «алгоритмом А». Звездочка (*) была добавлена, так как в формальной нотации того времени этот символ часто обозначал оптимальную или эвристически наилучшую версию эвристического поиска.
Но он тоже требовательный к памяти. Потому что это вариант поиска в ширину.
Алгоритм A* работает как умный навигатор, который на каждом шаге выбирает следующую точку на основе двух вещей: сколько он уже прошёл и сколько, по его мнению, ещё осталось до цели.
Для этого алгоритм использует две очереди (или списка) и одну главную формулу.
Главная формула оценки: f(n) = g(n) + h(n)
Каждому перекрёстку (вершине (n)), который видит алгоритм, присваивается оценка привлекательности f(n):
- g(n) - реальная стоимость пути от самого старта до текущей вершины n. Это то, сколько шагов мы уже реально потратили.
- h(n) - эвристика, которую мы рассмотрели.
- f(n) - итоговая оценка пути через эту вершину. Чем меньше это число, тем перспективнее выглядит маршрут.
Два главных списка алгоритма
Алгоритм раскладывает все известные ему точки по двум кучкам:
- ОткрытыйСписок: Перекрёстки, которые мы уже обнаружили, посчитали для них оценку f(n), но ещё не заходили на них, чтобы осмотреться дальше. Это список кандидатов на следующий шаг.
- ЗакрытыйСписок: Перекрёстки, которые мы уже посетили и проверили все ответвления от них. Туда возвращаться больше не нужно.
Пошаговый алгоритм:
- Старт: Помещаем стартовую точку в ОткрытыйСписок. Её (g = 0), а f равна эвристике до цели.
- Поиск лучшего: Пока ОткрытыйСписок не пуст, берём из него вершину с самым маленьким значением f(n).
- Проверка на победу: Смотрим - не является ли эта выбранная вершина нашей целью?
- Если да, то ура, путь найден! Восстанавливаем маршрут по "хлебным крошкам" (указателям, откуда мы пришли).
- Шаг вперёд (исследование соседей): Если это ещё не цель, переносим текущую вершину в ЗакрытыйСписок. Затем смотрим на всех её соседних соседей:
- Если сосед уже в ЗакрытыйСписок - игнорируем (мы там уже были).
- Если сосед уже в ОткрытыйСписок - тоже игнорируем, пока не пришло его время, значит мы его когда-то уже оценивали, новая оценка с учетом уже пройденного пути, точно будет больше.
- Считаем для соседа новый потенциальный путь через текущую вершину.
- Если соседа ещё нет в ОткрытыйСписок - добавляем его туда и записываем, откуда мы к нему пришли (чтобы потом собрать путь обратно).
- Повтор: Возвращаемся к пункту 2.
- Если ОткрытыйСписок опустел, а до цели мы так и не дошли - значит, пути вообще не существует.
Но этого в нашем случае не может быть, мы были к этому готовы поэтому сразу не пошли бы этим путем.
Хороший и быстрый алгоритм. Но не подходит для пятнашек в чистом виде. Тоже моментально заполнит всю память под огромное количество узлов. На такой случай есть разные модификации с управлением занимаемой памятью - под списки выделяется фиксированный объем, а по мере заполнения из него удаляют наименее перспективные узлы.
Но это приводит к ухудшению эффективности - накладные расходы на менеджмент памяти и повторное прохождение одних и тех же маршрутов, которые "забыли", но вдруг пришлось вспомнить.
4.2. Алгоритм IDА*
Тут нам на помощь приходит второй алгоритм информированного поиска - IDA* (Iterative deepening A*, итеративно углубляющийся A*).
Алгоритм IDA* (Iterative Deepening A*, итеративно углубляющийся A*) был придуман американским ученым-информатиком Ричардом Корфом (Richard E. Korf) в 1985 году.
Главные особенности IDA*:
Зачем он нужен: Классический алгоритм A* хранит в памяти все открытые вершины (очередь с приоритетом), из-за чего на больших графах у него может полностью закончиться оперативная память. Корф разработал IDA* как гибрид A* и поиска в глубину с итеративным погружением, чтобы резко сократить потребление памяти.
Экономия памяти: В отличие от экспоненциального роста памяти в A*, алгоритм IDA* использует память, линейную по глубине поиска, что делает его незаменимым для решения сложных комбинаторных задач (например, пятнашек или кубика Рубика).
Алгоритм выполняет поиск в глубину, в отличие от предшественника. Он использует те же функции, что и предок: глубина текущего пути g(n), эвристика h(n) и итоговая сумма f(n).
Он ограничивает глубину поиска пороговым значением стоимости f(n) = g(n) + h(n).
- В качестве первого значения порога принимается значение функции эвристики h(n) в начальной вершине.
- Запускаем итерацию поиска с этим значением порога.
- Ведем поиск в глубину.
- Если мы уткнулись в тупик по порогу, то запоминаем минимальную оценку выше порога (МинимальнаяОценкаВышеПорога) из всех соседей тупиковой вершины.
- Возвращаемся к развилке - рекурсивно переходим к п.3, углубляемся в следующий возможный путь до следующего тупика.
- Если цель найдена - то ура! Выходим из рекурсии с возвращением значения
- Если тупик, то переходим к п. 3.a.
- Если цель не найдена при выходе из рекурсии, порог увеличивается до МинимальнаяОценкаВышеПорога, и переход к п.2.
- A* хранит в памяти всю карту исследованных окрестностей (дерево поиска целиком). Для пятнашек эта память кончается молниеносно.
- IDA* хранит в памяти только текущую ветку (длиной максимум в L шагов). Расход памяти растёт не экспоненциально, а линейно. Именно поэтому пятнашки на рекордную глубину решают именно через IDA*.
Естественно за это надо платить скоростью работы. Т.к. мы очень много раз проходим по одним и тем же узлам.
Но с этого хотя бы можно начать. И это еще не конец истории, дальше тоже будет все очень непросто. Такая вот простая игра "Пятнашки"...
Об этом я, надеюсь, расскажу в следующий раз...

Вступайте в нашу телеграмм-группу Инфостарт