理论分析
1.回溯算法是什么?
其实回溯算法其实就是我们常说的 DFS 算法,本质上就是一种暴力穷举算法。
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
- 路径:也就是已经做出的选择。
- 选择列表:也就是你当前可以做的选择。
- 结束条件:也就是到达决策树底层,无法再做选择的条件。
回溯是 DFS 中的一种技巧。回溯法采用 试错 的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。
通俗上讲,回溯是一种走不通就回头的算法。
回溯的本质是穷举所有可能,尽管有时候可以通过剪枝去除一些根本不可能是答案的分支, 但是从本质上讲,仍然是一种暴力枚举算法。
回溯法可以抽象为树形结构,并且是是一颗高度有限的树(N 叉树)。回溯法解决的都是在集合中查找子集,集合的大小就是树的大小,递归的深度 构成树的高度。
2.回溯算法代码是否有规律可循?
对于此类要枚举所有方案的题目,我们都应该先想到「回溯算法」。
-
「回溯算法」从算法定义上来说,不一定要用 DFS 实现,但通常结合 DFS 来做,难度是最低的。
-
「回溯算法」根据当前决策有多少种选择,对应了两套模板:
1.每一次独立的决策只对应 选择 和 不选 两种情况:
- 确定结束回溯过程的 base case(终止条件)
- 遍历每个位置,对每个位置进行决策(做选择 -> 递归 -> 撤销选择)
void dfs(当前位置, 路径(当前结果), 结果集) {
if (当前位置 == 结束位置) {
结果集.add(路径);
return;
}
选择当前位置;
dfs(下一位置, 路径(当前结果), 结果集);
撤销选择当前位置;
dfs(下一位置, 路径(当前结果), 结果集);
}
2.每一次独立的决策都对应了多种选择(通常对应了每次决策能选择什么,或者每次决策能选择多少个 …):
- 确定结束回溯过程的 base case(终止条件)
- 遍历所有的「选择」
- 对选择进行决策 (做选择 -> 递归 -> 撤销选择)
void dfs(选择列表, 路径(当前结果), 结果集) {
if (满足结束条件) {
结果集.add(路径);
return;
}
for (选择 in 选择列表) {
做选择;
dfs(路径’, 选择列表, 结果集);
撤销选择;
}
}
有时有些题对速度有要求,需要剪枝才能通过
const visited = {}
function dfs(i) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
visited[i] = true // 将当前状态标为已搜索
dosomething(i) // 对i做一些操作
for (根据i能到达的下个状态j) {
if(剪枝条件){//剪枝掉某些不必递归的情况
return;
}
if (!visited[j]) { // 如果状态j没有被搜索过
dfs(j)
}
}
undo(i) // 恢复i
}
具体做题时,可以简单画一下树形结构图模拟一下每次搜索的过程,可以加深理解和有助于分析。
力扣上回溯算法相关的题大体上可分为下面几种:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
题目示例
leetcode46. 全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
思路分析
以[1,2,3]为例,抽象成树形结构如下:
- 回溯三部曲
- 递归函数参数
首先排列是有序的,也就是说[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>> ans;//最终答案
vector<int> path;//当前保存的排列结果
void backtrack(vector<int>& nums, vector<bool>& used){
if(path.size() >= nums.size()){//终止条件
ans.push_back(path);
return;
}
//排列问题(需要所有的数)每层都是从0开始搜索而不是startIndex
for(int i = 0; i < nums.size(); i++){
if(used[i] == true) continue;// path里已经使用过的元素,直接跳过
used[i] = true;//要使用的
path.push_back(nums[i]);
backtrack(nums, used);//递归向下搜索
path.pop_back();
used[i] = false;//回溯撤销加入的数,并重置访问状态
}
}
vector<vector<int>> permute(vector<int>& nums) {
int n = nums.size();
vector<bool> used(n, false);//判断数字是否使用过
backtrack(nums, used);
return ans;
}
};
讲解参考自:https://leetcode-cn.com/problems/permutations/solution/46-quan-pai-lie-hui-su-suan-fa-jing-dian-ti-mu-xia/
leetcode131. 分割回文串
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。
回文串 是正着读和反着读都一样的字符串。
思路分析
求所有的分割方案,凡是求所有方案的题基本上都没有什么优化方案,就是「爆搜」。
问题在于,爆搜什么?显然我们可以爆搜每个回文串的起点。如果有连续的一段是回文串,我们再对剩下连续的一段继续爆搜。
为什么能够直接接着剩下一段继续爆搜?
因为任意的子串最终必然能够分割成若干的回文串(最坏的情况下,每个回文串都是一个字母)。 所以我们每次往下爆搜时,只需要保证自身连续一段是回文串即可。
举个🌰 来感受下我们的爆搜过程,假设有样例 abababa,刚开始我们从起点第一个 a 进行爆搜:
- 发现 a 是回文串,先将 a 分割出来,再对剩下的 bababa 进行爆搜
- 发现 aba 是回文串,先将 aba 分割出来,再对剩下的 baba 进行爆搜
- 发现 abab 是回文串,先将 abab 分割出来,再对剩下的 aba 进行爆搜
- 发现 ababab 是回文串,先将 ababab 分割出来,再对剩下的 a 进行爆搜
…然后再对下一个起点(下个字符) b 进行爆搜?
不需要。 因为单个字符本身构成了回文串,所以以 b 为起点,b之前构成回文串的方案, 必然覆盖在我们以第一个字符为起点所展开的爆搜方案内(在这里就是对应了上述的第一步所展开的爆搜方案中)。
因此我们只需要以首个字符为起点,枚举以其开头所有的回文串方案,加入集合,然后对剩下的字符串部分继续爆搜。就能做到以任意字符作为回文串起点进行分割的效果了。
一定要好好理解上面那句话 ~
剩下的问题是,我们如何快速判断连续一段 [i, j] 是否为回文串,因为爆搜的过程每个位置都可以作为分割点,复杂度为
O(2^n)的。此思路可用动态规划实现,之前DP的题目也做过,详见另一篇博客的具体介绍
class Solution {
public:
vector<vector<string>> ans;
vector<string> path;
//使用动态规划推导出每段区间是否为回文子串,代替每次都要判断是否为回文子串
vector<vector<string>> partition(string s) {
int n = s.size();
vector<vector<bool>> dp(n,vector<bool>(n, false));//dp[i][j]的值代表i到j是否为回文子串
//由内向外扩散判断,中心的是回文,只要判断新加的两端是否一样即可
for(int j = 0; j < n; j++){
for(int i = j; i >= 0; i--){
if(i == j){//当 [i, j] 只有一个字符时,必然是回文串
dp[i][j] = true;
}else if(i + 1 == j){//当 [i, j] 长度为 2 时,满足 cs[i] == cs[j] 即回文串
dp[i][j] = s[i] == s[j];
}else{// 当 [i, j] 长度大于 2 时,满足 中间是回文,两端一样 即回文串
dp[i][j] = dp[i + 1][j - 1] && s[i] == s[j];
}
}
}
dfs(s, 0, dp);
return ans;
}
//回溯(递归)查找所有可能情况
void dfs(const string& s, int startIdex, const vector<vector<bool>>& dp){
if(startIdex >= s.size()){//终止条件:切割点跑到字符串末尾即结束
ans.push_back(path);
return;
}
//剩下可选择情况:从切割点startIndex到末尾继续分割回文子串
for(int i = startIdex; i < s.size(); i++){
if(dp[startIdex][i]){//
path.push_back(s.substr(startIdex, i - startIdex + 1));
dfs(s, i + 1, dp);//继续往后找
path.pop_back();//回溯:找完一种情况,删除最后的回文子串,到前面岔路口继续找其他可能的长子串
}
}
}
};
思路参考自:https://leetcode-cn.com/problems/palindrome-partitioning/solution/wei-sha-yao-zhe-yang-bao-sou-ya-shi-ru-h-41gf/