Why TDD makes a lot of sense for Sudoko
My colleague Thomas sent me a very interesting link about attempts to solve Sudoku using test-driven development. The article, somewhat unfairly, pits Ron Jeffries’ explorations of Sudoku using test-driven development against Peter Norvig’s “design driven” approach.
I found both attempts lacking. However, while Ron Jeffries freely admitted that he didn’t even know the rules of Sudoku when he started, both Norvig himself and his readers fawn over his solution. I didn’t find it very understandable.
So I took it upon myself to examine the problem myself. I did some up-front thinking in the shower and on the subway, then attacked the problem with TDD. I ended up with a solution that works in all cases (unlike Norvig). My implementation has readable code, readable tests, and solves the problem reasonably fast.
Observations and conjectures
Here are a few things I learned from the exercise:
- When you’re using TDD to solve a tricky algorithm, you have to think about both the algorithm and the test approach.
- Solving a problem with a known algorithm using TDD gives more readable code than I otherwise would expect.
- When I solved the problem with TDD, running the solution on real problems worked the very first time I tried it.
- The trick to making TDD work is to work from the outside in.
- When creating a Sudoku solver, don’t think like a human! Think like a machine! The human algorithm is difficult to understand and likely to not work on all problems. This was the biggest problem with Norvig’s code
The journey
I decided on the following approach:
- I had decided upon an initial design with a solver class and a board class. The solver should use a recursive depth first search. The solver asks the board what options exists per cell, but it has no knowledge of the rules of Sudoku (such as no duplicate numbers on the same row).
- The first step was to get the solver (“the outside”) correct. For this step, I mocked out the board
- The second step was to implement the interface that the solver needed for the board. Mainly, this is a matter of specifying the rules for what numbers can occur in which cell on a Sudoku board.
- Finally, I wrote some code to read and write the Sudoku board. When trying the solver on real problems, it worked the first time, and solved 95 hard problems correct. It was somewhat slow, though.
After solving the problem the first time, I practices a few times and recorded a screen cast of the solution:
The solver
Testing the solver is a matter of creating a mock board and ensuring that the solver does the correct things. This is the most complex test case:
@Test
public void shouldBacktrackWhenNoMoreOptions() throws Exception {
SudokuSolver solver = new SudokuSolver();
SudokuBoard board = mock(SudokuBoard.class);
when(board.getOptionsForCell(anyInt(), anyInt()))
.thenReturn(singleOption());
when(board.getOptionsForCell(8, 7))
.thenReturn(moreOptions(1, 2));
when(board.getOptionsForCell(8, 8))
.thenReturn(noOptions())
.thenReturn(singleOption());
assertThat(solver.findSolution(board)).isTrue();
InOrder order = inOrder(board);
order.verify(board).setValueInCell(1, 8,7);
order.verify(board).setValueInCell(2, 8,7);
}
It specifies that all cells, except (8,7) and (8,8) return exactly one option. (8,7) returns two options. (8,8) returns no options the first time it is called, and one option the second time. The test verifies that a solution is found, and the solver tries to set both options for (8,7). This drives a rather simple algorithm. Here’s basically the whole algorithm:
public boolean findSolution(Board board, int cell) {
if (cell == SIZE*SIZE) return true;
boolean wasEmpty = board.isEmpty(row(cell), col(cell));
for (Integer value : board.getCellOptions(row(cell), col(cell))) {
board.setValueInCell(value, row(cell), col(cell));
if (findSolution(board, cell+1)) return true;
}
if (wasEmpty) board.clearValueInCell(row(cell), col(cell));
return false;
}
The algorithm tries all available options for a cell in order. If no solution works for the rest of the board, the algorithm returns false (for “no solution”). The algorithm is not how a human would solve Sudoku. But then again, we’re not writing a tutorial on how to solve Sudoku, we’re writing a program that solves Sudoku. The board As I implemented the solver, the interface for the board started to emerge. At that point in time, I had to create tests for the Sudoku board itself. A typical test verifies that the board doesn’t allow duplicate values in a row:
@Test
public void shouldDisallowOptionsInSameRow() throws Exception {
int row = 4;
board.setValueInCell(1, row, 5);
board.setValueInCell(2, row, 8);
board.setValueInCell(3, row+1, 5);
assertThat(board.getOptionsForCell(row, 0))
.excludes(1,2).contains(3);
}
The essence of SudokuBoard is finding out what values are legal in an open cell:
public List getOptionsForCell(int row, int col) {
if (!isEmpty(row,col)) return Arrays.asList(cells[row][col]);
List result = allOptions();
removeAllInRow(result, row);
removeAllInCol(result, col);
removeAllInBox(result, row, col);
return result;
}
TDD as a design guide
I invite you to compare Peter Norvig’s solution to mine (you can find the full source code for my solution in my github repository). It would probably have been possible for me to code the solution faster without tests, but it probably would not have worked the first time I tried it. I also would have much less confidence in the code. Finally, I think the design imposed by the tests made my code easier to understand.
Comments:
[Anonymous Guest] - Mar 30, 2011
You did misread Norvig. He describes his algorithm as “systematically try all possibilities until we hit one that works”. Thus his algorithm is almost the same as yours, except that (a) he uses heuristics to choose the variable ordering while you go in linear order, and (b) he is more aggressive about propagating constraints. The result is an algorithm that is complete, but is about 1000 times faster (and more compact in terms of lines of code, which some see as an advantage and some as a disadvantage). To the extent that your solution looks simpler, it is because it is simpler: it is doing so much less. You should get down to the .01 second per puzzle range before you can compare apples to apples.
[Elliott] - May 4, 2010
TDD is awesome. We all know and love it. However there are lots of times when the a few minutes on google will give you a better start then setting out the first tests. Google first, then test, then code will give a good code base that implements the correct solution, not just one that passes tests.
While this result is correct it is wildly more computationally expensive than it needs to be. Sudoku is an exact cover problem and should be solved with variations on the exact cover algorithms, if a backtracking solver is to be used.
http://en.wikipedia.org/wiki/Dancing_Links
http://www.ocf.berkeley.edu/~jchu/publicportal/…
http://www.sudocue.net/dancinglinks.htm
On top of that looking at the code I am reasonably sure that the code will find a solution to invalid puzzles. Puzzles where there are more than one solution will return the first found result.
Fitting the board into a smaller memory footprint will result in less cache invalidation and a rather large speed up. (Yea yeah it’s pre-mature optimization but solving these are not as hard as getting the code great.)
Try and make sure that nothing makes the assumption that the board is 9x9 there are lots of more advanced puzzles that are 16x16(they take forever but are a real accomplishment when solved.)
Johannes Brodwall - May 6, 2010
Thanks for your comments, Elliott. You raise many valid points on how to make the solution better. I will be studying the dancing links information more, but my initial impression is that this is fairly difficult stuff.
When it comes to the context of the kata, it’s fast enough for it’s purpose. I’d rather focus on making an algorithm that’s easy to understand than one that’s as fast as it could be. That being said, the unit tests constrain the design quite a bit, and it would be interesting to see how easy it is to optimize this solution.
Generalizing to 16x16 boards (or some of the other possible board configurations) requires more thinking than simply replacing all “9"s with variables. This is the sort of premature generalization I try to avoid until I understand what problems I’m required to solve.
If you’re up to the task, I would love to see a screencast where you solve sudoku using Dancing Links, though!
Johannes Brodwall - Mar 29, 2011
My solution turned out to be a lot slower than I initially thought, taking up to 2 minutes for a complex sudoku. I made some initial very stupid mistakes after I wrote the text but before I published it that made me optimistic. I had to change the text after that.
As I understand Norvig’s solution, it is a heuristic solution, which means that there are some (theoretic) cases it can miss. The solution I use (brute forcing) is complete, so it’s guaranteed to find a solution if there is one. I may have misread Norvig, though.
I’ve considered whether to write a follow-up post where I refactor the solution for speed. Would you be interested in this?
[Anonymous Guest] - Mar 29, 2011
Thanks for the very nice article. I didn’t understand the “all cases (unlike Norvig)” part. Norvig’s article says that his program was tested on 2 million examples and worked correctly on all of them (correctly failing to find a solution on the puzzles that in fact have no solution). Do you have examples of cases that his does wrong and yours does right? Also, what is the mean run time (and variance) or your program?