Fun Stuff: Solving a Sudoku puzzle


What is more satisfying than solving a Sudoku puzzle? Making a computer solve one for you.


Overview


Sudoku is a number puzzle with a straightforward goal: given some input arrangement of numbers in a 9x9 grid, fill in the blanks such that each row, column, and 3x3 sub-block contain all of the digits from 1 to 9. Much of the logic for completing Sudoku puzzles is straightforward and easily encodable in a simple computer program - so that is what we are going to do!

The Sudoku solver described here was developed for Project Euler's problem 96, which involves solving 50 Sudoku puzzles of varying difficulty. With the logic outlined below, the solver you develop should be able to complete 49 of the Project Euler puzzles, which is good enough to find the answer to problem 96 (with a little human input for the remaining puzzle). A working version of this solver (written in Python) can be found here, we have also developed a web interface where you can work a puzzle yourself or ask the solver for help.


You can also follow a video version of this tutorial, here:




Procedure


We will encode enough logic in a computer program to solve 74 out of a set of 75 sample puzzles (which includes the set of 50 Project Euler puzzles) using the procedure outlined here. Briefly, our algorithm will need to identify the following situations Step 1. Read the puzzle


Consider the following puzzle:

2

7

9

8

1

3

2

7

4

1

8

5

9

4

1

6

4

2

8

3

8

7

3

2

3

2

5

7

6

7

6

5

1

6

8

9


The puzzle above is one of the puzzles given in the file provided here. Specifically, the puzzle above is the seventh puzzle, defined in that file by


Grid 07
043080250
600000000
000001094
900004070
000608000
010200003
820500000
000000005
034090710

So, the first thing we need to do is develop a function to read in each puzzle from this file and to place the data into a suitable container. The Python function below will read in a single puzzle from the sample file and return that puzzle as a 9x9 numpy array of integers.


import numpy as np

def read_puzzle(f):
    """
    read puzzle 

    :param f: a file pointer to the open file containing the puzzles
    :return puzzle: a 9x9 numpy array containing the puzzle
    """

    puzzle = np.zeros((9,9),dtype=np.int8)

    # grid number (e.g., "GRID 01")
    f.readline()

    for i in range (0,9):
        line = f.readline()
        for j in range (0,9):
            puzzle[i,j] = line[j]

    return puzzle


Step 2. Initialize the candidate lists


For each blank spot in the puzzle (denoted with "0" in Project Euler's format), we can develop a list of possible candidate values for the solved puzzle. I find that an array of 81 lists representing each of the positions in the 9x9 grid is a suitable object that will allow us to add candidates [list.append(candidate)], remove candidates [list.remove(candidate)], or search for candidates within a given list [candidate in list]. Such an object can be initialized as 81 empty lists as


candidate_list = [ [] for _ in range(81) ]

An initial candidate list can be populated by marching through each blank position and checking whether each possible number (1-9) appears in the corresponding row/column/block. If the number does appear in the corresponding row/column/block, then it cannot be a candidate for that position. The update_candidate_list function defined here performs this task.


def update_candidate_list(puzzle, candidate_list):
    """
    update candidate list for each element of puzzle

    :param puzzle: the sudoku puzzle
    :param candidate_list: list of possible values for each element of puzzle
    """

    # clear candidate list
    for i in range (0,9):
        for j in range (0,9):
            ij = i * 9 + j
            candidate_list[ij].clear()

    # build candidate list

    # row
    for row in range (0,9):

        # column
        for column in range (0,9):

            # for this row/column, if no value already, what are possible values?
            if ( puzzle[row,column] == 0 ):

                for target in range (1,10):
    
                    in_row   = target in puzzle[row,:]
                    in_col   = target in puzzle[:,column]
                    in_block = check_block(puzzle, row, column, target)

                    if not in_row and not in_col and not in_block:
                        candidate_list[row*9 + column].append(target)

    return candidate_list

Note that the function "check_block" has yet to be defined. I leave it to you as an exercise to implement this function yourself. If you get stuck, you can find a working version in the full Python code.

At this point, we have minimal logic necessary to make some progress toward a solution. Once the candidate lists have been built, we can check if any position has only one possible candidate. If that is the case, then the puzzle can be updated accordingly. Consider the puzzle given above, where I have labeled the candidate lists for the first 3x3 sub-block:


5, 6

2

3

7

4, 5,     8

4, 8

6

9

1, 3,     4

8

1

3

2

7

4

1

8

5

9

4

1

6

4

2

8

3

8

7

3

2

3

2

5

7

6

7

6

5

1

6

8

9



The candidate lists for the positions highlighted in red have only one possible value, so we know these values must be solutions for these positions. Further, once the value 6 has been assigned to the bottom left position of this sub-block, we know that the top left position of the sub-block (shaded yellow) must have a value of 5. It turns out that this minimal logic is sufficient to solve some very easy Sudoku puzzles; at this point, your solver could be smart enough to complete 12/75 of the sample puzzles!

To make real progress for more challenging puzzles, though, we will need to implement additional logic that will allow us to eliminate potential candidates from the candidate lists.


Step 3. Identify loners


If a candidate only occurs once in a row/column/box, then it can be designated as the sole valid candiate for that position. Consider the puzzle given above, after applying the logic we just discussed to the first sub-block:


5

2

3

7

4, 8

4, 8

6

9

1, 4

8

1

3

2

7

4

1

8

5

9

4

1

6

4

2

8

3

8

7

3

2

3

2

5

7

6

7

6

5

1

6

8

9



The position highlighted in red has two candidate values: 1 and 4. However, we can see that the value of 1 is special because no other position in the block can accomodate this value. Therefore, we can remove the other candidate from the candidate list for the position highlighted in red; the final value for this position must be 1!

The following function identifies such loners in each row/column/block of the puzzle and prunes the candidate lists accordingly.


def loners(candidate_list):
    """
    if a candidate only occurs once in a row/column/box, then it can be designated
    as the sole valid candiate for that position

    :param candidate_list: list of possible values for each element of puzzle
    """

    # loop over target values
    for target in range (1,10):

        # check for target in candidate lists of given row
        for row in range (0,9):
            n_instances = 0
            for col in range (0,9):
                position = row * 9 + col
                in_list = target in candidate_list[position]
                if ( in_list ):
                   n_instances += 1

            # if it appears only once, remove all other candidates from relevant list
            if ( n_instances == 1 ):
                for col in range (0,9):
                    position = row * 9 + col
                    in_list = target in candidate_list[position]
                    if ( in_list ):
                       candidate_list[position].clear()
                       candidate_list[position].append(target)

        # check for target in candidate lists of given column
        for col in range (0,9):
            n_instances = 0
            for row in range (0,9):
                position = row * 9 + col
                in_list = target in candidate_list[position]
                if ( in_list ):
                   n_instances += 1

            # if it appears only once, remove all other candidates from relevant list
            if ( n_instances == 1 ):
                for row in range (0,9):
                    position = row * 9 + col
                    in_list = target in candidate_list[position]
                    if ( in_list ):
                       candidate_list[position].clear()
                       candidate_list[position].append(target)

        # check for target in candidate lists of given block
        for bi in range (0,3):
            start_row = 3*bi
            for bj in range (0,3):
                start_col = 3*bj
                n_instances = 0
                for row in range (start_row,start_row + 3):
                    for col in range (start_col,start_col + 3):
                        position = row * 9 + col
                        in_list = target in candidate_list[position]
                        if ( in_list ):
                            n_instances += 1

                # if it appears only once, remove all other candidates from relevant list
                if ( n_instances == 1 ):
                    for row in range (start_row,start_row + 3):
                        for col in range (start_col,start_col + 3):
                            position = row * 9 + col
                            in_list = target in candidate_list[position]
                            if ( in_list ):
                               candidate_list[position].clear()
                               candidate_list[position].append(target)

    return candidate_list



As simple as the procedure for identifying loners is, this logic is sufficient to solve many easy Sudoku puzzles. At this point, your solver could be smart enough to solve 40 of the 75 sample puzzles!


Step 4. Identify naked pairs


If a pair of positions in the same row/column/block have only two candidates and these candidates are identical, those values can be removed from the candidate lists for the remaining positions in that row/column/block. Consider again the puzzle from above:


5

2

3

7

4, 8

4, 8

6

9

1

8

1

5, 6,     9

3

2

7

4

1

8

5

9

4

1

6

4

5, 6

2

8

3

5, 7,     9

8

7

3

2

3

2

5

7

6

7

8, 9

4, 9

6

2, 9

4, 9

3, 4

5

1

6

8

9



The positions highlighted in red have only two candidate values, 4 and 9, making them a naked pair. The positions highlighted in yellow share a column or sub-block with the red shaded positions, and their candidate lists contain either 4 or 9; these values can safely be removed from these lists. Interestingly, this logic prunes the candidate lists for three of the yellow shaded positions to the point that we know precisely what values should solve these positions!

The following function identifies naked pairs in each row/column and block of the puzzle and prunes the candidate lists accordingly.


def naked_pairs(candidate_list):
    """
    if a pair of positions in the same row/column/block have only two candidates and these candidates
    are identical, those values can be removed from the candidate lists for the remaining positions
    in that row/column/block

    :param candidate_list: list of possible values for each element of puzzle
    """

    # rows
    for row in range (0,9):

        # march along pairs in this row by column
        for col1 in range (0,9):
            position1 = row * 9 + col1
            if ( len(candidate_list[position1]) != 2 ):
                continue
            for col2 in range (col1+1,9):
                position2 = row * 9 + col2
                if ( len(candidate_list[position2]) != 2 ):
                    continue
                # are the candidate lists the same?
                if ( candidate_list[position1] == candidate_list[position2] ):

                    val0 = candidate_list[position1][0]
                    val1 = candidate_list[position1][1]

                    # eliminate these candidates from remaining positions in row
                    for col in range (0,9):
                        position = row * 9 + col
                        if ( position == position1 or position == position2 ):
                            continue

                        in_list = val0 in candidate_list[position]
                        if ( in_list ):
                            candidate_list[position].remove(val0)

                        in_list = val1 in candidate_list[position]
                        if ( in_list ):
                            candidate_list[position].remove(val1)

    # columns

    ...
    ...
    ...

    # blocks

    ...
    ...
    ...

    candidate_list = loners(candidate_list)

    return candidate_list


In this code snippet, the logic applied to identifying naked pairs within rows can also be applied to columns and blocks, but the relevant code has been suppressed. If you have trouble developing this logic yourself, you can find the complete function in the full Python code. Lastly, note that it might be a good idea to check for loners again, after the candidate lists have been pruned during the search for naked pairs. With the identification of loners and naked pairs, your solver should be able to solve 44 of the 75 sample puzzles.


Step 5. Identify hidden pairs


If a pair of positions in the same row/column/block shared two common candidates that appear in no other position in the row/common/block, then these are the only viable candidates for these two positions. You should add a function to identify hidden pairs to your solver. As above, it is probably a good idea to check for loners and naked pairs at the end of this function.

You can find working code for identifying hidden pairs in the full Python code for this project. Once you have implemented box and row/column claiming, your solver should be able to solve 56 of the 75 sample puzzles.


Step 6. Identify naked triples


If three positions in the same row/column/block share the same three and only three candidates, those values can be removed from the candidate lists for the remaining positions in that row/column/block. You should add a function to identify naked triples to your solver and be sure to check for loners, naked pairs, and hidden pairs after you have pruned your candidate list in this function.

You can find working code for identifying naked triples in the full Python code for this project. With the addition of this logic, your solver should be able to solve 58 of the 75 sample puzzles.


Step 7. Box claiming and row/column claiming


If a candidate appears in only one row/column of a block, then the number is claimed for that block and removed from the candidate lists for the same row/column in other blocks. Similarly, if the only instances of a candidate in a row/column occur within the same block, then that candidate can be removed from the candidate lists of all other positions within that block. You should add three functions for box/row/column claiming to your solver. As always, it is probably a good idea to apply all previously developed logic at the end of each of these functions.

You can find working code for box/row/column clamining in the full Python code for this project. Once you have implemented box and row/column claiming, your solver should be able to solve 74 of the 75 sample puzzles. The remaining puzzle (#7) can be solved with some human intervention (i.e., by supplying one additional correct value to these puzzles in the input file).


Step 8. Iterate


To solve any Sudoku puzzle, the logic outlined above must be applied in an iterative fashion until no more moves can be made. The following code reads puzzles from puzzle.txt and attemps to solve each of them. A warning message is printed whenever a solution cannot be found:


import numpy as np

def main():
    """
    Sudoku solver.
    """

    # open puzzle file
    f = open("puzzles.txt","r")

    for n_puzzle in range (0,50):

        # read current puzzle
        puzzle = read_puzzle(f)

        # candidate list 
        candidate_list = [ [] for _ in range(81) ]

        # solve!
        for it in range (0,1000):

            # update candidate list
            candidate_list = update_candidate_list(puzzle, candidate_list)

            # check for loners
            candidate_list = loners(candidate_list)

            # check for naked pairs
            candidate_list = naked_pairs(candidate_list)

            # check for hidden pairs
            candidate_list = hidden_pairs(candidate_list)

            # check for naked triples
            candidate_list = naked_triples(candidate_list)

            # box claim:
            candidate_list = box_claiming(candidate_list)

            # row claim:
            candidate_list = row_claiming(candidate_list)

            # column claim:
            candidate_list = column_claiming(candidate_list)

            # update puzzle if any positions only have one candidate
            n_moves = 0
            for i in range (0,9):
                for j in range (0,9):
                    ij = i * 9 + j
                    n = len(candidate_list[ij])
                    if n == 1 :
                        puzzle[i,j] = candidate_list[ij][0]
                        n_moves += 1

            # no more moves
            if n_moves == 0:
                break

        # print puzzle
        #print(puzzle)

        # if solver is done, check candidate list to determine if it was successful
        candidate_list = update_candidate_list(puzzle, candidate_list)
        for position in range (0,81):
            if ( len(candidate_list[position]) > 0 ):
                print("    solver failed to solve puzzle %i!" % (n_puzzle+1) )
                break

if __name__ == "__main__":
    main()



free