<冲刺大厂之算法刷题>回溯算法

博客首页:热爱编程的大李子

专栏首页:LeetCode刷题

博主在学习阶段,如若发现问题,请告知,非常感谢

同时也非常感谢各位小伙伴们的支持

每日一语:I walk slowly, but I never walk backwards.

感谢: 我只是站在巨人们的肩膀上整理本篇文章,感谢走在前路的大佬们!

最后,祝大家每天进步亿点点! 欢迎大家点赞➕收藏⭐️➕评论支持博主!

⭐️ ⭐️上篇文章-<冲刺大厂之算法刷题>栈和队列 ⭐️ ⭐️

<冲刺大厂之算法刷题>回溯算法_第1张图片

文章目录

    • 77. 组合
        • 题目描述
        • 思路分析
        • 参考代码
    • 216.组合总和III
        • 题目描述
        • 思路分析
        • 参考代码
    • 17. 电话号码的字母组合
        • 题目描述
        • 思路分析
        • 参考代码
    • 39. 组合总和
        • 题目描述
        • 参考代码
    • 40. 组合总和 II
        • 题目描述
        • 思路分析
        • 算法设计
        • 参考代码
    • 131. 分割回文串
        • 题目描述
        • 思路分析
        • 算法设计
        • 参考代码
    • 93. 复原 IP 地址
        • 题目描述
        • 思路分析
        • 参考代码
    • 78. 子集
        • 题目描述
        • 思路分析
        • 参考代码
    • 90. 子集 II
        • 题目描述
        • 思路分析
        • 参考代码
    • 491. 递增子序列
        • 题目描述
        • 思路分析
        • 参考代码
    • 46. 全排列
        • 题目描述
        • 思路分析
        • 参考代码
    • 47. 全排列 II
        • 题目描述
        • 思路分析
        • 参考代码
    • 332.重新安排行程
        • 题目描述
        • 思路分析
        • 算法设计
        • 参考代码
    • 51. N皇后
        • 题目描述
        • 思路分析
        • 参考代码
    • 37. 解数独
        • 题目描述
        • 思路分析
        • 参考代码


本章回溯算法可以解决的问题如下:

<冲刺大厂之算法刷题>回溯算法_第2张图片

回溯法的模板:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

77. 组合

题目描述

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

输入:n = 1, k = 1
输出:[[1]]

思路分析

回溯法三部曲

  • 递归函数的返回值以及参数
    这里直接使用全局变量来操作,一个存放最终的结果集,一个存放符合条件的临时集合.这样我们也就不需要返回值了.
    参数:数据个数n,组合个数k 每次需要传入.另外也需要传入下次循环的起始位置,方便下一次其他数据进行组合.
vector<int> path;
vector<vector<int>> result;
void backtracking(int n,int k,int startIndex){
...
}
  • 回溯函数终止条件
    当path.size() == k了,则说明我们已经找到了大小为k的组合了. 结束递归
if(path.size()==k){
	result.push_back(path);
	return;//结束递归 
}
  • 单层搜索过程
    回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
    <冲刺大厂之算法刷题>回溯算法_第3张图片
    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(); // 回溯,撤销处理的节点
}

代码优化
可以剪枝的地方在递归中每一层的for循环条件处。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了

  • 已经选择的元素个数:path.size();
  • 还需要的元素个数为: k - path.size();
  • 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
    为什么有个+1呢,因为包括起始位置。
    所以中间for循环的条件可以改造为:i <= n-(k-path.size())+1

参考代码

vector<vector<int>> result;
vector<int> path;
void backtracking(int n,int k,int startIndex) {
	//回溯结束条件
	if(path.size()==k) {
		result.push_back(path);
		return;
	}
//剪枝优化1
//	if(path.size()+n-startIndex+1 < k) {
//		return;
//	}
//	for(int i = startIndex;i<= n-(k-path.size())+1;i++){//剪枝优化2
	for(int i = startIndex; i<= n; i++) { //
		path.push_back(i);
		backtracking(n,k,i+1);//递归+
		path.pop_back();//回溯
	}
}
vector<vector<int>> combine(int n, int k) {
	backtracking(n,k,1);
	return result;
}

216.组合总和III

题目描述

找出所有相加之和为 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]]

思路分析

本题相对于上个题只是多个 之和为n的限制,其他的处理思路和上题类似

参考代码

#include
using namespace std;
vector path;
vector> result;

void backtracking(int n,int k,int startIndex,int sum) {
	if(path.size()==k){
		if(sum == n){
			result.push_back(path);
		}
		return;
	}
	for(int i = startIndex;i <= 9 - (k-path.size())+1;i++){
		path.push_back(i);
		backtracking(n,k,i+1,sum+i);
		path.pop_back();
	}
}
vector> combinationSum3(int k, int n) {
	backtracking(n,k,1,0);
	return result;
}

17. 电话号码的字母组合

题目描述

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
<冲刺大厂之算法刷题>回溯算法_第4张图片

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

示例 3:

输入:digits = "2"
输出:["a","b","c"]

提示:

0 <= digits.length <= 4
digits[i] 是范围 [‘2’, ‘9’] 的一个数字。

思路分析

输入:“23”,抽象为树形结构,如图所示:
<冲刺大厂之算法刷题>回溯算法_第5张图片
图中可以看出遍历的深度就是输入的 字符串的长度,而叶子节点就是我们要收集的结果,宽度为 字符所对应的字符串长度 ,如2==>abc

回溯三部曲

  • 确定递归参数和返回值
    参数:需要传入输入的字符串digits,当前递归到的digits的index
    返回值:依旧定义两个个全局变量,一个是最终结果集,一个存放临时组合数.所以返回值为void 可以看出回溯类型的递归通常没有返回值
  • 确定递归结束条件
    如果index==digits.size(),则说明已经找到了满足的组合数,递归结束
  • 确定单层递归逻辑
    先从递归的index拿到对应的字符串,然后开始循环,递归,回溯…

参考代码

#include
using namespace std;

//LeetCode17:https://leetcode.cn/problems/letter-combinations-of-a-phone-number 
//本题属于组合问题的 “多个集合的组合” 
string path;
vector<string> result;
string arr[10] = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};

void backtracking(string& digits,int startIndex) {
	//结束条件
	if(startIndex==digits.size()) {
		result.push_back(path);
		return;
	}
	int num = digits[startIndex] - '0';//要遍历的字符串数组下标
	for(char ch : arr[num]) { //横向for循环
		path.push_back(ch);
		backtracking(digits,startIndex+1);//纵向递归
		path.pop_back();
	}
}

vector<string> letterCombinations(string digits) {
	if(digits=="") {//特殊情况判断 
		return result;
	}
	backtracking(digits,0);
	return result;
}

39. 组合总和

题目描述

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]

解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。

示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1
输出: []

参考代码

#include<iostream>
using namespace std;


//本题同LeetCode77只是结束条件变化了,其他思路都一致 
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& candidates, int target,int startIndex,int sum){
	if(sum > target){
		return;
	}else if(sum == target){
		result.push_back(path);
		return;
	}
	for(int i = startIndex; i < candidates.size(); i++){
		path.push_back(candidates[i]);
		backtracking(candidates,target,i,sum+candidates[i]);
		path.pop_back();
	}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
	backtracking(candidates,target,0,0);
	return result;
}

40. 组合总和 II

题目描述

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

示例 2:

输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]

思路分析

本题要是仅凭读题感觉好像比上一题还简单,但是看过示例之后就会发现,是我太天真了! 主要有如下区别:

  • 本题candidates中的每个数字在每个组合中只能使用一次
  • 本题数组candidates的元素是有重复的,而 39 组合总和 是无重复的数组

本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合

很容易想到的思路:把所有组合求出来,再用set或者map去重,但这么做很容易超时!

组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。 (同一树枝:也就是组合中数的维度,组合中的数可以有重复的. 同一树层:数组中数循环的维度,由于数组有重复数,所以上次可以循环到这个数,下次循环也可以到这个数,但是这俩数是在同一for循环内的.)

那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重

去重之前,我们需要对数进行排序.(为什么排序,大家可以思考一下)

树形结构如图所示
<冲刺大厂之算法刷题>回溯算法_第6张图片
used数组存储的是本层数的使用情况, 用于判断是否是同一个树层

算法设计

⭐️⭐️⭐️回溯三部曲

  • 确定递归函数参数和返回值
    每次需要传入数组candidates,循环的下一个位置下标 startIndex,当前组合数的和 sum,目标值 targetSum,以及用于去树层重的used数组
    由于我们使用全局变量来保存结果值,所以并不需要定义返回值.
  • 确定递归终止条件
    当sum>=targetSum时,结束递归. 当 = 时,还要将其加入到结果集中(由于后序会有剪枝操作,所以sum>targetSum可以省略)
  • 确定单层递归逻辑
    先判断当前数和上一个是否是同一个树层,如果是则跳过该数,循环下一个数. 否则更新sum,path数组,继续递归,递归完毕之后进行回溯,再继续判断循环下一个数.

⭐️⭐️⭐️本题核心就是去重: candidates[i] == candidates[i - 1]

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过,不必去重
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过,需要去重

代码剪枝:本题剪枝方案和上题类似

参考代码

#include
using namespace std;

vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& candidates, int target,int startIndex,int sum,vector<bool>& used) {
	if(sum > target) {
		return;
	} else if(sum == target) {
		result.push_back(path);
		return;
	}
	for(int i = startIndex; i < candidates.size(); i++) {
		if(i >=1 && candidates[i]==candidates[i-1] && used[i-1]==false) { //数层去重
			continue;
		}
		used[i] = true;
		path.push_back(candidates[i]);
		backtracking(candidates,target,i+1,sum+candidates[i]);
		path.pop_back();
		used[i] = false;
	}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
	vector<bool> used(candidates.size(),false);
	sort(candidates.begin(),candidates.end()) ;//排序
	backtracking(candidates,target,0,0,used);
	return result;
}

131. 分割回文串

题目描述

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

回文串 是正着读和反着读都一样的字符串。

示例 1:

输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]

示例 2:

输入:s = “a”
输出:[[“a”]]

思路分析

本题这涉及到两个关键问题:

  • 切割问题,有不同的切割方式
  • 判断回文

切割问题类似组合问题

对于字符串abcdef:

  • 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中在选组第三个…。

  • 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中在切割第三段…。

⭐️⭐️⭐️ 注意: 组合的index是选取的组合数的下标位置.而切割的index是切割位置数的index,但是实际位置却是index+1,所以下一次传入的将是index+1.

树形结构图
如图所示 index=0,切割位置1,下一轮传入的也是1,当所有的切割完毕,下一次传入的是 arr.size().这个将作为递归的结束条件
<冲刺大厂之算法刷题>回溯算法_第7张图片

算法设计

  • 确定递归函数参数和返回值
    参数:递归的字符串s,每层递归的startIndex
    全局变量path存放切割后的回文子串,result存放结果集,所以不需要返回值.
  • 确定递归结束条件
    如果起始位置已经>=s的大小,说明已经找到了一组分割方案了,结束递归即可
  • 确定单层递归逻辑
    首先判断这个子串[startIndex,i]是不是回文,如果是回文,就加入在vector path中,然后递归,完毕后回溯. 如果不是则 i++ 判断下一个…

回文串的判断我们可以使用双指针法或者反转字符串进行比较的方法

参考代码

#include
using namespace std;

vector<string> path;
vector<vector<string>> result;

bool isHuiWen(string& str,int i,int j){//判断是否是回文串 
	for(;i<j;i++,j--) {
		if(str[i]!=str[j]){
			return false;
		}
	}
	return true;
}
void backtracking(string& s,int startIndex){
	//结束条件 
	if(startIndex>=s.size()){
		result.push_back(path);
		return;
	}
	
	for(int i = startIndex; i < s.size();i++){
		if(isHuiWen(s,startIndex,i)){
			path.push_back(s.substr(startIndex,i-startIndex+1));
		}else{
			continue;
		}
		backtracking(s,i+1);
		path.pop_back();
	}
}

vector<vector<string>> partition(string s) {
	backtracking(s,0);
	return result;
}

93. 复原 IP 地址

题目描述

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。

例如:“0.1.2.201” 和 “192.168.1.1” 是 有效 IP 地址,但是 “0.011.255.245”、“192.168.1.312” 和 “[email protected]” 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 ‘.’ 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

示例 1:

输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]

示例 2:

输入:s = "0000"
输出:["0.0.0.0"]

示例 3:

输入:s = "101023"
输出:["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]

思路分析

这个题和上文的分割回文串很相似,先截取,再判断,符合则进行继续递归
<冲刺大厂之算法刷题>回溯算法_第8张图片
⭐️⭐️⭐️ 回溯三部曲

  • 确定递归参数和返回值
    参数:目标串s,搜索的起始位置startIndex,添加 .的数量pointNum
    返回值:因为使用了全局变量保存结果,所以无需返回值.
  • 确定递归结束条件
    本题虽然说是用三个.做分割,但是也要判断最后一段是否是合格串
  • 确定单层递归逻辑
    先判断是否是合格串,如果是则添加点. 然后递归,回溯,进行下一轮循环. 如果不是,则直接 i++

合格串需要满足的条件

  • 段位以0为开头的数字不合法
  • 段位里有非正整数字符不合法
  • 段位如果大于255了不合法

参考代码

#include
using namespace std;

vector<string> result;
//本题和上题非常相似,只是判断是否是IP这个过程复杂了些
void backtracking(string& s,int startIndex,int pointNum) {
	if(pointNum == 3) {
		if(isValid(s,startIndex,s.size()-1)) {
			result.push_back(s);
		}
		return;
	}
	for(int i = startIndex; i < s.size(); i++) {//i子串结束位置的索引  [startIndex,i]
		if(isValid(s,startIndex,i)) {
			s.insert(s.begin()+i+1,'.');
			backtracking(s,i+2,pointNum+1);
			s.erase(s.begin()+i+1);
		} else {
			continue;
		}
	}
}


bool isValid(string& s,int begin,int end) {
	if(begin > end) { //空字符
		return false;
	}
	if(s[begin]=='0'&&begin != end) { //1.是否以‘0’开头
		return false;
	}
	int num  = 0;
	for(int i = begin; i<=end; i++) {
		if(s[i] < '0' || s[i]>'9') { //3.是否不是数字
			return false;
		}
		num = num*10 + s[i]-'0' ;//注意不能写成 num *=10 + s[i]-'0'
		if(num > 255) { //2.数字大小是否 》=0 && 《=255
			return false;
		}
	}
	return true;
}

vector<string> restoreIpAddresses(string s) {
	backtracking(s,0,0);
	return result;
}

78. 子集

题目描述

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0]
输出:[[],[0]]

思路分析

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!

子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。

那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!

以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:

<冲刺大厂之算法刷题>回溯算法_第9张图片

回溯三部曲

  • 确定递归参数和返回值

递归参数:需要遍历的数组 vector &nums,每层遍历的起始下标startIndex .

返回值:由于定义全局变量来保存最终结果集以及单一结果,所以并不需要返回值.

  • 确定结束条件

由于这次是寻找所有的组合,所以并不需要结束条件.没递归一层就把对应的结果放入到结果集中.

  • 确定单层递归逻辑

将当前组合数加入单一结果集,递归,回来后进行回溯.进行下一次循环.

参考代码

#include
using namespace std;

vector<int> path;
vector<vector<int>> result;

void backtracing(vector<int>& nums,int startIndex){
	result.push_back(path);
	for(int i = startIndex; i < nums.size(); i++) {
		path.push_back(nums[i]);
		backtracing(nums,i+1);
		path.pop_back();
	}
}

vector<vector<int>> subsets(vector<int>& nums) {
	backtracing(nums,0);
	return result;
}

90. 子集 II

题目描述

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

示例 1:

输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

示例 2:

输入:nums = [0]
输出:[[],[0]]

思路分析

这个题的数组nums可能包含重复元素,则需要对树层进行去重. 目前我们有三种去重方法:利用vector used , 利用startIndex, 利用ordered_set ,下面我们将逐一进行演示.

用示例中的[1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序

<冲刺大厂之算法刷题>回溯算法_第10张图片

本题不再分析递归三部曲了,关键部分将添加相关注释.

参考代码

#include
using namespace std;
//https://leetcode.cn/problems/subsets-ii
vector<int> path;
vector<vector<int>> result;

//其实相比上题只是多了个同层去重的步骤
void backtracing(vector<int>& nums,int startIndex,vector<bool>& used){
	result.push_back(path);
	for(int i = startIndex; i < nums.size(); i++){
		if(i>=1 && used[i-1]== false && nums[i-1]==nums[i]){//树层去重 
			continue;
		}
		used[i]  = true;
		path.push_back(nums[i]);
		backtracing(nums,i+1,used);
		used[i]  = false;
		path.pop_back();
	}
}

vector<vector<int>> subsetsWithDup(vector<int>& nums) {
	vector<bool> used(nums.size(),0);
	sort(nums.begin(),nums.end());
	backtracing(nums,0,used);
	return result;
}

491. 递增子序列

题目描述

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

示例 1:

输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

示例 2:

输入:nums = [4,4,3,2,1]
输出:[[4,4]]

思路分析

本题的一个坑: 不能对nums进行排序, 不然就和示例对不上了.

以[4, 7, 6, 7]这个数组为例,抽象为树形结构如图:

<冲刺大厂之算法刷题>回溯算法_第11张图片

递归三部曲:

  • 确定递归参数和返回值

递归参数:虽然求的是序列,有顺序,但是因为元素不可以重复,并且有一定的顺序(从前往后的方向),所以需要startIndex. 当然也需要递归的数组: vector&nums

返回值: 因为定义了全局变量,所以并不需要

  • 确定递归结束条件

当元素个数 path.size() >= 2,可将临时组合存入结果集中.

  • 确定单层递归逻辑

由于数组中有重复元素,所以需要进行去重.而且是无序的,所以采用 ordered_set uset. 另外序列也需要递增

每次循环判断当前组合数是否本层重复以及递增,如果是,则放入临时组合集=> uset更新=>递归=>回溯=>循环下一个数. 如果不是,则进入跳过当前的数,进行下一个循环.

备注:因为 -100 <= nums[i] <= 100, 数据范围较小所以我们可以使用 int used[201]去重.这个相比ordered_set效率更高.

参考代码

#include
using namespace std;

vector<int> path;
vector<vector<int>> result;
//https://leetcode.cn/problems/increasing-subsequences
//void backtracing(vector& nums,int startIndex) {
//	if(path.size() > 1 ) {//回溯结束条件
//		result.push_back(path);
//	}
//	unordered_set uset;
//	for(int i = startIndex; i < nums.size(); i++) {
//		if((!path.empty() && path.back() > nums[i]) || uset.find(nums[i]) != uset.end() ) {//如果不是递增,或者元素同层已经使用过了
//			continue;
//		}
//		uset.insert(nums[i]);
//		path.push_back(nums[i]);
//		backtracing(nums,i+1);//递归
//		path.pop_back();//回溯
//	}
//}

//由于-100 <= nums[i] <= 100,所以可以使用 数组来做hash表
void backtracing(vector<int>& nums,int startIndex) {
	if(path.size() > 1 ) {//回溯结束条件
		result.push_back(path);
	}
	int used[201] = {0};// -100-100 0-200
	for(int i = startIndex; i < nums.size(); i++) {
		if((!path.empty() && path.back() > nums[i]) || used[nums[i]+100] == 1 ) {//如果不是递增,或者元素同层已经使用过了
			continue;
		}
		used[nums[i]+100] = 1;
		path.push_back(nums[i]);
		backtracing(nums,i+1);//递归
		path.pop_back();//回溯
	}
}



vector<vector<int>> findSubsequences(vector<int>& nums) {
	backtracing(nums,0);//这个题不能进行排序..
	return result;
}

46. 全排列

题目描述

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例 1:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1]
输出:[[1]]

思路分析

排列里面的数字有一定顺序性,而组合与顺序无关.这决定了startIndex的起始位置.

以[1,2,3]为例,抽象成树形结构如下:

<冲刺大厂之算法刷题>回溯算法_第12张图片

回溯三部曲

  • 确定递归参数和返回值

递归参数:递归的数组vector&nums ,用于标记元素的数组 vector& used

递归返回值:定义全局变量,无需返回值.

  • 确定递归结束条件

全排列要求排列后的数据和数组里面的数据个数相同. 所以结束条件: path.size()==nums.size()

  • 确定单层递归逻辑

判断当前循环的数是否被使用过,没被使用=>放入临时全排列集=>改变used=>递归=>回溯=>循环下一个数

注意:used数组常用于组合中树层/树枝的去重(常伴随着元素先排序), 也常用于排列中数据的标记. ordered_set常用于进行树层去重.

参考代码

#include
using namespace std;

vector<int> path;
vector<vector<int>> result;

void backtracing(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(used[i] == 1) {//如果已经用过了,则不再使用
			continue;
		}
		path.push_back(nums[i]);
		used[i] = 1;//做标记
		backtracing(nums,used);//递归
		used[i] = 0;//回溯
		path.pop_back();
	}
}

vector<vector<int>> permute(vector<int>& nums) {
	//因为backtracing不需要同层去重,所以不需要对nums进行排序 
	vector<int> used(nums.size(),0);//这里没必要使用hash表,直接使用一个数组标记即可。
	backtracing(nums,used);
	return result;
}

47. 全排列 II

题目描述

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

示例 1:

输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

示例 2:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

思路分析

这道题目和 46.全排列 的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列,牵涉到树层的去重. 我们可以采取 vector used , 利用ordered_set进行去重.

我以示例中的 [1,1,2]为例 ,抽象为一棵树,去重过程如图:

<冲刺大厂之算法刷题>回溯算法_第13张图片

参考代码

#include
using namespace std;

vector<int> path;
vector<vector<int>> result;
void backtracing(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(i>=1 && nums[i] == nums[i-1] && used[i-1]==false) { //同层去重
			continue;
		}
		if(used[i]==false) { //如果当前元素没有被使用
			path.push_back(nums[i]);
			used[i] = true;
			backtracing(nums,used);
			used[i] = false;
			path.pop_back();
		}
	}
}

vector<vector<int>> permuteUnique(vector<int>& nums) {
	vector<bool> used(nums.size(),0);
	sort(nums.begin(),nums.end());//因为要同层去重,所以要排序 
	backtracing(nums,used) ;
	return result;
}

332.重新安排行程

题目描述

给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。

所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。

  • 例如,行程 ["JFK", "LGA"]["JFK", "LGB"] 相比就更小,排序更靠前。

假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。

示例 1:

<冲刺大厂之算法刷题>回溯算法_第14张图片

输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]

示例 2:

<冲刺大厂之算法刷题>回溯算法_第15张图片

输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出:["JFK","ATL","JFK","SFO","ATL","SFO"]
解释:另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"] ,但是它字典排序更大更靠后。

思路分析

这道题目的几个难点:

  1. 一个行程中,如果航班处理不好容易变成一个圈,成为死循环
  2. 有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
  3. 使用回溯法(也可以说深搜) 的话,那么终止条件是什么呢?
  4. 搜索的过程中,如何遍历一个机场所对应的所有机场。

1、如何理解死循环

<冲刺大厂之算法刷题>回溯算法_第16张图片

从例子可以看出 ,出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环

2、该记录映射关系

有多种解法,字母序靠前排在前面,如何该记录映射关系呢 ?

一个机场映射多个机场,机场之间要靠字母序排列。一个机场映射多个机场,可以使用std::unordered_map,如果让多个机场之间再有顺序的话,就是用std::map 或者std::multimap 或者 std::multiset

这样存放映射关系可以定义为 unordered_map> targets 或者 unordered_map> targets

含义如下:

  • unordered_map targets:unordered_map<出发机场, 到达机场的集合> targets
  • unordered_map> targets:unordered_map<出发机场, map<到达机场, 航班次数>> targets

这两个结构,我选择了后者,因为如果使用unordered_map> targets 遍历multiset的时候,不能删除元素,一旦删除元素,迭代器就失效了。

再说一下为什么一定要增删元素呢,正如前面图中所示,出发机场和到达机场是会重复的,搜索的过程没及时删除目的机场就会死循环。

所以搜索的过程中就是要不断的删multiset里的元素,那么推荐使用unordered_map> targets

在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。

如果“航班次数”大于零,说明目的地还可以飞,如果如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。

相当于说我不删,我就做一个标记!

算法设计

以 [[“JFK”, “KUL”], [“JFK”, “NRT”], [“NRT”, “JFK”]为例,抽象为树形结构如下:

<冲刺大厂之算法刷题>回溯算法_第17张图片

回溯三部曲

  • 递归参数和返回值

递归参数:航班的映射关系 unordered_map> targets我们定义成全局变量(控制参数的个数), 还需要ticketNum表示一共有多少个航班(用于结束条件),还需要一个vector 记录结果.

返回值:我们需要找到一个航程,就是在树形结构中找一条通往叶子节点的路线.所以我们只需要找到就直接返回就可以了,也不用继续往下寻找了.

**备注:**本回溯函数中的参数都可作为全局变量.

  • 递归结束条件

根据观察得知,如果最终的行程里的机场个数要比航线列表中的航班数量+1.我们就找到了一个形成,就把所有的航班串到一起了,递归结束.

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, result)) return true;
        result.pop_back();
        target.second++;
    }
}

参考代码

#include
using namespace std;

// unordered_map<出发机场,map<到达机场,航班次数>>
unordered_map<string,map<string,int>> targets;

bool backtracking(int ticketNum,vector<string>& result) {
	if(result.size() == ticketNum+1) { //结束条件
		return true;
	}
	for(pair<const string,int> &target : targets[result[result.size()-1]]) {//以结束点为起始点开往新的航线。 注意:target必须设定为引用,因为要修改targets的属性值 
		if(target.second > 0) { //如果还有航班,就起飞
			result.push_back(target.first) ;
			target.second--;
			if(backtracking(ticketNum,result)) {//如果已经找到了航班次序,则不再进行寻找,直接进行返回
				return true;
			}
			target.second++;
			result.pop_back();

		}
	}
	return false;
}

vector<string> findItinerary(vector<vector<string>>& tickets) {
	targets.clear();
	vector<string> result;
	for(vector<string> vec : tickets) {//vec既可以用引用也可以再复制一份 
		targets[vec[0]][vec[1]]++;//记录映射关系
	}
	result.push_back("JFK") ;
	backtracking(tickets.size(),result);
	return result;
}

51. N皇后

题目描述

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

示例 1:

<冲刺大厂之算法刷题>回溯算法_第18张图片

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:[["Q"]]

思路分析

皇后们的约束条件:

  1. 不能同行
  2. 不能同列
  3. 不能同斜线

确定完约束条件,来看看究竟要怎么去搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树。

下面用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:

<冲刺大厂之算法刷题>回溯算法_第19张图片

可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。

那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了

回溯三部曲

  • 确定参数和返回值

参数:当前进行到棋盘的行数row,棋盘规模n,以及整个棋盘vector &chessboard

返回值:当我们把所有的棋子摆完,也就是把棋盘遍历一遍,则就结束,把棋盘加入结果集即可.因为结果有多个,所以并不需要返回值.

  • 确定递归结束条件

当进行的row和棋盘的规模相等,则递归结束

  • 确定单层递归逻辑

判断当前位置是否可以放置,如果可以则放棋子.然后递归进入下一行.递归回来进行回溯尝试其他情况.

参考代码

#include
using namespace std;

vector<vector<string>> result;

void backtracing(int row,vector<string>& checkerboard,int n){
	if(row == n){//结束条件 
		result.push_back(checkerboard) ;
		return;
	}
	for(int col = 0;col < n;col++) {
		if(isValid(row,col,checkerboard,n)){
			checkerboard[row][col] = 'Q';
			backtracing(row+1,checkerboard,n);
			checkerboard[row][col] = '.';
		}
	}
}

bool isValid(int row,int col,vector<string>& checkerboard,int n){
	//是否在同一列
	for(int i = 0;i < row;i++) {
		if(checkerboard[i][col] == 'Q'){
			return false;
		}
	}
	//是否在对角线上
	for(int i = row-1,j = col-1; i >=0 && j>=0; i--,j--){
		if(checkerboard[i][j] == 'Q'){
			return false;
		}
	}
	
	
	//是否在斜对角线上
	for(int i = row-1,j = col+1; i >=0 && j <n;i--,j++) {
		if(checkerboard[i][j] == 'Q'){
			return false;
		}
	}
	return true;
}

vector<vector<string>> solveNQueens(int n) {
	string s(n,'.');
	vector<string> path(n,s);
	backtracing(0,path,n);
	return result;
}

37. 解数独

题目描述

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

示例:

<冲刺大厂之算法刷题>回溯算法_第20张图片

输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]

解释:输入的数独如上图所示,唯一有效的解决方案如下所示:

<冲刺大厂之算法刷题>回溯算法_第21张图片

思路分析

N皇后问题 是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来来遍历列,然后一行一列确定皇后的唯一位置。

本题就不一样了,本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比N皇后更宽更深

因为这个树形结构太大了,我抽取一部分,如图所示:

<冲刺大厂之算法刷题>回溯算法_第22张图片

回溯三部曲

  • 确定参数和返回值

参数:由于数独的行列已知,所以参数只需要传入九宫格vector>& board即可.

返回值:由于答案只有一种,找到了就直接返回,没找到就尝试下一种情况,所以返回值是bool类型.

  • 确定递归结束条件

递归的话只要把数字填满就返回true,然后递归逐渐跳出并结束.

  • 确定单层递归逻辑

由于每一行需要填充多个数,也需要填充多个列,所以需要双层循环.如果当前位置需要填充,就先判断要填充的数是否合法(同行,同列,九宫格都不重复),如果合法就尝试下一个位置,不合法就尝试下一个数字.

参考代码

#include
using namespace std;

bool backtracing(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(backtracing(board)) {//如果找到了方案,则立即返回 
						return true;
					}
					board[i][j] = '.' ;
				}
			}
			return false;
		}
	}
	return true;
}

bool isValid(int row,int col,char k,vector<vector<char>>& board) {
	//同行
	for(int j = 0; j < board[0].size(); j++) {
		if(board[row][j]==k) {
			return false;
		}
	}
	//同列
	for(int i = 0; i < board.size(); i++) {
		if(board[i][col]==k) {
			return false;
		}
	}

	//同一个九宫格
	int startX = ( row / 3) * 3;
	int startY = (col / 3) * 3;
	for(int i = startX ; i < startX+3; i++) {
		for(int j = startY; j<startY+3; j++) {
			if(board[i][j]==k) {
				return false;
			}
		}
	}
	return true;
}

void solveSudoku(vector<vector<char>>& board) {
	backtracing(board);
}

你可能感兴趣的:(LeetCode刷题,算法,java,数据结构)