回溯(组合问题)

259 阅读2分钟
  • 回溯,其本质就是穷举,突出一个简单粗暴但有用。

  • 可以理解为在N叉树遍历递归

  • 一般会用到回溯的问题

  • 组合问题:N个数里面按一定规则找出k个数的集合

  • 切割问题:一个字符串按一定规则有几种切割方式

  • 子集问题:一个N个数的集合里有多少符合条件的子集

  • 排列问题:N个数按一定规则全排列,有几种排列方式

  • 棋盘问题:N皇后,解数独等等

回溯法解决的问题都可以抽象为树形结构

因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度

其模版可以参考这个

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

组合问题

题目:77

  • 递归用来解决多重循环问题
  • 需要两个全局变量,一个存储所有符合条件的结果,一个存储单一的结果
    List<List<Integer>> result = new ArrayList<>();
    LinkedList<Integer> path = new LinkedList<>();
  • 递归返回值和参数:集合n里取k个数,得有k,这样才知道结果有没有取到。也要有n,这样可以知道范围到哪里。还要有一个startIdx,因为取值逻辑是从左到右不重复的取,设置一个开始索引,有利于去重
private void combineHelper(int n, int k, int startIndex)
  • 回溯终止条件:有了一个符合条件的子集,这里的终止条件就是列表的长度满足答案需求k了,所以可以返回
        if (path.size() == k){
            result.add(new ArrayList<>(path));
            return;
        }
  • 单层处理逻辑:for循环横向遍历,从startIdx开始遍历。该函数负责深入递归,同时也要记得回溯的操作——撤销本次结果
       for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){
            path.add(i);
            combineHelper(n, k, i + 1);
            path.removeLast();
        }
  • 这里用到 i <= n - (k - path.size()) + 1;是一种剪枝优化,因为规定的长度为k,所以startIndex最多只能到这里。不要担心组合结果的时候组合不到后面,这是递归,它递归到最后几层的时候当然会涉及到后面的值。

电话号码的字母组合

题目:17

  • 首先,这个电话号码,每一个号码就对应一个集合。所以该题与77 216不同的地方在于,另外两个怎么说都是在一个集合里的,这个是从多个独立的集合里找,所以startIndex倒是用不太着
  • 这里的特殊点为
  • 先弄一个映射集合,不同电话号码对应的集合,字符串数组......
String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
  • 涉及到字符串拼接,使用StringBuilder当作单个结果的数据结构(和之前题里的path功能一样)
StringBuilder temp = new StringBuilder();
  • 这里不是直接添加,而是先找到对应的字符串,再从该字符串里选单个字符串添加
  • 返回值和参数:参数要有给的电话号码字符串digits,还要有电话号码与字符的那个映射集合,而且还要有一个记录索引num,表示要取第几个电话号码了
public void backTracking(String digits, String[] numString, int num)
//str 表示当前num对应的字符串
        String str = numString[digits.charAt(num) - '0'];
  • 关于StringBuilder的操作:添加和删除
temp.append(str.charAt(i));
temp.deleteCharAt(temp.length() - 1);

组合总和问题

注意,这种找数字总和的,基本都要先对数组进行排序

组合总和III

题目:216

  • 同组合问题(77)差不多,只不过这个多了一个判定条件,path总和是否为特定值,所以需要有一个sum实时与特定值比较

    private void backTracking(int k, int n, int sum, int startIndex)

  • 单层处理逻辑多了判定条件,回溯时也要把sum的值也减小

组合总和

题目:39

  • 根据之前一个题的经验,这里对target做减法,然后回溯

target = target - candidates[i];

  • 因为允许重复数字,但是不允许重复数组,比如有一个错误结果就是[[2,2,3],[2,3,2],[3,2,2],[7]]
  • 会发现后两个[2,3,2] [3,2,2]都是往回倒了,因为此时的循环里,是从数组的第一个开始算起

for (int i = 0; i < candidates.length; i++)

  • 发现问题后进行修正,可以出现重复数字,但是不允许重复数组,换个意思就是可以原地踏步,但不能吃回头草,所以参数里要有一个start,表示从当前位置出发

private void backTracking(int[] candidates, int target, int start)

  • 而在递归时,开始位置就是当前位置 backTracking(candidates, target, i);
  • 返回条件,一个是达到了要求,target == 0,添加该答案。一个是多了,直接返回
if (target == 0) { 
       res.add(new ArrayList<>(temp));
       return;
   } 
if (target < 0) { 
       return;
   }
  • 这里和之前题不同的一点是可以重复选,所以回溯时的start直接是i,而之前那个是i+1

组合总和II

题目:40

  • 这一题卡在了去重上,它不是简单的去重,此前简单的去重是因为本身数组里无重复数字或者答案集合里不让有重复数字。
  • 但是这一题,数组里有重复数字,答案集合里只是不允许每个数字用两个,但是可能会有重复数字。听起来很绕,不过看一下题目介绍就能明白。
  • 所以重点在去重,其他的模版套路都差不多
  • 此前有一个去重的方法思路,利用索引然后比较当前数组值与它前一个数组值的大小
           if (i > start && candidates[i] == candidates[i - 1]) {
               continue;
           //这里的i > start,算是一种表示当前层的下一种可能,这时候i=start的情况已经向下回溯完了,
           回到最初的这一层去找下一种可能
                
            }
  • 这里也引入一个数组used,来表示该数字是否被使用过
  • 每次把used[i]也加入到回溯里就行,先used[i] = true,回溯后再变为false
  • 可以看出在candidates[i] == candidates[i - 1]相同的情况下:
  1. used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  2. used[i - 1] == false,说明同一树层candidates[i - 1]使用过