forked from ascoders/weekly
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
1 changed file
with
213 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
如何尝试走迷宫呢?遇到障碍物就从头 “回溯” 继续探索,这就是回溯算法的形象解释。 | ||
|
||
更抽象的,可以将回溯算法理解为深度遍历一颗树,每个叶子结点都是一种方案的终态,而对某条路线的判断可能在访问到叶子结点之前就结束。 | ||
|
||
<img width=250 src="https://z3.ax1x.com/2021/06/26/R3HBoq.png"> | ||
|
||
相比动态规划,回溯可以解决的问题更复杂,尤其是针对具有后效性的问题。 | ||
|
||
动态规划之所以无法处理有后效性问题,原因是其 `dp(i)=F(dp(j))` 其中 `0<=j<i` 导致的,因为 `i` 通过 `i-1` 推导,如果 `i-1` 的某种选择会对 `i` 的选择产生影响,那么这个推导就是无效的。 | ||
|
||
而回溯,由于每条分支判断是相互独立的,互不影响,所以即便前面的选择具有后效性,这个后效性也可以在这条选择线路持续影响下去,而不影响其他分支。 | ||
|
||
所以回溯是一种适用性更广的算法,但相对的,其代价(时间复杂度)也更高,所以只有当没有更优算法时,才应当考虑回溯算法。 | ||
|
||
## 精读 | ||
|
||
经过上述思考,回溯算法的实现思路就清晰了:递归或迭代。由于两者可以相互转换,而递归理解成本较低,因此我更倾向于递归方式解决问题。 | ||
|
||
这里必须提到一点,即工作与算法竞赛思维的区别:由于递归调用堆栈深度较大,整体性能不如迭代好,且迭代写法不如递归自然,所以做算法题时,为了提升那么一点儿性能,以及不经意间流露自己的实力,可能大家更倾向用迭代方式解决问题。 | ||
|
||
但工作中,大部分是性能不敏感场景,可维护性反而是更重要的,所以工程代码建议用更易理解的递归方式解决问题,把堆栈调用交给计算机去做。 | ||
|
||
其实算法代码追求更简短,能写成一行的绝不换行也是同样的道理,希望大家能在不同环境里自由切换习惯,而不要拘泥于一种风格。 | ||
|
||
用递归解决回溯的套路不止一种,我介绍一下自己常用的 TS 语言方法: | ||
|
||
```typescript | ||
function func(params: any[], results: any[] = []) { | ||
// 消耗 params 生成 currentResult | ||
const { currentResult, restParams } = doSomething(params); | ||
// 如果 params 还有剩余,则递归消耗,直到 params 耗尽为止 | ||
if (restParams.length > 0) func(restParams, results.concat(currentResult)); | ||
} | ||
``` | ||
|
||
这里 `params` 就类似迷宫后面的路线,而 `results` 记录了已走的最佳路线,当 `params` 路线消耗完了,就走出了迷宫,否则终止,让其它递归继续走。 | ||
|
||
所以回溯逻辑其实挺好写的,难在如何判断这道题应该用回溯做,以及如何优化算法复杂度。 | ||
|
||
先从两道入门题讲起,分别是电话号码的字母组合与复原 IP 地址。 | ||
|
||
### 电话号码的字母组合 | ||
|
||
电话号码的字母组合是一道中等题,题目如下: | ||
|
||
> 给定一个仅包含数字 `2-9` 的字符串,返回所有它能表示的字母组合。答案可以按 **任意顺序** 返回。 | ||
> | ||
> 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。 | ||
> | ||
> <img width=200 src="https://z3.ax1x.com/2021/06/26/R3L0wd.png"> | ||
电话号码数字对应的字母其实是个映射表,比如 `2` 映射 `a,b,c`,`3` 映射 `d,e,f`,那么 `2,3` 能表示的字母组合就有 `3x3=9` 种,而要打印出比如 `ad`、`ae` 这种组合,肯定要用穷举法,穷举法也是回溯的一种,只不过每一种可能性都要而已,而复杂点儿的回溯可能并不是每条路径都符合要求。 | ||
|
||
所以这道题就好做了,只要构造出所有可能的组合就行。 | ||
|
||
接下来我们看一道类似,但有一定分支合法判断的题目,复原 IP 地址。 | ||
|
||
### 复原 IP 地址 | ||
|
||
复原 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" 和 "[email protected]" 是 **无效 IP 地址**。 | ||
首先肯定一个一个字符读取,问题就在于,一个字符串可能表示多种可能的 IP,比如 `25525511135` 可以表示为 `255.255.11.135` 或 `255.255.111.35`,原因在于,`11.135` 和 `111.35` 都是合法的表示,所以我们必须用回溯法解决问题,只是回溯过程中,会根据读取数据动态判定增加哪些新分支,以及哪些分支是非法的。 | ||
|
||
比如读取到 `[1,1,1,3,5]` 时,由于 `11` 和 `111` 都是合法的,因为这个位置的数字只要在 `0~255` 之间即可,而 `1113` 超过这个范围,所以被忽略,所以从这个场景中分叉出两条路: | ||
|
||
- 当前项:`11`,余项 `135`。 | ||
- 当前项:`111`,余项 `35`。 | ||
|
||
之后再递归,直到非法情况终止,比如以及满了 4 项但还有剩余数字,或者不满足 IP 范围等。 | ||
|
||
可见,只要梳理清楚合法与非法的情况,直到如何动态生成新的递归判断,这道题就不难。 | ||
|
||
这道题输入很直白,直接给出来了,其实不是每道题的输入都这么容易想,我们看下一道全排列。 | ||
|
||
### 全排列 | ||
|
||
全排列是一道中等题,题目如下: | ||
|
||
> 给定一个不含重复数字的数组 `nums` ,返回其 **所有可能的全排列** 。你可以 **按任意顺序** 返回答案。 | ||
与还原 IP 地址类似,我们也是消耗给的输入,比如 `123`,我们可以先消耗 `1`,余下 `23` 继续组合。但与 IP 复原不同的是,第一个数字可以是 `1` `2` `3` 中的任意一个,所以其实在生成当前项时有所不同:当前项可以从所有余项里挑选,然后再递归即可。 | ||
|
||
比如 `123` 的第一次可以挑选 `1` 或 `2` 或 `3`,对于 `1` 的情况,还剩 `23`,那么下次可以挑选 `2` 或 `3`,当只剩一项时,就不用挑了。 | ||
|
||
全排列的输入虽然不如还原 IP 地址的输入直白,但好歹是基于给出的字符串推导而出的,那么再复杂点的题目,输入可能会拆解为多个,这需要你灵活思考,比如括号生成题目。 | ||
|
||
### 括号生成 | ||
|
||
括号生成是一道中等题,题目如下: | ||
|
||
> 数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 **有效的** 括号组合。 | ||
> | ||
> 示例: | ||
> 输入:`n = 3` | ||
> | ||
> 输出:["((()))","(()())","(())()","()(())","()()()"] | ||
这道题基本思路与上一题很像,而且由于题目问的是所有可能性,而不是最优解,所以无法用动规,所以我们考虑回溯算法。 | ||
|
||
上一道 IP 题目的输入是已知字符串,而这道题的输入就要你动动脑经了。这道题的输入是字符串吗?显然不是,因为输入是括号数量,那么只有一个括号数量就够了吗?不够,因为题目要求有效括号,那什么是有效括号?闭合的才是,所以我们想到用左右括号数量表示这个数字,即输入是 `n`,那么转化为 `open=n, close=n`。 | ||
|
||
有了输入,如何消耗输入呢?我们每一步都可以用一个左括号 `open` 或一个右括号 `close`,但第一个必须是 `open`,且当前已消耗 `close` 数量必须小于已消耗 `open` 数量时,才可以加上 `close`,因为一个 `close` 左边必须有个 `open` 形成合法闭合。 | ||
|
||
所以这道题就迎刃而解了。回顾来看,回溯的入参要能灵活思考,而这个思考取决于你的经验,比如遇到括号问题,下意识就直到拆解为左右括号。所以算法之间是相通的,适当的知识迁移可以事半功倍。 | ||
|
||
好了,在此我们先打住,其实不是所有题目都可以用回溯解决,但有些题目看上去只是回溯题目的变种,但其实不然。我们回到上一道全排列题,与之比较像的是 **下一个排列**,这道题看上去好像是基于全排列衍生的,但却无法用回溯算法解决,我们看看这道题。 | ||
|
||
### 下一个排列 | ||
|
||
下一个排列是一道中等题,题目如下: | ||
|
||
> 实现获取 **下一个排列** 的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。 | ||
> | ||
> 如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。 | ||
> | ||
> 必须 **原地** 修改,只允许使用额外常数空间。 | ||
比如: | ||
|
||
> 输入:nums = [1,2,3] | ||
> | ||
> 输出:[1,3,2] | ||
> 输入:nums = [3,2,1] | ||
> | ||
> 输出:[1,2,3] | ||
如果你在想,能否借鉴全排列的思想,在全排列过程中自然推导出下一个排列,那大概率是想不通的,因为从整体推导到局部的效率太低,这道题直接给出一个局部值,我们必须用相对 “局部的方法” 快速推导出下一个值,所以这道题无法用回溯算法解决。 | ||
|
||
对于 `3,2,1` 的例子,由于已经是最大排列了,所以下个排列只能是初始化的 `1,2,3` 升序,这个是特例。除此之外,都有下一个更大排列,以 `1,2,3` 为例,更大的是 `1,3,2` 而不是 `2,1,3`。 | ||
|
||
我们再观察长一点的例子,比如 `3,2,1,4,5,6`,可以发现,无论前面如何降序,只要最后几个是升序的,只要把最后两个扭转即可:`3,2,1,4,6,5`。 | ||
|
||
如果是 `3,2,1,4,5,6,9,8,7` 呢?显然 `9,8,7` 任意相邻交换都会让数字变得更小,不符合要求,我们还是要交换 `5,6` .. 不 `6,9`,因为 `65x` 比 `596` 要大更多。到这里我们得到几个规律: | ||
|
||
1. 尽可能交换后面的数。交换 `5,6` 会比交换 `6,9` 更大,因为 `6,9` 更靠后,位数更小。 | ||
2. 我们将 `3,2,1,4,5,6,9,8,7` 分为两段,分别是前段 `3,2,1,4,5,6` 和后段 `9,8,7`,我们要让前段尽可能大的数和后段尽可能小的数交换,同时还要保证,后段尽可能小的数比前段尽可能大的数还要 **大**。 | ||
|
||
为了满足第二点,我们必须从后向前查找,如果是升序就跳过,直到找到一个数字 `j` 比 `j-1` 小,那么前段作为交换的就是第 `j` 项,后段要找一个最小的数与之交换,由于搜索的算法导致后段一定是降序的,因此从后向前找到第一个比 `j` 大的项交换即可。 | ||
|
||
最后我们发现,交换后也不一定是完美下一项,因为后段是降序的,而我们已经把前面一个尽可能最小的 “大” 位改大了,后面一定要升序才满足下一个排列,因此要把后段进行升序排列。 | ||
|
||
因为后段已经满足降序了,因此采用双指针交换法相互对调即可变成升序,这一步千万不要用快排,会导致整体时间复杂度提高 O(nlogn)。 | ||
|
||
最后由于只扫描了一次 + 反转后段一次,所以算法复杂度是 O(n)。 | ||
|
||
从这道题可以发现,不要轻视看似变种的题目,从全排列到下一个排列,可能要完全换一个思路,而不是对回溯进行优化。 | ||
|
||
我们继续回到回溯问题,回溯最经典的问题就是 N 皇后,也是难度最大的题目,与之类似的还有解决数独问题,不过都类似,我们这次还是以 N 皇后作为代表来理解。 | ||
|
||
### N 皇后问题 | ||
|
||
N 皇后问题是一道困难题,题目如下: | ||
|
||
> n 皇后问题 研究的是如何将 `n` 个皇后放置在 `n×n` 的棋盘上,并且使皇后彼此之间不能相互攻击。 | ||
> | ||
> 给你一个整数 `n` ,返回所有不同的 `n` 皇后问题 的解决方案。 | ||
> | ||
> 每一种解法包含一个不同的 `n` 皇后问题 的棋子放置方案,该方案中 `'Q'` 和 `'.'` 分别代表了皇后和空位。 | ||
皇后的攻击范围非常广,包括横、纵、斜,所以当 `n<4` 时是无解的,而神奇的时,`n>=4` 时都有解,比如下面两个图: | ||
|
||
<img width=400 src="https://z3.ax1x.com/2021/06/26/R8CtUS.png"> | ||
|
||
这道题显然具有 “强烈的” 后效性,因为皇后攻击范围是由其位置决定的,换而言之,一个皇后位置确定后,其他皇后的可能摆放位置会发生变化,因此只能用回溯算法。 | ||
|
||
那么如何识别合法与非法位置呢?核心就是根据横、纵、斜三种攻击方式,建立四个数组,分别存储哪些行、列、撇、捺位置是不能放置的,然后将所有合法位置都作为下一次递归的可能位置,直到皇后放完,或者无位置可放为止。 | ||
|
||
容易想到的就是四个数组,分别存储被占用的下标,这样的话,只是递归中条件判断分支复杂一些,其它其实并无难度。 | ||
|
||
这道题的空间复杂度进阶算法是,利用二进制方式,使用 **4 个数字** 代替四个下标数组,每个数组转化为二进制时,1 的位置代表被占用,0 的位置代表未占用,通过位运算,可以更快速、低成本的进行位置占用,与判断当前位置是否被占用。 | ||
|
||
这里只提一个例子,就可以感受到二进制魅力: | ||
|
||
由于按照行看,一行只能放一个皇后,所以每次都从下一行看起,因此行限制就不用看了(至少下一行不可能和前面的行冲突),所以我们只要记录列、撇、捺三个位置即可。 | ||
|
||
不同之处在于,我们采用二进制的数字,只要三个数字即可表示列、撇、捺。二进制位中的 1 表示被占用,0 表示不被占用。 | ||
|
||
比如列、撇、捺分别是变量 `x,y,z`,对应二进制可能是: | ||
|
||
- `0000001` | ||
- `0010000` | ||
- `0001100` | ||
|
||
“非” 逻辑是任意为 1 就是 1,因此 “非” 逻辑可以将所有 1 合并,即 `x | y | z` 即 `0011101`。 | ||
|
||
然后将这个结果取反,用非逻辑,即 `~(x | y | z)`,结果是 `1100010`,那这里所有的 `1` 就表示可放的位置,我们记这个变量为 `p`,通过 `p & -p` 不断拿最后一位 `1` 得到安放位置,即可调用递归了。 | ||
|
||
从这道题可以发现,N 皇后难度不在于回溯算法,而在于如何利用二进制写出高效的回溯算法。所以回溯算法考察的比较综合,因为算法本身很模式化,而且相对比较 “笨拙”,所以需要将更多重心放在优化效率上。 | ||
|
||
## 总结 | ||
|
||
回溯算法本质上是利用计算机高速计算能力,将所有可能都尝试一遍,唯一区别是相对暴力解法,可能在某个分支提前终止(枝剪),所以其实是一个较为笨重的算法,当题目确实具有后效性,且无法用贪心或者类似下一排列这种巧妙解法时,才应该采用。 | ||
|
||
最后我们要总结对比一下回溯与动态规划算法,其实动态规划算法的暴力递归过程就与回溯相当,只是动态规划可以利用缓存,存储之前的结果,避免重复子问题的重复计算,而回溯因为面临的问题具有后效性,不存在重复子问题,所以无法利用缓存加速,所以回溯算法高复杂度是无法避免的。 | ||
|
||
回溯算法被称为 “通用解题方法”,因为可以解决许多大规模计算问题,是利用计算机运算能力的很好实践。 | ||
|
||
> 讨论地址是:[精读《算法 - 回溯》· Issue #331 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/331) | ||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** | ||
|
||
> 关注 **前端精读微信公众号** | ||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> | ||
|
||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |