LeetCode题解 - 回溯算法


参考链接

一、回溯算法与深度优先遍历

回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:

  • 找到一个可能存在的正确的答案;
  • 在尝试了所有可能的分步方法后宣告该问题没有答案。

深度优先搜索 算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。

DFS 和回溯算法区别
DFS 是一个劲的往某一个方向搜索,而回溯算法建立在 DFS 基础之上的,但不同的是在搜索过程中,达到结束条件后,恢复状态,回溯上一层,再次搜索。因此回溯算法与 DFS 的区别就是有无状态重置

Backtracking(回溯)属于 DFS。

  • 普通 DFS 主要用在 可达性问题 ,这种问题只需要执行到特点的位置然后返回即可。
  • 而 Backtracking 主要用于求解 排列组合 问题,例如有 { ‘a’,‘b’,‘c’ } 三个字符,求解所有由这三个字符排列得到的字符串,这种问题在执行到特定的位置返回之后还会继续执行求解过程。

因为 Backtracking 不是立即返回,而要继续求解,因此在程序实现时,需要注意对元素的标记问题:

  • 在访问一个新元素进入新的递归调用时,需要将新元素标记为已经访问,这样才能在继续递归调用时不用重复访问该元素;
  • 但是在递归返回时,需要将元素标记为未访问,因为只需要保证在一个递归链中不同时访问一个元素,可以访问已经访问过但是不在当前递归链中的元素。

与动态规划的区别
共同点:用于求解多阶段决策问题。多阶段决策问题即:

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

不同点

  • 动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
  • 回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。

二、从全排列问题理解回溯算法

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

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

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

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

代码方面,回溯算法的框架

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

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

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

46、全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

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

我们尝试在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。以数组 [1, 2, 3] 的全排列为例。

  • 先写以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列(注意:递归结构体现在这里);
  • 再写以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列;
  • 最后写以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。

总结搜索的方法:按顺序枚举每一位可能出现的情况,已经选择的数字在 当前 要选择的数字中不能出现。按照这种策略搜索就能够做到 不重不漏。这样的思路,可以用一个树形结构表示。

image.png

说明:

  • 每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的「不同的值」体现,这些变量的不同的值,称之为「状态」;
  • 使用深度优先遍历有「回头」的过程,在「回头」以后, **状态变量需要设置成为和先前一样 **,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为「状态重置」;
  • 深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此 path 变量是一个栈
  • 深度优先遍历通过「回溯」操作,实现了全局使用一份状态变量的效果。

使用编程的方法得到全排列,就是在这样的一个树形结构中完成 遍历,从树的根结点到叶子结点形成的路径就是其中一个全排列。

代码实现:

class Solution {
    // 使用一个动态数组保存所有可能的全排列
    List<List<Integer>> res = new LinkedList<>();
	/* 主函数,输入一组不重复的数字,返回它们的全排列 */
    public List<List<Integer>> permute(int[] nums) {
        LinkedList<Integer> track = new LinkedList<>();// 记录「路径」
        backtrack(nums, track);
        return res;
    }
    // 路径:记录在 track 中
    // 选择列表:nums 中不存在于 track 的那些元素
    // 结束条件:nums 中的元素全都在 track 中出现
    public 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();// 取消选择
        }
    }
}

注意:变量track 所指向的列表 在深度优先遍历的过程中只有一份 ,深度优先遍历完成以后,回到了根结点,成为空列表。

在 Java 中,参数传递是 值传递,对象类型变量在传参的过程中,复制的是变量的地址。这些地址被添加到 res 变量,但实际上指向的是同一块内存地址,因此我们会看到 6 个空的列表对象。解决的方法很简单,在 res.add(new LinkedList(track)); 这里做一次拷贝即可。

我们这里稍微做了些变通,没有显式记录「选择列表」,而是通过numstrack推导出当前的选择列表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GUWpAy6Y-1620984416742)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210510110429242.png)]

其实想想看,回溯算法和动态规划是不是有点像呢?动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?

某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树大幅剪枝,这就变成了动态规划。而回溯框架时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高


三、回溯算法团灭排列/组合/子集问题

78. 子集(中等)

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

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

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
①递归树

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FpGmgBGp-1620984416743)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210510145822977.png)]

观察上图可得,选择列表里的数,都是选择路径(红色框)后面的数,比如[1]这条路径,他后面的选择列表只有"2、3",[2]这条路径后面只有"3"这个选择,那么这个时候,就应该使用一个参数start,来标识当前的选择列表的起始位置。也就是标识每一层的状态,因此被形象的称为"状态变量",最终函数签名如下

void backtrack(int[] nums, int start, List<Integer> track)
②找结束条件

此题非常特殊,所有路径都应该加入结果集,所以不存在结束条件。或者说当 start 参数越过数组边界的时候,程序就自己跳过下一层递归了,因此不需要手写结束条件,直接加入结果集

③找选择列表:

在①中已经提到过了,子集问题的选择列表,是上一条选择路径之后的数for(int i=start;i<nums.size();i++)

④判断是否需要剪枝

从递归树中看到,路径没有重复的,也没有不符合条件的,所以不需要剪枝

整体的代码如下:

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> subsets(int[] nums) {
        List<Integer> track = new ArrayList<>();
        backtrack(nums, 0, track);
        return res;
    }

    private void backtrack(int[] nums, int start, List<Integer> track){
        res.add(new ArrayList(track));
        for(int i = start; i < nums.length; i++){
            track.add(nums[i]);//做出选择
            backtrack(nums, i + 1, track);//递归进入下一层,注意i+1,标识下一个选择列表的开始位置,最重要的一步
            track.remove(track.size() - 1);//撤销选择
        }
    }
}

参考链接


90. 子集 II (中等)

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

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

解题思路:本题和上题的递归树的结构类似,只是出现了重复的元素,需要进行剪枝

④判断是否需要剪枝,首先需要先对数组排序,使用排序函数 Arrays.sort(nums);我们需要去除重复的集合,即需要剪枝,把递归树上的某些分支剪掉。那么应去除哪些分支呢?又该如何编码呢?

观察下图不难发现,应该去除当前选择列表中,与上一个数重复的那个数,引出的分支,如 “2,2” 这个选择列表,第二个 “2” 是最后重复的,应该去除这个 “2” 引出的分支
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Q6gAdUq2-1620984416744)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210510154024965.png)]
编码呢,刚刚说到是 “去除当前选择列表中,与上一个数重复的那个数,引出的分支”,说明当前列表最少有两个数,当i>start时,做选择的之前,比较一下当前数,与上一个数 (i-1) 是不是相同,相同则 continue,
参考链接

class Solution {
    List<List<Integer>> res = new ArrayList<>(); 
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        List<Integer> track = new ArrayList<>();
        Arrays.sort(nums); //首先对数组进行排序
        backtrack(nums, 0, track);
        return res;
    }

    private void backtrack(int[] nums, int start, List<Integer> track){
        
        res.add(new ArrayList(track));
        for(int i = start; i < nums.length; i++){
            //剪枝操作,去掉重复的数
            if(i > start && nums[i] == nums[i - 1]){
                continue;
            }
            track.add(nums[i]);
            backtrack(nums, i + 1, track);
            track.remove(track.size() - 1);
        }
    }
}

77. 组合(中等)

给定两个整数 nk,返回 1 … n 中所有可能的 k 个数的组合。

输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

解题思路:这就是典型的回溯算法,k 限制了树的高度,n 限制了树的宽度,直接套我们以前讲过的回溯算法模板框架就行了

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> combine(int n, int k) {
        List<Integer> track = new ArrayList<>();
        backtrack(1, n, k, track);
        return res;
    }

    private void backtrack(int start, int n, int k, List<Integer> track){
        // 这里判断是否到达树的底部
        if(track.size() == k){
            res.add(new ArrayList(track));
        }
        // 注意 i 从 start 开始递增
        for(int i = start; i <= n; i++){
            track.add(i);// 做选择
            backtrack(i + 1, n, k, track);
            track.remove(track.size() - 1);// 撤销选择
        }
    }
}

39. 组合总和(中等)

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

说明:

  • 所有数字(包括 target)都是正整数。
  • 解集不能包含重复的组合。
输入:candidates = [2,3,6,7], target = 7,
所求解集为:
[
  [7],
  [2,2,3]
]

解题思路:根据示例 1:输入: candidates = [2, 3, 6, 7],target = 7。

  • 候选数组里有 2,如果找到了组合总和为 7 - 2 = 5 的所有组合,再在之前加上 2 ,就是 7 的所有组合;
  • 同理考虑 3,如果找到了组合总和为 7 - 3 = 4 的所有组合,再在之前加上 3 ,就是 7 的所有组合,依次这样找下去。

基于以上的想法,可以画出如下的树形图。

说明:

  • 以 target = 7 为 根结点 ,创建一个分支的时 做减法
  • 每一个箭头表示:从父亲结点的数值减去边上的数值,得到孩子结点的数值。边的值就是题目中给出的 candidate 数组的每个元素的值;
  • 减到 0 或者负数的时候停止,即:结点 0 和负数结点成为叶子结点;
  • 所有从根结点到结点 0 的路径(只能从上往下,没有回路)就是题目要找的一个结果。

仔细观察上图可知,最后的结果是存在重复的,产生重复的原因是:在每一个结点,做减法,展开分支的时候,由于题目中说 每一个元素可以重复使用,我们考虑了 所有的 候选数,因此出现了重复的列表。因此可以采用上面几题的思路,在每一次搜索的时候设置 下一轮搜索的起点 start

参考链接

什么时候使用 used 数组,什么时候使用 begin 变量

  • 排列问题,讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为不同列表时),需要记录哪些数字已经使用过,此时用 used 数组;
  • 组合问题,不讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为相同列表时),需要按照某种顺序搜索,此时使用 begin 变量。
class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<Integer> track = new ArrayList<>();
        backtrack(candidates, 0, target, track);
        return res;
    }

    private void backtrack(int[] candidates, int start, int target, List<Integer> track){
        //小于零说明结果不符合要求,直接返回结束
        if(target < 0){
            return;
        }
        //等于零说明结果符合要求
        if(target == 0){
            res.add(new ArrayList(track));
        }
		//遍历,start为本分支上一节点的减数的下标
        for(int i = start; i < candidates.length; i++){
            //如果减数大于目标值,则差为负数,不符合结果
            if(candidates[i] <= target){
                track.add(candidates[i]);
                backtrack(candidates, i, target - candidates[i], track);//目标值减去元素值
                track.remove(track.size() - 1);
            }
        }
    }
}

40. 组合总和 II (中等)

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

candidates 中的每个数字在每个组合中只能使用一次。

说明:

  • 所有数字(包括目标数)都是正整数。
  • 解集不能包含重复的组合。
输入: candidates = [10,1,2,7,6,1,5], target = 8,
所求解集为:
[
  [1, 7],
  [1, 2, 5],
  [2, 6],
  [1, 1, 6]
]

解题思路: 本题和上题基本类似,还是采用回溯算法,需要注意的是题目要求==candidates 中含有重复元素,且每个数字在每个组合中只能使用一次==

题目仍然要求解集不能包含重复的组合,下面强调一下在这种情况下应该如何去重。

  • 首先,我们可以通过排序的办法,将重复的数字弄到一起。

  • 其次,我们希望如果集合选取了a + b + c = target, 那么我们就不要再选择a + c + b = target

    这个比较好实现,只要保证下一次递归的起点在本次访问下标的下一个位置即可,也就是说如果本次执行了combine.push_back(num[i])

    递归到下一层调用时,选择的起点就是i+1 这样就可以实现对 顺序排列的a b c 只可顺序的选取。

  • 最后, 还有一种情况也要注意, 比如说有个数组 [1, 1, 7] target = 8 经过上面的去重,我们得到的最终答案是[[1, 7], [1, 7]]这显然不是我们想要的, 究其原因在于,对两个1都进行了选择。针对这种情况,解决办法如下:

    在同一轮循环中,跳过和已访问过的元素数值相同的元素们。

通过上面三步即可解决这个去重的问题。

这个避免重复的思想实在是太重要了。
这个方法最重要的作用是,可以让同一层级,不出现相同的元素。即
                  1
                 / \
                2   2  这种情况不会发生 但是却允许了不同层级之间的重复即:
               /     \
              5       5
                例2
                  1
                 /
                2      这种情况确是允许的
               /
              2  
                
为何会有这种神奇的效果呢?
首先 cur-1 == cur 是用于判定当前元素是否和之前元素相同的语句。这个语句就能砍掉例1。
可是问题来了,如果把所有当前与之前一个元素相同的都砍掉,那么例二的情况也会消失。 因为当第二个2出现的时候,他就和前一个2相同了。
                
那么如何保留例2呢?
那么就用cur > start 来避免这种情况,你发现例1中的两个2是处在同一个层级上的,例2的两个2是处在不同层级上的。
在一个for循环中,所有被遍历到的数都是属于一个层级的。我们要让一个层级中,必须出现且只出现一个2,那么就放过第一个出现重复的2,但不放过后面出现的2。
第一个出现的2的特点就是 cur == begin. 第二个出现的2 特点是cur > begin.
class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        //重点:先对数组进行排序!!
        Arrays.sort(candidates);
        List<Integer> track = new ArrayList<>();
        backtrack(candidates, 0, target, track);
        return res;
    }

    private void backtrack(int[] candidates, int start, int target, List<Integer> track){
        if(target < 0){
            return;
        }
        if(target == 0){
            res.add(new ArrayList(track));
        }

        for(int i = start; i < candidates.length; i++){
            if(candidates[i] <= target){
                //重点去重语句,记住!!在同一个递归,即同一层级中相同的元素只使用一次
                if(i > start && candidates[i] == candidates[i - 1]){
                    continue;
                }
                track.add(candidates[i]);
                //注意这里是i+1, 后面的元素即使重复也可以依次递归使用
                backtrack(candidates, i + 1, target - candidates[i], track);
                track.remove(track.size() - 1);
            }
        }
    }
}

216. 组合总和 III (中等)

找出所有相加之和为 nk个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

说明:

  • 所有数字都是正整数。
  • 解集不能包含重复的组合。
输入: k = 3, n = 7
输出: [[1,2,4]]

解题思路:与上面题目类似,不同的是本题对于组合的大小有了限制,其必须包含K个数,仍套用框架即可:

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> combinationSum3(int k, int n) {
        List<Integer> track = new ArrayList<>();
        backtrack(n, 1, k, track);
        return res;
    }

    private void backtrack(int n, int start, int k, List<Integer> track){
        if(n < 0){
            return;
        }
        //注意这里,需要加上对track的大小的限制
        if(n == 0 && track.size() == k){
            res.add(new ArrayList(track));
        }

        for(int i = start; i <= 9; i++){
            track.add(i);
            backtrack(n - i, i + 1, k, track);
            track.remove(track.size() - 1);
        }
    }
}

46. 全排列(中等)

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

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

具体思路在第二节已经详细分析过,再次放到这里是为了比较和子集、组合的不同之处,从而加深对他们的理解

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
        List<Integer> track = new ArrayList<>();
        //重点:乐意构造一个boolean类型的数组来标记元素是否已经访问过
        boolean[] hasVisited = new boolean[nums.length];
        backtrack(nums, hasVisited, track);
        return res;
    }

    private void backtrack(final int[] nums, boolean[] visited, List<Integer> track) {
        if (track.size() == nums.length) {
            res.add(new ArrayList<>(track)); // 重新构造一个 List
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            //重点注意这里的剪枝操作,如果该元素已经访问过,则跳过
            if (visited[i]) {
                continue;
            }
            visited[i] = true;
            track.add(nums[i]);
            backtrack(nums, visited, track);
            track.remove(track.size() - 1);
            visited[i] = false;
        }
    }
}

47. 全排列 II (中等)

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

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

解题思路:画出树形结构如下:重点想象深度优先遍历在这棵树上执行的过程,哪些地方遍历下去一定会产生重复,这些地方的状态的特点是什么?
对比图中标注 ① 和 ② 的地方。相同点是:这一次搜索的起点和上一次搜索的起点一样。不同点是:

  • 标注 ① 的地方上一次搜索的相同的数刚刚被撤销;

  • 标注 ② 的地方上一次搜索的相同的数刚刚被使用。
    产生重复结点的地方,正是图中标注了「剪刀」,且被绿色框框住的地方。这里还有一个很细节的地方:

  • 在图中 ② 处,搜索的数也和上一次一样,但是上一次的 1 还在使用中;

  • 在图中 ① 处,搜索的数也和上一次一样,但是上一次的 1 刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支。

代码实现方面,在第 46 题的基础上,要加上这样一段代码:

if (i > 0 && nums[i] == nums[i - 1] && visited[i - 1] == 0) {
    continue;
}

这段代码就能检测到标注为 ① 的两个结点,跳过它们。

参考链接

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<Integer> track = new ArrayList<>();
        Arrays.sort(nums);
        //重点:构造一个数组来标记元素是否已经访问过
        int[] visited = new int[nums.length]; 
        backtrack(nums, visited, track);
        return res;
    }
    private void backtrack(final int[] nums, int[] visited, List<Integer> track) {
        if (track.size() == nums.length) {
            res.add(new ArrayList<>(track)); // 重新构造一个 List
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            if (i > 0 && nums[i] == nums[i - 1] && visited[i - 1] == 0) {
                continue;  // 防止重复
            }
            //重点注意这里的剪枝操作,如果该元素已经访问过,则跳过
            if (visited[i] == 1) {
                continue;
            }
            visited[i] = 1;
            track.add(nums[i]);
            backtrack(nums, visited, track);
            track.remove(track.size() - 1);
            visited[i] = 0;
        }
    }
}

以上,就是排列组合和子集三个问题的解法,总结一下

子集问题可以用回溯算法,要用 start 参数排除已选择的数字。

组合问题利用的是回溯思想,结果可以表示成树结构,我们只要套用回溯算法模板即可,关键点在于要用一个 start 排除已经选择过的数字。

排列问题是回溯思想,也可以表示成树结构套用算法模板,不同之处在于使用contains 方法或visited 数组排除已经选择的数字。

对于这三个问题,关键区别在于回溯树的结构,不妨多观察递归树的结构,很自然就可以理解代码的含义了。

四、其他回溯题目

17. 电话号码的字母组合(中等)

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vInwYf1r-1620984416745)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210512102602053.png)]

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

解题思路:首先使用哈希表存储每个数字对应的所有可能的字母,然后进行回溯操作。

回溯过程中维护一个字符串,表示已有的字母排列(如果未遍历完电话号码的所有数字,则已有的字母排列是不完整的)。该字符串初始为空。每次取电话号码的一位数字,从哈希表中获得该数字对应的所有可能的字母,并将其中的一个字母插入到已有的字母排列后面,然后继续处理电话号码的后一位数字,直到处理完电话号码中的所有数字,即得到一个完整的字母排列。然后进行回退操作,遍历其余的字母排列。

回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。

class Solution {
    List<String> res = new ArrayList<>();
    public List<String> letterCombinations(String digits) {
        if(digits == null || digits.length() == 0){
            return res;
        }
        Map<Character, String> phoneMap = new HashMap<Character, String>() {{
            put('2', "abc");
            put('3', "def");
            put('4', "ghi");
            put('5', "jkl");
            put('6', "mno");
            put('7', "pqrs");
            put('8', "tuv");
            put('9', "wxyz");
        }};
        backtrack(phoneMap, digits, 0, new StringBuilder());
        return res;
    }

    private void backtrack(Map<Character, String> phoneMap, String digits, int start, StringBuilder track){
        //当路径长度等于电话号码长度时,表示得到了一个完整的字母组合,将它添加到结果
        if(track.length() == digits.length()){
            res.add(track.toString());
        }else{
            char num = digits.charAt(start);//获取电话号码中的一位数字
            String letter = phoneMap.get(num);//从哈希表中找到该数字对应的字符串
        	//遍历该数字对应的字符串的每个字符,将该字符添加到路径
            for (char c : letter.toCharArray()) {
                track.append(c);
                backtrack(phoneMap, digits, start + 1, track);//继续处理电话号码的后一位数字
                track.deleteCharAt(track.length() - 1);
            }
        }
    }
}

257. 二叉树的所有路径(简单)

给定一个二叉树,返回所有从根节点到叶子节点的路径。

输入:
   1
 /   \
2     3
 \
  5
输出: ["1->2->5", "1->3"]
解释: 所有根节点到叶子节点的路径为: 1->2->5, 1->3

解题思路

class Solution {
    private List<String> res=new ArrayList<>();
    public List<String> binaryTreePaths(TreeNode root) {
        backtrack(root, new StringBuilder());
        return res;
    }
    private void backtrack(TreeNode root, StringBuilder track){
        if(root == null) return;
        track.append(root.val);
        if(root.left == null && root.right == null){
            res.add(track.toString());
            return;
        }else{
            track.append("->");
            backtrack(root.left, new StringBuilder(track));
            backtrack(root.right, new StringBuilder(track));//注意这里的new,因为是新的递归
        }
    }
}
class Solution {
    private List<String> res=new ArrayList<>();
    public List<String> binaryTreePaths(TreeNode root) {
        String track = "";
        backtrack(root, track);
        return res;
    }
    private void backtrack(TreeNode root, String track){
        if(root == null) return;
        track += root.val;
        if(root.left == null && root.right == null){
            res.add(track);
            return;
        }else{
            track += "->";
            backtrack(root.left, track);
            backtrack(root.right, track);
        }
    }
}

ps: 两种方法没有本质区别,主要是区分一下 StringBuilde r和 String 在使用上的区别,StringBuilder速度更快!!


93. 复原IP地址 (中等)

给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。

有效 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 = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
输入:s = "101023"
输出:["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]

解题思路

class Solution {
    List<String> res = new ArrayList<>();
    public List<String> restoreIpAddresses(String s) {
        if(s == null || s.length() == 0) return res;
        if(s.length() < 4 || s.length() > 12) return res;
        backtrack(s, 0, 0, new StringBuilder());
        return res;
    }

    private void backtrack(String s, int step, int start, StringBuilder track){
        int n = s.length();
        int len = track.length();
        //当字符串遍历完毕,且满足正好分成四个部分,说明是满足题目要求的
        if(step == 4 && start == n){
            res.add(track.toString());
            return;
        }
        for(int i = start; i < n ; i++){
            String str = s.substring(start, i + 1);//提取字符串,判断是否满足要求
            if((str.length() > 3) ||
                    ((str.length() > 1 && str.charAt(0) == '0') ||
                            (Integer.valueOf(str) > 255) )) {
                continue;
            }
            //如果满足要求就添加到路径
            track.append(str);
            if(i != n - 1){
                track.append(".");
            }
            backtrack(s, step + 1, i + 1, track); //这个错误找了超级久,传入的是i,不是start!脑子抽抽
            track.setLength(len);
        }
    }
}

79. 单词搜索(中等)

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true

解题思路

设函数 backtrack(i, j, k) 判断以网格的 (i, j)位置出发,能否搜索到单词 word[k..],其中 word[k..] 表示字符串 word 从第 k 个字符开始的后缀子串。如果能搜索到,则返回 true,反之返回 false。函数 backtrack(i, j, k) 的执行步骤如下:

  • 如果 board[i][j] ≠ s[k],当前字符不匹配,直接返回 false。
  • 如果当前已经访问到字符串的末尾,且对应字符依然匹配,此时直接返回 true。
  • 否则,遍历当前位置的所有相邻位置。如果从某个相邻位置出发,能够搜索到子串 word[k+1..],则返回 true,否则返回 false。

这样,我们对每一个位置 (i,j) 都调用函数 backtrack(i, j, 0) 进行检查:只要有一处返回 true,就说明网格中能够找到相应的单词,否则说明不能找到。

为了防止重复遍历相同的位置,需要额外维护一个与board 等大的visited 数组,用于标识每个位置是否被访问过。每次遍历相邻位置时,需要跳过已经被访问的位置。

class Solution {
    private final static int[][] direction = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    boolean res = false;

    public boolean exist(char[][] board, String word) {
        if (board == null) return false;
        int m = board.length, n = board[0].length;
        //需要创建一个visited 数组来记录每个位置是否已经遍历过
        boolean[][] visited = new boolean[m][n];
        for (int i = 0; i < m; i++){
            for (int j = 0; j < n; j++){
                backtrack(board, word, i, j, 0, visited);  // 从左上角开始遍历棋盘每个格子
            }
        }
        return res;
    }

    private void backtrack(char[][] board, String word, int row, int col, int start, boolean[][] visited){
        int m = board.length, n = board[0].length;
        if(start == word.length()){
            res = true;
            return;
        }
        //不满足条件的直接返回
        if(row<0 || row>= m || col<0 || col >=n || visited[row][col] || res
           || board[row][col]!=word.charAt(start))  return;
        //标记已经访问过的位置
        visited[row][col] = true;
        for(int[] dir : direction){
            backtrack(board, word, row + dir[0], col + dir[1], start + 1,  visited);
        }
        visited[row][col] = false;
    }
}

131. 分割回文串(中等)

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
class Solution {
    List<List<String>> res = new ArrayList<>();
    public List<List<String>> partition(String s) {
        List<String> track = new ArrayList<>();
        backtrack(s, 0, track);
        return res;
    }

    private void backtrack(String s, int start, List<String> track){
        if(start == s.length()){
            res.add(new ArrayList(track));
            return;
        }

        for(int i = start; i < s.length(); i++){
            String str = s.substring(start, i+1);//提取字符串,判断是否满足要求为回文串
            if(isPalindrome(str)){
                track.add(str);//如果是就添加到路径
                backtrack(s, i + 1, track);//遍历下一个字符开头的字符串
                track.remove(track.size() - 1);
            }
        }
    }
    private boolean isPalindrome(String s){
        int left = 0, right = s.length() - 1; 
        while(left <= right){
            if(s.charAt(left++) != s.charAt(right--)){
                return false;
            }
        }
        return true;
    }
}

51. N皇后(困难)

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

  • 皇后彼此不能相互攻击,也就是说:任何两个皇后都不能处于同一条横行、纵行或斜线上。
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

解题思路:这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。

函数backtrack依然像个在决策树上游走的指针,每个节点就表示在board[row][col]上放置皇后,通过isValid函数可以将不符合条件的情况剪枝

class Solution {
    //创建结果集res
    List<List<String>> res = new ArrayList<>(); 

    public List<List<String>> solveNQueens(int n) {
        List<StringBuilder> track = new ArrayList<>();    
        //初始化棋盘
        for(int i = 0; i < n; i++){
            StringBuilder str = new StringBuilder();
            for(int j = 0; j < n; j++){
                str.append('.');
            }
            track.add(str);
        }
        backtrace(track, 0);
        return res;
    }

    void backtrace(ArrayList<StringBuilder> track, int row){
        // 如果每一行都成功放置了皇后,记录结果
        if(row == track.size()){
            List<String> track1 = new ArrayList<>();
            //将StringBuilder类转化为String类
            for(int i = 0; i < track.size(); i++){
                track1.add(track.get(i).toString());                
            }
            res.add(track1);
            return;
        }

        int n = track.get(row).length();
        for(int col = 0; col < n; col++){
            if(!isValid(track,row,col)) continue;
            //做选择
            track.get(row).setCharAt(col,'Q');
            //进入下一行放皇后
            backtrace(track, row + 1);
            //撤销选择
            track.get(row).setCharAt(col,'.');
        }
    }
    // 是否可以在目标位置放皇后
    boolean isValid(List<StringBuilder> track, int row, int col){
        int  n = track.size();
        // 检查列是否有皇后冲突,检查路径上的每一行所对应的该列是否已经放置过
        for(int i = 0; i < n; i++){
            if(track.get(i).charAt(col) == 'Q') 
                return false;
        }
        // 检查右上方是否有皇后冲突
        for(int i = row-1, j = col+1; i>=0 && j <n; i--,j++){
            if(track.get(i).charAt(j) == 'Q') 
                return false;
        }
        // 检查左上方是否有皇后冲突
        for(int i= row-1, j = col-1; i>=0 && j >=0; i--,j--){
            if(track.get(i).charAt(j) == 'Q') 
                return false;
        }
        return true;  
    }
}

37. 解数独(困难)

编写一个程序,通过填充空格来解决数独问题。数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1X0Ax9fM-1620984416747)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210514170606139.png)]

输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j1CA8vjR-1620984416747)(C:\Users\lp\AppData\Roaming\Typora\typora-user-images\image-20210514170652977.png)]

解题思路:为了减少复杂度,我们可以backtrack函数返回值为boolean,如果找到一个可行解就返回 true,这样就可以阻止后续的递归。只找一个可行解,也是题目的本意。

class Solution {
    public void solveSudoku(char[][] board) {       
        backtrack(board, 0, 0);
    }

    private boolean backtrack(char[][] board, int row, int col){
        int m = 9, n = 9;
        if (col == n) {
            // 穷举到最后一列的话就换到下一行重新开始。
            return backtrack(board, row + 1, 0);
        }
        if (row == m) {
            // 找到一个可行解,触发 base case
            return true;
        }
        // 对这一行的每一列进行穷举
        for(int i = col; i < 9; i++){
            // 如果该位置是预设的数字,不用我们穷举,返回
            if(board[row][i] != '.') {
                return backtrack(board, row, i+1);
            }
            for(char ch = '1'; ch <= '9'; ch++){
                // 如果遇到不合法的数字,就跳过
                if(!isValid(board, row, i, ch)){
                    continue;
                }
                board[row][i] = ch;
                // 如果找到一个可行解,立即结束
                if(backtrack(board, row, i+1)){
                    return true;
                }
                board[row][i] = '.';               
            }
            // 穷举完 1~9,依然没有找到可行解,此路不通
            return false;
        }
        return false;       
    }
// 判断 board[i][j] 是否可以填入 n
    boolean isValid(char[][] board, int row, int col, char ch) {
        for (int i = 0; i < 9; i++) {
            // 判断行是否存在重复
            if (board[row][i] == ch) return false;
            // 判断列是否存在重复
            if (board[i][col] == n) return false;
            // 判断 3 x 3 方框是否存在重复,重点理解一下
            if (board[(row/3)*3 + i/3][(col/3)*3 + i%3] == ch)
                return false;
        }
        return true;
    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值