Sliding Puzzle
A Ruby gem for manipulating and solving sliding tile puzzles.
(Also see sliding_puzzle_rust)
Overview
You might have come across sliding tile puzzles before. They're usually cheap, plastic-y toys that can be rearranged by sliding their tiles around. Here's an example containing a picture of a frog:
One of the pieces is blank which means an adjacent tile can move into its place. After repeating this a few times, these puzzles can be tricky to solve. We can also think of these puzzles as grids of numbers:
The challenge is to find a sequence of moves to rearrange the 'start state' into the 'goal state' in as few moves as possible. This gem lets you play with these puzzles and it solves them in an optimal number of moves.
In this example the blank is in the the upper-left corner of the goal state, but it's arbitrary where it's located.
Usage
Puzzles are represented as arrays of numbers:
puzzle = SlidingPuzzle.new(
[1, 2, 0], # <-- the 0 represents blank
[3, 4, 5],
[6, 7, 8],
)
You can slide tiles around and print the result:
puzzle.slide!(:right)
puzzle.print
# [1, 0, 2]
# [3, 4, 5]
# [6, 7, 8]
The #slide
method will return a new SlidingPuzzle
whereas #slide!
will
mutate the existing one.
Moves
You can return an array of possible moves for a sliding puzzle:
puzzle = SlidingPuzzle.new(
[1, 2, 0],
[3, 4, 5],
[6, 7, 8],
)
puzzle.moves
#=> [:right, :up]
If you try a move that isn't valid, an error is raised:
puzzle.slide(:left)
# SlidingPuzzle::InvalidMoveError, "unable to slide left"
Scrambling
You can scramble a puzzle:
puzzle.scramble!
By default, this will perform 100 random moves, but you can set this:
puzzle.scramble!(moves: 3)
The #scramble
method will return a new SlidingPuzzle
whereas #scramble!
will mutate the existing one.
Dimensions
Puzzles can have different dimensions:
two_by_four = SlidingPuzzle.new(
[1, 2, 3, 4],
[5, 6, 7, 0],
)
two_by_four.slide!(:down)
two_by_four.print
# [1, 2, 3, 0]
# [5, 6, 7, 4]
Puzzles must be rectangular and contain a single blank.
Solving
Finding the shortest solution for a sliding puzzle is a hard problem. This gem provides 'oracles' to find these solutions:
goal_state = SlidingPuzzle.new(
[1, 2, 0],
[3, 4, 5],
[6, 7, 8],
)
oracle = SlidingPuzzle.oracle(goal_state)
This 'oracle' finds the shortest solution from any start state:
start_state = SlidingPuzzle.new(
[1, 4, 2],
[3, 7, 5],
[6, 0, 8],
)
oracle.solve(start_state)
#=> [:down, :down, :left]
Oracles
Oracles aren't magic. They are the result of precomputing solutions in advance. This gem provides oracles for puzzles with up to eight tiles:
goal_state = SlidingPuzzle.new(
[1, 2, 3, 4],
[5, 0, 6, 7],
)
oracle = SlidingPuzzle.oracle(goal_state)
The numbers of the goal state must be sequential, but the blank can be anywhere.
The #oracle
method will return nil
for a puzzle with more than eight tiles,
or if the numbers aren't sequential.
Impossible puzzles
Some starting positions are impossible to solve. For example, if you swap any two tiles from the goal state, there's no way to solve the puzzle:
unsolvable = SlidingPuzzle.new(
[2, 1, 0],
[3, 4, 5],
[6, 7, 8],
)
oracle.solve(unsolvable)
#=> nil
In total, there are N! possible configurations for a puzzle with N tiles (including the blank), but only half of these are solvable.
For the 3x3 puzzle, there are 9! / 2 = 181,400 solvable configurations.
Precomputing
For dimensions with no oracles, you can precompute your own:
goal_state = SlidingPuzzle.new(
[0, 1, 2, 3],
[4, 5, 6, 7],
[8, 9, 10, 11],
)
oracle = SlidingPuzzle.precompute(goal_state)
You can then write the result to a file:
oracle.write("path/to/file")
And read it in later:
oracle = SlidingPuzzle.read("path/to/file")
start_state = SlidingPuzzle.new(
[1, 5, 2, 3],
[4, 0, 6, 7],
[8, 9, 10, 11],
)
oracle.solve(start_state)
#=> [:down, :right]
For puzzles with greater than 12 tiles, you won't be able to precompute an
oracle in a reasonable amount of time. The 4x4 puzzle will take more than 40,000
times longer to precompute than the 3x4 puzzle and require terrabytes of RAM.
The debug
flag will reveal if it's ever likely to finish:
SlidingPuzzle.precompute(goal_state, debug: true)
# queue size: 1
# queue size: 2
# queue size: 3
# ...
If it just keeps growing, it's unlikely to finish.
Other methods
There are a few other methods that may be useful:
# Get the number on the first row and second column:
puzzle.get(0, 1)
#=> 5
# Find the row and column of the number 5:
puzzle.find(5)
#=> [0, 1]
# Return a clone of the array of tiles:
puzzle.tiles
#=> [[1, 5, 2, 3], [4, 0, 6, 7], [8, 9, 10, 11]]
# Return a clone of the puzzle:
puzzle.clone
#=> #<SlidingPuzzle:object_id>
Ideas to try
I hope you have fun with this gem. Here are some things to try: