1、算法基础

1、定义

简单的说,回溯算法就是一种结合递归的暴力搜索方法

2、适用问题

回溯算法 - 图1

3、算法思想

回溯算法 - 图2
伪代码:

  • 回溯函数遍历过程

    1. for(选择:本层集合中元素(苏中节点的数量就是集合的大小)){
    2. 处理几点;
    3. backtracking(路径,选择列表); //递归
    4. 回溯,撤销处理结果
    5. }
  • 回溯算法模板框架 ```java void backtracking(参数){ if(终止条件){ 存放条件; return; }

    for(选择:本层集合中元素(苏中节点的数量就是集合的大小)){ 处理几点; backtracking(路径,选择列表); //递归 回溯,撤销处理结果 } }

  1. <a name="lzasm"></a>
  2. # 2、组合问题
  3. > 首先画一个树形来模拟组合问题
  4. > 确定需要有一个递增的起始参数
  5. > 三部曲
  6. ```java
  7. ArrayList<List<Integer>> res = new ArrayList<>();
  8. LinkedList<Integer> path = new LinkedList<>();
  9. public List<List<Integer>> combine(int n, int k) {
  10. //从 start=1 开始
  11. trackCombine(n,k,1);
  12. return res;
  13. }
  14. //确定参数:前两个和原函数一致,start表示for循环中递归调用的起始位置
  15. void trackCombine(int n, int k,int start) {
  16. //终止条件
  17. if (path.size() == k) {
  18. res.add(new ArrayList<>(path)); //此处path是全局变量,所以需要重新创建
  19. return;
  20. }
  21. //for循环
  22. for (int i = start; i <= n; i++){
  23. path.add(i); //数字加入数组
  24. trackCombine(n, k, ++start);
  25. path.removeLast(); //回溯,返回上一级
  26. }
  27. }

3、组和总和

套用模板

  • 原始

    1. class Solution {
    2. ArrayList<List<Integer>> resSum3 = new ArrayList<>();
    3. LinkedList<Integer> paths = new LinkedList();
    4. public List<List<Integer>> combinationSum3(int k, int n) {
    5. trackSum3(k, n, 1);
    6. return resSum3;
    7. }
    8. void trackSum3(int k, int n, int start) {
    9. int sum = 0;
    10. if (paths.size() == k) {
    11. for (int i = 0; i < paths.size(); i++) {
    12. sum += paths.get(i);
    13. }
    14. if (sum==n) resSum3.add(new ArrayList<>(paths));
    15. }
    16. for (int i = start; i < 10; i++) {
    17. paths.add(i);
    18. trackSum3(k, n, ++start);
    19. paths.removeLast();
    20. }
    21. }
    22. }
  • 将求和带入到参数中

    1. class Solution {
    2. ArrayList<List<Integer>> resSum3 = new ArrayList<>();
    3. LinkedList<Integer> paths = new LinkedList();
    4. public List<List<Integer>> combinationSum3(int k, int n) {
    5. trackSum3(k, n, 1,0);
    6. return resSum3;
    7. }
    8. void trackSum3(int k, int n, int start,int sum) {
    9. //将sum带入 时间复杂度会增高
    10. if (paths.size() == k) {
    11. if (sum==n) resSum3.add(new ArrayList<>(paths));
    12. }
    13. for (int i = start; i < 10; i++) {
    14. sum += i;
    15. paths.add(i);
    16. trackSum3(k, n, ++start, sum);
    17. sum -= i;
    18. paths.removeLast();
    19. }
    20. }
    21. }
  • 剪枝

    1. class Solution {
    2. ArrayList<List<Integer>> resSum3 = new ArrayList<>();
    3. LinkedList<Integer> paths = new LinkedList();
    4. public List<List<Integer>> combinationSum3(int k, int n) {
    5. trackSum3(k, n, 1,0);
    6. return resSum3;
    7. }
    8. void trackSum3(int k, int n, int start,int sum) {
    9. //初步剪枝,如果求和过程中大于目标则返回
    10. if (sum > n) {
    11. return;
    12. }
    13. if (paths.size() == k) {
    14. if (sum==n) resSum3.add(new ArrayList<>(paths));
    15. return;
    16. }
    17. //二次剪枝,如果构不成k个数,则返回
    18. for (int i = start; i <= 9 - (k - paths.size()) + 1; i++) {
    19. sum += i;
    20. paths.add(i);
    21. trackSum3(k, n, i+1, sum);
    22. sum -= i;
    23. paths.removeLast();
    24. }
    25. }
    26. }

4、电话号码的字母组和

  1. class Solution {
  2. ArrayList<String> res = new ArrayList<>();
  3. public List<String> letterCombinations(String digits) {
  4. if (digits.isEmpty()) return res;
  5. String[] strMap = new String[]{ "","","abc","def","ghi","jkl","mno","pqrs","tuv", "wxyz"};
  6. trackLetter(digits, strMap, 0);
  7. return res;
  8. }
  9. StringBuffer sb = new StringBuffer();
  10. //此处的num不是记录开始位置,而是为了记录结果集中当前处理的下标
  11. void trackLetter(String digits, String[] strMap, int num) {
  12. if (num== digits.length()) {
  13. res.add(sb.toString());
  14. return;
  15. }
  16. String str = strMap[digits.charAt(num)-'0'];
  17. //遍历str
  18. for (int i = 0; i < str.length(); i++) {
  19. sb.append(str.charAt(i));
  20. trackLetter(digits, strMap, num+1);
  21. sb.deleteCharAt(sb.length() - 1);
  22. }
  23. }
  24. }

5、组和总和||

此题的难点,主要是数字可以重复使用 对于此,有以下方式应对:

  • 将数组排序,从小到大,保证数字递加时不会提前跳出循环
  • 在回溯模板中,for循环增加 如果sum大于target时,跳出循环
  • for 中的下标 i ,作为下次循环的起始值
  1. ArrayList<List<Integer>> ressum = new ArrayList<>();
  2. LinkedList<Integer> path = new LinkedList<>();
  3. public List<List<Integer>> combinationSum(int[] candidates, int target) {
  4. if (candidates.length == 0) {
  5. return ressum;
  6. }
  7. //数组先行排序
  8. Arrays.sort(candidates);
  9. trackSum(candidates, target, 0,0);
  10. return ressum;
  11. }
  12. void trackSum(int[] cand, int target,int sum, int start) {
  13. System.out.println("sum:"+sum);
  14. if (sum == target){
  15. ressum.add(new ArrayList<>(path));
  16. return;
  17. }
  18. for (int i = start; i < cand.length; i++) {
  19. //由于这种相加的方式,是可以重复的添加,因此需要添加此条件跳出循环
  20. if (sum + cand[i] > target) break;
  21. System.out.println(i);
  22. path.add(cand[i]);
  23. //将i 作为下次开始,保证数字的重复使用
  24. trackSum(cand, target, sum + cand[i], i);
  25. path.removeLast();
  26. }
  27. }

6、分割回文串

  1. 回溯函数的参数:
    1. 字符串,索引值
  2. 回溯函数的逻辑:
    1. 如果子字符串为回文字符串,则将结果加到path中
    2. 如果不是回文,则跳过本次for循环
    3. 递归调用下一个索引起点(索引++)
    4. path移除末尾元素进行回溯
  3. 终止条件
    1. 如果索引值大于等于s 的长度,则返回
  1. class Solution {
  2. ArrayList<List<String >> resP = new ArrayList<>();
  3. LinkedList<String> pathp = new LinkedList<>();
  4. public List<List<String>> partition(String s) {
  5. if (s.length()==0) return resP;
  6. tracePart(s, 0);
  7. return resP;
  8. }
  9. void tracePart(String s, int index) {
  10. // System.out.println("index: "+index);
  11. if (index >= s.length()) {
  12. resP.add(new ArrayList<>(pathp));
  13. return;
  14. }
  15. for (int i = index; i < s.length(); i++) {
  16. if (partFlag(s, index, i)) {
  17. //此处的i+1|只是为了截取字符串
  18. pathp.add(s.substring(index, i+1));
  19. } else {
  20. //当不是回文时,跳过本次循环
  21. continue;
  22. }
  23. //当遍历字符是回文时,在本次的基础上索引直接加1(不需要再减回来),遍历下一个字符
  24. tracePart(s,i+1);
  25. //路径变量进行回溯
  26. pathp.removeLast();
  27. }
  28. }
  29. boolean partFlag(String s, int start, int end) {
  30. for (int i = start, j = end; i < j; i++, j--) {
  31. if (s.charAt(i) != s.charAt(j)) {
  32. return false;
  33. }
  34. }
  35. return true;
  36. }
  37. }

7、复原IP地址

image.png 复原IP地址本质上还是数字组和问题,只不过不能改变数字的顺序,变的只是切割的位置,而且每一个 被分割的子字符串都要符合IP地址的条件。 按照以上思路,我们可以预先定义一个路径集和结果集。 在回溯模板中,参数设置为字符串和开始索引 终止条件就是路径集的大小为 4 ,并且如果开始索引的大小和s的长度一致时,将路径集通过join的方法加入到结果集中。 for循环中i = start,由于字串大小限制最大为3,所以可以增加条件 i<start+3 与 i<s.length() 如果子字符串不符合ip条件。直接跳出循环否则进行回溯 在isValid函数中,s.length()!=1 对单“0”的优化

  1. class Solution {
  2. ArrayList<String> resIP = new ArrayList<>();
  3. LinkedList<String> pathIP = new LinkedList<>();
  4. public List<String> restoreIpAddresses(String s) {
  5. if (s.length() == 0) {
  6. return resIP;
  7. }
  8. trackIP(s, 0);
  9. return resIP;
  10. }
  11. void trackIP(String s, int start) {
  12. //如果存入的数字总共有四个
  13. if (pathIP.size() == 4) {
  14. //且构成的数字和原字符串一致
  15. if (start == s.length()) {
  16. //数组用join方法加标点
  17. resIP.add(String.join(".", pathIP));
  18. }
  19. return;
  20. }
  21. //因为IP是由三个数字组成,且不能超过字符串长度限制
  22. for (int i = start; i < start + 3 && i < s.length(); i++) {
  23. //System.out.println("start:" + start);
  24. // System.out.println("i:" + i);
  25. String sub = s.substring(start, i+1);
  26. //如果不符合要求,可以直接跳出递归
  27. if (!isValid(sub)) break;
  28. //将字串加入路径中
  29. pathIP.add(sub);
  30. trackIP(s, i + 1);
  31. //回溯
  32. pathIP.removeLast();
  33. }
  34. }
  35. boolean isValid(String s) {
  36. //s.length()!=1 对单“0”的优化
  37. if (s.length()!=1&&s.charAt(0)=='0' || Integer.valueOf(s)>255) {
  38. return false;
  39. }
  40. return true;
  41. }
  42. }

8、子集问题

与组和、排列不同,如果将这些都抽象成一棵树的话,组和和分割是收集树的叶子节点,而子集是收集树的所有节点。 又因为子集都是无序的,所以回溯时,for循环要从start开始,并且每一层递归的结果都需要收集。 终止条件是当前长度等于s.length

  1. class Solution {
  2. ArrayList<List<Integer>> resSub = new ArrayList<>();
  3. LinkedList<Integer> pathSub = new LinkedList<>();
  4. public List<List<Integer>> subsets(int[] nums) {
  5. if (nums.length==0) return resSub;
  6. trackSub(nums, 0);
  7. return resSub;
  8. }
  9. void trackSub(int[] nums,int start) {
  10. resSub.add(new ArrayList<>(pathSub));
  11. if (start==nums.length) return;
  12. for (int i = start; i < nums.length; i++) {
  13. pathSub.add(nums[i]);
  14. trackSub(nums, i + 1);
  15. pathSub.removeLast();
  16. }
  17. }
  18. }

9、子集问题2

思想和 8 类似,还是收集回溯树的路径信息,主要有以下两点变动

  1. 有序数组无序且重复,因此需要先对数组进行排序
  2. 为了防止子集重复问题,在for中如果相邻两个数重复则直接跳过
  1. ArrayList<List<Integer>> resSub = new ArrayList<>();
  2. LinkedList<Integer> pathSub = new LinkedList<>();
  3. void trackSub(int[] nums,int start) {
  4. resSub.add(new ArrayList<>(pathSub));
  5. if (start==nums.length) {
  6. System.out.println("changdu ");
  7. return;
  8. }
  9. for (int i = start; i < nums.length; i++) {
  10. //相邻两个数字重复则跳过
  11. if (i>start && nums[i]==nums[i-1]) continue;
  12. pathSub.add(nums[i]);
  13. trackSub(nums, i + 1);
  14. pathSub.removeLast();
  15. }
  16. }
  17. public List<List<Integer>> subsetsWithDup(int[] nums) {
  18. if (nums.length==0) return resSub;
  19. //将数组排序
  20. Arrays.sort(nums);
  21. trackSub(nums, 0);
  22. return resSub;
  23. }

10、递增子序列

与子集问题类似,都是收集抽象树上的路径,但是这个数组不是组和问题,所以不能直接排序,因此需要利用一个hash,将数字的使用情况收集起来

  • 利用hashmap ```java class Solution { ArrayList> resSeq = new ArrayList<>(); LinkedList pathSeq = new LinkedList<>(); public List> findSubsequences(int[] nums) {

    1. if (nums.length==0) return resSeq;
    2. trackSeq(nums,0);
    3. return resSeq;

    }

    void trackSeq(int[] nums, int start) {

    1. if (pathSeq.size()>1) resSeq.add(new ArrayList<>(pathSeq));
    2. if (start== nums.length) return;
    3. //录本层元素是否重复使⽤,新的⼀层uset都会重新定义(清空)
    4. HashMap<Integer, Integer> map = new HashMap<>();
    5. for (int i = start; i < nums.length; i++) {
    6. if (!pathSeq.isEmpty()&&nums[i] < pathSeq.getLast()){ continue;}
    7. if (map.getOrDefault(nums[i],0)>0) continue;
    8. map.put(nums[i], map.getOrDefault(nums[i], 0) + 1);
    9. pathSeq.add(nums[i]);
    10. trackSeq(nums, i + 1);
    11. pathSeq.removeLast();
    12. }

    }

}

  1. - 利用hashset
  2. ```java
  3. class Solution {
  4. //存放结果
  5. List<List<Integer>> result = new ArrayList<>();
  6. //暂存结果
  7. List<Integer> path = new ArrayList<>();
  8. public List<List<Integer>> findSubsequences(int[] nums) {
  9. backTrack(nums, 0);
  10. return result;
  11. }
  12. private void backTrack(int[] nums, int startIndex) {
  13. if(path.size() > 1){
  14. result.add(new ArrayList<>(path));//注意这⾥不要加return,因为要取树上的所有节点
  15. }
  16. HashSet<Integer> uset = new HashSet<>();//录本层元素是否重复使⽤,新的⼀层uset都会重新定义(清空)
  17. for (int i = startIndex; i < nums.length; i++) {
  18. //!path.isEmpty()&&nums[i]<path.get(path.size()-1)) : 保证子序列是递增的
  19. //!uset.add(nums[i]) :保证在同一层不会重复使用相同数字
  20. if ((!path.isEmpty()&&nums[i]<path.get(path.size()-1))||!uset.add(nums[i])) {
  21. continue;
  22. }
  23. path.add(nums[i]);
  24. backTrack(nums, i + 1);
  25. path.remove(path.size() - 1);
  26. }
  27. }
  28. }

11、全排列

全排可以想象成一种暴力排列的方式,因此不需要有前后顺序,所以for循环中的索引每次都从0 开始,参数也无需有索引开始的取值,但由于每次都从0 开始,所以需要判断每层的递归树中使用次数

  • 辅助数组

    1. class Solution {
    2. ArrayList<List<Integer>> resPer = new ArrayList<>();
    3. LinkedList<Integer> pathPer = new LinkedList<>();
    4. boolean[] visited;
    5. public List<List<Integer>> permute(int[] nums) {
    6. visited = new boolean[nums.length];
    7. tackPermute(nums);
    8. return resPer;
    9. }
    10. void tackPermute(int[] nums) {
    11. if (pathPer.size() == nums.length) {
    12. resPer.add(new ArrayList<>(pathPer));
    13. return;
    14. }
    15. for (int j = 0; j < nums.length; j++) {
    16. if (visited[j]) {
    17. continue;
    18. }
    19. visited[j] = true;
    20. pathPer.add(nums[j]);
    21. tackPermute(nums);
    22. pathPer.removeLast();
    23. visited[j] = false;
    24. }
    25. }
    26. }
  • 直接判断path中是否含有这个数字

    1. class Solution {
    2. ArrayList<List<Integer>> resPer = new ArrayList<>();
    3. LinkedList<Integer> pathPer = new LinkedList<>();
    4. public List<List<Integer>> permute(int[] nums) {
    5. tackPermute(nums);
    6. return resPer;
    7. }
    8. void tackPermute(int[] nums) {
    9. if (pathPer.size() == nums.length) {
    10. resPer.add(new ArrayList<>(pathPer));
    11. return;
    12. }
    13. for (int j = 0; j < nums.length; j++) {
    14. if (pathPer.contains(nums[j])) {
    15. continue;
    16. }
    17. pathPer.add(nums[j]);
    18. tackPermute(nums);
    19. pathPer.removeLast();
    20. }
    21. }
    22. }

    12、全排列2

    大致思路:由于题目中的数组可以重复,但是结果不能有重复的排列,主要思路和组合问题2 类似,都是先将数组排序,然后在for 循环中控制数字添加到路径中。 具体:经分析,此题应是树的层级去重,因此在前后判断的同时还需加一个控制位,来对递归层数相同的排列去重,否则直接横向、纵向都会被去重。具体参考下图: image.png

  1. public class Day_02 {
  2. ArrayList<List<Integer>> resPer = new ArrayList<>();
  3. LinkedList<Integer> pathPer = new LinkedList<>();
  4. boolean[] uset;
  5. public List<List<Integer>> permuteUnique(int[] nums) {
  6. uset = new boolean[nums.length];
  7. Arrays.sort(nums);
  8. trackPer(nums);
  9. return resPer;
  10. }
  11. void trackPer(int[] nums) {
  12. if (nums.length == pathPer.size()) {
  13. resPer.add(new ArrayList<>(pathPer));
  14. }
  15. for (int i = 0; i < nums.length; i++) {
  16. if (uset[i]) continue;
  17. //通过uset[i - 1] 进行横向去重
  18. //当条件为true ,也能AC,但是由于这样是纵向去重的,所以复杂度会更高
  19. if (i != 0 && nums[i] == nums[i - 1] && uset[i - 1]==false) {
  20. continue;
  21. }
  22. //去重后对标记位赋值,可以视为加锁
  23. uset[i] = true;
  24. pathPer.add(nums[i]);
  25. trackPer(nums);
  26. pathPer.removeLast();
  27. uset[i] = false;}}}

【同层剪枝 VS 非同层剪枝】 相信你现在已经知道如下条件的作用了,很多人对最后一个子条件比较疑惑。即采用!visited[i - 1] 或 visited[i - 1],结果竟然是一样的,不光都是对的,而且输出也是一摸一样。

  1. if(visited[i] || (i > 0 && nums[i - 1] == nums[i] && !visited[i - 1])) continue;

实际上如果你愿意付出点时间用纸笔跟踪一下递归过程,你会发现 !visited[i - 1] 实现了 「同层剪枝」,而 visited[i - 1] 实现「非同层剪枝」。 【同层剪枝】 当选取到nums[i],并满足 i > 0 && nums[i - 1] == nums[i] 时,若 !visited[i - 1] = true,说明以nums[i - 1]为某一层元素的选择已穷尽,以至于在回溯的时候置 visited[i - 1] = false)。于是后续会根据这个条件跳过同层相等元素。 【非同层剪枝】 最后一个子条件若采用 visited[i - 1],当选取到nums[i],并满足 i > 0 && nums[i - 1] == nums[i] 时,若 visited[i - 1] = true,表明当前是在nums[i - 1]的子树中选择nums[i],根据这个条件,在子树中遇到nums[i],总是不选取(continue),那么该子树总是无法提供有效排列(因为缺少nums[i]),于是对该子树的搜索都是无效的。之后回溯到nums[i - 1]所在层后,由于撤销为 visited[i - 1] = false,不再满足visited[i - 1] = true,于是不会跳过,可以正常选取到包含nums[i - 1]和nums[i]的排列。 通过上述说明,采用!visited[i - 1]的「同层剪枝」效率更高,因为「非同层剪枝」对nums[i - 1]的子树(存在nums[i] == nums[i - 1])的搜索是无效的。 另外我们也可以看到,无论哪一种,输出有效排列的顺序是一致的。二者的差别可理解为,非同层剪枝比同层剪枝多做了无效子树搜索动作。

13、N皇后问题

N皇后主要难点在于这个是二维的问题,打破这个障碍,思路就变得通顺了,无非就是一行代表递归的层数,n+1代表递归层数+1,然后在访问到点时,校验这个点是否符合N皇后的规则 image.png

  1. class Solution {
  2. List<List<String>> res = new ArrayList<>();
  3. public List<List<String>> solveNQueens(int n) {
  4. char[][] chessboard = new char[n][n];
  5. for (char[] c : chessboard) {
  6. Arrays.fill(c, '.');
  7. }
  8. backTrack(n, 0, chessboard);
  9. return res;
  10. }
  11. public void backTrack(int n, int row, char[][] chessboard) {
  12. if (row == n) {
  13. res.add(Array2List(chessboard));
  14. return;
  15. }
  16. for (int col = 0;col < n; ++col) {
  17. if (isValid (row, col, n, chessboard)) {
  18. chessboard[row][col] = 'Q';
  19. backTrack(n, row+1, chessboard);
  20. chessboard[row][col] = '.';
  21. }
  22. }
  23. }
  24. public List Array2List(char[][] chessboard) {
  25. List<String> list = new ArrayList<>();
  26. for (char[] c : chessboard) {
  27. list.add(String.copyValueOf(c));
  28. }
  29. return list;
  30. }
  31. public boolean isValid(int row, int col, int n, char[][] chessboard) {
  32. // 检查列
  33. for (int i=0; i<row; ++i) { // 相当于剪枝
  34. if (chessboard[i][col] == 'Q') {
  35. return false;
  36. }
  37. }
  38. // 检查45度对角线
  39. for (int i=row-1, j=col-1; i>=0 && j>=0; i--, j--) {
  40. if (chessboard[i][j] == 'Q') {
  41. return false;
  42. }
  43. }
  44. // 检查135度对角线
  45. for (int i=row-1, j=col+1; i>=0 && j<=n-1; i--, j++) {
  46. if (chessboard[i][j] == 'Q') {
  47. return false;
  48. }
  49. }
  50. return true;
  51. }
  52. }