回溯算法
- 《代码随想录》
- 什么是回溯算法?
- 回溯算法也可以叫做回溯搜索法,它是一种搜索方式。
- 回溯是递归的副产品,只要有递归就会有回溯。
- 回溯法的效率:
- 回溯法的本质是穷举,穷举所有可能,然后选出我们想要的答案。(n层for循环嵌套)
- 如果想让回溯法更高效一些,可以加一些剪枝操作,但也无法改变回溯法就是穷举的本质。
- 回溯法一般可以解决如下几种问题:
- 组合问题: N个数里面按一定规则找出K个数的集合
- 切割问题: 一个字符串按一定规则由于几种切割方式
- 子集问题: 一个N个数的集合里有多少符合条件的子集
- 排列问题: N个数按一定规则全排列,有几种排列方式。
- 棋盘问题: N皇后,解数独等
- 如何理解回溯法?
- 回溯法解决的都可以抽象为树型结构。
- 因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度构成了树的深度。
- 递归要有终止条件,所以必然是一棵高度有限的树(N叉树)
- 回溯模板
- for循环横向遍历,递归 纵向遍历,回溯不断调整结果集。
- 性能分析
- 组合问题分析
- 时间复杂度: $O(n* 2^n)$
- 组合问题其实就是一种子集问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度: $O(n)$
- 和子集问题同理。
- 子集问题分析
- 时间复杂度: $O(n * 2^n)$
- 每种元素状态无非选与不选,所以时间复杂度为&O(2^n)$;
- 构造每一组子集都需要填进数组,又需要$O(n)$;
- 所以最终时间复杂度为: $O(n*2^n)$
- 空间复杂度: $O(n)$
- 递归深度为n,所以系统栈所用空间为$O(n)$
- 每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传引用,并不会重新申请内存。
- 排列问题
- 时间复杂度: $O(n!)$
- 这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n n-1 n-2 * ..... 1 = n!。每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:result.push_back(path)),该操作的复杂度为$O(n)$。
- 所以,最终时间复杂度为:n * n!,简化为$O(n!)$。
- 空间复杂度: $O(n)$
- 和子集问题同理。
77. 组合
- 回溯法的经典题目
- 图解如下图所示:
- 剪枝优化,将for循环控制条件改为
216.组合总和III
- 模板题,在上一题的基础上,在收集结果的时候增加是否等于目标值的判断。
- 优化剪枝
17. 电话号码的字母组合
- 注意题目的意思是,根据这几个数字所对应的字母进行组合。几个数字,每个组合就有几个元素。
- 从每个数字对应的元素中取一个。然后组合。
- 与上面题的不同,本体每一个数字代表的是不同的组合,也就是求不同集合之间的组合,而上面两道题,都是都同一个集合中的组合。
- 隐藏回溯细节
- 增加参数s,每次修改仅在调用递归函数传参时修改,递归结束返回回来,原值并未被修改。从而达到回溯效果。
39. 组合总和
- 注意题目要求,所有元素不限制选取次数。在我们实际的代码中,要修改模板的控制下标。
- 方法2: 无序排序,递归后,收集结果前判断,是否超过目标,超过目标值终止计算,返回,继续选取下一个值。因为没排序,后面可能还有符合条件的。
注意对比与方法1终止条件位置的不同。
- 错误提示:
- 参数startIndex,传递的时候不同于上面的两题,而是传入当前收集的值的下标,i,不是i+1,为了继续重复选取当前元素。
- 开始我每次传的都是0,这样会造成重复的组合。
40.组合总和II
- 在上一题的基础上增加去重
- 去重1: 使用startIndex去重
- 使用used数组去重——
下面的90题子集II有更详细的去重解释。
- 同一个树枝上的元素可以重复,同一个树层上的元素不可以重复。去的就是同一个树层的重。
- 详见代码中的注释。
131.分割回文串
- 方法2: 相对于方法2来说,方法1更直观一些。
- 可结合下图理解,当前元素,选与不选的这个过程。
- startIndex既是边界,也是当前要操作的元素。
- 方法1,是通过使用used数组来标记当前元素(startIndex下标所对应的元素)选还是不选,最后到达边界,遍历原数组,统一收集结果path,在放入最终结果集ret中。
- 方法2,则是将选的元素直接收集进path数组中,不选了,也就是回溯,再将其pop出来,此时不断移动边界startIndex直至到达边界。到达边界后,再将小数组path放进结果集ret中。
90. 子集 II
- 开始没想明白这跟上面那个题有什么区别,我说上面那个没说不让重复不也没重吗,其实不对,因为上面那个给的数据就不是重复的,所以求子集没重复,这题给的数据出现重复的元素了,所以就需要增加一个去重操作。
相当于40题组合总和II中,我们使用的used数组,是一个意思,但是我觉得那样的意思容易让人弄混,容易直译数组名used的含义,所以我们这里将其改为int型数组,1表示当前树枝上使用了,2表示当前树层上使用了,0表示暂未考虑。
- 上方划线文字作废,就按照true表示当前树枝使用过,false表示当前树层使用过就行。
- 如下方图解所示,理解起来稍微有一点抽象,可以画图走一遍,下图仅给出第一次选1的分支。
- 仔细想一下,其实不难。
- 就是先确定一点,即,这个树枝上第一个元素,我们称为"父亲",然后再往后选剩下的每一个数,即"孩子"
- 重复的情况就是,当前这个元素上当前这个元素和前一个元素相同,并且前一个元素不是父亲元素(i > 0),——说明,当前父亲和孩子的组合,出现过了,就是重复啦,去掉。
- 那你可能就问了,那后面也是重复的吗,当然,再往后选孩子的孩子,也是重复的。因为在上一个"树枝"已经选取过了。
- 如下图所示意:
- 方法2: 使用下标去重,看起来更简单些,但思想同使用used数组。此方法更贴切本题中第二张图。
- 方法3: 使用set去重,去重判断同理。不做过多解释。
- 有趣的一点也是重要的一点是,注意,是先判断是出现过,然后在往set里emplace,这步就相当于使用used数组中的。可以对照理解一下。是一个意思。
491.递增子序列
本题无法像上一题那样简单的使用used数组(bool数组)进行去重。因为并不是简单的对比前一个元素,因为前一个元素不一定就可以放进去,也就是说如果(可以的话)仍使用上面used数组去重方式,逻辑会更加复杂。- 方法1: 使用set去重,同上题使用set的方法,如下图所示,不理解就跟着走一下。
- 方法2: 使用用数组模拟哈希表来去重
46. 全排列
相对于上面使用used数组去重,本题会更好理解些,我们使用used数组,仅仅来记录当前元素是否被使用过,而且不需要indexStart来记录每层递归开始的位置,而是每次都从头开始,从而收集所有的排列方式。
主要的一点是,不会给重复的数,以此为去重问题,见下题。
给上面的补充,写在这里,每一层,是指对应树枝的下面的每一层,不是整体的每一层。
47.全排列 II
- 在上一题的基础上,给出重复的元素,需要增加去重。类似于上面求子集问题中的去重。
- 在去重逻辑中,当前元素与前一位相等,并且前一位对应used为false,去掉。
- 还是求子集问题中的去重道理。不过可以更简单的理解为,重复的数,以其为选中基准,重新排列组合出来的结果也是相同的(因为顺序固定了)。所以要去掉。——树层去重
先选中的这个数为下面树层的根。
- 方法1: 树层去重
- 方法2: 树枝去重
即,对于树的每一个树枝来说,当我们确定了一个树层的根后,下面不可以再选与其相同的子根。
- 树层去重和树枝去重对比
- 可以很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位的去重虽然最后可以得到答案,但是做了很多无用搜索。
- 方法3: 使用set去重
- 注意:
- 使用set去重的版本相对于used数组的版本效率会低很多。
- 因为频繁的对unordered_set进行insert操作,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且insert的时候其底层的符号表也要做相应的扩充,也是费时的。
332. 重新安排行程
- 就是看能不能把所有票都用上。走完全程。
第51题. N皇后
- 时间复杂度: $O(n!)$
- 空间复杂度: $O(n)$,和子集问题同理
- 皇后们的要求
- 不能同行
- 不能同列
- 不能同斜线,45 & 135
37. 解数独
时间复杂度: $O(9^m)$,m是'.'的数目
空间复杂度: $O(n^2)$,递归的深度是$O(n^2)$
判断棋盘是否合法
- 同行是否重复
- 同列是否重复
- 9宫格里是否重复
就是一个萝卜一个坑,理论上来说有几个空白位置就会递归几层。递归只是为了找到坑位,不会再向后移动,搜寻别的坑。详见下方注释。
- 这个回溯拖得时间还是比较长了,抓紧抓紧!Orz
评论区