Leetcode 笔记 —— 回溯算法 ( Java code )

回溯算法

定义

回溯算法,采用试错的思想,尝试分步地去解决一个问题;在分步解决问题的过程中,当它发现现有的分步答案不能得到有效的正确的解答时,它将取消上一步甚至是上几步的计算;再通过其他的可能的分步解答再次尝试寻找问题的解答。

回溯算法通常使用递归的方式实现,在重复上述步骤之后,会有两种结果:

  • 找到一个可能存在的正确的答案
  • 在尝试了所有可能的分步方式后,确定没有正确的答案

回溯算法实际上是深度优先搜索的一种特殊情况

深度优先搜索DFSDepth-First-Search):
是一种用于遍历或搜索树或者图的算法,这个算法会 尽可能深 地搜索树的分支。

  1. 当节点 v 的所在的一条边的所有其他节点都已经被探寻过之后,搜索将回溯到节点 v ,去探寻从节点 v 出发的其他所在边;
  2. 上述过程一直进行到已经发现从源节点可以达到的所有节点为止;
  3. 如果此时,还存在未探寻过的节点,则选择其中一个作为源节点,重复上述过程;
  4. 直到所有的节点都被访问到为止

广度优先搜索BFSBreadth-First-Search) :
也是一种用于遍历或搜索树或者图的算法,这个算法会 尽可能广 地搜索节点的邻居。
将一个节点看作是源节点,按与源节点相邻节点数量作为标准,将其他节点分为一层节点(邻居节点),二层节点(邻居节点的邻居节点),逐层探寻所有节点

与动态规划比较

共同点:
用于求解多阶段决策问题

多阶段决策问题,即,

  • 求解一个问题可以分为多个步骤(阶段)
  • 每一个步骤(阶段)可以有多个选择

不同点:

  • 动态规划只需要评估最优解是多少,但是最优解对应的具体解是什么并不做要求
  • 回溯算法可以搜索得到所有的方案(其中也包括最有解),所以,其本质上是一种遍历解法,时间复杂度较高

剪枝

由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,就可以提前结束,这一步操作称为 剪枝

回溯算法应用 剪枝 技巧可以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但能够剪枝节约的时间更多;

提示:
剪枝是一种技巧,通常需要根据不同问题场景采用不同的剪枝策略,需要在做题的过程中不断总结。

由于回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。

Leetcode 例题

全排列问题

问题描述

leetcode 46题
给定一个 没有重复 数字的序列,返回其所有可能的全排列

示例:

输入: [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]
问题解析

这个问题可以看作是,对于 n n n 个排成一行的空格,从左往右依次填入序列中给定的 n n n 个数字,每个数字只能使用一次

因此,可以使用回溯算法模拟穷举过程,即,从左到右每一个位置都依次尝试填入一个数字,看能不能填完 n n n 个空格

可以定义一个递归函数 BackTrack(int begin, int len, List permutation, List> result) 表示从左往右填到第 begin 位置,当前排列为 permutationlen 表示 n 个数字序列的长度, result 表示所有排列的序列

那么递归函数中存在两种情况,

  • 如果 begin == len ,则说明已经填写完成 n n n 个位置(下标从 0 0 0 开始),即找到了一个可行解,将此时的 permutation 放入 result 中,递归结束;
  • 如果 begin < len ,则需要考虑第 b e g i n begin begin 个位置填写的数字;遍历题目中给出的还没有填写的数字,尝试填写,然后继续尝试填写下一个位置,即调用函数 BackTrack(begin + 1, len, permutation, result); ;回溯时,撤销填写的数字,并继续尝试其他没有被标记过的数字

因为重复遍历寻找没有填写过的数字效率不高,复杂度也比较高,所以可以尝试一种简化方法;

将题目给定的 n n n 个数字的序列分为左右两部分,左边表示已经填过的数字,右边表示待填的数字,在回溯过程中维护这个数组即可。

假设,已经填到第 b e g i n begin begin 个位置,那么 nums 序列中的 [ 0 , b e g i n − 1 ] [0, begin - 1] [0,begin1] 是已经填过的数字的集合, [ b e g i n , n − 1 ] [begin, n - 1] [begin,n1] 是待填的数字集合;

[ b e g i n , n − 1 ] [begin, n−1] [begin,n1] 里的数去填第 b e g i n begin begin 个数,假设待填的数的下标为 i i i ,那么填完以后我们将第 i i i 个数和第 b e g i n begin begin 个数交换,即能使得在填第 b e g i n + 1 begin + 1 begin+1 个数的时候, nums 序列的 [ 0 , b e g i n ] [0, begin] [0,begin] 部分为已填过的数, [ b e g i n + 1 , n − 1 ] [begin + 1, n−1] [begin+1,n1] 为待填的数,回溯的时候交换回来即能完成撤销操作。

参考代码
class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<List<Integer>> ();
        List<Integer> permutation = new ArrayList<Integer>();

        for(int num : nums) {
            permutation.add(num);
        }
        int len = nums.length;
        BackTrack(0, len, permutation, result);
        return result;
    }

    public void BackTrack(int begin, int len, List<Integer> permutation, List<List<Integer>> result) {
        // 所有的数字都填完了
        if(begin == len) {
            result.add(new ArrayList<Integer>(permutation));
        }
        for(int i = begin; i < len; i++) {
            // 动态维护数组
            Collections.swap(permutation, begin, i);
            // 继续递归填下一个数字
            BackTrack(begin + 1, len, permutation, result);
            // 撤销
            Collections.swap(permutation, begin, i);
        }
    }
}

permutation 是对象类型,不同于基本数据类型

result.add(permutation) 是把 permutation 的 地址添加到 result 中;

new ArrayList<>() 的作用是复制,把遍历到叶子节点的时候的 permutation 的样子复制出来,到一个新的列表,实现题目要求

组合总和

问题描述

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

candidates 中的数字可以无限制重复被选取。

说明:
所有数字(包括 target)都是正整数。
解集不能包含重复的组合。

示例 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]
]

提示:

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

定义递归函数 dfs(candidates, target, res, combine, index) 表示当前在 candidates 数组的第 index 位还剩下 target 要组合,已经组合的列表为 combine

递归的终止条件是 target <= 0 或者 candidates 数组已经用完了

那么,在当前的函数中,每次我们可以选择跳过或者不跳过第 index 个数,执行代码分别为 dfs(candidates, target, res, combine, index + 1)dfs(candidates, target - candidates[index], res, combine, index);因为每个数字都可以被无限制重复选取,则搜索的下标仍然是 index

参考代码

不考虑剪枝:

class Solution {
    /*
    combine 是对象类型,不同于基本数据类型
    res.add(combine) 是把 combine 的 地址添加到 res 中
    而combine 在遍历之后为空列表,最后只会看到 res 中是一个又一个的空列表,都指向一块内存
    new ArrayList<>() 的作用是复制,把遍历到叶子节点的时候的 combine 的样子复制出来,到一个新的列表,实现题目要求
    */
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> res = new ArrayList<List<Integer>>();
        List<Integer> combine = new ArrayList<Integer>();
        dfs(candidates, target, res, combine, 0);
        return res;
    }

    public void dfs(int[] candidates, int target, List<List<Integer>> res, List<Integer> combine, int index) {
        if(index == candidates.length) {
            return;
        }
        if(target == 0) {
            res.add(new ArrayList<Integer>(combine));
            return;
        }
        // 直接跳过当前元素
        dfs(candidates, target, res, combine, index + 1);
        // 选择当前元素
        if(target - candidates[index] >= 0) {
            combine.add(candidates[index]);
            dfs(candidates, target - candidates[index], res, combine, index);
            combine.remove(combine.size() - 1);
        }
    }
}

考虑剪枝:

public class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        int len = candidates.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }

        // 排序是剪枝的前提
        Arrays.sort(candidates);
        Deque<Integer> path = new ArrayDeque<>();
        dfs(candidates, 0, len, target, path, res);
        return res;
    }

    private void dfs(int[] candidates, int begin, int len, int target, Deque<Integer> path, List<List<Integer>> res) {
        // 由于进入更深层的时候,小于 0 的部分被剪枝,因此递归终止条件值只判断等于 0 的情况
        if (target == 0) {
            res.add(new ArrayList<>(path));
            return;
        }

        for (int i = begin; i < len; i++) {
            // 重点理解这里剪枝,前提是候选数组已经有序,
            if (target - candidates[i] < 0) {
                break;
            }
            
            path.addLast(candidates[i]);
            dfs(candidates, i, len, target - candidates[i], path, res);
            path.removeLast();
        }
    }
}

子集

问题描述

给你一个整数数组 nums ,数组中的元素互不相同 。返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集。你可以按任意顺序返回解集。

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0]
输出:[[],[0]]

提示:

1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同
问题解析

我们同样可以定义一个函数 dfs(int cur, int[] nums) 表示对子集的构成中,是否选择第 cur 个元素

使用 ans 表示最终包含所有子集的解集,使用 t 表示当前的子集

显然,当 cur == nums.length 时, 数组中的所有元素都已经被选择过(可能被放入子集,也可能没有被放入子集),解集 ans 的元素加 1,并且退出函数

对于第 cur 个元素,如果选择放入子集,则,先增加子集中的元素,然后执行代码 dfs(cur + 1, nums),表示对下一个元素是否放入子集进行选择;

对于第 cur 个元素,如果选择不放入子集,则,可以直接执行代码 dfs(cur + 1, nums),表示对下一个元素是否放入子集进行选择;

参考代码
class Solution {
    List<Integer> t = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> subsets(int[] nums) {
        dfs(0, nums);
        return ans;
    }

    public void dfs(int cur, int[] nums) {
        if (cur == nums.length) {
            ans.add(new ArrayList<Integer>(t));
            return;
        }
        t.add(nums[cur]);
        dfs(cur + 1, nums);
        t.remove(t.size() - 1);
        dfs(cur + 1, nums);
    }
}

你可能感兴趣的:(笔试面试,回溯算法,Java,Leetcode)