Saint Louis University |
Computer Science 180
|
Dept. of Math & Computer Science |
Please see the general programming webpage for details about the programming environment for this course, guidelines for programming style, and details on electronic submission of assignments.
The files you may need for this assignment can be downloaded here.
For this assignment, you are allowed to work with one other student if you wish (in fact, we suggest that you do so). If any student wishes to have a partner but has not been able to locate one, please let the instructor know so that we can match up partners.
Please make sure you adhere to the policies on academic integrity in this regard.
A magic square is an arrangement of the numbers from 1 to n2 in an nxn matrix, with each number occuring exactly once, and such that the sum of the entries of any row, any column or either main diagonal is the same.
For example, here is an example of a 3x3 magic square:
2 | 9 | 4 |
7 | 5 | 3 |
6 | 1 | 8 |
Page 155 of the text discusses a generic way to solve puzzles such as this, essentially relying on the brute force of modern computers to compute and test all possible assignments. The recursive approach starts with an empty square and a list of unused values. It considers what value to place in the next available cell of the square, trying each possibile value from the list of unused values. For each one, it recurses with the slightly more complete square and a new list of unusued values (with the newly used value no longer on such list).
In class, we saw how this approach could be directly applied to the magic squares problem. Tracing through the recursion, the first completed square is
1 | 2 | 3 |
4 | 5 | 6 |
7 | 8 | 9 |
Of course, this square is not magic. So the recursion backtracks and considers other possible assignments, such as
1 | 2 | 3 |
4 | 5 | 6 |
7 | 9 | 8 |
eventually exhausting every possible assignment, and reporting those which were truly magic.
We are providing you with working code which implements this brute-force approach to solving the problem. And indeed, for a 3x3 square, the computer is fast enough to try all possible assignments in a matter of seconds, reporting 8 correct solutions (though we note that all 8 of these are actually symmetric to each other).
Unfortunately, as fast as computers are, we seem to hit a road block trying this out on larger squares. Even for a 4x4 square, the original program seems to take way too long (i.e. days?!). The reason is that there are far too many possibilites, and thus far too many recursive calls being made. The following chart outlines some of the numbers involved
n | Number of Actual Magic Squares (ignoring symmetries) |
Number of Candidate Assignments (i.e., n2!) |
Number of Recurisve Calls made by brute force approach |
Running Time (on turing) |
---|---|---|---|---|
1 | 1 | 1 | 2 | 0.00 sec |
2 | 0 | 24 | 60 | 0.00 sec |
3 | 1 | 362880 | 986410 | 1.15 sec |
4 | 880 | 20922789888000 (that 20 trillion) |
even more | c. 240 days |
5 | 275305224 | 25! (over 1025) |
even more | ha! |
The goal of this assignment is to show how relatively simple strategies can be used to streamline the process in a remarkable way. Using the 4x4 case as a benchmark, we will show how the brute force approach, which takes eight months worth of computations, can be improved to a program which finds all solutions in under 10 seconds of computation time.
In the following section, we outline a series of specific improvement strategies, each of which you must implement. Please be careful to ensure the correctness of your program as you make changes. Though our original verson is quite slow, it works correctly. There is little use for developing a faster program which is wrong!
We recommend that you take these one at a time, using the 3x3 case for testing, and the 4x4 case as a benchmark for measuring the efficiency.
If you run the brute force approach for the 3x3 case, you will find that it reports 8 different solutions. It turns out that all eight of these are really symmetric versions of the same solution.
In general, any solution can be rotated and flipped to result in one of eight symmetric solutions. That is, the square can be rotated so that any of the four corners appears at the top-left, and further, it can be flipped around the main diagonal, resuling in four additional solutions.
Our first task is to determine a way to report only one of a set of eight symmetric solutions. To do so, we will select one of those to be the "canonical" form for the set. Specifically, we suggest the following two rules defining a canonical form:
In the file Square.cpp you will find a routine defined as follows:
Implement that function./* * Checks whether the current (partial) settings is in canonical form. * That is with top-left corner as the smallest of the corners, and * top-right corner as the smaller of its two adjacent corners. */ bool Square::canonical() { return true; // stub }
This change by itself will not really improve the overall efficiency of the algorithm (though it will when combined with the later techniques). However the valid() routine of the brute force solution does call the canonical() routine as one of its tests in determining if a candidate configuration is considered as a solution. So if you succeed in implement this routine, running the 3x3 puzzle should result in the report of a single, canonical solution.
The lionshare of the improvement comes from using some obvious intuitions to reduce wasted efforts. For example, early in the brute force approach the following partial solution is considered:
1 | 2 | 3 |
4 | 5 | . |
. | . | . |
In its original form, the recursion continues to
try all possible placements of the set
In the file Square.cpp you will find a routine defined as follows:
If this routine returns 'false', the main program will not further expand the current configuration, saving a great deal of otherwise wasted effort./* * Presuming that (row,col) was the most recently set entry, this * method attempts to determine whether that entry invalidates the * partial solution. * * If it becomes clear that this solution cannot be extended to a * valid solution, this method returns false. Otherwise it returns * true (Note that it still may be impossible to complete the * solution). */ bool Square::partialValidate(int row, int col) { return true; // stub }
In implementing this routine, you do not need to automatically check all rows, columns and diagonals in the entire square. Simply check the influence of the most recently added value, identified via the parameters. That cell will lie in a single row, a single column, and possible one or both diagonals. Furthermore, you need not check the sums unless the most recently placed item is the one which completes the given row, column or diagonal.
Also, rewrite the canonical() routine so that it too can be sued to rule out a partial solution purely because it is clear that it cannot result in a canoncial form. (be careful, make sure not to rule out partial solutions unless it is clear they cannot possible be canonical when completed)
Recall that the high-level recursion is done by adding values to the square, one-at-a-time. The Square class was given the responsibility for deciding which cell of the square was to be filled in by each successive value. The recursion works irregardless of the particular order.
The default behavior, implemented in the original code for this
assignment, was to fill in the square in what is called
"row-major" order. For example, if the values
1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |
However, we can further improve the efficiency of the overall program by redefining the order of insertions. Since we prune the recursion as soon as we recognize an infeasible partial solution, the algorithm can be improved by ordering the cells so that such recognition happens sooner rather than later.
We wish to have you revise the Square class so that the following non-standard order is used. The first n items should be placed, left-to-right in the top row. The next (n-1) items should complete the leftmost column, from top-to-bottom. Finally, the remaining (n-1)x(n-1) subsquare should be completed in similar fashion (recursively). For example, here is the desired cell order for a 4x4 example:
1 | 2 | 3 | 4 |
5 | 8 | 9 | 10 |
6 | 11 | 13 | 14 |
7 | 12 | 15 | 16 |
To implement a particular cell order, you must adapt the Square.cpp implementation. The existing square implementation keeps a count of the current number of filled cells. When a new value is to be added to the square, the following private method determines which cell should be next used, based on such a count:
The original implementation simply implements row-major order. You should modify this code to implement the general pattern discussed above./* * In an nxn square, there are n^2 spots to fill in eventually. * Assuming that 'prevCount' cells have already been filled, this * routine identifies where in the square the next insertion should be * placed. */ Square::Cell Square::whichCell(int prevCount,int n)
Though the pattern may seem complex at first, it is relatively easy to describe an implement with recursion. You are expected to use recursion for this task.
To give you some benchmarks to compare to, here are some statistics we've gathered about the improvements we achieve through a combination of the improvements for this assignment.
For the 3x3 case, even brute force is quick enough, but we summarize the internal benchmarks as follows:
Improvements used | Number of Recurisve Calls | Running Time (on turing) |
---|---|---|
Brute Force | 986,410 | 1.15 sec |
consider pruning upon complete row/col/diag |
2378 | 0.00 sec |
also prune when not canonical |
1200 | 0.00 sec |
also with improved order of fill |
312 | 0.00 sec |
and with Extra Credit |
222 | 0.00 sec |
For the 4x4 case, we observe the following efficiencies:
Improvements used | Number of Recurisve Calls | Running Time (on turing) |
---|---|---|
Brute Force | > 20 trillion | c. 240 days |
consider pruning upon complete row/col/diag |
630,492,673 | 28 min |
also prune when not canonical |
314,969,485 | 14.9 min |
also with improved order of fill |
2,571,437 | 8.87 sec |
and with Extra Credit |
1,313,885 | 5.54 sec |
All such files can be downloaded here.
For this assignment, we will be providing you with several files which comprise the complete program, though you will not need to modify, or even read, many of them. We will briefly discuss the purpose of each file:
Square.h, Square.cpp
These are the only two files that you should consider modifying.
They define the Square class, based upon the brute
force implementation.
Magic.cpp
This is the driver. It implements the high-level recursion.
Its use is further discussed in the following section.
Vector.h, Vector.tcc, BoundaryViolationException.h
These provide an implementation of the text's VectorADT. It is
used by the Magic driver, but is not needed for the
Square implementation.
makefile
This makefile should allow you to rebuild your project by
simply typing 'make' rather than in invoking the compiler
directly.
The driver expects a single command-line argument which is the square's width, n. It then begins the recursion. In order to provide feedback as it executes, it provides the following output:
For ease of debugging, we have added additional functionality. If a second command-line argument is given to the driver, such as
Magic 3 debugthe software automatically enters a debugging mode in which the partially filled square is printed as each recursive call is made. Of course, this will generate a large amount of data. You would probably not be able to sift through this much data, but you may wish to look at the beginning of it. This can be done by piping the output to the more command, such as:
Magic 3 debug | moreAlternatively, you can redirect the output to a file and look at it later, though the file may become huge. This would be done as
Magic 3 debug > filename
Finally, you may wish to print out a (partial) square from other places of your code during development. Please note that we already provide an overloaded output operator. This would allow you to print your current square from within any method of the square class using the syntax:
std::cout << (*this);
Square.cpp (and possiblly Square.h)
Most of your work will involve the file Square.cpp.
You may or may not find need to modify the class definition in
Square.h, most notably, if you wish to introduce any
additional private members.
Readme File
A brief summary of your program, and any further comments you
wish to make to the grader. If you do the extra credit, please
make this clear.
The lesson thus far has been that it is in your best interest to prune the recursion as soon as you are able, even if you must use more complicated logic in determining infeasible partial solutions.
In the original assignment, we waited until a given row (resp. column, diagonal) was complete, before checking its sum and pruning if it did not match the target. However, in some cases, we should be able to prune even sooner. For example consider the following partial solution
1 | 2 | 3 | . |
. | . | . | . |
. | . | . | . |
. | . | . | . |
Given that the target value for 4x4 is 34 and that individual values are between {1, .., 16}, it is pointless to continue on the above example. No single value is enough, given that the row sums to 6 thus far. In similar spirit, if the first three entries were {16, 15, 14} we may as well stop, as this already sums to 45, not to mention the remaining square which will add at least one.
For extra credit, rewrite the partialValidate to detect such cases, ideally further improving the overall efficiency of the process.