Compare commits

..
5 Commits
Author SHA1 Message Date
ascoders c72477ed7d update readme 2021-06-28 10:46:52 +08:00
ascoders dc09550e0c Merge branch 'master' of https://github.com/ascoders/weekly 2021-06-28 10:46:20 +08:00
ascoders dc66e76dd1 200 2021-06-28 10:46:14 +08:00
黄子毅 cecef87d13 Merge pull request #329 from Turkyden/patch-1
Update 140.精读《结合 React 使用原生 Drag Drop API》.md
2021-06-18 11:11:39 +08:00
Dengju Deng d0ca3a348f Update 140.精读《结合 React 使用原生 Drag Drop API》.md 2021-06-17 18:01:49 +08:00
3 changed files with 216 additions and 2 deletions
+2 -1
View File
@@ -6,7 +6,7 @@
前端界的好文精读,每周更新!
最新精读:<a href="./算法/199.精读《算法 - 滑动窗口》.md">199.精读《算法 - 滑动窗口》</a>
最新精读:<a href="./算法/200.精读《算法 - 回溯》.md">200.精读《算法 - 回溯》</a>
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
@@ -233,6 +233,7 @@
- <a href="./算法/198.精读《算法 - 动态规划》.md">198.精读《算法 - 动态规划》</a>
- <a href="./算法/199.精读《算法 - 滑动窗口》.md">199.精读《算法 - 滑动窗口》</a>
- <a href="./算法/200.精读《算法 - 回溯》.md">200.精读《算法 - 回溯》</a>
## 关注前端精读微信公众号
@@ -180,7 +180,7 @@ const dragProps = {
```jsx
const dropProps = {
onDragOver: ev => {
// 做一些样式处理,提示用户此时松手会将元素防止在何处
// 做一些样式处理,提示用户此时松手会将元素放置在何处
},
onDrop: ev => {
ev.stopPropagation()
+213
View File
@@ -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" 和 "192.168@1.1" 是 **无效 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)