|
| 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 | + |
0 commit comments