回溯算法最佳實踐:解數獨

讀完本文,你能夠去力扣拿下以下題目:java

37.解數獨git

-----------算法

常常拿回溯算法來講事兒的,無非就是八皇后問題和數獨問題了。那咱們今天就經過實際且有趣的例子來說一下如何用回溯算法來解決數獨問題。框架

1、直觀感覺

說實話我小的時候也嘗試過玩數獨遊戲,但歷來都沒有完成過一次。作數獨是有技巧的,我記得一些比較專業的數獨遊戲軟件,他們會教你玩數獨的技巧,不過在我看來這些技巧都太複雜,我根本就沒有興趣看下去。函數

不過自從我學習了算法,多困難的數獨問題都攔不住我了。下面是我用程序完成數獨的一個例子:工具

PS:GIF 可能出現 bug,若卡住​點開查看便可,​下同。​學習

這是一個安卓手機中的數獨遊戲,我使用一個叫作 Auto.js 的腳本引擎,配合回溯算法來實現自動完成填寫,而且算法記錄了執行次數。spa

能夠觀察到前兩次都執行了 1 萬屢次,而最後一次只執行了 100 屢次就算出了答案,這說明對於不一樣的局面,回溯算法獲得答案的時間是不相同的。code

那麼計算機如何解決數獨問題呢?其實很是的簡單,就是窮舉嘛,下面我可視化了求解過程:遞歸

算法的核心思路很是很是的簡單,就是對每個空着的格子窮舉 1 到 9,若是遇到不合法的數字(在同一行或同一列或同一個 3×3 的區域中存在相同的數字)則跳過,若是找到一個合法的數字,則繼續窮舉下一個空格子

對於數獨遊戲,也許咱們還會有另外一個誤區:就是下意識地認爲若是給定的數字越少那麼這個局面的難度就越大。

這個結論對人來講應該沒毛病,但對於計算機而言,給的數字越少,反而窮舉的步數就越少,獲得答案的速度越快,至於爲何,咱們後面探討代碼實現的時候會講。

上一個 GIF 是最後一關 70 關,下圖是第 52 關,數字比較多,看起來彷佛不難,可是咱們看一下算法執行的過程:

能夠看到算法在前兩行窮舉了半天都沒有走出去,因爲時間緣由我就沒有繼續錄製了,事實上,這個局面窮舉的次數大概是上一個局面的 10 倍。

言歸正傳,下面咱們就來具體探討一下如何用算法來求解數獨問題,順便說說我是如何可視化這個求解過程的

2、代碼實現

首先,咱們不用管遊戲的 UI,先單純地解決回溯算法,LeetCode 第 37 題就是解數獨的問題,算法函數簽名以下:

void solveSudoku(char[][] board);

輸入是一個9x9的棋盤,空白格子用點號字符 . 表示,算法須要在原地修改棋盤,將空白格子填上數字,獲得一個可行解。

至於數獨的要求,你們想必都很熟悉了,每行,每列以及每個 3×3 的小方格都不能有相同的數字出現。那麼,如今咱們直接套回溯框架便可求解。

前文回溯算法詳解,已經寫過了回溯算法的套路框架,若是還沒看過那篇文章的,建議先看看

回憶剛纔的 GIF 圖片,咱們求解數獨的思路很簡單粗暴,就是對每個格子全部可能的數字進行窮舉。對於每一個位置,應該如何窮舉,有幾個選擇呢?很簡單啊,從 1 到 9 就是選擇,所有試一遍不就好了

// 對 board[i][j] 進行窮舉嘗試
void backtrack(char[][] board, int i, int j) {
    int m = 9, n = 9;
    for (char ch = '1'; ch <= '9'; ch++) {
        // 作選擇
        board[i][j] = ch;
        // 繼續窮舉下一個
        backtrack(board, i, j + 1);
        // 撤銷選擇
        board[i][j] = '.';
    }
}

emmm,再繼續細化,並非 1 到 9 均可以取到的,有的數字不是不知足數獨的合法條件嗎?並且如今只是給 j 加一,那若是 j 加到最後一列了,怎麼辦?

很簡單,當 j 到達超過每一行的最後一個索引時,轉爲增長 i 開始窮舉下一行,而且在窮舉以前添加一個判斷,跳過不知足條件的數字

void backtrack(char[][] board, int i, int j) {
    int m = 9, n = 9;
    if (j == n) {
        // 窮舉到最後一列的話就換到下一行從新開始。
        backtrack(board, i + 1, 0);
        return;
    }
    
    // 若是該位置是預設的數字,不用咱們操心
    if (board[i][j] != '.') {
        backtrack(board, i, j + 1);
        return;
    } 

    for (char ch = '1'; ch <= '9'; ch++) {
        // 若是遇到不合法的數字,就跳過
        if (!isValid(board, i, j, ch))
            continue;
        
        board[i][j] = ch;
        backtrack(board, i, j + 1);
        board[i][j] = '.';
    }
}

// 判斷 board[i][j] 是否能夠填入 n
boolean isValid(char[][] board, int r, int c, char n) {
    for (int i = 0; i < 9; i++) {
        // 判斷行是否存在重複
        if (board[r][i] == n) return false;
        // 判斷列是否存在重複
        if (board[i][c] == n) return false;
        // 判斷 3 x 3 方框是否存在重複
        if (board[(r/3)*3 + i/3][(c/3)*3 + i%3] == n)
            return false;
    }
    return true;
}

emmm,如今基本上差很少了,還剩最後一個問題:這個算法沒有 base case,永遠不會中止遞歸。這個好辦,何時結束遞歸?顯然 r == m 的時候就說明窮舉完了最後一行,完成了全部的窮舉,就是 base case

另外,前文也提到過,爲了減小複雜度,咱們可讓 backtrack 函數返回值爲 boolean,若是找到一個可行解就返回 true,這樣就能夠阻止後續的遞歸。只找一個可行解,也是題目的本意。

最終代碼修改以下:

boolean backtrack(char[][] board, int i, int j) {
    int m = 9, n = 9;
    if (j == n) {
        // 窮舉到最後一列的話就換到下一行從新開始。
        return backtrack(board, i + 1, 0);
    }
    if (i == m) {
        // 找到一個可行解,觸發 base case
        return true;
    }

    if (board[i][j] != '.') {
        // 若是有預設數字,不用咱們窮舉
        return backtrack(board, i, j + 1);
    } 

    for (char ch = '1'; ch <= '9'; ch++) {
        // 若是遇到不合法的數字,就跳過
        if (!isValid(board, i, j, ch))
            continue;
        
        board[i][j] = ch;
        // 若是找到一個可行解,當即結束
        if (backtrack(board, i, j + 1)) {
            return true;
        }
        board[i][j] = '.';
    }
    // 窮舉完 1~9,依然沒有找到可行解,此路不通
    return false;
}

boolean isValid(char[][] board, int r, int c, char n) {
    // 見上文
}

如今能夠回答一下以前的問題,爲何有時候算法執行的次數多,有時候少?爲何對於計算機而言,肯定的數字越少,反而算出答案的速度越快

咱們已經實現了一遍算法,掌握了其原理,回溯就是從 1 開始對每一個格子窮舉,最後只要試出一個可行解,就會當即中止後續的遞歸窮舉。因此暴力試出答案的次數和隨機生成的棋盤關係很大,這個是說不許的。

那麼你可能問,既然運行次數說不許,那麼這個算法的時間複雜度是多少呢

對於這種時間複雜度的計算,咱們只能給出一個最壞狀況,也就是 O(9^M),其中 M 是棋盤中空着的格子數量。你想嘛,對每一個空格子窮舉 9 個數,結果就是指數級的。

這個複雜度很是高,但稍做思考就能發現,實際上咱們並無真的對每一個空格都窮舉 9 次,有的數字會跳過,有的數字根本就沒有窮舉,由於當咱們找到一個可行解的時候就當即結束了,後續的遞歸都沒有展開。

這個 O(9^M) 的複雜度其實是徹底窮舉,或者說是找到全部可行解的時間複雜度。

若是給定的數字越少,至關於給出的約束條件越少,對於計算機這種窮舉策略來講,是更容易進行下去,而不容易走回頭路進行回溯的,因此說若是僅僅找出一個可行解,這種狀況下窮舉的速度反而比較快。

至此,回溯算法就完成了,你能夠用以上代碼經過 LeetCode 的判題系統,下面咱們來簡單說下我是如何把這個回溯過程可視化出來的。

3、算法可視化

讓算法幫我玩遊戲的核心是算法,若是你理解了這個算法,剩下就是藉助安卓腳本引擎 Auto.js 調 API 操做手機了,工具我都放在後臺了,你等會兒就能夠下載。

用僞碼簡單說下思路,我能夠寫兩個函數:

void setNum(Button b, char n) {
    // 輸入一個方格,將該方格設置爲數字 n
}

void cancelNum(Button b) {
    // 輸入一個方格,將該方格上的數字撤銷
}

回溯算法的核心框架以下,只要在框架對應的位置加上對應的操做,便可將算法作選擇、撤銷選擇的過程徹底展現出來,也許這就是套路框架的魅力所在:

for (char ch = '1'; ch <= '9'; ch++) {
    Button b = new Button(r, c);
    // 作選擇
    setNum(b, ch);
    board[i][j] = ch;
    // 繼續窮舉下一個
    backtrack(board, i, j + 1)
    // 撤銷選擇
    cancelNum(b);
    board[i][j] = '.';
}

以上思路就能夠模擬出算法窮舉的過程:

相關文章
相關標籤/搜索