In this article I will cover usage of breadth-first search in implementation of Shisen-Sho game.
The main difference with classic mahjong solitaire games is that to remove two tiles, they must have a connection, and the connection path must not exceeed two 90° turns (in other words: the connection can have at most 3 segments). The connection path must be free of tiles and can’t have diagonal segments.
There are 3 different types of connections:
One segment
Two segments
Three segments
There are several ways to find a path between the tiles.
Here’s description of algorithm which FreeShisen app uses:
ALGORITHM TO COMPUTE CONNECTION PATH BETWEEN PIECES A (IA,JA) AND B (IB,JB)
- Delete A and B from the board (consider them as blank spaces)
- Calculate the set H of possible horizontal lines in the board (lines through blank spaces)
- Calculate the set V of possible vertical lines in the board
- Find HA, VA, HB, VB in the sets
- If HA=HB, result is a straight horizontal line A-B
- If VA=VB, result is a straight vertical line A-B
- If HA cuts VB, the result is an L line A-(IA,JB)-B
- If VA cuts HB, the result is an L line A-(IB,JA)-B
- If exists an V line that cuts HA and HB, the result is a Z line A-(IA,JV)-(IB-JV)-B
- If exists an H line that cuts VA and VB, the result is a Z line A-(IV,JA)-(IV,JB)-B
Basically, this is a brute-force search, which is pretty ineffective. It also does not yield the shortest paths.
Another approach is implemented in kshisen:
Find two segments path:
There are two options for a possible path (red and green lines).
Find three segments path:
From first tile position go in all directions (left, top, right, bottom) and try to find a two segments path to second tile, for example:
It is the more efficient method, compared to the brute-force, since we perform search only in part of the game field. And most of the time this algorithm will provide the shortest paths.
In some cases the algorithm will produce suboptimal paths.
In a situation where two or more possible paths exist, the solution will depend on in which direction you go first in the 3rd step.
In breadth-first search version we will use a similar technique.
On each iteration of algorithm expand search in all directions (left, top, right, bottom), keeping track of number of remaining turns.
The outline of an algorithm to find a path from tile A
to tile B
:
queue
for storing nodesA
, add them to queue
queue
:
A
to B
, exitN
from queue
N
:
N
is B
, the path is found, exitN
is not B
, go to step 3N
is empty cell, expand search from this nodeIt is important to understand how we expand nodes. For each node we offset it’s x and y positions in all possible directions and thus make new nodes:
If we expand a node in the same direction as parent node - we don’t change number of remaining turns.
If old and new directions differ, decrease number of turns. If number of remaining turns becomes negative - stop expanding in that direction.
N.B. I will use Kotlin to implement the algorithm
First, create an array of arrays for storing game field, where null
is for an empty cell and Char
is for a tile:
val width = 6
val height = 4
// create field and initialize it with nulls
val gameField: Array<Array<Char?>> = Array(width + 2) { Array(height + 2) { null } }
Accessing tile at (x, y)
is easy:
val tile: Char? = gameField[x][y]
The width
and height
are size of the puzzle, and the extra 2
’s are needed for building paths for tiles
at the edges of the field.
Now, let’s throw some numbers tiles on the field:
val alphabet: MutableList<Char> = ('A'..'Z').toMutableList()
val tiles = ArrayList<Char>(width * height)
val tileCount = 4 // two pairs for each tile
for (i in 0 until (width * height / tileCount)) {
val tile = alphabet.removeFirst()
repeat(tileCount) {
tiles.add(tile)
}
}
// shuffle tiles
tiles.shuffle()
// fill the field
for (x in 1..width) {
for (y in 1..height) {
gameField[x][y] = tiles.removeLast()
}
}
After these manipulations, our gameField
will look like this (.
is empty cell):
. . . . . . . .
. B D E F D D .
. B C C B A A .
. A C F F A D .
. E F E E C B .
. . . . . . . .
Create SearchNode
class, which will hold information about nodes:
class SearchNode(
val x: Int, // x position of the node in the field
val y: Int, // y position of the node in the field
val turnsLeft: Int, // number of turns left
val direction: Direction?, // direction of expansion, will be `null` for initial node
val parent: SearchNode? // parent of this node, for tracking down the path
)
To simplify working with directions, create a separate Direction
class with corresponding dx
and dy
offsets:
enum class Direction(
val dx: Int,
val dy: Int
) {
UP(dx = 0, dy = -1),
DOWN(dx = 0, dy = 1),
LEFT(dx = -1, dy = 0),
RIGHT(dx = 1, dy = 0)
}
Point
class will be used to build the resulting path:
class Point(
val x: Int,
val y: Int
)
We will need two methods for finding a the path and expanding nodes:
fun findPath(
ax: Int, ay: Int, // coordinates of the first tile
bx: Int, by: Int // coordinates of the second tile
): List<Point> {
TODO("Not implemented")
}
fun expand(node: SearchNode): List<SearchNode> {
TODO("Not implemented")
}
Everything is ready for implementing the search algorithm.
For findPath
we need to do the following:
(ax, ay)
and (bx, by)
are not the same cell(ax, ay)
and (bx, by)
and their labels matchqueue
and add initial node to it (tile at (ax, ay)
)queue
is not empty:
node
from the queue
node
is the target node - if it is, path foundnode
is empty, expand itThe code:
fun findPath(
ax: Int, ay: Int, // coordinates of the first tile
bx: Int, by: Int // coordinates of the second tile
): List<Point> {
if (ax == bx && ay == by) {
// the same cell
return emptyList()
}
val a = gameField[ax][ay]
val b = gameField[bx][by]
if (a == null || b == null || a != b) {
// either one of cells is empty or tile labels does not match, no path
return emptyList()
}
val queue = ArrayDeque<SearchNode>()
// expand initial node
val children = expand(
SearchNode(x = ax, y = ay, turnsLeft = 2, direction = null, parent = null)
)
queue.addAll(children)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
if (gameField[node.x][node.y] != null) {
// don't need to check tile label, only coordinates
if (node.x == bx && node.y == by) {
// path found, trace back to build the list
val path = ArrayList<Point>()
var p: SearchNode? = node
while (p != null) {
path.add(Point(p.x, p.y))
p = p.parent
}
return path
} else {
// tile itself cannot be expanded
// continue to the next iteration
continue
}
}
// expand node
queue.addAll(expand(node))
}
// there's no path between a and b
return emptyList()
}
In expand
method, for every Direction
:
turnsLeft
0
, continue to next iterationnewX
and newY
In code:
fun expand(node: SearchNode): List<SearchNode> {
val children = ArrayList<SearchNode>()
for (direction in Direction.values()) {
val turnsLeft = if (node.direction == null || node.direction == direction) {
// if the direction is the same (or null, if it's the initial node)
// keep turnsLeft the same
node.turnsLeft
} else {
// we made a turn
node.turnsLeft - 1
}
if (turnsLeft < 0) {
// no turns left, try to expand in another direction
continue
}
val newX = node.x + direction.dx
val newY = node.y + direction.dy
if (newX !in 0..(width + 1) || newY !in 0..(height + 1)) {
// make sure we're not getting out of bounds
continue
}
// add node to list
children.add(SearchNode(newX, newY, turnsLeft, direction, node))
}
return children
}
Here is visualization of the algorithm (numbers - turns left, colors - iterations):
As you can see, the algorithm also expands in directions which are guaranteed to fail. We can use some tricks to prune unpromising nodes from the search set.
If tiles are next to each other, no need to run BFS
Manhattan distance is to the rescue:
So, we just check if distance is 1:
fun manhattan(x1: Int, y1: Int, x2: Int, y2: Int): Int {
return abs(x2 - x1) + abs(y2 - y1)
}
fun findPath(...): List<Point> {
// ...
if (manhattan(ax, ay, bx, by) == 1) {
return listOf(Point(ax, ay), Point(bx, by))
}
// run bfs
}
This simple check will eliminate startup cost of BFS.
Tiles should have at least one empty cell nearby - otherwise we can’t connect the tiles (unless they are next to each other)
It’s easy to see if one of the tiles is blocked, the path cannot be found:
So, let’s add another check to findPath
:
fun hasEmptyCells(x: Int, y: Int): Boolean {
return x - 1 >= 0 && gameField[x - 1][y] == null ||
x + 1 <= (width + 1) && gameField[x + 1][y] == null ||
y - 1 >= 0 && gameField[x][y - 1] == null ||
y + 1 <= (height + 1) && gameField[x][y + 1] == null
}
fun findPath(...): List<Point> {
// check manhattan distance
if (!hasEmptyCells(ax, ay) || !hasEmptyCells(bx, by)) {
return emptyList()
}
// run bfs
}
For the last two segments the distance to the second tile must decrease with each step - if it is not, we can stop search in corresponding direction
Here’s another place where manhattan distance comes in handy. Let’s we look closely what happens when we have paths with different number of segments:
For example, 3 segments path:
As you can see, the distance raises at the beginning (from 2
to 4
), then descreases as we approach target cell on
the x and y axes.
So, to improve our algorithm, we add a simple check:
fun expand(node: SearchNode, bx: Int, by: Int): List<SearchNode> {
// ...
for (direction in Direction.values()) {
// ...
if (turnsLeft < 2) {
// for the last two segments the distance
// must decrease on each step in path
val distance = manhattan(node.x, node.y, bx, by)
val newDistance = manhattan(newX, newY, bx, by)
if (distance < newDistance) {
// try another direction
continue
}
}
// add node to list
// ...
}
return children
}
Although BFS is not the optimal solution in terms of memory, it always finds the shortest paths and thus works well for shisen-sho.
The code can be found in my repository italankin/shisensho-base:
And if you haven’t played the game itself, you should definetely try it out. For example, download my Shisen-Sho for Android.
Also, kshisen is a good (and free) one to start, but I personally like Kyodai Mahjongg more.
]]>As a developer of 15 puzzle Android game, I need to generate starting puzzle positions for different puzzle configurations, which must always have a solution - and this, as it turns out, is a challenge.
In this post I will cover so-called solvability of 15 puzzle and different approaches to this topic for various puzzle sizes and types.
A classic 15 puzzle is a 4x4 grid of numbers from 1 to 15 and one blank square. It looks like this:
The goal of the game is to arrange tiles in order by sliding (vertically or horizontally) tiles:
A tile can be moved only if it stands next to blank (vertically or horizontally, but not diagonally):
Here’s an example of solving 3x3 variant:
It would be a bore to play the game, starting with the same position every time - we need to find a way of generating new ones, preferably unique.
But first, let’s find out how many positions are there.
Because we have 16 squares, and each square can be in 16 states (either one of 15 numbers or blank), the number of possible positions are equal to 16! = 20 922 789 888 000
.
However, the only half of them are solvable, so there’s 16! / 2
(roughly 10^{13}) positions, from which we can reach the goal state.
Interesting fact: starting a new 15 puzzle game (at least in my version), you can be certain that no one else in the world has ever seen your position!
So, when generating a puzzle, we must guarantee its solvability - otherwise players won’t be able to reach the goal.
A solution that comes in mind: take positions which we know are solvable and choose randomly for every new game.
A big drawback of this method is disk space requirement. If we try to store 16! / 2
positions as the array of 16 32-bit integers, it would take at least (16! / 2) * (16 * 4)
bytes (~608 TB).
Adding the support for custom sizes, such as 3x3 or 3x4, will require even more space. Of course, the format can be optimized (e.g. instead of 32-bit integers we can use 4-bit and compression), but it will be a large file nonetheless. And we even do not take into the account the time it would take to generate ~10^{13} positions!
As a workaround, we can take a much smaller set, such as 10^{5}. It will make the game simpler in some way, since we exclude a large part of possible positions.
But wait, if we somehow have managed to generate trillions of different positions beforehand, why we can’t do it in runtime?
Rather than preparing a collection of solvable positions, we can generate them on demand.
One method is the following:
0
is a blank square):
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 0
0
to that square, for example, number 6
:
1 2 3 4
5 >6< 7 8
9 10 11 12
13 14 15 >0<
0
to a target square:
1 2 3 4
5 >0< 6 7
9 10 11 8
13 14 15 12
This method guarantees that the resulting position will be solvable because we get there by making valid moves. The only question is: how many times do we need to repeat 2-3?
I’ve run a simulation for 4x4, and here’s the chart showing different number of iterations and an average number of moves required to solve the puzzle:
An average solve length is 52.59, so 150 iterations is enough to generate a good puzzle.
But, there’s one issue with this method: number of swaps.
To make a move we swap 0
with another number in the representing array.
Each iteration of the algorithm will require ~2.67 swaps, which is pretty inefficient.
Can we do better?
Luckily, we can.
The idea is to take goal position and apply a random shuffle. For 4x4, shuffling an array of numbers would require 15 swaps, which is much less than ~398 we saw in the previous section.
How will it perform?
Pretty nice! For 15 swaps (ignore 0.5 part for now) the average result is 53 moves, which is almost one move “harder” than random moves method with 150 iterations.
But, there’s one problem: half of the positions are unsolvable, so 50% of time we’ll put the player in a labyrinth with no way out.
What if there was a way to check whether position is solvable or not? Then we make a shuffle, check, and if the position is not solvable, shuffle again. Because of 50% chance to get solvable configuration, it will require just a few shuffles on average.
But how to check the solvability? There’s an answer to this question too!
The essence of solvability of an arbitrary position lies in parity (whether a number is even or odd) and inversions.
At first, we need to count number of inversions:
For each number in the starting position (from left to right, from top to bottom) count the numbers which stand after each number and are less than that number (excluding 0)
It is actually total count of numbers that are out of their natural order. For the final position number of inversions is equal to 0 because the numbers are in ascending order.
Let’s look closely how number of inversions change when we make a move. For example, this position has 52 inversions:
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
If we make a move in horizontal direction, number of inversions does not change:
8 4 12 3
14 >9< >0< 15
7 2 5 1
10 11 13 6
Because we do not count 0
, the order of other numbers stays the same.
But move in vertical direction changes number of inversions.
If we move 4
, it will be 53:
8 >0< 12 3
14 >4< 9 15
7 2 5 1
10 11 13 6
Not only we have changed number of inversions, but the parity is now different: from even (52) to odd (53).
But why does the number of inversions change?
Let’s look at two states of the puzzle, before and after 4
move (we don’t need to look at numbers other than first six because their order preserves):
Before:
8 4 12 3 14 0 - other numbers -
After:
8 0 12 3 14 4 - other numbers -
The move changed number of inversions:
Number | Numbers less than (before) | Numbers less than (after) | Inversions change |
---|---|---|---|
8 |
3 , 4 |
3 , 4 |
0 |
4 |
3 |
- | -1 |
12 |
3 |
3 , 4 |
+1 |
3 |
- | - | 0 |
14 |
- | 4 |
+1 |
Total change: +1 (52 → 53)
For puzzles with a width of 4 there will always be 3 numbers between 0
and the moving number.
Because 3 is odd, we’ll never face the situation when count of numbers less than moving number and count of numbers bigger than moving number are equal.
Because of that, possible outcomes are:
For puzzles with odd width there are always even count of numbers between, so the parity of inversions is invariant.
Since vertical move flips the parity, 0
’s row number in the position shows how many times the parity of inversions has flipped.
Because 0
in the final position is in the last row, we count 0
’s position from bottom, starting at 1 (from here forth I’ll count from bottom and starting at 1, unless otherwise noted).
If row number is odd, parity is retained, if it is even, parity is flipped.
Actually, the idea of looking at
0
’s position is to find an answer to the question: has the parity changed?
You can also count from 0 and even number will tell you that the parity of inversions hasn’t changed.
So, the position is solvable when:
0
in odd row0
in even rowHere’s the full algorithm for checking the solvability:
0
, counting from the bottom0
’s row number is odd, the puzzle is solvable, otherwise - not solvable0
’s row number is even, the puzzle is solvable, otherwise - not solvableExample: for the starting position:
12 13 11 2
4 5 3 14
1 9 15 6
8 7 0 10
0
’s row number: 10
’s row number (1) is odd, so the puzzle is solvable (in fact, in 57 moves)What to do if the puzzle is unsolvable?
As I said earlier, we could just shuffle the array until we get a solvable one. But, actually, we can make use of one little trick:
Swapping two largest numbers (e.g. for 4x4 it’s
14
and15
) will flip the parity of number of inversions
That is, if we get an unsolvable configuration, just by exchanging two numbers we make the puzzle solvable.
This is where that 0.5 came from: 50% of time we get solvable configuration right away (in 15 swaps), and for other 50% situations we make an additional swap of two largest numbers (16th swap), averaging total number of swaps to 15.5.
Maybe you have noted that solvability of a position is tied to width of a puzzle, but there’s nothing about height. There’s no mistake: height can be any number larger than 1, and we only look at the width. So, the rules are the same as for square variations.
Until this moment we only considered variants where numbers in the final position come in ascending order, from left to right, from top to bottom.
But, we can think of puzzles where rules are different, for example, the “snake”:
1 2 3 4
8 7 6 5
9 10 11 12
0 15 14 13
Unfortunately, our algorithm will not work for this configuration, and we need to come up with a new one.
Let’s look at how we count number of inversions:
For every number in the starting position (from left to right, from top to bottom)
Note that we traverse position in the order numbers appear in the final position (o
is start, x
is end):
o → → →
→ → → →
→ → → →
→ → → x
But for ‘snake’ it is different:
o → → ↘
↙ ← ← ↙
↘ → → ↘
x ← ← ↙
We just need to reverse the direction of iteration for every other row. And, actually, we don’t need to bother about width of a puzzle.
A side note on width
To understand why we don't check the width of the puzzle, let's see what happens when we make a move in snake:1 2 3 4 8 7 6 5 >9< 10 11 12 >0< 15 14 13To simplify, we leave only the numbers that are affected by the move:- - - - - - - - >0< 10 11 12 >9< 15 14 13In any case there will be even count of numbers between, and this is also true for odd width variations:1 2 3 6 >0< 4 7 >5< 8- - - 6 >5< - 7 >0< -Which means the parity of inversions is invariant.
The algorithm is simple:
And, for “spiral”:
o → → ↘
↗ → ↘ ↓
↑ x ↙ ↓
↖ ← ← ↙
The algorithm is exactly the same.
And it will work for any other configuration where you can draw a line with a pen by following number order without making gaps, e.g.:
↗ ↘ x o
↑ ↓ ↑ ↓
↑ ↘ ↗ ↓
↖ ← ← ↙
There’s another interesting configuration worth noting: rather than removing 16
in 4x4 puzzle, remove any other number.
If you try to apply the classic algorithm on such configurations, you will find that 50% of the puzzles will be unsolvable, although algorithm states the opposite. Let’s see:
12 8 7 15
0 6 4 1
10 9 13 11
3 16 14 5
In this position the number 2
is missing, it has 50 inversions and 0
’s row number is 3 - so, it must be solvable.
But, if you try to solve the puzzle, you will end up with something like this (10
and 11
are in wrong order):
1 0 3 4
5 6 7 8
9 11<>10 12
13 14 15 16
If you dig a little deeper, you will find the classic algorithm is not working only for positions where 0
’s row number in the goal position is even.
What can be done here?
We need to pay attention to two things:
0
’s row number in the start and goal positionsFor puzzles with odd width we check whenever number of inversions in the start and goal positions have the same parity.
For even width, as we found earlier, we need to find how many times the parity of inversions has flipped.
Because the 0
in the goal position now can be on any row, instead of counting row from the bottom, we take the difference between row positions in the start and goal.
In general, the algorithm for reachability from start position S
to goal position G
is:
G
- I(G)
S
- I(S)
I(G)
and I(S)
have the same parity, G
is reachable from S
(in other words, S
is solvable)G
- B(G)
S
- B(S)
I(G)
I(S)
and B(G) - B(S)
^{2} have the same parity, G
is reachable from S
I(S)
and B(G) - B(S)
have different parity, G
is reachable from S
G
is unreachable from S
.^{1} You can count from top or bottom, from 0 or 1, it doesn’t matter. Because in the next step we subtract row numbers, we just want the relative positions of blank in the start and goal states
^{2} B(G) - B(S)
can be replaced with B(G) + B(S)
, because we’re only interested in parity
A side note
Actually, you don't need a special algorithm to generate a valid puzzle of arbitrary configuration.
You just create a mapping of your goal position to the classic goal position and perform actions on the backing classic field, but display your mapped position instead.
Here’s whole the algorithm written in Java:
/**
* Calculate number of inversions in the {@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) {
// no need to check for 0 and 1
continue;
}
for (int j = i + 1; j < size; j++) {
int m = list.get(j);
if (m > 0 && n > m) {
inversions++;
}
}
}
return inversions;
}
/**
* Check whether {@code goal} is reachable from {@code start}
* for a puzzle with {@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;
}
// the above if-else statement can be replaced with:
// return (goalInversions + startInversions + goalZeroRow + startZeroRow) % 2 == 0;
} else {
// 'startInversions' should have the same parity as 'goalInversions'
return startInversions % 2 == goalInversions % 2;
}
}
A free lunch: the universal algorithm works for any goal configuration, regardless of zero position or missing number.
If you made this far, congratulations! Now you know (I hope) a little more of this (seemingly) simple game of 15 puzzle.
The source code of my game is hosted on GitHub. There are also tests you can play with, trying different game types and configurations.
And, in case you want to play the game itself, you can download it from Google Play.
]]>Прочитать можно по ссылке.
P.S. На момент написания поста (середина октября) было около 3 тысяч активных устройств, а сейчас уже более 7. Прогресс!
]]>Hello world, this is my first Jekyll blog post.
I hope you like it!
А если честно, то я решил завести блог больше для себя, чем для того, чтобы поделиться чем-то с миром.
Здесь будут (надеюсь) посты о моем опыте как разработчика, так и в целом как жителя планеты Земля.
Большую часть буду писать на русском, но, думаю, англоязычные статьи тут тоже будут появляться (если в этом будет смысл).
Надеюсь, меня хватит больше, чем на три поста.
]]>