您现在的位置是:首页 > 文章详情

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

日期:2020-11-29点击:343

前言

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

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

回溯算法是建立在递归之上,虽说第四章已经详细说明了递归的执行机制,但例子都是简单的单递归形式。所以本章首先复习下递归,之后层层递进,最后直至解决回溯的代表问题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 }; 

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

还有就是这个函数是两次递归调用自身,首先是先执行添加左括号的递归,当左括号已经添加完时,再开始执行添加右括号的递归,注意观察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 

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

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

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

代码如下:

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为例:

例如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 }; 

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

_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()操作,最终会影响所有收集到的组合。来看下优化后的结果:

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ⁿ)。所以在处理大数据时回溯对计算机计算性能要求非常高,而回溯算法也有其特有剪枝操作来减少无谓的计算量。

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

原文链接:https://my.oschina.net/u/4430861/blog/4759238
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章