该部分重点在于,如何绘制回溯图,如何根据回溯图找到回溯模板的三个关键点,厘清回溯逻辑。通过习题练习加深巩固对回溯模板的理解,顺利拿下回溯这一重难点。
回溯模板:递归出口;局部枚举;放下前任
接下来,我们就根据题目,逐题分析模板如何使用的,怎么灵活的处理模板中“局部枚举”不部分和“放下前任”部分的。
1.组合总和问题
题目见LeetCode39,描述为:给定一个无重复元素的整数数组candidates和目标证书target,找出candidates中可以使数字和为目标数target的所有不同组合,并以列表的形式返回。
题目分析:对于candidates=[2,3,6,7],target=7,我们通过画图的方式模拟回溯方式选择的过程。
通过理解上面的回溯图,我们可以清晰的发现回溯模板三个要素!
递归出口:target==0找到答案,记录path;targe<0该条路径不是答案,未找到路径
局部枚举:当前枚举的数字是candidates[i],下一次枚举的数字是candidates[i+1]
放下前任:先枚举candidates[i],然后寻找目标值为target-candidates[i]的所有路径,找寻完毕后,将candidates[i]从path数组中移除,表示该条路径已经找寻完毕,枚举下一个数字进行目标和路径寻找(相当于前任已经过去了,要重新收拾行李继续往前找寻真爱!)。
需要特别注意的是,面对回溯问题,当函数的局部变量发生修改时,也需要判断该局部变量是否会影响后续枚举逻辑,因此在“放下前任”模块,也需要对关键局部变量做回溯处理!
厘清回溯模板思路后,直接上代码!
List<List<Integer>> res = new ArrayList<>(); //记录答案
List<Integer> path = new ArrayList<>(); //记录当前正在访问的路径
public List<List<Integer>> combinationSum(int[] candidates, int target) {
myDfs(candidates, 0, target);
return res;
}
public void myDfs(int[] c, int idx, int target) {
//递归出口
if(target < 0){
return;
}
if(target == 0){
res.add(new ArrayList<>(path));
return;
}
for (int i = idx; i < c.length; i++) {
//局部枚举
if(c[i] <= target){
path.add(c[i]);
myDfs(c, i, target - c[i]);
//放下前任
path.remove(path.size() - 1);
}
}
}
2.分割回文串
题目见LeetCode131,描述为,给定一个字符串s,将s分成一些子串,使得每个子串都是回文串,返回所有的分割方案。
题目分析:对于s="aab",我们通过画图的方式模拟回溯方式选择的过程。
通过理解上面的回溯图,我们再次梳理回溯模板三个要素!
递归出口:整个字符串都已经切割,记录切割方法;
局部枚举:当前枚举的切割方案按i个字符长度切割,下一次枚举的切割方案按i+1个字符长度切割。当前切割的子串是回文串,记录切割方式;当前切割的子串不是回文串,继续向后枚举。
放弃前任:先枚举当前的切割方案,再完成子串的回文切割任务,完成子串回文切割任务后,将当前枚举的切割方案回溯。
厘清回溯模板思路后,直接上代码!
List<List<String>> lists = new ArrayList<>();
Deque<String> deque = new LinkedList<>();
public List<List<String>> partition(String s) {
myBackTracking(s, 0);
return lists;
}
private void myBackTracking(String s, int startIndex) {
//递归出口
if(startIndex >= s.length()){
lists.add(new ArrayList<>(deque));
return;
}
for (int i = startIndex; i < s.length(); i++) {
//局部枚举
if(isPalindrome(s, startIndex, i)){
deque.offer(s.substring(startIndex, i + 1));
}else{
continue;
}
myBackTracking(s, i + 1);
//放下前任
deque.pollLast();
}
}
3.子集问题
题目见LeetCode78,描述为:给你一个整数数组,返回该数组的幂集(所有子集)。
与组合、分割类的回溯问题不同,子集类的回溯问题需要找到所有情况,而前者仅仅需要找到满足要求的结果。我们通过下面的回溯图进一步加深对这句话的理解!
题目分析:对于s="123",我们通过画图的方式模拟回溯方式选择的过程。
来继续重复上述过程,找寻解题思路!通过理解上面的回溯图,我们再次梳理回溯模板三个要素!
递归出口:数组已经全部枚举
局部枚举:当前枚举nums[i]加入集合,下一次枚举nums[i+1]加入集合。加入集合就记录子集情况!
放弃前任:当枚举nums[i]加入集合后,需要完成nums剩余元素子集寻找问题,然后将当前nums[i]移除集合,完成回溯。
厘清回溯模板思路后,直接上代码!
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
//空集合也是一个子集
if (nums.length == 0) {
result.add(new ArrayList<>());
return result;
}
subsetsHelper(nums, 0);
return result;
}
private void mySubsetsHelper(int[] nums, int startIndex) {
result.add(path);
if(startIndex >= nums.length){
return;
}
for (int i = startIndex; i < nums.length; i++) {
path.add(nums[i]);
mySubsetsHelper(nums, i + 1);
path.remove(path.size() - 1);
}
}
4.排列问题
题目见LeetCode46,描述为:给定一个数组的全排列
题目分析:通过前面三道题的练习,我相信全排列的回溯图已经复现在你的脑海里了,而且我们需要练习的就是拿到陌生的问题,自动浮现回溯图,然后找到模板中三个关键问题,从而顺利解决问题。
递归出口:数组已经全部枚举,记录排列顺序
局部枚举:当前枚举剩余集合中第i个元素加入排列,下一次可以枚举剩余集合中剩余的元素加入排列(使用访问标记数组完成)
放下前任:枚举当前元素加入排列,再完成剩余集合全排列子任务,然后将当前枚举的元素移除出排列,完成回溯!
结合代码,我们再次梳理回溯模板!
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] used;
public List<List<Integer>> permute(int[] nums) {
if (nums.length == 0) {
return result;
}
used = new boolean[nums.length];
myPermuteHelper(nums);
return result;
}
private void myPermuteHelper(int[] nums) {
if(path.size() == nums.length){
result.add(new ArrayList<>(path));
}
for (int i = 0; i < nums.length; i++) {
//排列中使用过nums[i]
if(used[i]){
continue;
}
path.add(nums[i]);
used[i] = true;
myPermuteHelper(nums);
used[i] = false;
path.remove(path.size() - 1);
}
}
5.字母大小写全排列
题目见LeetCode784,描述为:给定一个字符串s,将s中的字母转换大小写完成字母大小全排列!
题目分析:对于s="abc",我们通过画图的方式模拟回溯方式选择的过程。
来吧!继续通过理解上面的回溯图,又一次梳理回溯模板三个要素!
递归出口:枚举了全部字符
局部枚举:枚举当前字母为“变换字母”(原本是大写字母就枚举小写字母),下一次枚举原字母。
放下前任:枚举当前字母后,再完成剩余字母的全排列任务,然后将当前枚举的字母进行还原,完成回溯!
字母的大小写变换操作,可以通过异或运算完成,'a'^32=='A'
厘清回溯模板思路后,直接上代码!
List<String> res = new ArrayList<>();
public List<String> letterCasePermutation(String s) {
char[] cs = s.toCharArray();
myDfs(cs, 0);
return res;
}
void myDfs(char[] cs, int idx) {
res.add(String.valueOf(cs));
for (int i = idx; i < cs.length; i++) {
if(isDigit(cs[i])) continue;
cs[i] = changeLetter(cs[i]);
myDfs(cs, i + 1);
cs[i] = changeLetter(cs[i]);
}
}
6.单词搜索
题目见LeetCode79,描述为:给定一个m x n的二维字符网格board和一个单词word,如果word存在于网格中,返回true;否则,返回false。单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”指的是水平相邻或垂直相邻。
题目分析:从上到下,从左到右遍历网格,每个坐标判断是网格字符是否是word的第k个字符,如果能找到word的第k字符,则返回true,否则返回false。
递归出口:board[i][j]!=word[k],说明该条路径搜索失败;k==word.length说明找到了字符串的结尾,找到网格中路径,可以组成字符串s。
局部枚举:枚举从当前坐标ij出发能否找到word,下一次枚举从左至右、从上至下坐标出发,能否找到word。
放弃前任:枚举当前位置ij后,再完成从ij相邻位置出发搜索word(可能是word)部分的任务,然后将ij设置为未访问,完成回溯!
我们结合代码,再次理解单词搜索的回溯过程!(有些难度!)
public boolean myExist1(char[][] board, String word){
int h = board.length;
int w = board[0].length;
boolean[][] vis = new boolean[h][w];
//局部枚举
for (int i = 0; i < h; i++) {
for (int j = 0; j < w; j++) {
if(dfs1(board, vis, i, j, 0, word)) return true;
}
}
return false;
}
public boolean dfs1(char[][] board, boolean[][] vis, int i, int j, int k, String s){
int h = board.length;
int w = board[0].length;
//递归出口
if(s.charAt(k) != board[i][j]) return false;
if(k == s.length()-1) return true;
//局部枚举
vis[i][j] = true;
int[][] directs={{1,0},{-1,0},{0,1},{0,-1}};
for (int[] direct : directs) {
int newi = i + direct[0];
int newj = j + direct[1];
//越界
if(newi >= h || newj >= w || newi < 0 || newj < 0){
continue;
}
if(!vis[newi][newj]){
//从ij相邻位置出发搜索word(可能是word)部分的任务
if(dfs1(board, vis, newi, newj, k + 1, s)){
return true;
}
}
}
//放弃前任
vis[i][j] = false;
return false;
}
回溯确实是我的薄弱环节,请不要慌张,踏踏实实,稳步前进!朝闻道,夕死可以!
OK,《算法通关村第十八关——回溯白银挑战笔记》结束,喜欢的朋友三联加关注!关注鱼市带给你不一样的算法小感悟!(幻听)
再次,感谢鱼骨头教官的学习路线!鱼皮的宣传!小y的陪伴!ok,拜拜,第十八关第三幕见!