根据课本上的学习要点梳理,“通用解题法”,可以系统的搜索一个问题的所有解、任一解,他是一个既带有系统性(暴力遍历)又带有跳跃性(剪枝)的搜索算法。
理解回溯法和深度优先搜索策略
回溯的本质就是递归,就是一种搜索方式,在问题的解空间树中,按深度优先搜索策略,从根节点出发搜索解空间树,算法搜索到解空间的一个节点时,先判断该节点是否包含问题的解,如果肯定不包含,跳过该节点的子树(剪枝),逐层向其祖先节点回溯;否则进入该子树,继续按深度优先搜索下一层节点(因此我们必须明确每一层节点的含义)。
回溯法求问题的一个解时,只要搜索到一个解,就可以结束了。
回溯法求问题的全部解时,需要回溯到根,并且根节点的所有子树都要被搜索到才能结束。
回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
回溯法的效率如何呢?他虽然很难,但并不是什么高效的算法,回溯的本质是穷举,穷举所有的可能,然后选出我们想要的答案。如果想要回溯法更加高效一些,那就根据题目的意思加一些剪枝操作,但还是该不了穷举的本质。虽然他效率不高,但很多问题不用回溯法根本写不出来。
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
子集问题和排列问题如何区分? 组合不强调元素的顺序,而排列会强调元素的顺序。
掌握用回溯法解题的算法框架
1)递归回溯
2)迭代回溯
3)子集树算法框架
4)排列树算法框架
递归回溯三部曲
- 回溯函数模板返回值以及参数
- 回溯函数的终止条件
- 回溯搜索的单层遍历逻辑
1、void backtrack(参数)回溯算法中函数返回值一般为void。再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
2、遍历树形结构需要终止条件,达到了终止条件,一般就是搜索到叶子节点了,也就找到了一个满足条件的答案,把这个答案存放起来,并结束本层递归
终止条件的伪代码如下:
if(终止条件){
存放结果;
return;
}
3、回溯搜索的遍历过程
回溯法一般书在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成了树的深度
(集合的大小和孩子的数量是相等的)for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
整体的代码模板可以写成这样:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
递归回溯的代码框架
迭代回溯代码框架
子集树与排列树
子集树的典型代表就是子集问题、组合问题(0-1背包、最优装载、最大团、符号三角形--有点坑、n后问题、图的m着色问题)。这类子集树通常有2^n个节点。
排列树的典型代表就是排列问题(旅行售货员,批处理作业调度、圆排列、电路板排列),这类排列树通常有n!个节点。
子集树的一般算法可以描述为:
排列树的一般算法可以描述为:
范例学习回溯法的设计策略
范例1--组合问题1(代码随想录的搬运工):
主要是这个归一化的解题模板和思路值得学习;
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[2,4],[3,4],[2,3],[1,2],[1,3],[1,4]
思考:直接的暴力解法,首先想到的就是for循环,几个k几个for循环,n = 100, k = 3 那么就三层for循环,问题在于这个k到底是多少呢?我们没办法写出k个for循环,回溯搜索法来了,虽然回溯法也是暴力,但至少能写出来,不像for循环嵌套k层让人绝望。
用回溯法就很明智,第i层就是取了第i个数,横向遍历的就是集合的大小,注意:[1,2]=[2,1],所以啊,从左向右取数字,取过的数,就不用重复取了。横向遍历的时候,集合是越来越小的。
然后把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
- 递归函数的返回值以及参数
定义两个全局变量,一个存放符合条件的单层结果,一个用来存放符合条件的结果的集合
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
startIndex来记录下一层递归,搜索的起始位置,防止出现重复的组合,搜索过的前面的元素,就不需要再搜索了,一共有n个元素,
void backtracking(int n, int k, int startIndex)
- 回溯函数终止条件
path数组的大小达到k,说明找到了一个子集大小为k的组合
if (path.size() == k) {
result.push_back(path);
return;
}
- 单层搜索逻辑
如此我们才遍历完图中的这棵树?
for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
剪枝优化:
如果起始位置之后的元素个数已经不足 我们需要的元素个数,那就没必要搜索了
所以可以将循环遍历从i=startindex 到 i=n,修改为:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
-
已经选择的元素个数:path.size();
-
还需要的元素个数为: k - path.size();
-
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
范例1--总结
组合问题是回溯法解决的经典问题,我们开始的时候给大家列举一个很形象的例子,就是n为100,k为50的话,直接想法就需要50层for循环。
从而引出了回溯法就是解决这种k层for循环嵌套的问题。
然后进一步把回溯法的搜索过程抽象为树形结构,可以直观的看出搜索的过程。
接着用回溯法三部曲,逐步分析了函数参数、终止条件和单层搜索的过程。
剪枝优化要根据题目的意思来,最好是画画图理解
范例2--组合问题2(代码随想录的搬运工)
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:
- 所有数字都是正整数。
- 解集不能包含重复的组合。
示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]
示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]
与范例1的区别在于:组合中只允许含有 1 - 9 的正整数,k相当于树的深度,9(因为整个集合就是9个数)就是树的宽度。例如 k = 2,n = 4的话,就是在集合[1,2,3,4,5,6,7,8,9]中求 k(个数) = 2, n(和) = 4的组合。
- 确定递归函数参数
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
targetSum(int)目标和,也就是题目中的n。
k(int)就是题目中要求k个数的集合。
sum(int)为已经收集的元素的总和,也就是path里元素的总和。
tartIndex(int)为下一层for循环搜索的起始位置。
- 确定终止条件
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
- 单层搜索过程
回溯过程和处理过程是一一对应的,处理有加号,回溯的时候就有减号
for (int i = startIndex; i <= 9; i++) {
sum += i;
path.push_back(i);
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
剪枝优化:跟范例1一样,for循环的范围也可以剪枝,i <= 9 - (k - path.size()) + 1就可以了。
范例3--子集问题(代码随想录的搬运工)
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
分析:求子集问题和组合问题不一样,组合问题和分割问题都是收集树的叶子节点,但是子集问题是收集树的所有节点,其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
有同学问了,什么时候for可以从0开始呢?求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
- 递归函数参数
全局变量数组path为子集收集元素,二维数组result存放子集组合。(也可以放到递归函数参数里),递归函数参数在上面讲到了,需要startIndex,确保不会被重复选择。
- 递归终止条件
当startindex已经大于数组长度,就终止了,没有元素可以选择了
if (startIndex >= nums.size()) {
return;
}
- 单层搜索逻辑
求取子集问题不需要任何剪枝,因为子集问题就是要遍历整棵树
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 子集收集元素
backtracking(nums, i + 1); // 注意从i+1开始,元素不重复取
path.pop_back(); // 回溯
}
其实这些问题都是由标准模板的题目,要弄清楚子集问题、组合问题和分割问题的区别,组合问题分割问体都是手机树形结构的叶子节点的结果,子集问题是收集树中所有节点的结果。
范例4--全排列(代码随想录的搬运工)
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
- 输入: [1,2,3]
- 输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
这是典型的排列树,我们就不按照代码随想录的那一套写了
- 递归函数参数
vector<vector<int>> result;存放结果
定义 t 为需要排列的第t个数,也就是递归深度(解空间树的层数)
定义 n 为需要排列的总个数
定义vector<int> nums为原始的输入
void backtrack(int t,int n,vector<int>& nums)
- 递归终止条件
if(t==n){
result.push_back(nums);
return;
}
- 单层搜索逻辑
for(int i=t;i<n;i++){
swap(nums[t],nums[i]);
backtrack(t+1,n,nums);
swap(nums[t],nums[i]);
}
swap函数:
void swap(int &a,int &b){
int tmp=a;
a=b;
b=tmp;
}
按照排列树的代码写起来很简单,其余排列树也是根据题目要求有着不同的变化。
代码随想录官方的思路也值得学习:
递归函数的参数:排列是有序的,[1,2]和[2,1]是两个集合,和之前分析的组合不同的地方,可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。但排列问题需要一个used数组,标记已经选择的元素。
递归终止条件:当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
单层搜索逻辑:因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
代码如下:
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) continue; // path里已经收录的元素,直接跳过
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
大家此时可以感受出排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了
范例5--棋盘问题--解数独(代码随想录的搬运工)
一个数独的解法需遵循如下规则: 数字 1-9 在每一行只能出现一次。 数字 1-9 在每一列只能出现一次。 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。 空白格用 '.' 表示。
提示:
- 给定的数独序列只包含数字 1-9 和字符 '.' 。
- 你可以假设给定的数独只有唯一解。
- 给定数独永远是 9x9 形式的
解题步骤与分析
N皇后问题是因为每一行、每一列只会放置一个皇后,for循环只需要遍历列的位置就行了,因为我们可以确定每一行放置一个皇后。
解数独不一样,棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
- 递归函数以及参数
递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
代码如下:
bool backtracking(vector<vector<char>>& board)
找到一个解就行了
- 递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
不用终止条件会不会死循环?
递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
- 单层遍历逻辑
那么有没有永远填不满的情况呢?
我们需要的是一个二维的递归(也就是两个for循环嵌套着递归)
一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!
bool backtracking(vector<vector<char>>& board) {
for (int i = 0; i < board.size(); i++) { // 遍历行
for (int j = 0; j < board[0].size(); j++) { // 遍历列
if (board[i][j] != '.') continue;
// (i, j) 这个位置放k是否合适
for (char k = '1'; k <= '9'; k++) {
if (isValid(i, j, k, board)) {
board[i][j] = k; // 放置k
// 如果找到合适一组立刻返回
if (backtracking(board)) return true;
board[i][j] = '.'; // 回溯,撤销k
}
}
// 9个数都试完了,都不行,那么就返回false
return false;
}
}
// 遍历完没有返回false,说明找到了合适棋盘位置了
return true;
}
注意这里return false的地方,这里放return false 是有讲究的。
因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!
那么会直接返回, 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去。
判断棋盘是否合法
判断棋盘是否合法有如下三个维度:
- 同行是否重复
- 同列是否重复
- 9宫格里是否重复
代码如下:
bool isValid(int row, int col, char val, vector<vector<char>>& board) {
for (int i = 0; i < 9; i++) { // 判断行里是否重复
if (board[row][i] == val) {
return false;
}
}
for (int j = 0; j < 9; j++) { // 判断列里是否重复
if (board[j][col] == val) {
return false;
}
}
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) { // 判断9方格里是否重复
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == val ) {
return false;
}
}
}
return true;
}
解数独很难,数独的搜索过程有两个循环,一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,遍历这个位置(i,j)放9个数字的可能性。return true和return false也很有讲究,如果尝试了9个数字都不行,说明棋盘找不到解了,就应该return false。判断棋盘合法性的代码也很难很长,所以需要单独写,这样方便检查也方便看。
总结
回溯法其实很多都是按照模板来套用,跟暴力搜索类似,最好剪枝和判断是否满足题目条件的时候单独写一个函数,一来看起来好看,二来好修改,杂糅在一起反而感觉一团糟。
有关分割和去重的问题,需要明白如何模拟分割线,根据模板和题意来写,比动态规划简单多了。
明天就是除夕了,兔年快乐,心想事成,如愿以偿。