回溯算法 马里奥式学习法

本文介绍了回溯算法的学习方法,通过《超级马里奥:奥德赛》游戏中的思路,提出掌握一套回溯算法模板并不断练习以应对各种回溯问题。文章详细讲解了回溯算法的框架,包括选择列表、结束条件和剪枝条件,并列举了一系列回溯算法相关问题,如全排列、组合总和、Flood Fill、字符串回溯和游戏问题等,帮助读者理解和实践回溯算法。
摘要由CSDN通过智能技术生成

前言

回溯算法,其实一点也不难,难的是起步,和写了就忘。

但是,最近玩的一款游戏《超级马里奥:奥德赛》给了我灵感,这款游戏,就是在开始的时候,把所有基础动作都交给你。只要你能在不断练习中,熟悉这些基础操作,那么,对于后期再难的关卡,你都能从容应付。

所以,我认为,只要掌握一套回溯算法的模板,并对着这套模板持续刷题练习,那很快就能掌握回溯的解题思路,从而在日后面对回溯问题的时候,才能从容不迫。

在接下来的内容中,我会总结一套回溯算法的思路,然后在几乎每道题中,都根据这个思路,来思考怎么解决回溯问题。


回溯算法

大部分问题,如果无法解决,那么使用回溯算法,总归能得出部分答案

搜索问题的解,可以通过 遍历 实现。所以很多教程把「回溯算法」称为爆搜(暴力解法)。因此回溯算法用于 搜索一个问题的所有的解 ,通过深度优先遍历的思想实现。

其与动态规划的区别在于:

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

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

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

相关问题:

题型一:排列、组合、子集相关问题
提示:这部分练习可以帮助我们熟悉「回溯算法」的一些概念和通用的解题思路。解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法。

  1. 全排列(中等)

  2. 全排列 II(中等):思考为什么造成了重复,如何在搜索之前就判断这一支会产生重复;

  3. 组合总和(中等)

  4. 组合总和 II(中等)

  5. 组合(中等)

  6. 子集(中等)

  7. 子集 II(中等):剪枝技巧同 47 题、39 题、40 题;

  8. 第 k 个排列(中等):利用了剪枝的思想,减去了大量枝叶,直接来到需要的叶子结点;

  9. 复原 IP 地址(中等)

题型二:Flood Fill
提示:Flood 是「洪水」的意思,Flood Fill 直译是「泛洪填充」的意思,体现了洪水能够从一点开始,迅速填满当前位置附近的地势低的区域。类似的应用还有:PS 软件中的「点一下把这一片区域的颜色都替换掉」,扫雷游戏「点一下打开一大片没有雷的区域」。

下面这几个问题,思想不难,但是初学的时候代码很不容易写对,并且也很难调试。我们的建议是多写几遍,忘记了就再写一次,参考规范的编写实现(设置 visited 数组,设置方向数组,抽取私有方法),把代码写对。

  1. 图像渲染(Flood Fill,中等)

  2. 岛屿数量(中等)

  3. 被围绕的区域(中等)

  4. 单词搜索(中等)
    说明:**以上问题都不建议修改输入数据,设置 visited 数组是标准的做法。**可能会遇到参数很多,是不是都可以写成成员变量的问题,面试中拿不准的记得问一下面试官

    又注:岛屿问题,修改输入数据貌似没什么问题,可以减少使用的空间

题型三:字符串中的回溯问题
提示:字符串的问题的特殊之处在于,字符串的拼接生成新对象,因此在这一类问题上没有显示「回溯」的过程,但是如果使用 StringBuilder 拼接字符串就另当别论。
在这里把它们单独作为一个题型,是希望朋友们能够注意到这个非常细节的地方。

  1. 电话号码的字母组合(中等);
  2. 字母大小写全排列(中等);
  3. 括号生成(中等) :这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。

题型四:游戏问题
回溯算法是早期简单的人工智能,有些教程把回溯叫做暴力搜索,但回溯没有那么暴力,回溯是有方向地搜索。「力扣」上有一些简单的游戏类问题,解决它们有一定的难度,大家可以尝试一下。

  1. 解数独(困难):思路同「N 皇后问题」;
  2. 祖玛游戏(困难)
  3. 扫雷游戏(困难)
  4. N 皇后(困难):其实就是全排列问题,注意设计清楚状态变量,在遍历的时候需要记住一些信息,空间换时间;

其与深度优先搜索dfs的区别

dfs会将所有可能遍历

回溯虽然也会遍历所有可能,但当其到达结束条件的时候,会进行回溯,即撤销之前的遍历

下图👇可以帮助更好得理解:

image-20210309104723932

回溯算法的框架

回溯算法,说白了,就是一个决策树的遍历过程,我们只需要考虑如下三个问题:

  1. 选择列表:也就是你当前可以做出的选择(不要选重)
  2. 结束条件:也就是到达决策树底层,无法在作出选择的条件
  3. 剪枝条件:看看能不能在中途就判断出下面无效,直接剔除

image-20210309101622037

框架

image-20201219174134060

其核心,就是for循环里面的循环,在递归调用之前“做选择”,在递归调用之后“撤销选择

回溯算法相关问题

46_全排列

问题

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

示例:

输入: [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]
思考步骤

image-20210309103002239

代码

是否存在,使用的是contains()方法:

public class Solution {
   
    public List<List<Integer>> res = new LinkedList<>();
    //获取全排列过后的值
    public List<List<Integer>>premute(int[] nums) {
   
        //储存路径(已经经过的位置)
        LinkedList<Integer> track = new LinkedList<>();
        backtrace(track,nums);
        return res;
    }

    //回溯
    public void backtrace(LinkedList<Integer>track,int[] nums) {
   
        //终止条件
        if (track.size()==nums.length) {
   
            //这里一定要add重新new的,不然res中,所有的引用都是一样的
            res.add(new LinkedList(track));
            return;
        }
        for (int num : nums) {
   
            if (track.contains(num)) continue;
            //做选择
            track.add(num);
            //进入下一层决策树
            backtrace(track,nums);
            //取消选择
            track.removeLast();
        }
    }

    public static void main(String[] args) {
   
        Solution solution = new Solution();
        int []nums={
   1,2,3,4};
        List<List<Integer>> premute = solution.premute(nums);
        for (List<Integer> list : premute) {
   
            System.out.println(list.toString());
        }
    }
}

是否存在,使用的是used[]数组:

List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
   
        int len = nums.length;
        if (len==0) return res;
        //记录路径,别忘了到边界的时候,撤回
        List<Integer> track = new LinkedList<>();
        //记录当前位置是否被使用过
        boolean[] used = new boolean[len];

        dfs(track,0,used,nums);
        return res;
    }

      /**
       * 回溯
       * @param track
       * @param depth 记录深度
       * @param used
       * @param nums
       */
    public void dfs(List<Integer>track,int depth,boolean[] used,int[] nums) {
   
        //结束条件
        if (depth==used.length) {
   
            //这里一定要使用新的内存空间,不然,所有答案都会是一样的
            res.add(new ArrayList<>(track));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
   
            //用used数组替代contains函数,使得时间复杂度由O(n)->O(1)
            if (!used[i]) {
   
                used[i]=true;
                track.add(nums[i]);
                dfs(track,depth+1,used,nums);
                //撤销操作
                used[i]=false;
                track.remove(track.size()-1);
            }
        }
    }

47_全排列II(剪枝)

问题

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

示例 1:

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

示例 2:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思考步骤

这道题,和上一道题很像,就是多了一个去处重复结果的约束

如果在结果生成之后,再去结果集中去重,那会很麻烦,毕竟要对比的是数组,不是字符串

所以,要在数组生成的时候进行剪枝

  1. 选择列表

在nums中进行选择,但是要用used数组记录之前是否选中

  1. 结束条件

track.length==nums.length

  1. 剪枝

有两种剪枝方式:

if(nums[i]==nums[i-1] && uesd[i-1]==false)

if(nums[i]==nums[i-1] && uesd[i-1]==true)

即当前元素和前面一个元素相等,但是前面一个元素没用过(用过)

两种方式都可以剪枝,但是效率不一样

这里要注意,无论使用那种剪枝方法,都只对有序数列有效 所以,在进入DFS之前,需要对数列进行排序

1、if i > 0 and nums[i] == nums[i-1] and check[i-1] == 0
灰色为剪枝部分,蓝色为答案部分:

image-20210309123524571

2、if i > 0 and nums[i] == nums[i-1] and check[i-1] == 1
灰色为剪枝部分,蓝色为答案部分:

image-20210309123541349

能够明显发现第一种能够提前剪枝,减少计算步骤和搜索次数,并且第一种选择了重复元素的第一个,而第二种选择了重复元素的最后一个,虽然答案都相同,我们成年人当然是要选效率更高一些的那一种对吧~

代码
 List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
   
        int len = nums.length;
        //这种剪枝方法,只适合排好序的数组
        Arrays.sort(nums);
        if (len==0) return res;
        boolean[] used = new boolean[len];
        List<Integer> track = new LinkedList<>();
        backTrack(nums,track,used);
        return res;
    }

    public void backTrack(int[] nums,List<Integer>track,boolean[] used) {
   
        //结束条件
        if (track.size()==nums.length) 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FARO_Z

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值