赞
踩
关于回溯,先用大白话说几个结论:
1.回溯能干什么?
主要解决那些暴力枚举也无法解决的问题。
2.回溯与递归是什么关系?
回溯是递归的纵横拓展,主要是递归(纵)+局部暴力枚举(横)。所以你可以从递归和暴力两个方面来拆解回溯问题。
3.回溯的关键在于“回”上,也就是要撤销,为什么要撤销?
因为回溯本质上仍然是枚举,你不喜欢她的前任,你要将她前任的所有东西都仍然,然后才愿意重新开始!就这么回事。
4.回溯经常看到“剪枝”,什么是剪枝,为什么要剪枝?
剪枝就是去掉那些不必要的递归,从而提高执行效率。假如有五个男孩子都和一个女生说要厮守终生。她会和这五个人都先过一辈子再确定谁会真正做到吗?当然不会。而是会先思考一下,有一个可能导出勾勾搭搭,那她可以认为这个人只是一时兴起,剪掉!有一个可能太窝囊了,什么都不会,那她可以认为和他在一起会非常累,剪掉!有一个长得太对不起祖宗,可能会影响下一代,剪掉!最后就剩下两个,再进一步考虑!这就是剪枝!
5.回溯问题很典型吗?
非常典型,主要是组合、分割、子集、排列,棋盘等以及拓展。
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式 棋
盘问题:N皇后,解数独等等。
6.回溯代码有模板吗?
有!基本模板是:
- void backtracking(参数) {
- if (终止条件) {
- 存放结果;
- return;
- }
- for (选择本层集合中元素(画成树,就是树节点孩子的大小)){
- 处理节点;
- backtracking();
- 回溯,撤销处理结果;
- }
- }
7.上面的模板中本层集合元素是怎么确定的?怎么画成树?
大部分回溯问题的集合与如下的结构类似,而确定集合元素就是枚举任一元素的所有可能,有几种可能,就对应第二层就有几个结点。
看完之后是不是感觉没有想象中那么难了?
我们知道在二叉树中,按照前序遍历的过程如下所示:
- 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叉树。
我们前面说回溯主要解决一些暴力枚举也无法解决的问题,什么问题这么神奇,暴力都无法解决呢?看个例子:
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}
这就是我们思考该问题的基本过程,写成代码也很容易,双层循环轻松搞定:
- 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,你怎么写循环呢?是不是发现这时候已经无能为例了?所以暴力搜索就不行了。
这种类型的问题就要考虑使用回溯了,这就是组合问题,除此之外子集、排列、切割、棋盘等方面都有类似的问题。
前面说回溯就是对递归进行纵横拓展,纵向就是递归,用来解决多层嵌套问题,因此有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();
我们一个个解释。
我们可以看到在递归里有个循环
- 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}
所以说这个for循环解决的就是“横”向宽度的问题。
回溯最难理解的部分是这个回溯过程:
- 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撤销操作。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。