【算法-LeetCode】22. 括号生成(回溯;有重复元素的全排列)

22. 括号生成 - 力扣(LeetCode)

发布:2021年9月20日20:47:17

问题描述及示例

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

有效括号组合需满足:左括号必须以正确的顺序闭合。

示例 1:
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2:
输入:n = 1
输出:["()"]

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/generate-parentheses
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

提示:
1 <= n <= 8

我的题解(回溯)

有关回溯也有一些通用的思路总结,可以看我下面博客中的相关描述:

参考:【算法-LeetCode】46. 全排列(回溯算法初体验)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】93. 复原 IP 地址(回溯;递归)_赖念安的博客-CSDN博客

其实,我就是把这道题看做是上面的『【算法-LeetCode】46. 全排列 』的一个变形。唯一的不同就是需要在全排列的基础上考虑字符的重复性,从而在回溯时对一些分支做“修剪”操作,这样可以大大提高性能,而且必须做这个“修剪”操作,如果不修剪的话,那程序就会因为超时而无法通过。

总体思路和上面那道全排列的思路完全一致。所以这里就不多描述了。其实就是把上面那道题中的传入的数组参数 nums=[1,2,3] 改成了这里的括号字符串(假设 n=3parenthesisStr = '((()))'。如果不考虑超时问题的话,那么两道题的代码可以说是完全一致。

也就是:把 '((()))' 中的这六个字符进行全排列操作。再根据一定的过滤规则对得到的所有结果进行去重、剔除等过滤操作,最后获取符合要求的括号字符串。

但是本题是要考虑代码运行时间的问题的,所以不能简单地完全照搬上面的那种解决方法,而要在每个回溯阶段做事先的过滤筛选操作,否则最后就会超时。

详细解释请看下方注释:

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function (n) {
  // 有传入的参数n生成一个特定的括号字符串,如n=1,则生成();n=2,则生成(()),
  // 这个生成的parenthesisStr就是我们要进行全排列的原始字符串
  let parenthesisStr = '';
  for (let i = 0; i < n; i++) {
    parenthesisStr = `(${parenthesisStr})`;
  }
  // results用于存储最中获取的所有符合条件的括号字符串,并作为最后的返回值返回
  // 一开始我是利用了Set来进行去重操作的,后来发现遇到大数据量时行不通
  // let results = new Set();
  let results = [];
  // 这个match原本是用来判断一个括号字符串是否合法的辅助栈结构,但是后来发现行不通,遂弃
  // let match = [];
  // temp用来存储某一个有效的括号字符串
  let temp = [];
  backtracking(parenthesisStr, []);
  // 一开始我是用Set结构的键来存储有效的括号字符串来达到去重效果的,故返回results的keys
  // return [...results.keys()];
  // 后来直接改用了数组来存储有效果的括号字符串,直接将其返回即可
  return results;

  // 回溯函数(封装递归操作),str是待排列的括号字符串,注意这个str在每一层递归中的值
  // 都不会变,used用于记录str中字符的使用情况,used在每一层递归中的值会随情况改变
  function backtracking(str, used) {
    // 递归的终止条件,当temp中存储的括号字符的个数和str长度相等时则说明找到了一个候选值
    if (temp.length === str.length) {
      // 这个match是失败前的尝试操作,不用管它
      // match.length ? match = [] : results.set(temp.join(''));
      // 判断当前的找到的这个候选值是否是有效的括号字符串,注意将temp传入判断函数中时使用
      // 了解构赋值,主要是因为这个判断函数中有对数组的pop和push等改变原数组的操作
      // 而数组是对象类型,所以如果不用结构赋值拷贝一份temp里的值作为判断函数里的参数的话
      // temp的值就会在判断过程中被改变,最后存入results中的值就不是我们想要的值了
      if (isValidParenthesis([...temp])) {
        // 这个add是原先用Set结构表示results时的操作,不用管它
        // results.add(temp.join(''));
        // 如果当前的这个temp中的字符确实可以组成有效的括号字符串,则将其存入results
        // 注意temp目前是数组类型,需要先将其用空字符串拼接为题目需要的完整字符串
        results.push(temp.join(''));
      }
      // 用return结束本层递归
      return;
    }
    // 开始遍历str字符串中的每个字符,注意这里的i是从0开始的,因为有传入该层递归的
    // used数组来标识哪个字符是否被用过,故不用担心上一层递归已经被取用过的字符被重复使用
    for (let i = 0; i < str.length; i++) {
      // if (used[i]) {
      // 这里是整个题目与全排列的那道题最大的不同点,这里不但和那个全排列的题目里一样
      // 控制了跳过上一层递归使用过的字符,还控制了跳过同一层里重复使用过的字符
      // (这也是防止超时的关键!)详情请看下方【补充】
      if (used[i] || (!used[i-1] && str[i] === str[i-1] && i > 0)) {
        continue;
      }
      // 做过上面的筛选后,将当前遍历的这个字符压入temp
      temp.push(str[i]);
      // 同时标志该字符的使用情况为“已使用”
      used[i] = true;
      // 下面的这个if else 判断是此前我想用match栈来过滤不合规的括号字符串时写的,已弃用
      // if (!match.length) {
      //   match.push(str[i])
      // } else {
      //   match[match.length - 1] === '(' && str[i] === ')' ? match.pop() : match.push(str[i]);
      // }
      // 开始递归,也就是对剩余的字符串做同样的“深度”探索,注意传入的used数组的状态改变
      backtracking(str, used);
      // 上面那层递归完成后(可能找到了合适的组合,也可能没找到),进行关键的回溯操作
      temp.pop();
      used[i] = false;
    }
  }
  // 这个辅助函数用于判断一个数组里的括号字符是否可以拼接成符合要求的括号字符串
  // 注意被传入的arr是数组类型(注意数组也是一种对象类型),而在此函数内部有对arr进行
  // shift操作,而该操作会改变arr的值,所以上面在调用这个函数时使用了结构赋值,目的就是
  // 防止传入的参数在判断过程中被修改。
  function isValidParenthesis(arr) {
    // 如果arr的第一个字符元素为 ),则不可能组成合法的括号字符串,所以直接返回false
    if (arr[0] === ')') {
      return false;
    }
    // helpStack是用于判断的辅助栈
    let helpStack = [];
    // 将arr中的字符逐个从头部弹出
    while (arr.length) {
      // temp2用于接收被弹出的arr头部元素
      let temp2 = arr.shift();
      // 如果helpStack中的栈顶元素能和temp2匹配为一对括号
      if (helpStack[helpStack.length - 1] === '(' && temp2 === ')') {
        // 则将helpStack的栈顶元素弹出
        helpStack.pop();
      } else {
        // 如果不能匹配成一对括号,则将temp2压入helpStack栈顶等待下一次匹配
        helpStack.push(temp2);
      }
    }
    // arr中的所有元素都被弹出来后,如果helpStack为空,则说明arr中的字符可以拼接成一个
    // 合法的字符串,否则说明arr中的元素无法满足要求
    return !helpStack.length;
  }
};


提交记录
执行结果:通过
8 / 8 个通过测试用例
执行用时:88 ms, 在所有 JavaScript 提交中击败了25.37%的用户
内存消耗:41.1 MB, 在所有 JavaScript 提交中击败了8.28%的用户
时间:2021/09/20 21:35

相关补充

补充

一开始我把这道题想得很简单:

  • ①先把那些括号字符进行全排列操作;
  • ②上面所得到的全排列结果肯定包含符合要求的括号字符串,只要把那些符合要求的结果在递归的终止条件那里筛选出来即可;
  • ③当然可能会有重复的结果,所以一开始就是用了JavaScript中的 Set 结构,因为这个类型的结构在添加重复元素时会跳过那些已有的元素,这自然就达到了去重的效果。

按这样想确实是很有道理,而且经过试验,在我使用本地浏览器调试时,输入 n=1或2或3 也确实是可以得到正确结果的,然而当我输入 n=8 时(因为题目中提示了 1<=n<=8),程序就陷入了长时间的运行……

下面是当 n 分别取 3、4、6时所能生成的全排列数:

在这里插入图片描述

n=3时的全排列结果数

在这里插入图片描述

n=4时的全排列结果数

在这里插入图片描述

n=6时的全排列结果数

可以看到,随着n取值的增大,所得到的全排列结果数就越来越多,如果把最后筛选合规结果的操作堆积到递归的结束条件那里,那工作量就会变得很大,于是就导致了超时。

所以,不能简单地用 Set 类型的数据来完成去重的操作,而要在把这个任务分配到每一层递归中去完成,而且是一旦发现使用了同一层递归中已经使用过某个元素,则立即停止继续往下递归,这样就能大大减少不必要的冗余操作(似乎也被叫做“枝剪”)。

那么如果做出这个判断呢?关键就是遍历字符串时的那个 if 判断:

if (used[i] || (!used[i-1] && str[i] === str[i-1] && i > 0)) {
  continue;
}

如果只是对一组无重复的元素进行全排列,那么,if 条件里就只要判断当前遍历的元素是否在上一层递归中使用过

也即是只要判断 used[i] 的值是否为 true

但是本题中是需要对一组有重复的元素进行全排列。那么 if 条件里就还得判断当前遍历的元素和之前用过的元素是否一样,如果一样的话,那就没必要再进行递归探索了,因为结果一定和之前取用过的那个元素的结果一模一样。

如果之前那个元素 str[i-1] 取用过(used[i-1]true),且当前的这个元素和之前那个元素相等(str[i] === str[i-1]),则说明不需要再做有重复结果的递归了。限制 i>0 是为了防止 str[i-1] 下标越界。

例子:假设 n=3。那么生成的将要进行全排列的字符串为 parenthesisStr = '((()))'

我们先取用第一个元素:( 作为某个排列的开头,然后通过递归对剩下的 (())) 进行全排列。合规的全排列的结果全部存进了 results 中。接下来,再取用第二个元素:( 作为某个排列的开头,然后通过递归对剩下的 ())) 进行全排列,但是在经过上面提到的那个 if 判断时,程序发现,当前取用的这个元素和之前取用的那个元素是一样的,那么就可以认为以这两个元素为开头所获得的那些全排列结果是完全一样的,所以程序在这里选择停止往下再做递归了。之后的元素也是同样的道理,如果之前用过的话,就不再浪费时间做递归了。

只要理解了这一点。那么这道题的思路就清晰了。

全排列的过程也可以抽象为对一棵多叉树的搜索:

在这里插入图片描述

把全排列看做是对一棵多叉树的搜索

注意,上面树中的每个节点中其实还应该填入取用当前元素后,还剩下那些元素可以被取用,那样才更有利于理解多叉树的深度搜索过程。

上面的多叉树就可以看做是一棵经过“枝剪”操作的树。为什么呢?因为如果没有经过枝剪的话,那么上面二叉树的每个节点应当是有 6 个子节点(或者说6个分叉)的。就是因为在递归过程中发现了有些元素和之前用过的元素一样,所以就不再继续递归了。

可以这样具象化地理解:每进行一层递归,就会在上面的树中的相应节点后加一条分支。如果阻止了一层递归,则不会生成多余的分支。

Tips:递归的流程本来就很抽象,如果想要理清楚整个流程,可以利用浏览器中的逐步调试功能来观察函数调用栈和相关局部变量以及存在闭包环境中的全局变量的变化。

更新:2021年10月26日09:58:36

突然在我的这篇博客下面看到另一篇博客,感觉里面的题解思路还挺多样的,没想到居然还能用到动态规划(真是万物皆可dp😂),感谢博主分享!

参考:算法——LeetCode22. 括号生成_知北行的博客-CSDN博客

【更新结束】

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

更新:2021年9月20日21:44:06

参考:括号生成 - 括号生成 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年9月20日20:46:53
参考:Set - JavaScript | MDN
更新:2021年9月21日11:37:27
参考:【算法-LeetCode】46. 全排列(回溯算法初体验)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】93. 复原 IP 地址(回溯;递归)_赖念安的博客-CSDN博客
更新:2021年10月26日09:58:05
参考:算法——LeetCode22. 括号生成_知北行的博客-CSDN博客

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值