數獨題的生成與解決方法

前言

最近在學習Java,在樑勇的 Introduction to Java Programming 10ed 中看到了一個數獨問題的例子,這個例子其實是引導學習二維數組的例子,書本中給出的例子也比較簡單,就是判斷一個數獨答案是不是正確的。
其實進行到這,學習知識的目的已經達到了,但是隻能輸入一個數獨答案判斷一下是否正確,這實在是太太太太太傻了,不知道有多傻。我始終按耐不住心中那股探索欲,我要做一個生成數獨題的程序,同時它還能自己解決。於是這就開啓了潘多拉的魔盒。

背景

數獨是一種源自18世紀末的瑞士,後在美國發展,並在日本得以發揚光大的數學智力拼圖遊戲,其遊戲規則爲:在由9個小九宮格組成的大九宮格里,已經填有若干數字,需用數字1~9填滿剩下的空格,使得

  1. 每行9個格子填入9個不同的數字
  2. 每列9個格子填入9個不同的數字
  3. 每宮9個格子填入9個不同的數字
問題                  答案
0 0 0 0 0 0 0 0 0    1 2 3 4 6 5 7 8 9
0 0 0 0 0 0 1 6 2    4 5 7 3 8 9 1 6 2
0 0 0 0 2 7 0 0 3    8 6 9 1 2 7 4 5 3
0 0 4 0 0 1 0 0 0    3 7 4 5 9 1 6 2 8
0 0 0 0 0 0 3 9 0    5 8 1 6 7 2 3 9 4
0 0 6 0 3 4 0 0 0    2 9 6 8 3 4 5 1 7
0 4 0 0 0 0 0 0 1    6 4 8 2 5 3 9 7 1
0 0 5 0 4 8 2 0 6    7 1 5 9 4 8 2 3 6
0 3 0 7 1 6 8 0 0    9 3 2 7 1 6 8 4 5

難度等級的度量

對於我這個數獨遊戲的門外漢,我只能通過感性認識來度量一道數獨題的難度。
一個人類解決一道數獨題是在已有的信息之上來解決的,已有的信息包括剩餘數字的數量以及數字的分佈。一個數獨題中,剩餘數字的數量以及數字分佈的均勻、對稱性是決定問題難度的關鍵。因此可以通過兩個衡量因素:數字個數、數字分佈,來衡量一個數獨問題的難度。難度可以這樣劃分:

  1. 已知格總數
  2. 行中已知格數
  3. 列中已知格數

那麼問題來了,一道題最少可以留下幾個格子,人們纔有可能解決呢?這個問題目前仍無定案,不過聽數學家說是17個,不過那將是骨灰級難度了。一般來說,數獨題是在22~30個左右。因此我就把數獨題設置成這個樣子。
其次,就是數字的分佈,從出題者的角度看,數字的分佈也就是在一個數獨答案之上選擇按照什麼順序挖洞(把某個數字挖掉),爲了使得剩餘數字分佈均勻一些,可以隨機挖洞,或者隔開一個挖一個。爲了把難度加大,就讓剩餘數字分佈不均勻一些,比方說按照從左到右從上到下的順序挖洞。嘻嘻,我就是這樣乾的。
其實還可以通過寫程序解決問題,並且統計解決時間來衡量一個問題的難度。不過那就是研究數獨的人乾的事兒了,我們是Coder,只需要在腦子裏有一個難度的印象就行了。

算法分析

我們的目標是讓程序生成一道題,並且自己解決這道題。
求解算法
這裏我採用的是深度優先搜索的方式解決一道題,算法從上到下,從左到右依次嘗試填入每個數字,最終尋找出正確解決,十分暴力。

    /*
     * DFS解數獨問題
     */
    public static boolean dfs(int[][] f, boolean[][] r, boolean[][] c, boolean[][] b) {
        for(int i = 0; i < 9; i++)
            for(int j = 0; j < 9; j++)
                if(f[i][j] == 0) {
                    int k = i / 3 * 3 + j / 3;
                    // 嘗試填入1~9
                    for(int n = 1; n < 10; n++) {
                        if(!r[i][n] && !c[j][n] && !b[k][n]) {
                            // 嘗試填入一個數
                            r[i][n] = true;
                            c[j][n] = true;
                            b[k][n] = true;
                            f[i][j] = n;
                            // 檢查是否滿足數獨正解
                            if(dfs(f, r, c, b))
                                return true;
                            // 不滿足則回溯
                            r[i][n] = false;
                            c[j][n] = false;
                            b[k][n] = false;
                            f[i][j] = 0;
                        }
                    }
                    // 嘗試所有數字都不滿足則回溯
                    return false;
                }
        return true;
    }

函數的(f,r,c,b)二維數組分別表示

  • f : 九宮格的數字,f[i][j]的範圍是1~9
  • r : r[0][1] = true 表示第0行裏已經有1填入了
  • c : c[0][1] = true 表示第0列裏已經有1填入了
  • b : b[0][1] = true 表示第0宮裏已經有1填入了

利用這4個全局的二維數組可以比較快速的判斷當前解決方案的狀態是否滿足數組的限制條件,其實也可以專門寫函數來判斷,不過我這算是用空間換時間了。
生成算法
我的生成算法首先使用拉斯維加斯隨機算法來生成一個數獨答案,是數獨答案。然後按照從上到下從左到右的順序依次挖洞,不過這個挖洞可沒那麼簡單,這一挖還得使得生成的數獨題只有唯一解,因此就得多做一步判斷唯一解的工作。

    /*
     * 拉斯維加斯隨機算法生成一個隨機數獨問題
     */
    public static boolean lasVegas(int n) {
        int i, j, k, value;
        Random random = new Random();
        
        // 初始化
        for(i = 0; i < 9; i++) {
            for(j = 0; j < 9; j++) {
                field[i][j] = 0;
                rows[i][j+1] = false;
                cols[i][j+1] = false;
                blocks[i][j+1] = false;
            }
        }
        
        // 隨機填入數字
        while(n > 0) {
            i = random.nextInt(9);
            j = random.nextInt(9);
            if(field[i][j] == 0) {
                k = i / 3 * 3 + j / 3;
                value = random.nextInt(9) + 1;
                if(!rows[i][value] && !cols[j][value] && !blocks[k][value]) {
                    field[i][j] = value;
                    rows[i][value] = true;
                    cols[j][value] = true;
                    blocks[k][value] = true;
                    n--;
                }
            }
        }
        
        // 檢查並且生成一個數組解
        if(dfs(field, rows, cols, blocks))
            return true;
        else
            return false;
    }

拉斯維加斯算法中的n表示隨機填入幾個位置,你可以自己取值,不過我取的是11,因爲取11的時候粗略測量已經有99%的概率生成一個正解了,可以參照:

    public static void main(String[] args) {
        // 拉斯維加斯算法生成數獨
        while(!lasVegas(11));
        
        // 輸入剩餘數字數
        Scanner input = new Scanner(System.in);
        System.out.print("Enter the level(22 - 30): ");
        int level = input.nextInt();
        
        while(level < 22 || level > 30) {
            System.out.print("Enter the level(22 - 30): ");
            level = input.nextInt();
        }
        
        // 生成數獨題
        generateByDigMethod(level);
        printer();

        // 提示答案
        System.out.print("Wanan answer ? (input 1): ");
        int hint = input.nextInt();
        if(hint == 1) {
            dfs(field, rows, cols, blocks);
            printer();
        }
    }

那麼如果判斷唯一解呢?其實用的是反證法的思想,挖掉一個洞,比如是第三行第三個,原來的數字是9,這下我們把它換成1~8,然後讓上面的程序解一下。如果它還能解出答案,那麼這個問題就有至少兩個解了,這就不對了。於是乎我們跳過它,去挖第三行第四個,然後繼續判斷。最終我們就生成唯一解的題目了!

    /*
     * 挖洞法生成一個數獨問題
     * level: 剩餘數字
     */
    public static void generateByDigMethod(int level) {
        // 從上到下從左到右的順序挖洞
        for(int i = 0; i < 9; i++)
            for(int j = 0; j < 9; j++)
                if(checkUnique(i, j)) {
                    int k = i / 3 * 3 + j / 3;
                    rows[i][field[i][j]] = false;
                    cols[j][field[i][j]] = false;
                    blocks[k][field[i][j]] = false;
                    field[i][j] = 0;
                    level++;
                    if(81 == level)
                        break;
                }
    }
    /*
     * 判斷唯一解
     * 挖掉[r, c]位置的數字判斷是否得到唯一解
     */
    public static boolean checkUnique(int r, int c) {
        // 挖掉第一個位置一定有唯一解
        if(r == 0 && c == 0)
            return true;
        
        int k = r / 3 * 3 + c / 3;
        boolean[][] trows = new boolean[9][10];
        boolean[][] tcols = new boolean[9][10];
        boolean[][] tblocks = new boolean[9][10];
        int[][] tfield = new int[9][9];
        
        // 臨時數組
        for(int i = 0; i < 9; i++) {
            for(int j = 0; j < 9; j++) {
                trows[i][j+1] = rows[i][j+1];
                tcols[i][j+1] = cols[i][j+1];
                tblocks[i][j+1] = blocks[i][j+1];
                tfield[i][j] = field[i][j];
            }
        }
        
        // 假設挖掉這個數字
        trows[r][field[r][c]] = false;
        tcols[c][field[r][c]] = false;
        tblocks[k][field[r][c]] = false;
        
        for(int i = 1; i < 10; i++)
            if(i != field[r][c]) {
                tfield[r][c] = i;
                if(!trows[r][i] && !tcols[c][i] && !tblocks[k][i]) {
                    trows[r][i] = true;
                    tcols[c][i] = true;
                    tblocks[k][i] = true;
                    // 更換一個數字之後檢查是否還有另一解
                    if(dfs(tfield, trows, tcols, tblocks))
                        return false;
                    trows[r][i] = false;
                    tcols[c][i] = false;
                    tblocks[k][i] = false;
                }
            }
        // 已嘗試所有其他數字發現無解即只有唯一解
        return true;
    }

判斷結果正確與否
最後送上一段判斷正解的算法,很簡單的算法

/**
 * 數獨答案檢查
 * @author trav
 */
public class CheckSudokuSolution {

    public static void main(String[] args) {
        int[][] grid = readSolution();
        
        System.out.println(isValid(grid) ? "Valid solution" : "Invalid solution");
    }
    
    public static int[][] readSolution() {
        Scanner input = new Scanner(System.in);
        
        System.out.println("Enter a Sudoku puzzle solution:");
        int[][] grid = new int[9][9];
        for(int i = 0; i < 9; i++)
            for(int j = 0; j < 9; j++)
                grid[i][j] = input.nextInt();
        return grid;
    }
    
    public static boolean isValid(int[][] grid) {
        for(int i = 0; i < 9; i++)
            for(int j = 0; j < 9; j++)
                if(grid[i][j] < 1 || grid[i][j] > 9 || !isValid(i, j, grid))
                    return false;
        return true;
    }
    
    public static boolean isValid(int i, int j, int[][] grid) {
        // 檢查列唯一性
        for(int column = 0; column < 9; column++)
            if(column != j && grid[i][column] == grid[i][j])
                return false;
        
        // 檢查行唯一性
        for(int row = 0; row < 9; row++)
            if(row != i && grid[row][j] == grid[i][j])
                return false;
        
        // 檢查格唯一性
        for(int row = (i / 3) * 3; row < (i / 3) * 3 + 3; row++)
            for(int col = (j / 3) * 3; col < (j / 3) * 3 + 3; col++)
                if(row != i && col != j && grid[row][col] == grid[i][j])
                    return false;
        
        return true;
    }

}

算法複雜度分析

聰明的讀者應該已經發現了,生成算法十分依賴求解算法,因此分析時間複雜度的關鍵在於調用了多少次求解算法,因爲DFS的時間複雜度大家都知道是O(V+E)。
在生成算法中,包括生成一個最終解以及挖洞。生成一個最終解由於採用的是隨機算法,因此分析起來比較複雜,不過將n取11的時候已經有99%概率生成正解了,也就是99%的概率只需要嘗試一次,因此不妨就設爲O(V+E)。
而挖洞的過程中,需要嘗試81次,也就是 81 * O(V+E),然而V也就是81,因此時間複雜度是O(V^2),還是挺大的,有待改進。

總結

程序中還有許多可以改進的地方,比如設置難度級別、生成的題目可以進行對稱輪換、挖洞的順序可以按難度分爲多種等等。算法時間複雜度還是挺高的,不過還好數獨只有81個格子,在我的機子上還是跑得飛快的。
聽說多做做數獨題可以防止老年癡呆,這下舒服了。

Reference

[1]薛源海,蔣彪彬,李永卓,閆桂峯,孫華飛.基於“挖洞”思想的數獨遊戲生成算法[J].數學的實踐與認識,2009,39(21):1-7.
[2]Sudoku Wikipedia, 2018. https://en.wikipedia.org/wiki/Sudoku

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章