目录
一、回溯法(试探法)
1.1回溯概念
通过穷举所有可能情况来找到所有解法,若发现当前选择并不是可行解,舍弃当前值,并对前面的步骤作出修改,并尝试重新选择找到可行解。走不通就回退再走的方法就是回溯算法。解决回溯问题,实际上就是一个决策树的遍历过程,只需考虑如下问题:
- 路径:也就是已经做出的选择。
- 选择列表:也就是你当前可以做的选择。
- 结束条件:也就是到达决策树底层,无法再做选择的条件。
回溯本质:穷举所有可能,选出需要的答案,其效率并不高,若想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
回溯核心: for 循环里面的递归,递归调用前「做选择」,在递归调用之后「撤销选择」
回溯算法和递归函数通常是放在一起说,相辅相成,递归的实现过程就是回溯过程
回溯法解决的都是在集合中递归查找子集,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
回溯算法中函数返回值一般为void,回溯算法属于树形结构,遍历树形结构一定要有终止条件:叶子节点, 把这个答案存放起来,并结束本层递归。
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
for循环理解为横向遍历,backtracking(递归)纵向遍历,搜索叶子节点就是终止条件。
回溯函数伪代码(回溯三部曲):
- 递归函数和参数返回值
- 确定终止条件
- 单层递归逻辑
void backtracking(参数) { //回溯法一般没有返回值
if (终止条件) {
//存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
1.2 回溯三部曲
(1)递归函数的返回值以及参数
定义两个全局变量result数组、path数组,path数组存放符合条件的单一结果,result数组存放符合条件结果的集合。同时定义一个变量startIndex,记录每一层开始遍历起始位置
(2)回溯函数终止条件
即到达叶子节点,path数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
result二维数组,把path保存起来,并终止本层递归。
(3)单层搜索的过程
搜索过程就是一个树型结构的遍历过程,如下图,for循环横向遍历,递归过程是纵向遍历。
for循环每次从startIndex开始遍历,然后用path保存取到的节点i。backtracking(递归函数)通过不断调用一直往深处遍历,遇到叶子节点就返回。
二、组合问题
2.1【编程题】组合问题
【编程题】给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
解析:n相当于树的宽度,k相当于树的深度
1.假设开始集合是 1,2,3,4, k为2,从左向右取数,取过的数,不重复取。
2.第一次取1,集合变为2,3,4 ,只需要再取一个数即可,得到集合[1,2] [1,3] [1,4],以此类推。
3.每次搜索到叶子节点,就找到了一个结果。只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
注意!!!
res.add(list)
浅拷贝:添加一个list后,list如果改变,res里的值会
跟着改变。
res.add(new ArrayList(list))
深拷贝:添加一个list后,list若改变,res里的值不会
跟着改变。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracing(n,k,1);
return result;
}
public void backtracing(int n,int k,int startIndex) {
if(path.size() == k) { //终止条件
result.add(new ArrayList<>(path)); // path大小为k,即找到其中一个子集,需要将其放入result
return;
}
for(int i = startIndex; i <= n-(k-path.size()) + 1; i++) { //i <= n-(k-path.size()) + 1 属于剪枝操作
path.add(i);
backtracing(n,k,i+1); //回溯
path.removeLast(); //撤销上一个元素,进行下一步操作
}
}
}
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]]
class Solution {
List<List<Integer>> result = new ArrayList<>(); //定义两个数组用于存放搜索到的结果
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracing(k, n, 1, 0);
return result;
}
private void backtracing(int k, int n, int startIndex, int sum) { //回溯函数
if (sum > n) return; //和不能 > n
if (path.size() > k) return; //每个子集大小不能 > k
if (sum == n && path.size() == k) { //符合求和为n,大小为k表示符合结果,存入result
result.add(new ArrayList<>(path));
return;
}
for(int i = startIndex; i <= 9; i++) { // 遍历每一层,及其实现构成
path.add(i); //path数组存入每一次遍历的结果
sum += i; //求和,
backtracing(k, n, i + 1,sum); //判断是否是n
sum -= i; //不符合则退回上一步,继续判断
path.removeLast(); //不符合时,path也需要拿出上一次放入的值
}
}
}
2.3【编程题】电话号码的字母组合
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
k个元素对应k层,每层元素只取一个元素
class Solution {
List<String> result = new ArrayList<>(); //设置全局列表 存储 最后组合结果
public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) { //若字符串为空,直接返回result,空值[]
return result;
}
//设置字符串数组,对应下标为数字0-9,下标对应处的字母为其映射对应字母。 为保持对应关系,新增了两个无效数组""
String[] str = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
backTracking(digits, str, 0);
return result; //结束时 返回 组合
}
StringBuilder temp = new StringBuilder(); //StringBuild更为高效,后续大量拼接可用append,线程安全的
public void backTracking(String digits, String[] str, int num) { //如digits为"23",num 记录遍历的第几个数字,则str表示2对应的 abc
if (num == digits.length()) { //遍历完成,记录每次得到的字符串
result.add(temp.toString());
return;
}
String str1 = str[digits.charAt(num) - '0']; // 将num指向的数字转为int,并取下表对应的字符串
for (int i = 0; i < str1.length(); i++) {
temp.append(str1.charAt(i));
backTracking(digits, str, num + 1);
temp.deleteCharAt(temp.length() - 1); //剔除末尾的继续尝试
}
}
}
2.4【编程题】组合总和
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的所有不同组合 ,并以列表形式返回。
candidates
中的 同一个 数字可无限制重复选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 对于给定的输入,保证和为 target
的不同组合数少于 150
个。
解析:先对数组进行排序,可更好的进行剪枝操作
1.确定函数返回值和参数(arr, target, sum, startIndex)
2.确定终止条件:
if(sum > target) return;
if(sum == target) {
result.push(path);
return;
}
3.单层搜索逻辑:单层for循环依然是从startIndex开始,搜索candidates集合。
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push(candidates[i]);
backtracking(candidates, target, sum, i); // 关键点:不用i+1了,表示可以重复读取当前的数
sum -= candidates[i]; // 回溯
path.pop_back(); // 回溯
}
剪枝优化Java代码实现
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // 先进行排序,方便剪枝
backtracking(candidates, target, 0, 0); //回溯操作
return res;
}
public void backtracking(int[] candidates, int target, int sum, int index) {
if (sum == target) { // 找到数字 和为 target 的组合
res.add(new ArrayList<>(path)); //添加到path组合
}
for (int i = index; i < candidates.length; i++) {
if (sum + candidates[i] > target) break; // 若求和 > target 则终止遍历
path.add(candidates[i]); //未超出target则加入到path,再次进行回溯操作
backtracking(candidates, target, sum + candidates[i], i);
path.remove(path.size() - 1); // 回溯之后,移除 path 最后添加的一个元素
}
}
}
三、分割回文串
3.1【编程题】分割回文串
给一个字符串 s
,请将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。回文串 是正着读和反着读都一样的字符串。
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
递归纵向遍历,for循环横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
解析:伪代码编写
1.确定函数的返回值和参数(str,startIndex)
2.确定终止条件
void backtracking (String str, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= str.size()) {
result.push(path);
}
}
3.单层搜索逻辑:到达叶子节点收集结果
for (int i = startIndex; i < str.size(); i++) {
if (isPalindrome(str, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在str中的子串
string str1 = str.substr(startIndex, i - startIndex + 1);
path.push(str1);
} else { // 如果不是则直接跳过
continue;
}
backtracking(str, i + 1); // 寻找i+1为起始位置的子串
path.pop(); // 回溯过程,弹出本次已经填在的子串
}
整体代码实现:
class Solution {
List<List<String>> lists = new ArrayList<>(); //lists存放子串属于回文串的集合
Deque<String> deque = new LinkedList<>();
public List<List<String>> partition(String s) {
backTracking(s, 0);
return lists;
}
private void backTracking(String s, int startIndex) {
if (startIndex >= s.length()) { //终止条件:遍历到最后一个元素后
lists.add(new ArrayList(deque)); //每次都把找到的子串放入到lists ,终止处是符合回文的
}
for (int i = startIndex; i < s.length(); i++) {
if (isPalindrome(s, startIndex, i)) { //如果是回文子串,则将其添加到deque队列
String str = s.substring(startIndex, i + 1);
deque.addLast(str);
} else {
backTracking(s, i + 1); //起始位置后移,保证不重复
deque.removeLast();
}
}
}
//判断是否是回文串
private boolean isPalindrome(String s, int startIndex, int end) {
for (int i = startIndex, j = end; i < j; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
}