回溯(backtrack)描述
前言
回溯描述。
一、核心概念
- 在深度优先搜索(DFS)的递归过程中回溯:回溯问题可抽象为树形结构,递归向下处理和获取结果,回溯向上撤销结果
- 相对于暴力算法,是一种间接/有技巧(使用递归代替多重循环)的暴力搜索/穷举,不高效
二、典型问题
分类依据:代码随想录 (programmercarl.com)和力扣
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 图问题:图中使用深度优先遍历
- 棋盘问题:N皇后,解数独等等
三、算法步骤
- 将回溯问题抽象为树形结构
- 问题规模为树的宽度
- 递归深度为树的深度
- 确定回溯函数的参数和返回值
- 可依据逻辑需要后补充参数
可能的参数:
候选集合
问题规模为树的宽度width
递归深度为树的深度depth
当前结点问题规模的起始索引start_index
标记集合中元素是否已选取的数组
结果集合res和results
- 返回值一般为空(void)
- 确定回溯函数的终止条件和获取结果逻辑
- 在树的叶子结点获取结果
- 在树的叶子结点结束当前层递归,并回溯撤销结果
注意:子集问题,在树的每结点获取结果
- 确定回溯函数的遍历和处理结果逻辑
- 横向遍历当前树层各结点的问题规模
- 纵向递归处理结果
四、模板
1 | // 0. 将回溯问题抽象为树形结构 |
五、模板示例
77.组合 - 力扣(LeetCode)——中等的题解:
1 | class Solution |
六、性能分析
时间复杂度:指数级
组合问题:
- 时间复杂度:一般为:组合规模 × 每组合判断并加入结果集的时间
如:$O(\complement^n_m × n)$。从m规模中取n规模,有$\complement^n_m$种组合;每组合判断并加入结果集的时间需要O(n)
如:$O(2^n × n)$。从n规模中取或不取,有$2^n$种组合;每组合判断并加入结果集的时间需要O(n)
- 空间复杂度:一般为:递归栈规模 + 结果集规模:$O(n)$。n为数据规模
切割和子集问题:类似组合问题
排列问题:全排列
- 时间复杂度:一般为:全排列规模 × 每排列判断并加入结果集的时间:$O(n!×n)$。n为数据规模
参见:
回溯算法入门级详解 + 练习(持续更新) - 全排列 - 力扣(LeetCode)
本周小结!(回溯算法系列三) | 代码随想录 (programmercarl.com)
- 空间复杂度:类似组合问题
图和棋盘问题:具体问题具体分析
七、优化方法
1. 剪枝
作用:降低时间复杂度
描述:
- 不符合条件的问题
- 不再继续处理并获取结果
形式:
横向剪枝:缩小当前树层各结点的问题规模:修改for()循环
纵向剪枝:增加终止条件
模板示例:216.组合总和 III - 力扣(LeetCode)——中等的题解:
1 | class Solution |
八、其他技巧
1. 元素不可以和可以重复选取
- 在同一集合中,若元素不可以重复选取,则在递归时,修改当前结点问题规模的起始索引start_index
如:77.组合 - 力扣(LeetCode)——中等
- 在同一集合中,若元素可以重复选取,则在递归时,不修改当前结点问题规模的起始索引start_index
如:39.组合总和 - 力扣(LeetCode)——中等
2. 从同一集合中取和从不同集合间取元素
- 从同一集合中取元素,需要参数:当前结点问题规模的起始索引start_index
如:77.组合 - 力扣(LeetCode)——中等
- 从不同集合间取元素,可能不需要参数:当前结点问题规模的起始索引start_index
如:17.电话号码的字母组合 - 力扣(LeetCode)——中等
3. 求和问题
- 在求和问题中,可能需要先排序后剪枝
如:39.组合总和 - 力扣(LeetCode)——中等
4. 先排序后去重
排序后才可以,通过相邻元素判断是否重复选取
- 树层去重
如:40.组合总和 II - 力扣(LeetCode)——中等
如:47.全排列 II - 力扣(LeetCode)——中等
- 树枝去重
如:47.全排列 II - 力扣(LeetCode)——中等
排列问题可以在树层去重,也可以在树枝去重;但在树层去重效率更高。参见:代码随想录 (programmercarl.com)
5. 去重的方式
去重:即标记集合中元素是否已选取
- 数组
- 编程语言内置的哈希表数据结构(有频繁的插入、映射、查找和删除操作,时间和空间复杂度高)。如:C++的unordered_set
- 哈希数组(已知数据规模)
6. 组合、切割、排列和子集问题的区别
组合、切割和排列问题,树的叶子结点为结果(遍历符合条件的树枝)
子集问题,树的每个结点为结果(遍历树);所以可以不需要确定回溯函数的终止条件,不存在剪枝的优化方法
7. 组合、切割、子集和排列问题的区别
组合问题、切割和子集问题,集合是无序的,for()循环从当前结点问题规模的起始索引start_index开始;可能需要去重
排列问题,集合是有序的,for()循环从0开始,必需去重
8. 二维横向遍历
- 在二维横向遍历中
- 进行递归和回溯
如:37.解数独 II - 力扣(LeetCode)——困难
九、力扣例题
组合问题:
- 77.组合——中等
- 216.组合总和 III——中等
- 17.电话号码的字母组合——中等
- 39.组合总和——中等
- 40.组合总和 II——中等
切割问题:
- 131.分割回文串——中等
- 93.复原 IP 地址——中等
子集问题:
- 78.子集——中等
- 90.子集 II——中等
- 491.递增子序列——中等
排列问题:
- 46.全排列——中等
- 47.全排列 II——中等
其他问题:
- 332.重新安排行程——困难
图问题:
- 51.N 皇后——困难
- 37.解数独——困难
总结
回溯描述。
参考资料
- 代码随想录 (programmercarl.com)
- 《代码随想录》作者:孙秀洋
- 力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台