文章目录
- 前言
- 组合总和问题
- 分割回文串
- 子集问题
- 排序问题
- 字母大小写全排列
- 单词搜索
- 总结
前言
提示:我不愿再给你写信了。因为我终于感到,我们的全部通信知识一个大大的幻影,我们每个人知识再给自己写信。 --安德烈·纪德
回溯主要解决一些暴力枚举也搞不定的问题,例如组合、分割、子集、排列、棋盘等。这关我们就看看如何接入。
组合总和问题
参考题目地址:39. 组合总和 - 力扣(LeetCode)
如过不考虑重复的,这个题目和113的那个题目相差无疑,如果可以重复,哪呢是否可以无限制取下去呢?也不会,因为题目也给了说明。每个元素最小为1,因此最多一个target个1.我们画图该怎么做呢,对于序列{2,3,6,7}.target = 7.很明显我们选择1个2,还剩下target = 7 - 2 = 5.然后再选取一个2,还剩下target = target - 2 = 3;之后再选一个2,变成了target = target - 2 = 1,此时最小的为2,不能再选了,就要退出。看看有没有3。ok,有那么第一个结果就是{2,2,3}.
之后我们继续回退到只选1个2的时候,这是2不能选了,从{3,6,7}中选择,如下图所示,没有符合规定的。
依次类推,然后尝试从3和6、7中开始选择。
最终的结果就是{2,2,3}和{2,5}.为了方便,我们可以先对元素做个排序,然后按照上面的过程执行,形成一个树形图:
这个图横向是针对每个元素做暴力枚举,纵向是递归(向下找)也是纵横问题,实现起来代码也不复杂:
public List<List<Integer>> combinationSum(int[] c, int target) {
List<List<Integer>> res = new ArrayList();
List<Integer> path = new ArrayList();
dfs(c,0,target,path,res);
return res;
}
public void dfs(int[] c,int u,int target,List<Integer> path,List<List<Integer>> res){
if(target < 0){
return;
}
if(target == 0){
res.add(new ArrayList(path));
return;
}
for(int i = u; i < c.length; i++){
if(c[i] <= target){
path.add(c[i]);
dfs(c,i,target - c[i],path,res);
path.remove(path.size() - 1);
}
}
}
分割回文串
参考题目地址:131. 分割回文串 - 力扣(LeetCode)
字符串如何判断回文本身就是一道算法题,本题在其之上还要解决一个问题:如何分割?如果暴力切割很麻烦,解决起来也很困难,如果从回溯的角度去思考就很清晰:
我们说回溯本身仍然回进行枚举的,这里也是一样的。切割线(途中的|)切割到字符串的结尾位置,说明找到了一个切割方法。这里就是先试一试,第一次切割’a’,第二次切割’aa‘,第三次切割’aab’。这对应的就是回溯里面的for循环(也就是横向遍历)
我们还要说回溯(需要进行递归操作)第一次切合’a’,剩下的就是‘ab’.递归就是在将其且下一个回文下来,也就是第二个‘a’,剩下的‘b’再交给递归进行一步切割。这就是纵向方面要干的事,依次类推。
至于回溯操作与前面的一样的道理,不再赘述。通过代码就可以发现,切割问题的回溯搜索的过程和嘴和问题的回溯搜索的过程是差不多的。
class Solution {
List<List<String>> res = new ArrayList<>();
Deque<String> path = new LinkedList<>();
public List<List<String>> partition(String s) {
backTracking(s,0);
return res;
}
private void backTracking(String s, int startIndex) {
// 如果起始位置大于s的大小,说明找到了一组分割方案
if (startIndex >= s.length()) {
res.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < s.length(); i++) {
// 如果是回文子串,则记录
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
path.addLast(str);
} else {
continue;
}
// 起始位置后移,保证不重复
backTracking(s, i + 1);
path.removeLast();
}
}
public boolean isPalindrome(String str,int start,int end){
for(int i = start, j = end; i < j; i++,j--){
if(str.charAt(i) != str.charAt(j)){
return false;
}
}
return true;
}
}
子集问题
子集问题也是回溯的经典使用场景。回溯可以画成一种状态树,子集,组合,分割,都可以抽象出来。
但是子集也有它特有的类型:需要找出所有情况。
参考题目:78. 子集 - 力扣(LeetCode)
画图详解:
从图中也可以看出,遍历这颗树的时候,就可以记录下所有的节点,也就是得到子集结果。
那么什么时候停下来呢?起始可以不加终止条件,因为startIdex >= nums.size(),for循环就结束了。
记住:求子集问题,不需要任何的剪枝操作!因为子集就是要遍历整颗树。这样实现起来也比较容易。
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList();
public List<List<Integer>> subsets(int[] nums) {
// 空集也是
if(nums.length == 0 ){
res.add(new ArrayList<>());
return res;
}
backTracking(nums,0);
return res;
}
public void backTracking(int[] nums,int startIndex){
// 记住:加进来,再判断
res.add(new ArrayList(path));
if(startIndex >= nums.length){
return;
}
for(int i = startIndex; i < nums.length; i++){
// 今典小连招
path.add(nums[i]);
backTracking(nums,i+ 1);
path.remove(path.size() - 1);
}
}
}
tips:上面的代码可以通过全局定义res和path,这样简介。这里(也可以转成参数传递的形式)
排序问题
参考题目介绍:46. 全排列 - 力扣(LeetCode)
排列问题也是经典的问题(每次刷是不是都难)。这个问题和前面的组合等问题的一个区别就是后面的还用再选(可重复),例如:1:开始使用了,但是到了2和3的时候仍然要使用一次。本质上是因为【1,2】和【2,1】从集合的角度看一个问题,单是从排列的角度是两个问题。
元素1在【1,2】中已经使用多了,但是在【2,1】中还要再使用一次,所以就不能使用startIndex了,此时可以使用一个used数组来标记已经选择的元素,完整的过程如图所示:
开图就知道了,怎么确定终止条件呢?就是到达叶子节点,那么是么时候到达叶子节点呢?
当时收集元素的数组path的大小达到和nums数组一样不就行了,说明就找到了一个全排列,也就是到达叶子节点了。
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
// 是否选
boolean[] used ;
public List<List<Integer>> permute(int[] nums) {
if(nums.length == 0){
return res;
}
used = new boolean[nums.length];
permuteHepler(nums);
return res;
}
public void permuteHepler(int[] nums){
if(path.size() == nums.length){
res.add(new ArrayList<>(path));
return ;
}
for(int i = 0; i < nums.length; i++){
// 解决01 10 问题
if(used[i]){
continue;
}
used[i] = true;
path.add(nums[i]);
permuteHepler(nums);
// 经典回溯
path.remove(path.size() - 1);
used[i] = false;
}
}
}
字母大小写全排列
参考题目地址:784. 字母大小写全排列 - 力扣(LeetCode)
如果本题去掉数组,只告诉你两个字母ab,然你每个字母变化大小写,那么就是ab,Ab,aB,AB四种情况。就和上面的处理一样了。这里有数字的干扰,我们就需要作过滤数字,只处理字母。还有就是添加大小写的转换问题了。
由于每个字符的大小形式刚好相差32,因此再大小写里面可以换成用c^32来进行转换和恢复。这样代码就简洁多了:
class Solution {
List<String> res = new ArrayList<>();
public List<String> letterCasePermutation(String s) {
dfs(s.toCharArray(),0);
return res;
}
public void dfs(char[] arr, int pos){
// 过滤数字
while(pos < arr.length && Character.isDigit(arr[pos])){
pos++;
}
// 终止条件
if(pos == arr.length){
res.add(new String(arr));
return;
}
// 转换
arr[pos]^=32;
dfs(arr,pos + 1);
// 撤销
arr[pos]^=32;
dfs(arr,pos + 1);
}
}
单词搜索
参考题目介绍:79. 单词搜索 - 力扣(LeetCode)
思路:从上到下,左到右遍历网格,每个坐标递归调用check(i,j,k)函数,i,j表示网格坐标,k表示word的第k个字符,如果能搜索到第k个字符返回true,否则返回false。
check函数的终止条件也很明确:
- 如果i,j的位置和字符上的字符位置k不相符,也就是说这个方面的路径有问题,直接返回false
- 如果搜索到了字符串的结尾,则找到了网格中的一条路径,这条路径上的字符正好可以组成字符串s,返回true。
两种情况都不满足,就把当前网格加入visited数组,visited表示该位置已经访问过了,然后顺着当前网格的坐标的四个方向继续尝试,如果没有找到k开始的子串,则返回回溯状态visited[i ] [j] = false,继续向后面访问。
分析一波复杂度:时间复杂度为O(ML*3^L),M,N 为网格的长度和宽度,L 为字符串word的长度,第一次电泳check函数的时候,回向4个方向进行测试,其余坐标的节点都是3个方向检车,走过的分支不会反方向回去,左移check函数的复杂度为3^L,而网格上面有MN个坐标,且存在剪枝,最坏的情况下是O(MN * 3 ^L).空间复杂符是O(MN) visited数组的空间是O(MN) ,check递归栈的最大深度在最坏的情况下是O(MN)
class Solution {
public boolean exist(char[][] board, String word) {
char[] words = word.toCharArray();
for(int i = 0; i < board.length; i++){
for(int j = 0; j < board[i].length;j++){
if(dfs(board,words,i,j,0)){
return true;
}
}
}
return false;
}
public boolean dfs(char [][] board,char[] words,int i,int j, int k){
// 边界+条件
if(i >= board.length || i < 0 || j >= board[0].length || j < 0 || board[i][j] != words[k]){
return false;
}
// 终止条件
if(k == words.length - 1){
return true;
}
// 防止重复
board[i][j] = '\0';
boolean res = dfs(board,words,i+1,j,k+1) || dfs(board,words,i - 1,j,k+1) || dfs(board,words,i,j - 1,k+1) || dfs(board,words,i,j+ 1,k+1);
// 恢复
board[i][j] = words[k];
return res;
}
}
总结
提示:组合总和问题;分割问题;子集问题;排列问题;搜索问题
如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/
如有不理解的地方,欢迎你在评论区给我留言,我都会逐一回复 ~
也欢迎你 关注我 ,喜欢交朋友,喜欢一起探讨问题。