刚刷完二叉树,里面包含了很多递归的思想。索性今天趁热打铁来看看回溯算法吧~
在做题之前呢,先回顾一下回溯的知识,具体可以参考卡哥的代码随想录,我认为讲的还是非常清晰的!
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
了解了以上知识后,我们就开始今日份拷打吧!
1.全排列(难度等级:中等)
这道题算是基础题啦,需要考虑的点是,数组中同一个元素是不能重复选取的。那么我们怎么在回溯过程中得知,某个元素是否已经被选取过了呢?这里我们使用一个LinkedList<Integer>来维护。
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>(); //存放已经操作过的元素
public List<List<Integer>> permute(int[] nums) {
if(nums.length==0) return res;
backtracking(nums, path);
return res;
}
public void backtracking(int[] nums, LinkedList<Integer> list){
if(list.size() == nums.length){
res.add(new ArrayList<>(list));
return;
}
for(int i=0; i<nums.length; i++){
//处理节点,注意选过的节点不可重复选取
if(list.contains(nums[i])){
continue;
}
list.add(nums[i]);
backtracking(nums, list);
//回溯,撤销处理结果
list.removeLast();
}
}
}
2.子集(难度等级:中等)
这道题相当于上一道题的升级版,因为除了需要考虑同一个元素不能重复取的情况,还需要考虑解集不能包含重复子集,即[1,2]和[2,1]不能重复出现。我们这里采取的方式是通过限制开始索引来维护,具体逻辑可以参考卡哥这幅图:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
if(nums.length==0) return res;
backtrackinig(nums, path, 0);
return res;
}
public void backtrackinig(int[] nums, LinkedList<Integer> path, int startIndex){
res.add(new ArrayList<>(path));
for(int i=startIndex; i<nums.length; i++){
if(path.contains(nums[i])){
continue;
}
path.add(nums[i]);
startIndex++;
backtrackinig(nums, path, startIndex);
path.removeLast();
}
}
}
3.电话号码的字母组合(难度等级:中等)
其实这道题和上一道很类似,都是属于组合问题,只不过这里需要加上返回条件。本题难点其实在于怎么把数字和英文字母的关系表达明确。
class Solution {
List<String> res = new ArrayList<>();
StringBuilder sb = new StringBuilder();
public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) {
return res;
}
String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
backtracking(numString, digits, 0);
return res;
}
public void backtracking(String[] numString, String digits, int index){
if(index==digits.length()){
res.add(sb.toString());
return;
}
//取当前操作数字
int num = digits.charAt(index)-'0';
String eng = numString[num];
for(int i=0; i<eng.length(); i++){
//操作当前节点
sb.append(eng.charAt(i));
backtracking(numString, digits, index+1);
sb.deleteCharAt(sb.length()-1); //回溯时删去末尾元素
}
}
}
4. 组合总和(难度等级:中等)
这道题本质还是组合问题,但需要注意的是同一个元素是可以重复出现的!即之前我们在for循环里处理当前元素是否已经被获取过的逻辑,在这道题里不需要了。同时,我们还要思考是否有剪枝策略呢?既然题目里提到了元素都是大于等于2的(没有负值出现),那么是不是只要当前组合的和大于目标值,我们就可以直接return了呀~
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtracking(candidates, target, path, 0, 0);
return res;
}
public void backtracking(int[] candidates, int target, LinkedList<Integer> path, int sum, int startIndex){
if(sum == target){
res.add(new ArrayList<>(path));
return;
}
if(sum > target){
return;
}
for(int i=startIndex; i<candidates.length; i++){
//处理当前节点
sum += candidates[i];
path.add(candidates[i]);
backtracking(candidates, target, path, sum, i);
sum -= candidates[i];
path.removeLast();
}
}
}
5.括号生成(难度等级:中等)
这道题初看其实很难看出回溯的影子,但确实又属于排列问题。只不过,这里排列的元素不是数字,而是左括号和右括号。此外,这里的排列是有特定规则的,即满足题目所述的条件。那么我们怎么将所谓的规则用代码体现呢?这里可以用当前已遍历元素种左括号和右括号的数量来限制。
class Solution {
List<String> res = new ArrayList<>();
public List<String> generateParenthesis(int n) {
backtracking(new StringBuilder(), 0, 0, n);
return res;
}
public void backtracking(StringBuilder cur, int open, int close, int n){
if(cur.length()==n*2){
res.add(cur.toString());
return;
}
//若左括号数满足规则
if(open < n){
cur.append('(');
backtracking(cur, open+1, close, n);
cur.deleteCharAt(cur.length()-1);
}
//若右括号数满足规则
if(close < open){
cur.append(')');
backtracking(cur, open, close+1, n);
cur.deleteCharAt(cur.length()-1);
}
}
}
6.单词搜索(难度等级:中等)
其实这道题也属于排列问题,但难点在于:1)二维数组的元素如何遍历;2) 如何表示二维数组的元素是否被使用过。二维数组遍历我们一般通过构造一个direction数组来实现:int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}分别表示4个行径方向。至于元素是否被使用过,通过一个boolean类型的二维数组来体现。另外,需要注意的是起点并不一定是(0,0),所以我们需要遍历所有情况,
class Solution {
public boolean exist(char[][] board, String word) {
int h = board.length, w = board[0].length;
boolean[][] visited = new boolean[h][w]; //判断某个元素是否已被使用过
for (int i = 0; i < h; i++) {
for (int j = 0; j < w; j++) {
//起点不同
boolean flag = check(board, visited, i, j, word, 0);
if (flag) {
return true;
}
}
}
return false;
}
public boolean check(char[][] board, boolean[][] visited, int i, int j, String s, int k) {
if (board[i][j] != s.charAt(k)) {
return false;
} else if (k == s.length() - 1) {
return true;
}
visited[i][j] = true;
int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; //四个前进方向
boolean result = false;
for (int[] dir : directions) {
int newi = i + dir[0], newj = j + dir[1];
if (newi >= 0 && newi < board.length && newj >= 0 && newj < board[0].length) {
if (!visited[newi][newj]) {
boolean flag = check(board, visited, newi, newj, s, k + 1);
if (flag) {
result = true;
break;
}
}
}
}
visited[i][j] = false;
return result;
}
}
7.分割回文串(难度等级:中等)
这道题也是最近大厂的常考题了~其实问题的逻辑无非在于1.分割 2.判断是否为回文串。需要注意的点也就是分割的操作了,其他跟之前的逻辑都差不多~
class Solution {
List<List<String>> res = new ArrayList<>();
List<String> path = new LinkedList<>();
public List<List<String>> partition(String s) {
backtracking(s, 0);
return res;
}
public void backtracking(String s, int stratIndex){
if(stratIndex >= s.length()){
res.add(new ArrayList(path));
return;
}
for(int i=stratIndex; i<s.length(); i++){
if(isHuiWen(s, stratIndex, i)){
String str = s.substring(stratIndex, i+1);
path.add(str);
backtracking(s, i+1);
path.removeLast();
}else{
continue;
}
}
}
public boolean isHuiWen(String s, int start, int end){
for(int i=start, j=end; i<j; i++,j--){
if(s.charAt(i)!=s.charAt(j)){
return false;
}
}
return true;
}
}
8.N 皇后(难度等级:困难)
这道题逻辑上可以分为三步:1.判断当前位置是否会与已有位置发生攻击;2.放置皇后;3.生成固定格式的结果。难点就在于如何表示当前位置是否会与已有位置发生攻击,即同一列、斜线怎么表示。这里给出空间占用比较高,但是较好理解的答案:
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> solutions = new ArrayList<List<String>>();
int[] queens = new int[n];
Arrays.fill(queens, -1);
Set<Integer> columns = new HashSet<Integer>(); //每一列是否有元素
Set<Integer> diagonals1 = new HashSet<Integer>(); //从左下到右上的斜线是否有元素
Set<Integer> diagonals2 = new HashSet<Integer>(); //从左上到右下的斜线上是否有元素
backtrack(solutions, queens, n, 0, columns, diagonals1, diagonals2);
return solutions;
}
public void backtrack(List<List<String>> solutions, int[] queens, int n, int row, Set<Integer> columns, Set<Integer> diagonals1, Set<Integer> diagonals2) {
if (row == n) {
List<String> board = generateBoard(queens, n);
solutions.add(board);
} else {
for (int i = 0; i < n; i++) {
if (columns.contains(i)) {
continue;
}
int diagonal1 = row - i;
if (diagonals1.contains(diagonal1)) {
continue;
}
int diagonal2 = row + i;
if (diagonals2.contains(diagonal2)) {
continue;
}
queens[row] = i;
columns.add(i);
diagonals1.add(diagonal1);
diagonals2.add(diagonal2);
backtrack(solutions, queens, n, row + 1, columns, diagonals1, diagonals2);
queens[row] = -1;
columns.remove(i);
diagonals1.remove(diagonal1);
diagonals2.remove(diagonal2);
}
}
}
//生成结果
public List<String> generateBoard(int[] queens, int n) {
List<String> board = new ArrayList<String>();
for (int i = 0; i < n; i++) {
char[] row = new char[n];
Arrays.fill(row, '.');
row[queens[i]] = 'Q';
board.add(new String(row));
}
return board;
}
}
【总结】
其实对于回溯问题,如果你能把树结构画出来,基本就能解答了。回溯问题通常还需要考虑的几个因素以及对应代码我也总结了一下:
1.同一个元素不能重复取
2.子集不能重复,即[1, 2, 2]与[2,1,2]不能重复放进答案
3.进行适当的剪枝,以降低时间复杂度