什么是回溯
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。
回溯法,一般可以解决如下几种问题:
组合问题:N个数里面按一定规则找出k个数的集合,组合是不强调元素顺序的,排列是强调元素顺序。
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
回溯法的性能如何呢,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
回溯法解决的问题都可以抽象为树形结构,所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一颗高度有限的树(N叉树)。
回溯模板
1、回溯函数模板返回值以及参数
1)确定返回值:
在回溯算法中,我的习惯是函数起名字为backtracking。回溯算法中函数返回值一般为void。再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
2)终止条件
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
3)遍历逻辑
for横向循环搜索集合里面的每一个元素,集合的大小为树的宽度.
递归的深度构成树的深度。
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历
模板代码:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
}
一、组合问题
1、组合一---- 77题
1,…n个数,返回k个数的组合。
n为树的宽度,k为树的深度。
设定两个全局变量放最终结果和过程中的结果。
递归终止条件:path里面添加了k个元素说明找到了一个。
backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件单一结果
void backtracking(int n, int k, int startIndex)
//递归终止条件
if (path.size() == k) {
result.push_back(path);
return;
}
for(int i = startIndex;i<=n;i++){
path.push(i);
backtracking(n,k,i+1);//递归,控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back();//回溯
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
剪枝优化:
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
假设 n=4,k=4从i = 2开始的遍历都没意义。
接下来看一下优化过程如下:
已经选择的元素个数:path.size();
还需要的元素个数为: k - path.size();
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
code:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
path.push_back(i); // 处理节点
backtracking(n, k, i + 1);
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
2、组合二----216题
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int target, int startIndex, int sum){
//终止
if(sum == target && path.size() == k){
result.push_back(path);
return;
}
for(int i = startIndex;i<=n;i++){//可以剪枝,i <= n - (k - path.size()) + 1
path.push_back(i);
sum += i;
backtracking(n,k,target,i+1, sum);
sum-=i;
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(n, k, 0, 1);
return result;
}
//终止
3、组合三— 17题
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
电话号码的组合。思考如何将字符和数字关联。index大小为递归深度,横向遍历边界为lettermap中每个字符串的长度。
class Solution {
public:
const string lettermap[10] = {
"",
"",
"abc",
"def",
"ghi",
"jkl","mno","pqrs","tuv","wxyz"
};
vector<string> result;
string s;
void backtracking(const string & digits, int index) {
if(index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';//这里表示字母和数字关联
string letters = lettermap[digit];
for(int i=0; i < letters.size();i++){
s.push_back(letters[i]);
backtracking(digits, index+1);
s.pop_back();
}
}
vector<string> letterCombinations(string digits){
s.clear();
result.clear();
if(digits.size()==0) return result;
backtracking(digits,0);
return result;
}
};
4、组合四-- 39题
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
本题为有重复情况下,原集合元素无限制重复被选取。这里递归的起始索引为i,不再是i+1表示可以从上一次取元素的地方再取,而无需在上一次的下一个元素开始。
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
int sum;
void backtracking(vector<int>& candidates, int target, int startIndex){
if(sum > target) return;
if(sum == target){
result.push_back(path);
return;
}
for(int i = startIndex; i<candidates.size();i++){
sum+= candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, i);//这里取i,不是i+1.
path.pop_back();
sum-=candidates[i];
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sum = 0;
backtracking(candidates, target, 0);
return result;
}
};
5、组合五-- 40题
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
与上一题不同之处在于,原集合中存在重复的元素。所以要在搜索的过程中就去掉重复组合。都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。**所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。强调一下,树层去重的话,需要对数组排序!**引入一个used数组,
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& candidates, int target,vector<int> &used, int sum, int start){
if(sum > target) return;
if(sum == target) {
result.push_back(path);
return;
}
for(int i = start;i<candidates.size();i++){
if(i>0&&candidates[i-1] == candidates[i] && used[i-1] == 0) continue;//这里包含同一树层的去重逻辑。
path.push_back(candidates[i]);
sum+=candidates[i];
used[i] = 1;
backtracking(candidates, target, used, sum, i+1);
path.pop_back();
sum-=candidates[i];
used[i] =0;
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<int> used(candidates.size(),0);
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, used, 0, 0);
return result;
}
};
二、切割问题
1、切割一----131题
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
可以抽象成一个树形结构,例如“aab”,先切a,之后切aa,最后切aab。递归用来纵向遍历,for循环用来横向遍历,切割线切割到字符串的结尾位置,说明找到了一个切割方法。
首先判断回文。
bool huiwen(string &s, int start, int end){
for(int i =start, j = end;i<j;i++,j--){
if(s[i]!=s[j]) return false;
}
return true;
}
完整代码:
class Solution {
public:
vector<string> path;
vector<vector<string>> result;
bool huiwen(string &s, int start, int end){
for(int i =start, j = end;i<j;i++,j--){
if(s[i]!=s[j]) return false;
}
return true;
}
void backtracking(string &s,int start){
if(start >= s.size()){
result.push_back(path);
return;
}
for(int i = start;i<s.size();i++){
if(huiwen(s,start,i)){
string str = s.substr(start, i-start+1); //注意这里截取字串加进path,字串开始索引为start,终止为i,整个字串为(start, i-start+1);
path.push_back(str);
backtracking(s,i+1);
path.pop_back();
}
}
}
vector<vector<string>> partition(string s) {
backtracking(s,0);
return result;
}
};
2、切割二----93题
给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。
有效的 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。
引入一个pointnum 。当pointnum ==3 时终止。
还是应该先判断截取的字串是否合法。
判定条件:
段位以0为开头的数字不合法
段位如果大于255了不合法
bool isValid(const string &s, int start, int end){
if(start == '0') return false;
int num = 0;
for(int i = start;i<=end;i++){
num = num*10 + s[i]-'0';
if(num>255) return false;
}
return true;
完整代码:
class Solution {
public:
vector<string> result;
bool isValid(const string &s, int start, int end){
if (start > end) {
return false; //这里要注意,不加会出现1.1.11.的情况。
}
if(s[start] == '0' && start!=end) return false;
int num = 0;
for(int i = start;i <= end;i++){
num = num*10 + s[i]-'0';
if(num>255) return false;
}
return true;
}
void backtracking(string &s, int start, int pointnum){
if(pointnum == 3){
if(isValid(s, start, s.size()-1)){
result.push_back(s);
return;
}
}
for(int i = start;i<s.size();i++){
if(isValid(s,start,i)){
s.insert(s.begin()+i+1,'.');
pointnum++;
backtracking(s, i+2,pointnum);
pointnum--;
s.erase(s.begin()+i+1);
}
}
}
vector<string> restoreIpAddresses(string s) {
if(s.size()>12) return result;
backtracking(s, 0, 0);
return result;
}
};
三、子集问题
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
1、子集一----78题
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums,int start){
result.push_back(path);//子集问题一定放在开头,收集所有节点
if(start>=nums.size()){
return;
}
for(int i = start; i<nums.size();i++){
path.push_back(nums[i]);
backtracking(nums, i+1);//不能重复,从下一个开始取
path.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums,0);
return result;
}
};
2、子集二----90题
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。输入有重复,但解集不能包含重复的子集。所以同一数层上要去重。利用used数组。(别忘排序)
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, int start, vector<int> &used){
result.push_back(path);
if(start >= nums.size()) return;
for(int i = start;i<nums.size();i++){
if(i>0 && nums[i-1] == nums[i] && used[i-1] == 0) continue;
path.push_back(nums[i]);
used[i] = 1;
backtracking(nums, i+1, used);
used[i] = 0;
path.pop_back();
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> used(nums.size(),0);
sort(nums.begin(), nums.end()); //别忘了排序!!!
backtracking(nums,0,used);
return result;
}
};
四、排列问题
首先排列是有序的,也就是说[1,2] 和[2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了,从0开始。但排列问题需要一个used数组,标记已经选择的元素,而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
注意两个地方:1) void backtracking(vector& nums, vector used),由于不在意startindex。所以不传了。2) if(used[i] == 1) continue;// path里已经收录的元素,直接跳过。
1、排列一----90题
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, vector<int> used){
if(nums.size() == path.size()){
result.push_back(path);
return;
}
for(int i = 0;i<nums.size();i++){
if(used[i] == 1) continue;// path里已经收录的元素,直接跳过
path.push_back(nums[i]);
used[i] = 1;
backtracking(nums,used);
used[i] = 0;
path.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums){
vector<int> used(nums.size(),0);
backtracking(nums, used);
return result;
}
};
2、排列二----47题
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。此题要进行去重操作。
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, vector<int> &used){
if(path.size() == nums.size()){
result.push_back(path);
return;
}
for(int i = 0;i<nums.size();i++){
if(i>0 && nums[i-1] == nums[i] && used[i-1] == 0){//去重
continue;
}
if(used[i] == 0){
used[i] = 1;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = 0;
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<int> used(nums.size(),0);
sort(nums.begin(), nums.end());
backtracking(nums, used);
return result;
}
};
五、其他问题
1、N皇后----51题
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来来遍历列,然后一行一列确定皇后的唯一位置。
还是先判断是否有效,有效包括检查行,检查列,检查45°和135°。这里注意检查对角线,一定要从后往前检查从右下往左上,从左下往右上。
创建二维字符串数组:vector str;
class Solution {
vector<vector<string>> result;
bool isValid(int n, int row, int col,vector<string>&chessboard){
//行
for(int i =0;i<row;i++){
if(chessboard[i][col] == 'Q') return false;
}
for(int i = 0;i<col;i++){
if(chessboard[row][i] == 'Q') return false;
}
for(int i = row-1,j=col-1; i>=0&&j>=0;i--,j--){
if(chessboard[i][j] == 'Q') return false;
}
for(int i = row-1, j = col+1; i>=0&&j<n; j++,i--){
if(chessboard[i][j] == 'Q') return false;
}
return true;
}
void backtracking(int n, int row, vector<string>&chessboard){
if(row == n) {
result.push_back(chessboard);
return;
}
for(int col = 0;col<n;col++){
if(isValid(n, row, col, chessboard)){
chessboard[row][col] = 'Q';
backtracking(n, row+1, chessboard);
chessboard[row][col] = '.';
}
}
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n,string(n, '.'));
backtracking(n,0,chessboard);
return result;
}
};
2、路径规划----51题
给定一个机票的字符串二维数组 [from, to],子数组中的两个成员分别表示飞机出发和降落的机场地点,对该行程进行重新规划排序。所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始,按字符自然排序返回最小的行程组合。
关键:一个机场映射多个机场,机场之间要靠字母序排列,一个机场映射多个机场,可以使用std::unordered_map,如果让多个机场之间再有顺序的话,就是用std::map 或者std::multimap 或者 std::multiset。
1)unordered_map<string, map<string, int>> targets:unordered_map<出发机场, map<到达机场, 航班次数>> targets
在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。如果“航班次数”大于零,说明目的地还可以飞,如果如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
2)本题targets和result都需要初始化:先统计映射关系:
for(const vector<string> & vec:tickets){
targets[vec[0]vec[1]]++;} //vec[0] 是出发机场,vec[1]是到达机场,统计到达机场的航班次数(例如:{"JFK","KUL"},vec[0]是JFK,vec[1]是KUL) ;
result.push_back("JFK"); // 起始机场
3)注意函数返回值用的是bool,因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线。
终止条件判断:设定一个航班数量的变量,当结果数目等于航班数目+1终止。
if (result.size() == ticketNum + 1) {
return true;
}
4)for 循环开始,遍历targets里面的map<string, int>对。
for(pair<const string, int>& target : targets[result[result.size()-1]]){
if(target.second >0){
result.push_back(target.first);
target.second--;
if(backtracking(ticketnum)) return true;
result.pop_back();
target.second++;
}
}
return false;
}
完整代码
class Solution {
public:
unordered_map<string, map<string, int>> targets;
vector<string> result;
bool backtracking(int ticketnum ){
if(result.size() == ticketnum+1){
return true;
}
class Solution {
public:
unordered_map<string, map<string, int>> targets;
vector<string> result;
bool backtracking(int ticketnum ){
if(result.size() == ticketnum+1){
return true;
}
for(pair<const string, int>& target : targets[result[result.size()-1]]){
if(target.second >0){
result.push_back(target.first);
target.second--;
if(backtracking(ticketnum)) return true;
result.pop_back();
target.second++;
}
}
return false;
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
targets.clear();
// vector<string> result;
for(const vector<string> & vec : tickets){
targets[vec[0]][vec[1]]++;
}
result.push_back("JFK");
backtracking(tickets.size());
return result;
}
};
vector<string> findItinerary(vector<vector<string>>& tickets) {
targets.clear();
// vector<string> result;
for(const vector<string> & vec : tickets){
targets[vec[0]][vec[1]]++;
}
result.push_back("JFK");
backtracking(tickets.size());
return result;
}
};
3、解数独----37题
编写一个程序,通过填充空格来解决数独问题。
一个数独的解法需遵循如下规则: 数字 1-9 在每一行只能出现一次。 数字 1-9 在每一列只能出现一次。 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。 空白格用 ‘.’ 表示。
1)递归函数的返回值需要是bool类型,因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
2)在树形图中可以看出我们需要的是一个二维的递归(也就是两个for循环嵌套着递归)一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!
3)判断有效:
判断棋盘是否有效有如下三个维度:
同行是否重复
同列是否重复
9宫格里是否重复
class Solution {
public:
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 i=0;i<9;i++){
if(board[i][col] == val) return false;
}
int startrow = (row/3) *3;//这里要注意除以3取整再×3
int startcol= (col/3) *3;
for(int i = startrow; i<startrow+3;i++){
for(int j = startcol;j<startcol+3;j++){
if(board[i][j] == val) return false;
}
}
return true;
}
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;//终止条件, 所有格子均被填满。
for(char k = '1';k<='9';k++){
if(isValid(i,j,k,board)){
board[i][j] = k;
if(backtracking(board)) return true;
board[i][j] = '.';
}
}
return false;
}
}
return true;
}
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};
总结
组合问题:不强调顺序,生成结果不重复,原集合元素不可以重复取时,每一次startIndex从i+1开始取;如果u、原集合元素可以重复,每次从i开始。如果原集合元素存在重复,同一数层要去重,引入used数组,先排序,然后判断
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) { continue; }
切割问题:截取字串s.substr(startIndex, i - startIndex + 1),然后backtracking(s, i + 1);
子集问题:收集所有树节点。 result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉自己。设定startIndex,回溯从i+1开始。backtracking(nums, i + 1); 集合里如果有重复元素了,而且求取的子集要去重。
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) { continue; }
排列问题:首先排列是有序的,也就是说[1,2] 和[2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。可以重复取元素,不再设定startIndex,每次i从0开始。纵向同一树枝上,if (used[i] == true) continue; // path里已经收录的元素,直接跳过,used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。如果原集合里面再有重复,就再加上排序和去重。
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) { continue; }