美文网首页
前端学数据结构与算法(十三):01执行的艺术 - 回溯算法(上)

前端学数据结构与算法(十三):01执行的艺术 - 回溯算法(上)

作者: 飞跃疯人院_a | 来源:发表于2020-11-29 19:38 被阅读0次

    前言

    在最初尝试学习算法时,对两个算法留下了深刻的印象,一个是动态规划,另一个就是回溯算法。如果说算法思想的艺术,那归于动态规划;但如果说用计算机执行机制解决问题的艺术,那非回溯算法莫属了,也由衷的赞叹,原来计算机还能这么执行。

    什么是回溯算法?它能解决什么问题?带着这两个问题,然后用示例来回答这几个问题是本章的目的。

    回溯算法是建立在递归之上,虽说第四章已经详细说明了递归的执行机制,但例子都是简单的单递归形式。所以本章首先复习下递归,之后层层递进,最后直至解决回溯的代表问题N皇后,逐步彻底搞懂回溯算法。闲言少叙,一起来认识这么酷的算法吧。

    什么是回溯算法?

    我想每个人都希望自己拥有回到过去的能力,如果当时选择勇敢的追她,也许现在就没遗憾了吧?如果当时能好好学习,现在也不至于被学历卡脖子了?如果当时听父母的留在老家,现在也不至于这么累吧?每一个选择的背后,都是舍弃掉其他选择的可能性,也就是选择的机会成本

    回到算法,那有没可能上述三个选择我都做了,最后我还把它们选择之后的结果进行比较,选择最满意的那个。可以!回溯恰恰就是能把每种可能性都尝试的算法,这是一种暴力搜索算法,它可以对问题每个不同的分支进行尝试,不要说人生的十字路口,就是米字路口我也把每种结果给你整明白。

    回溯算法能解决什么问题?

    回溯算法能进行暴力的搜索,那它到底具体能解决什么问题?
    它解决的问题是需要暴力才能解决的问题-_-#, 以下列举回溯能解决的六种常见问题类型。

    1. 组合问题

    比如请给出数字[1, 2, 3, 4]两两组合的每种可能性,结果就是[12, 13, 14, 23, 24, 34],因为2332是一个组合,所以忽略32。如果你说这个很简答了,使用循环也可以解决,那题目条件换一下,给出数字1 - 20之间每12种组合的可能性,这时遍历就不好使了。

    2.排列问题

    给出[1, 2, 3]所有的全排列的可能性,结果就是[123, 132, 213, 231, 312, 321]

    3.子集问题

    给出数字[1, 2, 3]所有的子集(幂集)的可能性,结果就是[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

    4.分割问题

    给出一个数字字符串25525511135,返回它所有组成IP地址格式的可能,结果就是["255.255.11.135", "255.255.111.35"]

    5.floodFill填色问题

    问题类型参考力扣200岛屿和547朋友圈,之后详细介绍。

    6.棋盘问题

    类型参考解数独和8皇后问题,之后详细介绍。

    上述的六种类型看着区别可能挺大,其实它们都是树型问题这个类型,这个到具体问题讲解时再说明其中的道道。

    再次理解递归

    因为回溯是建立在递归之上,也可以说回溯就是递归,只是递归更复杂些的调用,所以我们还是先用两个题目重新理解下的递归调用。

    1480 - 一维数组的动态和 ↓

    给你一个数组 nums , 请返回 nums 的动态和。
    
    输入:nums = [1,2,3,4]
    输出:[1,3,6,10]
    解释:动态和计算过程为 [1, 1+2, 1+2+3, 1+2+3+4] 。
    
    来源:力扣(LeetCode)
    链接:https://leetcode-cn.com/problems/running-sum-of-1d-array
    

    简单来说,就是依次累加的过程,每一个数字的和,等于它之前所有的元素加上自身的和。从前往后使用一次遍历解决:

    var runningSum = function (nums) {
      for (let i = 0; i < nums.length - 1; i++) {
        nums[i + 1] = nums[i + 1] + nums[i]
      }
      return nums
    };
    

    此时我们开始折腾,如果是使用递归如何解决这个问题?其实上面的描述:每一个数字的和,等于它之前所有元素加上自身的和已经将子问题进行的拆解。所以此时我们可以倒推,数组最后一个数字的和就等于它之前所有的和加上自身,再依次往前倒推后,代码如下:

    var runningSum = function (nums) {
      const _helper = end => {
        if (end === 0) { // 到数组头了,开始归
          return nums[0]
        }
        nums[end] = _helper(end - 1) + nums[end] // 它前面的元素加自身
        return nums[end] // 返回计算后的结果
      }
      _helper(nums.length - 1) // 从最后一个数字开始
      return nums
    };
    

    这是最简单的单递归调用,也就是说递归函数里只调用自身一次,接下来看一个调用自身两次的问题。

    22 - 括号生成 ↓

    数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。
    输入:n = 3
    输出:
    [
      "((()))",
      "(()())",
      "(())()",
      "()(())",
      "()()()"
    ]
    
    来源:力扣(LeetCode)
    链接:https://leetcode-cn.com/problems/generate-parentheses
    

    根据题意可知,最终的长度一定是2n,且左括号和右括号的个数分别是n,且必须以左括号作为有效括号的起始,只有右括号的剩余数量大于左括号时才能放置,先看代码,我画了一个执行顺序图,大家对比看看:

    var generateParenthesis = function (n) {
      const ret = [] // 存放有效的括号集合
      const _helper = (left, right, parenthesi) => {
        if (left === 0 && right === 0) { // 当左右括号的数量都用完时
          ret.push(parenthesi) // 找到了一个有效的集合
        }
        if (left > 0) { // 放左括号的规则:当还有左括号时就可以进行放置
          _helper(left - 1, right, parenthesi + '(') // 减少左括号的数量
        }
        if (right > left) { // 放右括号的规则:当右括号的剩余数量大于左括号时才能放置
          _helper(left, right - 1, parenthesi + ')') // 减少右括号的数量
        }
      }
      _helper(n, n, '') // 传入左括号和右括号的剩余数量
      return ret
    };
    
    image

    图中灰色的节点是部分剪枝的操作,因为只要是按照灰色节点添加括号,后续的生成必然是不合规的,所以在代码里直接不执行后续的递归即可。

    还有就是这个函数是两次递归调用自身,首先是先执行添加左括号的递归,当左括号已经添加完时,再开始执行添加右括号的递归,注意观察6步到第7步的跳跃,因为递归是调用自身两次,2步到第3只是执行了其中的一种可能性,因为是深度优先遍历,当这个可能性已经执行完毕时,就需要跳跃到第7步执行另一种可能性,之后的每个函数遵循这样的规则,这也是递归的执行机制。

    不难发现,当递归每一步的可能性是两次时,最终的执行顺序生成的就是一颗二叉树,递归的深度取决于最终结果的长度,结果是2n长度,所以深度就是2n

    回溯算法是啥?就是把这里的可能性2次,换成n次,从而把二叉树会变成n叉树,而且在每次递归之后再执行另外的操作,此时就是树的后序遍历执行的时机。

    组合问题

    组合排列经常一起出现,这里先简单说明下它们的区别。

    什么是组合?假设有三个明星abc,有三位粉丝xyz,让每位粉丝在其中选2位认可的明星,有多少选法?很明显粉丝选abba都无所谓,最终的结果里,这两种选择是一样的,所以这就是组合问题。

    什么是排列?还是假如有三个明星abc,有三位粉丝xyz,让每位粉丝选出你认为的第1名和第2名,有多少种选法?很明显此时abba就是两种不同的结果,都需要统计,这就是排列问题。

    17 - 电话号码的字母组合 ↓

    给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
    给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
    
    输入:"23"
    输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
    
    来源:力扣(LeetCode)
    链接:https://leetcode-cn.com/problems/letter-combinations-of-a-phone-number
    
    image

    以输入23为例,就是把2对应的abc都分别提取出来,去和3对应的def进行组合。我们需要一个辅助函数来帮助我们这件事,它做的事就是把数字对应的字母取出来,用取出来的字母,去和下一个数字对应的字母进行组合,最终找到所有组合。

    什么时候算是找到了一个符合要求的组合?当这个组合的长度等于输入数字的长度时,就算是找到了一个。

    因为每一个数字平均代表的就是3个字母,所以用递归来表示执行树时,就会是一颗三叉树。递归的深度是多少?取决于输入的数字的个数,例如当输入27465五个数字时,最终执行树的深度就是5层。

    image

    代码如下:

    const letterCombinations = digits => {
      if (digits === '') { // 当输入空字符串时
        return [] // 返回空集合
      }
      const ret = [] // 用于存放所有找到的组合
      const letterArr = [' ', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
      // 存放数字对应的字母表
      
      const _helper = (start, s) => { // 辅助函数
        if (s.length === digits.length) { // 找到了一个组合
          ret.push(s)
          return // 返回上一层递归
        }
        const letters = letterArr[digits[start]] // 对应的就是abc, def
        for (let i = 0; i < letters.length; i++) {
          _helper(start + 1, s + letters[i]) // 执行多次递归
        }
      }
      
      _helper(0, '')
      return ret
    };
    

    传入的参数start,表示的是需要依次处理的输入数字的下标。以23为例,首先处理下标0也就是2对应字母问题,在进入下一层递归时+ 1,是为了处理3对应的字母组合问题。

    传入的s表示的是当前组合,每次进入下一层递归时的s + letters[i],就是带着这一层的结果进行下一层。即使这一层已经符合组合的要求,也是在下一层的递归终止条件进行结果的保存,然后终止递归,回到上一层递归。

    因为每一层的递归都是循环的执行n次,所以当本层循环执行完,才会返回上一层。

    77 - 组合 ↓

    给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
    
    示例:
    输入: n = 4, k = 2
    输出: [[1,2],[1,3],[1,4],[2,4],[2,3],[3,4]]
    
    来源:力扣(LeetCode)
    链接:https://leetcode-cn.com/problems/combinations
    

    有了上一个组合问题的解决经验后,我们直接上执行树,再来解释这题的异同点,以1 ... 4,且k2为例:

    image

    例如1331是同一个组合,所以在处理时,我们需要忽略'相同'的组合,如何进行忽略操作?每次进入下一层递归时,起始的遍历的位置都进行+ 1即可。其余的逻辑与上一题类似,代码如下:

    var combine = function (n, k) {
      if (n < 0 || k < 0 || k > n) { // n和k都得是符合条件的
        return []
      }
      const ret = []
      const _helper = (start, arr) => {
        if (arr.length === k) {
          ret.push(arr)
          return
        }
        for (let i = start; i <= n; i++) { // i = start,忽略比start小的数进行组合
          _helper(i + 1, arr.concat(i))
        }
      }
      _helper(1, [])
      return ret
    };
    
    image

    这个解法确实能获得通过,但是好家伙,这也太慢了吧。原因就出在我们进入下一层递归的核心逻辑上:

    _helper(i + 1, arr.concat(i))
    

    没想到concat这个方法会这么慢,其实我们的目的无非就是把当前的数字i加入到数组里,然后带入到下一层递归去,所以我们可以使用push方法。

    同时也有一点,从执行树不难发现,最后去找另外一个组合时,需要将之前组合的末尾弹出才行,需要留出位置。所以我们在每次循环后,进行弹出留位置操作。优化如下:

    var combine = function (n, k) {
      if (n < 0 || k < 0 || k > n) {
        return []
      }
      const ret = []
      const _helper = (start, arr) => {
        if (arr.length === k) {
          ret.push([...arr]) // 需要进行拷贝
          return
        }
        for (let i = start; i <= n; i++) {
          arr.push(i)
          _helper(i + 1, arr)
          arr.pop()
        }
      }
      _helper(1, [])
      return ret
    };
    

    在终止条件那里找到了一个符合的组合时,我们需要进行一次拷贝,因为每一层的循环都有arr.pop()操作,最终会影响所有收集到的组合。来看下优化后的结果:

    image

    39 - 组合总和

    给定一个无重复元素的数组 candidates 和一个目标数 target ,
    找出 candidates 中所有可以使数字和为 target 的组合。
    candidates 中的数字可以无限制重复被选取。
    
    说明:
      所有数字(包括 target)都是正整数。
      解集不能包含重复的组合。 
    
    输入:candidates = [2,3,6,7], target = 7,
    所求解集为:[[7], [2,2,3]]
    
    来源:力扣(LeetCode)
    链接:https://leetcode-cn.com/problems/combination-sum
    

    这题有几个关键信息,首先是多了一个target目标值,也就是说我们每次统计的值需要与这个目标值进行比对,一个对比较很有帮助的操作就是首先对candidates进行排序,这样的话就可以将计算结果进行保存起来,只在它的基础进行加减即可。

    还有一个信息是可以无限制的使用数组里的某个数,排序之后这个操作也会很方便,直接从最小的数开始统计每种组合的可能。

    剩下的解题框架和上一题是类似的,我们写出来代码如下:

    var combinationSum = function (candidates, target) {
      candidates.sort((a, b) => a - b)
      const ret = []
      const _helper = (start, sum, arr) => {
        if (sum > target) { // 因为是排好序的,所以sum接下来只会更大
          return
        }
        if (sum === target) { // 正好找到了
          ret.push([...arr])
          return
        }
        for (let i = start; i < candidates.length; i++) {
          sum += candidates[i] // sum为计算结果
          arr.push(candidates[i]) // arr存放暂存的集合
          _helper(i, sum, arr) // 注意第一个参数没有+ 1,因为要无限使用
          sum -= candidates[i]
          arr.pop()
        }
      }
      _helper(0, 0, [])
      return ret
    };
    

    有两个递归结束条件,如果是大于那就结束当前层递归,结束之后sum -= candidates[i],减去candidates[i]这个不合规或已经被使用的值,然后弹出candidates[i],进入下一次循环。

    其实和上一题的解题思路是一致的,只是说多了一些解题的限制条件而已。

    最后

    回溯算法的复杂度我们还没有分析,这里简单的说明下。其实从之前的题目不难发现,回溯算法的复杂度非常高。例如17题,如果输入5个数字,那么最终执行树展开最下一层的节点就是3 * 3 * 3 * 3 * 3个,这个数据规模是指数级别的O(2ⁿ)。所以在处理大数据时回溯对计算机计算性能要求非常高,而回溯算法也有其特有剪枝操作来减少无谓的计算量。

    回溯算法这个题目很广,剩下的几种题型再下一章进行讲解。

    相关文章

      网友评论

          本文标题:前端学数据结构与算法(十三):01执行的艺术 - 回溯算法(上)

          本文链接:https://www.haomeiwen.com/subject/ucgkwktx.html