1. 程式人生 > >數獨題的生成與解決方法

數獨題的生成與解決方法

前言

最近在學習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