【LeetCode选讲·第十七期】「有效的数独」「组合总和」「组合总和II」

159 阅读5分钟

T36 有效的数独

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

朴素解法:分别校验

本题是一道典型的「模拟题」。其最简单粗暴的解法便是依照题意分三次扫描整个棋盘,分别对行、列、块这三个维度按照题意进行检测。

这种解法唯一的难点就在于对3 * 3区域的校验。为了更加方便地处理,我们不妨为所有的小区域进行标号(记作areaIdx)如下:

250px-sudoku-by-l2g-20050714svg.png

然后我们通过观察,归纳出由areaIdx计算出每一块区域左上角坐标startXstartY的数学公式,再利用双重循环对每一块区域进行遍历,即可解决问题。

代码如下:

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

提交结果:

1.png

这种做法的缺陷也很明显,最坏情况下,我们需要将整个棋盘(也就是二维数组)扫描整整三遍,算法效率比较糟糕。

优化:合三为一

事实上,我们最多只需要将棋盘扫描一遍就能得出答案。

简单来说,我们只需要建立三组全程可供查询的哈希表,每一个哈希表组中的每一个哈希表都承担着记录棋盘某一行/列/区域的使命。

它们分别针对三个维度,在对棋盘进行扫描的过程中实时对获取到的值进行记录,以供我们在一遍扫描的过程中对三个维度直接进行检查。

本质上这种思路其实就是将我们在「朴素解法」中在循环代码块中创建的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;
}

提交结果:

1.png

进一步优化:位运算

如果我们想要进一步提升程序的性能,可以引入「位运算」来代替基于数组或集合实现的哈希表。

下面我们直接通过通过具体的例子,来理解如何运用「位运算」实现哈希表。

假设我们现在创建变量x = 0b0(即x = 0),用于储存两个数据35

我们先来储存第一个数据。

首先,我们执行「左移」操作1 << 3,得到一个新的二进制数0b1000

然后我们再将这个数据与x进行「按位或」操作x |= 0b1000,其目的在于将二进制数中含1的二进制位导入到x中。由于原先x = 0b0,相当于没有储存任何数据,因此执行该操作后得x = 0b1000

然后我们再来储存5,操作方法与上面完全一样,最终得到x = 0b10100

我们很容易发现现在x中的第3位和第5位都已经变成了1——我们成功实现了哈希表的储存功能!

那么实现哈希表查询功能的思路也就呼之欲出了。

假设我们现在要查询3x中是否有记录,我们需要先执行「右移」操作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;
}

提交结果:

1.png

T39 组合总和

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

思路一: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);
        }
    }
};

提交结果:

1.png

值得一提的是,在「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方法的性能开销是客观存在的,因此实际上这种优化方案对代码性能的提升幅度并不明显的。

提交结果如下:

1.png

Tip: 先别急着否定这种方法,在后面的解题中TA还有用武之地!

思路二:DP动态规划

根据我们先前的做题经验,我们已经意识到DP和DFS之间在某些情况下是可以互相转化的,本题亦是如此。

在「LeetCode」上已经有网友给出了基于DP实现的本题题解。但考虑到倘若在这题使用DP,将不可避免地计算nums数组中从最小值到最大值之间所有整数的拆分情况,算法空间开销非常庞大。故这种方法在本文中我们不再进行学习,感兴趣的同学请自行阅读!

T40 组合总和II

题目链接:leetcode-cn.com/problems/co…

思路:DFS搜索

本题是前面的「组合总和」的变式,我们只要在理解题意的基础上,直接在先前代码的基础上进行修改即可。

修改要点如下:

  1. 确保程序能够正确处理存在重复元素的数组;

  2. 确保程序能按题目要求实现去重.

要点(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分别和25组合,就会出现两组相同的答案[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);
        }
    }
};

如果我们稍加思考,就会发现其实这种方法存在很大的问题!

我们不得不需要在最终搜索到一个可能的答案后,再进行校验。这么做就意味着先前程序耗费的大量时间进行搜索,而不进行任何预先的剪枝,实际上都在做无用功。

事实上,这段代码提交上去后的确会出现超时的问题。

1.png

据此我们可以得出一条经验:对于使用「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);
        }
    }
};

提交结果:

1.png

写在文末

我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》

我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!

QQ图片20220701165008.png