【算法攻坚】回溯模板解题

485 阅读2分钟

这是我参与11月更文挑战的第26天,活动详情查看:2021最后一次更文挑战

今日题目

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。

candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。

对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例 1:

输入: candidates = [2,3,6,7], target = 7 输出: [[7],[2,2,3]]

示例 2:

输入: candidates = [2,3,5], target = 8 输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1 输出: []

示例 4:

输入: candidates = [1], target = 1 输出: [[1]]

示例 5:

输入: candidates = [1], target = 2 输出: [[1,1]]

提示:

1 <= candidates.length <= 30 1 <= candidates[i] <= 200 candidate 中的每个元素都是独一无二的。 1 <= target <= 500

思路

这道题是典型的回溯思想寻找最优解的题目,所以还是尝试按照通用模板去解题

解决一个回溯问题,实际上就是一个决策树的遍历过程。只需要思考 3 个问题:

  • 1、路径:也就是已经做出的选择。

  • 2、选择列表:也就是你当前可以做的选择。

  • 3、结束条件:也就是到达决策树底层,无法再做选择的条件。

伪代码实现方面,回溯算法的框架:

result = []
backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

for 选择 in 选择列表:
    做选择
    backtrack(路径, 选择列表)
    撤销选择

这个模板的核心就是 for 循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择,特别简单。

第一步实现全排列输出:

  • 1、路径:用有序链表存储
  • 2、选择列表:也就是要遍历的元素数组
  • 3、结束条件:链表长度达到最大的数组长度
List<List<Integer>> res = new LinkedList<>();

/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
    // 记录「路径」这里的 选择列表 即包含在nums中
    LinkedList<Integer> track = new LinkedList<>();
    backtrack(nums, track);
    return res;
}

// 路径:记录在 track 中
// 选择列表:nums 中的元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
    // 触发结束条件
    if (track.size() == nums.length) {
        res.add(new LinkedList(track));
        return;
    }
    
    for (int i = 0; i < nums.length; i++) {
        // 排除不合法的选择
        if (track.contains(nums[i]))
            continue;
        // 做选择
        track.add(nums[i]);
        // 进入下一层决策树
        backtrack(nums, track);
        // 取消选择,返回上一层决策树
        track.removeLast();
    }
}

通过上述模板代码实现后,只是实现全排列组合输出,

下一步实现,等于正整数 target的全排列:

  • 1、路径:用有序链表存储
  • 2、选择列表:也就是要遍历的元素数组
  • 3、结束条件:链表中元素之和等于target
  • 4、剪枝:元素排序后,如果当前元素大于target,后面也不会再满足,所以直接结束
public List<List<Integer>> res =  new LinkedList<>();

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    LinkedList<Integer> track = new LinkedList<>();
    // 排序的原因是在回溯的时候比较容易剪枝
    Arrays.sort(candidates);
    // 套用上面的公式,candidates是指选择列表,target用来判断是否结束以及用于剪枝
    // track则是路径,即已经做出的选择
    backtrack(candidates, target, track);
    return res;
}

private void backtrack(int[] candidates, int target, LinkedList<Integer> track) {
    //先判断结束条件
    if (target < 0) {
        return;
    }
    if (target == 0){
        // 当target等于0的时候,将路径加入到结果列表中
        res.add(new LinkedList<>(track));
        return;
    }
    // 遍历选择列表,这里没有去重
    //下面会设置start,每次递归的时候只在candidates中当前及之后的数字中选择。
    for(int i = 0;i < candidates.length;i++){
        // 这就是排序的好处,可以直接这样剪枝,否则还得遍历
        if(target < candidates[i]){
            break;
        } 
        track.add(candidates[i]);
        backtrack(candidates,target-candidates[i],track);
        track.removeLast();
    }
}

上述代码实现了等于target的组合输出,但是并没有达到题目要求的去除重复组合的要求,输出的结果中会有重复的组合元素,所以下一步考虑去重,也就是每次不再从0开始递归元素,都是从当前节点往后进行选取,这样就不会再出现获取前面已经组合过的元素

public List<List<Integer>> res =  new LinkedList<>();

public List<List<Integer>> combinationSum(int[] candidates, int target) {
    LinkedList<Integer> track = new LinkedList<>();
    Arrays.sort(candidates);
    backtrack(candidates, 0, target, track);
    return res;
}

private void backtrack(int[] candidates, int start, int target, LinkedList<Integer> track) {
    if (target < 0) {
        return;
    }
    if (target == 0){
        res.add(new LinkedList<>(track));
        return;
    }
    //int i= start去重组合的关键
    for(int i = start;i < candidates.length;i++){
        if(target < candidates[i]) {
            break;
        }
        track.add(candidates[i]);
        backtrack(candidates,i,target-candidates[i],track);
        track.removeLast();
    }
}

执行用时:2 ms, 在所有 Java 提交中击败了99.65%的用户

内存消耗:38.8 MB, 在所有 Java 提交中击败了21.05%的用户

小结

回溯算法解题整体都是有着类似的目的,都是在寻找所有可行解的题,我们都可以尝试用搜索回溯的方法来解决。同时要记住算法实现的基本框架,可以大大减少实现难度。