理论基础
1. 回溯法:即回溯搜索法,就是暴力搜索+(剪枝优化)
-
回溯是递归的副产品,只要有递归就会有回溯。
-
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,所以并不高效。如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
-
回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成树的宽度,递归的深度构成的树的深度。递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
2. 回溯法,一般可以解决如下几种问题:即在集合中递归查找子集
-
组合问题:N个数里面按一定规则找出k个数的集合,不强调元素顺序
-
切割问题:一个字符串按一定规则有几种切割方式
-
子集问题:一个N个数的集合里有多少符合条件的子集
-
排列问题:N个数按一定规则全排列,有几种排列方式,强调元素顺序
-
棋盘问题:N皇后,解数独等等
3. 回溯算法模板:三部曲
-
返回值和参数:
void backtracking(参数)
-
终止条件:搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
if (终止条件) {
存放结果;
return;
}
-
遍历过程:回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
回溯法就用递归来解决for嵌套层数的问题。
可以从图中看出for循环是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
一、组合
回溯三部曲:
1. 递归函数参数:输入为n和k,还需要startIndex来控制for循环的起始位置,防止重复;
2. 终止条件:叶子节点,path.size() == k;
3. 单层逻辑:单层for循环依然是从startIndex开始,控制树的横向遍历,path处理节点、递归、回溯。
class Solution {
List<List<Integer>> result= new ArrayList<>(); // 存放所有结果
LinkedList<Integer> path = new LinkedList<>(); // 当前结果
public List<List<Integer>> combine(int n, int k) {
// n相当于树的宽度,k相当于树的深度
backtracking(n, k, 1);
return result;
}
public void backtracking(int n,int k,int startIndex){
if (path.size() == k){ // 终止条件
result.add(new ArrayList<>(path));
return;
}
// 剪枝优化:i <= n - (k - path.size()) + 1
for (int i = startIndex; i <= n; i++){
// startIndex记录本层递归的中,集合从哪里开始遍历,防止重复
path.add(i);
backtracking(n, k, i+1);
path.removeLast(); // 回溯
}
}
}
剪枝优化:当n = 4,k = 4,第一层for循环中,从元素2、3、4开始的遍历都没有意义。 在第二层for循环中,从元素3、4开始的遍历都没有意义。
所以,可以剪枝的地方在递归中每一层的for循环所选择的起始位置 — i 。如果for循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了。
已经选择的元素个数:path.size();
还需要的元素个数为: k - path.size();
在集合n中至多可以从该起始位置 : n - (k - path.size()) + 1,开始遍历。为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
二、组合总和III
回溯三部曲:
1. 递归函数参数:输入为n和k,sum统计单一结果path里的总和,还需要startIndex来控制for循环的起始位置;
2. 终止条件:path.size() 和 k相等就终止,如果此时path里收集到的元素和(sum) 和 n 相同了,就用result收集当前的结果,如果path.size() == k 但sum != n 直接返回;
3. 单层逻辑:单层for循环依然是从startIndex开始,控制树的横向遍历,path处理节点、递归(sum+i, i+1)、回溯。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracking(n, k, 1, 0);
return result;
}
private void backtracking(int n, int k, int startIndex, int sum){
if(path.size() == k){
if(sum == n){ // 如果path.size() == k 但sum != n 直接返回,不记录这个解
result.add(new ArrayList<>(path));
}
return;
}
for(int i = startIndex; i <= 9; i++){
path.add(i);
backtracking(n, k, i+1, sum+i); // sum+i代入函数,省去了显式回溯过程
path.removeLast(); // 回溯
}
}
}
剪枝优化:已选元素总和如果已经大于n了,往后遍历没有意义
有两个地方可以剪枝,一是起始位置 i 处,二是当和>n时。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracking(n, k, 1, 0);
return result;
}
private void backtracking(int n, int k, int startIndex, int sum){
if (sum > n) { // 剪枝
return;
}
if(path.size() == k){ //终止条件
if(sum == n){ // 如果path.size() == k 但sum != n 直接返回,不记录这个解
result.add(new ArrayList<>(path));
}
return;
}
// 剪枝
for(int i = startIndex; i <= 9 - (k - path.size()) + 1; i++){
path.add(i);
backtracking(n, k, i+1, sum+i); // sum+i代入函数,省去了显式回溯过程
path.removeLast(); // 回溯
}
}
}
三、组合总和-子集内元素可重复,递归不必i+1
本题没有数量要求,可以无限重复,但有总和的限制,所以间接地有个数的限制。递归没有层数的限制,只要选取的元素总和超过target,就返回。
回溯三部曲:
1. 递归函数参数:输入为集合candidates和目标值target,sum统计单一结果path里的总和,还需要startIndex来控制for循环的起始位置;
2. 终止条件:终止有两种情况,sum大于target和sum等于target。sum等于target的时候,需要收集结果,sum大于target时直接返回;
3. 单层逻辑:单层for循环依然是从startIndex开始,搜索candidates集合。本题元素为可重复选取的,递归backtracking(candidates, target, sum, i) 时不用 i+1 了,表示可以重复读取当前的数。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtracking(candidates, target, 0, 0); // 从下标0开始
return result;
}
private void backtracking(int[] candidates, int target, int startIndex, int sum){
// 两个终止条件
if (sum > target) {
return;
}
if(sum == target){
result.add(new ArrayList<>(path));
return;
}
for(int i = startIndex; i < candidates.length; i++){
path.add(candidates[i]);
backtracking(candidates, target, i, sum+candidates[i]); // 不必i+1,表示可以重复读取当前的数
path.removeLast(); // 回溯
}
}
}
剪枝优化
对于sum已经大于target的情况,依然进入了下一层递归,只是下一层递归终止条件会判断sum > target返回。如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历,后边的遍历也不会有满足的结果(因为排序了)。
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++)
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // 先进行排序
backtracking(candidates, target, 0, 0);
return result;
}
private void backtracking(int[] candidates, int target, int startIndex, int sum){
if(sum == target){ // 终止条件
result.add(new ArrayList<>(path));
return;
}
// 如果 sum + candidates[i] > target 就终止遍历
for (int i = startIndex; i < candidates.length; i++){
if (sum + candidates[i] > target){
break;
}
path.add(candidates[i]);
backtracking(candidates, target, i, sum+candidates[i]); // 不必i+1,表示可以重复读取当前的数
path.removeLast(); // 回溯
}
}
}
四、组合总和II-去重
子集不能重复,集合内可有重复元素,需去重:排序后,若 i > startIndex && candidates[i] == candidates[i - 1],说明在同一树层,当前candidates[i]重复,跳过本次以 i 开头的所有遍历。
if ( i > startIndex && candidates[i] == candidates[i - 1] ){ // 跳过使用过的元素
continue;
}
回溯三部曲:
1. 递归函数参数:输入为集合candidates和目标值target,sum统计单一结果path里的总和,还需要startIndex来控制for循环的起始位置;
2. 终止条件:sum≥target,sum等于target的时候,需要收集结果,sum大于target时在单层逻辑剪枝,if (sum + candidates[i] > target) break;
3. 单层逻辑:单层for循环依然是从startIndex开始,搜索candidates集合。本题子集不能重复需去重,当 ( i > startIndex && candidates[i] == candidates[i - 1] )时说明当前元素与前一个元素相同(需要先排序),跳过当前元素的循环实现去重。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates); // 先进行排序
backtracking(candidates, target, 0, 0);
return result;
}
private void backtracking(int[] candidates, int target, int startIndex, int sum){
if(sum == target){ // 终止条件
result.add(new ArrayList<>(path));
return;
}
// 如果 sum + candidates[i] > target 就终止遍历
for (int i = startIndex; i < candidates.length; i++){
if (sum + candidates[i] > target){
break;
}
if ( i > startIndex && candidates[i] == candidates[i - 1] ){ // 跳过使用过的元素
continue;
}
path.add(candidates[i]);
backtracking(candidates, target, i+1, sum+candidates[i]); // 注意此处i+1
path.removeLast(); // 回溯
}
}
}
五、电话号码的字母组合-多个集合求组合
在组合问题中,如果是一个集合来求组合的话,就需要startIndex,例如一二三四;如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如本题。
回溯三部曲:
1. 递归函数参数:输入为字符串s和遍历到第num个数字了,需要num是因为需要逐个数字与字符集合对应;
2. 终止条件:遍历到第num==digits.length()个数字,说明整个电话号码都遍历结束
3. 单层逻辑:找到本轮第num个数字对应的字符集合,依次加入,继续向下递归。
class Solution {
String[] letter_map = {" ","*","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"}; // 让下标对应数字,数字与字母的映射:数组或map
List<String> list = new ArrayList<>(); // 所有结果
StringBuilder temp = new StringBuilder(); // 当前字符串,每次迭代获取一个字符串,会涉及大量的字符串拼接,所以这里选择更为高效的 StringBuilder
public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) {
return list;
}
backTracking(digits, 0);
return list;
}
public void backTracking(String digits, int num) {// num记录遍历到第几个数字了
// 终止条件
if (num == digits.length()) {
list.add(temp.toString());
return;
}
//str:当前数字对应的可选的字符
String str = letter_map[digits.charAt(num) - '0']; // 字符转int(-‘0’)
for (int i = 0; i < str.length(); i++) {
temp.append(str.charAt(i)); // 添加
backTracking(digits, num + 1); // 递归
temp.deleteCharAt(temp.length() - 1); // 回溯
}
}
}
六、分割回文串-较难
两个问题:1. 切割; 2. 判断回文
切割与组合问题类似:
回溯三部曲:
1. 递归函数参数:输入为字符串s和startIndex,需要startIndex是因为切割过的地方不能重复切割
2. 终止条件:切割线(startIndex)切到了字符串最后面,说明找到了一种切割方法,切割完毕
3. 单层逻辑:在for (int i = startIndex; i < s.size(); i++)循环中,截取的子串为[startIndex, i],判断这个子串是不是回文,是回文则记录,不是回文继续切割。
判断回文:双指针,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串。
class Solution {
List<List<String>> result = new ArrayList<>();
Deque<String> path = new LinkedList<>(); // 切割过的回文子串
public List<List<String>> partition(String s) {
backtracking(s, 0);
return result;
}
private void backtracking(String s, int startIndex){
// 终止条件:起始位置 ≥ s长度,说明找到了一组分割方案
if (startIndex >= s.length()) {
result.add(new ArrayList(path));
return;
}
for(int i = startIndex; i < s.length(); i++){
//如果[startIndex, i]是回文子串,则记录
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
path.addLast(str);
} else { // 不是回文,跳过当前i的循环,后移i继续添加使子串变长
continue;
}
backtracking(s, i + 1); //起始位置i+1,不是startInedx+1,保证不重复
path.removeLast(); // 回溯
}
}
// 判断回文:双指针法
private 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;
}
}
七、复原IP地址
也属于切割问题
回溯三部曲:
1. 递归函数参数:输入为字符串s和startIndex和pointNum,需要startIndex是因为切割过的地方不能重复切割,pointNum用来记录添加点的数量;
2. 终止条件:pointNum为3说明字符串分成了4段,切割完毕,验证一下第四段是否合法,如果合法就加入到结果集里;
3. 单层逻辑:在for (int i = startIndex; i < s.size(); i++)循环中,截取的子串为[startIndex, i],判断这个子串是不是合法(0~255),如果合法就在字符串后面加上符号'
.'
表示已经分割,如果不合法就结束本层循环。注意:下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符'
.'
),同时记录分割符的数量pointNum 要 +1。回溯的时候,将刚刚加入的分隔符删掉,pointNum-1
判断子串是否合法:
-
以0为开头的数字不合法
-
非正整数字符不合法
-
大于255不合法
class Solution {
List<String> result = new ArrayList<>();
public List<String> restoreIpAddresses(String s) {
if (s.length() > 12){ // 剪枝,长度>3*4一定不行
return result;
}
backTrack(s, 0, 0);
return result;
}
// startIndex: 搜索的起始位置, pointNum:添加逗点的数量
private void backTrack(String s, int startIndex, int pointNum) {
if (pointNum == 3) {// 终止条件:逗点数量为3时,分割了四段
// 判断第四段⼦字符串是否合法,如果合法就放进result中
if (isValid(s,startIndex,s.length()-1)) {
result.add(s);
}
return;
}
for (int i = startIndex; i < s.length(); i++) {
if (isValid(s, startIndex, i)) {
s = s.substring(0, i + 1) + "." + s.substring(i + 1); //在str的后⾯插⼊⼀个逗点
backTrack(s, i + 2, pointNum + 1);// 插⼊逗点之后下⼀个⼦串的起始位置为i+2,逗点数+1
s = s.substring(0, i + 1) + s.substring(i + 2);// 回溯删掉逗点
} else {
break;
}
}
}
// 判断字符串s在左闭⼜闭区间[start, end]所组成的数字是否合法
private Boolean isValid(String s, int start, int end) {
if (start > end) {
return false;
}
if (s.charAt(start) == '0' && start != end) { // 0开头的数字不合法
return false;
}
int num = 0;
for (int i = start; i <= end; i++) {
if (s.charAt(i) > '9' || s.charAt(i) < '0') { // 遇到⾮数字字符不合法
return false;
}
num = num * 10 + (s.charAt(i) - '0');
if (num > 255) { // 如果⼤于255了不合法
return false;
}
}
return true;
}
}
优化:用StringBuilder存s,向字符串插入字符时无需复制整个字符串,从而减少了操作的时间复杂度,也不用开新空间存subString,从而减少了空间复杂度。
class Solution {
List<String> result = new ArrayList<>();
public List<String> restoreIpAddresses(String s) {
StringBuilder sb = new StringBuilder(s);
backTracking(sb, 0, 0);
return result;
}
private void backTracking(StringBuilder s, int startIndex, int dotCount){
if(dotCount == 3){
if(isValid(s, startIndex, s.length() - 1)){
result.add(s.toString());
}
return;
}
for(int i = startIndex; i < s.length(); i++){
if(isValid(s, startIndex, i)){
s.insert(i + 1, '.');
backTracking(s, i + 2, dotCount + 1);
s.deleteCharAt(i + 1);
}else{
break;
}
}
}
//[start, end]
private boolean isValid(StringBuilder s, int start, int end){
if(start > end)
return false;
if(s.charAt(start) == '0' && start != end)
return false;
int num = 0;
for(int i = start; i <= end; i++){
int digit = s.charAt(i) - '0';
num = num * 10 + digit;
if(num > 255)
return false;
}
return true;
}
}
八、子集问题-所有节点
如果把子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
子集也是一种组合问题,因为它的集合是无序的。既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始。但求排列问题的时候,集合是有序的,for循环从0开始。
遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
回溯三部曲:
1. 递归函数参数:输入为数组nums和startIndex,需要startIndex是因为取过的元素不会重复取;
2. 终止条件:startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。也可以不加终止条件,因为startIndex >= nums.size(),本层for循环也结束了
3. 单层逻辑:处理节点,递归(startIndex+1),回溯。
class Solution {
List<List<Integer>> result = new ArrayList<>();// 所有结果
LinkedList<Integer> path = new LinkedList<>();// 当前结果
public List<List<Integer>> subsets(int[] nums) {
backtracking(nums, 0);
return result;
}
private void backtracking(int[] nums, int startIndex){
result.add(new ArrayList<>(path));// 记录所有节点
// 终止条件:可以不写
if(startIndex >= nums.length){
return;
}
for (int i = startIndex; i < nums.length; i++){
path.add(nums[i]);
backtracking(nums, i + 1);
path.removeLast();
}
}
}
九、子集II
与八题区别在于nums中有重复元素,但子集不能重复。与四题组合总和类似,可以先将nums排序,如果 ( i > startIndex && nums[i] == nums[i - 1] )时说明当前元素与前一个元素相同(,跳过当前元素的循环实现去重。
回溯三部曲:
1. 递归函数参数:输入为数组nums和startIndex,需要startIndex是因为取过的元素不会重复取;
2. 终止条件:startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。也可以不加终止条件,因为startIndex >= nums.size(),本层for循环也结束了
3. 单层逻辑:判断元素是否重复,若重复则从continue跳过,不重复则处理节点,递归(startIndex+1),回溯。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
backtracking(nums, 0);
return result;
}
private void backtracking(int[] nums, int startIndex){
result.add(new ArrayList<>(path)); // 记录所有节点
if (startIndex >= nums.length){ // 终止条件,可不写
return;
}
for (int i = startIndex; i < nums.length; i++){
if (i > startIndex && nums[i] == nums[i - 1]){
continue;
}
path.add(nums[i]);
backtracking(nums, i + 1);
path.removeLast();
}
}
}
十、递增子序列
与九题相似,但去重逻辑不同。九题先排序,再去重。但本题求自增子序列,是不能对原数组进行排序的。
回溯三部曲:
1. 递归函数参数:输入为数组nums和startIndex,需要startIndex是因为取过的元素不会重复取;
2. 终止条件:递增子序列大小至少为2,所以path.size() > 1时记录结果;
3. 单层逻辑:同一父节点下的同层上使用过的元素就不能再使用了(见下图),使用哈希表完成去重。
class Solution {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> findSubsequences(int[] nums) {
backTracking(nums, 0);
return result;
}
private void backTracking(int[] nums, int startIndex){
if(path.size() > 1){
result.add(new ArrayList<>(path)); // 记录节点
}
HashSet<Integer> hs = new HashSet<>();
for(int i = startIndex; i < nums.length; i++){
// path非空且path最后一位大于当前元素——>非递增 或 同层重复
if((!path.isEmpty() && path.get(path.size() -1 ) > nums[i]) || hs.contains(nums[i])){
continue;
}
hs.add(nums[i]);
path.add(nums[i]);
backTracking(nums, i + 1);
path.remove(path.size() - 1);
}
}
}
十一、全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
排列问题顺序不同,结果也不同,所以排列问题无需startIndex。
回溯三部曲:
1. 递归函数参数:输入为数组nums;
2. 终止条件:叶子节点,当path的大小==nums数组大小时,说明找到了一个全排列,即到达了叶子节点;
3. 单层逻辑:排列问题每次都要从头开始搜索,需判断当前元素nums[i]是否已经在path中,若不在可以加入,若已存在不能再加,跳过。
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
if (nums.length == 0){
return result;
}
backtrack(nums);
return result;
}
public void backtrack(int[] nums) {
if (path.size() == nums.length) { // 终止条件:叶子节点
result.add(new ArrayList<>(path));
}
for (int i =0; i < nums.length; i++) {
// 如果path中已有,则跳过
if (path.contains(nums[i])) {
continue;
}
path.add(nums[i]);
backtrack(nums);
path.removeLast();
}
}
}
十二、全排列II
给定一个可包含重复数字的序列 nums ,按任意顺序返回所有不重复的全排列。
与四、八相似。
class Solution {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new LinkedList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
if(nums.length == 0){
return result;
}
boolean[] used = new boolean[nums.length];
// used[i - 1] == true,说明同⼀树⽀nums[i - 1]使⽤过
// used[i - 1] == false,说明同⼀树层nums[i - 1]使⽤过
Arrays.sort(nums); // 排序方便去重,通过相邻节点判断是否重复使用
backTracking(nums, used);
return result;
}
public void backTracking(int[] nums, boolean[] used){
if(path.size() == nums.length){
result.add(new ArrayList<>(path));
}
for(int i = 0; i < nums.length; i++){
if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false){ // 同一树层去重,此时说明同一树层有重复元素
continue;
}
if(used[i] == false){ //如果同⼀树⽀nums[i]没使⽤过开始处理
used[i] = true; //标记同⼀树⽀nums[i]使⽤过,防止同一树枝重复使用
path.add(nums[i]);
backTracking(nums, used);
path.removeLast();
used[i] = false;
}
}
}
}
十三、重新安排行程
难点:
一个行程中,如果航班处理不好容易变成一个圈,成为死循环——>排序+used数组
有多种解法,字母序靠前排在前面,如何该记录映射关系——>used数组
使用回溯法(深搜) 的话,终止条件是什么——>path.size()==n+1
搜索的过程中,如何遍历一个机场所对应的所有机场
回溯三部曲:
1. 递归函数参数:输入为数组nums;
2. 终止条件:唯一叶子节点,当tickets中有n趟航班,那么只要找出一种行程,行程里的机场个数是 n+1 就可以了;
3. 单层逻辑:遍历一个机场所去的所有机场。
函数返回值用的是boolean,之前回溯算法一般函数返回值都是void,但本题只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线
class Solution {
List<String> result = new LinkedList<>();
List<String> path = new LinkedList<>();
public List<String> findItinerary(List<List<String>> tickets) {
Collections.sort(tickets, (a, b) -> a.get(1).compareTo(b.get(1))); // lambda表达式:对机票的到达机场排序,用作sort方法的比较器
path.add("JFK"); // 第一个出发地一定为JFK
boolean[] used = new boolean[tickets.size()]; // 记录该航班是否用过
backTracking((ArrayList) tickets, used);
return result;
}
public boolean backTracking(ArrayList<List<String>> tickets, boolean[] used) {
if (path.size() == tickets.size() + 1) { // 终止条件:唯一叶子节点
result = new LinkedList(path);
return true;
}
for (int i = 0; i < tickets.size(); i++) {
if (!used[i] && tickets.get(i).get(0).equals(path.getLast())) { // 该航班没用过且起终点相符
path.add(tickets.get(i).get(1));
used[i] = true;
if (backTracking(tickets, used)) {
return true;
}
used[i] = false;
path.removeLast();
}
}
return false;
}
}
十四、n皇后
约束条件:
不能同行
不能同列
不能同斜线
棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度。
回溯三部曲:
1. 递归函数参数:输入为棋盘大小n,当前遍历到棋盘的第几层-row,和棋盘chessboard;
2. 终止条件:叶子节点,row==n;
3. 单层逻辑:递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。每次都是要从新的一行的起始位置0开始搜,所以都是从0开始。
按约束条件验证棋盘是否合法。没有同行检查是因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了。
class Solution {
List<List<String>> res = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
char[][] chessboard = new char[n][n]; // 字符二维数组存棋盘
for (char[] c : chessboard){ // 初始全空位
Arrays.fill(c, '.');
}
backTrack(n, 0, chessboard);
return res;
}
public void backTrack(int n, int row, char[][] chessboard) {
if (row == n){ // 终止条件
res.add(Array2List(chessboard));
return;
}
for (int col = 0; col < n; col++){ //每一行都从0列开始遍历
if (isValid (row, col, n, chessboard)){
chessboard[row][col] = 'Q';
backTrack(n, row+1, chessboard);
chessboard[row][col] = '.';
}
}
}
public List Array2List(char[][] chessboard){ // 二维字符数组转字符串列表
List<String> list = new ArrayList<>();
for (char[] c : chessboard){
list.add(String.copyValueOf(c)); // 字符数组转换为字符串
}
return list;
}
public boolean isValid(int row, int col, int n, char[][] chessboard){ // 检查[row, col]是否合法
// 检查列
for (int i=0; i<row; i++) { // 相当于剪枝
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查左上对角线
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查右上对角线
for (int i = row - 1, j = col + 1; i >= 0 && j <= n - 1; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
}
十五、解数独-二维递归
之前的问题都是一维递归,本题中棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
回溯三部曲:
1. 递归函数参数:输入为二维棋盘;
2. 终止条件:无需终止条件,找到符合条件的解立即返回;
会不会死循环: 递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止,不会死循环;
会不会永远填不满:如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解,那么会直接返回false,不会永远填不满棋盘而无限递归下去。
3. 单层逻辑:一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性。
判断棋盘是否合法:
同行是否重复
同列是否重复
9宫格里是否重复
class Solution {
public void solveSudoku(char[][] board) {
backTracking(board);
}
private 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(isValidSudoku(i, j, k, board)){
board[i][j] = k;
if(backTracking(board)){
return true; // 如果找到合适的解立即返回
}
board[i][j] = '.'; // 回溯
}
}
// 9个数都试完了,都不行,无解
return false;
}
}
return true;
}
private boolean isValidSudoku(int row, int col, char val, char[][] board){
// 同行
for(int i = 0; i < 9; i++){
if(board[row][i] == val){
return false;
}
}
// 同列
for(int j = 0; j < 9; j++){
if(board[j][col] == val){
return false;
}
}
// 同九宫格
int startRow = (row / 3) * 3; // 注意此处的start
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++){
for (int j = startCol; j < startCol + 3; j++){
if (board[i][j] == val){
return false;
}
}
}
return true;
}
}