[LeetCode][Hard] Sudoku Solver 解数独 | Java

3,155 阅读4分钟

问题

Sudoku Solver Hard

Write a program to solve a Sudoku puzzle by filling the empty cells.

A sudoku solution must satisfy all of the following rules:

  1. Each of the digits 1-9 must occur exactly once in each row.
  2. Each of the digits 1-9 must occur exactly once in each column.
  3. Each of the digits 1-9 must occur exactly once in each of the 9 3x3 sub-boxes of the grid.

The '.' character indicates empty cells.

 

Example 1:

image.png

Input: board = [
["5","3",".",".","7",".",".",".","."],
["6",".",".","1","9","5",".",".","."],
[".","9","8",".",".",".",".","6","."],
["8",".",".",".","6",".",".",".","3"],
["4",".",".","8",".","3",".",".","1"],
["7",".",".",".","2",".",".",".","6"],
[".","6",".",".",".",".","2","8","."],
[".",".",".","4","1","9",".",".","5"],
[".",".",".",".","8",".",".","7","9"]
]
Output: [
["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"]
]
Explanation: The input board is shown above and the only valid solution is shown below:

image.png  

Constraints:

  • board.length == 9
  • board[i].length == 9
  • board[i][j] is a digit or '.'.
  • It is guaranteed that the input board has only one solution.

解题思路一

如果有喜欢玩数独的朋友,看到这个题肯定跟我一样很开心,以为之前玩数独的经验可以有用武之地了。不过当我做完才发现,跟我想象的不一样,根本没有用武之地-_-|||。计算机擅长的是穷举,程序员的工作就是让它聪明地穷举。废话不多说,回到本题,本文将给出两种解法,一种是穷举,另一种是聪明的穷举。

做过数独的朋友应该知道,开始做题时,一个空格往往有多个可能的候选数字,我们需要分析各个节点之间的制约关系,除掉一些候选数字,直到每个空格都只剩一个候选项,才能得到最终的解。

解法一的思路简单明了:

  1. 循环直到遇到空格'.' 
  2. 尝试所有候选数字,如果能找到一个,则将其填入数独表,继续寻找下一个空格'.',重复此步骤
  3. 如果没有候选数字,则返回到上一个空格,并将其重置为'.',在上一格继续执行步骤2,尝试其他可能的候选数字
  4. 若在步骤2中处理完所有空格,则数独解答成功

其实就是在来来回回不断地尝试。用递归的方式来表达,需要定义递归函数doSolve(char[][] board, int row, int col),来处理某个节点。从board[0][0]开始,按照从左至右,从上至下的顺序,对board中的元素逐个调用递归函数。当遇到上述步骤3中的情况,无法继续尝试下去时,递归函数doSolve返回失败。如果可以继续尝试,则返回成功。当所有空格都被递归函数访问完毕,则解答成功。

解法一虽然被LeetCode接受,但是执行比较慢,仅仅打败了14%的竞争方案。

参考答案一


public class Solution {

    public void solveSudoku(char[][] board) {
        doSolve(board, 0, 0);
    }

    private boolean doSolve(char[][] board, int row, int col) {
        // if all cells have been solved, then return true
        if(row == 9 && col == 0) {
            return true;
        }
        // decide the next row & col to solve
        final int nextRow = (col + 1) == 9 ? row + 1 : row;
        final int nextCol = (col + 1) == 9 ? 0 : col + 1;

        // if current cell has been solved then solve the next one
        if(board[row][col] != '.') {
            return doSolve(board, nextRow, nextCol);
        }

        // if current cell hasn't been solved, then try from 1 to 9
        for(char num = '1'; num <= '9'; num++) {
            if(isValid(board, row, col, num)){
                // if the candidate num is valid
                // set it to board temporarily
                board[row][col] = num;
                // then solve the next one
                if(doSolve(board, nextRow, nextCol)) {
                    // if all the rest cells have been solved, return true
                    return true;
                }
                // if any rest cells can't be solved, set back to '.' and continue trying
                board[row][col] = '.';
            }
        }

        return false;
    }

    private boolean isValid(char[][] board, int row, int col, char num) {
        // the row & col of the first cell in the cube
        int cubeRow = (row / 3) * 3;
        int cubeCol = (col / 3) * 3;
        for (int i = 0; i < 9; i++) {
            // if num already exists in the same col
            // or num already exists in the same row
            // or num already exists in the same cube
            if (board[i][col] == num
                || board[row][i] == num
                || board[cubeRow + i / 3][cubeCol + i % 3] == num) {
                return false;
            }
        }
        return true;
    }
}

image.png

解题思路二

这个解法也有人称之为李显龙算法,是的!你没有看错,就是那个李显龙,新加坡现任总理,前总理李光耀的儿子。他当年在剑桥大学数学系求学时,用C语言写出了这个算法。这位李先生可能是总理里面最会写程序的,程序员里面最会当总理的;P。

在解读这个算法之前,我们先来好好复习一下位运算吧。下面这些操作符还记得吗?

  • &按位与, e.g. 010 & 110 = 010
  • |按位或, e.g. 010 | 110 = 110
  • ~按位取反, e.g. ~010 = 101
  • &=按位与分配, e.g. a = 11110, b = 1000, 执行a &= ~b后, a = 10110
  • |=按位或分配, e.g. a = 10110, b = 1000, 执行a |= b后, a = 11110
  • <<移位, e.g. 1 << 4 = 10000
  • -负转换, Java中表示为补码,即反码+1, e.g. -10110 = 01001 + 1 = 01010

回到这个算法,它究竟好在哪里呢?其实它的总体思想跟解法一差不多,都是穷举,只不过用位运算对其中的一些操作进行了优化。具体的优化如下:

九宫格中数字的占用情况

解法一中利用原始的board[][]来记录数字的占用情况。

解法二用Bitmap的方式,表示一行,一列或者一宫中已经被占用的数字。比如int[9] rowBitmap, colBitmap, cubeBitmap;,Bitmap中包含10个bit,最低位(最右边的bit)永远是0,而剩下的bit从低位到高位分别表示数字1到9是否可用1表示对应的数字可用,而0表示已经被占用。比如0b1000000010表示对应的行、列或者宫中,19没有被占用。

另外,Bitmap还被用来表示一个空格中的备选数字,比如int cellBitmap的值为0b1000000010时,表示19没有在该空格所在的行、列或者宫中出现。

寻找空格中的候选数字

解法一中寻找一个空格的候选数字时,使用了一层for循环,寻找对应行、列,宫中未使用的数字。

解法二就简单了,cellBitmap = rowBitmap[row] & colBitmap[col] & cubeBitmap[cube];一次位运算拿走不谢。这就是李显龙算法的精妙之处!

处理优先级

解法一中并没有设置优先顺序,而是按照空格出现的自然顺序进行处理。

解法二由简入繁,从候选数字最少的开始处理,减少了尝试失败时,可能需要返回的级数。为了构建优先级,要将二维的九宫格int[9][9] board转换为一维数组int[81] entry

同时,用int[81] sequence表示处理的优先顺序,它的值对应了entry中的一维坐标。board中已知的数字放在最前面,然后处理空格,从cellBitmap1最少的空格开始处理。我们可以使用Java提供的函数Integer.bitCount (cellBitmap)来方便地获取1的数量。

坐标转换

为了避免重复进行一维坐标到二维坐标的转换,使用int[81] squareToRow, squareToCol, squareToCube;保存坐标映射,避免重复计算。

其他位运算的使用

board与entry之间的转换

为了方便位运算,entry中是用Bitmap方式表示数字,即board[i][j]==3表示为entry[i*9+j]==0b1000。将3转换成0b1000只需要用位移操作,1 << board[i][j] ,而将0b1000转换成3,可以是用Java提供的函数Integer.numberOfTrailingZeros(entry[i*9+j])

修改或还原Bitmap

在尝试过程中,需要修改行、列、宫对应的Bitmap,如果尝试失败,还需要还原成原来的Bitmap。修改Bitmap时使用按位与分配rowBitmap[rowIdx] &= ~nextDigitBit,还原时使用按位或分配rowBitmap[rowIdx] |= nextDigitBit

例如: rowBitmap[rowIdx] = 1111111110, nextDigitBit = 1000, 执行rowBitmap[rowIdx] &= ~nextDigitBit修改之后,rowBitmap[rowIdx] == 1111110110;然后再执行rowBitmap[rowIdx] |= nextDigitBit还原之后,rowBitmap[rowIdx] == 1111111110

获取候选数字

当我们知道了一个空格中所有的备选数字cellBitmap,如何逐个取出来使用呢?这里需要使用负转换和按位与。执行nextDigitBit = cellBitmap & -cellBitmap可以得到最低位的1和它后面的0。比如cellBitmap = 1001000000,执行上述运算后nextDigitBit == 1000000,是不是很神奇!然后修改cellBitmap &= ~nextDigitBit,将对应的Bit置零,循环尝试所有的候选数字,直到cellBitmap == 0

思路分析就到这里,该解法最终打败了99.69%的竞争方案,李显龙总理请收下我的膝盖Orz。

参考答案二


public class Solution {

    static final int ALL_ZEROS = 0;
    // 0x3fe = 1111111110
    static final int ALL_ONES = 0x3fe;
    // bitmaps for row/col/cube, 1 means available
    int[] rowBitmap, colBitmap, cubeBitmap;
    // 1D array to store board nums' pos in bitmap, e.g. board[i][j] == 3, then entry[i*9+j] == 1000
    int[] entry;
    // 1D array to store the priority of SQUARE index, 0 <= sequence[i] < 81, 0 <= i < 81
    int[] sequence;
    // always points to the first empty cell's SQUARE index, which is stored in SEQUENCE
    int seqStart;
    // 1D utility arrays to store mapping from SQUARE to ROW/COLs/CUBES
    int[] squareToRow, squareToCol, squareToCube;

    public void solveSudoku(char[][] board) {
        seqStart = 0;
        rowBitmap = new int[9];
        colBitmap = new int[9];
        cubeBitmap = new int[9];
        entry =  new int[81];
        sequence =  new int[81];
        squareToRow =  new int[81];
        squareToCol =  new int[81];
        squareToCube = new int[81];
        // initialize all helping data structures
        // all digits are initially all available (marked by 1) in all rows/columns/cubes
        for (int i = 0; i < 9; i++) {
            rowBitmap[i] = colBitmap[i] = cubeBitmap[i] = ALL_ONES;
        }
        // sequence stores all SQUARE indices of all cells, with 0..start-1 being all filled cells
        // and the rest all empty, initially start = 0
        for (int i = 0; i < 81; i++) {
            sequence[i] = i;
        }
        for (int i = 0; i < 9; i++) {
            for (int j = 0; j < 9; j++) {
                // mapping from SQUARE to I/J is also beneficial: avoid calculating I/J from SQUARE later
                int square = i * 9 + j;
                squareToRow[square] = i;
                squareToCol[square] = j;
                squareToCube[square] = (i / 3) * 3 + j / 3;
            }
        }
        // fill in the given cells. update the bitmaps at the same time
        for (int i = 0; i < 9; i++) {
            for (int j = 0; j < 9; j++) {
                if (board[i][j] != '.') {
                    int square = i * 9 + j, val = board[i][j] - '0';
                    // e.g. val = 3, valbit = '1000'
                    int valbit = 1 << val;
                    // e.g. bitmap = 1111111110, valbit = 1000, bitmap &= ~valbit makes 1111110110
                    rowBitmap[i] &= ~valbit;
                    colBitmap[j] &= ~valbit;
                    cubeBitmap[(i / 3) * 3 + j / 3] &= ~valbit;
                    entry[square] = valbit;
                    int seqIter = seqStart;
                    // compact non-empty cells to the front
                    // and use seqStart to mark the first empty cell's position
                    while (seqIter < 81 && sequence[seqIter] != square) {
                        seqIter++;
                    }
                    swapSeq(seqStart++, seqIter);
                }
            }
        }
        // main solver process
        boolean success = place (seqStart);
        assert success : "Unsolvable Puzzle!";
        // dump result back from ENTRY array to BOARD
        for (int s = 0; s < 81; s++) {
            int i = squareToRow[s], j = squareToCol[s];
            board[i][j] = (char) (Integer.numberOfTrailingZeros (entry[s]) + '0');
        }
    }

    boolean place (int seqPos) {
        // if all cells have been solved, then return true
        if (seqPos >= 81) {
            return true;
        }
        // find the most determinable cell and swap to the front of SEQUENCE
        int seqNext = nextPos (seqPos);
        swapSeq (seqPos, seqNext);
        int square = sequence[seqPos];
        int rowIdx = squareToRow[square];
        int colIdx = squareToCol[square];
        int cubeIdx = squareToCube[square];
        int cellBitmap = rowBitmap[rowIdx] & colBitmap[colIdx] & cubeBitmap[cubeIdx];
        while (cellBitmap > 0) {
            // get each available bit/digit in order
            int nextDigitBit = cellBitmap & -cellBitmap;
            cellBitmap &= ~nextDigitBit;
            entry[square] = nextDigitBit;
            // claim this DIGIT is used in row/column/cube
            rowBitmap[rowIdx] &= ~nextDigitBit;
            colBitmap[colIdx] &= ~nextDigitBit;
            cubeBitmap[cubeIdx] &= ~nextDigitBit;

            if (place (seqPos + 1)) {
                return true;
            }

            // undo claims in the bitmaps
            rowBitmap[rowIdx] |= nextDigitBit;
            colBitmap[colIdx] |= nextDigitBit;
            cubeBitmap[cubeIdx] |= nextDigitBit;
            entry[square] = ALL_ZEROS;
        }
        swapSeq (seqPos, seqNext);
        return false;
    }

    // find among empty cells the most determinable one: least bits on its bitmap;
    int nextPos (int pos) {
        int minIdx = pos, minDigitCount = 100;
        for (int i = pos; i < 81; i++) {
            int square = sequence[i];
            // number of bits in cellBitmap is the number of digits that this cell can take
            int cellBitmap = rowBitmap[squareToRow[square]]
                             & colBitmap[squareToCol[square]]
                             & cubeBitmap[squareToCube[square]];
            // counts the '1's, so you know how many digits this CELL can take: we want the minimum one
            int numPossibleDigits = Integer.bitCount (cellBitmap);
            if (numPossibleDigits < minDigitCount) {
                minIdx = i;
                minDigitCount = numPossibleDigits;
            }
        }
        return minIdx;
    }

    void swapSeq (int i, int j) {
        int tmp = sequence[i];
        sequence[i] = sequence[j];
        sequence[j] = tmp;
    }
}

image.png

拓展训练

来放松一下,做一个简单点的有效数独问题吧!

leetcode.com/problems/va…

或者到作者的LeetCode专栏中看看,有没有其他感兴趣的问题吧!

juejin.cn/column/6997…

资料链接

原题 leetcode.com/problems/su…

原著李显龙脸书上的解法 www.facebook.com/leehsienloo…