算法-回溯算法
0. 理论基础
回溯
通常和递归
一起使用- 回溯能够解决的几类问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
- 回溯算法解决的问题都可以抽象成
N叉树
,其中- 集合大小 为 树的宽度
- 递归深度 为 树的深度。
- 回溯算法的模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
// 树中节点孩子的数量就是集合的大小
for (选择:本层集合中元素) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
1. 组合(LeetCode 77)
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
class Solution {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> results = new ArrayList<List<Integer>>();
public List<List<Integer>> combine(int n, int k) {
backtracking(k, 1, n);
return results;
}
// num表示还需要选择的元素个数
public void backtracking(int num, int start, int end) {
if(num == 0) {
results.add(new ArrayList<Integer>(path));
return ;
}
// 剪枝:有效i的最大值为end-num+1,后面的i凑不齐num个元素
for(int i = start; i <= end-num+1; ++i) {
path.add(i);
backtracking(num-1, i+1, end);
path.removeLast();
}
return ;
}
}
2. 组合总和 III(LeetCode 216)
找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
class Solution {
List<Integer> path = new LinkedList<Integer>();
List<List<Integer>> results = new ArrayList<List<Integer>>();
int sum = 0;
public List<List<Integer>> combinationSum3(int k, int n) {
backtracking(1, 9, k, n);
return results;
}
public void backtracking(int start, int end, int k, int n) {
// 剪枝
if(sum > n)
return ;
// 终止条件
if(path.size() == k) {
if(sum == n)
results.add(new ArrayList<Integer>(path));
return ;
}
for(int i = start; i <= end-(k-path.size())+1; ++i) {
path.add(i);
sum += i;
backtracking(i+1, end, k, n);
path.removeLast();
sum -= i;
}
return ;
}
}
3. 电话号码的字母组合(LeetCode 17)
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
注意:
- 在字符串拼接频繁使用的情况下,使用
StringBuilder
- 简单的数字对应不需要HashMap,使用数组即可,比如这里的String[]
class Solution {
String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
List<String> results = new ArrayList<String>();
StringBuilder path = new StringBuilder();
public List<String> letterCombinations(String digits) {
if(digits == null ||digits.length() == 0)
return results;
backtracking(0, digits);
return results;
}
// num表示当前在digits中的索引
public void backtracking(int num, String digits) {
if(num == digits.length()) {
results.add(path.toString());
return ;
}
//str 表示当前digits[num]数字对应的所有可能字母
String str = numString[digits.charAt(num) - '0'];
for(int i = 0; i < str.length(); ++i) {
path.append(str.charAt(i));
backtracking(num + 1, digits);
path.deleteCharAt(path.length() - 1);
}
return ;
}
}
4. 组合总和(LeetCode 39)
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,
找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的同一个
数字可以无限制重复被选取
。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtracking(candidates, target, 0, 0);
return results;
}
public void backtracking(int[] candidates, int target, int sum, int start) {
if(sum > target)
return ;
if(sum == target) {
results.add(new ArrayList<Integer>(path));
}
// i不小于start,防止重复组合的出现
for(int i = start; i<candidates.length; ++i) {
path.add(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i);
path.removeLast();
sum -= candidates[i];
}
return ;
}
}
5. 组合总和 II(LeetCode 40)
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
- 对数组进行排序,使用
Arrays.sort(nums)
- 本题是在树的同层上进行去重
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[[1,1,6], [1,2,5], [1,7], [2,6]]
解释: candidates = [1, 1, 2, 5, 6, 7, 10]
在for循环中选了第一个1的所有情况中包含了一个1,两个1的情况。
从第二个1开始的情况为一个1的情况,已经被包含了,因此continue跳过。
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 先对数组进行排序
Arrays.sort(candidates);
backtracking(candidates, target, 0, 0);
return results;
}
public void backtracking(int[] candidates, int target, int sum, int start) {
if(sum > target)
return ;
if(sum == target) {
results.add(new ArrayList<Integer>(path));
return ;
}
for(int i = start; i<candidates.length; ++i) {
// 树的横向层面,防止相同元素重复选取形成组合
if(i > start && candidates[i] == candidates[i-1])
continue;
path.add(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i+1);
path.removeLast();
sum -= candidates[i];
}
return ;
}
}
6. 分割回文串(LeetCode 131)
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
class Solution {
List<List<String>> results = new ArrayList<List<String>>();
List<String> path = new LinkedList<String>();
public List<List<String>> partition(String s) {
backtracking(s, 0);
return results;
}
public void backtracking(String s, int start) {
if(start >= s.length()) {
results.add(new ArrayList(path));
return ;
}
for(int i = start; i<s.length(); ++i) {
if(isPalindrome(s, start, i)) {
// start ~ i
path.add(s.substring(start, i+1));
backtracking(s, i+1);
path.removeLast();
}
}
return ;
}
// 双指针判断是否为回文字符串
public boolean isPalindrome(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;
}
}
7. 复原IP地址(LeetCode 93)(有难度)
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。
你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
class Solution {
List<String> results = new ArrayList<String>();
public List<String> restoreIpAddresses(String s) {
if(s.length() < 4 || s.length() > 12)
return results;
StringBuilder sb = new StringBuilder(s);
backtracking(sb, 0, 0);
return results;
}
public void backtracking(StringBuilder sb, int start, int sum) {
// 在s的基础上插入三个点,如果4个数字都是合法的,则加入results
if(sum == 3) {
if(isValid(sb, start, sb.length()-1)) {
results.add(sb.toString());
}
return ;
}
for(int i = start; i<sb.length(); ++i) {
if(isValid(sb, start, i)) {
sb.insert(i+1, '.');
backtracking(sb, i+2, sum+1);
sb.deleteCharAt(i+1);
}
// 以不合法的字串为前缀的串也是不合法的所以可以直接break
else
break;
}
}
public boolean isValid(StringBuilder sb, int start, int end) {
if(start > end)
return false;
if(end > start+2)
return false;
if(end > start && sb.charAt(start) == '0')
return false;
int num = 0;
for(int i = start; i<=end; ++i) {
int digit = sb.charAt(i) - '0';
num = num*10 + digit;
if(num > 255)
return false;
}
return true;
}
}
8. 子集(LeetCode 78)
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
public List<List<Integer>> subsets(int[] nums) {
backtracking(nums, 0);
return results;
}
public void backtracking(int[] nums, int start) {
results.add(new ArrayList<Integer>(path));
if(start >= nums.length) {
return ;
}
for(int i = start; i<nums.length; ++i) {
path.add(nums[i]);
backtracking(nums, i+1);
path.removeLast();
}
}
}
9. 子集 II(LeetCode 90)
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
注意:
- 因为nums中有重复的元素,并且每个元素只能使用一次,还要子集不重复,因此需要先排序,再在for循环中进行树的层级剪枝
- 和
组合总和 II(LeetCode 40)
的思路是一致的
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
backtracking(nums, 0);
return results;
}
public void backtracking(int[] nums, int start) {
results.add(new ArrayList<Integer>(path));
if(start >= nums.length)
return ;
for(int i = start; i<nums.length; ++i) {
// 树的层级剪枝
if(i>start && nums[i] == nums[i-1])
continue;
path.add(nums[i]);
backtracking(nums, i+1);
path.removeLast();
}
}
}
10. 非递减子序列(LeetCode 491)
给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
注意:
- 因为不能进行排序,所以需要
hashset
或数组来存储 该树层中已经使用过的元素,避免选到重复元素 - for循环的第一个筛选条件很重要,可以选择的情况
- 该层中当前元素未被使用过,且为第一个元素
- 该层中当前元素未被使用过,且不小于path的最后一个元素
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
public List<List<Integer>> findSubsequences(int[] nums) {
backtracking(nums, 0);
return results;
}
public void backtracking(int[] nums, int start) {
if(path.size() >= 2)
results.add(new ArrayList<Integer>(path));
if(start >= nums.length)
return ;
// 记录同层的之前使用过的元素,避免重复
// 遍历树的每层前,都会刷新
HashSet<Integer> set = new HashSet<>();
for(int i = start; i<nums.length; ++i) {
if((!path.isEmpty() && path.getLast() > nums[i]) || set.contains(nums[i]))
continue;
path.add(nums[i]);
set.add(nums[i]);
backtracking(nums, i+1);
path.removeLast();
}
}
}
11. 全排列(LeetCode 46)
给定一个不含重复数字
的数组 nums ,返回其所有可能的全排列
。你可以按任意顺序返回答案。
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思路:
- 因为nums的取值范围是-10到10,所以可以使用used数组来记录纵向上某个元素是否被使用过
- used[i]为true表示该元素已使用,为false表示该元素未使用,可以进行选取
- 排列问题和组合问题的区别在于:排列问题的for循环是从0开始的,而组合问题往往需要维护start
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
boolean[] used = new boolean[21];
public List<List<Integer>> permute(int[] nums) {
backtracking(nums);
return results;
}
public void backtracking(int[] nums) {
if(path.size() == nums.length){
results.add(new ArrayList<>(path));
return ;
}
for(int i=0; i<nums.length; ++i) {
if(used[nums[i]+10] == true)
continue;
path.add(nums[i]);
used[nums[i]+10] = true;
backtracking(nums);
path.removeLast();
used[nums[i]+10] = false;
}
}
}
12. 全排列II(LeetCode 47)
给定一个可包含重复数字
的序列 nums ,按任意顺序 返回所有不重复的全排列。
- 1 <= nums.length <= 8
- -10 <= nums[i] <= 10
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
注意
- used数组可以用数值做下标,也可以用索引做下标,关键是清楚下标的含义
- 本题需要进行树层去重和树枝去重
- 树层去重:排序, nums[i] == nums[i-1], used[i-1]==false确保判断的是同树层
- 树枝去重:used[i] = true
// 方法一
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
boolean[] used = new boolean[8];
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
backtracking(nums);
return results;
}
public void backtracking(int[] nums) {
if(path.size() == nums.length) {
results.add(new ArrayList<>(path));
return ;
}
for(int i=0; i<nums.length; ++i) {
// 同树层的去重
if(i>0 && nums[i] == nums[i-1] && used[i-1] == false)
continue;
// 树枝去重
if(used[i] == true)
continue;
path.add(nums[i]);
used[i] = true;
backtracking(nums);
path.removeLast();
used[i] = false;
}
}
}
// 方法二
class Solution {
List<List<Integer>> results = new ArrayList<List<Integer>>();
List<Integer> path = new LinkedList<Integer>();
// 纵向记录元素的使用情况
boolean[] used = new boolean[8];
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
backtracking(nums);
return results;
}
public void backtracking(int[] nums) {
if(path.size() == nums.length) {
results.add(new ArrayList<>(path));
return ;
}
// 防止同树层选取重复元素,每层开始时重置
boolean[] usedSameLevel = new boolean[21];
for(int i=0; i<nums.length; ++i) {
if(used[i] || usedSameLevel[nums[i]+10])
continue ;
path.add(nums[i]);
used[i] = true;
usedSameLevel[nums[i]+10] = true;
backtracking(nums);
path.removeLast();
used[i] = false;
}
}
}
13. 重新安排行程(LeetCode 332)(有难度)
给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
例如,行程 ["JFK", "LGA"] 与 ["JFK", "LGB"] 相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。
输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]
class Solution {
List<String> results;
List<String> path = new LinkedList<String>();
Map<String, Map<String, Integer>> map;
public List<String> findItinerary(List<List<String>> tickets) {
map = new HashMap<String, Map<String, Integer>>();
for(List<String> t: tickets) {
Map<String, Integer> temp;
if(map.containsKey(t.get(0))) {
temp = map.get(t.get(0));
temp.put(t.get(1), temp.getOrDefault(t.get(1), 0) + 1);
} else {
// 使用TreeMap: 按照target的字典序升序
temp = new TreeMap<>();
temp.put(t.get(1), 1);
}
map.put(t.get(0), temp);
}
path.add("JFK");
backtracking(tickets.size());
return path;
}
public boolean backtracking(int num) {
if(path.size() == num + 1) {
return true;
}
String last = path.getLast();
if(map.containsKey(last)) {
for(Map.Entry<String, Integer> target : map.get(last).entrySet()) {
int count = target.getValue();
if(count > 0) {
path.add(target.getKey());
target.setValue(count-1);
if(backtracking(num))
return true;
path.removeLast();
target.setValue(count);
}
}
}
return false;
}
}
14. N皇后问题(LeetCode 51)
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
思路:
- 将问题的求解抽象为树结构,父节点是子节点的上一步
- 一行一行地放置棋子,每行一个,
递归的深度
- 对于每行,检查每一列,
for循环
,位置有效isValid
才进行放置,并进一步递归
class Solution {
List<List<String>> results = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
char[][] chessboard = new char[n][n];
for(char[] row : chessboard) {
Arrays.fill(row, '.');
}
backtracking(n, 0, chessboard);
return results;
}
public void backtracking(int n, int row, char[][] chessboard) {
if(row == n) {
results.add(Array2List(chessboard));
return;
}
for(int i = 0; i<n; ++i) {
if(isValid(row, i, n, chessboard)) {
chessboard[row][i] = 'Q';
backtracking(n, row+1, chessboard);
chessboard[row][i] = '.';
}
}
}
public boolean isValid(int row, int col, int n, char[][] chessboard) {
// 检查同列
for(int i = 0; i<row; ++i) {
if(chessboard[i][col] == 'Q')
return false;
}
// 检查对角线1(45度)
for(int i = row-1, j = col-1; i>=0 && j >= 0; i--, j--) {
if(chessboard[i][j] == 'Q')
return false;
}
// 检查对角线2(135度)
for(int i = row-1, j = col+1; i>=0 && j <= n-1; i--, j++) {
if(chessboard[i][j] == 'Q')
return false;
}
return true;
}
// 将二维字符数组char[][]转化为List<String>
public List<String> Array2List(char[][] chessboard) {
List<String> list = new ArrayList<>();
for(char[] row : chessboard) {
list.add(String.copyValueOf(row));
}
return list;
}
}
15. 解数独(LeetCode 37)
编写一个程序,通过填充空格来解决9*9数独问题。
数独的解法需 遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。
思路
- 递归的深度表示,填入数字的个数。即每下一步都是递归一次
class Solution {
public void solveSudoku(char[][] board) {
backtracking(board);
}
public boolean backtracking(char[][] board) {
for(int i = 0; i<9; ++i) {
for(int j = 0; j<9; ++j) {
// 跳过有数字的格子
if(board[i][j] != '.')
continue;
for(char k = '1'; k<='9'; ++k) {
if(isValid(i, j, k, board)) {
board[i][j] = k;
if(backtracking(board))
return true;
board[i][j] = '.';
}
}
// 遍历了1-9都不行,返回false
return false;
}
}
return true;
}
public boolean isValid(int row, int col, char ch, char[][] board) {
// 检查同行
for(int i = 0; i<9; ++i) {
if(i == col)
continue;
if(board[row][i] == ch)
return false;
}
// 检查同列
for(int i = 0; i<9; ++i) {
if(i == row)
continue;
if(board[i][col] == ch)
return false;
}
// 检查同一个9宫格
for(int i = row/3*3; i<row/3*3+3; ++i) {
for(int j = col/3*3; j<col/3*3+3; ++j) {
if(i == row && j == col)
continue;
if(board[i][j] == ch)
return false;
}
}
return true;
}
}