T36 解数独
仅含唯一答案的DFS搜索
从内容上讲,这道题是我们先前做过的「有效的数独」的后续。先前题解中的部分代码在这里是可以直接复用的。
从算法上将,这道题和经典的「八皇后问题」类似,都是运用「DFS搜索」进行解题,且不同于我们先前做的几道搜索题,这两道题输入的测试用例只存在唯一的答案。
最简单的版本
我们仍然先不考虑任何优化,先敲一个最简单的版本:
- 在每一次通过循环试数时,都需要分三次遍历整个棋盘,以检测所放数字是否符合题意。
- 由于只存在唯一答案,所以我们需要引入标志遍历
flag,以便于搜索到正确答案后直接结束所有搜索。
代码如下:
let range = 9;
let flag;
function solveSudoku(board) {
flag = false;
dfs(board, 0, 0);
return board;
}
function dfs(board, y, x) {
//已遍历到一行的末尾,则需要换行再搜
if (x >= range) return dfs(board, y + 1, 0);
//已遍历完整个棋盘,则说明一定找到正确答案
if (y >= range) {
flag = true;
return;
}
//该格已带数字
if (board[y][x] !== '.') return dfs(board, y, x + 1);
//向下搜索
for (let i = 1; i <= range; i++) {
board[y][x] = i + '';
if (check(board, y, x)) {
dfs(board, y, x + 1);
//回溯后,如果已找到正确答案则放弃后续搜索
if(flag) return;
}
board[y][x] = '.';
}
}
//直接复用「有效的数独」里的代码!
function check(board, y, x) {
let set1 = new Set();
let set2 = new Set();
let set3 = new Set();
for (let i = 0; i < range; i++) {
let val = board[y][i];
if (val === '.') continue;
if (set1.has(val)) return false;
set1.add(val);
}
for (let j = 0; j < range; j++) {
let val = board[j][x];
if (val === '.') continue;
if (set2.has(val)) return false;
set2.add(val);
}
let areaIdx = Math.floor(x / 3) + Math.floor(y / 3) * 3;
let startY = Math.floor(areaIdx / 3) * 3;
let startX = (areaIdx % 3) * 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
let val = board[i + startY][j + startX];
if (val === '.') continue;
if (set3.has(val)) return false;
set3.add(val);
}
}
return true;
}
提交结果:
第一次优化:哈希表
在「有效的数独」中,我们为了避免反复遍历整个棋盘,采用三组「哈希表」分三个维度记录已出现在棋盘上的数字。在本题中我们仍然可以这么做。
需要注意的是,由于输入的二维数组中已经包含了一些数字,我们在初始化「哈希表」时需要先将它们直接记入进去。
此外我还会借用本题简单分享一个利用「摩根定律」简化代码的小技巧,具体请看注释。
代码如下:
let range = 9;
let flag;
let RowHashs = [];
let ColHashs = [];
let AreaHashs = [];
function solveSudoku(board) {
flag = false;
for (let i = 0; i < range; i++) {
RowHashs[i] = new Set();
ColHashs[i] = new Set();
AreaHashs[i] = new Set();
}
for (let i = 0; i < range; i++) {
for (let j = 0; j < range; j++) {
let val = board[i][j];
if (val !== '.') {
RowHashs[i].add(val);
ColHashs[j].add(val);
AreaHashs[getAreaIdx(i, j)].add(val);
}
}
}
dfs(board, 0, 0);
return board;
}
function dfs(board, y, x) {
//已遍历到一行的末尾
if (x >= range) return dfs(board, y + 1, 0);
//已遍历完整个棋盘
if (y >= range) {
flag = true;
return;
}
//该格已带数字
if (board[y][x] !== '.') return dfs(board, y, x + 1);
//向下搜索
let areaIdx = getAreaIdx(y, x);
for (let i = 1; i <= range; i++) {
let newVal = i + '';
//技巧:「摩根定律」!
// !RowHashs[y].has(newVal) && !ColHashs[x].has(newVal) && !AreaHashs[areaIdx].has(newVal) 等价表达式如下
if (!(RowHashs[y].has(newVal) || ColHashs[x].has(newVal) || AreaHashs[areaIdx].has(newVal))) {
board[y][x] = newVal;
RowHashs[y].add(newVal);
ColHashs[x].add(newVal);
AreaHashs[areaIdx].add(newVal);
dfs(board, y, x + 1);
if(!flag) {
//如搜索失败,尝试下一次前,需要先
//清除哈希表中的相应记录,并恢复当前格子的"."状态
RowHashs[y].delete(newVal);
ColHashs[x].delete(newVal);
AreaHashs[areaIdx].delete(newVal);
board[y][x] = '.';
} else {
return;
}
}
}
}
function getAreaIdx(y, x) {
return Math.floor(x / 3) + Math.floor(y / 3) * 3;
}
提交结果:
第二次优化:消灭多余的flag变量
实际上,我们并不需要引入额外的标志变量flag来标记是否已搜索到正确答案。
在这类仅含唯一答案的搜索题中,我们可以巧妙利用递归函数的返回值来实现"搜到即止"的效果。
代码如下:
let range = 9;
let RowHashs = [];
let ColHashs = [];
let AreaHashs = [];
function solveSudoku(board) {
for (let i = 0; i < range; i++) {
RowHashs[i] = new Set();
ColHashs[i] = new Set();
AreaHashs[i] = new Set();
}
for (let i = 0; i < range; i++) {
for (let j = 0; j < range; j++) {
let val = board[i][j];
if (val !== '.') {
RowHashs[i].add(val);
ColHashs[j].add(val);
AreaHashs[getAreaIdx(i, j)].add(val);
}
}
}
dfs(board, 0, 0);
return board;
}
function dfs(board, y, x) {
//在递归调用dfs函数时需要加上return符号,用于传递返回值。后同!
if (x >= range) return dfs(board, y + 1, 0);
//返回true表示已搜索到正确答案
if (y >= range) return true;
if (board[y][x] !== '.') return dfs(board, y, x + 1);
let areaIdx = getAreaIdx(y, x);
for (let i = 1; i <= range; i++) {
let newVal = i + '';
if (!(RowHashs[y].has(newVal) || ColHashs[x].has(newVal) || AreaHashs[areaIdx].has(newVal))) {
board[y][x] = newVal;
RowHashs[y].add(newVal);
ColHashs[x].add(newVal);
AreaHashs[areaIdx].add(newVal);
//通过if语句接受递归调用dfs函数的返回结果
//如果接收到true,表示已搜索到正确答案,则放弃搜索并直接将true传递下去。
//如果接收到false,表示搜索路径失败,则尝试在当前格子填入下一个i。
if (dfs(board, y, x + 1)) {
return true;
} else {
RowHashs[y].delete(newVal);
ColHashs[x].delete(newVal);
AreaHashs[areaIdx].delete(newVal);
board[y][x] = '.';
}
}
}
//如果当前格子尝试填入1~9都被回溯,说明此路肯定不通
//返回false表示搜索失败,需要放弃此条路径
return false;
}
function getAreaIdx(y, x) {
return Math.floor(x / 3) + Math.floor(y / 3) * 3;
}
提交结果:
第三次优化:引入位运算
与先前的「有效的数独」一样,我们可以通过「位运算」巧妙地代替借助数组和Set实现的「哈希表」,以进一步压缩我们的空间开销。
需要注意的是,我们需要在先前的基础上,再实现利用位运算模拟从哈希表中移除记录的功能。为此我们需要再引入一种新的位运算——「按位非」,再结合我们先前已经接触过的「按位与」来实现这一功能。
注意,在这里我们不展开说明利用位运算模拟移除功能的原理,请各位同学自行完成相关的推导!
代码如下:
let range = 9;
let RowHashs = [];
let ColHashs = [];
let AreaHashs = [];
function solveSudoku(board) {
for (let i = 0; i < range; i++) {
RowHashs[i] = 0;
ColHashs[i] = 0;
AreaHashs[i] = 0;
}
for (let i = 0; i < range; i++) {
for (let j = 0; j < range; j++) {
let val = board[i][j];
if (val !== '.') {
val = 1 << val;
RowHashs[i] |= val;
ColHashs[j] |= val;
AreaHashs[getAreaIdx(i, j)] |= val;
}
}
}
dfs(board, 0, 0);
return board;
}
function dfs(board, y, x) {
if (x >= range) return dfs(board, y + 1, 0);
if (y >= range) return true;
if (board[y][x] !== '.') return dfs(board, y, x + 1);
let areaIdx = getAreaIdx(y, x);
for (let i = 1; i <= range; i++) {
if (!(RowHashs[y] >> i & 1 || ColHashs[x] >> i & 1 || AreaHashs[areaIdx] >> i & 1)) {
let data = 1 << i;
board[y][x] = i + '';
RowHashs[y] |= data;
ColHashs[x] |= data;
AreaHashs[areaIdx] |= data;
if (dfs(board, y, x + 1)) {
return true;
} else {
RowHashs[y] &= ~data;
ColHashs[x] &= ~data;
AreaHashs[areaIdx] &= ~data;
board[y][x] = '.';
}
}
}
return false;
}
function getAreaIdx(y, x) {
return Math.floor(x / 3) + Math.floor(y / 3) * 3;
}
提交结果:
写在文末
我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。
我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!