算法-回溯算法

0. 理论基础

  1. 回溯通常和递归一起使用
  2. 回溯能够解决的几类问题:
    1. 组合问题:N个数里面按一定规则找出k个数的集合
    2. 切割问题:一个字符串按一定规则有几种切割方式
    3. 子集问题:一个N个数的集合里有多少符合条件的子集
    4. 排列问题:N个数按一定规则全排列,有几种排列方式
    5. 棋盘问题:N皇后,解数独等等
  3. 回溯算法解决的问题都可以抽象成N叉树,其中
    • 集合大小 为 树的宽度
    • 递归深度 为 树的深度。
  4. 回溯算法的模板
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循环的第一个筛选条件很重要,可以选择的情况
    1. 该层中当前元素未被使用过,且为第一个元素
    2. 该层中当前元素未被使用过,且不小于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;
    }
}
posted @ 2024-07-30 17:30  Frank23  阅读(7)  评论(0编辑  收藏  举报