《300分钟搞定数据结构与算法》学习之旅 第四讲:递归与回溯

本文内容摘自《300分钟搞定数据结构与算法》,仅供学后查阅

递归和回溯的关系密不可分:

  • 递归的基本性质就是函数调用,在处理问题的时候,递归往往是把一个大规模的问题不断地变小然后进行推导的过程。

  • 回溯则是利用递归的性质,从问题的起始点出发,不断地进行尝试,回头一步甚至多步再做选择,直到最终抵达终点的过程。

递归(Recursion)

算法思想

递归算法是一种调用自身函数的算法(二叉树的许多性质在定义上就满足递归)。

举例:(汉诺塔问题)有三个塔 A、B、C,一开始的时候,在塔 A 上放着 n 个盘子,它们自底向上按照从大到小的顺序叠放。现在要求将塔 A 中所有的盘子搬到塔 C 上,让你打印出搬运的步骤。在搬运的过程中,每次只能搬运一个盘子,另外,任何时候,无论在哪个塔上,大盘子不能放在小盘子的上面。

解法:

  1. 从最终的结果出发,要把 n 个盘子按照大小顺序叠放在塔 C 上,就需要将塔 A 的底部最大的盘子搬到塔 C;

  2. 为了实现步骤 1,需要将除了这个最大盘子之外的其余盘子都放到塔 B 上。

由上可知,将原来的问题规模从 n 个盘子变成了 n-1 个盘子,即将 n-1 个盘子转移到塔 B 上。

如果一个函数,能将 n 个盘子从塔 A,借助塔 B,搬到塔 C。那么,也可以利用该函数将 n-1 个盘子从塔 A,借助塔 C,搬到塔 B。同理,不断地把问题规模变小,当 n 为 1,也就是只有 1 个盘子的时候,直接打印出步骤。

代码:

void hano(char A, char B, char C, int n) {
    if (n > 0) {
        hano(A, C, B, n - 1);
        move(A, C);
        hano(B, A, C, n - 1);
  }
}

由上述总结出递归的算法思想,将一个问题的规模变小,然后再利用从小规模问题中得出的结果,结合当前的值或者情况,得出最终的结果。

通俗来说,把要实现的递归函数看成是已经实现好的, 直接利用解决一些子问题,然后需要考虑的就是如何根据子问题的解以及当前面对的情况得出答案。这种算法也被称为自顶向下(Top-Down)的算法。

Leetcode : 91\247

递归的时间复杂度是O(n2)

回溯(Backtracking)

算法思想

回溯实际上是一种试探算法,这种算法跟暴力搜索最大的不同在于,在回溯算法里,是一步一步地小心翼翼地进行向前试探,会对每一步探测到的情况进行评估,如果当前的情况已经无法满足要求,那么就没有必要继续进行下去,也就是说,它可以帮助我们避免走很多的弯路

回溯算法的特点在于,当出现非法的情况时,算法可以回退到之前的情景,可以是返回一步,有时候甚至可以返回多步,然后再去尝试别的路径和办法。这也就意味着,想要采用回溯算法,就必须保证,每次都有多种尝试的可能。

解题模板

解题步骤

  1. 判断当前情况是否非法,如果非法就立即返回;

  2. 当前情况是否已经满足递归结束条件,如果是就将当前结果保存起来并返回;

  3. 当前情况下,遍历所有可能出现的情况并进行下一步的尝试;

  4. 递归完毕后,立即回溯,回溯的方法就是取消前一步进行的尝试。

代码模板

function fn(n) {

    // 第一步:判断输入或者状态是否非法?
    if (input/state is invalid) {
        return;
  }

    // 第二步:判读递归是否应当结束?
    if (match condition) {
        return some value;
  }

    // 遍历所有可能出现的情况
    for (all possible cases) {
  
        // 第三步: 尝试下一步的可能性
        solution.push(case)
        // 递归
        result = fn(m)

        // 第四步:回溯到上一步
        solution.pop(case)
    
    }
    
}

例题分析

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

说明:

  • 所有数字(包括 target)都是正整数。

  • 解集不能包含重复的组合。

解题思路

题目要求的是所有不重复的子集,而且子集里的元素的值的总和等于一个给定的目标。

思路 1:暴力法。

罗列出所有的子集组合,然后逐个判断它们的总和是否为给定的目标值。解法非常慢。

思路 2:回溯法。

从一个空的集合开始,小心翼翼地往里面添加元素。

每次添加,检查一下当前的总和是否等于给定的目标。

如果总和已经超出了目标,说明没有必要再尝试其他的元素了,返回并尝试其他的元素;

如果总和等于目标,就把当前的组合添加到结果当中,表明我们找到了一种满足要求的组合,同时返回,并试图寻找其他的集合。

代码实现

int[][] combinationSum(int[] candidates, int target) {
    int[][] results;
    backtracking(candidates, target, 0, [], results - 换另外一种颜色高亮);
    return results;
}

void backtracking = (int[] candidates, int target, int start, int[] solution, int[][] results) => {
    if (target < 0) {
        return;
  }

    if (target === 0) {
        results.push(solution);
        return;
  }

    for (int i = start; i < candidates.length; i++) {
        solution.push(candidates[i]);
        backtracking(candidates, target - candidates[i], i, solution, results);
        solution.pop();
    }
}

在主函数里:

  1. 定义一个 results 数组用来保存最终的结果;

  2. 调用函数 backtracking,并将初始的情况以及 results 传递进去,这里的初始情况就是从第一个元素开始尝试,而且初始的子集为空。

在 backtracking 函数里:

  1. 检查当前的元素总和是否已经超出了目标给定的值,每添加进一个新的元素时,就将它从目标总和中减去;

  2. 如果总和已经超出了目标给定值,就立即返回,去尝试其他的数值;

  3. 如果总和刚好等于目标值,就把当前的子集添加到结果中。

在循环体内:

  1. 每次添加了一个新的元素,立即递归调用 backtracking,看是否找到了合适的子集

  2. 递归完毕后,要把上次尝试的元素从子集里删除,这是最重要的。

以上,就完成了回溯。

提示:这是一个最经典的回溯的题目,麻雀虽小,但五脏俱全。它完整地体现了回溯算法的各个阶段。

LeetCode: 51

结语
递归和回溯可以说是算法面试中最重要的算法考察点之一,很多其他算法都有它们的影子。例如,二叉树的定义和遍历就利用到了递归的性质;归并排序、快速排序的时候也运用了递归;我们将在第 6 课介绍动态规划,它其实是对递归的一种优化;还有第 7 课里的二分搜索,也可以利用递归去实现。

注意:要能熟练掌握好分析递归复杂度的方法,必须得有比较扎实的数学基础,比如对等差数列、等比数列等求和公式要牢记。

建议:LeetCode 上对递归和回溯的题目分类做得很好,有丰富的题库,建议大家多做。

©️2020 CSDN 皮肤主题: 精致技术 设计师:CSDN官方博客 返回首页