欢迎光临
我们一直在努力

一天一道力扣Hot100(37):深度优先算法--括号生成

LeetCode 22. 括号生成(Go 语言实现)

个人主页:
> 我不会起名字322 < (欢迎各位大佬莅临😊)

其他栏目:
> 技术栈学习笔记 <

其他栏目:
> 力扣Hot100题目解析 <

其他栏目:
> Go项目学习笔记 <




前言:

上一篇组合总和里,我总结出了回溯的"三要素":路径、选择列表、结束条件。当时我说"一旦想清楚这三样东西,剩下的就是套模板"。

今天这道括号生成,正好是检验这句话的好例子——它和组合总和长得很不一样,没有数组、没有 startIndex、没有求和,但本质上还是同一套东西。我们一起来看看,三要素在这道题里分别变成了什么。

  • 首先我们说DFS到底是干了什么事:DFS 是一种遍历策略;当它不设终点、走遍决策树的所有分支时,就是在穷举。回溯题用 DFS 穷举所有决策路径,并在满足条件处收集答案
  • 路径(已做的选择),这是回溯题要看的第一个数据。这道题的路径就是当前已经拼出来的括号字符串。和组合总和不同的是,这里的选择不是从数组里挑元素,而是每一步决定"放左括号还是放右括号"。
  • 选择列表(当前可做的选择),这是回溯要看的第二个数据。这道题的选择列表很特殊——它不是一个数组,而是由当前状态动态决定的:能不能放左括号?能不能放右括号?取决于已经用了几个左括号、几个右括号。
  • 结束条件(到达决策树的底层),这是回溯要看的第三个数据。这里就是括号用完了,也就是字符串长度达到了 2 * n,此时把路径写入结果集。
  • 整体的一个模板还是这样的:

    func backtrack(路径, 选择列表) {
    if 满足结束条件 {
    结果集 = append(结果集, 路径的拷贝)
    return
    }
    for 选择 := 选择列表 {
    做选择 // 路径加入该选择
    backtrack(路径, 新的选择列表)
    撤销选择 // 路径移除该选择
    }
    }

    下面我们来看一道题目深入理解一下

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

    示例 1:

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

    示例 2:

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

    示例 3:

    输入:n = 0
    输出:[""]

    有效括号的条件

    在动手写代码之前,先想清楚"什么样的括号组合才算有效"。有两个条件缺一不可:

  • 数量相等:左括号和右括号各 n 个,总数 2n
  • 前缀合法:任意前缀中,左括号数量 ≥ 右括号数量
  • 第 2 条尤其关键。比如 "())(" 虽然左括号右括号都是 2 个,但中途右括号一度超过了左括号,所以不合法。这个条件直接决定了我们的剪枝策略。

    • 首先,我们不需要排序,也不需要数组。这道题的输入只有一个整数 n,没有候选数组,所以组合总和里的 sort.Ints 和 startIndex 在这里都用不上。这正说明:三要素是本质,具体形式随题目而变。

    • 其次,我们要定义几个数据

    • 一个是当前已经拼出来的括号字符串(路径)

      path := make([]byte, 0, 2*n) // 用 []byte 避免频繁字符串拼接

    • 一个是结果集,用来写入结果

      result := []string{}

    • 上面两个是确定的,最后一个看题目不同来自己确定。这道题我们需要记录已经用了几个左括号、几个右括号,用来判断下一步能放什么、以及什么时候结束

      open, close := 0, 0

      注意:这里不像组合总和那样传 curSum,因为括号题没有"和"要凑,取而代之的是两个计数器。

    • 有了上面的数据,我们现在来套用模板来写这道题目

      func backtrack(路径, 选择列表) {
      首先是这个大框架,路径肯定要传参 path,选择列表呢?
      这道题的选择列表不是数组,而是由 open 和 close 两个变量动态决定的:
      open < n 时,可以放 '('
      close < open 时,可以放 ')'
      }

      if 满足结束条件 {
      这里的满足条件就是 len(path) == 2*n

      这里的路径拷贝就是把当前符合答案标准的 path 转成 string 写入结果集
      结果集 = append(结果集, string(path))
      return
      }
      //这里我们要想,如果还能放括号怎么办呢?显然,继续下面的递归即可
      //那如果既不能放左括号、也不能放右括号呢?这种情况其实不会出现,
      //因为只要没到 2*n,open < n 或 close < open 至少有一个成立

      // 选择一:放左括号
      if open < n {
      path = append(path, '(')
      backtrack(open+1, close)
      path = path[:len(path)1] // 撤销选择
      }

      // 选择二:放右括号(右括号不能多于左括号,这就是剪枝)
      if close < open {
      path = append(path, ')')
      backtrack(open, close+1)
      path = path[:len(path)1] // 撤销选择
      }

    • 因此,我们最后改造的函数就是

      func generateParenthesis(n int) []string {
      result := []string{}
      path := make([]byte, 0, 2*n) // 预分配容量

      var backtrack func(open, close int)
      backtrack = func(open, close int) {
      // 结束条件:括号用完
      if len(path) == 2*n {
      result = append(result, string(path))
      return
      }

      // 选择一:加左括号
      if open < n {
      path = append(path, '(')
      backtrack(open+1, close)
      path = path[:len(path)1] // 回溯
      }

      // 选择二:加右括号(右括号不能多于左括号,这就是剪枝)
      if close < open {
      path = append(path, ')')
      backtrack(open, close+1)
      path = path[:len(path)1] // 回溯
      }
      }

      backtrack(0, 0)
      return result
      }

    复杂度分析

    • 时间复杂度:O(4ⁿ / √n),即第 n 个卡特兰数
    • 空间复杂度:O(n),递归栈深度(不算结果存储)

    总结

    回过头看,这道题再次印证了"回溯三要素":

    三要素在括号生成里的体现
    路径 path,记录当前拼出的括号串
    选择列表 由 open、close 动态决定:能放 ( 吗?能放 ) 吗?
    结束条件 len(path) == 2*n 时收集答案

    和组合总和对比一下,区别一目了然:

    组合总和括号生成
    路径 path []int path []byte
    选择列表 startIndex 之后的数组元素 open/close 动态判断
    结束条件 curSum == target len(path) == 2*n
    剪枝 curSum + candidates[i] > target close < open

    题目变了,形式变了,但三要素的骨架没变。这就是为什么我说"想清楚这三样,剩下的就是套模板"。

    本文是 《算法题目解析系列》 的第 [37] 篇,本系列将持续更新,每篇都提供清晰的思路与编程语言实现。欢迎关注,第一时间获取更新。如果你有想看的题目,也可以在评论区留言告诉我。

    赞(0)
    未经允许不得转载:171主机测评 » 一天一道力扣Hot100(37):深度优先算法--括号生成
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址