「回溯算法」专题 3:字符串中的回溯问题


「回溯算法」专题 3:字符串中的回溯问题

我们这一节来看一个在字符串上进行搜索的问题,这道题是「力扣」上第 22 题:括号生成问题。

给出 n 代表生成括号的对数,请你写出一个函数,使其能够生成所有可能的并且有效的括号组合。

例如,给出 n = 3,生成结果为:

[
  "((()))",
  "(()())",
  "(())()",
  "()(())",
  "()()()"
]

这是一个很典型地使用回溯算法完成的问题,因为要完成一件事情(生成 n 对括号),并且是有效的括号。

这件事情可以有若干种做法,每一种做法有若干个步骤。并且要我们求出这若干种解法,这样的问题显然可以使用回溯算法解决。

我们就拿示例 3 在草稿纸做一个计算。

要得到一个符合条件的字符串,首先第 1 个字符一定是 ,不可能是右括号;

第 2 个字符,可以是左括号,也可以是右括号,

如果第 2 个字符使用了左括号,第 3 个字符可以使用左括号,也可以使用右括号;

如果第 2 个字符使用了右括号,第 3 个字符只能使用左括号,因为如果使用了右括号,就是不符合题意的括号,在它之前,没有与之配对的括号。

其实分析到这里,我们的思路就来了,是否可以使用左括号和右括号,其实是和已经使用的左右括号的个数相关的,因此我们在搜索的时候,就需要考虑这个因素。

我们再把刚才的分析概括一下:

  • 只要左括号还有剩余的数量,换句话说,只要左括号可以用,那么就可以在当前位置添加左括号;
  • 右括号的使用是有限制的,如果之前已经使用的左括号数量和右括号数量相等,那么当前就不能够使用右括号,原因我们刚刚也说了,如果使用了右括号,在之前就不能找到与之匹配的左括号。

根据这样的思路,我们可以把这张图画完。就是这样的一个树形结构。

(缺个图)

下面我们看一下如何编码:

根据我们刚才的分析,需要设计的状态变量有:

1、左括号还可以使用的个数;

2、右括号还可以使用的个数;

3、当前已经拼接出的字符串,可以理解为是一个路径变量

Java 代码:

import java.util.ArrayList;
import java.util.List;

public class Solution {

    // 做减法

    public List<String> generateParenthesis(int n) {
        List<String> res = new ArrayList<>();
        // 特判
        if (n == 0) {
            return res;
        }

        // 执行深度优先遍历,搜索可能的结果
        dfs("", n, n, res);
        return res;
    }

    /**
     * @param curStr 当前递归得到的结果
     * @param left   左括号还有几个可以使用
     * @param right  右括号还有几个可以使用
     * @param res    结果集
     */
    private void dfs(String curStr, int left, int right, List<String> res) {
        // 因为每一次尝试,都使用新的字符串变量,所以无需回溯
        // 在递归终止的时候,直接把它添加到结果集即可,注意与「力扣」第 46 题、第 39 题区分
        if (left == 0 && right == 0) {
            res.add(curStr);
            return;
        }

        // 剪枝(如图,左括号可以使用的个数严格大于右括号可以使用的个数,才剪枝,注意这个细节)
        if (left > right) {
            return;
        }

        if (left > 0) {
            dfs(curStr + "(", left - 1, right, res);
        }

        if (right > 0) {
            dfs(curStr + ")", left, right - 1, res);
        }
    }
}

这就是代码。

我们会发现,这个代码有一点点神奇,它好像没有回溯的过程。但事实上,这个题回溯的过程并不明显。这是因为在 Java 语言里:curStr + "(" 这个语法生成了新的字符串对象,因此,在递归的每一个结点,其实都生成了新的字符串对象。因此,在递归终止的时候,我们直接将字符串添加到结果集中就好了。

感兴趣的朋友,不妨使用 StringBuilder 字符序列这个类进行搜索,这样的操作就很像我们之前的搜索,就一定会有「撤销选择」和「状态重置」的操作。

但事实上,它们都是在树形问题上的深度优先遍历,都有「回到过去」的过程,大家依然是需要通过理解「深度优先遍历」这个过程去体会,我们使用「遍历」这个思想,特别是「深度优先遍历」去搜索整个状态空间的思路,它有一个「回到过去」的过程,就是通过回到过去,我们就可以尝试不同的选择,因此命名为「回溯」算法。

下面提供两道练习题:希望大家通过这两道练习,进一步体会这个思想。

练习

1、「力扣」第 17 题:电话号码的字母组合

2、「力扣」第 784 题: 字母大小写全排列


文章作者: liwei
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 liwei !
评论
 上一篇
「回溯算法」专题 4:组合问题 「回溯算法」专题 4:组合问题
「回溯算法」专题 4:组合问题再次体会分析递归结构的重要意义,画出树形图是关键。并且初步感知递归分支可以修建的情况。
下一篇 
「回溯算法」专题 2:从「全排列」问题开始认识「回溯算法」 「回溯算法」专题 2:从「全排列」问题开始认识「回溯算法」
「回溯算法」专题 2:从「全排列」问题开始认识「回溯算法」体会回溯的方法在求解排列问题中的应用,掌握使用数组记录每次走过的路的技巧,体会在这样的过程中状态重置的意义。 首先画出这个问题的树形结构。 所有符合条件的结点在这棵递归树的叶子结
  目录