要求所有可行解的题型使用回溯算法
回溯算法
-
概念:搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一个纯暴力的搜索
-
要点:在回溯点的时候,做了什么需要还原,回溯语句在调用自身函数语句之后
-
一般步骤:
针对给出的问题,确定问题的解空间(简单来说就是暴力法找到所有解)
确定结点的扩展搜索规则
以深度优先的方式搜索解空间,并在搜索过程中,使用剪枝的思想减少算法复杂度 -
模板
void backtracking(){ if(终止条件) { 收集结果 return } for(集合的元素集,类似子节点的个数) { 处理结点 递归函数; 回溯操作 } }
-
心得:
1、在写代码前先画出树形结构,根据树形结构来决定终止条件、不同情况下子节点的个数、如何递归等等
2、写代码时不能犹豫,不能陷入递归而混乱,写递归函数时若不确定实参则先什么都不要写,途中需要再加入
3、写递归函数体时不要尝试以深度的思想去思考,人脑能压几个栈啊?应该专注于树形结构中
题型一、组合问题
相关题目:39、40、77、216
解题思路:组合问题一般是要求找出所有给定长度的组合之和为某个目标值。我们可以引入“离目标值的差”的概念,将长度和“离目标值的差”作为终止条件,而“离目标值的差”在递归中是需要不断变化的,同时在递归中需要变化的还有指针的位置。子节点的个数,下界一般是当前指针所在位置,上界为可以取的最大值。
例题:
//216
class CombinationSum3 {
public List<List<Integer>> combinationSum3(int k, int n) {
List<List<Integer>> result = new ArrayList<List<Integer>>();
List<Integer> path = new ArrayList<Integer>();
backtracking(result, path, k, n, 1);
return result;
}
/**
*
* @param result 结果集
* @param path 路径
* @param k 组合长度
* @param n 离目标值的差
* @param index 指针
*/
public void backtracking(List<List<Integer>> result,List<Integer> path,int k,int n,int index) {
//终止条件:路径长度为k且和为目标值
if(path.size()==k && n==0) {
result.add(new ArrayList<Integer>(path));
return;
}
//子集(子节点)个数:下界为指针index,上界为最大值9
for(int i=index;i<=9;i++) {
//当当前元素的值小于或等于离目标值的差
if(i<=n) {
path.add(i);
//递归,指针向后移动一位且更新离目标值的差
backtracking(result, path, k, n-i, i+1);
//回溯
path.remove(path.size()-1);
}
else break;
}
}
}
部分树形结构:
每天一道LeetCode题,冲!!!
题型二、排列问题
相关题目:46、47、60、526、996
解题思路:这类题目一般是给定一个数组,要求其全排列或者基于全排列的某些问题。由于是全排列,终止条件一般为路径长度等于数组长度,子集合范围一般是从0到数组长度-1。这类题还有一个常用的剪枝技巧:使用一个boolean数组来记录已使用过的元素,防止递归时路径中出现同一个元素。若题目所给的数组是可以包含重复元素的,为了使得结果集中不会因为选择了相同的元素而产生重复结果,可以使用以下方法剪枝:
//i>0:防止i-1时数组越界 当i的值等于i-1的值且i-1的值未被使用过时将其跳过,使得同一有重复元素
//的组合只出现一次,不会出现重复(前提是数组已排序,使得相等的元素相邻)
if(i>0 && nums[i]==nums[i-1] && !used[i-1]) {
continue;
}
例题:
//47
class PermutationⅡ {
public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> result = new ArrayList<List<Integer>>();
List<Integer> path = new ArrayList<Integer>();
boolean[] used = new boolean[nums.length];
//排序,使得相等的元素相邻
Arrays.sort(nums);
backtracking(result, path, nums,used);
return result;
}
/**
*
* @param result 结果集
* @param path 路径
* @param nums 条件数组
* @param used 判断元素是否已使用过
*/
//剪枝
public void backtracking(List<List<Integer>> result,List<Integer> path,int[] nums,boolean[] used) {
if(path.size() == nums.length) {
result.add(new ArrayList<Integer>(path));
return;
}
for(int i=0;i<nums.length;i++) {
if(!used[i]) {
//i>0:防止i-1时数组越界 当i的值等于i-1的值且i-1的值未被使用过时将其跳过,使得同一有重复元素
//的组合只出现一次,不会出现重复
if(i>0 && nums[i]==nums[i-1] && !used[i-1]) {
continue;
}
path.add(nums[i]);
used[i] = true;
backtracking(result, path, nums,used);
path.remove(path.size()-1);
used[i] = false;
}
}
}
}
部分树形结构:
每天一道LeetCode题,冲!!!
题型三、排列和组合杂交问题
相关题目:1079
解题思路:与上两种题型基本相同,1079解题过程与模板有些不同
例题:
//1079
class LetterTilePossibilities {
public int numTilePossibilities(String tiles) {
List<String> result = new ArrayList<String>();
StringBuffer path = new StringBuffer();
boolean[] used = new boolean[tiles.length()];
//为了将字符串排序而先将字符串转为数组
String[] temp = tiles.split("");
Arrays.sort(temp);
backtracking(result, path, temp, used);
return result.size();
}
public void backtracking(List<String> result,StringBuffer path,String[] temp,boolean[] used) {
//这里没有用模板的if语句开头来写终止条件是因为只要出现在路径上的组合都可纳入结果集
for(int i=0;i<temp.length;i++) {
//排除已使用元素
if(!used[i]) {
//排除相邻相同元素被选择导致重复结果
if(i>0 && temp[i].equals(temp[i-1]) && !used[i-1]) {
continue;
}
path.append(temp[i]);
used[i] = true;
//直接将路径加入结果集
result.add(path.toString());
backtracking(result, path, temp, used);
path.deleteCharAt(path.length()-1);
used[i] = false;
}
}
}
}
每天一道LeetCode题,冲!!!
题型四、n皇后问题
相关题目:51、52
解题思路:
-
n皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
-
即每行放置一个皇后,皇后不能在同一列或同一对角线
-
不能在同一列:当前坐标的列不等于所有已放置的皇后的列
-
不能在同一对角线:
左斜对角线,当前坐标的行和列之和不等于所有已放置皇后的坐标行和列之和
右斜对角线,当前坐标的行和列之差不等于所有已放置皇后的坐标行和列之差 -
终止条件:能够遍历到最后棋盘一行,说明此时皇后已全部正确放置
-
子集合:从1到n
-
递归时行数加1,回溯时将最近一个皇后移除
-
使用一个一维数组存放皇后位置,i表示行,queensPosition[i]表示列
例题:
//51
class NQueens {
public List<List<String>> solveNQueens(int n) {
List<List<String>> result = new ArrayList<List<String>>();
List<String> solution = new ArrayList<String>();
//给定空间为n+1,目的是遍历时指针从1开始,因为初始化数组时queensPosition[i]会被置0,
//相当于每行的皇后都已经在第0列上,为了排除这个干扰,将舍弃这一列
int[] queensPosition = new int[n+1];
//棋盘行的遍历从1开始
backtracking(result, solution, n, queensPosition, 1);
return result;
}
/**
* 所有坐标从1开始
* @param result 结果集,存放所有皇后放法
* @param solution 存放一种皇后放法
* @param n n皇后
* @param queensPosition 存放皇后坐标,i表示行,queensPosition[i]表示列
* @param row 棋盘的行
*/
void backtracking(List<List<String>> result,List<String> solution,int n,int[] queensPosition,int row) {
//终止条件:当棋盘的行能够遍历到最后一行时,说明此时皇后已全部正确放置
if(row == n+1) {
result.add(new ArrayList<String>(solution));
return;
}
//从1开始,这里的i表示列
for(int i=1;i<=n;i++) {
//用于格式化输出
StringBuffer path = new StringBuffer();
//若当前坐标可放皇后
if(check(row, i, queensPosition)) {
//在此行该坐标前的列填充"."
for(int j=1;j<i;j++) {
path.append(".");
}
//在此坐标上填充"Q"
path.append("Q");
//在此行该坐标后的列填充"."
while(path.length() < n) {
path.append(".");
}
//将格式化后的皇后放法存入solution
solution.add(path.toString());
//更新已放的皇后坐标
queensPosition[row] = i;
//递归时行移动
backtracking(result, solution, n, queensPosition, row+1);
//回溯,将放入的皇后去除
queensPosition[row] = 0;
//solution删除最后一个皇后放法
solution.remove(solution.size()-1);
}
}
}
/**
* 判断该位置能不能放置皇后
* @param x 行
* @param y 列
* @param queensPosition 皇后坐标,i表示行,queensPosition[i]表示列
*/
boolean check(int x,int y,int[] queensPosition) {
//行x及其之后的行还没放皇后
for(int i=1;i<=x;i++) {
//若有皇后和当前位置在同一列,则返回false
if(queensPosition[i] == y) return false;
//若有皇后和当前位置在同一左斜对角线,则返回false
if(i+queensPosition[i] == x+y) return false;
//若有皇后和当前位置在同一右斜对角线,则返回false
if(i-queensPosition[i] == x-y) return false;
}
return true;
}
}
树形结构:
除此之外,还发现了一个问题:在函数迭代过程中,结果集result是无需也不能随函数迭代而发生变化的,按理说应该作为全局变量而不是递归函数的形参,但是作为形参也没有错,原因是ArrayList不会随迭代而发生改变。但是将它作为全局变量更容易理解。在52题中,返回的不是一个结果集,而是所有皇后放法的方法数量,这个int变量就应该作为全局变量,如果作为形参则会出错。
每天一道LeetCode题,冲!!!
题型五、四方向搜索
相关题目:79、212、980
解题思路:这类问题的本质是给出一个图(二维数组),让你找出从起点到终点的所有可能路径。因为是“图”,所以就涉及到一个递归时要考虑从四个方向分别搜索的问题。我的做法是:根据四个不同方向不同情况来进行不同的回溯与递归,这样可以解决问题,但是代码有些冗余。
例题:
//980
class UniquePathⅢ {
//计算当前路径已走过多少元素,由于要从起点开始寻找,所以先将起点算入,count置1
int count = 1;
public int uniquePathsIII(int[][] grid) {
//起点的坐标
int beginX = 0;
int beginY = 0;
int endX = 0;
int endY = 0;
//障碍数
int block = 0;
//遍历整个图,求得起点、终点的坐标及障碍数
for(int i=0;i<grid.length;i++) {
for(int j=0;j<grid[0].length;j++) {
if(grid[i][j] == 1) {
beginX = i;
beginY = j;
}
if(grid[i][j] == 2) {
endX = i;
endY = j;
}
if(grid[i][j] == -1) {
block++;
}
}
}
//起点与终点的距离为整个图的元素总数减去障碍数
int distance = grid.length*grid[0].length - block;
List<Integer> result = new ArrayList<Integer>();
boolean[][] used = new boolean[grid.length][grid[0].length];
//由于从起点开始,将起点置为已使用过
used[beginX][beginY] = true;
//从起点位置开始递归
backtracking(result, used, distance, beginX, beginY, grid, endX, endY);
//返回结果集元素总数
return result.size();
}
/**
*
* @param result 结果集,保存线路条数
* @param used 记录元素是否被使用过
* @param distance 起点与终点之间的距离(包括起点与终点)
* @param x 当前元素行坐标
* @param y 当前元素列坐标
* @param grid 条件图
* @param endX 终点行坐标
* @param endY 终点列坐标
*/
void backtracking(List<Integer> result,boolean[][] used,int distance,int x,int y,int[][] grid,int endX,int endY) {
//当终点提前被使用时,回溯
if(used[endX][endY]&&count<distance) {
return;
}
//终止条件:终点被使用且走过的元素等于distance
if(used[endX][endY] && count==distance) {
//结果集增加一种情况后回溯
result.add(count);
return;
}
//四方向遍历
//注意防止数组越界
//当下一元素值为0或2时可走
//上
if(x-1>=0 && !used[x-1][y] && (grid[x-1][y]==0||grid[x-1][y]==2)) {
//已走元素个数加1
count++;
//将该元素置为已使用
used[x-1][y] = true;
//递归,当前元素坐标变化
backtracking(result, used, distance, x-1, y, grid, endX, endY);
//回溯,元素-1,该元素置为未使用
count--;
used[x-1][y] = false;
}
//下
if(x+1<grid.length && !used[x+1][y] && (grid[x+1][y]==0||grid[x+1][y]==2)) {
count++;
used[x+1][y] = true;
backtracking(result, used, distance, x+1, y, grid, endX, endY);
count--;
used[x+1][y] = false;
}
//左
if(y-1>=0 && !used[x][y-1] && (grid[x][y-1]==0||grid[x][y-1]==2)) {
count++;
used[x][y-1] = true;
backtracking(result, used, distance, x, y-1, grid, endX, endY);
count--;
used[x][y-1] = false;
}
//右
if(y+1<grid[0].length && !used[x][y+1] && (grid[x][y+1]==0||grid[x][y+1]==2)) {
count++;
used[x][y+1] = true;
backtracking(result, used, distance, x, y+1, grid, endX, endY);
count--;
used[x][y+1] = false;
}
}
}
每天一道LeetCode题,冲!!!
题型六、子集合问题
相关问题:78、90
解题思路:这类问题更像是纯粹的深度遍历和广度遍历结合的问题。这类问题的难点是深度遍历时不能遍历到重复的元素,若数组中允许出现相同元素,则在广度遍历中还要注意不能遍历到相同元素。这类问题无需使用boolean数组来保存元素是否被使用,可用更加简洁有效的方法:使用一个index指针,来保存当前元素子集合的首元素。这样,每次递归中在循环内就只会遍历index指针后的元素,而每次递归将index更新,能保证在深度遍历中不遍历到重复元素。在广度遍历时,指针i>index,为使广度遍历中不能遍历到相同元素,可加一个限定条件:当nums[i]==nums[i-1]时跳过此次循环,当然,前提是数组已排序。
例题:
//90
class SubsetsⅡ {
public List<List<Integer>> subsetsWithDup(int[] nums) {
List<List<Integer>> result = new ArrayList<List<Integer>>();
List<Integer> path = new ArrayList<Integer>();
result.add(new ArrayList<Integer>());
//数组排序,确保相同元素在相邻位置
Arrays.sort(nums);
backtracking(result, path, nums, 0);
return result;
}
/**
*
* @param result 结果集
* @param path 路径
* @param nums 条件数组
* @param index 当前元素子集合的头元素
*/
void backtracking(List<List<Integer>> result,List<Integer> path,int[] nums,int index) {
//终止条件:路径长度等于数组长度
if(path.size() == nums.length) {
return;
}
//从当前元素子集合的头元素开始遍历
for(int i=index;i<nums.length;i++) {
//当i==index时为深度遍历,此时遇到相邻元素相等没错
//当i>index说明已进行了广度遍历,此时遇到相邻元素相等需跳过防止进行了相同的深度遍历出现重复
if(i!=index && nums[i]==nums[i-1]) {
continue;
}
path.add(nums[i]);
result.add(new ArrayList<Integer>(path));
//递归时更新index,深度遍历
backtracking(result, path, nums, i+1);
path.remove(path.size()-1);
}
}
}
树形结构:
每天一道LeetCode题,冲!!!
题型七、Trie(前缀树、字典树)
相关题目:208、211
解题思路:什么是前缀树?这位大佬写的不错:
https://blog.csdn.net/weixin_39778570/article/details/81990417
当遇到要求输入单词,并查找单词是否存在的题型时,前缀树能够发挥很大的作用,所以也叫字典树。这类题主要是学习写前缀树。
例题:
//208
//前缀树(字典树)学习模板
class Trie {
//树的结点类作为前缀树的内部类好处:
//方便将存在一定逻辑关系的类组织在一起,又可以对外界隐藏
/**树的结点类
* 包含两个属性:结点的子节点(next域)、结点是否为最终节点(isEnd)
* 在该题中,结点的子节点可能有26个(26个英文字母),所以该树为26叉树
* 使用一个长度为26的数组来保存子节点集合
*/
class TrieNode {
private TrieNode[] next;
private boolean isEnd;
public TrieNode() {
next = new TrieNode[26];
isEnd = false;
}
}
//声明根节点
private TrieNode root;
/** Initialize your data structure here. */
//构造方法初始化根节点
public Trie() {
root = new TrieNode();
}
//插入一个英文单词
/** Inserts a word into the trie. */
public void insert(String word) {
//从根节点开始
TrieNode node = root;
char[] wordList = word.toCharArray();
//遍历每个英文字母
for(int i=0;i<wordList.length;i++) {
//若该英文字母不在当前结点的子节点集合的应在位置上,则在这个位置声明一个新的结点
if(node.next[wordList[i]-'a'] == null) {
node.next[wordList[i]-'a'] = new TrieNode();
}
//将结点移动到新的位置上
node = node.next[wordList[i]-'a'];
}
//所有英文字母遍历完后,此时结点所在位置是最后一个英文字母所在位置,将这个结点标记为最终结点
node.isEnd = true;
}
//搜索一个英文单词
/** Returns if the word is in the trie. */
public boolean search(String word) {
TrieNode node = root;
char[] wordList = word.toCharArray();
for(int i=0;i<wordList.length;i++) {
//若搜索过程中在指定位置找不到结点,则搜索失败
if(node.next[wordList[i]-'a'] == null) {
return false;
}
node = node.next[wordList[i]-'a'];
}
//所有英文字母遍历完后,返回最后一个字母所在结点是否为最终结点
return node.isEnd;
}
//判断字符串是否为已插入英文单词的前缀
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
TrieNode node = root;
char[] prefixList = prefix.toCharArray();
for(int i=0;i<prefixList.length;i++) {
if(node.next[prefixList[i]-'a'] == null) {
return false;
}
node = node.next[prefixList[i]-'a'];
}
//若能够遍历到最后一个字母则搜索成功
return true;
}
}
这道题可以作为前缀树学习的模板,但是没有用到递归和回溯。同样的题型第211题就有用到。
每天一道LeetCode题,冲!!!