sliding_puzzle 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b1d7484d10c76bbf7e0f8be38bb08988ea6a5973e4d523c8cbbd114a5516c1dc
4
+ data.tar.gz: 850ae2da5a00df634aa1e7ef850ad2171a33a912925ad61c9a0999b697577269
5
+ SHA512:
6
+ metadata.gz: 8185f6328ed9afca9cdff45dd7fad6e9e6c8e270e7a0cea049eac5a3ff4ce0023e8e9604e93f6a35c4084fcfe51091db51a267daccbd64e9059c24319b69c6cd
7
+ data.tar.gz: 94215e1af28e0f89f6acf436e2c71d214093fa42221078b2a7dad5ec3914eb6e6cf16a4f29e0f283b7603a1e584e7b88658ef5cf3274c6437973cf6454dec7c6
data/README.md ADDED
@@ -0,0 +1,269 @@
1
+ ## Sliding Puzzle ##
2
+
3
+ A Ruby gem for manipulating and solving sliding tile puzzles.
4
+
5
+ **TODO - this gem is a work in progress**
6
+
7
+ ### Overview ###
8
+
9
+ You might have come across sliding tile puzzles before. They're usually cheap,
10
+ plastic-y toys that can be rearranged by sliding their tiles around. Here's an
11
+ example containing a picture of a frog:
12
+
13
+ <br/><p align="center">
14
+ <img src="https://raw.githubusercontent.com/tuzz/sliding_puzzle/master/frog.jpg" />
15
+ </p><br/>
16
+
17
+ One of the pieces is blank which means an adjacent tile can move into its place.
18
+ After repeating this a few times, these puzzles can be tricky to solve. We can
19
+ also think of these puzzles as grids of numbers:
20
+
21
+ <br/><p align="center">
22
+ <img src="https://raw.githubusercontent.com/tuzz/sliding_puzzle/master/grid.jpg" />
23
+ </p><br/>
24
+
25
+ The challenge is to find a sequence of moves to rearrange the 'start state' into
26
+ the 'goal state' in as few moves as possible. This gem lets you play with these
27
+ puzzles and it solves them in an optimal number of moves.
28
+
29
+ In this example the blank is in the the upper-left corner of the goal state, but
30
+ it's arbitrary where it's located.
31
+
32
+ ### Usage ###
33
+
34
+ Puzzles are represented as arrays of numbers:
35
+
36
+ ```ruby
37
+ puzzle = SlidingPuzzle.new(
38
+ [1, 2, 0], # <-- the 0 represents blank
39
+ [3, 4, 5],
40
+ [6, 7, 8],
41
+ )
42
+ ```
43
+
44
+ You can slide tiles around and print the result:
45
+
46
+ ```ruby
47
+ puzzle.slide!(:right)
48
+ puzzle.print
49
+ # [1, 0, 2]
50
+ # [3, 4, 5]
51
+ # [6, 7, 8]
52
+ ```
53
+
54
+ The `#slide` method will return a new `SlidingPuzzle` whereas `#slide!` will
55
+ mutate the existing one.
56
+
57
+ ### Moves ###
58
+
59
+ You can return an array of possible moves for a sliding puzzle:
60
+
61
+ ```ruby
62
+ puzzle = SlidingPuzzle.new(
63
+ [1, 2, 0],
64
+ [3, 4, 5],
65
+ [6, 7, 8],
66
+ )
67
+
68
+ puzzle.moves
69
+ #=> [:right, :up]
70
+ ```
71
+
72
+ If you try a move that isn't valid, an error is raised:
73
+
74
+ ```ruby
75
+ puzzle.slide(:left)
76
+ # SlidingPuzzle::InvalidMoveError, "unable to slide left"
77
+ ```
78
+
79
+ ### Scrambling ###
80
+
81
+ You can scramble a puzzle:
82
+
83
+ ```ruby
84
+ puzzle.scramble!
85
+ ```
86
+
87
+ By default, this will perform 100 random moves, but you can set this:
88
+
89
+ ```ruby
90
+ puzzle.scramble!(moves: 3)
91
+ ```
92
+
93
+ The `#scramble` method will return a new `SlidingPuzzle` whereas `#scramble!`
94
+ will mutate the existing one.
95
+
96
+ ### Dimensions ###
97
+
98
+ Puzzles can have different dimensions:
99
+
100
+ ```ruby
101
+ two_by_four = SlidingPuzzle.new(
102
+ [1, 2, 3, 4],
103
+ [5, 6, 7, 0],
104
+ )
105
+
106
+ two_by_four.slide!(:down)
107
+ two_by_four.print
108
+ # [1, 2, 3, 0]
109
+ # [5, 6, 7, 4]
110
+ ```
111
+
112
+ Puzzles must be rectangular and contain a single blank.
113
+
114
+ ### Solving ###
115
+
116
+ Finding the shortest solution for a sliding puzzle is
117
+ [a hard problem](https://en.wikipedia.org/wiki/15_puzzle#Solvability). This gem
118
+ provides 'oracles' to find these solutions:
119
+
120
+ ```ruby
121
+ goal_state = SlidingPuzzle.new(
122
+ [1, 2, 0],
123
+ [3, 4, 5],
124
+ [6, 7, 8],
125
+ )
126
+
127
+ oracle = SlidingPuzzle.oracle(goal_state)
128
+ ```
129
+
130
+ This 'oracle' finds the shortest solution from any start state:
131
+
132
+ ```ruby
133
+ start_state = SlidingPuzzle.new(
134
+ [1, 4, 2],
135
+ [3, 7, 5],
136
+ [6, 0, 8],
137
+ )
138
+
139
+ oracle.solve(start_state)
140
+ #=> [:down, :down, :left]
141
+ ```
142
+
143
+ ### Oracles ###
144
+
145
+ Oracles aren't magic. They are the result of precomputing solutions in advance.
146
+ This gem provides oracles for puzzles with up to eight tiles:
147
+
148
+ ```ruby
149
+ goal_state = SlidingPuzzle.new(
150
+ [1, 2, 3, 4],
151
+ [5, 0, 6, 7],
152
+ )
153
+
154
+ oracle = SlidingPuzzle.oracle(goal_state)
155
+ ```
156
+
157
+ The numbers of the goal state must be sequential, but the blank can be anywhere.
158
+
159
+ The `#oracle` method will return `nil` for a puzzle with more than eight tiles,
160
+ or if the numbers aren't sequential.
161
+
162
+ ### Impossible puzzles ###
163
+
164
+ Some starting positions are
165
+ [impossible to solve](https://en.wikipedia.org/wiki/15_puzzle#Solvability). For
166
+ example, if you swap any two tiles from the goal state, there's no way to solve
167
+ the puzzle:
168
+
169
+ ```ruby
170
+ unsolvable = SlidingPuzzle.new(
171
+ [2, 1, 0],
172
+ [3, 4, 5],
173
+ [6, 7, 8],
174
+ )
175
+
176
+ oracle.solve(unsolvable)
177
+ #=> nil
178
+ ```
179
+
180
+ In total, there are [N!](https://en.wikipedia.org/wiki/Factorial) possible
181
+ configurations for a puzzle with N tiles (including the blank), but only half of
182
+ these are solvable.
183
+
184
+ For the 3x3 puzzle, there are 9! / 2 = 181,400 solvable configurations.
185
+
186
+ ### Precomputing ###
187
+
188
+ For dimensions with no oracles, you can precompute your own:
189
+
190
+ ```ruby
191
+ goal_state = SlidingPuzzle.new(
192
+ [0, 1, 2, 3],
193
+ [4, 5, 6, 7],
194
+ [8, 9, 10, 11],
195
+ )
196
+
197
+ oracle = SlidingPuzzle.precompute(goal_state)
198
+ ```
199
+
200
+ You can then write the result to a file:
201
+
202
+ ```ruby
203
+ oracle.write("path/to/file")
204
+ ```
205
+
206
+ And read it in later:
207
+
208
+ ```ruby
209
+ oracle = SlidingPuzzle.read("path/to/file")
210
+
211
+ start_state = SlidingPuzzle.new(
212
+ [1, 5, 2, 3],
213
+ [4, 0, 6, 7],
214
+ [8, 9, 10, 11],
215
+ )
216
+
217
+ oracle.solve(start_state)
218
+ #=> [:down, :right]
219
+ ```
220
+
221
+ For puzzles with greater than 12 tiles, you won't be able to precompute an
222
+ oracle in a reasonable amount of time. The 4x4 puzzle will take more than 40,000
223
+ times longer to precompute than the 3x4 puzzle and require terrabytes of RAM.
224
+ The `debug` flag will reveal if it's ever likely to finish:
225
+
226
+ ```ruby
227
+ SlidingPuzzle.precompute(goal_state, debug: true)
228
+ # queue size: 1
229
+ # queue size: 2
230
+ # queue size: 3
231
+ # ...
232
+ ```
233
+
234
+ If it just keeps growing, it's unlikely to finish.
235
+
236
+ ### Other methods ###
237
+
238
+ There are a few other methods that may be useful:
239
+
240
+ ```ruby
241
+ # Get the number on the first row and second column:
242
+ puzzle.get(0, 1)
243
+ #=> 5
244
+
245
+ # Find the row and column of the number 5:
246
+ puzzle.find(5)
247
+ #=> [0, 1]
248
+
249
+ # Return a clone of the array of tiles:
250
+ puzzle.tiles
251
+ #=> [[1, 5, 2, 3], [4, 0, 6, 7], [8, 9, 10, 11]]
252
+
253
+ # Return a clone of the puzzle:
254
+ puzzle.clone
255
+ #=> #<SlidingPuzzle:object_id>
256
+ ```
257
+
258
+ ### Ideas to try ###
259
+
260
+ I hope you have fun with this gem. Here are some things to try:
261
+
262
+ 1) Find an algorithm to solve a puzzle without using an oracle
263
+ 2) Compare the number of moves your algorithm makes with the oracle
264
+ 3) Write a web server for interacting with sliding puzzles
265
+ 4) Read
266
+ [more](https://www.ijcai.org/Proceedings/03/Papers/267.pdf)
267
+ [about](https://www.ijcai.org/Proceedings/03/Papers/267.pdf)
268
+ techniques for coping with larger dimensions
269
+ 5) Write a user interface to slide tiles around
@@ -0,0 +1,136 @@
1
+ class SlidingPuzzle
2
+ def self.oracle(goal_state)
3
+ Oracle.lookup(goal_state)
4
+ end
5
+
6
+ def self.precompute(goal_state, **options)
7
+ Oracle.precompute(goal_state, **options)
8
+ end
9
+
10
+ def self.read(path)
11
+ Oracle.read(path)
12
+ end
13
+
14
+ def initialize(*tiles)
15
+ self.tiles = flatten_tiles(tiles)
16
+ self.max_row = @tiles.size - 1
17
+ self.max_column = @tiles.first.size - 1
18
+
19
+ must_be_rectangular!
20
+ must_contain_one_blank!
21
+ end
22
+
23
+ def slide!(direction)
24
+ unless moves.include?(direction)
25
+ raise InvalidMoveError, "unable to slide #{direction}"
26
+ end
27
+
28
+ x1, y1 = find(0)
29
+ x2, y2 = x1, y1
30
+
31
+ y2 += 1 if direction == :left
32
+ y2 -= 1 if direction == :right
33
+ x2 += 1 if direction == :up
34
+ x2 -= 1 if direction == :down
35
+
36
+ @tiles[x1][y1], @tiles[x2][y2] = @tiles[x2][y2], @tiles[x1][y1]
37
+ self
38
+ end
39
+
40
+ def slide(direction)
41
+ clone.slide!(direction)
42
+ end
43
+
44
+ def clone
45
+ self.class.new(*tiles)
46
+ end
47
+
48
+ def print
49
+ Kernel.print @tiles.map(&:inspect).join("\n")
50
+ end
51
+
52
+ def moves
53
+ row, column = find(0)
54
+ moves = []
55
+
56
+ moves.push(:left) unless column == max_column
57
+ moves.push(:right) unless column.zero?
58
+ moves.push(:up) unless row == max_row
59
+ moves.push(:down) unless row.zero?
60
+
61
+ moves
62
+ end
63
+
64
+ def scramble!(moves: 100)
65
+ moves.times do
66
+ slide!(self.moves.sample)
67
+ end
68
+
69
+ self
70
+ end
71
+
72
+ def scramble(moves: 100)
73
+ clone.scramble!(moves: moves)
74
+ end
75
+
76
+ def tiles
77
+ JSON.parse(JSON.generate(@tiles))
78
+ end
79
+
80
+ def get(row, column)
81
+ @tiles[row][column]
82
+ end
83
+
84
+ def find(number)
85
+ tiles.each.with_index do |numbers, row|
86
+ numbers.each.with_index do |n, column|
87
+ return [row, column] if n == number
88
+ end
89
+ end
90
+
91
+ nil
92
+ end
93
+
94
+ def ==(other)
95
+ tiles == other.tiles
96
+ end
97
+
98
+ alias :eql? :==
99
+
100
+ def hash
101
+ tiles.hash
102
+ end
103
+
104
+ private
105
+
106
+ def flatten_tiles(tiles)
107
+ if tiles[0].is_a?(Array) && tiles[0][0].is_a?(Array)
108
+ tiles.flatten(1)
109
+ else
110
+ tiles
111
+ end
112
+ end
113
+
114
+ def must_be_rectangular!
115
+ sizes = tiles.map(&:size)
116
+
117
+ if sizes.uniq.size > 1
118
+ raise NotRectangularError, "puzzle must be rectangular"
119
+ end
120
+ end
121
+
122
+ def must_contain_one_blank!
123
+ blanks = tiles.flatten.count(0)
124
+
125
+ unless blanks == 1
126
+ raise BlankError, "puzzle must contain a single blank"
127
+ end
128
+ end
129
+
130
+ attr_accessor :max_row, :max_column
131
+ attr_writer :tiles
132
+
133
+ class InvalidMoveError < StandardError; end
134
+ class NotRectangularError < StandardError; end
135
+ class BlankError < StandardError; end
136
+ end
@@ -0,0 +1,109 @@
1
+ class SlidingPuzzle
2
+ class Oracle
3
+ OPPOSITES = {
4
+ left: :right,
5
+ right: :left,
6
+ up: :down,
7
+ down: :up,
8
+ }
9
+
10
+ def self.precompute(goal_state, debug: false)
11
+ goal_state = goal_state.clone
12
+
13
+ queue = [goal_state]
14
+ lookup_table = { goal_state => :goal }
15
+
16
+ until queue.empty?
17
+ puts "queue size: #{queue.size}" if debug
18
+ puzzle = queue.shift
19
+
20
+ puzzle.moves.each do |direction|
21
+ next_puzzle = puzzle.slide(direction)
22
+
23
+ unless lookup_table[next_puzzle]
24
+ lookup_table[next_puzzle] = OPPOSITES[direction]
25
+ queue.push(next_puzzle)
26
+ end
27
+ end
28
+ end
29
+
30
+ new(lookup_table)
31
+ end
32
+
33
+ def self.precompute_all(max_tiles: 8, directory: "oracles", debug: false)
34
+ FileUtils.mkdir_p("oracles")
35
+
36
+ 1.upto(5) do |rows|
37
+ 1.upto(5) do |columns|
38
+ number_of_tiles = rows * columns - 1
39
+ next if number_of_tiles > max_tiles
40
+
41
+ numbers = 1.upto(number_of_tiles).to_a
42
+
43
+ 0.upto(number_of_tiles) do |position|
44
+ numbers_with_blank = numbers.dup.insert(position, 0)
45
+ tiles = numbers_with_blank.each_slice(columns).to_a
46
+
47
+ goal_state = SlidingPuzzle.new(tiles)
48
+ path = "#{directory}/#{basename(goal_state)}.dat"
49
+
50
+ print "Precomputing #{path}... " if debug
51
+
52
+ oracle = precompute(goal_state)
53
+ oracle.write(path)
54
+
55
+ puts "Done." if debug
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.lookup(goal_state)
62
+ filename = "#{basename(goal_state)}.dat"
63
+ path = File.expand_path("#{__dir__}/../../oracles/#{filename}")
64
+
65
+ read(path) if File.exist?(path)
66
+ end
67
+
68
+ def self.basename(puzzle)
69
+ puzzle.tiles.map { |row| row.join(",") }.join(":")
70
+ end
71
+
72
+ def initialize(lookup_table)
73
+ self.lookup_table = lookup_table
74
+ end
75
+
76
+ def solve(sliding_puzzle)
77
+ moves = []
78
+ next_puzzle = sliding_puzzle
79
+
80
+ loop do
81
+ direction = lookup_table[next_puzzle]
82
+
83
+ return nil unless direction
84
+ return moves if direction == :goal
85
+
86
+ moves.push(direction)
87
+ next_puzzle = next_puzzle.slide(direction)
88
+ end
89
+ end
90
+
91
+ def write(path)
92
+ data = Marshal.dump(self)
93
+ gzip = Zlib::Deflate.deflate(data)
94
+
95
+ File.open(path, "wb") { |f| f.write(gzip) }
96
+ end
97
+
98
+ def self.read(path)
99
+ gzip = File.binread(path)
100
+ data = Zlib::Inflate.inflate(gzip)
101
+
102
+ Marshal.load(data)
103
+ end
104
+
105
+ private
106
+
107
+ attr_accessor :lookup_table
108
+ end
109
+ end
@@ -0,0 +1,6 @@
1
+ require "json"
2
+ require "zlib"
3
+ require "fileutils"
4
+
5
+ require "sliding_puzzle/base"
6
+ require "sliding_puzzle/oracle"
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sliding_puzzle
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Patuzzo
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-01-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 3.7.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 3.7.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.11.3
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.11.3
41
+ description: A Ruby gem for manipulating and solving sliding tile puzzles.
42
+ email: chris@patuzzo.co.uk
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/sliding_puzzle.rb
49
+ - lib/sliding_puzzle/base.rb
50
+ - lib/sliding_puzzle/oracle.rb
51
+ homepage: https://github.com/tuzz/sliding_puzzle
52
+ licenses:
53
+ - MIT
54
+ metadata: {}
55
+ post_install_message:
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubyforge_project:
71
+ rubygems_version: 2.7.3
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Sliding Puzzle
75
+ test_files: []