用 JS 做一個數獨游戲(一)


用 JS 做一個數獨游戲(一)

數獨的棋盤由 9x9 的方格組成,每一行的數字包含 1 ~ 9 九個數字,並且每一列包含 1 ~ 9 這 9 個不重復的數字,另外,整個棋盤分為 9 個 3x3 的塊,每個塊中包含的數字也是 1 ~ 9。數獨棋盤是非常對稱的,所以行和列實際上通過旋轉一定的角度就可以相互轉換。

數獨終盤

生成步驟

生成數獨終盤有多種方法,其中一種是挖洞法:先生成一個隨機的數獨終盤,然后隨機隱藏某幾個位置的數字,讓用戶進行填空。這里我們用到的方法就是挖洞法,以行為單位進行數字的填充。

初始的挖洞法步驟:


  1. 生成的一個空的數獨棋盤,棋盤中每個格子的數字都是 0;

數獨棋盤用 Board 對象表示,該對象中有 81 個 Grid 對象,表示 9x9 的方格。

// class Board
for( let i = 0; i < 9; i++ ) {
    for( let j = 0; j < 9; j++ ) {
        this.grids[i * 9 + j] = new Grid( i, j );
    }
}
  1. 生成 1~9 的亂序排列並填充第一行(填充第一列的效果和填充第一行再進行旋轉其實是一樣的效果,由於填充第一行元素時,其它各行都沒有填充過數字,因此第一行的約束條件是最少的);
// populate the first row
let row0 = this.getRowGrids(0);
let randomArray = Utils.getRandomValue();
randomArray.forEach( (element, index ) => {
    row0[index].setValue( element );
});

填充完第一行的數獨棋盤:

  1. 填充第 2 行時,計算第 1 個格子可以使用的數字集合;

這個集合是從一個數組 [1, 2, 3, 4, 5, 6, 7, 8, 9] (把它稱為 基本數組)中剔除掉已經被用過的數字。這些用過的數字是從這個格子所在的行、列及塊中有效的數字。例如上圖中,第 2 行,第 1 列(在數組中序號分別是 1,0)已經用過的數字是 2, 4, 7,那么第 2 行第 1 列可以使用的數字就只能在剩下未使用過的數字中隨機選擇一個;

  1. 從 (2, 1) 格子處選擇可用數字集合中的一個,填充至該位置;

  2. 重復 3~4 步,填充完第 2 行;

  3. 重復第 3~5 步,依次填充完其它行;

  4. 完成數獨終盤。


錯誤的示例

然而上面的算法步驟有一個致命的缺陷:在某些位置(第一個出現的位置是第二行最后一列)可能出現 1~9 所有數字都被該格子所在的行、列和塊使用過了,那么這個格子就沒有可用的數字。如下圖:

可以看到,第 2 行第 9 列沒有可用的數字,無法進行填充。其它行也有可能出現這種情況。在這個數獨棋盤中,所有為 0 的格子都是無法滿足約束條件的。

實際上,出現 0 的位置是因為下面的函數在調用 getRandomValueArray 時返回的數組位空,或者說 used 數組已經是包含 1~9 這 9 個數字。

getRandomValidValue(pos) {
    let used = this.getUsedValueArrayAt(pos);
    let valueArray = this.getRandomValueArray(used);
    return valueArray[0] === undefined ? 0 : valueArray[0];
}

在 github 上存有 錯誤示例的代碼

修正挖洞法

直接采用挖洞法生成的數獨終盤不一定是有效的,如果把填充過程當作一棵樹,填充的數字為樹的節點,那么有些樹枝的路徑是無法滿足數獨的要求的。當填充過程中發現某個位置沒有可選的數字時,應該返回上一個填充的節點,在上一個節點處選擇其他的數字進行填充。也就是回溯方法。

回溯方法需要記錄每個格子可用的數字集合,以及填充時用到的數字,以便在算法失敗時(某個位置沒有可用數字集合)進行回溯。回溯時已經嘗試過的數字將被標記為不可用。有兩點需要注意:

  • 對每一行的格子而言,從左往右的順序實際上構成了一個棧,因此不需要額外構建一個堆棧來存儲填充時的情況。

  • 另外需要注意的一點是,若在 (i, 1) 位置的格子沒有可用的數字時,需要回溯到上一行的第 1 列重新選擇。

為了記錄每次分支時選擇的數字,引入一個用於記錄當前選擇的對象 Choice

class Choice {
    /**
     * 
     * @param {Array<Number>} choiceSet 
     */
    constructor(choiceSet) {
        this.choiceSet = choiceSet;
        this.attemptIndex = -1;
    }

    /**
     * 將索引移至下一個位置,並返回該位置的數字
     */
    next() {
        this.attemptIndex++;
        return this.choiceSet[this.attemptIndex];
    }
}

修正的算法步驟:


  1. 生成的一個空的數獨棋盤,棋盤中每個格子的數字都是 0;

  2. 生成 1~9 的亂序排列並填充第一行;

  3. 填充第 2 行時,計算第 1 個格子,其位置為 (2,1),可以使用的數字集合;

  4. 從 (2, 1) 格子處選擇可用數字集合中的一個,填充至該位置,將用過的數字標記為不可用;

  5. 若某個位置 (i, j) 的格子已無可用的數字集合,若該格子位於第 1 列,則返回上一行,從第一列進行回溯,否則只需返回到上一列進行回溯。

  6. 重復 3~5 步,填充完第 2 行;

  7. 重復第 3~6 步,依次填充完其它行;

  8. 完成數獨終盤。


修正后的算法實現:

整體的填充步驟沒有變化,在填充時首先判斷 grid 對象的 choice 屬性,若為 undefined,則需要計算 grid 所在的位置處可能的選擇:

for(let i = 1; i < 9; i++) {
    for(let j = 0; j < 9; j++) {
        grid = this.grids[i*9+j];
        // process.stdout.write( `i=${i}, j=${j}.  `);
        if( grid.choice === undefined ) {
            used   = this.getUsedValueArrayAt( {row: i, col: j} );
            unused = Utils.getRandomValue( {exclude: used} );
            grid.choice = new Choice( unused );
        }
        index = this.populateGrid(grid);
        i = index.i; 
        j = index.j;
    }
}

填充方法 populateGrid 完成了回溯部分:

/**
 * 填充方格,並返回修正過的 i,j索引
 * 
 * 該函數屬於回溯部分,在方格沒有可選數字時,修正索引,以便進行回溯
 * @param {Grid} grid 
 * @returns {{i: Number, j: Number}}
 */
populateGrid(grid) {
    let i = grid.row, j = grid.col;
    let value = grid.choice.next();

    if( value !== undefined ) {
        // 有可以選擇的數字
        grid.setValue(value);
    }
    else {
        // 沒有可選的數字
        // Utils.printRow(this, i);
        grid.value  = 0;
        grid.choice = undefined;
        if( j == 0 ) {
            // i = (i > 0 ? i - 1 : i );
            i -= 1;
            j -= 1;
            this.resetPartialGrids({rowStart: i, rowEnd: i+1, colStart: 1, colEnd: 9});  // 返回上一行后,清空該行其它列的數據
        }
        else {
            j -= 2;
        }
    }
    return {i, j};
}

需要回溯時,populateGrid 會返回修改后的索引,以便重入特定位置,進行回溯。

完成數獨終盤

通過修正后的挖洞法,最終可以生成一個有效的數獨終盤。生成 1000 種數獨終盤所用的時間如下:

其他

在使用 JavaScript 編寫數獨時,使用了 QUnit 進行代碼測試,在編寫測試代碼時不僅提高了代碼的准確性,也幫助我在測試時完善邏輯代碼,用更好的函數封裝組織邏輯。

可以直接在 Node 中使用 QUnit,也可以通過瀏覽器執行測試。用瀏覽器執行時,視覺效果更好:

引入的 html 文件也比較簡單,主要內容如下:

<body>
  <div id="qunit"></div>
  <div id="qunit-fixture"></div>
  <script src="https://code.jquery.com/qunit/qunit-2.6.1.js"></script>
  <script src="./NumberPlaceGame.js"></script>
  <script src="./TestNumberPlaceGame.js"></script>
</body>

代碼

能夠正確生成數獨終盤的 代碼
測試代碼

參考資料

https://www.cnblogs.com/wangqinze/p/7501716.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM