羊羊刷题笔记Day27/60 | 第七章 回溯算法P3 | 39. 组合总和、40. 组合总和II、131. 分割回文串

162 阅读11分钟

39 组合总和

可多次使用元素,无重复元素

思路

本题与77.组合的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
回忆一下做过的组合题目 组合但不可重复 - 组合不可重复且有总和限制 - 自由组合且有总和限制
本题搜索的过程抽象成树形结构如下:
image.png 注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回
与前面的终止条件为 k == path.size() 不同

回溯三部曲

  • 递归函数参数

这里依然是定义两个全局变量**,二维数组result存放结果集,数组path存放符合条件的结果**。
首先是题目中给出的参数,集合candidates, 和目标值target。
此外为了更好理解,再定义int型的sum变量来统计单一结果path里的总和,(其实这个sum也可以不用,用target做相应的减法就可以了,最后如何target==0就说明找到符合的结果了
本题还需要startIndex来控制for循环的起始位置。

结合之前的题目,**什么时候需要startIndex呢?**如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17. 电话号码的字母组合。如果在同一个集合中取值,则需要考虑元素重复问题,而去使用startIndex

代码如下:

private LinkedList<Integer> list = new LinkedList<>();
private List<List<Integer>> result = new ArrayList<>();
private void backtracking(int[] candidates, int target, int sum, int startIndex) {
  • 递归终止条件

在如下树形结构中:
image.png
从叶子节点可以清晰看到,终止只有两种情况,sum大于targetsum等于target
sum等于target的时候,需要收集结果,代码如下:

// 终止条件 - 等于(找到了 加入) 大于(结束)
if (sum == target) {
    result.add(new ArrayList<>(list));
    return;
}
if (sum > target) return;
  • 单层搜索的逻辑

单层for循环依然是从startIndex开始,搜索candidates集合。
注意本题和前面题目的一个区别是:本题元素为可重复选取的
如何重复选取呢,看代码,注释部分:

for (int i = startIndex;i <= candidates.length - 1;i++){
    list.add(candidates[i]);
    sum += candidates[i];
    backtracking(candidates, target, sum, i);// 由于可以重复,所以还是传i
    sum -= candidates[i];
    list.removeLast();
}

完整代码如下:

class Solution {
    private LinkedList<Integer> list = new LinkedList<>();
    private List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        int startIndex = 0;
        int sum = 0;
        backtracking(candidates, target, sum, startIndex);

        return result;
    }

    private void backtracking(int[] candidates, int target, int sum, int startIndex) {
        // 终止条件 - 等于(找到了 加入) 大于(结束)
        if (sum == target) {
            result.add(new ArrayList<>(list));
            return;
        }
        if (sum > target) return;

        for (int i = startIndex;i <= candidates.length - 1;i++){
            list.add(candidates[i]);
            sum += candidates[i];
            backtracking(candidates, target, sum, i);// 由于可以重复,所以还是传i
            sum -= candidates[i];
            list.removeLast();
        }


    }
}

剪枝优化

在这个树形结构中:(❗注意需要首先对数组元素进行排序
image.png
以及上面逻辑的代码大家可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归只是下一层递归结束判断的时候,会判断sum > target的话就返回
其实如果已经知道下一层的sum会大于target,就没有必要再循环进入下一层递归了
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一次循环的sum(就是 sum + candidates[i])已经大于target,后面的计算已经没有意义了,就结束本轮for循环的遍历
如图:
image.png
for循环剪枝代码如下:

for (int i = startIndex;i <= candidates.length - 1 && sum + candidates[i] <= target;i++){}

整体代码如下:

class Solution {
    private LinkedList<Integer> list = new LinkedList<>();
    private List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        int startIndex = 0;
        int sum = 0;
        Arrays.sort(candidates); // 对数组进行排序
        backtracking(candidates, target, sum, startIndex);

        return result;
    }

    private void backtracking(int[] candidates, int target, int sum, int startIndex) {
        // 终止条件 - 等于(找到了 加入) 大于(结束)
        if (sum == target) {
            result.add(new ArrayList<>(list));
            return;
        }
        if (sum > target) return;// 剪枝后这行其实可以删去,在for循环中已判断

        // 剪枝操作:如果已经大于target后面相加也没有意义,结束循环
        for (int i = startIndex;i <= candidates.length - 1 && sum + candidates[i] <= target;i++){
            list.add(candidates[i]);
            sum += candidates[i];
            backtracking(candidates, target, sum, i);// 由于可以重复,所以还是传i
            sum -= candidates[i];
            list.removeLast();
        }

    }
}
  • 时间复杂度: O(n * 2n),注意这只是复杂度的上界,因为剪枝的存在,真实的时间复杂度远小于此
  • 空间复杂度: O(target)

总结

本题要注意有两点不同:

  • 组合没有数量要求
  • 元素可无限重复选取

针对这两个问题,我都做了详细的分析。
并且对比17. 电话号码的字母组合对组合问题,要区分什么时候用startIndex,什么时候不用,并用做了对比。
最后还给出了本题的剪枝优化,(这个优化如果是初学者的话并不容易想到)
在求和问题中,排序之后加剪枝是常见的套路!

40 组合总和Ⅱ

可以多次使用元素**,有重复元素,但组合不能重复**

思路

对比上一题 39. 组合总和,这题目:

  1. 本题candidates 中的每个数字在每个组合中只能使用一次
  2. 本题数组candidates的元素是有重复的,而上一题是无重复元素的数组candidates
  3. 虽然元素有重复,但解集不能包含重复的组合,也是本题的难点

基于第三点,一开始尝试过把所有组合求出来,再用set去重,然而在面对极端的数据时超时
所以要在搜索的过程中就去掉重复组合。
所谓去重,其实就是使用过的元素不能重复选取。
但这里要注意的点是:组合问题可以抽象为树形结构,那么**“使用过”在这个树形结构上是有两个维度的**,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。(理解这两个层面上的“使用过” 对写代码逻辑时非常重要!
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。(即组内可重复,组间不可重复
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重
举个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示,观察蓝字部分
image.png
可以看到图中,每个节多加了used数组,用于记录元素是否被使用过。这个used数组也是判断是否树层还是树枝重复的重要依据

回溯三部曲

  • 递归函数参数

与上一题套路相同,此题还需要加一个boolean型数组used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是used来完成的。
代码如下:

private LinkedList<Integer> path = new LinkedList<>();
private List<List<Integer>> result = new ArrayList<>();
private void backtracking(int[] candidates, int target, int startIndex, int sum, boolean[] used) {}
  • 递归终止条件

总和大于目标和返回,等于目标和记录。
代码如下:

if (sum == target && !result.contains(path)) {
    result.add(new ArrayList<>(path));
    return;
}
if (sum > target) return;
  • 单层搜索的逻辑

这里与上一题最大的不同就是要去重了。
前面提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个条件满足代表有重复元素,但有可能是树层或树枝重复出现,而后一个条件满足代表是树层间出现重复元素
此时for循环里就应该做continue的操作。
这块比较抽象,如图(关注蓝字):
image.png
在图中将used的变化用橘黄色标注上,如上面所说,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

具体used变化如下图(关注回溯过程):
image.png
那么单层搜索的逻辑代码如下:

for (int i = startIndex; i <= candidates.length - 1 && sum + candidates[i] <= target; i++) {
    // “树层”剪枝 - 条件12:不能使用重复元素 - 条件3:限定为树层重复(树枝重复是被允许的)
    if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false){
        continue;
    }
    path.add(candidates[i]);
    sum += candidates[i];
    used[i] = true;
    backtracking(candidates, target, i + 1, sum, used);
    used[i] = false;
    sum -= candidates[i];
    path.removeLast();
}

注意:先排序后判断sum + candidates[i] <= target为剪枝操作,与上一题“剪枝优化”类似
因此整体代码如下:
与上一题类似,区别在于其树层和树枝去重 - 在for循环里判断以及每次遍历要改变used数组 其他逻辑与上一题类似

class Solution {
    private LinkedList<Integer> path = new LinkedList<>();
    private List<List<Integer>> result = new ArrayList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        int startIndex = 0;
        int sum = 0;
        boolean [] used = new boolean[candidates.length];
        Arrays.sort(candidates);
        Arrays.fill(used,false);
        backtracking(candidates,target,startIndex,sum,used);
        return result;
    }

    private void backtracking(int[] candidates, int target, int startIndex, int sum, boolean[] used) {
        if (sum == target && !result.contains(path)) {
            result.add(new ArrayList<>(path));
            return;
        }
        if (sum > target) return;

        for (int i = startIndex; i <= candidates.length - 1 && sum + candidates[i] <= target; i++) {
            // “树层”剪枝 - 条件12:不能使用重复元素 - 条件3:限定为树层重复(树枝重复是被允许的)
            if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false){
                continue;
            }
            path.add(candidates[i]);
            sum += candidates[i];
            used[i] = true;
            backtracking(candidates, target, i + 1, sum, used);
            used[i] = false;
            sum -= candidates[i];
            path.removeLast();
        }
    }
}
  • 时间复杂度: O(n * 2^n)
  • 空间复杂度: O(n)

另外也可以用startIndex进行去重👈

总结

本题同样是求组合总和,但就是因为其数组candidates有重复元素,而要求不能有重复的组合,所以相对上一题难度提升了不少。

131 分割回文串

哪些情况可以重复,哪些情况不能重复

思路

本题这涉及到两个关键问题:

  1. 切割问题,有不同的切割方式
  2. 判断回文

那么回溯究竟是如何切割字符串呢?
例如对于字符串abcdef:

  • 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
  • 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。

所以切割问题,也可以抽象为一棵树形结构,如图:
image.png
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。

回溯三部曲

  • 递归函数参数

全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
同样,startIndex已经在之前碰到过,同一集合需要,不同集合间不需要。
代码如下:

List<List<String>> result = new ArrayList<>();
LinkedList<String> path = new LinkedList<>();
private void backtracking(String s, int startIndex) {}
  • 递归函数终止条件

image.png
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,并且startIndex恰好就是切割线。
所以终止条件代码如下:

// 终止条件 - 分割点(startIndex)大于数组长度(判断是否是回文在递归逻辑中判断)
if (startIndex >= s.length()) {
    result.add(new ArrayList<>(path));
    return;
}
  • 单层搜索的逻辑

来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串
首先判断这个子串是不是回文,如果是回文,就加入在 path中,path用来记录切割过的回文子串。
代码如下:

for (int i = startIndex; i < s.length(); i++) {
    // 判断是否回文字串,是则记录
    if (isPalindrome(s, startIndex, i)) {
        // 收集回文区间
        String str = s.substring(startIndex, i + 1);
        path.addLast(str);
        // 继续向下递归
        backtracking(s, i + 1);
        // 回溯
        path.removeLast();
    }
    // 如果不是回文则继续切割
}

注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1

判断回文子串方法

最后我们看一下isPalindrome()判断回文子串方法。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
是前面双指针的知识了,比较简单。代码如下:

private boolean isPalindrome(String s, int startIndex, int end) {
    // 头尾指针,判断是否对称
    for (int i = startIndex,j = end;i < j;i++,j--){
        if (s.charAt(i) != s.charAt(j)){
            return false;
        }
    }

    return true;
}

因此,整体代码如下:

class Solution {
    List<List<String>> result = new ArrayList<>();
    LinkedList<String> path = new LinkedList<>();

    // 主函数
    public List<List<String>> partition(String s) {
        int startIndex = 0;
        backtracking(s, startIndex);
        return result;

    }

    // 回溯算法
    private void backtracking(String s, int startIndex) {
        // 终止条件 - 分割点(startIndex)大于数组长度(判断是否是回文在递归逻辑中判断)
        if (startIndex >= s.length()) {
            result.add(new ArrayList<>(path));
            return;
        }

        for (int i = startIndex; i < s.length(); i++) {
            // 判断是否回文字串,是则记录
            if (isPalindrome(s, startIndex, i)) {
                // 收集回文区间
                String str = s.substring(startIndex, i + 1);
                path.addLast(str);
                // 继续向下递归
                backtracking(s, i + 1);
                // 回溯
                path.removeLast();
            }
            // 如果不是回文则继续切割
        }

    }

    // 判断是否回文字符串
    private boolean isPalindrome(String s, int startIndex, int end) {
        // 头尾指针,判断是否对称
        for (int i = startIndex,j = end;i < j;i++,j--){
            if (s.charAt(i) != s.charAt(j)){
                return false;
            }
        }

        return true;
    }
}

优化方法见这里👈

总结

这道题目在leetcode上是中等,但可以说是hard的题目了,但是代码其实就是按照模板的样子来的。
做题时需要注意以下难点:

  • 切割问题可以抽象为组合问题
  • 如何模拟那些切割线
  • 切割问题中递归如何终止
  • 在递归循环中如何截取子串
  • 如何判断回文

第一个难点比较难想:不知道如何切割,甚至知道要用回溯法,也不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割
如果意识到这一点,算是重大突破了。接下来就可以对着模板照葫芦画瓢。
但接下来如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了
关于模拟切割线,其实就是index是上一层已经确定了的分割线,i是这一层试图寻找的新分割线
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1

学习资料:

39. 组合总和

40.组合总和II

131.分割回文串