Sudoku Squares are elegant, and frankly, magical.1
With pockets of time being freed up with work from home orders during the COVID-19 pandemic, I picked up Sudoku to fill in the gaps. And man, it’s fun solving them - it’s cathartic seeing the numbers fall nicely into place.
It’s one thing to solve Sudokus, but another to create one.
Out of curiosity, I decided to try and make a fully filled Sudoku Grid by hand. I gave up after a few attempts. It’s surprisingly tedious and difficult to build one off the cuff.
Wouldn’t it be much much easier for a computer to do all the tedious comparisons and checks rather than relying on my error-prone brain?
Since I was quite free waiting to enroll into University… one thing led to another, which led to this homemade Sudoku game being born. This is how I made it.
A Lazy Approach
The first approach that came to my mind was basically building the board similar to how I would try doing it by hand.
The algorithm’s script is in the repo under sudogen.py
.
The Algorithm ⚙️
-
Fill up the grid row by row randomly with valid (unused) numbers that comply with Sudoku rules
-
If the row can’t be built, try making the row again from scratch
-
If the row cannot be built multiple times, give up on the current row and build the previous row from scratch again
-
Repeat until all 9 rows of the Sudoku grid fall into place
This is, admittedly, a very lazy way of building the Sudoku board. It does not keep track of the values that were picked and failed (which means that there is a chance that the row can choose a failed number again). It even ‘gives up’ prematurely, even though a solution to building a row can be found.
But before we work on making the approach more rigorous, let’s make the code work first.
Making the Grid
First things first, we need to set up a empty sudoku grid so that we can fill it in.
|
|
The next thing we need to do is to build the row. To fill in the cells in the row, we need to find out what the possible (unused) values can be placed in each cell.
Searching the Rows, Columns and Squares
To find the unused
numbers that can be inserted, we must search and find out which numbers have appeared in the position’s row, column and square. This is essentially how one would check if their Sudoku answer is correct.
An intuitive approach is to search the row/column/square and check which values already exist, which will implicitly give us the unused values.
This is one way we can implement this if we just checked the row:
|
|
This approach is slow. Its worst case time complexity is O(n^2).
This slowness adds up over time when building the Sudoku. This function is the workhorse of the algorithm, as it is called for every position of the Sudoku grid, which means that at least 81 calls need to be made!
So what I did was implement a counting sort.
|
|
This allows us to map the values that have been used when scanning through the row, col & squares of the grid. We can then check for a value’s occurence with a O(1) list access to vmap
. For example, to find how many times ‘1’ has appeared, I simply need to call vmap[1]
.
This reduces our time complexity to O(n)2.
We can implement this trivially for rows and columns, since we can access a row/column using rowIndex
/colIndex
. But finding the 3x3 squares is a little harder. First, we need to know where the square starts and stops for the position being queried.
|
|
With these limits, we can then implement our counting sort for a 3x3 square in the Sudoku:
|
|
We can then finally build our overall function that checks for row, column and square:
|
|
Building Row by Row
Let’s start building the grid row by row. From columns 1 to 9, we randomly choose one of the unused values to insert into the grid.
In the likely event that there is no solution where getAvailValues
returns an empty list, we execute step 2 of the algorithm: Discard the row and start it again through a recursive call.
There is a possibility that the arrangement of previous rows make it such that no permutation of numbers can give a valid Sudoku row. The function will then loop forever in recursion purgatory.
To prevent this, we can keep track of the number of recursions the function has gone through and break it if it passes a certain threshold.3
|
|
We can then wrap it up with additional logic to control row generation:
|
|
A purely recursive approach (getting generateRow
to call itself to build the next row) was dropped in favor of a iterative approach for ease of debugging. Python also tended to just stop going into deeper levels of recursion even when setting the system variable for recursion depth limit to the max.
Running the code with a simple script to print every row, the grid generator works!4
At this point, I was ready to wrap up. Mission accomplished, after all. But then a friend of mine who knew I was working on this project showed me this Sudoku backtracking solver.
A Simple Backtrack
I ran the code in the link. It looked very, very impressive, with all the numbers flickering about as the algorithm methodically worked its way to solve the puzzle.
Although my script builds the full board rather than a puzzle with blanks to be solved, perhaps there is a way to implement a backtracking algorithm in the code myself?
The Algorithm ⚙️
A backtracking algorithm essentially tries all possible permutations of a solution and discards the failed attempts. It is typically done through recursion.
-
Build row by row from the leftmost to rightmost cells with randomly chosen values from the set of unused, valid values
-
If a value cannot be found, go back a cell. Blacklist the cell’s previously selected value and proceed with another valid one
-
If no valid value exists for the returned cell after a backtrack, go back another cell. If the cell is the first of the row, go back to the previous row instead and rebuild it from scratch randomly
-
Repeat until the grid is formed
I implemented the ‘backtrack’ non-recursively, using a simple blacklist for a single row:
|
|
For every column index, failed values get logged into the blacklist and are excluded when getAvailValues()
is being called in the event of a backtrack:
|
|
The row generating logic dropped the recursion in favor of an iterative approach which is somewhat easier to debug:
|
|
Running the script, it works! But what if we were more rigorous with the depth of the blacklist?
Mother Of All Backtracks (MOAB)
Instead of keeping track of a row’s worth of failed solutions, how about we extend it to the whole grid?
|
|
This particular implementation was pretty hairy, with the backtracking logic extending out to 2 seperate functions.
In the repo, this is sudogen3
if you are interested in the full implementation code.
The Algorithm ⚙️
-
Build row by row from the leftmost to rightmost cells with randomly chosen values from the set of unused, valid values
-
If a value cannot be found, go back a cell. Blacklist the cell’s previously selected value and proceed with another valid one
-
If no valid value exists for the returned cell after a backtrack, go back until we reach a cell with valid values
-
Repeat until the grid is formed
This was a can of worms. Additional flags and conditions needed to be added to catch perculiar edge cases, such as infinite loops5 that the algorithm could get stuck in.
|
|
Visualising Efficiency
The fun part comes when we take a look at the data we can get from the algorithms.
To check the efficiency of the 3 different generators, I created a counter variable __timesCalled
that records the amount of times getAvailValues()
is being called. This is because getAvailValues()
is indicative of the algorithm’s efficiency since it is called for every single cell input.
All the effort into making a thorough, methodical Sudoku generator must pay off in terms of efficiency, right?
After all, it intuitively makes sense that with a proper backtracking algorithm, we can systematically search through values and quickly converge to a valid sudoku square. It should at least be better than randomly re-building rows, leaving it up to chance.
Well, no.
It was kind of obvious while debugging the three algorithms that the MOAB and Simple Blacklist algorithms were slow.
For each of the 3 Sudoku algorithms discussed, 1000 puzzles were generated and the number of lookup calls to getAvailValues()
were recorded down.
Here’s the scatter plot of the Lazy Approach, Simple Backtrack and MOAB.
There’s a lot of noise, but it is abundantly clear that the blacklisting approaches are generally less efficient than the Lazy Approach.
Let’s plot a Box & Whisker diagram to get a cleaner look into the stats.
With this, it is clear that on average, the Lazy Approach takes much fewer lookups than the Simple Blacklist and MOAB. But why?
Perhaps its because it’s more likely for the algo to stumble onto the ‘correct’ sequence of values randomly for any given row rather than be going through all the possible permutations using the backtracking algo. Especially at the later rows where there may only be one ‘valid’ sequence of values that exists and will naturally converge to a solution, but many wrong values to choose from and permutate through.
On Efficiency
To begin with, backtracking is a naiive approach to solving problems, at least in the case of Sudoku. Backtracking is a form of brute forcing, which is inefficient from the get-go.
There are opportunities for more elegant ways to build the grid using the properties of Sudoku, such as reflections, rotations & group-theory. There are a wide range of papers and studies for Sudoku that go into detail on finding all sorts of permutations for valid Sudoku grids which can further advise Sudoku generating algorithms. The perfect build case for my scripts are 81 lookup calls, but it is entirely possible to go below 81 lookups and reach really high levels of efficiency.
Of course, I’m pretty sure that out there in the wild, people have already created Sudoku generating algorithms that exploit its mathematical properties to dizzying levels of efficiency.
Puzzle-Making and Tkinter
At this point I’ve spent a considerable amount of time building and debugging this program. Why not just make it into a proper game? To wrap this project up, I made the UI using Tkinter and made it into a fully functional game with varying difficulty.
The puzzle is formed by calling the sudoku grid generator, then popping off random positions from the grid. The higher the difficulty, the more positions popped.
This is a naiive approach to accessing difficulty. A true reflection of difficulty not only scales with the number of blanks, but also takes into account the techniques that a player needs to use to solve the puzzles. But for my intents and purposes, the naiive approach should still get the job done for a vast majority of the time.
If you’re interested to try my homemade Sudoku out, download my project here and enjoy! 🍻
-
Sudokus are not actually magic squares, but are similar insofar as the rows and columns add up to the same number. Sudokus are specifically, a special type of latin squares. ↩︎
-
Alternatively, we can achieve a similar reduction of time complexity by using a dictionary. ↩︎
-
I used an evil global variable to track the recursion depth. It would be better if the counter was passed in as an argument in the function. However, to keep things simple, I went with the evil option 😈 ↩︎
-
If you are coding along, you can see the grid on terminal by putting the following under your script’s driver function:
↩︎0 1 2
sudoku = generateSudoku() for row in sudoku: print(row)
-
Under certain conditions, the algorithm oscillates to and fro two points of the grid. I am not sure about the mathematics behind the trigger to this condition, since the conditions themselves are hard to catch and debug. ↩︎