用 JS 做一個數獨游戲(一)
數獨的棋盤由 9x9 的方格組成,每一行的數字包含 1 ~ 9 九個數字,並且每一列包含 1 ~ 9 這 9 個不重復的數字,另外,整個棋盤分為 9 個 3x3 的塊,每個塊中包含的數字也是 1 ~ 9。數獨棋盤是非常對稱的,所以行和列實際上通過旋轉一定的角度就可以相互轉換。
數獨終盤
生成步驟
生成數獨終盤有多種方法,其中一種是挖洞法:先生成一個隨機的數獨終盤,然后隨機隱藏某幾個位置的數字,讓用戶進行填空。這里我們用到的方法就是挖洞法,以行為單位進行數字的填充。
初始的挖洞法步驟:
- 生成的一個空的數獨棋盤,棋盤中每個格子的數字都是 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~9 的亂序排列並填充第一行(填充第一列的效果和填充第一行再進行旋轉其實是一樣的效果,由於填充第一行元素時,其它各行都沒有填充過數字,因此第一行的約束條件是最少的);
// populate the first row
let row0 = this.getRowGrids(0);
let randomArray = Utils.getRandomValue();
randomArray.forEach( (element, index ) => {
row0[index].setValue( element );
});
填充完第一行的數獨棋盤:
- 填充第 2 行時,計算第 1 個格子可以使用的數字集合;
這個集合是從一個數組 [1, 2, 3, 4, 5, 6, 7, 8, 9] (把它稱為
基本數組
)中剔除掉已經被用過的數字。這些用過的數字是從這個格子所在的行、列及塊中有效的數字。例如上圖中,第 2 行,第 1 列(在數組中序號分別是 1,0)已經用過的數字是 2, 4, 7,那么第 2 行第 1 列可以使用的數字就只能在剩下未使用過的數字中隨機選擇一個;
-
從 (2, 1) 格子處選擇可用數字集合中的一個,填充至該位置;
-
重復 3~4 步,填充完第 2 行;
-
重復第 3~5 步,依次填充完其它行;
-
完成數獨終盤。
錯誤的示例
然而上面的算法步驟有一個致命的缺陷:在某些位置(第一個出現的位置是第二行最后一列)可能出現 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];
}
}
修正的算法步驟:
-
生成的一個空的數獨棋盤,棋盤中每個格子的數字都是 0;
-
生成 1~9 的亂序排列並填充第一行;
-
填充第 2 行時,計算第 1 個格子,其位置為 (2,1),可以使用的數字集合;
-
從 (2, 1) 格子處選擇可用數字集合中的一個,填充至該位置,將用過的數字標記為不可用;
-
若某個位置 (i, j) 的格子已無可用的數字集合,若該格子位於第 1 列,則返回上一行,從第一列進行回溯,否則只需返回到上一列進行回溯。
-
重復 3~5 步,填充完第 2 行;
-
重復第 3~6 步,依次填充完其它行;
-
完成數獨終盤。
修正后的算法實現:
整體的填充步驟沒有變化,在填充時首先判斷 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>