XPlorations
| Sudoku Solver (TDD) |
July, 2006 |
| Sudoku is a fairly
well-known type of puzzle. Solving it turns out to be easier than I
expected, but a somewhat odd example of test-driven
development. |
There's been discussion on the extremeprogramming group about
solving Sudoku puzzles. I saw Ron Jeffries first do a
spike, and
then start solving. Then Cory
Foy pitched in
his version as well. I skipped to the bottom of his article and
found out it wasn't doing an exhaustive search, but rather looking
for simple "forced" cells.
From the discussion, I had decided I wouldn't be fancy on the
indexing: I'll just use a simple array, and define constraints as
indexes on the array. I also decided to first do an exhaustive
depth-first search, and then performance-tune if necessary.
First Test, Unusually Large
Here's my first test:
| |
public void testBoardThatHasToApplyConstraints() {
Board board = new Board(new int[] {
1, 2,
0, 1 });
ArrayList constraints = new ArrayList();
constraints.add(new Constraint(new int[] { 0, 1
}));
constraints.add(new Constraint(new int[] { 2, 3
}));
constraints.add(new Constraint(new int[] { 0, 2
}));
constraints.add(new Constraint(new int[] { 1, 3
}));
Board result = new Solver().solve(board,
constraints);
Board expected = new Board(new int[] {
1, 2,
2, 1 });
assertEquals(expected, result);
} |
This isn't a real Sudoku puzzle but it's close enough to get me
started.
I don't often start with a test this big but I wanted to capture
the direction I plan to go. I used this chance to create stubs for
the Board, Constraint, and Solver classes and their methods:
| |
public class Board {
public Board(int[] cells) {}
}
public class Constraint {
public Constraint(int[] indexes) {}
}
public class Solver {
public Board solve(Board board, ArrayList
constraints) {
return null;
}
}
|
As you'd expect, this test comes back red. It's definitely too
advanced for where we are. So I'm going to rename the failing test
method to xtestBoardThatHasToApplyConstraints(), (so
it won't run) and take a smaller bite, what Kent Beck calls a Child
Test.
Board Equality
My smaller step will be to test whether two boards are equal.
It's much easier, and I have to do it anyways to make the final
"assertEquals()" work, so it's a good step. I
implemented the next series of tests one at a time, but I'll show
them all together:
| |
public class BoardTest extends TestCase {
Board board;
protected void setUp() {
board = new Board(new int[] { 1, 2, 0, 1 });
}
public void testBoardNotEqualToNull() {
assertFalse(board.equals(null));
}
public void testBoardNotEqualToNonBoardObject() {
assertFalse(board.equals(new Object()));
}
public void testBoardsOfDifferentSizesArentEqual() {
Board board2 = new Board(new int[] { 1, 2, 3 });
assertFalse(board.equals(board2));
assertFalse(board2.equals(board));
}
public void testBoardsWithDifferentContentsArentEqual() {
Board board2 = new Board(new int[] { 1, 2, 1, 1 });
assertFalse(board.equals(board2));
assertFalse(board2.equals(board));
}
public void testBoardsWithSameContentsAreEqual() {
Board board2 = new Board(new int[] { 1, 2, 0, 1 });
assertEquals(board, board2);
assertEquals(board2, board);
}
public void testEqualityIsTransitive() {
Board board2 = new Board(new int[] { 1, 2, 0, 1 });
Board board3 = new Board(new int[] { 1, 2, 0, 1 });
assertEquals(board, board2);
assertEquals(board2, board3);
assertEquals(board, board3);
}
}
|
The implementation is what you'd expect:
| |
public class Board {
int[] cells;
public Board(int[] cells) {
this.cells = cells;
}
public boolean equals(Object obj) {
if (obj == null) return false;
if (obj.getClass() != Board.class) return false;
Board that = (Board) obj;
if (this.cells.length != that.cells.length) return false;
for (int i = 0; i < cells.length; i++)
if (this.cells[i] != that.cells[i])
return false;
return true;
}
}
|
A Small, Full Grid
Rather than tackle that large test I started with, I have a much
simpler case. Let's look at a board with everything filled in,
under no constraints.
| |
public void testSolvedBoardComesBackAsIsWithNoConstraints() {
Board board = new Board(new int[] { 1, 2, 2, 1 });
Board result = new Solver().solve(board, new ArrayList());
assertEquals(board, result);
}
|
A straightforward solution makes that work:
| |
public Board solve(Board board, ArrayList constraints) {
if (solved(board, constraints))
return board;
throw new Error("No solution found");
}
private boolean solved(Board board, ArrayList constraints) {
return true;
}
|
This next test is the same, but with constraints added. Since we
always return the board we were given, we expect it to pass (and it
does). A Constraint is a set of "n" positions; the constraint it
enforces is that each number 1..n must appear exactly once in the
cells with those positions.
| |
public void testSolvedBoardComesBackAsIsWithConstraints() {
Board board = new Board(new int[] { 1, 2, 2, 1 });
ArrayList constraints = new ArrayList();
constraints.add(new Constraint(new int[] { 0, 1 }));
constraints.add(new Constraint(new int[] { 2, 3 }));
constraints.add(new Constraint(new int[] { 0, 2 }));
constraints.add(new Constraint(new int[] { 1, 3 }));
Board result = new Solver().solve(board, constraints);
assertEquals(board, result);
}
|
I'll do a bit of "Fake It" here. I want to say the board is solved
if no constraint says it isn't. I'll run through the constraints,
but for now they'll all answer "true" (i.e., that the puzzle is
solved as far as that constraint is concerned). The tests stay
green
| |
// In Board.java:
private boolean solved(Board board, ArrayList constraints) {
for (int i = 0; i < constraints.size(); i++) {
Constraint constraint = (Constraint) constraints.get(i);
if (!constraint.solvedBy(board)) return false;
}
return true;
}
// In Constraint.java:
public boolean solvedBy(Board board) {
return true;
}
|
To show that Constraint isn't picky enough, let's add a
test:
| |
public void testConstraintIsNotSolvedIfAnyIntegerIsMissing() {
Board board = new Board(new int[] { 1, 2, 0, 1 });
Constraint constraint = new Constraint(new int[] { 2, 3 });
assertFalse(constraint.solvedBy(board));
}
|
Constraint makes a list of values used and returns false if any
(digits 1 or greater) is not exactly 1.
| |
// In Constraint.java:
public boolean solvedBy(Board board) {
int[] valuesUsed = new int[board.width()+1];
for (int i = 0; i < indexes.length; i++)
valuesUsed[board.at(indexes[i])]++;
for (int i = 1; i < valuesUsed.length; i++)
if (valuesUsed[i] != 1)
return false;
return true;
}
// In Board.java:
public class Board {
int[] cells;
int width;
public Board(int[] cells) {
this.cells = cells;
width = (int) Math.sqrt(cells.length);
}
public int width() {
return width;
}
public int at(int i) {
return cells[i];
}
// etc.
}
|
Validity
We have the notion of whether a puzzle is solved (all
constraints say they are solved). But there's another notion we
need along the way: whether a board configuration is valid. A board
is valid if it has no constraint that says it's not. And the
constraints will allow 0s (empty cells), but not two cells that
claim to own the same number. So: empty cells in a row is valid,
but two 7s in a row are not.
| |
public void testBoardIsValidIfNoConstraintsAreContradicted() {
Board board = new Board(new int[] { 1, 2, 0, 1 });
ArrayList constraints = new ArrayList();
constraints.add(new Constraint(new int[] { 2, 3 }));
assertTrue(new Solver().valid(board, constraints));
}
|
We can make valid() return true and get to green. It needs to do
more when we test the other way:
| |
public void testBoardIsInvalidIfAnyConstraintIsContradicted() {
Board board = new Board(new int[] { 1, 2, 1, 1 });
ArrayList constraints = new ArrayList();
constraints.add(new Constraint(new int[] { 2, 3 }));
assertFalse(new Solver().valid(board, constraints));
}
|
| |
// In Solver.java:
public boolean valid(Board board, ArrayList constraints) {
for (int i = 0; i < constraints.size(); i++) {
Constraint constraint = (Constraint) constraints.get(i);
if (!constraint.accepts(board)) return false;
}
return true;
}
// In Constraint.java:
public boolean accepts(Board board) {
int[] valuesUsed = new int[board.width()+1];
for (int i = 0; i < indexes.length; i++)
valuesUsed[board.at(indexes[i])]++;
for (int i = 1; i < valuesUsed.length; i++)
if (valuesUsed[i] > 1)
return false;
return true;
}
|
If you look back at the earlier
Constraint.solvedBy() method, you'll see that the
first half of these two methods is identical. We'll extract a
helper method:
| |
public boolean solvedBy(Board board) {
int[] valuesUsed = valuesUsed(board);
for (int i = 1; i < valuesUsed.length; i++)
if (valuesUsed[i] != 1)
return false;
return true;
}
public boolean accepts(Board board) {
int[] valuesUsed = valuesUsed(board);
for (int i = 1; i < valuesUsed.length; i++)
if (valuesUsed[i] > 1)
return false;
return true;
}
private int[] valuesUsed(Board board) {
int[] valuesUsed = new int[board.width()+1];
for (int i = 0; i < indexes.length; i++)
valuesUsed[board.at(indexes[i])]++;
return valuesUsed;
}
|
There's still duplication - those loops are very similar - but
we'll leave it. In Ruby or Smalltalk, it might be easier to get rid
of.
Depth-First Search
We're up to the edge of solving real Sudoku. When I do them by
hand, I have a handful of strategies (look for forced cells,
consider both down and across, etc.) But at least for my skill
level, the hard problems have always required me to do a search:
try it one way and see what happens, and backtrack if that
fails.
My worst-case strategy is a standard strategy from AI:
depth-first search. One algorithm for that says:
- Make a stack and push the initial candidates.
- While there are still things in the stack:
- Pop the top item.
- If it's a solution, return it.
- If it's not a solution but it's valid, generate any consequent
candidates and push them on the stack.
- If it's neither a solution nor valid, don't push anything
- If you have an empty stack at the end, you found no
solution.
To represent my candidates, I'll have a Candidate class. It's
just a data bag with three things we need to track: the board, the
next index to change, and the value to put there. It's one of those
rare classes so simple I won't write a test for it.
| |
public class Candidate {
private Board board;
private int index;
private int value;
public Candidate(Board board, int index, int value) {
this.board = board;
this.index = index;
this.value = value;
}
public Board board() {
return board;
}
public int index() {
return index;
}
public int value() {
return value;
}
}
|
In a search like this, we'll frequently take a board, and want
to modify one cell. If we work on a copy of the board, backtracking
is easy - just don't use that copy any more.
| |
public void testBoardCanBeBuiltWithOneValueChanged() {
Board board2 = new Board(board, 0, 2);
assertEquals(2, board2.at(0));
assertEquals(new Board(new int[] { 2, 2, 0, 1 }), board2);
assertFalse(board.equals(board2));
}
|
Create that new constructor, copying the cells and the width. (I
forgot to copy the width on my first try:(
| |
public Board(Board old, int index, int value) {
this.cells = new int[old.cells.length];
for (int i = 0; i < cells.length; i++)
cells[i] = old.cells[i];
width = old.width;
cells[index] = value;
}
|
The next test can force the depth-first algorithm.
| |
public void testBoardThatHasToApplyConstraints() {
Board board = new Board(new int[] { 1, 2, 0, 1 });
ArrayList constraints = new ArrayList();
constraints.add(new Constraint(new int[] { 0, 1 }));
constraints.add(new Constraint(new int[] { 2, 3 }));
constraints.add(new Constraint(new int[] { 0, 2 }));
constraints.add(new Constraint(new int[] { 1, 3 }));
Board result = new Solver().solve(board, constraints);
Board expected = new Board(new int[] { 1, 2, 2, 1 });
assertEquals(expected, result);
}
|
As I go through this exposition, this just seems like too big a
step. Let's try a smaller step that checks the generation of
candidates.
| |
public void testIfIndexIsTooBigThenNothingGetsPushed() {
Board board = new Board(new int[] {1, 2, 0, 1});
Stack stack = new Stack();
new Solver().pushCandidates(stack, board, 4);
assertTrue(stack.isEmpty());
}
public void testIfCellAlreadyFilledOnlyThatBoardGetsPushed() {
Board board = new Board(new int[] {1, 2, 0, 1});
Stack stack = new Stack();
new Solver().pushCandidates(stack, board, 0);
assertEquals(1, stack.size());
assertEquals(new Candidate(board, 0, 1), stack.peek());
}
public void testIfCellIsEmptyThenPushAllCandidates() {
Board board = new Board(new int[] {1, 2, 0, 1});
Stack stack = new Stack();
new Solver().pushCandidates(stack, board, 2);
assertEquals(2, stack.size());
assertEquals(new Candidate(board, 2, 1), stack.elementAt(0));
assertEquals(new Candidate(board, 2, 2), stack.elementAt(1));
}
|
This code makes those tests pass:
| |
// In Solver.java:
public void pushCandidates(Stack stack, Board board, int index) {
if (index >= board.width() * board.width()) return;
if (board.has(index)) {
stack.push(new Candidate(board, index, board.at(index)));
return;
}
for (int i = 1; i <= board.width(); i++)
stack.push(new Candidate(board, index, i));
}
// In Candidate.java:
public boolean equals(Object obj) {
if (obj == null || obj.getClass() != Candidate.class) return false;
Candidate that = (Candidate) obj;
return this.board.equals(that.board)
&& this.index == that.index
&& this.value == that.value;
}
// In Board.java:
public boolean has(int index) {
return cells[index] != 0;
}
|
Let's return to
testBoardThatHasToApplyConstraints(). Now I'll take a
go at the search algorithm. Confession - I accidentally passed
board (rather than newBoard) to
pushCandidates(), and used the debugger to see I had
the wrong board.
| |
public Board solve(Board board, ArrayList constraints) {
Stack stack = new Stack();
pushCandidates(stack, board, 0);
while (!stack.isEmpty()) {
Candidate candidate = (Candidate) stack.pop();
Board newBoard = new Board(candidate.board(), candidate.index(), candidate.value());
if (solved(newBoard, constraints))
return newBoard;
if (valid(newBoard, constraints))
pushCandidates(stack, newBoard, candidate.index() + 1);
}
throw new Error("No solution found");
}
|
The next two tests pass already. One's a small board, the
other is the example from the Wikipedia article cited above. (I
also tried a couple "hard" ones from the back of a Sudoku book, but
I won't include them here.)
| |
public void test4x4Board() {
Board board = new Board(new int[] {
1,2, 0,0,
0,0, 1,2,
0,1, 0,4,
4,0, 0,0
});
ArrayList constraints = new ArrayList();
// Row constraints
constraints.add(new Constraint(new int[] { 0, 1, 2, 3 }));
constraints.add(new Constraint(new int[] { 4, 5, 6, 7 }));
constraints.add(new Constraint(new int[] { 8, 9, 10, 11 }));
constraints.add(new Constraint(new int[] { 12, 13, 14, 15 }));
// Column constraints
constraints.add(new Constraint(new int[] { 0, 4, 8, 12 }));
constraints.add(new Constraint(new int[] { 1, 5, 9, 13 }));
constraints.add(new Constraint(new int[] { 2, 6, 10, 14 }));
constraints.add(new Constraint(new int[] { 3, 7, 11, 15 }));
// Box constraints
constraints.add(new Constraint(new int[] { 0, 1, 4, 5 }));
constraints.add(new Constraint(new int[] { 2, 3, 6, 7 }));
constraints.add(new Constraint(new int[] { 8, 9, 12, 13 }));
constraints.add(new Constraint(new int[] { 10, 11, 14, 15 }));
Board result = new Solver().solve(board, constraints);
Board expected = new Board(new int[] {
1,2, 4,3,
3,4, 1,2,
2,1, 3,4,
4,3, 2,1
});
assertEquals(expected, result);
}
public void testBigBoardWiki() {
Board board = new Board(new int[] {
5,3,0, 0,7,0, 0,0,0,
6,0,0, 1,9,5, 0,0,0,
0,9,8, 0,0,0, 0,6,0,
8,0,0, 0,6,0, 0,0,3,
4,0,0, 8,0,3, 0,0,1,
7,0,0, 0,2,0, 0,0,6,
0,6,0, 0,0,0, 2,8,0,
0,0,0, 4,1,9, 0,0,5,
0,0,0, 0,8,0, 0,7,9
});
ArrayList constraints = constraints9x9();
Board result = new Solver().solve(board, constraints);
Board expected = new Board(new int[] {
5,3,4, 6,7,8, 9,1,2,
6,7,2, 1,9,5, 3,4,8,
1,9,8, 3,4,2, 5,6,7,
8,5,9, 7,6,1, 4,2,3,
4,2,6, 8,5,3, 7,9,1,
7,1,3, 9,2,4, 8,5,6,
9,6,1, 5,3,7, 2,8,4,
2,8,7, 4,1,9, 6,3,5,
3,4,5, 2,8,6, 1,7,9
});
assertEquals(expected, result);
}
private ArrayList constraints9x9() {
ArrayList constraints = new ArrayList();
// Row constraints
constraints.add(new Constraint(new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }));
constraints.add(new Constraint(new int[] { 9, 10, 11, 12, 13, 14, 15, 16, 17 }));
constraints.add(new Constraint(new int[] { 18, 19, 20, 21, 22, 23, 24, 25, 26 }));
constraints.add(new Constraint(new int[] { 27, 28, 29, 30, 31, 32, 33, 34, 35 }));
constraints.add(new Constraint(new int[] { 36, 37, 38, 39, 40, 41, 42, 43, 44 }));
constraints.add(new Constraint(new int[] { 45, 46, 47, 48, 49, 50, 51, 52, 53 }));
constraints.add(new Constraint(new int[] { 54, 55, 56, 57, 58, 59, 60, 61, 62 }));
constraints.add(new Constraint(new int[] { 63, 64, 65, 66, 67, 68, 69, 70, 71 }));
constraints.add(new Constraint(new int[] { 72, 73, 74, 75, 76, 77, 78, 79, 80 }));
// Column constraints
constraints.add(new Constraint(new int[] { 0, 9, 18, 27, 36, 45, 54, 63, 72 }));
constraints.add(new Constraint(new int[] { 1, 10, 19, 28, 37, 46, 55, 64, 73 }));
constraints.add(new Constraint(new int[] { 2, 11, 20, 29, 38, 47, 56, 65, 74 }));
constraints.add(new Constraint(new int[] { 3, 12, 21, 30, 39, 48, 57, 66, 75 }));
constraints.add(new Constraint(new int[] { 4, 13, 22, 31, 40, 49, 58, 67, 76 }));
constraints.add(new Constraint(new int[] { 5, 14, 23, 32, 41, 50, 59, 68, 77 }));
constraints.add(new Constraint(new int[] { 6, 15, 24, 33, 42, 51, 60, 69, 78 }));
constraints.add(new Constraint(new int[] { 7, 16, 25, 34, 43, 52, 61, 70, 79 }));
constraints.add(new Constraint(new int[] { 8, 17, 26, 35, 44, 53, 62, 71, 80 }));
// Box constraints
constraints.add(new Constraint(new int[] { 0, 1, 2, 9, 10, 11, 18, 19, 20 }));
constraints.add(new Constraint(new int[] { 3, 4, 5, 12, 13, 14, 21, 22, 23 }));
constraints.add(new Constraint(new int[] { 6, 7, 8, 15, 16, 17, 24, 25, 26 }));
constraints.add(new Constraint(new int[] { 27, 28, 29, 36, 37, 38, 45, 46, 47 }));
constraints.add(new Constraint(new int[] { 30, 31, 32, 39, 40, 41, 48, 49, 50 }));
constraints.add(new Constraint(new int[] { 33, 34, 35, 42, 43, 44, 51, 52, 53 }));
constraints.add(new Constraint(new int[] { 54, 55, 56, 63, 64, 65, 72, 73, 74 }));
constraints.add(new Constraint(new int[] { 57, 58, 59, 66, 67, 68, 75, 76, 77 }));
constraints.add(new Constraint(new int[] { 60, 61, 62, 69, 70, 71, 78, 79, 80 }));
return constraints;
}
|
Analysis
This snapped together fairly well. It's taken me longer to write
it up than it did to solve.
The oddest part for me was my first test. I wanted a test that
was something like the real problem, and this was a good test for
that. But it was a larger first step than I usually use. I suspect
this is because I had a solution "trail" in mind.
There's still room for some refactoring, though Java doesn't
make it particularly easy to get rid of similar loops. You could
pull out a Constraints class (rather than the ArrayList);
Solver.solved() and Solver.valid() could
move over there. Or maybe Solver really should just be
Constraints.
There's some room for work in the Board class. I tried it with
clone() one time and with a constructor the other. A
third alternative would be to put a copy-and-modify operation on
Board (having the board generate a copy of itself with the cells
modified). I'd keep that up my sleeve if I needed to later.
This had some of the flavor of re-implementing a known
algorithm: I certainly had a certain approach in mind when I
started. The steps didn't feel so big when I did them the first
time, but I felt a strong urge to fill in the blanks while writing
it up. (I wonder which impulse is better.)
The algorithm itself turned out to be as nice as I expected. The
setting up of constraints is somewhat ugly, but it was quick and
easy. I made my list and checked it twice. If I were generalizing
beyond this case, I'd definitely revisit constraint setup.
The biggest surprise for me was in performance. I had fully
expected this program to need a lot of performance tuning. After
all, I didn't do anything to cut down the huge search space. But
even the hardest boards I've tried run in about a second.
Resources
[Written July 10, 2006.]
|