理解回溯法
溯的本质是穷举,穷举所有可能,然后选出我们想要的答案。
回溯法解决的所有问题都可以抽象为树形结构!因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度构成的树的深度。
应用场景
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
局限性
- 耗时高,需要剪枝优化
代码模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
回溯四部曲
1. 抽象出树形结构
把搜索过程抽象成树形结构。
- 最初的集合作为树的第一层;
- 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,需调整可选择的范围。
- 每次选取操作对应产生一个结果,作为树的下一层节点;
- 当前集合中所有可能的选取操作,对应的结果构成树的下一层。
2. 回溯函数返回值以及参数
3. 回溯函数终止条件
达到终止条件后清空结果,回溯开始
4. 单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程,for循环用来横向遍历,递归的过程是纵向遍历。
通过控制变量(如索引)记录选取的位置,计算下一层递归开始的位置。
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
curPath.add(i); // 处理节点
backtracking(n, k, i + 1, curPath); // 递归:控制树的纵向遍历,下一层搜索要从i+1开始
curPath.remove(Integer.valueOf(i)); // 回溯:撤销处理的节点,重置到下一步横向遍历开始的状态
}
剪枝优化
回溯法虽然是暴力搜索,但有时候可以通过剪枝优化。
如给定两个整数n和k,返回1…n中所有可能的k个数的组合。如果n = 4,k = 4,第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了。
把整个回溯过程抽象为一颗树形结构后,可以直观的看出,剪枝究竟是剪的哪里。
for (int i = startIndex; i <= n - k + 1 + curPath.size(); i++) { // 控制树的横向遍历,剪枝优化
curPath.add(i); // 处理节点
backtracking(n, k, i + 1, curPath); // 递归:控制树的纵向遍历,下一层搜索要从i+1开始
curPath.remove(Integer.valueOf(i)); // 回溯:撤销处理的节点,重置到下一步横向遍历开始的状态
}
需要注意的点:
- 边界。例如startIndex = i时,剪枝的条件是k - size <= n - i + 1,因为包括当前下标i。