- 如果解决一个问题有多个步骤,每一个步骤有多种方法,题目又要我们找出所有的方法,可以使用回溯算法;
- 回溯算法是在一棵树上的 深度优先遍历(因为要找所有的解,所以需要遍历);
1.括号生成
分析
- 当前左右括号都有大于 0 个可以使用的时候,才产生分支;
- 产生左分支的时候,只看当前是否还有左括号可以使用;
- 产生右分支的时候,还受到左分支的限制,右边剩余可以使用的括号数量一定得在严格大于左边剩余的括号数量的时候,才可以产生分支;
- 在左边和右边剩余的括号数都等于 0 的时候结算。
class Solution {
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
if(n < 1) return res;
dfs("", n, n, res);
return res;
}
/**
* @param curStr 当前递归得到的结果
* @param left 左括号还有几个可以使用
* @param right 右括号还有几个可以使用
* @param res 结果集
*/
public void dfs(String curStr, int left, int right, List<String> res) {
// 因为每一次尝试,都使用新的字符串变量,所以无需回溯
// 在递归终止的时候,直接把它添加到结果集即可
if(left == 0 && right == 0) {
res.add(curStr);
return;
}
// 剪枝(如图,左括号可以使用的个数严格大于右括号可以使用的个数,才剪枝,注意这个细节)
if(left > right) return;
if(left > 0) dfs(curStr + "(", left - 1, right, res);
if(right > 0) dfs(curStr + ")", left, right - 1, res);
}
}
原文地址
————————————————————————————————————————
2.组合总和
分析
根据示例:candidates = [2, 3, 6, 7] , target = 7
- 候选数组里有 2,如果找到了组合总和为 7 - 2 = 5 的所有组合,再在之前加上 2 ,就是 7 的所有组合
- 同理考虑 3,如果找到了组合总和为 7 - 3 = 4 的所有组合,再在之前加上 3 ,就是 7 的所有组合,依次这样找下去。
说明:
- 以 target = 7 为根结点 ,创建一个分支的时做减法 ;
- 每一个箭头表示:从父亲结点的数值减去边上的数值,得到孩子结点的数值。边的值就是题目中给出的 candidate 数组的每个元素的值;
- 减到 0 或者负数的时候停止,即:结点 0 和负数结点成为叶子结点;
- 所有从根结点到结点 0 的路径(只能从上往下,没有回路)就是题目要找的一个结果。
这棵树有 4 个叶子结点的值 0,对应的路径列表是 [[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]],而示例中给出的输出只有 [[7], [2, 2, 3]]。即:题目中要求每一个符合要求的解是不计算顺序的。
可不可以在搜索的时候就去重呢?答案是可以的。遇到这一类相同元素不计算顺序的问题,我们在搜索的时候就需要 按某种顺序搜索。具体的做法是:每一次搜索的时候设置下一轮搜索的起点begin
即:从每一层的第 2 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidate 里的元素。
public class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int len = candidates.length;
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
Deque<Integer> path = new ArrayDeque<>();
dfs(candidates, 0, len, target, path, res);
return res;
}
/**
* @param candidates 候选数组
* @param begin 搜索起点
* @param len 冗余变量,是 candidates 里的属性,可以不传
* @param target 每减去一个元素,目标值变小
* @param path 从根结点到叶子结点的路径,是一个栈
* @param res 结果集列表
*/
private void dfs(int[] candidates, int begin, int len, int target, Deque<Integer> path, List<List<Integer>> res) {
// target 为负数和 0 的时候不再产生新的孩子结点
if (target < 0) {
return;
}
//递归结束条件
if (target == 0) {
//如果这样写,在后面改变path时,res中的元素也会进行相应的改变,因为这是引用传递。
//所以要新建一个集合,复制path中的元素,再存入res中
//res.add(path);
res.add(new ArrayList<>(path));
return;
}
//在选择列表中进行选择操作
//重点理解这里从 begin 开始搜索的语意
for (int i = begin; i < len; i++) {
//做出选择
path.addLast(candidates[i]);
//进入下一轮操作
// 注意:由于每一个元素可以重复使用,下一轮搜索的起点依然是 i,这里非常容易弄错
dfs(candidates, i, len, target - candidates[i], path, res);
//撤销选择
// 状态重置
path.removeLast();
}
}
}
剪枝提速:
- 如果 target 减去一个数得到负数,那么减去一个更大的树依然是负数,同样搜索不到结果。基于这个想法,我们可以对输入数组进行排序,添加相关逻辑达到进一步剪枝的目的;
public class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int len = candidates.length;
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
// 排序是剪枝的前提
Arrays.sort(candidates);
Deque<Integer> path = new ArrayDeque<>();
dfs(candidates, 0, len, target, path, res);
return res;
}
private void dfs(int[] candidates, int begin, int len, int target, Deque<Integer> path, List<List<Integer>> res) {
//递归结束条件
// 由于进入更深层的时候,小于 0 的部分被剪枝,因此递归终止条件值只判断等于 0 的情况
if (target == 0) {
res.add(new ArrayList<>(path));
return;
}
//在选择列表中进行选择操作
for (int i = begin; i < len; i++) {
// 重点理解这里剪枝,前提是候选数组已经有序
if (target - candidates[i] < 0) {
break;
}
//做出选择
path.addLast(candidates[i]);
//进入下一轮继续操作
dfs(candidates, i, len, target - candidates[i], path, res);
//撤销操作
path.removeLast();
}
}
}
原文地址
————————————————————————————————————————
3.组合总和II
分析
如何去掉重复的集合:
- 不重复就需要按 顺序 搜索, 在搜索的过程中检测分支是否会出现重复结果 。注意:这里的顺序不仅仅指数组 candidates 有序,还指按照一定顺序搜索结果。
将数组先排序的思路来自于这个问题:去掉一个数组中重复的元素。很容易想到的方案是:先对数组 升序 排序,重复的元素一定不是排好序以后相同的连续数组区域的第 1 个元素。也就是说,剪枝发生在:同一层数值相同的结点第 2、3 … 个结点,因为数值相同的第 1 个结点已经搜索出了包含了这个数值的全部结果,同一层的其它结点,候选数的个数更少,搜索出的结果一定不会比第 1 个结点更多,并且是第 1 个结点的子集。
public class Solution {
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
Arrays.sort(candidates);
}
/**
* @param candidates 候选数组
* @param start 从候选数组的 start 位置开始搜索
* @param target 表示剩余,这个值一开始等于 target,基于题目中说明的"所有数字(包括目标数)都是正整数"这个条件
* @param path 从根结点到叶子结点的路径
* @param res
*/
public void dfs(int[] candidates, int start, int target, Deque<Integer> path, List <List<Integer>> res) {
//递归结束条件
if(target == 0) {
res.add(new ArrayList<>(path));
return;
}
//在选择列表中进行选择操作
for(int i = start; i < candidates.length; i++) {
//剪枝
// 大剪枝:减去 candidates[i] 小于 0,减去后面的 candidates[i + 1]、candidates[i + 2] 肯定也小于 0,因此用 break
if(target - candidates[i] < 0) break;
// 小剪枝:同一层相同数值的结点,从第 2 个开始,候选数更少,结果一定发生重复,因此跳过,用 continue
if(i > start && candidates[i] == candidates[i - 1]) continue;
//做选择
path.addLast(candidates[i]);
//进入下一轮继续操作
dfs(candidates, i + 1, target - candidates[i], path, res);
//撤销选择
path.removeLast();
}
}
}
-
解释语句:if(i > start && candidates[i] == candidates[i - 1]) continue; 是如何避免重复的
1 / \ 2 2 这种情况不会发生 但是却允许了不同层级之间的重复即: / \ 5 5 例2 1 / 2 这种情况确是允许的 / 2 为何会有这种神奇的效果呢? 首先 i-1 == i 是用于判定当前元素是否和之前元素相同的语句。这个语句就能砍掉例1。 可是问题来了,如果把所有当前与之前一个元素相同的都砍掉,那么例二的情况也会消失。 因为当第二个2出现的时候,他就和前一个2相同了。 那么如何保留例2呢? 那么就用 i > start 来避免这种情况,你发现例1中的两个2是处在同一个层级上的, 例2的两个2是处在不同层级上的。 在一个for循环中,所有被遍历到的数都是属于一个层级的。我们要让一个层级中, 必须出现且只出现一个2,那么就放过第一个出现重复的2,但不放过后面出现的2。 第一个出现的2的特点就是 i == start. 第二个出现的2 特点是i > start.
原文地址
————————————————————————————————————————
4.全排列
分析
以数组 [1, 2, 3] 的全排列为例。
- 先写以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列
- 再写以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列;
- 最后写以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。
设计状态变量
- 首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即:在已经选择了一些数的前提下,在剩下的还没有选择的数中,依次选择一个数,这显然是一个 递归 结构;
- 递归的终止条件是: 一个排列中的数字已经选够了 ,因此我们需要一个变量来表示当前程序递归到第几层,我们把这个变量叫做 depth,或者命名为 index ,表示当前要确定的是某个全排列中下标为 index 的那个数是多少;
- 布尔数组 used,初始化的时候都为 false 表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true ,这样在考虑下一个位置的时候,就能够以 O(1) 的时间复杂度判断这个数是否被选择过,这是一种「以空间换时间」的思想。
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
int len = nums.length;
if(len < 1) return res;
Deque<Integer> path = new ArrayDeque<>(len);
boolean[] used = new boolean[len];
dfs(nums, len, 0, used, path, res);
return res;
}
public void dfs(int[] nums, int len, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
//递归结束条件
if(depth == len) {
res.add(new ArrayList<>(path));
return;
}
//在选择列表中进行选择
for(int i = 0; i < len; i++) {
if(!used[i]) {
//做选择
used[i] = true;
path.addLast(nums[i]);
//进入下一轮继续选择
dfs(nums, len, depth + 1, used, path, res);
//撤销选择
used[i] = false;
path.removeLast();
}
}
}
}
原文地址
————————————————————————————————————————
5.全排列II
分析
对比图中标注 ① 和 ② 的地方。相同点是:这一次搜索的起点和上一次搜索的起点一样。不同点是:
- 标注 ① 的地方上一次搜索的相同的数刚刚被撤销;
- 标注 ② 的地方上一次搜索的相同的数刚刚被使用。
这里还有一个很细节的地方:
- 在图中 ② 处,搜索的数也和上一次一样,但是上一次的 1 还在使用中;
- 在图中 ① 处,搜索的数也和上一次一样,但是上一次的 1 刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支。
class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
int len = nums.length;
Deque<Integer> path = new ArrayDeque<>(len);
Arrays.sort(nums);
boolean[] used = new boolean[len];
}
public void dfs(int[] nums, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
if(depth == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for(int i = 0; i < nums.length; i++) {
if(used[i]) continue;
// 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
// 写 !used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
if(i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
//if(i > 0 && nums[i] == nums[i - 1] && used[i - 1]) continue;也可以正确运行
//used[i - 1] 前面加不加感叹号的区别仅在于保留的是相同元素的顺序索引,还是倒序索引。
//很明显,顺序索引(即使用 !used[i - 1] 作为剪枝判定条件得到)的递归树剪枝更彻底,思路也相对较自然。
used[i] = true;
path.addLast(nums[i]);
dfs(nums, depth + 1, used, path, res);
used[i] = false;
path.removeLast();
}
}
}
原文地址
————————————————————————————————————————
6.组合
分析
说明:
- 叶子结点的信息体现在从根结点到叶子结点的路径上,因此需要一个表示路径的变量 path,它是一个列表,特别地,path 是一个栈;
- 每一个结点递归地在做同样的事,区别在于搜索起点,因此需要一个变量 start,表示在区间[start, n]里选出若干个数的组合;
public class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
if (k <= 0 || n < k) {
return res;
}
// 从 1 开始是题目的设定
Deque<Integer> path = new ArrayDeque<>();
dfs(n, k, 1, path, res);
return res;
}
private void dfs(int n, int k, int begin, Deque<Integer> path, List<List<Integer>> res) {
// 递归终止条件是:path 的长度等于 k
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
// 遍历可能的搜索起点
for (int i = begin; i <= n; i++) {
// 向路径变量里添加一个数
path.addLast(i);
// 下一轮搜索,设置的搜索起点要加 1,因为组合数理不允许出现重复的元素
dfs(n, k, i + 1, path, res);
// 重点理解这里:深度优先遍历有回头的过程,因此递归之前做了什么,递归之后需要做相同操作的逆向操作
path.removeLast();
}
}
}
优化:剪枝
在上面的代码,搜索起点遍历到n是不必要的,事实上,如果 n = 7, k = 4,从5开始搜索已经没有意义了,这是因为即使把5选上,后面的数只有6和7,一共就3个候选数,凑不出4个数的组合。因此,搜索起点有上界。
例如:n = 6 ,k = 4。
path.size() == 1 的时候,接下来要选择 3 个数,搜索起点最大是 4,最后一个被选的组合是 [4, 5, 6];
path.size() == 2 的时候,接下来要选择 2 个数,搜索起点最大是 5,最后一个被选的组合是 [5, 6];
path.size() == 3 的时候,接下来要选择 1 个数,搜索起点最大是 6,最后一个被选的组合是 [6];
再如:n = 15 ,k = 4。
path.size() == 1 的时候,接下来要选择 3 个数,搜索起点最大是 13,最后一个被选的是 [13, 14, 15];
path.size() == 2 的时候,接下来要选择 2 个数,搜索起点最大是 14,最后一个被选的是 [14, 15];
path.size() == 3 的时候,接下来要选择 1 个数,搜索起点最大是 15,最后一个被选的是 [15];
可以归纳出:搜索起点的上界 + 接下来要选择的元素个数 - 1 = n
接下来要选择的元素个数 = k - (path.size())
整理得到:搜索起点的上界 = n - (k - (path.size())) + 1
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
if(n < 1 || n < k || k < 1) return res;
Deque<Integer> path = new ArrayDeque<>();
}
public void dfs(int n, int k, int begin, Deque<Integer> path, List<List<Integer>> res) {
if(path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
for(int i = begin; i < n - (k - path.size()) + 1; i++) {
path.addLast(i);
dfs(n, k, i + 1, path, res);
path.removeLast();
}
}
}
原文地址
————————————————————————————————————————
7.子集
分析
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
dfs(nums, 0, path, res);
return res;
}
public void dfs(int[] nums, int n, Deque<Integer> path, List<List<Integer>> res) {
//走过的所有路径都是子集的一部分,所以都要加入到集合中
res.add(new ArrayList<>(path));
for(int i = n; i < nums.length; i++) {
//做出选择
path.addLast(nums[i]);
//递归
dfs(nums, i + 1, path, res);
//撤销选择
path.removeLast();
}
}
}
位运算解决
数组中的每一个数字都有选和不选两种状态,我们可以用0和1表示,0表示不选,1表示选择。如果数组的长度是n,那么子集的数量就是2^n。
比如数组长度是3,就有8种可能,分别是
[0,0,0]
[0,0,1]
[0,1,0]
[0,1,1]
[1,0,0]
[1,0,1]
[1,1,0]
[1,1,1]
public static List<List<Integer>> subsets(int[] nums) {
//子集的数量是2的nums.length次方,这里通过移位计算
int length = 1 << nums.length;
List<List<Integer>> res = new ArrayList<>(length);
//遍历从0到length中间的所有数字,根据数字中1的位置来找子集
for (int i = 0; i < length; i++) {
List<Integer> list = new ArrayList<>();
for (int j = 0; j < nums.length; j++) {
//如果数字i的某一个位置是1,就把数组中对
//应的数字添加到集合
if (((i >> j) & 1) == 1)
list.add(nums[j]);
}
res.add(list);
}
return res;
}
原文地址
————————————————————————————————————————
8.单词搜索
分析
代码一:
class Solution {
public int m;
public int n;
//偏移量数组,代表走的方向
// x-1,y
// x,y-1 x,y x,y+1
// x+1,y
int[][] direction = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};
//标记该点是否已被选择过,true表示被选择过,false表示还没被选择
boolean[][] marked;
public boolean exist(char[][] board, String word) {
m = board.length;
if(m == 0) return false;
n = board[0].length;
marked = new boolean[m][n];
//遍历所有的点,并找到其中符合条件的点
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
if(dfs(board, i, j, 0, word)) return true;
}
}
return false;
}
//判断网格中是否有该字符串
//start 表示当前是第几个字符,从0开始
public boolean dfs(char[][] board, int row, int col, int start, String word) {
//如果start表示最后一个字符,且也和字符串的最后一个字符一致,则返回true;
if(start == word.length() - 1) return board[row][col] == word.charAt(start);
if(board[row][col] == word.charAt(start)) {
marked[row][col] = true; //表示这个字符已被选择
for(int i = 0; i < 4; i++) {
int newX = row + direction[i][0]; //新的横坐标
int newY = col + direction[i][1]; //新的纵坐标
if(inArea(newX, newY) && !marked[newX][newY]) {
if(dfs(board, newX, newY, start + 1, word) return true;
}
}
marked[row][col] = false; //撤销选择
}
return false;
}
//判断这个(x,y)这个点是否越界
public boolean inArea(int x, int y) {
return x >= 0 && x < m && y >= 0 && y < n;
}
}
代码二:
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[0].length; j++) {
//从[i,j]这个坐标开始查找
if (dfs(board, words, i, j, 0))
return true;
}
}
return false;
}
public boolean dfs(char[][] board, char[] words, int i, int j, int index) {
//边界的判断,如果越界直接返回false。index表示的是查找到字符串word的第几个字符,
//如果这个字符不等于board[i][j],说明验证这个坐标路径是走不通的,直接返回false
if(i < 0 || i >= board.length || j < 0 || j >= board[0].length || board[i][j] != words[index])
return false;
//如果word的每个字符都查找完了,直接返回true
if(index == words.length - 1) return true;
//把当前坐标的值保存下来,为了在最后复原
char temp = board[i][j];
//然后修改当前坐标的值
board[i][j] = '.';
//走递归,沿着当前坐标的上下左右4个方向查找
boolean res = dfs(board, words, i + 1, j, index + 1) ||
dfs(board, words, i + 1, j, index + 1) ||
dfs(board, words, i + 1, j, index + 1) ||
dfs(board, words, i + 1, j, index + 1);
//递归之后再把当前的坐标复原
board[i][j] = temp;
return res;
}