Skip to content

Commit 54bb663

Browse files
author
Marc A. Saegesser
committed
Initial release
0 parents  commit 54bb663

12 files changed

+884
-0
lines changed

.gitignore

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.idea
2+
.idea_modules
3+
target
4+
project/boot
5+
*.swp
6+
project.vim
7+
tags
8+
.lib
9+
*~
10+
*#
11+
.DS_Store
12+
.history
13+
.cache
14+
.classpath
15+
.project
16+
.settings

README.md

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Scramble Squares Puzzle Solver
2+
3+
One of the benefits of having a son doing a CS major is that sometimes
4+
he sends me his homework assignments just because he knows I'll get a
5+
kick out of solving them. He's usually right.
6+
7+
This assignment is from a class on Creative Problem Solving and Team
8+
Programming. The allowed languages were C, C++, Java and Python. I
9+
guess I'll fail since I worked on the problem only by myself and
10+
implemented the solution in Scala.
11+
12+
Kudos to the class for being polyglot, but raspberries for not
13+
allowing lots of interesting languages.
14+
15+
And, of course, this is being posted after the real assignment is due.
16+
I did not provide any assistance to anyone taking the class, including
17+
my son.
18+
19+
## The Problem
20+
The problem consists of a 3x3 square of tiles. Each tile has four
21+
sides, each with a symbol. The symbols are half of an arrow (one half is
22+
the tail, other half is the head). Each symbol is also one of four
23+
colors: red, green, blue and yellow. The goal is to rearrange
24+
the tiles so that every pair of adjacent edges has the same color and
25+
matching symbols (i.e. one head and one tail). The allowed operations
26+
on the board are swapping two tiles and rotating a tile.
27+
28+
The input to the program is a set of nine tiles and the output should
29+
show all unique solutions, up to rotation. There may be several unique
30+
solutions. Each tile is assigned a label when it is read. Labels are
31+
integer values from 1 to 9.
32+
33+
Any solution to the puzzle is really one of four solutions, which are
34+
just the rotations of the whole board by 90, 180 and 270 degrees. Only
35+
one of these rotations should be included in the results. A given
36+
arrangement of tiles can be identified by its _signature_, the
37+
sequence of tile labels from left to right, top to bottom
38+
(e.g. 123456789). The output should only include the solution rotation
39+
which has the lexicographically smallest signature. For example, if
40+
123456789 is a solution then so is 741852063,
41+
987654321, 369258147. Only solution 123456789 should be shown. Also,
42+
the output should be sorted by board signature with smallest values
43+
first.
44+
45+
The problem statement also include details of the input file format
46+
and the required output format for displaying the solved boards. I
47+
won't reproduce all that here as it isn't very interesting and should
48+
be clear from the code and sample data.
49+
50+
## The Solution
51+
Tiles are marked as either _Fixed_ or _Free_. A fixed tile can no longer
52+
be moved or rotated and becomes a _constraint_ on the solution. To place
53+
a Fixed tile next to one or more other Fixed tiles requires that all
54+
the adjacent edges of the Fixed tiles are matched (same color,
55+
different ends of an arrow). A Free tile provides no constraints on
56+
the board; any edge (either Fixed or Free) is allowed next to a
57+
Free tile's edges. The board begins with all nine tiles Free.
58+
59+
For a given board the board builder generates all valid boards that
60+
can be reached by placing one more Fixed tile subject to the
61+
constraints of any existing Fixed tiles. We start by trying to fix
62+
tiles in the top left corner. Since the board begins as all Free, the
63+
first tile Fixed will have no constraints (note: see the discussion
64+
below on symmetry constraints). If all tiles are unique this results
65+
in 36 new boards (i.e. one board each with each of the four rotations
66+
of the nine tiles in the top left corner). The resulting boards are
67+
then each fed back into the board builder to generate all of the new
68+
boards reachable from these initial boards. If there are valid boards
69+
reachable from a given tile configuration the board builder returns an
70+
empty list of boards and this branch of the solution space is
71+
abandoned. This algorithm is repeated until all solutions have been
72+
found.
73+
74+
## The Use of Symmetry
75+
The simple algorithm above will find all solutions to the puzzle,
76+
including all four rotations of each unique solution. The solver would
77+
then need to discard all the solutions that are rotations of the
78+
desired solution. It is much more efficient to never generate these
79+
duplicate rotations in the first place.
80+
81+
This can be achieved by a few additional constraints in the board builder
82+
based on symmetric properties of the puzzle.
83+
84+
Because we only want the rotation with the smallest signature we can
85+
immediately see that the tiles labled 7, 8 and 9 should never be
86+
placed in the top-left corner. If they were then there must at least
87+
one other corner with a tile labled with smaller number. If we were to
88+
find a solution to the puzzle with a 7, 8 or 9 in the top-left corner
89+
there there will always be another, rotated, solution that has the
90+
smaller corner in the top-left position. Rather than find, and later
91+
discard, these solutions we simply stop before we ever generate
92+
them. More generally, we should never place a tile in a corner if that
93+
tile's label is less than the label of the tile already placed in the
94+
top-left corner. If such a tile was placed and we then found solutions
95+
those solutions would be duplicates of the solutions found when the
96+
smaller tile was placed in the top-left corner. By exluding this scenario
97+
from the search space we avoid finding these duplicates.
98+
99+
## Optimal Tile Placement Order
100+
There is one more optimaztion to be applied to the preceeding
101+
algorithm. When a new tile is to be placed it can have 0, 1, 2, 3 or
102+
4 constraints depending one how many fixed tiles it is adjacent to. If
103+
we place tiles starting at the top left and proceed left to right and
104+
top to bottom, then the first tile will have 0 constraints, the next
105+
1, etc. The resulting list of tile constraint numbers is 0, 1, 1, 1, 2,
106+
2, 1, 2, 2. We don't encounter a two edge constraint until attempting
107+
to place a tile in the fifth position on the board. However, if we
108+
instead places into positions 1, 2, 5, 4, 3, 6, 7, 8, 9 (numbering
109+
from the top, left corner) then the edge constraint counts are 0, 1,
110+
1, 2, 1, 2, 1, 2, 2. Notice that the first two-edge constraint now
111+
happens in the fourth position. A two-edge constraint is much less
112+
likely to be matched to the remaining free tiles than a single-edge
113+
constraint. So encountering this constraint sooner greatly reducdes
114+
the solution space that needs to be searched.
115+
116+
Experimental results comparing this optimization to the original
117+
algorithm show almost a 30% decrease in the time required to find all
118+
valid solutions.
119+
120+
## Results
121+
The initial board solver, without the optimal tile placement order,
122+
solves 10,000 solvable puzzles in around 64,000 milliseconds, or about
123+
6.4 milliseconds per puzzle. The solver with the optimal placement
124+
pattern solves 10,000 puzzles in about 45,000 milliseconds or about
125+
4.5 milliseconds per puzzle.
126+
127+
Here are some representative runs using the Scala REPL on my laptop, a
128+
Lenova P50 with an Intel Core i7 at 2.6GHz. This is obviously not a
129+
definitive measure of performance but these results have been pretty
130+
stable.
131+
132+
```scala
133+
scala> timeSolutions(boardStream.take(10000).toList)(ArrowPuzzleSolver.simpleSolver(_)(SymmetryBuilder))
134+
res1: String = 10000 solved in 63874ms. Avg=6.3874ms/puzzle
135+
136+
scala> timeSolutions(boardStream.take(10000).toList)(ArrowPuzzleSolver.simpleSolver(_)(SymmetryBuilder2))
137+
res4: String = 10000 solved in 45011ms. Avg=4.5011ms/puzzle
138+
```
139+
140+
The boardStream value is defined as
141+
142+
```scala
143+
val boardStream = Stream.continually(genShuffledBoard.sample).filter(_.isDefined).map(_.get)
144+
```
145+
146+
Where the `genShuffledBoard` is a ScalaCheck generator that creates
147+
random solvable boards. It works by creating a random solved board and
148+
then shuffling and rotating the tiles. Solving solvable boards will in
149+
general take longer than solving truly random board configurations
150+
because there will always be at least one configuration that places
151+
all 9 tiles, while boards with no solutions may be invalidated quite
152+
quickly.
153+
154+
I also impelmented a basic parallel solver that tried to improve
155+
performance by using multiple cores in parallel to solve a
156+
board. However, the current board solver is efficient enough that the
157+
extra overhead of context switching and combining results from
158+
multiple CPUs very much outweighed any advantage from parallel
159+
execution. Parallelism might be use for solving several different
160+
boards in parallel, but seems unlikely that any simple parallel
161+
processing will improve single board solving times. (Famous last words).
162+
163+
164+

build.sbt

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name := "ArrowPuzzleSolver"
2+
3+
scalaVersion in ThisBuild := "2.12.4"
4+
5+
scalacOptions in ThisBuild ++= Seq(
6+
"-feature",
7+
"-deprecation",
8+
"-Yno-adapted-args",
9+
"-Ywarn-value-discard",
10+
"-Ywarn-numeric-widen",
11+
"-Ywarn-dead-code",
12+
"-Xlint",
13+
"-Xfatal-warnings",
14+
"-unchecked",
15+
"-language:implicitConversions")
16+
17+
scalacOptions in (Compile, console) ~= (_.filterNot(_ == "-Xlint"))
18+
scalacOptions in (Test, console) ~= (_.filterNot(_ == "-Xlint"))
19+
20+
testOptions in Test += Tests.Setup(classLoader =>
21+
classLoader
22+
.loadClass("org.slf4j.LoggerFactory")
23+
.getMethod("getLogger", classLoader.loadClass("java.lang.String"))
24+
.invoke(null, "ROOT")
25+
)
26+
27+
libraryDependencies in ThisBuild ++= Seq(
28+
"com.typesafe.scala-logging" %% "scala-logging" % "3.7.2",
29+
// "org.slf4j" % "slf4j-api" % "1.7.7",
30+
"ch.qos.logback" % "logback-classic" % "1.2.3",
31+
"org.scalatest" %% "scalatest" % "3.0.0" % "test",
32+
"org.scalacheck" %% "scalacheck" % "1.13.4" % "test"
33+
)
34+
35+
initialCommands in (Test, console) := """
36+
import org.saegesser.puzzle._
37+
import org.saegesser.test._
38+
import TestUtil._
39+
"""
40+
41+
initialCommands in (Compile, console) := """
42+
import org.saegesser.puzzle._
43+
"""
44+
45+
parallelExecution in Test := false
46+
47+
lazy val puzzle = project.in(file("."))

project/build.properties

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sbt.version=0.13.17

puzzles/arrows.txt

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Y1,G0,R0,B1
2+
Y0,B1,R1,B0
3+
Y1,R1,G0,B0
4+
Y1,G0,B0,G1
5+
B0,G0,R1,Y1
6+
Y0,G1,R1,B0
7+
R0,B1,Y1,G0
8+
Y0,R1,B1,G0
9+
R0,G1,Y1,B0

src/main/scala/ArrowPuzzle.scala

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package org.saegesser.puzzle
2+
3+
/** Defines useful types used by the puzzle solver.
4+
*/
5+
object ArrowPuzzle {
6+
// Enumerates the sides of a tile
7+
type EdgeSide = Int
8+
final val EdgeTop: EdgeSide = 0
9+
final val EdgeRight: EdgeSide = 1
10+
final val EdgeBottom: EdgeSide = 2
11+
final val EdgeLeft: EdgeSide = 3
12+
13+
// Enumerates all the colors
14+
type Color = Char
15+
final val Red: Color = 'R'
16+
final val Green: Color = 'G'
17+
final val Blue: Color = 'B'
18+
final val Yellow: Color = 'Y'
19+
20+
// Enumerates the shapes
21+
type Shape = Int
22+
final val Tail: Shape = 0
23+
final val Head: Shape = 1
24+
25+
// The value of an edge is a color and a shape.
26+
case class EdgeValue(color: Color, shape: Shape) {
27+
override def toString: String = s"$color$shape"
28+
}
29+
30+
object EdgeValue {
31+
/** Create an EdgeValue from a string.
32+
*
33+
* The string must have the form "CS" where C is a color value
34+
* ("R", "G", "B", "Y") and S is a shape value ("0", "1"). This
35+
* is the formated used in the input file.
36+
*/
37+
def fromString(value: String): EdgeValue =
38+
EdgeValue(value(0), Integer.parseInt("" + value(1)))
39+
}
40+
41+
/** A representation of a tile's edges.
42+
*/
43+
case class Edges(t: EdgeValue, r: EdgeValue, b: EdgeValue, l: EdgeValue) {
44+
/** A string representation of the edges in the format required for the program's output.
45+
*/
46+
override def toString: String = s"<$t, $r, $b, $l>"
47+
48+
/** Return a specific EdgeValue given an EdgeSide.
49+
*/
50+
def side(s: EdgeSide): EdgeValue =
51+
s match {
52+
case EdgeTop => t
53+
case EdgeRight => r
54+
case EdgeBottom => b
55+
case EdgeLeft => l
56+
}
57+
58+
/** Compute all unique roations of the Edges.
59+
*/
60+
def rotations: Vector[Edges] =
61+
Vector(
62+
Edges(t, r, b, l),
63+
Edges(r, b, l, t),
64+
Edges(b, l, t, r),
65+
Edges(l, t, r, b)).distinct
66+
}
67+
68+
object Edges {
69+
def apply(es: Array[String]): Edges =
70+
es.map(EdgeValue.fromString) match {
71+
case Array(t, r, b, l) => Edges(t, r, b, l)
72+
case _ => throw new IllegalArgumentException(s"Invalid edge array $es")
73+
}
74+
}
75+
76+
/** Compute the value of the edge that matches the given edge.
77+
*
78+
* This is an edge with the same color and the other side of the
79+
* given edges symbol.
80+
*/
81+
def matchingEdgeValue(e: EdgeValue): EdgeValue =
82+
e.copy(shape=((e.shape+1) % 2))
83+
84+
/** Represents a constraint on a tile.
85+
*
86+
* The four optional EdgeValues specify the constraints to be applied to a tile. For example,
87+
* a Constraint(None, Some(EdgeValue(Red, Tail)), Some(EdgeValue(Green, Head)), None) indicates
88+
* that a tile matching this constraint must have a right edge of Red/Head and a bottom edge of
89+
* Green/Tail. The top and left edges are unconstrained.
90+
*/
91+
case class Constraint(t: Option[EdgeValue], r: Option[EdgeValue], b: Option[EdgeValue], l: Option[EdgeValue])
92+
}

0 commit comments

Comments
 (0)