Вообще‑то я не особый любитель игр. Но прочитал тут на любимом Хабре про Кужлёвку и захотелось в это дело поиграть. Не буду утомлять описанием игры, скажу только что игра на мой взгляд исключительно достойная, хотя и не без серьёзных (опять‑таки на мой дилетантский взгляд) недостатков. Перехожу к делу. Первый (и пока единственный) затык у меня случился в эпизоде, где Михалычу нужно собрать Серп и Молот из плиток типа пятнашек. Помучившись с этим часа полтора, я понял, что не смогу этого сделать даже за миллион. Хотя может конечно я просто тупой как пробка. Но на берегу спасённый мной мечехвост ждёт сигаретку! Не могу же я бросить древнее живое существо одно, да ещё без курева! Так что пойдём добывать сигареты!

Первым делом сделаем скриншот игры с головоломкой, загрузим его в графический редактор (я использую gimp) и перенумеруем в нём все плитки числами от 0 до 7. Числом 8 занумеруем пустую плитку (дырку):

Пронумерованный скриншот из игры. Размер 741x741.
Пронумерованный скриншот из игры. Размер 741x741.

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

Решение на картинке
Решение на картинке

Забудем теперь про картинки и будем смотреть только на номера плиток. Тогда задача сформулируется так:

Есть таблица размером 3x3, в которой записаны числа от 0 до 8. Каждым ходом разрешается менять позиции числа 8 и одного из его соседей по горизонтали или вертикали. Требуется найти последовательность ходов, преобразующих таблицу

0

1

2

3

4

5

6

7

8

в таблицу

1

0

4

6

3

5

2

7

8

Поможет нам в этом волновой алгоритм. Волновой алгоритм это алгоритм поиска пути в лабиринте, моделирующий распространение волны в некоей среде в соответствии с принципом Гюйгенса — каждая точка волнового фронта является независимым источником волны. Среда тут может обладать самыми разнообразными свойствами. Содержать препятствия, давать штрафы или поощрения на некоторые пути и т. п. Алгоритм хоть и не очень знаком начинающим, однако применяется повсюду, где грубо говоря требуется попасть из точки А в точку В (либо определить, что такой путь невозможен). От трассировки топологии чипов, до известной офисной игры Lines.

Забудем теперь и про таблицы. Вместо таблиц будем использовать массивы целых, в которых элементы таблицы записаны слева направо, сверху вниз. Например исходная таблица представляется массивом {0, 1, 2, 3, 4, 5, 6, 7, 8}, а целевая — {1, 0, 4, 6, 3, 5, 2, 7, 8}. Среда в которой распространяется наша волна и представляет собой множество таких массивов. Точнее не совсем так. Алгоритм завершится успехом (он может завершиться и не успешно!), когда мы достигнем целевой точки — массива {1, 0, 4, 6, 3, 5, 2, 7, 8}. Но нас интересует не сам факт успеха, а путь от начального массива к целевому. Значит вместе с массивом изображающим таблицу, среда должна хранить ещё и ссылку на предыдущий узел (из которого волна пришла в данную точку). Для самого первого узла (содержащего начальный массив) эта ссылка очевидно будет пустой. Таким образом двигаясь от целевого узла по предыдущим, мы вытащим задом наперед весь путь. Ну и раз уж узел среды более сложен чем просто массив целых, в нём можно заодно хранить и ещё что‑то полезное. Будем там заодно хранить номер передвигаемой плитки (т. е. число, которое при переходе от предыдущего, меняется позициями с восьмёркой). Напишем класс (будем всё писать на java), представляющий узел среды:

class Node
{
  	public int[] value;    //массив перестановок
	public Node  parent;   //предыдущий узел
	public int act;        //активная (перемещаемая) плитка
	Node() {value=null; parent=null; act=-1;}
}

В словесном описании алгоритм выглядит примерно так:

  1. Создаем первый узел среды, значение которого установим в {0, 1, 2, 3, 4, 5, 6, 7, 8}, и помещаем его в волновой фронт и список узлов.

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

  3. Получаем позицию восьмерки и список её соседей.

  4. Для каждого соседа восьмерки создаем новый массив, в котором этот сосед и восьмерка меняются местами.

  5. Если такой массив уже был, ничего не делаем. Если этот массив ещё не встречался, создаем новый узел, с этим массивом в качестве значения. В качестве предыдущего указываем узел из старого волнового фронта. И в качестве активной плитки - число, с которым восьмерка менялась местами. Включаем этот узел в список узлов и новый волновой фронт. Проверяем, не является ли полученный массив целевым. Если да, то успешное завершение.

  6. Если новый волновой фронт пуст, не успешное завершение. Если не пуст, переходим к пункту 2.

Ниже приведена программа на java полностью решающая задачу. Не пинайте ногами, если коряво получилось, писал её по-быстрому. Кроме собственно волнового алгоритма, она ещё выводит картинки, показывающие ходы, и возникающие при этом позиции. Манипуляции с картинками довольно просты, и я их здесь не рассматриваю. Для рисования картинок ей нужен файл puzzle.png. Чтобы получить его, просто сохраните под этим именем первую картинку в статье.

Текст программы:

Hidden text
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import javax.imageio.ImageIO;

public class Main 
{
	//Копирует плитку с номером p0 из исходного изображение img0 в позицию p1 изображения img1.
	//Если hl=true, плитка подсвечивается
	static void copy(int p0, BufferedImage img0, int p1, BufferedImage img1, boolean hl)
	{
		int ww=img0.getWidth()/3;
		int hh=img0.getHeight()/3;
		int y0=(p0/3)*hh;
		int x0=(p0%3)*ww;
		int y1=(p1/3)*hh;
		int x1=(p1%3)*ww;
		for(int i=0; i<hh; i++)
		{
			for(int j=0; j<ww; j++)
			{
				int rgb=img0.getRGB(x0+j, y0+i);
				if(hl)
				{
					int d=100;
					int r=((rgb>>16)&255)+d, g=((rgb>>8)&255)+d, b=(rgb&255)+d;
					if(r>255) r=255;
					if(g>255) g=255;
					if(b>255) b=255;
					rgb=(255<<24)+(r<<16)+(g<<8)+b;
					
				}
				img1.setRGB(x1+j, y1+i, rgb);
			}
		}
	}
	
	//Строит изображение img1 из исходного изображения img0 в соответствии с массивом перестановок плиток a.
	//hl - номер подсвеченной плитки.
	static void makeAll(int[] a, BufferedImage img0, BufferedImage img1, int hl)
	{
		for(int i=0; i<a.length; i++)
		{
			copy(a[i], img0, i, img1, (i==hl));
		}
	}

	//Выводит позицию, заданную массивом перестановок плиток a в файл оut. Плитка с номером hl подсвечивается.
	static void outPosition(int[] a, String out, int hl)
	{
		try
		{
			BufferedImage img=ImageIO.read(new File("puzzle.png"));
			int ww=img.getWidth();
			int hh=img.getHeight();
			int type=img.getType();
			BufferedImage img1=new BufferedImage(ww, hh, type);
			makeAll(a, img, img1, hl);
			ImageIO.write(img1, "png", new File(out));
		}
		catch (Exception e)
		{
			e.printStackTrace();
		}
	}
	
	//Класс узлов среды
	static class Node
	{
		public int[] value;    //массив перестановок
		public Node  parent;   //предыдущий узел
		public int act;        //активная (перемещаемая) плитка
		Node() {value=null; parent=null; act=-1;}
	}
	static String target;           //Наша целевая строка
	static HashSet<String> field;   //Множество уже пройденных строк
	static ArrayList<Node> nodes;   //Массив узлов
	static ArrayList<Node> front;   //Фронт волны
	
	//Получает массив перестановок (поле value узла Node)
	//Выдает массив, нулевой элемент которого позиция числа 8, а остальные - соседи числа 8.
	static int[] getNeibours(int[] val)
	{
		int pos=0;
		for(int i=0; i<val.length; i++)
		{
			if(val[i]==8) {pos=i; break;}
		}
		if((pos<0)||(pos>8)) return null;
		int x=pos%3, y=pos/3;
		int n=0;
		int[] a=new int[6];
		a[n++]=pos;
		if(x-1 >= 0) a[n++]=pos-1;
		if(x+1 <  3) a[n++]=pos+1;
		if(y-1 >= 0) a[n++]=pos-3;
		if(y+1 <  3) a[n++]=pos+3;
		int[] b=new int[n];
		for(int i=0; i<n; i++) b[i]=a[i];
		return b;
	}
	
	//Массив перестановок в строку
	static String toString(int[] val)
	{
		String s="";
		String a="012345678";
		for(int i=0; i<val.length; i++) s+=a.charAt(val[i]);
		return s;
	}
	
	//Шаг волнового алгоритма
	static int step()
	{
		ArrayList<Node> tmp=new ArrayList<>(); //новый волновой фронт
		for(int i=0; i<front.size(); i++) 
		{//Проходим по всему старому волновому фронту
			Node nd=front.get(i);
			int[] neib=getNeibours(nd.value); //массив соседств
			int pos=neib[0];
			if(neib != null)
			{
				for(int j=1; j<neib.length; j++)
				{//для каждого из соседей меняем его местами с числом 8
					int[] val=new int[nd.value.length];
					for(int k=0; k<nd.value.length; k++) val[k]=nd.value[k];
					int t=val[pos];
					val[pos]=val[neib[j]];
					val[neib[j]]=t;
					if(field.add(toString(val))) //смотрим, проходили мы уже такую позицию или нет 
					{//если проходили, добавляем узел в список узлов и новый волновой фронт 
						Node nd1=new Node();
						nd1.value=val;
						nd1.parent=nd;
						nd1.act=neib[j];
						tmp.add(nd1);
						nodes.add(nd1);
						if(target.equals(toString(val))) 
							return 1; //если получили целевую строку, дальше ничего делать не надо
					}
				}				
			}
		}
		if(tmp.size()==0) 
			return -1; //если новый волновой фронт пустой, путь к целевой строке невозможен
		front=tmp; //обновляем волновой фронт
		return 0;
	}
	
	//Извлечение пути от конца к началу
	static ArrayList<Node> extractPath()
	{
		ArrayList<Node> path=new ArrayList<>();
		Node nd=nodes.get(nodes.size()-1);
		int act=-1;
		do
		{
			Node nd1=nd.parent;
			int act1=nd.act;
			nd.act=act;
			path.add(nd);
			nd=nd1;
			act=act1;
		} while(nd != null);
		return path;
	}
	
	//Выводит одну суммарную картинку
	static void makeTotal()
	{
		try
		{
			BufferedImage img=ImageIO.read(new File("puzzle.png"));
			int ww=img.getWidth();
			int hh=img.getHeight();
			int type=img.getType();
			int gap=30; //промежуток между картинками
			BufferedImage img1=new BufferedImage(5*ww+4*gap, 4*hh+3*gap, type);
			Graphics2D gr=img1.createGraphics();
			gr.setPaint(new Color(255, 255, 255));
			gr.fillRect ( 0, 0, img1.getWidth(), img1.getHeight());
			
			int nn=1;
			int xx=0, yy=0;
			for(int i=0; i<4; i++)
			{
				xx=0;
				for(int j=0; j<5; j++)
				{
					String name="./solution/step_"+nn+".png";
					nn++;
					BufferedImage imgn=ImageIO.read(new File(name));
					int w=imgn.getWidth(), h=imgn.getHeight();
					for(int y=0; y<h; y++)
					{
						for(int x=0; x<w; x++)
						{
							int rgb=imgn.getRGB(x, y);
							img1.setRGB(x+xx, y+yy, rgb);
						}
					}
					xx+=ww+gap;
				}
				yy+=hh+gap;
			}
			ImageIO.write(img1, "png", new File("./solution/total.png"));
		}
		catch (Exception e)
		{
			e.printStackTrace();
		}		
	}
	
	static void solve()
	{	
		field=new HashSet<>();
		nodes=new ArrayList<>();
		front=new ArrayList<>();
		target="104635278";
		int[] a= new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8};
		Node nd0 = new Node();
		nd0.value=a;
		field.add(toString(a));
		nodes.add(nd0);
		front.add(nd0);
		int res=0;
		while(res==0) res=step();
		if(res==1) 
		{
			System.out.println("Puzzle solved. Now save pictures");
			ArrayList<Node> path=extractPath();
			for(int i=0; i<path.size(); i++)
			{
				outPosition(path.get(i).value, "./solution/step_"+(path.size()-i)+".png", path.get(i).act);
			}
			makeTotal();
			System.out.println("Done");
		}
		else
		{
			System.out.println("Solution not found");
		}
	}
	
	public static void main(String[] args) 
	{                     
		solve();
	}
}

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

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

А вот так прикольно это выглядит на анимации:

Всего 20 ходов и откроется портал к товарищу Сталину. У него мы сигаретами и разживемся!

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


  1. thevlad
    28.05.2023 09:23
    +3

    Только это не волновой алгоритм(который является частным случаем алгоритма Дейкстры для регулярной сетки), а обычный поиск в глубину/ширину. Да и как по мне реализованный довольно вырвиглазно. (но это субъективно)


    1. eugenk Автор
      28.05.2023 09:23

      Вобщем-то где-то как-то так и есть. Главное что оно позволило мне пройти в игрухе уровень, чего бы я ручками стопудово не сделал :)))


      1. oleg420mlg
        28.05.2023 09:23

        Так там ж пропустить пятнашки можно!


        1. eugenk Автор
          28.05.2023 09:23

          Ну пятнашки и есть. В самом начале об этом пишу. Только доска 3x3, а не 4x4 как в классических. Так что даже проще. Однако решение занимает 20 ходов. Причем это КРАТЧАЙШЕЕ решение ! Вы уверены, что сами способны найти его ручками ??? Я мучился часа полтора если не два, прежде чем перейти к радикальным методам.

          Там что, ещё где-то пятнашки есть ??? Или Вы про эти ??? Я вобщем-то только начал играть. Дошел до пока обувного магазина. Так что не особо знаю, что там можно пропустить, что нельзя. Вообще игра очень понравилась.


          1. oleg420mlg
            28.05.2023 09:23

            Да не, я к тому, что их скипнуть можно прямо в игре. А так — алгоритм достаточно простой, если понять, какая плиточка отсутствует. Если нижняя правая (как в игре), то надо сперва верхний ряд собрать, потом средний. После этого спокойно дособирается и нижний.


          1. rombell
            28.05.2023 09:23
            +1

            Да ладно. Простейшая же задача. Вы в настоящие пятнашки играли? По сравнению с ними (которые сами по себе — элементарщина) это — ерудна, точно так же собирается первый слой, левый столбец, остаток крутится


            1. eugenk Автор
              28.05.2023 09:23

              Давным-давно. Говорю же, возможно я изрядно туповат, но с этим не справился.


  1. rombell
    28.05.2023 09:23
    +4

    Первым делом сделаем скриншот игры с головоломкой

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


    1. eugenk Автор
      28.05.2023 09:23

      Спасибо, поправил. Тут редактор поменялся. Раньше был тег cut, а теперь вместо него текст статей фактически разбивается на две части - заголовок для ленты и основной текст. Я немного подзабыл об этом. Теперь надеюсь стало понятно ?


    1. oleg420mlg
      28.05.2023 09:23

      Да, один абзац как-будто куда-то пропал, судя по логлайнам этой статьи в ВК. Ето Кужлевка.


      1. eugenk Автор
        28.05.2023 09:23

        Спасибо, поправил.


  1. myswordishatred
    28.05.2023 09:23

    Пятнашки же вроде рекурсивно решаются вполне успешно.

    Есть у нас, например, поле n*n. Собираем сначала верхний и левый ряд, получаем поле (n-1)*(n-1). Ну и повторяем Собираем новый верхний и левый ряд, получаем поле (n-2)*(n-2). Собираем...

    А сами пятнашки в игре же вроде можно пропустить?


    1. eugenk Автор
      28.05.2023 09:23

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


      1. flange
        28.05.2023 09:23

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


        1. eugenk Автор
          28.05.2023 09:23

          Ну вобщем-то конечно это так, задача является NP-полной со всеми вытекающими. Но в данном конкретном случае просмотрено 51079 узлов. Во фронте волны 10877 узлов. На моём ноутбуке считается мгновенно. Глаз во всяком случае никакой задержки не замечает. Т.е. заведомо быстрее трети секунды. Всего состояний доски 9!=362880. Насколько я помню, достижимых из них ровно половина, т.е. 181440. Так что в этой конкретной задаче просматривается примерно треть всех достижимых состояний за треть секунды. Значит даже в наихудшем случае считать будет около секунды. Так что вполне ещё по-божески. Писал я тут на хабре и про куда более жуткие задачки https://habr.com/ru/articles/485786/ :)))


  1. OptimumOption
    28.05.2023 09:23

    Не суметь "ручками" решить "пятнашку" 3х3 за полтора часа?! Я был лучшего мнения о хабровчанах...


    1. eugenk Автор
      28.05.2023 09:23

      Ну тупой я, тупой блин как пробка ! Сколько раз можно повторять ! Вам что, справку из дурдома показать ??? Это мы можем, легко ! :)))