【LeetCode选讲·第十八期】「解数独」(仅含唯一答案的DFS搜索)

131 阅读2分钟

T36 解数独

题目链接:leetcode.cn/problems/su…

仅含唯一答案的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;
}

提交结果:

1.png

第一次优化:哈希表

在「有效的数独」中,我们为了避免反复遍历整个棋盘,采用三组「哈希表」分三个维度记录已出现在棋盘上的数字。在本题中我们仍然可以这么做。

需要注意的是,由于输入的二维数组中已经包含了一些数字,我们在初始化「哈希表」时需要先将它们直接记入进去。

此外我还会借用本题简单分享一个利用「摩根定律」简化代码的小技巧,具体请看注释。

代码如下:

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;
}

提交结果:

2.png

第二次优化:消灭多余的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;
}

提交结果:

3.png

第三次优化:引入位运算

与先前的「有效的数独」一样,我们可以通过「位运算」巧妙地代替借助数组和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;
}

提交结果:

4.png

写在文末

我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。

我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!

QQ图片20220701165008.png