T36 有效的数独
朴素解法:分别校验
本题是一道典型的「模拟题」。其最简单粗暴的解法便是依照题意分三次扫描整个棋盘,分别对行、列、块这三个维度按照题意进行检测。
这种解法唯一的难点就在于对3 * 3区域的校验。为了更加方便地处理,我们不妨为所有的小区域进行标号(记作areaIdx)如下:
然后我们通过观察,归纳出由areaIdx计算出每一块区域左上角坐标startX、startY的数学公式,再利用双重循环对每一块区域进行遍历,即可解决问题。
代码如下:
const range = 9;
function isValidSudoku(board) {
//校验每一行
for (let y = 0; y < range; y++) {
let set = new Set();
for (let x = 0; x < range; x++) {
let val = board[x][y];
if (set.has(val)) return false;
val !== '.' && set.add(val);
}
}
//校验每一列
for (let x = 0; x < range; x++) {
let set = new Set();
for (let y = 0; y < range; y++) {
let val = board[x][y];
if (set.has(val)) return false;
val !== '.' && set.add(val);
}
}
//校验每一区域
for (let areaIdx = 0; areaIdx < range; areaIdx++) {
let set = new Set();
//难点!
let startX = Math.floor(areaIdx / 3) * 3;
let startY = (areaIdx % 3) * 3;
for (let x = 0; x < 3; x++) {
for (let y = 0; y < 3; y++) {
let val = board[startX + x][startY + y];
if (set.has(val)) return false;
val !== '.' && set.add(val);
}
}
}
return true;
}
提交结果:
这种做法的缺陷也很明显,最坏情况下,我们需要将整个棋盘(也就是二维数组)扫描整整三遍,算法效率比较糟糕。
优化:合三为一
事实上,我们最多只需要将棋盘扫描一遍就能得出答案。
简单来说,我们只需要建立三组全程可供查询的哈希表,每一个哈希表组中的每一个哈希表都承担着记录棋盘某一行/列/区域的使命。
它们分别针对三个维度,在对棋盘进行扫描的过程中实时对获取到的值进行记录,以供我们在一遍扫描的过程中对三个维度直接进行检查。
本质上这种思路其实就是将我们在「朴素解法」中在循环代码块中创建的Set集合全部暴露出来,直接在一遍扫描的过程中集体进行维护和查询。
哈希表组的建立可以采用数组+Set的形式,也可以直接采用二维数组。本文提供的代码选用前者实现。
由于代码实现比较简单,本文正文部分不再具体展开分析:
const range = 9;
const isValidSudoku = board => {
/* 初始化校验哈希表组 */
//根据扫描顺序,用于检测横向的哈希表可以复用,
//因此我们只需创建一个Set对象,而不采用嵌套结构。
let RowHash = new Set();
let ColHashArr = [];
let AreaHashArr = [];
for (let i = 0; i < range; i++) {
ColHashArr.push(new Set());
AreaHashArr.push(new Set());
}
/* 扫描整个棋盘 */
for (let y = 0; y < range; y++) {
for (let x = 0; x < range; x++) {
let val = board[y][x];
if (val === '.') continue;
let areaIdx = Math.floor(x / 3) + Math.floor(y / 3) * 3;
//直接通过下标的形式从哈希表组中,获取需要查询/更新的哈希表
if (RowHash.has(val) || ColHashArr[x].has(val) || AreaHashArr[areaIdx].has(val)) {
return false;
}
RowHash.add(val);
ColHashArr[x].add(val);
AreaHashArr[areaIdx].add(val);
}
//清空横向检测哈希表,以供复用。
//P.S.有点「动态规划」里「滚动数组」的味道。
RowHash.clear();
}
return true;
}
提交结果:
进一步优化:位运算
如果我们想要进一步提升程序的性能,可以引入「位运算」来代替基于数组或集合实现的哈希表。
下面我们直接通过通过具体的例子,来理解如何运用「位运算」实现哈希表。
假设我们现在创建变量x = 0b0(即x = 0),用于储存两个数据3、5。
我们先来储存第一个数据。
首先,我们执行「左移」操作1 << 3,得到一个新的二进制数0b1000。
然后我们再将这个数据与x进行「按位或」操作x |= 0b1000,其目的在于将二进制数中含1的二进制位导入到x中。由于原先x = 0b0,相当于没有储存任何数据,因此执行该操作后得x = 0b1000。
然后我们再来储存5,操作方法与上面完全一样,最终得到x = 0b10100。
我们很容易发现现在x中的第3位和第5位都已经变成了1——我们成功实现了哈希表的储存功能!
那么实现哈希表查询功能的思路也就呼之欲出了。
假设我们现在要查询3在x中是否有记录,我们需要先执行「右移」操作x >> 3得到二进制数0b101,这样原本在二进制位第3位的1就会跑到第1位。然后我们再执行「按位与」操作0b101 & 0b1,则当且仅当两数的二进制位第1位都为1的时候,表达式才会返回1,表示查询成功。
至此,利用「位运算」实现哈希表功能的基本思路就分析完毕了。
下面我们将刚才介绍的思路运用到本题中,代码如下:
const range = 9;
const isValidSudoku = board => {
let RowHash = 0;
let ColHashArr = [];
let AreaHashArr = [];
for (let i = 0; i < range; i++) {
ColHashArr.push(0);
AreaHashArr.push(0);
}
for (let y = 0; y < range; y++) {
for (let x = 0; x < range; x++) {
let val = board[y][x];
if (val === '.') continue;
//将val转成 Number 类型
val = + val;
let areaIdx = Math.floor(x / 3) + Math.floor(y / 3) * 3;
if ((RowHash >> val) & 1 || (ColHashArr[x] >> val) & 1 || (AreaHashArr[areaIdx] >> val) & 1) {
return false;
}
RowHash |= 1 << val;
ColHashArr[x] |= 1 << val;
AreaHashArr[areaIdx] |= 1 << val;
}
RowHash = 0;
}
return true;
}
提交结果:
T39 组合总和
思路一:DFS搜索
这是一道典型的「DFS搜索」应用题,与我们先前做过的「括号生成」类似。在那一篇文章中我们已经较为系统地学习过了使用「DFS搜索」解题的一般思路及剪枝技巧,故本文不再赘述。
题目不是很难,让我们简单过一遍代码。所有的要点都已通过注释进行说明:
const combinationSum = (nums, target) => {
const ansArr = [];
dfs(nums, 0 , target, '', ansArr, 0);
return ansArr;
};
const dfs = (nums, curSum, targetSum, curAns, ansArr, tailNum) => {
//每一个节点往下搜时,nums中的所有
//大于等于tailNum(父节点数值)的元素都有可能是下一个子节点。
//如果我们选用小于tailNum的元素作为子节点,势必会导致重复!
for (let num of nums) {
if (num < tailNum) continue; //去重剪枝!
let newSum = num + curSum;
if (newSum === targetSum) {
//这里我们采用字符串的形式在搜索过程中记录答案,
//在得出最终答案后再转换为数组。这样做的好处在于
//避免每一次搜索到新的num时都需要创建新的拷贝数组。
ansArr.push((curAns + num).split(',').map(Number));
}
//只有newSum小于targetSum时往下搜索才可能有解
else if (newSum < targetSum) {
dfs(nums, newSum, targetSum, curAns + num+ ',', ansArr, num);
}
}
};
提交结果:
值得一提的是,在「LeetCode」平台上,有网友提出可以通过先对数组排序的方法,来进一步提升搜索过程中去重剪枝的效率。
实现代码如下:
const combinationSum = (nums, target) => {
const ansArr = [];
const end = nums.length - 1;
nums.sort((a, b) => a - b);
//如果数组中最小的数也比target大,则肯定无解
if (end >= 0 && nums[0] <= target) {
dfs(nums, 0 , target, '', ansArr, 0, end);
}
return ansArr;
};
const dfs = (nums, curSum, targetSum, curAns, ansArr, i, end) => {
//通过传递遍历起点i直接跳过不能作为子节点的数组元素
for (; i <= end; i++) {
let num = nums[i];
let newSum = num + curSum;
if (newSum === targetSum) {
ansArr.push((curAns + num).split(',').map(Number));
}
else if (newSum < targetSum) {
dfs(nums, newSum, targetSum, curAns + num+ ',', ansArr, i, end);
}
}
};
但毕竟sort方法的性能开销是客观存在的,因此实际上这种优化方案对代码性能的提升幅度并不明显的。
提交结果如下:
Tip: 先别急着否定这种方法,在后面的解题中TA还有用武之地!
思路二:DP动态规划
根据我们先前的做题经验,我们已经意识到DP和DFS之间在某些情况下是可以互相转化的,本题亦是如此。
在「LeetCode」上已经有网友给出了基于DP实现的本题题解。但考虑到倘若在这题使用DP,将不可避免地计算nums数组中从最小值到最大值之间所有整数的拆分情况,算法空间开销非常庞大。故这种方法在本文中我们不再进行学习,感兴趣的同学请自行阅读!
T40 组合总和II
题目链接:leetcode-cn.com/problems/co…
思路:DFS搜索
本题是前面的「组合总和」的变式,我们只要在理解题意的基础上,直接在先前代码的基础上进行修改即可。
修改要点如下:
-
确保程序能够正确处理存在重复元素的数组;
-
确保程序能按题目要求实现去重.
要点(1)只要我们能够正确理解题意,就很容易实现:在「组合总和」中,数组nums是非重复数组,并且里面的元素是可以供我们随意复用的;而在本题中nums可能会含有有限个相同元素——这意味着对复用的次数作出了限制,且从题目所给的测试用例来看传入的数组有可能是完全乱序的。
因此为了方便处理,我们这里直接选用上一题中基于sort方法实现的题解代码进行改造;同时确保在创建搜索子节点时始终选用nums[i]后方的元素nums[i + 1]而非原先的nums[i]即可。
本题的主要难点在于要点(2)。首先我们要搞明白本题产生重解的原因:除了在「组合总和」中我们已知的找比nums[i]小的数会导致重复,在本题中,由于传入数组中可能含有多个重复元素,因此可能会产生重复答案。
例如题中所给的测试样例nums = [10,1,2,7,6,1,5], target = 8,如果我们不加以去重,两个1分别和2、5组合,就会出现两组相同的答案[1, 2, 5]。
了解了出现重复答案的原理,下面我们来考虑一下如何实现去重。
有同学会提出一种简单粗暴的办法:采用Set对象记录已取得的答案,再和后面计算出来的答案进行比较,检查是否重复。
代码如下:
const combinationSum2 = (nums, target) => {
const ansArr = [];
const end = nums.length - 1;
const set = new Set();
nums.sort((a, b) => a - b);
if (end >= 0 && nums[0] <= target) {
dfs(nums, 0 , target, '', ansArr, 0, end, set);
}
return ansArr;
};
const dfs = (nums, curSum, targetSum, curAns, ansArr, start, end, set) => {
for (let i = start; i <= end; i++) {
let num = nums[i];
let newSum = num + curSum;
if (newSum === targetSum) {
let finalAns = (curAns + num);
!set.has(finalAns) && (
set.add(finalAns),
ansArr.push(finalAns.split(',').map(Number))
);
}
else if (newSum < targetSum && i + 1 <= end) {
dfs(nums, newSum, targetSum, curAns + num+ ',', ansArr, i + 1, end, set);
}
}
};
如果我们稍加思考,就会发现其实这种方法存在很大的问题!
我们不得不需要在最终搜索到一个可能的答案后,再进行校验。这么做就意味着先前程序耗费的大量时间进行搜索,而不进行任何预先的剪枝,实际上都在做无用功。
事实上,这段代码提交上去后的确会出现超时的问题。
据此我们可以得出一条经验:对于使用「DFS搜索」实现的算法,得出答案前的预先剪枝是必不可少的,否则大概率会出现性能低下甚至超时的问题。
那么落实到本题该怎么办呢?其实非常简单!
既然我们对nums数组已经进行了排序,那么数组中相同的元素此时应该都"挨"在一起了。我们只需要在创建搜索子节点时,直接跳过重复出现的元素即可!
代码如下:
const combinationSum2 = (nums, target) => {
const ansArr = [];
const end = nums.length - 1;
if (end >= 0) {
nums.sort((a, b) => a - b);
nums[0] <= target && dfs(nums, 0 , target, '', ansArr, 0, end);
}
return ansArr;
};
const dfs = (nums, curSum, targetSum, curAns, ansArr, start, end) => {
for (let i = start; i <= end; i++) {
if (i > start && nums[i] === nums[i - 1]) continue; //预先剪枝!
let num = nums[i];
let newSum = num + curSum;
if (newSum === targetSum) {
ansArr.push((curAns + num).split(',').map(Number));
}
else if (newSum < targetSum && i + 1 <= end) {
dfs(nums, newSum, targetSum, curAns + num+ ',', ansArr, i + 1, end);
}
}
};
提交结果:
写在文末
我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》。
我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!