mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-24 20:46:28 +08:00
Replace ":" with "。"
This commit is contained in:
parent
71692af8c4
commit
c5a7323817
47 changed files with 159 additions and 165 deletions
|
@ -12,7 +12,7 @@
|
|||
|
||||
## 内容微调
|
||||
|
||||
如下图所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码:
|
||||
如下图所示,每个页面的右上角都有“编辑图标”。您可以按照以下步骤修改文本或代码。
|
||||
|
||||
1. 点击“编辑图标”,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。
|
||||
2. 修改 Markdown 源文件内容,检查内容的正确性,并尽量保持排版格式的统一。
|
||||
|
@ -24,7 +24,7 @@
|
|||
|
||||
## 内容创作
|
||||
|
||||
如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施 Pull Request 工作流程:
|
||||
如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施以下 Pull Request 工作流程。
|
||||
|
||||
1. 登录 GitHub ,将[本仓库](https://github.com/krahets/hello-algo) Fork 到个人账号下。
|
||||
2. 进入您的 Fork 仓库网页,使用 `git clone` 命令将仓库克隆至本地。
|
||||
|
|
|
@ -359,7 +359,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
总的来看,数组的插入与删除操作有以下缺点:
|
||||
总的来看,数组的插入与删除操作有以下缺点。
|
||||
|
||||
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
|
||||
- **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
|
||||
|
@ -599,13 +599,13 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
|
||||
## 数组优点与局限性
|
||||
|
||||
数组存储在连续的内存空间内,且元素类型相同。这包含丰富的先验信息,系统可以利用这些信息来优化操作和运行效率,包括:
|
||||
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来优化数据结构的操作效率。
|
||||
|
||||
- **空间效率高**: 数组为数据分配了连续的内存块,无须额外的结构开销。
|
||||
- **支持随机访问**: 数组允许在 $O(1)$ 时间内访问任何元素。
|
||||
- **缓存局部性**: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
|
||||
|
||||
连续空间存储是一把双刃剑,它导致的缺点有:
|
||||
连续空间存储是一把双刃剑,其存在以下缺点。
|
||||
|
||||
- **插入与删除效率低**:当数组中元素较多时,插入与删除操作需要移动大量的元素。
|
||||
- **长度不可变**: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
|
||||
|
@ -613,7 +613,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
|
||||
## 数组典型应用
|
||||
|
||||
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构,主要包括:
|
||||
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
|
||||
|
||||
- **随机访问**:如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
|
||||
- **排序和搜索**:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
|
||||
|
|
|
@ -854,7 +854,7 @@
|
|||
|
||||
许多编程语言都提供内置的列表,例如 Java, C++, Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
|
||||
为了帮助你理解列表的工作原理,我们在此提供一个简易版列表实现,重点包括:
|
||||
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
|
||||
|
||||
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
|
||||
- **数量记录**:声明一个变量 size,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
!!! question "图片“链表定义与存储方式”中,浅蓝色的存储结点指针是占用一块内存地址吗?还是和结点值各占一半呢?"
|
||||
|
||||
文中只是一个示意图,只是定性表示。定量的话需要根据具体情况分析:
|
||||
文中的示意图只是定性表示,定量表示需要根据具体情况进行分析。
|
||||
|
||||
- 不同类型的结点值占用的空间是不同的,比如 int, long, double, 或者是类的实例等等。
|
||||
- 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。
|
||||
|
@ -59,9 +59,9 @@
|
|||
|
||||
!!! question "C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法的书上都不怎么直接用这个,是不是有什么局限性呢?"
|
||||
|
||||
一方面,我们往往更青睐使用数组实现算法,而只有在必要时才使用链表。这是因为:
|
||||
一方面,我们往往更青睐使用数组实现算法,而只有在必要时才使用链表,主要有两个原因。
|
||||
|
||||
1. 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 `std::list` 通常比 `std::vector` 更占用空间。
|
||||
2. 缓存不友好:由于数据不是连续存放的,`std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
|
||||
- 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 `std::list` 通常比 `std::vector` 更占用空间。
|
||||
- 缓存不友好:由于数据不是连续存放的,`std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
|
||||
|
||||
另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 `stack` 和 `queue` ,而非链表。
|
||||
|
|
|
@ -793,7 +793,7 @@
|
|||
- **时间**:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
|
||||
- **空间**:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
|
||||
|
||||
即便如此,**回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案**。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,**关键是如何进行效率优化**,常见方法有:
|
||||
即便如此,**回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案**。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,**关键是如何进行效率优化**,常见的效率优化方法有两种。
|
||||
|
||||
- **剪枝**:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
|
||||
- **启发式搜索**:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
|
||||
|
@ -820,7 +820,7 @@
|
|||
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
|
||||
- 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
|
||||
|
||||
请注意,对于许多组合优化问题,回溯都不是最优解决方案,例如:
|
||||
请注意,对于许多组合优化问题,回溯都不是最优解决方案。
|
||||
|
||||
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
|
||||
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
### 重复选择剪枝
|
||||
|
||||
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 `selected` ,其中 `selected[i]` 表示 `choices[i]` 是否已被选择。剪枝的实现原理为:
|
||||
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 `selected` ,其中 `selected[i]` 表示 `choices[i]` 是否已被选择,并基于它实现以下剪枝操作。
|
||||
|
||||
- 在做出选择 `choice[i]` 后,我们就将 `selected[i]` 赋值为 $\text{True}$ ,代表它已被选择。
|
||||
- 遍历选择列表 `choices` 时,跳过所有已被选择过的节点,即剪枝。
|
||||
|
@ -269,7 +269,7 @@
|
|||
|
||||
### 两种剪枝对比
|
||||
|
||||
请注意,虽然 `selected` 和 `duplicated` 都用作剪枝,但两者的目标不同:
|
||||
请注意,虽然 `selected` 和 `duplicated` 都用作剪枝,但两者的目标是不同的。
|
||||
|
||||
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 `state` 中重复出现。
|
||||
- **相等元素剪枝**:每轮选择(即每个开启的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在遍历中哪些元素已被选择过,作用是保证相等元素只被选择一次。
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
给定一个正整数数组 `nums` 和一个目标正整数 `target` ,请找出所有可能的组合,使得组合中的元素和等于 `target` 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。
|
||||
|
||||
例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,解为 $\{3, 3, 3\}, \{4, 5\}$ 。需要注意两点:
|
||||
例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,解为 $\{3, 3, 3\}, \{4, 5\}$ 。需要注意以下两点。
|
||||
|
||||
- 输入集合中的元素可以被无限次重复选取。
|
||||
- 子集是不区分元素顺序的,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。
|
||||
|
@ -119,19 +119,19 @@
|
|||
|
||||
![子集搜索与越界剪枝](subset_sum_problem.assets/subset_sum_i_naive.png)
|
||||
|
||||
为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,因为:
|
||||
为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,有两方面原因。
|
||||
|
||||
- 当数组元素较多,尤其是当 `target` 较大时,搜索过程会产生大量的重复子集。
|
||||
- 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
|
||||
|
||||
### 重复子集剪枝
|
||||
|
||||
**我们考虑在搜索过程中通过剪枝进行去重**。观察下图,重复子集是在以不同顺序选择数组元素时产生的,具体来看:
|
||||
**我们考虑在搜索过程中通过剪枝进行去重**。观察下图,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。
|
||||
|
||||
1. 第一轮和第二轮分别选择 $3$ , $4$ ,会生成包含这两个元素的所有子集,记为 $[3, 4, \dots]$ 。
|
||||
2. 若第一轮选择 $4$ ,**则第二轮应该跳过 $3$** ,因为该选择产生的子集 $[4, 3, \dots]$ 和 `1.` 中生成的子集完全重复。
|
||||
1. 当第一轮和第二轮分别选择 $3$ , $4$ 时,会生成包含这两个元素的所有子集,记为 $[3, 4, \dots]$ 。
|
||||
2. 之后,当第一轮选择 $4$ 时,**则第二轮应该跳过 $3$** ,因为该选择产生的子集 $[4, 3, \dots]$ 和 `1.` 中生成的子集完全重复。
|
||||
|
||||
如下图所示,每一层的选择都是从左到右被逐个尝试的,因此越靠右剪枝越多。
|
||||
在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
|
||||
|
||||
1. 前两轮选择 $3$ , $5$ ,生成子集 $[3, 5, \dots]$ 。
|
||||
2. 前两轮选择 $4$ , $5$ ,生成子集 $[4, 5, \dots]$ 。
|
||||
|
@ -145,7 +145,7 @@
|
|||
|
||||
为实现该剪枝,我们初始化变量 `start` ,用于指示遍历起点。**当做出选择 $x_{i}$ 后,设定下一轮从索引 $i$ 开始遍历**。这样做就可以让选择序列满足 $i_1 \leq i_2 \leq \dots \leq i_m$ ,从而保证子集唯一。
|
||||
|
||||
除此之外,我们还对代码进行了两项优化:
|
||||
除此之外,我们还对代码进行了以下两项优化。
|
||||
|
||||
- 在开启搜索前,先将数组 `nums` 排序。在遍历所有选择时,**当子集和超过 `target` 时直接结束循环**,因为后边的元素更大,其子集和都一定会超过 `target` 。
|
||||
- 省去元素和变量 `total`,**通过在 `target` 上执行减法来统计元素和**,当 `target` 等于 $0$ 时记录解。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
谈及计算机中的数据,我们会想到文本、图片、视频、语音、3D 模型等各种形式。尽管这些数据的组织形式各异,但它们都由各种基本数据类型构成。
|
||||
|
||||
**基本数据类型是 CPU 可以直接进行运算的类型**,在算法中直接被使用。它包括:
|
||||
**基本数据类型是 CPU 可以直接进行运算的类型**,在算法中直接被使用,主要包括以下几种类型。
|
||||
|
||||
- 整数类型 `byte` , `short` , `int` , `long` 。
|
||||
- 浮点数类型 `float` , `double` ,用于表示小数。
|
||||
|
@ -11,7 +11,7 @@
|
|||
|
||||
**基本数据类型以二进制的形式存储在计算机中**。一个二进制位即为 $1$ 比特。在绝大多数现代系统中,$1$ 字节(byte)由 $8$ 比特(bits)组成。
|
||||
|
||||
基本数据类型的取值范围取决于其占用的空间大小,例如 Java 规定:
|
||||
基本数据类型的取值范围取决于其占用的空间大小。下面以 Java 为例。
|
||||
|
||||
- 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个数字。
|
||||
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字。
|
||||
|
@ -31,7 +31,7 @@
|
|||
| 字符 | `char` | 2 bytes / 1 byte | $0$ | $2^{16} - 1$ | $0$ |
|
||||
| 布尔 | `bool` | 1 byte | $\text{false}$ | $\text{true}$ | $\text{false}$ |
|
||||
|
||||
对于上表,需要注意以下几点:
|
||||
对于上表,需要注意以下几点。
|
||||
|
||||
- C, C++ 未明确规定基本数据类型大小,而因实现和平台各异。上表遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于 Unix 64 位操作系统(例如 Linux , macOS)。
|
||||
- 字符 `char` 的大小在 C, C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。
|
||||
|
|
|
@ -40,10 +40,10 @@ Unicode 是一种字符集标准,本质上是给每个字符分配一个编号
|
|||
|
||||
目前,UTF-8 已成为国际上使用最广泛的 Unicode 编码方法。**它是一种可变长的编码**,使用 1 到 4 个字节来表示一个字符,根据字符的复杂性而变。ASCII 字符只需要 1 个字节,拉丁字母和希腊字母需要 2 个字节,常用的中文字符需要 3 个字节,其他的一些生僻字符需要 4 个字节。
|
||||
|
||||
UTF-8 的编码规则并不复杂,分为两种情况:
|
||||
UTF-8 的编码规则并不复杂,分为以下两种情况。
|
||||
|
||||
1. 对于长度为 1 字节的字符,将最高位设置为 $0$ 、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,**UTF-8 编码可以向下兼容 ASCII 码**。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
|
||||
2. 对于长度为 $n$ 字节的字符(其中 $n > 1$),将首个字节的高 $n$ 位都设置为 $1$ 、第 $n + 1$ 位设置为 $0$ ;从第二个字节开始,将每个字节的高 2 位都设置为 $10$ ;其余所有位用于填充字符的 Unicode 码点。
|
||||
- 对于长度为 1 字节的字符,将最高位设置为 $0$ 、其余 7 位设置为 Unicode 码点。值得注意的是,ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说,**UTF-8 编码可以向下兼容 ASCII 码**。这意味着我们可以使用 UTF-8 来解析年代久远的 ASCII 码文本。
|
||||
- 对于长度为 $n$ 字节的字符(其中 $n > 1$),将首个字节的高 $n$ 位都设置为 $1$ 、第 $n + 1$ 位设置为 $0$ ;从第二个字节开始,将每个字节的高 2 位都设置为 $10$ ;其余所有位用于填充字符的 Unicode 码点。
|
||||
|
||||
下图展示了“Hello算法”对应的 UTF-8 编码。观察发现,由于最高 $n$ 位都被设置为 $1$ ,因此系统可以通过读取最高位 $1$ 的个数来解析出字符的长度为 $n$ 。
|
||||
|
||||
|
@ -53,7 +53,7 @@ UTF-8 的编码规则并不复杂,分为两种情况:
|
|||
|
||||
![UTF-8 编码示例](character_encoding.assets/utf-8_hello_algo.png)
|
||||
|
||||
除了 UTF-8 之外,常见的编码方式还包括:
|
||||
除了 UTF-8 之外,常见的编码方式还包括以下两种。
|
||||
|
||||
- **UTF-16 编码**:使用 2 或 4 个字节来表示一个字符。所有的 ASCII 字符和常用的非英文字符,都用 2 个字节表示;少数字符需要用到 4 个字节表示。对于 2 字节的字符,UTF-16 编码与 Unicode 码点相等。
|
||||
- **UTF-32 编码**:每个字符都使用 4 个字节。这意味着 UTF-32 会比 UTF-8 和 UTF-16 更占用空间,特别是对于 ASCII 字符占比较高的文本。
|
||||
|
@ -64,13 +64,13 @@ UTF-8 的编码规则并不复杂,分为两种情况:
|
|||
|
||||
## 编程语言的字符编码
|
||||
|
||||
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长的编码。这是因为在等长编码下,我们可以将字符串看作数组来处理,其优点包括:
|
||||
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长的编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。
|
||||
|
||||
- **随机访问**: UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要找到第 $i$ 个字符,我们需要从字符串的开始处遍历到第 $i$ 个字符,这需要 $O(n)$ 的时间。
|
||||
- **字符计数**: 与随机访问类似,计算 UTF-16 字符串的长度也是 $O(1)$ 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
|
||||
- **字符串操作**: 在 UTF-16 编码的字符串中,很多字符串操作(如分割、连接、插入、删除等)都更容易进行。在 UTF-8 编码的字符串上进行这些操作通常需要额外的计算,以确保不会产生无效的 UTF-8 编码。
|
||||
|
||||
实际上,编程语言的字符编码方案设计是一个很有趣的话题,其涉及到许多因素:
|
||||
实际上,编程语言的字符编码方案设计是一个很有趣的话题,其涉及到许多因素。
|
||||
|
||||
- Java 的 `String` 类型使用 UTF-16 编码,每个字符占用 2 字节。这是因为 Java 语言设计之初,人们认为 16 位足以表示所有可能的字符。然而,这是一个不正确的判断。后来 Unicode 规范扩展到了超过 16 位,所以 Java 中的字符现在可能由一对 16 位的值(称为“代理对”)表示。
|
||||
- JavaScript 和 TypeScript 的字符串使用 UTF-16 编码的原因与 Java 类似。当 JavaScript 语言在 1995 年被 Netscape 公司首次引入时,Unicode 还处于相对早期的阶段,那时候使用 16 位的编码就足够表示所有的 Unicode 字符了。
|
||||
|
@ -78,7 +78,7 @@ UTF-8 的编码规则并不复杂,分为两种情况:
|
|||
|
||||
由于以上编程语言对字符数量的低估,它们不得不采取“代理对”的方式来表示超过 16 位长度的 Unicode 字符。这是一个不得已为之的无奈之举。一方面,包含代理对的字符串中,一个字符可能占用 2 字节或 4 字节,从而丧失了等长编码的优势。另一方面,处理代理对需要增加额外代码,这增加了编程的复杂性和 Debug 难度。
|
||||
|
||||
出于以上原因,部分编程语言提出了不同的编码方案:
|
||||
出于以上原因,部分编程语言提出了一些不同的编码方案。
|
||||
|
||||
- Python 3 使用一种灵活的字符串表示,存储的字符长度取决于字符串中最大的 Unicode 码点。对于全部是 ASCII 字符的字符串,每个字符占用 1 个字节;如果字符串中包含的字符超出了 ASCII 范围,但全部在基本多语言平面(BMP)内,每个字符占用 2 个字节;如果字符串中有超出 BMP 的字符,那么每个字符占用 4 个字节。
|
||||
- Go 语言的 `string` 类型在内部使用 UTF-8 编码。Go 语言还提供了 `rune` 类型,它用于表示单个 Unicode 码点。
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。
|
||||
|
||||
首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,我们首先给出三者的定义:
|
||||
首先需要指出,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,我们首先给出三者的定义。
|
||||
|
||||
- **原码**:我们将数字的二进制表示的最高位视为符号位,其中 $0$ 表示正数,$1$ 表示负数,其余位表示数字的值。
|
||||
- **反码**:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
|
||||
|
@ -96,7 +96,7 @@ $$
|
|||
b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0
|
||||
$$
|
||||
|
||||
根据 IEEE 754 标准,32-bit 长度的 `float` 由以下部分构成:
|
||||
根据 IEEE 754 标准,32-bit 长度的 `float` 由以下三个部分构成。
|
||||
|
||||
- 符号位 $\mathrm{S}$ :占 1 bit ,对应 $b_{31}$ 。
|
||||
- 指数位 $\mathrm{E}$ :占 8 bits ,对应 $b_{30} b_{29} \ldots b_{23}$ 。
|
||||
|
@ -145,9 +145,6 @@ $$
|
|||
| $1, 2, \dots, 254$ | 正规数 | 正规数 | $(-1)^{\mathrm{S}} \times 2^{(\mathrm{E} -127)} \times (1.\mathrm{N})$ |
|
||||
| $255$ | $\pm \infty$ | $\mathrm{NaN}$ | |
|
||||
|
||||
特别地,次正规数显著提升了浮点数的精度,这是因为:
|
||||
值得说明的是,次正规数显著提升了浮点数的精度。最小正正规数为 $2^{-126}$ ,最小正次正规数为 $2^{-126} \times 2^{-23}$ 。
|
||||
|
||||
- 最小正正规数为 $2^{-126} \approx 1.18 \times 10^{-38}$ 。
|
||||
- 最小正次正规数为 $2^{-126} \times 2^{-23} \approx 1.4 \times 10^{-45}$ 。
|
||||
|
||||
双精度 `double` 也采用类似 `float` 的表示方法,此处不再详述。
|
||||
双精度 `double` 也采用类似 `float` 的表示方法,在此不做赘述。
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
# 分治搜索策略
|
||||
|
||||
我们已经学过,搜索算法分为两大类:
|
||||
我们已经学过,搜索算法分为两大类。
|
||||
|
||||
- **暴力搜索**:它通过遍历数据结构实现,时间复杂度为 $O(n)$ 。
|
||||
- **自适应搜索**:它利用特有的数据组织形式或先验信息,可达到 $O(\log n)$ 甚至 $O(1)$ 的时间复杂度。
|
||||
|
||||
实际上,**时间复杂度为 $O(\log n)$ 的搜索算法通常都是基于分治策略实现的**,例如:
|
||||
实际上,**时间复杂度为 $O(\log n)$ 的搜索算法通常都是基于分治策略实现的**,例如二分查找和树。
|
||||
|
||||
- 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。
|
||||
- 树是分治关系的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 $O(\log n)$ 。
|
||||
|
||||
以二分查找为例:
|
||||
二分查找的分治策略如下所示。
|
||||
|
||||
- **问题可以被分解**:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
|
||||
- **子问题是独立的**:在二分查找中,每轮只处理一个子问题,它不受另外子问题的影响。
|
||||
|
@ -28,7 +28,7 @@
|
|||
|
||||
从分治角度,我们将搜索区间 $[i, j]$ 对应的子问题记为 $f(i, j)$ 。
|
||||
|
||||
从原问题 $f(0, n-1)$ 为起始点,二分查找的分治步骤为:
|
||||
从原问题 $f(0, n-1)$ 为起始点,通过以下步骤进行二分查找。
|
||||
|
||||
1. 计算搜索区间 $[i, j]$ 的中点 $m$ ,根据它排除一半搜索区间。
|
||||
2. 递归求解规模减小一半的子问题,可能为 $f(i, m-1)$ 或 $f(m+1, j)$ 。
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
### 判断是否为分治问题
|
||||
|
||||
原问题定义为从 `preorder` 和 `inorder` 构建二叉树。我们首先从分治的角度分析这道题:
|
||||
原问题定义为从 `preorder` 和 `inorder` 构建二叉树,其是一个典型的分治问题。
|
||||
|
||||
- **问题可以被分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
|
||||
- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
|
||||
|
@ -16,14 +16,14 @@
|
|||
|
||||
### 如何划分子树
|
||||
|
||||
根据以上分析,这道题是可以使用分治来求解的,但问题是:**如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**?
|
||||
根据以上分析,这道题是可以使用分治来求解的,**但如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**?
|
||||
|
||||
根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分:
|
||||
根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分。
|
||||
|
||||
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图的树对应 `[ 3 | 9 | 2 1 7 ]` 。
|
||||
- 中序遍历:`[ 左子树 | 根节点 | 右子树 ]` ,例如上图的树对应 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
|
||||
以上图数据为例,我们可以通过下图所示的步骤得到划分结果:
|
||||
以上图数据为例,我们可以通过下图所示的步骤得到划分结果。
|
||||
|
||||
1. 前序遍历的首元素 3 是根节点的值。
|
||||
2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
|
@ -33,7 +33,7 @@
|
|||
|
||||
### 基于变量描述子树区间
|
||||
|
||||
根据以上划分方法,**我们已经得到根节点、左子树、右子树在 `preorder` 和 `inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量:
|
||||
根据以上划分方法,**我们已经得到根节点、左子树、右子树在 `preorder` 和 `inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量。
|
||||
|
||||
- 将当前树的根节点在 `preorder` 中的索引记为 $i$ 。
|
||||
- 将当前树的根节点在 `inorder` 中的索引记为 $m$ 。
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
# 分治算法
|
||||
|
||||
「分治 divide and conquer」,全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括“分”和“治”两步:
|
||||
「分治 divide and conquer」,全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括“分”和“治”两个步骤。
|
||||
|
||||
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
|
||||
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
|
||||
|
||||
如下图所示,“归并排序”是分治策略的典型应用之一,其算法原理为:
|
||||
如下图所示,“归并排序”是分治策略的典型应用之一。
|
||||
|
||||
1. **分**:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
|
||||
2. **治**:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。
|
||||
|
@ -14,17 +14,17 @@
|
|||
|
||||
## 如何判断分治问题
|
||||
|
||||
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据:
|
||||
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
|
||||
|
||||
1. **问题可以被分解**:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
|
||||
2. **子问题是独立的**:子问题之间是没有重叠的,互相没有依赖,可以被独立解决。
|
||||
3. **子问题的解可以被合并**:原问题的解通过合并子问题的解得来。
|
||||
|
||||
显然归并排序,满足以上三条判断依据:
|
||||
显然,归并排序是满足以上三条判断依据的。
|
||||
|
||||
1. 递归地将数组(原问题)划分为两个子数组(子问题)。
|
||||
2. 每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
|
||||
3. 两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)。
|
||||
1. **问题可以被分解**:递归地将数组(原问题)划分为两个子数组(子问题)。
|
||||
2. **子问题是独立的**:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
|
||||
3. **子问题的解可以被合并**:两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)。
|
||||
|
||||
## 通过分治提升效率
|
||||
|
||||
|
@ -70,7 +70,7 @@ $$
|
|||
|
||||
## 分治常见应用
|
||||
|
||||
一方面,分治可以用来解决许多经典算法问题:
|
||||
一方面,分治可以用来解决许多经典算法问题。
|
||||
|
||||
- **寻找最近点对**:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对。
|
||||
- **大整数乘法**:例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法。
|
||||
|
@ -78,7 +78,7 @@ $$
|
|||
- **汉诺塔问题**:汉诺塔问题可以视为典型的分治策略,通过递归解决。
|
||||
- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。
|
||||
|
||||
另一方面,分治在算法和数据结构的设计中应用非常广泛,举几个已经学过的例子:
|
||||
另一方面,分治在算法和数据结构的设计中应用非常广泛。
|
||||
|
||||
- **二分查找**:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,然后在剩余区间执行相同的二分操作。
|
||||
- **归并排序**:文章开头已介绍,不再赘述。
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
!!! question
|
||||
|
||||
给定三根柱子,记为 `A` , `B` , `C` 。起始状态下,柱子 `A` 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 `C` 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则:
|
||||
给定三根柱子,记为 `A` , `B` , `C` 。起始状态下,柱子 `A` 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 `C` 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则。
|
||||
|
||||
1. 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入。
|
||||
2. 每次只能移动一个圆盘。
|
||||
|
@ -68,7 +68,7 @@
|
|||
|
||||
本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。
|
||||
|
||||
至此,我们可总结出下图所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ 。子问题的解决顺序为:
|
||||
至此,我们可总结出下图所示的汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。
|
||||
|
||||
1. 将 $n-1$ 个圆盘借助 `C` 从 `A` 移至 `B` 。
|
||||
2. 将剩余 $1$ 个圆盘从 `A` 直接移至 `C` 。
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 动态规划问题特性
|
||||
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
|
||||
|
||||
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
|
||||
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
|
||||
|
@ -20,7 +20,7 @@
|
|||
|
||||
![爬到第 3 阶的最小代价](dp_problem_features.assets/min_cost_cs_example.png)
|
||||
|
||||
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个,即:
|
||||
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
|
||||
|
||||
$$
|
||||
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
||||
|
@ -204,7 +204,7 @@ $$
|
|||
|
||||
不难发现,此问题已不满足无后效性,状态转移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因为 $dp[i-1]$ 代表本轮跳 $1$ 阶,但其中包含了许多“上一轮跳 $1$ 阶上来的”方案,而为了满足约束,我们就不能将 $dp[i-1]$ 直接计入 $dp[i]$ 中。
|
||||
|
||||
为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来决定下一步该怎么跳:
|
||||
为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来决定下一步该怎么跳。
|
||||
|
||||
- 当 $j$ 等于 $1$ ,即上一轮跳了 $1$ 阶时,这一轮只能选择跳 $2$ 阶。
|
||||
- 当 $j$ 等于 $2$ ,即上一轮跳了 $2$ 阶时,这一轮可选择跳 $1$ 阶或跳 $2$ 阶。
|
||||
|
@ -294,7 +294,7 @@ $$
|
|||
[class]{}-[func]{climbing_stairs_constraint_dp}
|
||||
```
|
||||
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题恢复无后效性。然而,许多问题具有非常严重的“有后效性”,例如:
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
|
||||
|
||||
!!! question "爬楼梯与障碍生成"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 动态规划解题思路
|
||||
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题。
|
||||
|
||||
1. 如何判断一个问题是不是动态规划问题?
|
||||
2. 求解动态规划问题该从何处入手,完整步骤是什么?
|
||||
|
@ -13,12 +13,12 @@
|
|||
|
||||
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
|
||||
|
||||
在此基础上,还有一些动态规划问题的“加分项”,包括:
|
||||
在此基础上,动态规划问题还有一些判断的“加分项”。
|
||||
|
||||
- 问题包含最大(小)或最多(少)等最优化描述。
|
||||
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
|
||||
|
||||
而相应的“减分项”包括:
|
||||
相应地,也存在一些“减分项”。
|
||||
|
||||
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
|
||||
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
|
||||
|
@ -91,7 +91,7 @@ $$
|
|||
|
||||
### 方法一:暴力搜索
|
||||
|
||||
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素:
|
||||
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,递归函数包括以下要素。
|
||||
|
||||
- **递归参数**:状态 $[i, j]$ 。
|
||||
- **返回值**:从 $[0, 0]$ 到 $[i, j]$ 的最小路径和 $dp[i, j]$ 。
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
每一轮的决策是对字符串 $s$ 进行一次编辑操作。
|
||||
|
||||
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 $s$ 和 $t$ 的长度分别为 $n$ 和 $m$ ,我们先考虑两字符串尾部的字符 $s[n-1]$ 和 $t[m-1]$ :
|
||||
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 $s$ 和 $t$ 的长度分别为 $n$ 和 $m$ ,我们先考虑两字符串尾部的字符 $s[n-1]$ 和 $t[m-1]$ 。
|
||||
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 相同,我们可以跳过它们,直接考虑 $s[n-2]$ 和 $t[m-2]$ 。
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 不同,我们需要对 $s$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
|
||||
|
@ -39,7 +39,7 @@
|
|||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为下图所示的三种情况:
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为下图所示的三种情况。
|
||||
|
||||
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ 。
|
||||
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$ 。
|
||||
|
|
|
@ -132,10 +132,7 @@ $$
|
|||
|
||||
![方案数量递推关系](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
|
||||
|
||||
我们可以根据递推公式得到暴力搜索解法:
|
||||
|
||||
- 以 $dp[n]$ 为起始点,**递归地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。
|
||||
- 最小子问题的解 $dp[1] = 1$ , $dp[2] = 2$ 是已知的,代表爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。
|
||||
我们可以根据递推公式得到暴力搜索解法。以 $dp[n]$ 为起始点,**递归地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即 $dp[1] = 1$ , $dp[2] = 2$ ,表示爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。
|
||||
|
||||
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
|
||||
|
||||
|
@ -245,10 +242,10 @@ $$
|
|||
|
||||
## 方法二:记忆化搜索
|
||||
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做:
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
|
||||
|
||||
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而将重叠子问题剪枝。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而避免重复计算该子问题。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -436,7 +433,7 @@ $$
|
|||
|
||||
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
|
||||
|
||||
总结以上,动态规划的常用术语包括:
|
||||
根据以上内容,我们可以总结出动态规划的常用术语。
|
||||
|
||||
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解。
|
||||
- 将最小子问题对应的状态(即第 $1$ , $2$ 阶楼梯)称为「初始状态」。
|
||||
|
|
|
@ -49,7 +49,7 @@ $$
|
|||
|
||||
### 方法一:暴力搜索
|
||||
|
||||
搜索代码包含以下要素:
|
||||
搜索代码包含以下要素。
|
||||
|
||||
- **递归参数**:状态 $[i, c]$ 。
|
||||
- **返回值**:子问题的解 $dp[i, c]$ 。
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
- 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
|
||||
- 在完全背包中,每个物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。
|
||||
|
||||
这就导致了状态转移的变化,对于状态 $[i, c]$ 有:
|
||||
在完全背包的规定下,状态 $[i, c]$ 的变化分为两种情况。
|
||||
|
||||
- **不放入物品 $i$** :与 0-1 背包相同,转移至 $[i-1, c]$ 。
|
||||
- **放入物品 $i$** :与 0-1 背包不同,转移至 $[i, c-wgt[i-1]]$ 。
|
||||
|
@ -214,7 +214,7 @@ $$
|
|||
|
||||
### 动态规划思路
|
||||
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点:
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点。
|
||||
|
||||
- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。
|
||||
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
|
||||
|
@ -228,7 +228,7 @@ $$
|
|||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
与完全背包的状态转移方程基本相同,不同点在于:
|
||||
本题与完全背包的状态转移方程存在以下两个差异。
|
||||
|
||||
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
|
||||
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。
|
||||
|
|
|
@ -34,7 +34,7 @@ $$
|
|||
|
||||
![有权图与无权图](graph.assets/weighted_graph.png)
|
||||
|
||||
图的常用术语包括:
|
||||
图数据结构包含以下常用术语。
|
||||
|
||||
- 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。
|
||||
- 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
|
||||
|
@ -52,7 +52,7 @@ $$
|
|||
|
||||
![图的邻接矩阵表示](graph.assets/adjacency_matrix.png)
|
||||
|
||||
邻接矩阵具有以下特性:
|
||||
邻接矩阵具有以下特性。
|
||||
|
||||
- 顶点不能与自身相连,因此邻接矩阵主对角线元素没有意义。
|
||||
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称。
|
||||
|
|
|
@ -125,11 +125,11 @@
|
|||
=== "删除顶点"
|
||||
![adjacency_list_remove_vertex](graph_operations.assets/adjacency_list_remove_vertex.png)
|
||||
|
||||
以下是基于邻接表实现图的代码示例。细心的同学可能注意到,**我们在邻接表中使用 `Vertex` 节点类来表示顶点**,这样做的原因有:
|
||||
以下是基于邻接表实现图的代码示例。细心的同学可能注意到,**我们在邻接表中使用 `Vertex` 节点类来表示顶点**,而这样做是有原因的。
|
||||
|
||||
- 如果我们选择通过顶点值来区分不同顶点,那么值重复的顶点将无法被区分。
|
||||
- 如果类似邻接矩阵那样,使用顶点列表索引来区分不同顶点。那么,假设我们想要删除索引为 $i$ 的顶点,则需要遍历整个邻接表,将其中 $> i$ 的索引全部减 $1$ ,这样操作效率较低。
|
||||
- 因此我们考虑引入顶点类 `Vertex` ,使得每个顶点都是唯一的对象,此时删除顶点时就无须改动其余顶点了。
|
||||
1. 如果我们选择通过顶点值来区分不同顶点,那么值重复的顶点将无法被区分。
|
||||
2. 如果类似邻接矩阵那样,使用顶点列表索引来区分不同顶点。那么,假设我们想要删除索引为 $i$ 的顶点,则需要遍历整个邻接表,将其中 $> i$ 的索引全部减 $1$ ,这样操作效率较低。
|
||||
3. 因此我们考虑引入顶点类 `Vertex` ,使得每个顶点都是唯一的对象,此时删除顶点时就无须改动其余顶点了。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
|
|
@ -247,7 +247,7 @@ BFS 通常借助队列来实现。队列具有“先入先出”的性质,这
|
|||
[class]{}-[func]{graph_dfs}
|
||||
```
|
||||
|
||||
深度优先遍历的算法流程如下图所示,其中:
|
||||
深度优先遍历的算法流程如下图所示。
|
||||
|
||||
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点。
|
||||
- **曲虚线代表向上回溯**,表示此递归方法已经返回,回溯到了开启此递归方法的位置。
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
### 贪心策略确定
|
||||
|
||||
最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出下图所示的贪心策略:
|
||||
最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出下图所示的贪心策略。
|
||||
|
||||
1. 将物品按照单位价值从高到低进行排序。
|
||||
2. 遍历所有物品,**每轮贪心地选择单位价值最高的物品**。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
贪心算法是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解。贪心算法简洁且高效,在许多实际问题中都有着广泛的应用。
|
||||
|
||||
贪心算法和动态规划都常用于解决优化问题。它们有一些相似之处,比如都依赖最优子结构性质。两者的不同点在于:
|
||||
贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理是不同的。
|
||||
|
||||
- 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
|
||||
- 贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。
|
||||
|
@ -105,7 +105,7 @@
|
|||
|
||||
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
|
||||
|
||||
一般情况下,贪心算法适用于以下两类问题:
|
||||
一般情况下,贪心算法适用于以下两类问题。
|
||||
|
||||
1. **可以保证找到最优解**:贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效。
|
||||
2. **可以找到近似最优解**:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解是非常困难的,能以较高效率找到次优解也是非常不错的。
|
||||
|
@ -114,7 +114,7 @@
|
|||
|
||||
那么问题来了,什么样的问题适合用贪心算法求解呢?或者说,贪心算法在什么情况下可以保证找到最优解?
|
||||
|
||||
相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质:
|
||||
相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质。
|
||||
|
||||
- **贪心选择性质**:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
|
||||
- **最优子结构**:原问题的最优解包含子问题的最优解。
|
||||
|
@ -133,13 +133,13 @@
|
|||
|
||||
## 贪心解题步骤
|
||||
|
||||
贪心问题的解决流程大体可分为三步:
|
||||
贪心问题的解决流程大体可分为以下三步。
|
||||
|
||||
1. **问题分析**:梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。
|
||||
2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终能解决整个问题。
|
||||
3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要使用到数学证明,例如归纳法或反证法等。
|
||||
|
||||
确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,原因包括:
|
||||
确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要包含以下原因。
|
||||
|
||||
- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略都比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。
|
||||
- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是个典型案例。
|
||||
|
@ -150,7 +150,7 @@
|
|||
|
||||
## 贪心典型例题
|
||||
|
||||
贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下是一些典型的贪心算法问题:
|
||||
贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下列举了一些典型的贪心算法问题。
|
||||
|
||||
1. **硬币找零问题**:在某些硬币组合下,贪心算法总是可以得到最优解。
|
||||
2. **区间调度问题**:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
|
||||
|
|
|
@ -26,10 +26,9 @@ $$
|
|||
|
||||
![初始状态](max_capacity_problem.assets/max_capacity_initial_state.png)
|
||||
|
||||
如下图所示,**若此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。这是因为在移动长板 $j$ 后:
|
||||
如下图所示,**若此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。
|
||||
|
||||
- 宽度 $j-i$ 肯定变小。
|
||||
- 高度由短板决定,因此高度只可能不变( $i$ 仍为短板)或变小(移动后的 $j$ 成为短板)。
|
||||
这是因为在移动长板 $j$ 后,宽度 $j-i$ 肯定变小;而高度由短板决定,因此高度只可能不变( $i$ 仍为短板)或变小(移动后的 $j$ 成为短板)。
|
||||
|
||||
![向内移动长板后的状态](max_capacity_problem.assets/max_capacity_moving_long_board.png)
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ $$
|
|||
|
||||
![最优切分因子](max_product_cutting_problem.assets/max_product_cutting_greedy_infer3.png)
|
||||
|
||||
总结以上,可推出贪心策略:
|
||||
总结以上,可推出以下贪心策略。
|
||||
|
||||
1. 输入整数 $n$ ,从其不断地切分出因子 $3$ ,直至余数为 $0$ , $1$ , $2$ 。
|
||||
2. 当余数为 $0$ 时,代表 $n$ 是 $3$ 的倍数,因此不做任何处理。
|
||||
|
@ -137,7 +137,7 @@ $$
|
|||
|
||||
![最大切分乘积的计算方法](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png)
|
||||
|
||||
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有:
|
||||
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有三种。
|
||||
|
||||
- 运算符 `**` 和函数 `pow()` 的时间复杂度均为 $O(\log a)$ 。
|
||||
- 函数 `math.pow()` 内部调用 C 语言库的 `pow()` 函数,其执行浮点取幂,时间复杂度为 $O(1)$ 。
|
||||
|
|
|
@ -18,18 +18,18 @@ index = hash(key) % capacity
|
|||
|
||||
## 哈希算法的目标
|
||||
|
||||
为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点:
|
||||
为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。
|
||||
|
||||
- **确定性**:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
|
||||
- **效率高**:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
|
||||
- **均匀分布**:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。
|
||||
|
||||
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。举两个例子:
|
||||
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
|
||||
|
||||
- **密码存储**:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
|
||||
- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。
|
||||
|
||||
对于密码学的相关应用,哈希算法需要满足更高的安全标准,以防止从哈希值推导出原始密码等逆向工程,包括:
|
||||
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。
|
||||
|
||||
- **抗碰撞性**:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
|
||||
- **雪崩效应**:输入的微小变化应当导致输出的显著且不可预测的变化。
|
||||
|
@ -38,7 +38,7 @@ index = hash(key) % capacity
|
|||
|
||||
## 哈希算法的设计
|
||||
|
||||
哈希算法的设计是一个复杂且需要考虑许多因素的问题。然而对于简单场景,我们也能设计一些简单的哈希算法。以字符串哈希为例:
|
||||
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
|
||||
|
||||
- **加法哈希**:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
|
||||
- **乘法哈希**:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
|
||||
|
@ -223,7 +223,7 @@ $$
|
|||
|
||||
在实际中,我们通常会用一些标准哈希算法,例如 MD5 , SHA-1 , SHA-2 , SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
|
||||
|
||||
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。直至目前:
|
||||
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。
|
||||
|
||||
- MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用。
|
||||
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安全应用与协议中。
|
||||
|
@ -239,7 +239,7 @@ $$
|
|||
|
||||
## 数据结构的哈希值
|
||||
|
||||
我们知道,哈希表的 `key` 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 `hash()` 函数来计算各种数据类型的哈希值,包括:
|
||||
我们知道,哈希表的 `key` 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 `hash()` 函数来计算各种数据类型的哈希值。
|
||||
|
||||
- 整数和布尔量的哈希值就是其本身。
|
||||
- 浮点数和字符串的哈希值计算较为复杂,有兴趣的同学请自行学习。
|
||||
|
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
上节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一数组索引。
|
||||
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们切换一下思路:
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下思路。
|
||||
|
||||
1. 改良哈希表数据结构,**使得哈希表可以在存在哈希冲突时正常工作**。
|
||||
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
|
||||
|
||||
哈希表的结构改良方法主要包括链式地址和开放寻址。
|
||||
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
|
||||
|
||||
## 链式地址
|
||||
|
||||
|
@ -15,21 +15,21 @@
|
|||
|
||||
![链式地址哈希表](hash_collision.assets/hash_table_chaining.png)
|
||||
|
||||
链式地址下,哈希表的操作方法包括:
|
||||
哈希表在链式地址下的操作方法发生了一些变化。
|
||||
|
||||
- **查询元素**:输入 `key` ,经过哈希函数得到数组索引,即可访问链表头节点,然后遍历链表并对比 `key` 以查找目标键值对。
|
||||
- **添加元素**:先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中。
|
||||
- **删除元素**:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除。
|
||||
|
||||
该方法存在一些局限性,包括:
|
||||
链式地址存在以下局限性。
|
||||
|
||||
- **占用空间增大**,链表包含节点指针,它相比数组更加耗费内存空间。
|
||||
- **查询效率降低**,因为需要线性遍历链表来查找对应元素。
|
||||
|
||||
以下给出了链式地址哈希表的简单实现,需要注意:
|
||||
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
|
||||
|
||||
- 为了使得代码尽量简短,我们使用列表(动态数组)代替链表。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
|
||||
- 以下代码实现了哈希表扩容方法。具体来看,当负载因子超过 $0.75$ 时,我们将哈希表扩容至 $2$ 倍。
|
||||
- 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
|
||||
- 以下实现包含哈希表扩容方法。当负载因子超过 $0.75$ 时,我们将哈希表扩容至 $2$ 倍。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -113,7 +113,7 @@
|
|||
|
||||
### 线性探测
|
||||
|
||||
线性探测采用固定步长的线性查找来进行探测,对应的哈希表操作方法为:
|
||||
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
|
||||
|
||||
- **插入元素**:通过哈希函数计算数组索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 $1$ ),直至找到空位,将元素插入其中。
|
||||
- **查找元素**:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空位,说明目标键值对不在哈希表中,返回 $\text{None}$ 。
|
||||
|
@ -122,12 +122,12 @@
|
|||
|
||||
![开放寻址和线性探测](hash_collision.assets/hash_table_linear_probing.png)
|
||||
|
||||
然而,线性探测存在以下缺陷:
|
||||
然而,线性探测存在以下缺陷。
|
||||
|
||||
- **不能直接删除元素**。删除元素会在数组内产生一个空位,当查找该空位之后的元素时,该空位可能导致程序误判元素不存在。为此,通常需要借助一个标志位来标记已删除元素。
|
||||
- **容易产生聚集**。数组内连续被占用位置越长,这些连续位置发生哈希冲突的可能性越大,进一步促使这一位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
|
||||
|
||||
以下代码实现了一个简单的开放寻址(线性探测)哈希表。值得注意两点:
|
||||
以下代码实现了一个简单的开放寻址(线性探测)哈希表。
|
||||
|
||||
- 我们使用一个固定的键值对实例 `removed` 来标记已删除元素。也就是说,当一个桶内的元素为 $\text{None}$ 或 `removed` 时,说明这个桶是空的,可用于放置键值对。
|
||||
- 在线性探测时,我们从当前索引 `index` 向后遍历;而当越过数组尾部时,需要回到头部继续遍历。
|
||||
|
|
|
@ -441,7 +441,7 @@
|
|||
|
||||
那么,如何基于 `key` 来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` ,**我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
|
||||
|
||||
输入一个 `key` ,哈希函数的计算过程分为两步:
|
||||
输入一个 `key` ,哈希函数的计算过程分为以下两步。
|
||||
|
||||
1. 通过某种哈希算法 `hash()` 计算得到哈希值。
|
||||
2. 将哈希值对桶数量(数组长度)`capacity` 取模,从而获取该 `key` 对应的数组索引 `index` 。
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# 堆
|
||||
|
||||
「堆 heap」是一种满足特定条件的完全二叉树,主要可分为下图所示的两种类型:
|
||||
「堆 heap」是一种满足特定条件的完全二叉树,主要可分为下图所示的两种类型。
|
||||
|
||||
- 「大顶堆 max heap」:任意节点的值 $\geq$ 其子节点的值。
|
||||
- 「小顶堆 min heap」:任意节点的值 $\leq$ 其子节点的值。
|
||||
|
||||
![小顶堆与大顶堆](heap.assets/min_heap_and_max_heap.png)
|
||||
|
||||
堆作为完全二叉树的一个特例,具有以下特性:
|
||||
堆作为完全二叉树的一个特例,具有以下特性。
|
||||
|
||||
- 最底层节点靠左填充,其他层的节点都被填满。
|
||||
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
|
||||
|
@ -656,7 +656,7 @@
|
|||
|
||||
### 堆顶元素出堆
|
||||
|
||||
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
|
||||
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤。
|
||||
|
||||
1. 交换堆顶元素与堆底元素(即交换根节点与最右叶节点)。
|
||||
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素)。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 算法定义
|
||||
|
||||
「算法 algorithm」是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性:
|
||||
「算法 algorithm」是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性。
|
||||
|
||||
- 问题是明确的,包含清晰的输入和输出定义。
|
||||
- 具有可行性,能够在有限步骤、时间和内存空间下完成。
|
||||
|
@ -10,13 +10,13 @@
|
|||
|
||||
## 数据结构定义
|
||||
|
||||
「数据结构 data structure」是计算机中组织和存储数据的方式,它的设计目标如下:
|
||||
「数据结构 data structure」是计算机中组织和存储数据的方式,具有以下设计目标。
|
||||
|
||||
- 空间占用尽量减少,节省计算机内存。
|
||||
- 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
|
||||
- 提供简洁的数据表示和逻辑信息,以便使得算法高效运行。
|
||||
|
||||
**数据结构设计是一个充满权衡的过程**。如果想要在某方面取得提升,往往需要在另一方面作出妥协,例如:
|
||||
**数据结构设计是一个充满权衡的过程**。如果想要在某方面取得提升,往往需要在另一方面作出妥协。
|
||||
|
||||
- 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度。
|
||||
- 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间。
|
||||
|
|
|
@ -206,7 +206,7 @@ git clone https://github.com/krahets/hello-algo.git
|
|||
|
||||
## 算法学习路线
|
||||
|
||||
从总体上看,我们可以将学习数据结构与算法的过程划分为三个阶段:
|
||||
从总体上看,我们可以将学习数据结构与算法的过程划分为三个阶段。
|
||||
|
||||
1. **算法入门**。我们需要熟悉各种数据结构的特点和用法,学习不同算法的原理、流程、用途和效率等方面内容。
|
||||
2. **刷算法题**。建议从热门题目开刷,如[剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)和[LeetCode Hot 100](https://leetcode.cn/problem-list/2cktkvj/),先积累至少 100 道题目,熟悉主流的算法问题。初次刷题时,“知识遗忘”可能是一个挑战,但请放心,这是很正常的。我们可以按照“艾宾浩斯遗忘曲线”来复习题目,通常在进行 3-5 轮的重复后,就能将其牢记在心。
|
||||
|
|
|
@ -10,10 +10,10 @@
|
|||
|
||||
如下图所示,我们先初始化指针 $i = 0$ 和 $j = n - 1$ ,分别指向数组首元素和尾元素,代表搜索区间 $[0, n - 1]$ 。请注意,中括号表示闭区间,其包含边界值本身。
|
||||
|
||||
接下来,循环执行以下两个步骤:
|
||||
接下来,循环执行以下两步。
|
||||
|
||||
1. 计算中点索引 $m = \lfloor {(i + j) / 2} \rfloor$ ,其中 $\lfloor \space \rfloor$ 表示向下取整操作。
|
||||
2. 判断 `nums[m]` 和 `target` 的大小关系,分为三种情况:
|
||||
2. 判断 `nums[m]` 和 `target` 的大小关系,分为以下三种情况。
|
||||
1. 当 `nums[m] < target` 时,说明 `target` 在区间 $[m + 1, j]$ 中,因此执行 $i = m + 1$ 。
|
||||
2. 当 `nums[m] > target` 时,说明 `target` 在区间 $[i, m - 1]$ 中,因此执行 $j = m - 1$ 。
|
||||
3. 当 `nums[m] = target` 时,说明找到 `target` ,因此返回索引 $m$ 。
|
||||
|
@ -205,12 +205,12 @@
|
|||
|
||||
## 优点与局限性
|
||||
|
||||
二分查找在时间和空间方面都有较好的性能:
|
||||
二分查找在时间和空间方面都有较好的性能。
|
||||
|
||||
- 二分查找的时间效率高。在大数据量下,对数阶的时间复杂度具有显著优势。例如,当数据大小 $n = 2^{20}$ 时,线性查找需要 $2^{20} = 1048576$ 轮循环,而二分查找仅需 $\log_2 2^{20} = 20$ 轮循环。
|
||||
- 二分查找无须额外空间。相较于需要借助额外空间的搜索算法(例如哈希查找),二分查找更加节省空间。
|
||||
|
||||
然而,二分查找并非适用于所有情况,原因如下:
|
||||
然而,二分查找并非适用于所有情况,主要有以下原因。
|
||||
|
||||
- 二分查找仅适用于有序数据。若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 $O(n \log n)$ ,比线性查找和二分查找都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
|
||||
- 二分查找仅适用于数组。二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
|
||||
|
|
|
@ -8,10 +8,10 @@
|
|||
|
||||
回忆二分查找插入点的方法,搜索完成后 $i$ 指向最左一个 `target` ,**因此查找插入点本质上是在查找最左一个 `target` 的索引**。
|
||||
|
||||
考虑通过查找插入点的函数实现查找左边界。请注意,数组中可能不包含 `target` ,此时有两种可能:
|
||||
考虑通过查找插入点的函数实现查找左边界。请注意,数组中可能不包含 `target` ,这种情况可能导致以下两种结果。
|
||||
|
||||
1. 插入点的索引 $i$ 越界;
|
||||
2. 元素 `nums[i]` 与 `target` 不相等;
|
||||
- 插入点的索引 $i$ 越界。
|
||||
- 元素 `nums[i]` 与 `target` 不相等。
|
||||
|
||||
当遇到以上两种情况时,直接返回 $-1$ 即可。
|
||||
|
||||
|
@ -179,14 +179,14 @@
|
|||
|
||||
我们知道,当数组不包含 `target` 时,最后 $i$ , $j$ 会分别指向首个大于、小于 `target` 的元素。
|
||||
|
||||
根据上述结论,我们可以构造一个数组中不存在的元素,用于查找左右边界,如下图所示。
|
||||
因此,如下图所示,我们可以构造一个数组中不存在的元素,用于查找左右边界。
|
||||
|
||||
- 查找最左一个 `target` :可以转化为查找 `target - 0.5` ,并返回指针 $i$ 。
|
||||
- 查找最右一个 `target` :可以转化为查找 `target + 0.5` ,并返回指针 $j$ 。
|
||||
|
||||
![将查找边界转化为查找元素](binary_search_edge.assets/binary_search_edge_by_element.png)
|
||||
|
||||
代码在此省略,值得注意的有:
|
||||
代码在此省略,值得注意以下两点。
|
||||
|
||||
- 给定数组不包含小数,这意味着我们无须关心如何处理相等的情况。
|
||||
- 因为该方法引入了小数,所以需要将函数中的变量 `target` 改为浮点数类型。
|
||||
|
|
|
@ -111,7 +111,7 @@
|
|||
|
||||
此方法虽然可用,但其包含线性查找,因此时间复杂度为 $O(n)$ 。当数组中存在很多重复的 `target` 时,该方法效率很低。
|
||||
|
||||
现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target` 和 `nums[m]` 大小关系:
|
||||
现考虑拓展二分查找代码。如下图所示,整体流程保持不变,每轮先计算中点索引 $m$ ,再判断 `target` 和 `nums[m]` 大小关系。
|
||||
|
||||
1. 当 `nums[m] < target` 或 `nums[m] > target` 时,说明还没有找到 `target` ,因此采用普通二分查找的缩小区间操作,**从而使指针 $i$ 和 $j$ 向 `target` 靠近**。
|
||||
2. 当 `nums[m] == target` 时,说明小于 `target` 的元素在区间 $[i, m - 1]$ 中,因此采用 $j = m - 1$ 来缩小区间,**从而使指针 $j$ 向小于 `target` 的元素靠近**。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
「搜索算法 searching algorithm」用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。
|
||||
|
||||
根据实现思路,搜索算法总体可分为两种:
|
||||
搜索算法可根据实现思路分为以下两类。
|
||||
|
||||
- **通过遍历数据结构来定位目标元素**,例如数组、链表、树和图的遍历等。
|
||||
- **利用数据组织结构或数据包含的先验信息,实现高效元素查找**,例如二分查找、哈希查找和二叉搜索树查找等。
|
||||
|
|
|
@ -92,13 +92,13 @@
|
|||
|
||||
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。假设输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
|
||||
|
||||
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的“前缀和”。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和,即:
|
||||
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的“前缀和”。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和:
|
||||
|
||||
$$
|
||||
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}
|
||||
$$
|
||||
|
||||
**前缀和具有明确的意义,`prefix[num] - 1` 代表元素 `num` 在结果数组 `res` 中最后一次出现的索引**。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 `nums` 的每个元素 `num` ,在每轮迭代中执行:
|
||||
**前缀和具有明确的意义,`prefix[num] - 1` 代表元素 `num` 在结果数组 `res` 中最后一次出现的索引**。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 `nums` 的每个元素 `num` ,在每轮迭代中执行以下两步。
|
||||
|
||||
1. 将 `num` 填入数组 `res` 的索引 `prefix[num] - 1` 处。
|
||||
2. 令前缀和 `prefix[num]` 减小 $1$ ,从而得到下次放置 `num` 的索引。
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
阅读本节前,请确保已学完“堆“章节。
|
||||
|
||||
「堆排序 heap sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序:
|
||||
「堆排序 heap sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序。
|
||||
|
||||
1. 输入数组并建立小顶堆,此时最小元素位于堆顶。
|
||||
2. 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。
|
||||
|
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
实际上,许多编程语言(例如 Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
|
||||
|
||||
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 $O(n^2)$ ,但在实际情况中,**插入排序的使用频率显著高于冒泡排序和选择排序**。这是因为:
|
||||
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 $O(n^2)$ ,但在实际情况中,**插入排序的使用频率显著高于冒泡排序和选择排序**,主要有以下原因。
|
||||
|
||||
- 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及 3 个单元操作;插入排序基于元素赋值实现,仅需 1 个单元操作。因此,**冒泡排序的计算开销通常比插入排序更高**。
|
||||
- 选择排序在任何情况下的时间复杂度都为 $O(n^2)$ 。**如果给定一组部分有序的数据,插入排序通常比选择排序效率更高**。
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 归并排序
|
||||
|
||||
「归并排序 merge sort」是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段:
|
||||
「归并排序 merge sort」是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段。
|
||||
|
||||
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
|
||||
2. **合并阶段**:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。
|
||||
|
@ -9,7 +9,7 @@
|
|||
|
||||
## 算法流程
|
||||
|
||||
如下图所示,“划分阶段”从顶至底递归地将数组从中点切为两个子数组:
|
||||
如下图所示,“划分阶段”从顶至底递归地将数组从中点切为两个子数组。
|
||||
|
||||
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]` )。
|
||||
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分。
|
||||
|
@ -46,7 +46,7 @@
|
|||
=== "<10>"
|
||||
![merge_sort_step10](merge_sort.assets/merge_sort_step10.png)
|
||||
|
||||
观察发现,归并排序的递归顺序与二叉树的后序遍历相同,对比来看:
|
||||
观察发现,归并排序与二叉树后序遍历的递归顺序是一致的。
|
||||
|
||||
- **后序遍历**:先递归左子树,再递归右子树,最后处理根节点。
|
||||
- **归并排序**:先递归左子数组,再递归右子数组,最后处理合并。
|
||||
|
@ -147,9 +147,9 @@
|
|||
[class]{}-[func]{merge_sort}
|
||||
```
|
||||
|
||||
合并方法 `merge()` 代码中的难点包括:
|
||||
实现合并函数 `merge()` 存在以下难点。
|
||||
|
||||
- **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]` 。
|
||||
- **需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]` 。
|
||||
- 在比较 `tmp[i]` 和 `tmp[j]` 的大小时,**还需考虑子数组遍历完成后的索引越界问题**,即 `i > leftEnd` 和 `j > rightEnd` 的情况。索引越界的优先级是最高的,如果左子数组已经被合并完了,那么不需要继续比较,直接合并右子数组元素即可。
|
||||
|
||||
## 算法特性
|
||||
|
@ -160,9 +160,9 @@
|
|||
|
||||
## 链表排序 *
|
||||
|
||||
归并排序在排序链表时具有显著优势,空间复杂度可以优化至 $O(1)$ ,原因如下:
|
||||
对于链表,归并排序相较于其他排序算法具有显著优势,**可以将链表排序任务的空间复杂度优化至 $O(1)$** 。
|
||||
|
||||
- 由于链表仅需改变指针就可实现节点的增删操作,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建辅助链表。
|
||||
- 通过使用“迭代划分”替代“递归划分”,可省去递归使用的栈帧空间。
|
||||
- **划分阶段**:可以通过使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
|
||||
- **合并阶段**:在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建额外链表。
|
||||
|
||||
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。
|
||||
|
|
|
@ -221,7 +221,7 @@
|
|||
|
||||
## 快排为什么快?
|
||||
|
||||
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,原因如下:
|
||||
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
|
||||
|
||||
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行。
|
||||
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
|
||||
|
|
|
@ -475,7 +475,7 @@
|
|||
|
||||
在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
|
||||
|
||||
综上所述,当入栈与出栈操作的元素是基本数据类型(如 `int` , `double` )时,我们可以得出以下结论:
|
||||
综上所述,当入栈与出栈操作的元素是基本数据类型(如 `int` , `double` )时,我们可以得出以下结论。
|
||||
|
||||
- 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。
|
||||
- 基于链表实现的栈可以提供更加稳定的效率表现。
|
||||
|
|
|
@ -124,7 +124,7 @@
|
|||
|
||||
![完全二叉树的数组表示](array_representation_of_tree.assets/array_representation_complete_binary_tree.png)
|
||||
|
||||
如下代码给出了数组表示下的二叉树的简单实现,包括以下操作:
|
||||
以下代码实现了一个基于数组表示的二叉树,包括以下几种操作。
|
||||
|
||||
- 给定某节点,获取它的值、左(右)子节点、父节点。
|
||||
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列。
|
||||
|
@ -203,13 +203,13 @@
|
|||
|
||||
## 优势与局限性
|
||||
|
||||
二叉树的数组表示的优点包括:
|
||||
二叉树的数组表示主要有以下优点。
|
||||
|
||||
- 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快。
|
||||
- 不需要存储指针,比较节省空间。
|
||||
- 允许随机访问节点。
|
||||
|
||||
然而,数组表示也具有一些局限性:
|
||||
然而,数组表示也存在一些局限性。
|
||||
|
||||
- 数组存储需要连续内存空间,因此不适合存储数据量过大的树。
|
||||
- 增删节点需要通过数组插入与删除操作实现,效率较低。
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 二叉搜索树
|
||||
|
||||
如下图所示,「二叉搜索树 binary search tree」满足以下条件:
|
||||
如下图所示,「二叉搜索树 binary search tree」满足以下条件。
|
||||
|
||||
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值。
|
||||
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.` 。
|
||||
|
@ -13,7 +13,7 @@
|
|||
|
||||
### 查找节点
|
||||
|
||||
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。如下图所示,我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val` 和 `num` 之间的大小关系:
|
||||
给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。如下图所示,我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val` 和 `num` 之间的大小关系。
|
||||
|
||||
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right` 。
|
||||
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left` 。
|
||||
|
@ -114,7 +114,7 @@
|
|||
|
||||
![在二叉搜索树中插入节点](binary_search_tree.assets/bst_insert.png)
|
||||
|
||||
在代码实现中,需要注意以下两点:
|
||||
在代码实现中,需要注意以下两点。
|
||||
|
||||
- 二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回。
|
||||
- 为了实现插入节点,我们需要借助节点 `pre` 保存上一轮循环的节点。这样在遍历至 $\text{None}$ 时,我们可以获取到其父节点,从而完成节点插入操作。
|
||||
|
@ -195,9 +195,12 @@
|
|||
|
||||
### 删除节点
|
||||
|
||||
与插入节点类似,我们需要在删除操作后维持二叉搜索树的“左子树 < 根节点 < 右子树”的性质。首先,我们需要在二叉树中执行查找操作,获取待删除节点。接下来,根据待删除节点的子节点数量,删除操作需分为三种情况:
|
||||
与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。
|
||||
|
||||
如下图所示,当待删除节点的度为 $0$ 时,表示待删除节点是叶节点,可以直接删除。
|
||||
1. 在二叉树中执行查找操作,获取待删除节点。
|
||||
2. 根据待删除节点的子节点数量(三种情况),执行对应的删除节点操作。
|
||||
|
||||
如下图所示,当待删除节点的度为 $0$ 时,表示该节点是叶节点,可以直接删除。
|
||||
|
||||
![在二叉搜索树中删除节点(度为 0)](binary_search_tree.assets/bst_remove_case1.png)
|
||||
|
||||
|
@ -207,7 +210,7 @@
|
|||
|
||||
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,**因此这个节点可以是右子树的最小节点或左子树的最大节点**。
|
||||
|
||||
假设我们选择右子树的最小节点(即中序遍历的下一个节点),则删除操作如下图所示。
|
||||
假设我们选择右子树的最小节点(即中序遍历的下一个节点),则删除操作流程如下图所示。
|
||||
|
||||
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 `tmp` 。
|
||||
2. 将 `tmp` 的值覆盖待删除节点的值,并在树中递归删除节点 `tmp` 。
|
||||
|
@ -298,21 +301,19 @@
|
|||
[class]{BinarySearchTree}-[func]{remove}
|
||||
```
|
||||
|
||||
### 中序遍历性质
|
||||
### 中序遍历有序
|
||||
|
||||
如下图所示,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。
|
||||
|
||||
这意味着在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
||||
|
||||
利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 $O(n)$ 时间,无须额外排序,非常高效。
|
||||
利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 $O(n)$ 时间,无须进行额外的排序操作,非常高效。
|
||||
|
||||
![二叉搜索树的中序遍历序列](binary_search_tree.assets/bst_inorder_traversal.png)
|
||||
|
||||
## 二叉搜索树的效率
|
||||
|
||||
给定一组数据,我们考虑使用数组或二叉搜索树存储。
|
||||
|
||||
观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
|
||||
给定一组数据,我们考虑使用数组或二叉搜索树存储。观察下表,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
|
||||
|
||||
<p align="center"> 表 <id> 数组与搜索树的效率对比 </p>
|
||||
|
||||
|
@ -326,7 +327,7 @@
|
|||
|
||||
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
|
||||
|
||||
![二叉搜索树的平衡与退化](binary_search_tree.assets/bst_degradation.png)
|
||||
![二叉搜索树的退化](binary_search_tree.assets/bst_degradation.png)
|
||||
|
||||
## 二叉搜索树常见应用
|
||||
|
||||
|
|
|
@ -226,7 +226,7 @@
|
|||
|
||||
我们也可以不使用递归,仅基于迭代实现前、中、后序遍历,有兴趣的同学可以自行实现。
|
||||
|
||||
下图展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分:
|
||||
下图展示了前序遍历二叉树的递归过程,其可分为“递”和“归”两个逆向的部分。
|
||||
|
||||
1. “递”表示开启新方法,程序在此过程中访问下一个节点。
|
||||
2. “归”表示函数返回,代表当前节点已经访问完毕。
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
!!! question "在 Java 中,字符串对比是否一定要用 `equals()` 方法?"
|
||||
|
||||
在 Java 中,对于基本数据类型,`==` 用于对比两个变量的值是否相等。对于引用类型,两种符号的工作原理不同:
|
||||
在 Java 中,对于基本数据类型,`==` 用于对比两个变量的值是否相等。对于引用类型,两种符号的工作原理是不同的。
|
||||
|
||||
- `==` :用来比较两个变量是否指向同一个对象,即它们在内存中的位置是否相同。
|
||||
- `equals()`:用来对比两个对象的值是否相等。
|
||||
|
|
Loading…
Reference in a new issue