sliding_puzzle 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []