Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaaa28953b | ||
|
|
9609101870 | ||
|
|
afb601a303 | ||
|
|
c72477ed7d | ||
|
|
dc09550e0c | ||
|
|
dc66e76dd1 | ||
|
|
cecef87d13 | ||
|
|
d0ca3a348f | ||
|
|
de4e309f39 | ||
|
|
ea56ac10ce | ||
|
|
90660c6d34 | ||
|
|
fcf04083a5 | ||
|
|
a1ea12a736 | ||
|
|
02a795bb8a | ||
|
|
6bffed8eb5 | ||
|
|
ddd5ceb74a | ||
|
|
3c6cee2f49 | ||
|
|
fc2f0c6f68 | ||
|
|
0dcf208315 | ||
|
|
7730834e83 | ||
|
|
91875ab232 |
@@ -5,7 +5,7 @@
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const dirs = ['前沿技术', '设计模式', '编译原理', '源码解读', '商业思考']
|
||||
const dirs = ['前沿技术', '设计模式', '编译原理', '源码解读', '商业思考', '算法']
|
||||
|
||||
dirs.forEach(dir => {
|
||||
const readDir = fs.readdirSync(`./${dir}`);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
前端界的好文精读,每周更新!
|
||||
|
||||
最新精读:<a href="./前沿技术/197.精读《低代码逻辑编排》.md">197.精读《低代码逻辑编排》</a>
|
||||
最新精读:<a href="./算法/201.精读《算法 - 二叉树》.md">201.精读《算法 - 二叉树》</a>
|
||||
|
||||
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
|
||||
|
||||
@@ -229,6 +229,13 @@
|
||||
- <a href="./商业思考/136.精读《极客公园 IFX - 下》.md">136.精读《极客公园 IFX - 下》</a>
|
||||
- <a href="./商业思考/137.精读《当我在分享的时候,我在做什么?》.md">137.精读《当我在分享的时候,我在做什么?》</a>
|
||||
|
||||
### 算法
|
||||
|
||||
- <a href="./算法/198.精读《算法 - 动态规划》.md">198.精读《算法 - 动态规划》</a>
|
||||
- <a href="./算法/199.精读《算法 - 滑动窗口》.md">199.精读《算法 - 滑动窗口》</a>
|
||||
- <a href="./算法/200.精读《算法 - 回溯》.md">200.精读《算法 - 回溯》</a>
|
||||
- <a href="./算法/201.精读《算法 - 二叉树》.md">201.精读《算法 - 二叉树》</a>
|
||||
|
||||
## 关注前端精读微信公众号
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
@@ -180,7 +180,7 @@ const dragProps = {
|
||||
```jsx
|
||||
const dropProps = {
|
||||
onDragOver: ev => {
|
||||
// 做一些样式处理,提示用户此时松手会将元素防止在何处
|
||||
// 做一些样式处理,提示用户此时松手会将元素放置在何处
|
||||
},
|
||||
onDrop: ev => {
|
||||
ev.stopPropagation()
|
||||
|
||||
@@ -31,14 +31,15 @@ Atomic Design 书中提到模块化思路以及原子级的模块抽象的方法
|
||||
体验策略的核心思路是以任务为导向的。主要通过四个方面去构建体验策略:流程与方法、度量体系、运营活动和最佳实践。
|
||||
|
||||
## 精读
|
||||
整个分享感受颇深,但就『自然』这个关键词才生了我自己的疑问。主字号、字阶和行高是否存在关系
|
||||
整个分享感受颇深,但就『自然』这个关键词才生了我自己的疑问:主字号、字阶和行高是否存在关系?
|
||||
|
||||
在梳理的这层关系上,缺少了对字体的讨论,而字体又是非常关键的因素。我在之后,断断续续查阅了不少资料。写下关系背后还要考虑的问题。
|
||||
|
||||
1. 字体
|
||||
我在查阅资料的时候,发现 x-height 在西文字体中的概念。在英文字体的设计中,字体的高度体包含三部份,以基线 (baseline) 为中央,以上称之上行区域 (ascender area),基准线内称之为 x-height,以下称为下行区域 (descender area)。小写西文字母中的核心部件都位于 x-height 位置中,这一位置也被称为排版的核心位置,是引导视线流动的关键。放一张在 wikipedie 上的图:
|
||||
|
||||
[image:CCC0E19B-0E90-4989-BD91-A2B33D38E8CD-6272-000031C10BD416C0/820px-Typography_Line_Terms.svg.png]
|
||||
我在查阅资料的时候,发现 x-height 在西文字体中的概念。在英文字体的设计中,字体的高度体包含三部份,以基线 (baseline) 为中央,以上称之上行区域 (ascender area),基准线内称之为 x-height,以下称为下行区域 (descender area)。小写西文字母中的核心部件都位于 x-height 位置中,这一位置也被称为排版的核心位置,是引导视线流动的关键。放一张在 wikimedia 上的图:
|
||||
|
||||

|
||||
|
||||
每一种西文字体的 x-height 是不一样的。非常幸运,Jukka Korpela 做了一网站专门可以测量 web 上字体的 x-height。其中,Arial 的 X-HEIGHT RATIO 是 0.519,而 Tahoma 是 0.545,Times New Roman 是 0.448。Arial 和 Times New Roman 之间的比例差距大概 17%。
|
||||
|
||||
@@ -47,6 +48,7 @@ Atomic Design 书中提到模块化思路以及原子级的模块抽象的方法
|
||||
这是第一个问题,第二个问题是中文字体没有 x-height,也就是说中文字体就等同于西文字体的全大写,错落的美感都没有。而且中文有一个问题是因为字形之前的差异,每个字之间的留白都不尽相同,看上去又会差一些。一般情况下,靠行间距来弥补视觉差,但总体上要排版达到西文字体的效果要花一些功夫。
|
||||
|
||||
2. 屏幕
|
||||
|
||||
我们的字体大小使用的是 points(pt),points 是一个物理衡量,它的标准是 72 points per inch(PPI)。但我们不同设备的 PPI 都是不一样的,那么造成了同样的设定在不同屏幕下看到的字体也会有差异。
|
||||
|
||||
Macbook Pro 的 PPI 是 220,Dell XPS 的 PPI 是 165,iPhone 7 有 326,但 iPhone 7p 的 PPI 有 401,而一般 HDTV 的 PPI 是 30。其中,iPhone,Macbook 都是 retina 屏。
|
||||
@@ -64,4 +66,4 @@ Macbook Pro 的 PPI 是 220,Dell XPS 的 PPI 是 165,iPhone 7 有 326,但
|
||||
## 总结
|
||||
曾经有国外的设计师有写文用黄金比例来构建字号与行高的关系,在一片喝彩中看到了资深设计师的反对,主要也是从以上和一些其它因素来说关系是比较难设定。
|
||||
|
||||
今天看到我们的设计与理性之间建立的关系,我还是比较坚信建立这种关系背后带来的是更大的价值。
|
||||
今天看到我们的设计与理性之间建立的关系,我还是比较坚信建立这种关系背后带来的是更大的价值。
|
||||
|
||||
@@ -5,14 +5,14 @@ ES 模块为 JavaScript 开发者带来了官方并且标准化的模块系统
|
||||
## 1. 引言
|
||||
|
||||
精读文章主要讨论了下面几点:
|
||||
- 模块旨在解决那些问题;
|
||||
- 模块旨在解决哪些问题;
|
||||
- 模块为开发者带来哪些;
|
||||
- ES 模块化的工作机制;
|
||||
- ES 模块化的现状;
|
||||
|
||||
## 2. 内容概要
|
||||
|
||||
### 模块旨在解决那些问题
|
||||
### 模块旨在解决哪些问题
|
||||
|
||||
JavaScript 开发可以简单地抽象成维护变量,赋值和计算操作。大量的代码在用于操作变量,开发者需要懂得如何去组织和维护这些变量。JavaScript 提供了一种方式,即函数作用域。在一个函数内只需要考虑这个函数的变量问题。不必去担心其他函数会操作这些变量。当然,随之带来的问题是,变量无法共享,无法在不同的函数之间相互共享变量。如果想要在作用域外共享变量,只能通过外层作用域,或者全局作用域。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
大家都知道移动端即时通讯是一个唯一寡头市场,因此当米聊看到微信开始反超的时候,就已经知道这场战争已经结束。当时小米重点业务还在手机,米聊是团队试水的一款产品,但看到歪打误撞进入一个如此蓝海的市场,小米自己也很纠结要不要把资源都投入到米聊上。
|
||||
|
||||
反观微信,当时手机 QQ 也在做,本来怎么也轮不到微信出场,但张小龙、马化腾、张志东在微信简历了深夜小组,每天晚上都即时同步微信的进展,这让微信即时获取到了腾讯内部资源,在各种关键节点帮了很多忙,甚至让手机 QQ 技术大牛直接支持微信改善高并发问题,快速完成 QQ 好友导入功能。
|
||||
反观微信,当时手机 QQ 也在做,本来怎么也轮不到微信出场,但张小龙、马化腾、张志东在微信建立了深夜小组,每天晚上都即时同步微信的进展,这让微信即时获取到了腾讯内部资源,在各种关键节点帮了很多忙,甚至让手机 QQ 技术大牛直接支持微信改善高并发问题,快速完成 QQ 好友导入功能。
|
||||
|
||||
雷军总结到 “如果腾讯一年后才有所反应,米聊胜率是 50%,如果是腾讯两三个月就有反应,米聊应该 100% 会死掉”。
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
前十年,手机设备制造厂商的格局发生了很大变化。国内经历了从小米,到 OPPO、VIVO,再到华为的演化。
|
||||
|
||||
印象深刻的是看了一个雷军创办小米前夕的访谈视频,雷军说 “大家看到苹果的成功,却没有看到这片蓝海的机会,现在手机制造领域竞争太不激烈了”。同时为了对抗苹果,谷歌开源了安卓源代码,小米利用这个机会打造一款符合中国人口味的手机操作系统,并借助用户社区与性价比优势一举占领了早起市场。
|
||||
印象深刻的是看了一个雷军创办小米前夕的访谈视频,雷军说 “大家看到苹果的成功,却没有看到这片蓝海的机会,现在手机制造领域竞争太不激烈了”。同时为了对抗苹果,谷歌开源了安卓源代码,小米利用这个机会打造一款符合中国人口味的手机操作系统,并借助用户社区与性价比优势一举占领了早期市场。
|
||||
|
||||
2015-2018 年出现了 OV 领跑的情况,即 OPPO、VIVO 后来居上,有两点原因:小米还在强调各项参数指标,但 OV 宣传的概念很易懂 “充电五分钟,通话两小时”;同时 OV 还注意到了下沉市场,通过各种综艺节目冠名与 **平均 25 万家线下门店布局**,超越了小米。
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
切入点是 **融资**。BAT 上市融资额度分别是:百度:1.112 亿美元、**阿里巴巴 69.88 亿美元**、腾讯 0.2188 亿美元,总额 71.2 亿美元。**而滴滴到目前为止的融资已经达到 208 亿美元,** 滴滴融资超过 BAT 总和,这说明了什么?这说明滴滴走了一条不正常的商业路线,即先疯狂再冷静的烧钱路线。
|
||||
|
||||
当一个行业增长速度极速增加时,老玩家将失去优势和壁垒,所以谁能更快扩张谁就能成为最终赢家,此时如果有大量资本投入快速占领市场,让企业成为这个领域的绝对霸主,投资者就可以通过上市退出的方式把之前烧的前赚回来。然而这种烧钱商业模式是有前提的,即 **极度充裕的资本 + 清晰的结构性机会**,滴滴的结构性机会非常清晰,先垄断再收割。
|
||||
当一个行业增长速度极速增加时,老玩家将失去优势和壁垒,所以谁能更快扩张谁就能成为最终赢家,此时如果有大量资本投入快速占领市场,让企业成为这个领域的绝对霸主,投资者就可以通过上市退出的方式把之前烧的钱赚回来。然而这种烧钱商业模式是有前提的,即 **极度充裕的资本 + 清晰的结构性机会**,滴滴的结构性机会非常清晰,先垄断再收割。
|
||||
|
||||
> 传统商业模式:融资 -> 赚钱。
|
||||
>
|
||||
@@ -128,7 +128,7 @@ Uber 创始人 特拉维斯·卡兰尼克 说了一句很经典的话,翻译
|
||||
|
||||
如果资本不充裕了,对创业者来说也还有机会,比如相应的会带来低人力成本与低广告投放成本。
|
||||
|
||||
最后,周航宣传了一个创业孵化项目,即投资人与创业者深度交流几个月,在这几个月内让创业者得到成长,让投资人能看清创业者是否具备潜力,这种投资者与创业者培养感情的孵化方式是比较新颖的,相对面试来说,有更多机会呆在一起可以看人看得更清楚,投资者与创业者更容易简历信任关系。
|
||||
最后,周航宣传了一个创业孵化项目,即投资人与创业者深度交流几个月,在这几个月内让创业者得到成长,让投资人能看清创业者是否具备潜力,这种投资者与创业者培养感情的孵化方式是比较新颖的,相对面试来说,有更多机会呆在一起可以看人看得更清楚,投资者与创业者更容易建立信任关系。
|
||||
|
||||
### 语言 AI 的未来构想
|
||||
|
||||
@@ -247,7 +247,7 @@ Uber 创始人 特拉维斯·卡兰尼克 说了一句很经典的话,翻译
|
||||
1. 食材可见:比如大块杏仁碎、大块黄桃粒等。
|
||||
2. 口味丰富:芝士、椰子、巧克力、曲奇。
|
||||
|
||||
我以为朋友当场就订购了几箱,说实话还是蛮有诱惑力的,产品叫 ffit8,可以天猫自行搜索。
|
||||
我一位朋友当场就订购了几箱,说实话还是蛮有诱惑力的,产品叫 ffit8,可以天猫自行搜索。
|
||||
|
||||
极客大会每个人都送了几袋,尝了一下还是蛮好吃的,有甜味,但为什么说无糖呢,查了一下原因,原来用的是低聚异麦芽糖,这种麦芽糖难以被吸收,所以也就可以认为是无糖的啦。
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ VIPKID 起步是依靠朋友圈传播,但随着项目的起量,需要通过
|
||||
|
||||
一加手机做的是高端手机,操作系统主打的是简洁,不会有任何广告,盈利方式则是其较高的定价。而相比手机大厂,一加手机的突破点在于集中力量做旗舰手机,通过集中投入研发资源达到单点突破。
|
||||
|
||||
最近一加也在做电视了,目的是为了占领客厅市场,可能因为手机买的比较火,资金链比较充裕所以做了更大的布局。
|
||||
最近一加也在做电视了,目的是为了占领客厅市场,可能因为手机卖的比较火,资金链比较充裕所以做了更大的布局。
|
||||
|
||||
### 解题 - 社区零售新物种的进化之道
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
上面是最基本的写作技巧,我就不继续展开了,接下来要重点聊聊的是前端精读是怎么做分享的。我会从如何写作、如何坚持、如何形成正循环三个方面谈谈自己的感受。
|
||||
|
||||
首先是写作方式,前端精读的命题很明确,就是基于某个文章或者观点进行精读,因此每篇文章都有一个明确的主题。第二步是摘要,讲文章内容精简的表达出来,这可以锻炼你的总结能力,也让读者能了解到背景知识。第三步是精读,这一步需要你有一些私藏干货,毕竟把文章直接翻译一遍是没有任何价值的,我在精读自己不熟悉领域的文章时经常遇到这个问题,此时我一般会找几篇类似的文章结合阅读,并找到一些可以互补的观点,这样的精读可以让文章的观点更加饱满。最后是总结,总结时可以点题,将重要内容再梳理一遍,也可以进行延伸,指出更进一步的思考方向。
|
||||
首先是写作方式,前端精读的命题很明确,就是基于某个文章或者观点进行精读,因此每篇文章都有一个明确的主题。第二步是摘要,将文章内容精简的表达出来,这可以锻炼你的总结能力,也让读者能了解到背景知识。第三步是精读,这一步需要你有一些私藏干货,毕竟把文章直接翻译一遍是没有任何价值的,我在精读自己不熟悉领域的文章时经常遇到这个问题,此时我一般会找几篇类似的文章结合阅读,并找到一些可以互补的观点,这样的精读可以让文章的观点更加饱满。最后是总结,总结时可以点题,将重要内容再梳理一遍,也可以进行延伸,指出更进一步的思考方向。
|
||||
|
||||
为了让分享坚持下来,我在每周结束之前都会提前立好下周精读的 Flag,在 Github 开一个 issue,这样不仅可以提醒我周末的写作,还可以收获很多来自社区的讨论与反馈,让文章聚集了社区的智慧。这种提前立 Flag 的做法让我想到了自家小区物业费的收取方式,每年年初都会提前征收一整年的物业费,抛开商业手法不谈,这至少意味着物业对业务整整一年的承诺,这种承诺支撑了物业后续一整年的服务,也支撑了每周下一次的精读文章。
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
很多人觉得动态规划很难,甚至认为面试出动态规划题目是在为难候选人,这可能产生一个错误潜意识:认为动态规划不需要掌握。
|
||||
|
||||
其实动态规划非常有必要掌握:
|
||||
|
||||
1. 非常锻炼思维。动态规划是非常锻炼脑力的题目,虽然有套路,但每道题解法思路差异很大,作为思维练习非常合适。
|
||||
2. 非常实用。动态规划听起来很高级,但实际上思路和解决的问题都很常见。
|
||||
|
||||
动态规划用来解决一定条件下的最优解,比如:
|
||||
|
||||
- 自动寻路哪种走法最优?
|
||||
- 背包装哪些物品空间利用率最大?
|
||||
- 怎么用最少的硬币凑零钱?
|
||||
|
||||
其实这些问题乍一看都挺难的,毕竟都不是一眼能看出答案的问题。但得到最优解又非常重要,谁能忍受游戏中寻路算法绕路呢?谁不希望背包放的东西更多呢?所以我们一定要学好动态规划。
|
||||
|
||||
## 精读
|
||||
|
||||
动态规划不是魔法,它也是通过暴力方法尝试答案,只是方式更加 “聪明”,使得实际上时间复杂度并不高。
|
||||
|
||||
### 动态规划与暴力、回溯算法的区别
|
||||
|
||||
上面这句话也说明了,所有动态规划问题都能通过暴力方法解决!是的,所有最优解问题都可以通过暴力方法尝试(以及回溯算法),最终找出最优的那个。
|
||||
|
||||
暴力算法几乎可以解决一切问题。回溯算法的特点是,通过暴力尝试不同分支,最终选择结果最优的线路。
|
||||
|
||||
而动态规划也有分支概念,但不用把每条分支尝试到终点,而是在走到分叉路口时,可以直接根据前面各分支的表现,直接推导出下一步的最优解!然而无论是直接推导,还是前面各分支判断,都是有条件的。动态规划可解问题需同时满足以下三个特点:
|
||||
|
||||
1. 存在最优子结构。
|
||||
2. 存在重复子问题。
|
||||
3. 无后效性。
|
||||
|
||||
### 存在最优子结构
|
||||
|
||||
即子问题的最优解可以推导出全局最优解。
|
||||
|
||||
什么是子问题?比如寻路算法中,走完前几步就是相对于走完全程的子问题,必须保证走完全程的最短路径可以通过走完前几步推导出来,才可以用动态规划。
|
||||
|
||||
不要小看这第一条,动态规划就难在这里,你到底如何将最优子结构与全局最优解建立上关系?
|
||||
|
||||
- 对于爬楼梯问题,由于每层台阶都是由前面台阶爬上来的,因此必然存在一个线性关系推导。
|
||||
- 如果变成二维平面寻路呢?那么就升级为二维问题,存在两个变量 `i,j` 与上一步之间关系了。
|
||||
- 如果是背包问题,同时存在物品数量 `i`、物品重量 `j` 和物品质量 `k` 三个变量呢?那就升级为三位问题,需要寻找三个之间的关系。
|
||||
|
||||
依此类推,复杂度可以上升到 N 维,维度越高思考的复杂度就越高,空间复杂度就越需要优化。
|
||||
|
||||
### 存在重复子问题
|
||||
|
||||
即同一个子问题在不同场景下存在重复计算。
|
||||
|
||||
比如寻路算法中,同样两条路线的计算中,有一段路线是公共的,是计算的必经之路,那么只算一次就好了,当计算下一条路时,遇到这个子路,直接拿第一次计算的缓存即可。典型例子是斐波那契数列,对于 `f(3)` 与 `f(4)`,都要计算 `f(1)` 与 `f(2)`,因为 `f(3) = f(2) + f(1)`,而 `f(4) = f(3) + f(2) = f(2) + f(1) + f(2)`。
|
||||
|
||||
这个是动态规划与暴力解法的关键区别,动态规划之所以性能高,是因为 **不会对重复子问题进行重复计算**,算法上一般通过缓存计算结果或者自底向上迭代的方式解决,但核心是这个场景要存在重复子问题。
|
||||
|
||||
当你觉得暴力解法可能很傻,存在大量重复计算时,就要想想是哪里存在重复子问题,是否可以用动态规划解决了。
|
||||
|
||||
### 无后效性
|
||||
|
||||
即前面的选择不会影响后面的游戏规则。
|
||||
|
||||
寻路算法中,不会因为前面走了 B 路线而对后面路线产生影响。斐波那契数列因为第 N 项与前面的项是确定关联,没有选择一说,所以也不存在后效性问题。
|
||||
|
||||
什么场景存在后效性呢?比如你的人生是否能通过动态规划求最优解?其实是不行的,因为你今天的选择可能影响未来人生轨迹,比如你选择了计算机这个职业,会直接影响到工作的领域,接触到的人,后面的人生路线因此就完全变了,所以根本无法与选择了土木工程的你进行比较,因为人生赛道都变了。
|
||||
|
||||
有同学可能觉得这样局限是不是很大?其实不然,无后效性的问题仍然很多,比如背包放哪件物品、当前走哪条路线、用了哪些零钱,都不会影响整个背包大小、整张地图的地形、以及你最重要付款的金额。
|
||||
|
||||
### 解法套路 - 状态转移方程
|
||||
|
||||
解决动态规划问题的核心就是写出状态转移方程,所谓状态转移,即通过某些之前步骤推导出未来步骤。
|
||||
|
||||
状态转移方程一般写为 `dp(i) = 一系列 dp(j) 的计算`,其中 `j < i`。
|
||||
|
||||
其中 `i` 与 `dp(i)` 的含义很重要,一般 `dp(i)` 直接代表题目的答案,`i` 就有技巧了。比如斐波那契数列,`dp(i)` 表示的答案就是最终结果,`i` 表示下标,由于斐波那契数列直接把状态转移方程告诉你了 `f(x) = f(x-1) + f(x-2)`,那么根本连推导都不必了。
|
||||
|
||||
**对于复杂问题,难在如何定义 `i` 的含义,以及下一步状态如何通过之前状态推导。** 这个做多了题目就有体会,如果没有,那即便再如何解释也难以说明,所以后面还是直接看例子吧。
|
||||
|
||||
先举一个最简单的动态规划例子 - 爬楼梯来说明问题。
|
||||
|
||||
### 爬楼梯问题
|
||||
|
||||
爬楼梯是一道简单题,题目如下:
|
||||
|
||||
> 假设你正在爬楼梯。需要 `n` 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?(给定 `n` 是一个正整数)
|
||||
|
||||
首先 `dp(i)` 就是问题的答案(解法套路,`dp(i)` 大部分情况就是答案,这样解题思路会最简化),即爬到第 `i` 阶台阶的方法数量,那么 `i` 自然就是要爬到第几阶台阶。
|
||||
|
||||
我们首先看是否存在 **最优子结构**?因为只能往上爬,所以第 `i` 阶台阶有几种爬方完全取决于前面有几种爬方,**而一次只能爬 1 或 2 个台阶,所以第 `i` 阶台阶只可能从第 `i-1` 或 `i-2` 个台阶爬上来的**,所以第 `i` 个台阶的爬法就是 `i-1` 与 `i-2` 总爬法之和。所以显然有最优子结构,连状态转移方程都呼之欲出了。
|
||||
|
||||
再看是否存在 **存在重复子问题**,其实爬楼梯和斐波那契数列类似,最终的状态转移方程是一样的,所以显然存在重复子问题。当然直观来看也容易分析出,10 阶台阶的爬法包含了 8、9 阶的爬法,而 9 阶台阶爬法包含了 8 阶的,所以存在重复子问题。
|
||||
|
||||
最后看是否 **无后效性**?由于前面选择一次爬 1 个或 2 个台阶并不会影响总台阶数,也不会影响你下一次能爬的台阶数,所以无后效性。如果你爬了 2 个台阶,因为太累,下次只能爬 1 个台阶,就属于有后效性了。或者只要你一共爬了 3 次 2 阶,就会因为太累而放弃爬楼梯,直接下楼休息,那么问题提前结束,也属于有后效性。
|
||||
|
||||
所以爬楼梯的状态转移方程为:
|
||||
|
||||
- `dp(i) = dp(i-1) + dp(i-2)`
|
||||
- `dp(1) = 1`
|
||||
- `dp(2) = 2`
|
||||
|
||||
注意,因为 1、2 阶台阶无法应用通用状态转移方程,所以要特殊枚举。这种枚举思路在代码里其实就是 **递归终结条件**,也就是作为函数 `dp(i)` 不能无限递归,当 `i` 取值为 1 或 2 时直接返回枚举结果(对这道题而言)。所以在写递归时,一定要优先写上递归终结条件。
|
||||
|
||||
然后我们考虑,对于第一阶台阶,只有一种爬法,这个没有争议吧。对于第二阶台阶,可以直接两步跨上来,也可以走两个一步,所以有两种爬法,也很容易理解,到这里此题得解。
|
||||
|
||||
关于代码部分,仅这道题写一下,后面的题目如无特殊原因就不写代码了:
|
||||
|
||||
```typescript
|
||||
function dp(i: number) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
return 1;
|
||||
case 2:
|
||||
return 2;
|
||||
default:
|
||||
return dp(i - 1) + dp(i - 2);
|
||||
}
|
||||
}
|
||||
|
||||
return dp(n);
|
||||
```
|
||||
|
||||
当然这样写重复计算了子结构,所以我们不要每次傻傻的执行 `dp(i - 1)`(因为这样计算了超多重复子问题),我们需要用缓存兜底:
|
||||
|
||||
```typescript
|
||||
const cache: number[] = [];
|
||||
|
||||
function dp(i: number) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
cache[i] = 1;
|
||||
break;
|
||||
case 2:
|
||||
cache[i] = 2;
|
||||
break;
|
||||
default:
|
||||
cache[i] = cache[i - 1] + cache[i - 2];
|
||||
}
|
||||
|
||||
return cache[i];
|
||||
}
|
||||
|
||||
// 既然用了缓存,最好子底向上递归,这样前面的缓存才能优先算出来
|
||||
for (let i = 1; i <= n; i++) {
|
||||
dp(i);
|
||||
}
|
||||
|
||||
return cache[n];
|
||||
```
|
||||
|
||||
当然这只是简单的一维线性缓存,更高级的缓存模式还有 **滚动缓存**。我们观察发现,这道题缓存空间开销是 `O(n)`,但每次缓存只用了上两次的值,所以计算到 `dp(4)` 时,`cache[1]` 就可以扔掉了,或者说,我们可以滚动利用缓存,让 `cache[3]` 占用 `cache[1]` 的空间,那么整体空间复杂度可以降低到 `O(1)`,具体做法是:
|
||||
|
||||
```typescript
|
||||
const cache: [number, number] = [];
|
||||
|
||||
function dp(i: number) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
cache[i % 2] = 1;
|
||||
break;
|
||||
case 2:
|
||||
cache[i % 2] = 2;
|
||||
break;
|
||||
default:
|
||||
cache[i % 2] = cache[(i - 1) % 2] + cache[(i - 2) % 2];
|
||||
}
|
||||
|
||||
return cache[i % 2];
|
||||
}
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
dp(i);
|
||||
}
|
||||
|
||||
return cache[n % 2];
|
||||
```
|
||||
|
||||
通过取余,巧妙的让缓存永远交替占用 `cache[0]` 与 `cache[1]`,达到空间利用最大化。当然,这道题因为状态转移方程是连续用了前两个,所以可以这么优化,如果遇到用到之前所有缓存的状态转移方程,就无法使用滚动缓存方案了。然而还有更高级的多维缓存,这个后面提到的时候再说。
|
||||
|
||||
接下来看一个进阶题目,最大子序和。
|
||||
|
||||
### 最大子序和
|
||||
|
||||
最大子序和是一道简单题,题目如下:
|
||||
|
||||
> 给定一个整数数组 `nums` ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
|
||||
|
||||
首先按照爬楼梯的套路,`dp(i)` 就表示最大和,由于整数数组可能存在负数,所以越多数相加,和不一定越大。
|
||||
|
||||
接着看 `i`,对于数组问题,大部分 `i` 都可以代表以第 `i` 位结尾的字符串,那么 `dp(i)` 就表示以第 `i` 位结尾的字符串的最大和。
|
||||
|
||||
可能你觉得以 `i` 结尾,就只能是 `[0-i]` 范围的值,那么 `[j-i]` 范围的字符串不就被忽略了?其实不然,`[j-i]` 如果是最大和,也会被包含在 `dp(i)` 里,因为我们状态转移方程可以选择不连上 `dp(i-1)`。
|
||||
|
||||
现在开始解题:首先题目是最大和的连续子数组,一般连续的都比较简单,因为对于 `dp(i)`,要么和前面连上,要么和前面断掉,所以状态转移方程为:
|
||||
|
||||
- `dp(i) = dp(i-1) + nums[i]` 如果 `dp(i-1) > 0`。
|
||||
- `dp(i) = nums[i]` 如果 `dp(i-1) <= 0`。
|
||||
|
||||
怎么理解呢?就是第 `i` 个状态可以直接由第 `i-1` 个状态推导出来,既然 `dp(i)` 是指以第 `i` 个字符串结尾的最大和,那么 `dp(i-1)` 就是以第 `i-1` 个字符串结尾的最大和,而且此时 `dp(i-1)` 已经算出来了,那么 `dp(i)` 怎么推导就清楚了:
|
||||
|
||||
因为字符串是连续的,所以 `dp(i)` 要么是 `dp(i-1)` + `nums[i]`,要么就直接是 `nums[i]`,所以选择哪种,取决于前面的 `dp(i-1)` 是否是正数,**因为以 `i` 结尾一定包含 `nums[i]`,所以 `nums[i]` 不管是正还是负,都一定要带上。** 所以容易得知,`dp(i-1)` 如果是正数就连起来,否则就不连。
|
||||
|
||||
好了,经过这么详细的解释,相信你已经完全了解动态规划的解题套路,后面的题目解释方式我就不会这么啰嗦了!
|
||||
|
||||
这道题如果再复杂一点,不连续怎么办呢?让我们看看最长递增子序列问题吧。
|
||||
|
||||
### 最长递增子序列
|
||||
|
||||
最长递增子序列是一道中等题,题目如下:
|
||||
|
||||
> 给你一个整数数组 `nums` ,找到其中最长严格递增子序列的长度。
|
||||
>
|
||||
> 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,`[3,6,2,7]` 是数组 `[0,3,1,6,2,2,7]` 的子序列。
|
||||
|
||||
其实之前的 [精读《DOM diff 最长上升子序列》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/192.%E7%B2%BE%E8%AF%BB%E3%80%8ADOM%20diff%20%E6%9C%80%E9%95%BF%E4%B8%8A%E5%8D%87%E5%AD%90%E5%BA%8F%E5%88%97%E3%80%8B.md) 有详细解析过这道题,包括还有更优的贪心解法,不过我们这次还是聚焦在动态规划方法上。
|
||||
|
||||
这道题与上一道的区别就是,首先递增,其次不连续。
|
||||
|
||||
按照套路,`dp(i)` 就表示以第 `i` 个字符串结尾的最长上升子序列长度,那么重点是,`dp(i)` 怎么通过之前的推导出来呢?
|
||||
|
||||
由于是不连续的,因此不能只看 `dp(i-1)` 了,因为 `nums[i]` 项与 `dp(j)`(其中 `0 <= j < i`)组合后都可能达到最大长度,因此需要遍历所有 `j`,尝试其中最大长度的组合。
|
||||
|
||||
所以状态转移方程为:
|
||||
|
||||
`dp[i] = max(dp[j]) + 1`,其中 `0<=j<i` 且 `num[j]<num[i]`。
|
||||
|
||||
这道题的出现,预示着较为复杂的状态转移方程的出现,即第 `i` 项不是简单由 `i-1` 推导,而是由之前所有 `dp(j)` 推导,其中 `0<=j<i`。
|
||||
|
||||
除此之外,还有推导变种,即根据 `dp(dp(i))` 推导,即函数里套函数,这类问题由于加深了一层思考脑回路,所以相对更难。我们看一道这样的题目:最长有效括号。
|
||||
|
||||
### 最长有效括号
|
||||
|
||||
最长有效括号是道困难题,题目如下:
|
||||
|
||||
> 给你一个只包含 `'('` 和 `')'` 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
|
||||
|
||||
这道题之所以是困难题,就因为状态转移方程存在嵌套思维。
|
||||
|
||||
我们首先按套路定义 `dp(i)` 为答案,即以第 `i` 下标结尾的字符串中最长有效括号长度。看出来了吗?一般字符串题目中,`i` 都是以字符串下标结尾来定义,很少有定义为开头或者别的定义行为。当然非字符串问题就不是这样了,这个在后面再说。
|
||||
|
||||
我们继续题目,如果 `s[i]` 是 `(`,那么不可能组成有效括号,因为最右边一定不闭合,所以考虑 `s[i]` 为 `)` 的场景。
|
||||
|
||||
如果 `s[i-1]` 为 `(`,那么构成了 `...()` 之势,最后两个自成合法闭合,所以只要看前面的即可,即 `dp(i-2)`,所以这种场景的状态转移方程为:
|
||||
|
||||
`dp(i) = dp(i-2) + 2`
|
||||
|
||||
如果 `s[i-1]` 是 `)` 呢?构成了 `...))` 的状态,那么只有 `i-1` 是合法闭合的,且这个合法闭合段之前必须是 `(` 与第 `i` 项形成闭合,才构成此时最长有效括号长度,所以这种场景的状态转移方程为:
|
||||
|
||||
`dp(i) = dp(i-1) + dp(i - dp(i-1) - 2) + 2`,你可以结合下面的图来理解:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/imgextra/i1/O1CN016tRvXm1o4p8U1Plfk_!!6000000005172-2-tps-1088-378.png">
|
||||
|
||||
可以看到,`dp(i-1)` 就是第二条横线的长度,然后如果红色括号匹配的话,长度又 +2,最后别忘了最左边如果有满足匹配的也要带上,这就是 `dp(i - dp(i-1) - 2)`,所以加到一起就是这种场景的括号最大长度。
|
||||
|
||||
到这里,一维动态规划问题深度基本上探索完了,在进入多维动态规划问题前,还有一类一维动态规划问题,属于表达式不难,也没有这题这么复杂的嵌套 DP,但是思维复杂度极高,**你一定不要盯着全流程看,那样复杂度太高,你需要充分认可 dp(i-x) 已经算出来部分的含义,进行高度抽象的思考。**
|
||||
|
||||
### 栅栏涂色
|
||||
|
||||
栅栏涂色是一道困难题,题目如下:
|
||||
|
||||
> 有 `k` 种颜色的涂料和一个包含 `n` 个栅栏柱的栅栏,每个栅栏柱可以用其中一种颜色进行上色。
|
||||
>
|
||||
> 你需要给所有栅栏柱上色,并且保证其中相邻的栅栏柱 **最多连续两个** 颜色相同。然后,返回所有有效涂色的方案数。
|
||||
|
||||
这道题 `k` 和 `n` 都非常巨大,常规暴力解法甚至普通 DP 都会超时。选择 `i` 的含义也很重要,这里 `i` 到底代表用几种颜色还是几个栅栏呢?选择栅栏会好做一些,因为栅栏是上色的主体。这样 `dp(i)` 就表示上色前 `i` 个栅栏的所有涂色方案。
|
||||
|
||||
首先看下递归终止条件。由于最多连续两个颜色相同,因此 `dp(0)` 与 `dp(1)` 分别是 `k` 与 `k*k`,因为每个栅栏随便刷颜色,自由组合。那么 `dp(2)` 有三个栅栏,非法情况是三个栅栏全同色,所以用所有可能减掉非法即可,非法场景只有 `k` 中,所以结果是 `k*k*k - k`。
|
||||
|
||||
那么考虑一般情况,对于 `dp(i)` 有几种涂色方案呢?直接思考情况太多,我们把情况一分为二,考虑 `i` 与 `i-1` 颜色相同与不同两种情况考虑。
|
||||
|
||||
如果 `i` 与 `i-1` 颜色相同,那么为了合法,`i-1` 肯定不能与 `i-2` 颜色相同了,否则就三个同色,这样的话,不管 `i-2` 是什么颜色,`i-1` 与 `i` 都只能少取一种颜色,少取的颜色就是 `i-2` 的颜色,因此 `[i-1,i]` 这个区间有 `k-1` 中取色方案,前面有 `dp(i-2)` 种取色方案,相乘就是最终方案数:`dp(i-2) * (k-1)`。
|
||||
|
||||
**这背后其实存在动态思维,即每种场景的 `k-1` 都是不同的颜色组合,只是无论前面 `dp(i-2)` 是何种组合,后面两个栅栏一定有 `k-1` 种取法,虽然颜色组合的色值不同,但颜色组合数量是不变的,所以可以统一计算。理解这一点非常关键。**
|
||||
|
||||
如果 `i` 与 `i-1` 颜色不同,那么第 `i` 项只有 `k-1` 种取法,一样也是动态的,因为永远不能和 `i-1` 颜色相同。最后乘上 `dp(i-1)` 的取色方案,就是总方案数:`dp(i-1) * (k-1)`。
|
||||
|
||||
所以最后总方案数就是两者之和,即 `dp(i) = dp(i-2) * (k-1) + dp(i-1) * (k-1)`。
|
||||
|
||||
这道题的不同之处在于,变化太多,任何一个栅栏取的颜色都会影响后面栅栏要取的颜色,**乍一看觉得是个有后效性的题目,无法用动态规划解决**。但实际上,虽然有后效性,但如果进行合理的拆解,后面栅栏的总可能性 `k-1` 是不变的,**所以考虑总可能性数量,是无后效性的**,因此站在方案总数上进行抽象思考,才可能破解此题。
|
||||
|
||||
接下来介绍多维动态规划,从二维开始。二维动态规划就是用两个变量表示 DP,即 `dp(i,j)`,一般在二维数组场景出现较多,当然也有一些两个数组之间的关系,也属于二维动态规划,为了继续探讨字符串问题,我选择了字符串问题的二维动态规划范例,编辑距离这道题来说明。
|
||||
|
||||
### 编辑距离
|
||||
|
||||
编辑距离是一道困难题,题目如下:
|
||||
|
||||
> 给你两个单词 `word1` 和 `word2`,请你计算出将 `word1` 转换成 `word2` 所使用的最少操作数。
|
||||
>
|
||||
> 你可以对一个单词进行如下三种操作:
|
||||
>
|
||||
> - 插入一个字符
|
||||
> - 删除一个字符
|
||||
> - 替换一个字符
|
||||
|
||||
只要是字符串问题,基本上 `i` 都表示以第 `i` 项结尾的字符串,但这道题有两个单词字符串,**为了考虑任意匹配场景,必须用两个变量表示,即 `i` `j` 分别表示 `word1` 与 `word2` 结尾下标时,最少操作次数。**
|
||||
|
||||
那么对于 `dp(i,j)` 考虑 `word1[i]` 与 `word2[j]` 是否相同,最后通过双重递归,先递归 `i`,在递归内再递归 `j`,答案就出来了。
|
||||
|
||||
假设最后一个字符相同,即 `word1[i] === word2[j]` 时,**由于最后一个字符不用改就相同了,所以操作次数就等价于考虑到前一个字符**,即 `dp(i,j) = dp(i-1,j-1)`
|
||||
|
||||
假设最后一个字符不同,那么 **最后一步** 有三种模式可以得到:
|
||||
|
||||
1. 假设是替换,即 `dp(i,j) = dp(i-1,j-1) + 1`,因为替换最后一个字符只要一步,并且和前面字符没什么关系,所以前面的最小操作次数直接加过来。
|
||||
2. 假设是插入,即 `word1` 插入一个字符变成 `word2`,那么只要变换到这一步再 +1 插入操作就行了,变换到这一步由于插入一个就行了,因此 `word1` 比 `word2` 少一个单词,其它都一样,要变换到这一步,就要进行 `dp(i,j-1)` 的变换,因此 `dp(i,j) = dp(i,j-1) + 1`。。
|
||||
3. 假设是删除,即 `word1` 删除一个字符变成 `word2`,同理,要进行 `dp(i-1,j)` 的变化后多一步删除,因此 `dp(i,j) = dp(i-1,j) + 1`。
|
||||
|
||||
由于题目取操作最少次数,所以这三种情况取最小即可,即 `dp(i,j) = min(dp(i-1,j-1), dp(i,j-1), dp(i-1,j)) + 1`。
|
||||
|
||||
所以同时考虑了最后一个字符是否相同后,合并了的状态转移方程就是最终答案。
|
||||
|
||||
我们再考虑终止条件,即 `i` 或 `j` 为 -1 时的情况,因为状态转移方程 `i` 和 `j` 不断减小,肯定会减少到 0 或 -1,因为 0 是字符串还有一个字符,相对比如考虑 -1 字符串为空时方便,因此我们考虑 -1 时作为边界条件。
|
||||
|
||||
当 `i` 为 -1 时,即 `word1` 为空,此时要变换为 `word2` 很显然,只有插入 `j` 次是最小操作次数,因此此时 `dp(i,j) = j`;同理,当 `j` 为 -1 时,即 `word2` 为空,此时要删除 `i` 次,因此操作次数为 `i`,所以 `dp(i,j) = i`。
|
||||
|
||||
### 非字符串问题
|
||||
|
||||
说到这,相信你在字符串动规问题上已经如鱼得水了,我们再看看非字符串场景的动规问题。非字符串场景的动规比较经典的有三个,第一是矩形路径最小距离,或者最大收益;第二是背包问题以及变种;第三是打家劫舍问题。
|
||||
|
||||
这些问题解决方式都一样,只是对于 `dp(i)` 的定义略有区别,比如对于矩形问题来说,`dp(i,j)` 表示走到 `i,j` 格子时的最小路径;对于背包问题,`dp(i,j)` 表示装了第 `i` 个物品时,背包还剩 `j` 空间时最大价格;对于打家劫舍问题,`dp(i)` 表示打劫到第 `i` 个房间时最大收益。
|
||||
|
||||
因为篇幅问题这里就不一详细介绍了,只简单说明一下矩形问题于打家劫舍问题。
|
||||
|
||||
对于矩形问题,状态转移方程重点看上个状态是如何转移过来的,一般矩形只能向右或者向下移动,路途可能有一些障碍物不能走,我们要做分支判断,然后选择一条符合题目最值要求的路线作为当前 `dp(i)` 的转移方程即可。
|
||||
|
||||
对于打家劫舍问题,由于不能同时打劫相邻的房屋,所以对于 `dp(i)`,要么为了打劫 `i-1` 而不打劫第 `i` 间,或者打劫 `i-2` 于第 `i` 间,取这两种终态的收益最大值即可,即 `dp(i) = max(dp(i-1), dp(i-2) + coins[i])`。
|
||||
|
||||
## 总结
|
||||
|
||||
动态规划的核心分为三步,首先定义清楚状态,即 `dp(i)` 是什么;然后定义状态转移方程,这一步需要一些思考技巧;最后思考验证一下正确性,即尝试证明你写的状态转移方程是正确的,在这个过程要做到状态转移的不重不漏,所有情况都被涵盖了进来。
|
||||
|
||||
动态规划最经典的还是背包问题,由于篇幅原因,可能下次单独出一篇文章介绍。
|
||||
|
||||
> 讨论地址是:[精读《算法 - 动态规划》· Issue #327 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/327)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
@@ -0,0 +1,235 @@
|
||||
滑动窗口算法是较为入门题目的算法,一般是一些有规律数组问题的最优解,也就是说,如果一个数组问题可以用动态规划解,但又可以使用滑动窗口解决,那么往往滑动窗口的效率更高。
|
||||
|
||||
双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程中本身就会产生一个窗口,比如当窗口收缩到某种程度,可以得到一些结论。
|
||||
|
||||
因此掌握滑动窗口非常基础且重要,接下来按照我的经验给大家介绍这个算法。
|
||||
|
||||
## 精读
|
||||
|
||||
滑动窗口使用双指针解决问题,所以一般也叫双指针算法,因为两个指针间形成一个窗口。
|
||||
|
||||
什么情况适合用双指针呢?一般双指针是暴力算法的优化版,所以:
|
||||
|
||||
1. 如果题目较为简单,且是数组或链表问题,往往可以尝试双指针是否可解。
|
||||
2. 如果数组存在规律,可以尝试双指针。
|
||||
3. 如果链表问题限制较多,比如要求 O(1) 空间复杂度解决,也许只有双指针可解。
|
||||
|
||||
也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,可以尝试双指针(滑动窗口)解法。
|
||||
|
||||
我们还是拿例子说明,首先是两数之和。
|
||||
|
||||
### 两数之和
|
||||
|
||||
两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,还是先讲这道题。题目如下:
|
||||
|
||||
> 给定一个整数数组 `nums` 和一个整数目标值 `target`,请你在该数组中找出 **和为目标值** `target` 的那 **两个** 整数,并返回它们的数组下标。
|
||||
>
|
||||
> 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
|
||||
|
||||
暴力解法就是穷举所有两数之和,发现和为 `target` 结束,显然这种做法有点慢,我们换一种思路。
|
||||
|
||||
由于可以用空间换时间,又只有两个数,我们可以对题目进行转化,即通过一次遍历,将 `nums` 每一项都减去 `target`,然后找到后面任意一项值为前面的结果,即表示它们和为 `target`。
|
||||
|
||||
可以用哈希表 `map` 加速查询,即将每一项 `target - num` 作为 key,如果后面任何一个 `num` 作为 key 可以在 `map` 中找到,则得解,且上一个数的原始值可以存在 `map` 的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。
|
||||
|
||||
之所以说这道题,是因为这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,需要用双指针解决(一般来说不会用到三或以上指针),那复杂点的题目就是三数之和了。
|
||||
|
||||
### 三数之和
|
||||
|
||||
三数之和是一道中等题,别以为只是两数之和的加强版,其思路完全不同。题目如下:
|
||||
|
||||
> 给你一个包含 `n` 个整数的数组 `nums`,判断 `nums` 中是否存在三个元素 `a`,`b`,`c` ,使得 `a + b + c = 0` ?请你找出所有和为 `0` 且不重复的三元组。
|
||||
|
||||
由于超过了两个数,所以不能像双指针一样求解了,因为即便用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案无法继续嵌套使用,即无法进一步降低复杂度。
|
||||
|
||||
为了降低时间复杂度,我们希望只遍历一次数组,这就需要数组满足一定条件我们才能用滑动窗口,所以我们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过因为题目复杂,这个牺牲是无法避免的。
|
||||
|
||||
假设从小到大排序,那我们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先创建两个指针,分别叫 `left` 与 `right`,通过不断修改 `left` 与 `right`,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回所有满足条件的窗口即可,记录其实很简单,只要在滑动过程中记录一下就行。
|
||||
|
||||
首先排除异常值,即数组长度过小,然后对于常规情况,我们拿一个全局变量存储当前窗口数的和,这样 `right + 1` 只要累加 `nums[right+1]`,`left + 1` 只要减去 `nums[left]` 即可快速拿到求和。
|
||||
|
||||
由于需要考虑所有情况,所以需要一次数组遍历,对于每次遍历的起始点 `i`,如果 `nums[i] > 0` 则直接跳过,因为数组排序后是递增的,后面的和只会永远大于 0;否则进行窗口滑动,先形成三个点 `[i, i+1, n-1]`,这样保持 `i` 不动,不断包夹后两个数字即可,只要它们的和大于 0,就将第三个点左移(数字会变小),否则将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。
|
||||
|
||||
这样的话时间复杂度是 O(n²),因为存在两次遍历,忽略快排较小的时间复杂度。
|
||||
|
||||
那么四数之和,五数之和呢?
|
||||
|
||||
### 四数之和
|
||||
|
||||
该题和三数之和完全一样,除了要求变成四个数。
|
||||
|
||||
首先还是排序,然后双重递归,即确定前两个数不变,不断包夹后两个数,后两个数就是 `i+1` 和 `n-1`,算法和三数之和一样,所以最终时间复杂度为 O(n³)。
|
||||
|
||||
那么 N 数之和(N > 2)都可以采用这个思路解决。
|
||||
|
||||
为什么没有更优的方法呢?我想可能因为:
|
||||
|
||||
1. 无论几数之和,快排一次时间复杂度都是固定的,所以沿用三数之和的方案其实占了排序算法便宜。
|
||||
2. 滑动窗口只能用两个指针进行移动,而没有三指针但又保持时间复杂度不变的窗口滑动算法存在。
|
||||
|
||||
所以对于 N 数之和,通过排序付出了 O(nlogn) 时间复杂度之后,可以用滑动窗口,将 2 个数时间复杂度优化为 O(n),所以整体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,所以总是忽略快排的时间复杂度,所以三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。
|
||||
|
||||
可以看到,我们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,**本质上是利用排序后数组有序的特性,让我们在不用遍历数组的前提下,可以对窗口进行滑动**,这是滑动窗口算法的核心思想。
|
||||
|
||||
为了加强这个理解,再看一道类似的题目,无重复字符的最长子串。
|
||||
|
||||
### 无重复字符的最长子串
|
||||
|
||||
无重复字符的最长子串是一道中等题,题目如下:
|
||||
|
||||
> 给定一个字符串,请你找出其中不含有重复字符的 **最长子串** 的长度。
|
||||
|
||||
由于最长子串是连续的,所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后,问题很简单,只要设定 `left` 和 `right`,并用一个哈希 Set 记录哪些元素存在过,在过程中记录最大长度,并尝试 `right` 右移,如果右移过程中发现出现重复字符,则 `left` 右移,直到消除这个重复字符为止。
|
||||
|
||||
解法并不难,但问题是,我们要想清楚,为什么用滑动窗口遍历一次就可以做到 **不重不漏**?即这道题时间复杂度只有 O(n) 呢?
|
||||
|
||||
只要想明白两个问题:
|
||||
|
||||
1. 由于子串是连续的,既然不存在跳跃的情况,只要一次滑动窗口内能包含所有解,就涵盖了所有情况。
|
||||
2. 一次滑动窗口内不包含什么?由于我们只将 `right` 右移,且出现重复后尝试将 `left` 右移到不重复后,`right` 再继续右移,这忽略了出现重复后, `right` 左移的情况。
|
||||
|
||||
我们重点看二个问题,显然,如果 `abcd` 这四个连续的字符不重复,那么 `left` 右移后,`bcd` 也显然不重复,所以如果此时就可以将 `right` 右移形成 `bcda` 的窗口继续找下去,而不需要尝试 `bc` 这种情况,因为这种情况虽然不重复,但一定不是最优解。
|
||||
|
||||
好了,通过这个例子我们看到,滑动窗口如何缩小窗口范围其实不难,但更要注重的是,背后对于为什么可以用滑动窗口的思考,滑动窗口有没有做到不重不漏,如果没有想清楚,可能整个思路都错了。
|
||||
|
||||
那么滑动窗口的应用已经说透了?其实没有,我们上面只说了缩小窗口这种比较单一的脑回路,其实双指针构成的滑动窗口不一定都是那么正常滑的,一种有意思的场景是快慢指针,即是以相对速度决定窗口如何滑动。
|
||||
|
||||
关于快慢指针,经典的题目有环形链表、删除有序数组中的重复项。
|
||||
|
||||
### 环形链表
|
||||
|
||||
环形链表是一道简单题,题目如下:
|
||||
|
||||
> 给定一个链表,判断链表中是否有环。
|
||||
|
||||
如果不是进阶要求空间复杂度 O(1),我们可以在遍历时稍稍 “污染” 一下原始链表,这样总能发现是否走了回头路。
|
||||
|
||||
但要求空间开销必须是常数,我们不得不考虑快慢指针。说实话第一次看到这道题时,如果能想到快慢指针的解法,绝对是相当聪明的,因为必须要有知识迁移的能力。怎么迁移呢?想象学校在开运动会,相信每次都有一个跑的最慢的同学,慢到被最快的同学追了一圈。
|
||||
|
||||
等等,操场不就是环形链表吗?**只要有人跑得慢,就会被跑得快的追上,追上不就是相遇了吗?** 所以快慢指针分别跑,只要相遇则判定为环形链表,否则不是环形链表,且一定有一个指针先走完。
|
||||
|
||||
那么细枝末节就是优化效率了,慢指针到底慢多少呢?
|
||||
|
||||
有人会说,运动会上,跑步慢的人如果想被快的人追上,最好就不要跑。对,但环形链表问题中,链表不是操场,可能只有某一段是环,也就是跑步慢的人至少要跑到环里,才可能与跑得快人的相遇,但跑得慢的人又不知道哪里开始成环,这就是难点。
|
||||
|
||||
你有没有想过,为什么快排用二分法,而不是三分法?为什么每次中间来一刀,可以最快排完?原因是二分可以用最小的 “深度” 将数组切割为最小粒度。那么同理,快慢指针中,慢指针要想被尽快追上,速度可能最好是快指针的一半。那从逻辑上分析,为什么呢?
|
||||
|
||||
直观来看,如果慢指针太慢,可能大部分时间都在进入环形之前的位置转悠,快指针虽然快,但永远在环里跑,所以总是无法遇到慢指针,这给我们的启示是,慢指针不能太慢;如果慢指针太快,几乎速度和快指针一样,就像两个运动员都互不相让的争夺第一一样,他们真的想相遇,估计得连续跑几个小时吧,所以慢指针也不能过快。所以这样分析下来,慢指针只能取折中的一半速度。
|
||||
|
||||
但用一半的慢速真的能最快相遇吗?不一定,举一个例子,假设链表是完美环形,一共有 [1,6] 共 6 个节点,那么慢指针一次走 1 步,快指针一次走 2 步,那么一共是 `2,3 3,5 4,1 5,3 6,5 1,1` 共走 6 步,但如果快指针一次走 3 步呢?一共是 `2,4 3,1 4,4` 3 步。这么说一般速度不一定最优?其实不是的,计算机在链表寻址时,节点访问的消耗也要考虑进去,后者虽然看上去更快,但其实访问链表 `next` 的次数更多,对计算机来说,还不如第一种来得快。
|
||||
|
||||
所以准确来说,不是快指针比慢指针快一倍速度,而是慢指针一次走一步,快指针一次走两步最优,因为相遇时,总移动步数最少。
|
||||
|
||||
再说一个简单问题,即用快慢指针判断链表中倒数第k个节点或者链表中点。
|
||||
|
||||
### 判断链表中点
|
||||
|
||||
快指针是慢指针速度 2 倍,当快指针到达尾部,慢指针的位置就是链表中点。
|
||||
|
||||
### 链表中倒数第k个节点
|
||||
|
||||
链表中倒数第k个节点是一道简单题,题目如下:
|
||||
|
||||
> 输入一个链表,输出该链表中倒数第 `k` 个节点。为了符合大多数人的习惯,本题从 `1` 开始计数,即链表的尾节点是倒数第 `1` 个节点。
|
||||
|
||||
这道题就是判断链表中点的变种,只要让慢指针比快指针慢 `k` 个节点,当快指针到达末尾时,慢指针就指向倒数第 `k+1` 个节点了。这道题注意一下数数别数错了即可。
|
||||
|
||||
接下来终于说道快慢指针的另一种经典用法题型,删除有序数组中的重复项了。
|
||||
|
||||
### 删除有序数组中的重复项
|
||||
|
||||
删除有序数组中的重复项是一道简单题,题目如下:
|
||||
|
||||
> 给你一个有序数组 `nums` ,请你 **原地** 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。
|
||||
|
||||
这道题,要原地删除重复元素,并返回长度,所以只能用快慢指针。但怎么用呢?快多少慢多少?
|
||||
|
||||
其实这道题快多少慢多少并不像前面题目一样预设好了,而是根据遇到的实际数字来判断。
|
||||
|
||||
我们假设慢指针是 `slow` 快指针是 `fast`,注意变量命名也有意思,同样是双指针问题,有的是 `slow right`,有的是 `slow fast`,重点在于用何种方法移动指针。
|
||||
|
||||
我们只要让 `fast` 扫描完全表,把所有不重复的挪到一起就好了,这样时间复杂度是 O(n),具体做法是:
|
||||
|
||||
1. 让 `slow` 和 `fast` 初始都指向 index 0。
|
||||
2. 由于是 **有序数组**,所以就算有重复也一定连在一起,所以可以让 `fast` 直接往后扫描,只有遇到和 `slow` 不同的值,才把其和 `slow+1` 交换,然后 `slow` 自增,继续递归,直到 `fast` 走到数组尾部结束。
|
||||
|
||||
做完这套操作后,`slow` 的下标值就是答案。
|
||||
|
||||
可以看到,这道题对于慢指针要如何慢,其实是根据值来判断的,如果 `fast` 的值与 `slow` 一样,那么 `slow` 就一直等着,因为相同的值要被忽略掉,让 `fast` 走就是在跳过重复值。
|
||||
|
||||
说完了常见的双指针用法,我们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 **盛最多水的容器** 与 **接雨水**。
|
||||
|
||||
### 盛最多水的容器
|
||||
|
||||
盛最多水的容器是一道中等题,题目如下:
|
||||
|
||||
> 给你 `n` 个非负整数 `a1,a2,...,an`,每个数代表坐标中的一个点 `(i, ai)` 。在坐标内画 `n` 条垂直线,垂直线 `i` 的两个端点分别为 `(i, ai)` 和 `(i, 0)` 。找出其中的两条线,使得它们与 `x` 轴共同构成的容器可以容纳最多的水。
|
||||
|
||||
<img width=400 src="https://z3.ax1x.com/2021/06/12/25WZZt.png">
|
||||
|
||||
建议先仔细读一读题目再继续,这道题相对比较复杂。
|
||||
|
||||
好了,为什么说这是一道双指针题目呢?因为我们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。
|
||||
|
||||
长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不一定最大,一眼是没法看出来最优解的。
|
||||
|
||||
所以还是得多次尝试,那怎么样可以用最少的尝试次数,但又不重不漏呢?定义 `left` `right` 两个指针,分别指向 `0` 与 `n-1` 即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪个呢?
|
||||
|
||||
- 较长的那个?如果新的比较短的更短,那么宽度更短了;如果新的比较短的更长,也没用,因为较短的决定了水位。
|
||||
- 较短的那个?如果新的较长,那么才有机会整体体积更大。
|
||||
|
||||
所以我们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程中最大体积就是全局最大体积。
|
||||
|
||||
这道题双指针的移动规则比较巧妙,与上面普通题目不一样,重点不是在是否会运用滑动窗口算法,而是能否找到移动指针的规则。
|
||||
|
||||
当然你可能会说,为什么两个指针要定义在最两端,而非别的地方?因为这样就无法控制变量了。
|
||||
|
||||
如果指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。比如我们移动较短的柱子,是因为较短的柱子确定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短还是较长的柱子哪个更优就说不准了。
|
||||
|
||||
说实话这种方法不太容易想到,需要多找几种选择尝试才能发现。当然,算法如果按照固定套路就能推导出来,也就没有难度了,所以要接受这种思维跳跃。
|
||||
|
||||
接下来我们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。
|
||||
|
||||
### 接雨水
|
||||
|
||||
接雨水是一道困难题,题目如下:
|
||||
|
||||
> 给定 `n` 个非负整数表示每个宽度为 `1` 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
|
||||
|
||||
<img width=400 src="https://z3.ax1x.com/2021/06/12/25OejP.png">
|
||||
|
||||
与盛雨水不同,这道接雨水看的是整体,我们要算出能接的所有水的数量。
|
||||
|
||||
其实相比上一道题,这道题还算比较好切入,因为我们从左到右计算即可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢?
|
||||
|
||||
显然凹槽是可以明确分组的,一个凹槽也无法被分割为多个凹槽,就像你看水坑一样,无论有多少,多深的坑在一起,总能一个一个数清楚,所以我们就从左到右开始数。
|
||||
|
||||
怎么数凹槽呢?用滑动窗口办法,每个窗口就是一个凹槽,那么窗口的起点 `left` 就是左边第一根柱子,有以下情况:
|
||||
|
||||
- 如果直接相邻的右边柱子更高(或一样高),那从它开始向右看,根本无法接雨水,所以直接抛弃,`left++`。
|
||||
- 如果直接相邻的右边柱子更矮,那就有产生凹槽的机会。
|
||||
- 那么继续往右看,如果右边一直都更矮,那也接不到雨水。
|
||||
- 如果右边出现一个高一些的,就可以接到雨水,那问题是怎么算能接多少,以及找到哪结束呢?
|
||||
- 只要记录最左边柱子高度,右边柱子的结束判断条件是 “遇到一个与最左边一样高的柱子”,因为一个凹槽能接多少水,取决于最短的柱子。当然,如果右边没有柱子了,虽然比最左边低一点,但只要比最深的高,也算一个结束点。
|
||||
|
||||
这道题,一旦遇到凹槽结束点,`left` 就会更新,开始新的一轮凹槽计算,所以存在多个滑动窗口。从这道题可以看出,滑动窗口题型相当灵活,不仅判断条件因题而异,窗口数量可能也有多个。
|
||||
|
||||
## 总结
|
||||
|
||||
滑动窗口本质是双指针的玩法,不同题目有不同的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。
|
||||
|
||||
其实按照规律包夹的套路属于碰撞指针范畴,一般对于排序好的数组,可以一步一步判断,或者用二分法判断,总之不用根据整体遍历来判断,效率自然高。
|
||||
|
||||
快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。
|
||||
|
||||
对于无固定套路的滑动窗口,就要根据题目仔细品味啦,如果所有套路都能总结出来,算法也少了乐趣。
|
||||
|
||||
> 讨论地址是:[精读《算法 - 滑动窗口》· Issue #328 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/328)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,258 @@
|
||||
二叉树是一种数据结构,并且拥有种类复杂的分支,本文作为入门篇,只介绍一些基本二叉树的题型,像二叉搜索树等等不在此篇介绍。
|
||||
|
||||
二叉树其实是链表的升级版,即链表同时拥有两个 Next 指针,就变成了二叉树。
|
||||
|
||||
二叉树可以根据一些特性,比如搜索二叉树,将查找的时间复杂度降低为 logn,而且堆这种数据结构,也是一种特殊的二叉树,可以以 O(1) 的时间复杂度查找最大值或者最小值。所以二叉树的变种很多,都可以很好的解决具体场景的问题。
|
||||
|
||||
## 精读
|
||||
|
||||
要入门二叉树,就必须理解二叉树的三种遍历策略,分别是:前序遍历、中序遍历、后序遍历,这些都属于深度优先遍历。
|
||||
|
||||
所谓前中后,就是访问节点值在什么时机,其余时机按先左后右访问子节点。比如前序遍历,就是先访问值,再访问左右;后续遍历就是先访问左右,再访问值;中序遍历就是左,值,右。
|
||||
|
||||
用递归方式遍历树非常简单:
|
||||
|
||||
```typescript
|
||||
function visitTree(node: TreeNode) {
|
||||
// 三选一:前序遍历
|
||||
// console.log(node.val)
|
||||
visitTree(node.left)
|
||||
// 三选一:中序遍历
|
||||
// console.log(node.val)
|
||||
visitTree(node.right)
|
||||
// 三选一:后序遍历
|
||||
// console.log(node.val)
|
||||
}
|
||||
```
|
||||
|
||||
当然题目需要我们巧妙利用二叉树三种遍历的特性来解题,比如重建二叉树。
|
||||
|
||||
### 重建二叉树
|
||||
|
||||
重建二叉树是一道中等题,题目如下:
|
||||
|
||||
> 输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
|
||||
>
|
||||
> 例如
|
||||
>
|
||||
> 前序遍历 preorder = `[3,9,20,15,7]`
|
||||
>
|
||||
> 中序遍历 inorder = `[9,3,15,20,7]`
|
||||
|
||||
先给你二叉树前序与中序遍历结果,让你重建二叉树,这种逆向思维的题目就难了不少。
|
||||
|
||||
仔细观察遍历特性可以看出,我们也许能推测出一些关键节点的位置,再通过数组切割递归一下就能解题。
|
||||
|
||||
前序遍历第一个访问的一定是根节点,因此 `3` 一定是根节点,然后我们在中序遍历找到 `3`,这样 **左边就是所有左子树的中序遍历结果,右边就是所有右子树的中序遍历结果**,我们只要再找到 **左子树的前序遍历结果与右子树的前序遍历结果**,就可以递归了,终止条件是左或右子树只有一个值,那样就代表叶子节点。
|
||||
|
||||
那么怎么找左右子树的前序遍历呢?上面例子中,我们找到了 `3` 的左右子树的中序遍历结果,由于前序遍历优先访问左子树,因此我们数一下中序遍历中,`3` 左边的数量,只有一个 `9`,那么我们从前序遍历的 `3,9,20,15,7` 在 `3` 之后推一位,那么 `9` 就是左子树前序遍历结果,`9` 后面的 `20,15,7` 就是柚子树的前序遍历结果。
|
||||
|
||||
最后只要递归一下就能解题了,我们将输入不断拆解为左右子树的的输入,直到达到终止条件。
|
||||
|
||||
解决此题的关键是,不仅要直到如何写前中后序遍历,还要知道前序遍历第一个节点是根节点,后序遍历最后一个节点是根节点,中序遍历以根节点为中心,左右分别是其左右子树,这几个重要延伸特征。
|
||||
|
||||
说完了反向,我们说正向,即递归一颗二叉树。
|
||||
|
||||
其实二叉树除了递归,还有一种常见的遍历方法是利用栈进行广度优先遍历,典型题目有从上到下打印二叉树。
|
||||
|
||||
### 从上到下打印二叉树
|
||||
|
||||
从上到下打印二叉树是一道简单题,题目如下:
|
||||
|
||||
> 从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。
|
||||
|
||||
这道题要求从左到右顺序打印,完全遵循广度优先遍历,我们可以在二叉树递归时,先不要急着读取值,而是按照左、中、右,遇到左右子树节点,就推入栈的末尾,利用 `while` 语句不断循环,直到栈空为止。
|
||||
|
||||
利用展开时追加到栈尾,并不断循环处理栈元素的方式非常优雅,而且符合栈的特性。
|
||||
|
||||
当然如果题目要求倒序打印,你就可以以 右、中、左 的顺序进行处理。
|
||||
|
||||
接下来看看深度优先遍历,典型题目是二叉树的深度。
|
||||
|
||||
### 二叉树的深度
|
||||
|
||||
二叉树的深度是一道简单题,题目如下:
|
||||
|
||||
> 输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。
|
||||
|
||||
由于二叉树有多种分支,在遍历前,我们并不知道哪条路线是最深的,所以必须利用递归尝试。
|
||||
|
||||
我们可以转换一下思路,用函数式语义方式来理解。假设我们有了这样一个函数 `deep` 来求二叉树深度,那么这个函数内容是什么呢?二叉树只可能存在左右子树,所以 `deep` 必然是左右子树的最大深度的最大值 +1(它自己)。
|
||||
|
||||
而求左右子树深度可以复用 `deep` 函数形成递归,我们只需要考虑边界情况,即访问节点不存在时,返回深度 `0` 即可,因此代码如下:
|
||||
|
||||
```typescript
|
||||
function deep(node: TreeNode) {
|
||||
if (!node) return 0
|
||||
return Math.max(deep(node.left), deep(node.right)) + 1
|
||||
}
|
||||
```
|
||||
|
||||
从这可以看出,二叉树一般能用比较优雅的递归函数解决,如果你的解题思路不包含递归,往往就不是最优雅的解法。
|
||||
|
||||
类似优雅的题目还有,平衡二叉树。
|
||||
|
||||
### 平衡二叉树
|
||||
|
||||
平衡二叉树是一道简单题,题目如下:
|
||||
|
||||
> 输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1,那么它就是一棵平衡二叉树。
|
||||
|
||||
同理,我们设函数 `isBalance` 就是答案函数,那么一个平衡二叉树的特征,必然是其左右子树也是平衡的,所以可以写成:
|
||||
|
||||
```typescript
|
||||
function isBalance(node: TreeNode) {
|
||||
if (root == null) return true
|
||||
return isBalance(node.left) && isBalance(node.right)
|
||||
}
|
||||
```
|
||||
|
||||
但是哪里不对,左右子树平衡还不够啊,万一左右子树之间深度相差超过 1 就坏了,所以还要求一下左右子树的深度,我们复用上题的函数 `deep`,整理一下如下:
|
||||
|
||||
```typescript
|
||||
function isBalance(node: TreeNode) {
|
||||
if (root == null) return true
|
||||
return isBalance(root.left) && isBalance(root.right) &&
|
||||
Math.abs(deep(root.left) - deep(root.right)) < 2
|
||||
}
|
||||
```
|
||||
|
||||
这道题提醒我们,不是所有递归都能完美写成仅自己调用自己的模式,不同题目要辅以其他函数,要敏锐的察觉到还缺少哪些条件。
|
||||
|
||||
还有一种递归,不是简单的函数自身递归自身,而是要构造出另一个函数进行递归,原因是递归参数不同。典型的题目有对称的二叉树。
|
||||
|
||||
### 对称的二叉树
|
||||
|
||||
对称的二叉树是一道简单题,题目如下:
|
||||
|
||||
> 请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
|
||||
|
||||
我们要注意,一颗二叉树的镜像比较特殊,比如最左节点与最右节点互为镜像,但它们的父节点并不相同,因此 `isSymmetric(tree)` 这样的参数是无法子递归的,我们必须拆解为左右子树作为参数,让它们进行相等判断,在传参时,将父级不同,但互为镜像的左右节点传入即可。
|
||||
|
||||
所以我们必须起一个新函数 `isSymmetricNew(left, right)`,将 `left.left` 与 `right.right` 对比,将 `left.right` 与 `right.left` 对比即可。
|
||||
|
||||
具体代码就不写了,然后注意一下边界情况即可。
|
||||
|
||||
这道题的重点是,由于镜像的关系,并不拥有相同的父节点,因此必须用一个新参数的函数进行递归。
|
||||
|
||||
那如果这道题反过来呢?要求构造一个二叉树镜像呢?
|
||||
|
||||
### 二叉树的镜像
|
||||
|
||||
二叉树的镜像是一道简单题,题目如下:
|
||||
|
||||
> 请完成一个函数,输入一个二叉树,该函数输出它的镜像。
|
||||
|
||||
判断镜像比较容易,但构造镜像就要想一想了:
|
||||
|
||||
```text
|
||||
例如输入:
|
||||
4
|
||||
/ \
|
||||
2 7
|
||||
/ \ / \
|
||||
1 3 6 9
|
||||
|
||||
镜像输出:
|
||||
4
|
||||
/ \
|
||||
7 2
|
||||
/ \ / \
|
||||
9 6 3 1
|
||||
```
|
||||
|
||||
观察发现,其实镜像可以理解为左右子树互换,同时 **其各子树的左右子树再递归互换**,这就构成了一个递归:
|
||||
|
||||
```typescript
|
||||
function mirrorTree(node: TreeNode) {
|
||||
if (node === null) return null
|
||||
|
||||
const left = mirrorTree(node.left)
|
||||
const right = mirrorTree(node.right)
|
||||
node.left = right
|
||||
node.right = left
|
||||
return node
|
||||
}
|
||||
```
|
||||
|
||||
我们要从下到上,因此先生成递归好的左右子树,再进行当前节点的互换,最后返回根节点即可。
|
||||
|
||||
接下来介绍一些有一定难度的经典题。
|
||||
|
||||
### 二叉树的最近公共祖先
|
||||
|
||||
二叉树的最近公共祖先是一道中等题,题目如下:
|
||||
|
||||
> 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
|
||||
|
||||
题目很简短,也很明确,就是寻找最近的公共祖先。显然,根节点是所有节点的公共祖先,但不一定是最近的。
|
||||
|
||||
我们还是用递归,先考虑特殊情况:如果任意节点等于当前节点,那么当前节点一定就是最近公共祖先,因为另一个节点一定在其子节点中。
|
||||
|
||||
然后,利用递归思想思考,假设我们利用 `lowestCommonAncestor` 函数分别找到左右子节点的最近公共祖先会怎样?
|
||||
|
||||
```typescript
|
||||
function lowestCommonAncestor(node, a, b) {
|
||||
const left = lowestCommonAncestor(node.left)
|
||||
const right = lowestCommonAncestor(node.right)
|
||||
}
|
||||
```
|
||||
|
||||
如果左右节点都找不到,说明只可能当前节点是最近公共子节点:
|
||||
|
||||
```typescript
|
||||
if (!left && !right) return node
|
||||
```
|
||||
|
||||
如果左节点找不到,则右节点就是答案,否则相反:
|
||||
|
||||
```typescript
|
||||
if (!left) return right
|
||||
return left
|
||||
```
|
||||
|
||||
这里巧妙利用了函数语义进行结果判断。
|
||||
|
||||
### 二叉树的右视图
|
||||
|
||||
二叉树的右视图是一道中等题,题目如下:
|
||||
|
||||
> 给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
|
||||
|
||||
想象一束光照,从二叉树右侧向左照射,自上而下读取即是答案。
|
||||
|
||||
其实这道题可以认为是一道融合题。右侧的光束可以认为是分层照射的,那么当我们用广度优先算法遍历时,对于每一层,都找到最后一个节点打印,并且按顺序打印就是最终答案。
|
||||
|
||||
有一道二叉树的题目,是根据树的深度,按照广度优先遍历打印成二维数组,记录树的深度其实也有巧妙办法,即在栈尾追加元素时,增加一个深度 key,那么访问时自然就可以读到深度值。
|
||||
|
||||
### 完全二叉树的节点个数
|
||||
|
||||
完全二叉树的节点个数是一道中等题,题目如下:
|
||||
|
||||
> 给你一棵 **完全二叉树** 的根节点 `root` ,求出该树的节点个数。
|
||||
>
|
||||
> **完全二叉树** 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 `h` 层,则该层包含 `1 ~ 2^h` 个节点。
|
||||
|
||||
用递归解决这道题的话,关键要分几种情况探讨完全二叉树。
|
||||
|
||||
由于最底层可能没有填满,但最底层一定有节点,而且是按照从左到右填的,那么递归遍历左节点就可以获取树的最大深度,通过最大深度我们可以快速计算出节点个树,前提是二叉树必须是满的。
|
||||
|
||||
但最底层节点可能不满,那怎么办呢?分情况即可,首先,如果一直按照 `node.right....right` 递归获得右侧节点深度,发现和最大深度相同,那么就是一个满二叉树,直接计算出结果即可。
|
||||
|
||||
我们再看 `node.right...left` 的深度如果等于最大深度,说明 `node.left` 也就是左子树是个满二叉树,可以通过数学公式 `2^n-1` 快速算出节点个树。
|
||||
|
||||
如果不等于最大深度呢?**则说明右子树深度减 1 是满二叉树**,也可以通过数学公式快速计算节点个数,再通过递归计算另一边即可。
|
||||
|
||||
## 总结
|
||||
|
||||
从题目中可以感受到,二叉树的解题魅力在于递归,二叉树问题中,我们可以同时追求优雅与答案。
|
||||
|
||||
> 讨论地址是:[精读《算法 - 二叉树》· 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))
|
||||
Reference in New Issue
Block a user