递归与回溯4:一文彻底理解回溯

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了递归与回溯4:一文彻底理解回溯相关的知识,希望对你有一定的参考价值。

1.回溯大白话

关于回溯,先用大白话说几个结论:

1.回溯能干什么?

主要解决那些暴力枚举也无法解决的问题。

2.回溯与递归是什么关系?

回溯是递归的纵横拓展,主要是递归(纵)+局部暴力枚举(横)。所以你可以从递归和暴力两个方面来拆解回溯问题。

3.回溯的关键在于“回”上,也就是要撤销,为什么要撤销?

因为回溯本质上仍然是枚举,你不喜欢她的前任,你要将她前任的所有东西都仍然,然后才愿意重新开始!就这么回事。

4.回溯经常看到“剪枝”,什么是剪枝,为什么要剪枝?

剪枝就是去掉那些不必要的递归,从而提高执行效率。假如有五个男孩子都和一个女生说要厮守终生。她会和这五个人都先过一辈子再确定谁会真正做到吗?当然不会。而是会先思考一下,有一个可能导出勾勾搭搭,那她可以认为这个人只是一时兴起,剪掉!有一个可能太窝囊了,什么都不会,那她可以认为和他在一起会非常累,剪掉!有一个长得太对不起祖宗,可能会影响下一代,剪掉!最后就剩下两个,再进一步考虑!这就是剪枝!

5.回溯问题很典型吗?

非常典型,主要是组合、分割、子集、排列,棋盘等以及拓展。

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

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

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

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

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

6.回溯代码有模板吗?

有!基本模板是:

    void backtracking(参数) 
        if (终止条件) 
            存放结果;
            return;
        
        for (选择本层集合中元素(画成树,就是树节点孩子的大小))
            处理节点;
            backtracking();
            回溯,撤销处理结果;
        
    

7.上面的模板中本层集合元素是怎么确定的?怎么画成树?

大部分回溯问题的集合与如下的结构类似,而确定集合元素就是枚举任一元素的所有可能,有几种可能,就对应第二层就有几个结点。

看完之后是不是感觉没有想象中那么难了? 

2.从N叉树开始聊回溯

我们知道在二叉树中,按照前序遍历的过程如下所示:

public static void treeDFS(TreeNode root) 
    if (root == null)
        return;
    System.out.println(root.val);
    treeDFS(root.left);
    treeDFS(root.right);

假如我现在是一个三叉、四叉甚至N叉树该怎么办呢?很显然这时候就不能用left和right来表示分支了,使用一个List比较好,也就是大致这样来遍历;

public static void treeDFS(TreeNode root) 
    //递归必须要有终止条件
    if (root == null)
        return;
    System.out.println(root.val);

    //通过循环,分别遍历N个子树
    for (int i = 1; i <= N; i++) 
        //2,一些操作,可有可无,视情况而定

        treeDFS("第i个子节点");

        //3,一些操作,可有可无,视情况而定
    

到这里,你有没有发现和刚刚说的回溯的模板非常像了?是的!基本框架非常像。

这里的参数和某些代码的含义等暂且不提,只是先告诉自己,回溯的框架就是遍历N叉树。

3.为什么有的问题暴力搜索也不行了?

我们前面说回溯主要解决一些暴力枚举也无法解决的问题,什么问题这么神奇,暴力都无法解决呢?看个例子:

LeetCode77 :给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。例如,输入n=4,k=2,则输出:

[
[2,4], [3,4], [2,3], [1,2], [1,3], [1,4]
]

如果让我们说过程,就是这样:n=4,那就是所有的数字为1,2,3,4

  1. 先取一个1,则有[1,2],[1,3],[1,4]三种可能。
  2. 然后取一个2,因为1已经取过了,所以不再取,则有[2,3],[2,4]两种可能。
  3. 再取一个3,因为1和2都取过了,所以不再取,则有[3,4]一种可能。
  4. 再取4,因为1,2,3都已经取过了,所以直接返回null。

这就是我们思考该问题的基本过程,写成代码也很容易,双层循环轻松搞定:

 int n = 4;
        for (int i = 1; i <= n; i++) 
            for (int j = i + 1; j <= n; j++) 
                System.out.println(i + " " + j);
            
        

假如n和k都变大,比如n是200,k是3呢?也可以,三层循环基本搞定:

 int n = 200;
  for (int i = 1; i <= n; i++) 
       for (int j = i + 1; j <= n; j++) 
            for (int u = j + 1; u <= n; n++) 
               System.out.println(i + " " + j + " " + u);
             
        
   

如何这里的K是5呢?甚至是50呢?你需要套多少层循环?甚至告诉你K就是一个未知的正整数k,你怎么写循环呢?是不是发现这时候已经无能为例了?所以暴力搜索就不行了。

这种类型的问题就要考虑使用回溯了,这就是组合问题,除此之外子集、排列、切割、棋盘等方面都有类似的问题。

4 回溯=递归+局部暴力枚举

前面说回溯就是对递归进行纵横拓展,纵向就是递归,用来解决多层嵌套问题,因此有for有几层嵌套,递归就有几层,在这里就是K是多少就多少次递归。横向就是枚举每个点都有的情况。

这句话仍然抽象,我们图示一下上面我们自己枚举时的过程。

 n=4时,我们可以选择的n有 1,2,3,4这四种情况,所以我们从第一层到第二层的分支有四个,分别表示可以取1,2,3,4。而且这里 从左向右取数,取过的数,不在重复取。 第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。到了取4时就直接为空了。

那么如何在这个树上遍历,然后收集到我们要的结果集呢? 很显然图中每次搜索到了叶子节点,我们就找到了一个结果。虽然最后一个是空,但是不影响结果。这里相当于只需要把从根节点开始每次选择的内容(分支)达到叶子节点时的结果收集起来,就可以求得 n个数中k个数的组合集合。

从图中可以发现n相当于树的宽度,k相当于树的深度。这里我们就将回溯模型与N叉树建立了联系。

当然这里的树和我们理解的N叉树还有有些区别的,每个元素节点的值不一定是一个。所以递归的完整代码就是如下:

    public List<List<Integer>> combine(int n, int k) 
        List<List<Integer>> res = new ArrayList<>();
        if (k <= 0 || n < k) 
            return res;
        
//        用户返回结果
        Deque<Integer> path = new ArrayDeque<>();
        dfs(n, k, 1, path, res);
        return res;
    

    public void dfs(int n, int k, int startIndex, Deque<Integer> path, List<List<Integer>> res) 
        // 递归终止条件是:path 的长度等于 k
        if (path.size() == k) 
            res.add(new ArrayList<>(path));
            return;
        

        // 针对一个结点,遍历可能的搜索起点,其实就是枚举
        for (int i = startIndex; i <= n; i++) 
            // 向路径变量里添加一个数,就是上图中的一个树枝的值
            path.addLast(i);
            // 搜索起点要加1是为了缩小范围,下一轮递归做准备,因为不允许出现重复的元素
            dfs(n, k, i + 1, path, res);
            // 递归之后需要做相同操作的逆向操作,具体后面继续解释
            path.removeLast();
        
    

这段代码的框架就是遍历N叉树,有两个地方可能比较难理解:

1.startIndex和i是怎么变化的,为什么传给下一层是要加1。

2.后面为什么会有一个回溯的操作 path.removeLast();

我们一个个解释。

4.1深入分析startIndex的变化

我们可以看到在递归里有个循环

 for (int i = startIndex; i <= n; i++) 
    dfs(n,k,i+1,path,res);

这里的循环有什么作用呢?看一下图就知道了,这里其实就是枚举,第一次n=4,可以选择1 ,2,3,4四种情况,所以就有四个分支,for循环就会执行四次:

 而对于第二层第一个,选择了1之后,剩下的元素只有2 ,3, 4了,所以这时候for循环就执行3。后面的则只有2次和1次。

那这里为什么第二层的结点数从左向右依次减少,到最后只剩空了呢?这里其实就是我们上面说的暴力过程的前半部分:

如果让我们说过程,就是这样:n=4,那就是所有的数字为1,2,3,4

  1. 先取一个1,则有[1,2],[1,3],[1,4]三种可能。
  2. 然后取一个2,因为1已经取过了,所以不再取,则有[2,3],[2,4]两种可能。
  3. 再取一个3,因为1和2都取过了,所以不再取,则有[3,4]一种可能。
  4. 再取4,因为1,2,3都已经取过了,所以直接返回null。

所以说这个for循环解决的就是“横”向宽度的问题。

4.2 为什么有个撤销的操作

回溯最难理解的部分是这个回溯过程:

  path.addLast(i);
  dfs(n, k, i + 1, path, res);
  path.removeLast();

最后为什么要remove这个撤销操作呢?直接说原因,就是当从一个分支跳到另一个分支的时候,如果不把前一个分支的数据给移除掉,那么list就会把前一个分支的数据带到下一个分支去,造成结果错误。具体怎么回事呢?我们可以对照结构图, 用我们拆解递归的方法,将递归拆分成函数调用,会发现输出第一条路径1,2的步骤如下如下::

对应的函数执行过程如下

 然后呢?1,2输出 之后会怎么执行呢?回归之后,假如我们将remove代码去掉,也就是这样子:

 注意上面的4号位置结束之后会让让一层递归执行for循环体,也就是上面的5。进入5之后,接着开始执行第6步:path.addLast(i)了,此时path的大小是3,元素是1,2,3,为什么会这样呢?

因为path是一个全局的引用,各个递归函数公用的,所以当1,2处理完之后,2污染了path变量。我们希望将1保留而将最后一个2给干掉,然后让3进来,所以这时候需要手动remove一下。

同样3处理完之后,我们也不希望3污染接下来的1,4,1全部走完之后也不希望1污染接下来的2,3等等,这就是为什么回溯里会在递归之后有一个remove撤销操作。

以上是关于递归与回溯4:一文彻底理解回溯的主要内容,如果未能解决你的问题,请参考以下文章

算法入门(回溯算法)

一文通数据结构与算法之——回溯算法+常见题型与解题策略+Leetcode经典题

2023-04-19 算法面试中常见的递归和回溯问题

Java数据结构与算法——递归与回溯

[XJTUSE 算法设计与分析] 第五章 回溯法

Leetcode之深度遍历递归与回溯法汇总