Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13963fbdd2 | ||
|
|
9b9d02b9de | ||
|
|
ec65f684bb | ||
|
|
022b1fdbbd | ||
|
|
ff121c7e1b | ||
|
|
a04380c1c1 | ||
|
|
3646b36db5 | ||
|
|
aaaa28953b | ||
|
|
9609101870 | ||
|
|
afb601a303 |
@@ -6,7 +6,7 @@
|
||||
|
||||
前端界的好文精读,每周更新!
|
||||
|
||||
最新精读:<a href="./算法/200.精读《算法 - 回溯》.md">200.精读《算法 - 回溯》</a>
|
||||
最新精读:<a href="./算法/203.精读《算法 - 二叉搜索树》.md">203.精读《算法 - 二叉搜索树》</a>
|
||||
|
||||
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
- <a href="./前沿技术/195.精读《新一代前端构建工具对比》.md">195.精读《新一代前端构建工具对比》</a>
|
||||
- <a href="./前沿技术/196.精读《前端职业规划 - 2021 年》.md">196.精读《前端职业规划 - 2021 年》</a>
|
||||
- <a href="./前沿技术/197.精读《低代码逻辑编排》.md">197.精读《低代码逻辑编排》</a>
|
||||
- <a href="./前沿技术/202.精读《React 18》.md">202.精读《React 18》</a>
|
||||
|
||||
### 设计模式
|
||||
|
||||
@@ -234,6 +235,8 @@
|
||||
- <a href="./算法/198.精读《算法 - 动态规划》.md">198.精读《算法 - 动态规划》</a>
|
||||
- <a href="./算法/199.精读《算法 - 滑动窗口》.md">199.精读《算法 - 滑动窗口》</a>
|
||||
- <a href="./算法/200.精读《算法 - 回溯》.md">200.精读《算法 - 回溯》</a>
|
||||
- <a href="./算法/201.精读《算法 - 二叉树》.md">201.精读《算法 - 二叉树》</a>
|
||||
- <a href="./算法/203.精读《算法 - 二叉搜索树》.md">203.精读《算法 - 二叉搜索树》</a>
|
||||
|
||||
## 关注前端精读微信公众号
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
React 18 带来了几个非常实用的新特性,同时也没有额外的升级成本,值得仔细看一看。
|
||||
|
||||
下面是几个关键信息:
|
||||
|
||||
- [React 18 工作小组](https://github.com/reactwg/react-18)。利用社区讨论 React 18 发布节奏与新特性。
|
||||
- [发布计划](https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html)。目前还没有正式发布,不过 `@alpha` 版已经可用了,[安装 alpha 版](https://github.com/reactwg/react-18/discussions/9)。
|
||||
- [React 18 新特性介绍](https://github.com/reactwg/react-18/discussions/4)。虽然还未正式发布,但特性介绍可以先行,本周精读主要就是解读这篇文档。
|
||||
|
||||
## 精读
|
||||
|
||||
总的来说,React 18 带来了 3 大新特性:
|
||||
|
||||
- Automatic batching。
|
||||
- Concurrent APIS。
|
||||
- SSR for Suspense。
|
||||
|
||||
同时为了开启新的特性,需要进行简单的 `render` 函数升级。
|
||||
|
||||
### Automatic batching
|
||||
|
||||
batching 是指,React 可以将回调函数中多个 `setState` 事件合并为一次渲染。
|
||||
|
||||
也就是说,`setState` 并不是实时修改 State 的,而将多次 `setState` 调用合并起来仅触发一次渲染,既可以减少程序数据状态存在中间值导致的不稳定性,也可以提升渲染性能。可以理解为如下代码所示:
|
||||
|
||||
```typescript
|
||||
function handleClick() {
|
||||
setCount((c) => c + 1);
|
||||
setFlag((f) => !f);
|
||||
// 仅触发一次渲染
|
||||
}
|
||||
```
|
||||
|
||||
但可惜的是,React 18 以前,如果在回调函数的异步调用中执行 `setState`,由于丢失了上下文,无法做合并处理,所以每次 `setState` 调用都会立即触发一次重渲染:
|
||||
|
||||
```typescript
|
||||
function handleClick() {
|
||||
// React 18 以前的版本
|
||||
fetch(/*...*/).then(() => {
|
||||
setCount((c) => c + 1); // 立刻重渲染
|
||||
setFlag((f) => !f); // 立刻重渲染
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 `promise`、`timeout` 或者 `event` 回调中调用多次 `setState`,也都会合并为一次渲染:
|
||||
|
||||
```typescript
|
||||
function handleClick() {
|
||||
// React 18+
|
||||
fetch(/*...*/).then(() => {
|
||||
setCount((c) => c + 1);
|
||||
setFlag((f) => !f);
|
||||
// 仅触发一次渲染
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
当然如果你非要 `setState` 调用后立即重渲染也行,只需要用 `flushSync` 包裹:
|
||||
|
||||
```typescript
|
||||
function handleClick() {
|
||||
// React 18+
|
||||
fetch(/*...*/).then(() => {
|
||||
ReactDOM.flushSync(() => {
|
||||
setCount((c) => c + 1); // 立刻重渲染
|
||||
setFlag((f) => !f); // 立刻重渲染
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
开启这个特性的前提是,将 `ReactDOM.render` 替换为 `ReactDOM.createRoot` 调用方式。
|
||||
|
||||
### 新的 ReactDOM Render API
|
||||
|
||||
升级方式很简单:
|
||||
|
||||
```typescript
|
||||
const container = document.getElementById("app");
|
||||
|
||||
// 旧 render API
|
||||
ReactDOM.render(<App tab="home" />, container);
|
||||
|
||||
// 新 createRoot API
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<App tab="home" />);
|
||||
```
|
||||
|
||||
API 修改的主要原因还是语义化,即当我们多次调用 `render` 时,不再需要重复传入 `container` 参数,因为在新的 API 中,`container` 已经提前绑定到 `root` 了。
|
||||
|
||||
`ReactDOM.hydrate` 也被 `ReactDOM.hydrateRoot` 代替:
|
||||
|
||||
```typescript
|
||||
const root = ReactDOM.hydrateRoot(container, <App tab="home" />);
|
||||
// 注意这里不用调用 root.render()
|
||||
```
|
||||
|
||||
这样的好处是,后续如果再调用 `root.render(<Appx />)` 进行重渲染,我们不用关心这个 `root` 来自 `createRoot` 或者 `hydrateRoot`,因为后续 API 行为表现都一样,减少了理解成本。
|
||||
|
||||
### Concurrent APIS
|
||||
|
||||
首先要了解 Concurrent Mode 是什么。
|
||||
|
||||
简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。
|
||||
|
||||
有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时常增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。
|
||||
|
||||
由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。
|
||||
|
||||
以上是背景输入。React 18 提供了三个新的 API 支持这一模式,分别是:
|
||||
|
||||
- startTransition。
|
||||
- useDeferredValue。
|
||||
- <SuspenseList>。
|
||||
|
||||
后两个文档还未放出,所以本文只介绍第一个 API:startTransition。首先看一下用法:
|
||||
|
||||
```typescript
|
||||
import { startTransition } from "react";
|
||||
|
||||
// 紧急更新:
|
||||
setInputValue(input);
|
||||
|
||||
// 标记回调函数内的更新为非紧急更新:
|
||||
startTransition(() => {
|
||||
setSearchQuery(input);
|
||||
});
|
||||
```
|
||||
|
||||
简单来说,就是被 `startTransition` 回调包裹的 `setState` **触发的渲染** 被标记为不紧急的渲染,这些渲染可能被其他紧急渲染所抢占。
|
||||
|
||||
比如这个例子,当 `setSearchQuery` 更新的列表内容很多,导致渲染时 CPU 占用 100% 时,此时用户又进行了一个输入,即触发了由 `setInputValue` 引起的渲染,此时由 `setSearchQuery` 引发的渲染会立刻停止,转而对 `setInputValue` 渲染进行支持,这样用户的输入就能快速反映在 UI 上,代价是搜索列表响应稍慢了一些。而一个 `transition` 被打断的状态可以通过 `isPending` 访问到:
|
||||
|
||||
```typescript
|
||||
import { useTransition } from "react";
|
||||
const [isPending, startTransition] = useTransition();
|
||||
```
|
||||
|
||||
其实这比较符合操作系统的设计理念,我们知道在操作系统是通过中断响应底层硬件事件的,中断都非常紧急(因为硬件能存储的消息队列非常有限,操作系统不能即使响应,硬件的输入可能就丢失了),因此要支持抢占式内核,并在中断到来时立刻执行中断(可能把不太紧急的操作放到下半部执行)。
|
||||
|
||||
对前端交互来说,用户角度发出的 “中断” 一般来自键盘或鼠标的操作,但不幸的是,前端框架甚至是 JS 都过于上层,它们无法自动识别:
|
||||
|
||||
1. 哪些代码是紧急中断产生的。比如 `onClick` 就一定是用户鼠标点击产生的吗?不一定,可能是 `xxx.onClick` 主动触发的,而非用户触发。
|
||||
2. 用户触发的就一定是紧急中断吗?不一定,比如键盘输入后,`setInputValue` 是紧急的,而更新查询列表的 `setSearchQuery` 就是非紧急的。
|
||||
|
||||
我们要理解到前端场景对用户操作感知的局限性,才能理解为什么必须手动指定更新的紧急程度,而不能像操作系统一样,上层程序无需感知中断的存在。
|
||||
|
||||
### SSR for Suspense
|
||||
|
||||
完整名称是:Streaming SSR with selective hydration。
|
||||
|
||||
即像水流一样,打造一个从服务端到客户端持续不断的渲染管线,而不是 `renderToString` 那样一次性渲染机制。selective hydration 表示选择性水合,水合指的是后端内容打到前端后,JS 需要将事件绑定其上,才能响应用户交互或者 DOM 更新行为,而在 React 18 之前,这个操作必须是整体性的,而水合过程可能比较慢,会引起全局的卡顿,所以选择性水合可以按需优先进行水合。
|
||||
|
||||
所以这个特性其实是转为 SSR 准备的,而功能启用载体就是 Suspense(所以以后不要再认为 Suspense 只是一个 loading 作用)。其实在 Suspense 设计之初,就是为了解决服务端渲染问题,只是一开始只实装了客户端测的按需加载功能,后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。
|
||||
|
||||
SSR for Suspense 解决三个主要问题:
|
||||
|
||||
- SSR 模式下,如果不同模块取数效率不同,会因为最慢的一个模块拖慢整体 HTML 吞吐时间,这可能导致体验还不如非 SSR 来的好。举一个极端情况,假设报表中一个组件依赖了慢查询,需要五分钟数据才能出来,那么 SSR 的后果就是白屏时间拉长到 5 分钟。
|
||||
- 即便 SSR 内容打到了页面上,由于 JS 没有加载完毕,所以根本无法进行 hydration,整个页面处于无法交互状态。
|
||||
- 即便 JS 加载完了,由于 React 18 之前只能进行整体 hydration,可能导致卡顿,导致首次交互响应不及时。
|
||||
|
||||
在 React 18 的 server render 中,只要使用 `pipeToNodeWritable` 代替 `renderToString` 并配合 `Suspense` 就能解决上面三个问题。
|
||||
|
||||
使用 `pipeToNodeWriteable` 可以看 [这个例子](https://codesandbox.io/s/festive-star-9hfqt?file=/server/render.js:1043-1575)。
|
||||
|
||||
最大的区别在于,服务端渲染由简单的 `res.send` 改成了 `res.socket`,这样渲染就从单次行为变成了持续性的行为。
|
||||
|
||||
那么 React 18 的 SSR 到底有怎样的效果呢?[这篇介绍文档](https://github.com/reactwg/react-18/discussions/37) 的图建议看一看,非常直观,这里我简要描述一下:
|
||||
|
||||
1. 被 `<Suspense>` 包裹的区块,在服务端渲染时不会阻塞首次吞吐,而且在这个区块准备完毕后(包括异步取数)再实时打到页面中(以 HTML 模式,此时还没有 hydration),在此之前返回的是 `fallback` 的内容。
|
||||
2. hydration 的过程也是逐步的,这样不会导致一下执行所有完整的 js 导致页面卡顿(hydration 其实就是 React 里写的回调注册、各类 Hooks,整个应用的量非常庞大)。
|
||||
3. hydration 因为被拆成多部,React 还会提前监听鼠标点击,并提前对点击区域优先级进行 hydration,甚至能抢占已经在其他区域正在进行中的 hydration。
|
||||
|
||||
那么总结一下,新版 SSR 性能提高的秘诀在于两个字:按需。
|
||||
|
||||
而这个难点在于,SSR 需要后端到前端的配合,在 React 18 之前,后端到前端的过程完全没有优化,而现在将 SSR HTML 的吞吐改成多次,按需,并且水合过程中还支持抢占,因此性能得到进一步提升。
|
||||
|
||||
## 总结
|
||||
|
||||
结合起来看,React 18 关注点在于更快的性能以及用户交互响应效率,其设计理念处处包含了中断与抢占概念。
|
||||
|
||||
以后提起前端性能优化,我们就多了一些应用侧的视角(而不仅仅是工程化视角),从以下两个应用优化视角有效提升交互反馈速度:
|
||||
|
||||
1. 随时中断的框架设计,第一优先级渲染用户最关注的 UI 交互模块。
|
||||
2. 从后端到前端 “顺滑” 的管道式 SSR,并将 hydration 过程按需化,且支持被更高优先级用户交互行为打断,第一优先水合用户正在交互的部分。
|
||||
|
||||
> 讨论地址是:[精读《React 18》· Issue #336 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/336)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
@@ -0,0 +1,150 @@
|
||||
二叉搜索树的特性是,任何一个节点的值:
|
||||
|
||||
- 都大于左子树任意节点。
|
||||
- 都小于右子树任意节点。
|
||||
|
||||
因为二叉搜索树的特性,我们可以更高效的应用算法。
|
||||
|
||||
## 精读
|
||||
|
||||
还记得 [《算法 - 二叉树》](https://github.com/ascoders/weekly/blob/master/%E7%AE%97%E6%B3%95/201.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%A0%91%E3%80%8B.md) 提到的 [二叉树的最近公公祖先](https://github.com/ascoders/weekly/blob/master/%E7%AE%97%E6%B3%95/201.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%A0%91%E3%80%8B.md) 问题吗?如果这是一颗二叉搜索树,是不是存在更巧妙的解法?你可以暂停先思考一下。
|
||||
|
||||
### 二叉搜索树的最近公共祖先
|
||||
|
||||
二叉搜索树的最近公共祖先是一道简单题,题目如下:
|
||||
|
||||
> 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。
|
||||
>
|
||||
> 百度百科中最近公共祖先的定义为:“对于有根树 `T` 的两个结点 `p`、`q`,最近公共祖先表示为一个结点 `x`,满足 `x` 是 `p`、`q` 的祖先且 `x` 的深度尽可能大(一个节点也可以是它自己的祖先)。”
|
||||
|
||||
第一个判断条件是相同的,即当前节点值等于 `p` 或 `q` 任意一个,则当前节点就是其最近公共祖先。
|
||||
|
||||
如果不是呢?同时考虑二叉搜索树与公共祖先的特性可以发现:
|
||||
|
||||
1. 如果 `p` `q` 两个节点分别位于当前节点的左 or 右边,则当前节点符合要求。
|
||||
2. 如果 `p` `q` 值一个大于,一个小于当前节点,说明 `p` `q` 分布在当前节点左右两侧。
|
||||
|
||||
基于以上考虑,可以仅通过值大小来判断,因此题目就被简化了。
|
||||
|
||||
接下来看一道入门题,即如何验证一颗二叉树是二叉搜索树。
|
||||
|
||||
### 验证二叉搜索树
|
||||
|
||||
验证二叉搜索树是一道中等题,题目如下:
|
||||
|
||||
> 给定一个二叉树,判断其是否是一个有效的二叉搜索树。
|
||||
>
|
||||
> 假设一个二叉搜索树具有如下特征:
|
||||
>
|
||||
> - 节点的左子树只包含小于当前节点的数。
|
||||
> - 节点的右子树只包含大于当前节点的数。
|
||||
> - 所有左子树和右子树自身必须也是二叉搜索树。
|
||||
|
||||
这道题看上去就应该用非常优雅的递归来实现。
|
||||
|
||||
二叉搜索树最重要的就是对节点值的限制,我们如果能正确卡住每个节点的值,就可以判断了。
|
||||
|
||||
如何判断节点值是否正确呢?我们可以用递归的方式倒推,即从根节点开始,假设根节点值为 `x`,那么左树节点的值就必须小于 `x`,再往左,那么值就要小于(假设第一个左节点值为 `x1`) `x1`,右树也是一样判断,因此就可以写出答案:
|
||||
|
||||
```typescript
|
||||
function isValidBST(node: TreeNode, min = -Infinity, max = Infinity) {
|
||||
if (node === null) return true
|
||||
// 判断值范围是否合理
|
||||
if (node.val < min || node.val > max) return false
|
||||
// 继续递归,并且根据二叉搜索树特定,进一步缩小最大、最小值的锁定范围
|
||||
return
|
||||
// 左子树值 max 为当前节点值
|
||||
isValidBST(node.left, min, node.val) &&
|
||||
// 右子树值 min 为当前节点值
|
||||
isValidBST(node.right, node.val, max) &&
|
||||
}
|
||||
```
|
||||
|
||||
接下来看一些简单的二叉搜索树操作问题,比如删除二叉搜索树中的节点。
|
||||
|
||||
### 删除二叉搜索树中的节点
|
||||
|
||||
删除二叉搜索树中的节点是一道中等题,题目如下:
|
||||
|
||||
> 给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
|
||||
>
|
||||
> 一般来说,删除节点可分为两个步骤:
|
||||
>
|
||||
> 1. 首先找到需要删除的节点;
|
||||
> 2. 如果找到了,删除它。
|
||||
>
|
||||
> 说明: 要求算法时间复杂度为 `O(h)`,`h` 为树的高度。
|
||||
|
||||
要删除二叉搜索树的节点,找到节点本身并不难,因为如果值小了,就从左子树找;如果值大了,就从右子树找,这本身查找起来是非常简单的。难点在于,如何保证删除元素后,这棵树还是一颗二叉搜索树?
|
||||
|
||||
假设我们删除的是叶子结点,很显然,二叉搜索树任意子树都是二叉搜索树,我们又没有破坏其他节点的关系,因此直接删除就行了,最简单。
|
||||
|
||||
如果删除的不是叶子结点,那么谁来 “上位” 代替这个节点呢?题目要求复杂度为 `O(h)` 显然不能重新构造,我们需要仔细考虑。
|
||||
|
||||
假设删除的节点存在右节点,那么肯定从右节点找到一个代替值移上来,找谁呢?找右节点的最小值呀,最小值很好找的,找完代替后,相当于 **问题转移为删除这个最小值节点,递归就完事了。**
|
||||
|
||||
假设删除的节点存在左节点,但是没有右节点,那就从左节点找一个最大的替换掉,同理递归删除找到的节点。
|
||||
|
||||
可以看到,删除二叉搜索树,为了让二叉搜索树性质保持不变,需要不断进行重复子问题的递归删除节点。
|
||||
|
||||
当你掌握二叉搜索树特性后,可以尝试构造二叉搜索树了,下面就是一道让你任意构造二叉搜索树的题目:不同的二叉搜索树。
|
||||
|
||||
### 不同的二叉搜索树
|
||||
|
||||
不同的二叉搜索树是一道中等题,题目如下:
|
||||
|
||||
> 给你一个整数 `n` ,求恰由 `n` 个节点组成且节点值从 `1` 到 `n` 互不相同的 **二叉搜索树** 有多少种?返回满足题意的二叉搜索树的种数。
|
||||
|
||||
这道题重点在于动态规划思维 + 笛卡尔积组合的思维。
|
||||
|
||||
需要将所有可能性想象为确定了根节点后,左右子树到底有几种组合方式?
|
||||
|
||||
举个例子,假设 `n=10`,那么这 10 个节点,假设我取第 3 个节点为根节点,那么左子树有 2 个节点,右子树有 7 个节点,这种组合情况就有 `DP(2) * DP(7)` 这么多,假设 `DP(n)` 表示 n 个节点能组成任意二叉搜索树的数量。
|
||||
|
||||
这仅是第 3 个节点为根节点的情况,实际上每个节点作为根节点都是不同的树(轴对称也算不同的),那么我们就要从第 1 个节点计算到第 `n` 个节点。
|
||||
|
||||
因此答案就出来了,我们先考虑特殊情况 `DP(0)=1` `DP(1)=1`,所以:
|
||||
|
||||
```typescript
|
||||
function numTrees(n: number) {
|
||||
const dp: number[] = [1, 1]
|
||||
|
||||
for (let i = 2; i <= n; i++) {
|
||||
for (let j = 1; j <= i; j++) {
|
||||
dp[i] += dp[j - 1] * dp[i - j]
|
||||
}
|
||||
}
|
||||
|
||||
return dp[n]
|
||||
}
|
||||
```
|
||||
|
||||
最后再看一道找值题,并不是找最大值,而是找第 k 大值。
|
||||
|
||||
### 二叉搜索树的第 K 大节点
|
||||
|
||||
二叉搜索树的第 K 大节点是一道简单题,题目如下:
|
||||
|
||||
> 给定一棵二叉搜索树,请找出其中第 `k` 大的节点。
|
||||
|
||||
这道题之所以简单,是因为二叉搜索树的中序遍历是从小到大的,因此只要倒序中序遍历,就可以找到第 `k` 大的节点。
|
||||
|
||||
倒序中序遍历,即右、根、左。
|
||||
|
||||
这道题就解决啦。
|
||||
|
||||
## 总结
|
||||
|
||||
二叉搜索树的特性很简单,就是根节点值夹在左右子树中间,利用这个特性几乎可以解决一切相关问题。
|
||||
|
||||
但通过上面几个例子可以发现,仅熟悉二叉搜索树特性还是不够的,一些题目需要结合二叉树中序遍历、公共祖先特征等通用算法思路结合来解决,因此学会融会贯通很重要。
|
||||
|
||||
> 讨论地址是:[精读《算法 - 二叉搜索树》· Issue #337 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/337)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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