1. lower-case nouns

2. fix 2 figures
3. Replace some 「」 by “”
pull/691/head
krahets 1 year ago
parent 2626de8d0b
commit 981144e42d

@ -12,9 +12,9 @@
## 内容微调
在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码:
您可以按照以下步骤修改文本或代码:
1. 点击编辑按钮,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。
1. 点击页面的右上角的“编辑图标”,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。
2. 修改 Markdown 源文件内容,检查内容的正确性,并尽量保持排版格式的统一。
3. 在页面底部填写修改说明然后点击“Propose file change”按钮。页面跳转后点击“Create pull request”按钮即可发起拉取请求。

@ -1,6 +1,6 @@
# 数组
「数组 Array」是一种线性数据结构其将相同类型元素存储在连续的内存空间中。我们将某个元素在数组中的位置称为该元素的「索引 Index」。
「数组 array」是一种线性数据结构其将相同类型元素存储在连续的内存空间中。我们将某个元素在数组中的位置称为该元素的「索引 index」。
![数组定义与存储方式](array.assets/array_definition.png)
@ -445,7 +445,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。
因为数组是线性数据结构,所以上述查找操作被称为「线性查找」
因为数组是线性数据结构,所以上述查找操作被称为“线性查找”
=== "Java"

@ -2,17 +2,17 @@
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
「链表 Linked List」是一种线性数据结构其中的每个元素都是一个节点对象各个节点通过“引用”相连接。引用记录了下一个节点的内存地址我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处它们的内存地址是无须连续的。
「链表 linked list」是一种线性数据结构其中的每个元素都是一个节点对象各个节点通过“引用”相连接。引用记录了下一个节点的内存地址我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处它们的内存地址是无须连续的。
![链表定义与存储方式](linked_list.assets/linkedlist_definition.png)
观察上图,链表中的每个「节点 Node」对象都包含两项数据节点的“值”、指向下一节点的“引用”。
观察上图,链表的组成单位是「节点 node」对象。每个节点都包含两项数据节点的“值”和指向下一节点的“引用”。
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
- 尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。
- 在 C, C++, Go, Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。
如以下代码所示,链表以节点对象 `ListNode` 为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。因此在相同数据量下,**链表通常比数组占用更多的内存空间**。
链表节点 `ListNode` 如以下代码所示。每个节点除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,**链表比数组占用更多的内存空间**。
=== "Java"
@ -633,7 +633,7 @@
### 查找节点
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于线性查找
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于线性查找。
=== "Java"

@ -2,7 +2,7 @@
**数组长度不可变导致实用性降低**。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构即长度可变的数组也常被称为「列表 List」。列表基于数组实现继承了数组的优点并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素而无须担心超过容量限制。
为解决此问题,出现了一种被称为「动态数组 dynamic array」的数据结构即长度可变的数组也常被称为「列表 list」。列表基于数组实现继承了数组的优点并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素而无须担心超过容量限制。
## 列表常用操作

@ -1,8 +1,8 @@
# 回溯算法
「回溯算法 Backtracking Algorithm」是一种通过穷举来解决问题的方法它的核心思想是从一个初始状态出发暴力搜索所有可能的解决方案当遇到正确的解则将其记录直到找到解或者尝试了所有可能的选择都无法找到解为止。
「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法它的核心思想是从一个初始状态出发暴力搜索所有可能的解决方案当遇到正确的解则将其记录直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用「深度优先搜索」来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
!!! question "例题一"

@ -290,7 +290,7 @@
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
而与时间复杂度不同的是,**我们通常只关注最差空间复杂度**。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
而与时间复杂度不同的是,**我们通常只关注最差空间复杂度**。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
@ -1077,7 +1077,7 @@ $$
### 指数阶 $O(2^n)$
指数阶常见于二叉树。高度为 $n$ 的「满二叉树」的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
指数阶常见于二叉树。高度为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
=== "Java"

@ -187,7 +187,7 @@ $$
## 统计时间增长趋势
时间复杂度分析」采取了一种不同的方法,其统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法函数 `A``B``C`
@ -426,11 +426,11 @@ $$
}
```
算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为「常数阶」
算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为“常数阶”
算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为「线性阶」
算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为“线性阶”
算法 `C` 中的打印操作需要循环 $1000000$ 次,虽然运行时间很长,但它与输入数据大小 $n$ 无关。因此 `C` 的时间复杂度和 `A` 相同,仍为「常数阶」
算法 `C` 中的打印操作需要循环 $1000000$ 次,虽然运行时间很长,但它与输入数据大小 $n$ 无关。因此 `C` 的时间复杂度和 `A` 相同,仍为“常数阶”
![算法 A 、B 和 C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
@ -1442,7 +1442,7 @@ $$
[class]{}-[func]{exp_recur}
```
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划贪心等算法来解决。
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决。
### 对数阶 $O(\log n)$
@ -1771,7 +1771,7 @@ $$
- 当 `nums = [?, ?, ..., 1]` ,即当末尾元素是 $1$ 时,需要完整遍历数组,**达到最差时间复杂度 $O(n)$** 。
- 当 `nums = [1, ?, ?, ...]` ,即当首个元素为 $1$ 时,无论数组多长都不需要继续遍历,**达到最佳时间复杂度 $\Omega(1)$** 。
「最差时间复杂度」对应函数渐近上界,使用大 $O$ 记号表示。相应地,「最佳时间复杂度」对应函数渐近下界,用 $\Omega$ 记号表示:
“最差时间复杂度”对应函数渐近上界,使用大 $O$ 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 $\Omega$ 记号表示:
=== "Java"
@ -1888,9 +1888,9 @@ $$
[class]{}-[func]{find_one}
```
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。
从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 $\frac{n}{2}$ ,平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$ 。

@ -17,6 +17,7 @@
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字。
下表列举了各种基本数据类型的占用空间、取值范围和默认值。此表格无须硬背,大致理解即可,需要时可以通过查表来回忆。
<p align="center"> 表:基本数据类型的占用空间和取值范围 </p>
| 类型 | 符号 | 占用空间 | 最小值 | 最大值 | 默认值 |
@ -78,7 +79,7 @@
=== "Go"
```go title=""
// 使用多种基本数据类型来初始化数组
// 使用多种基本数据类型来初始化数组
var numbers = [5]int{}
var decimals = [5]float64{}
var characters = [5]byte{}

@ -4,7 +4,7 @@
## 逻辑结构:线性与非线性
**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
**逻辑结构揭示了数据元素之间的逻辑关系**。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。
逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。
@ -31,7 +31,7 @@
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。**因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素**。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在离散的内存空间内。
**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。
**物理结构反映了数据在计算机内存中的存储方式**,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。
![连续空间存储与离散空间存储](classification_of_data_structure.assets/classification_phisical_structure.png)

@ -6,9 +6,9 @@
## 原码、反码和补码
从上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个。例如,`byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。
在展开分析之前,我们首先给出三者的定义:
实际上,**数字是以“补码”的形式存储在计算机中的**。在分析这样做的原因之前,我们首先给出三者的定义:
- **原码**:我们将数字的二进制表示的最高位视为符号位,其中 $0$ 表示正数,$1$ 表示负数,其余位表示数字的值。
- **反码**:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
@ -16,9 +16,7 @@
![原码、反码与补码之间的相互转换](number_encoding.assets/1s_2s_complement.png)
显然「原码」最为直观。但实际上,**数字是以「补码」的形式存储在计算机中的**。这是因为原码存在一些局限性。
一方面,**负数的原码不能直接用于运算**。例如,我们在原码下计算 $1 + (-2)$ ,得到的结果是 $-3$ ,这显然是不对的。
「原码 true form」虽然最直观但存在一些局限性。一方面**负数的原码不能直接用于运算**。例如在原码下计算 $1 + (-2)$ ,得到的结果是 $-3$ ,这显然是不对的。
$$
\begin{aligned}
@ -29,7 +27,7 @@ $$
\end{aligned}
$$
为了解决此问题,计算机引入了「反码」。如果我们先将原码转换为反码,并在反码下计算 $1 + (-2)$ ,最后将结果从反码转化回原码,则可得到正确结果 $-1$ 。
为了解决此问题,计算机引入了「反码 1's complement code」。如果我们先将原码转换为反码,并在反码下计算 $1 + (-2)$ ,最后将结果从反码转化回原码,则可得到正确结果 $-1$ 。
$$
\begin{aligned}
@ -51,7 +49,7 @@ $$
\end{aligned}
$$
与原码一样,反码也存在正负零歧义问题,因此计算机进一步引入了「补码」。我们先来观察一下负零的原码、反码、补码的转换过程:
与原码一样,反码也存在正负零歧义问题,因此计算机进一步引入了「补码 2's complement code」。我们先来观察一下负零的原码、反码、补码的转换过程:
$$
\begin{aligned}
@ -136,6 +134,7 @@ $$
**尽管浮点数 `float` 扩展了取值范围,但其副作用是牺牲了精度**。整数类型 `int` 将全部 32 位用于表示数字,数字是均匀分布的;而由于指数位的存在,浮点数 `float` 的数值越大,相邻两个数字之间的差值就会趋向越大。
进一步地,指数位 $E = 0$ 和 $E = 255$ 具有特殊含义,**用于表示零、无穷大、$\mathrm{NaN}$ 等**。
<p align="center"> 表:指数位含义 </p>
| 指数位 E | 分数位 $\mathrm{N} = 0$ | 分数位 $\mathrm{N} \ne 0$ | 计算公式 |

@ -40,6 +40,7 @@
- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$ 。
如下表所示,通过以上变量即可表示根节点在 `preorder` 中的索引,以及子树在 `inorder` 中的索引区间。
<p align="center"> 表:根节点和子树在前序和中序遍历中的索引 </p>
| | 根节点在 `preorder` 中的索引 | 子树在 `inorder` 中的索引区间 |

@ -1,11 +1,11 @@
# 分治算法
「分治 Divide and Conquer」全称分而治之是一种非常重要且常见的算法策略。分治通常基于递归实现包括“分”和“治”两步
「分治 divide and conquer」全称分而治之是一种非常重要且常见的算法策略。分治通常基于递归实现包括“分”和“治”两步
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
已介绍过的「归并排序」是分治策略的典型应用之一,它的分治策略为:
我们已学过的“归并排序”是分治策略的典型应用之一,其算法原理为:
1. **分**:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
2. **治**:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。
@ -34,7 +34,7 @@
### 操作数量优化
「冒泡排序」为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们把数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((\frac{n}{2})^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为:
“冒泡排序”为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们把数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((\frac{n}{2})^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为:
$$
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
@ -54,9 +54,9 @@ $$
**这意味着当 $n > 4$ 时,划分后的操作数量更少,排序效率应该更高**。请注意,划分后的时间复杂度仍然是平方阶 $O(n^2)$ ,只是复杂度中的常数项变小了。
进一步想,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是「归并排序」,时间复杂度为 $O(n \log n)$ 。
进一步想,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 $O(n \log n)$ 。
再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况与「桶排序」非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。
再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。
### 并行计算优化

@ -2,9 +2,9 @@
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。
实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
@ -26,7 +26,7 @@ $$
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
$$
这便可以引出最优子结构的含义:**原问题的最优解是从子问题的最优解构建得来的**。
这便可以引出最优子结构的含义:**原问题的最优解是从子问题的最优解构建得来的**。
本题显然具有最优子结构:我们从两个子问题最优解 $dp[i-1]$ , $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。
@ -184,7 +184,7 @@ $$
## 无后效性
无后效性是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。
无后效性是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。
以爬楼梯问题为例,给定状态 $i$ ,它会发展出状态 $i+1$ 和状态 $i+2$ ,分别对应跳 $1$ 步和跳 $2$ 步。在做出这两种选择时,我们无须考虑状态 $i$ 之前的状态,它们对状态 $i$ 的未来没有影响。

@ -29,7 +29,7 @@
动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 $dp$ 表,推导状态转移方程,确定边界条件等。
为了更形象地展示解题步骤,我们使用一个经典问题「最小路径和」来举例。
为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。
!!! question

@ -1,6 +1,6 @@
# 初探动态规划
「动态规划 Dynamic Programming」是一个重要的算法范式它将一个问题分解为一系列更小的子问题并通过存储子问题的解来避免重复计算从而大幅提升时间效率。
「动态规划 dynamic programming」是一个重要的算法范式它将一个问题分解为一系列更小的子问题并通过存储子问题的解来避免重复计算从而大幅提升时间效率。
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。
@ -239,7 +239,7 @@ $$
![爬楼梯对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
观察上图发现,**指数阶的时间复杂度是由于「重叠子问题」导致的**。例如:$dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ $dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
观察上图发现,**指数阶的时间复杂度是由于“重叠子问题”导致的**。例如:$dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ $dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

@ -1,6 +1,6 @@
# 图
「图 Graph」是一种非线性数据结构由「顶点 Vertex」和「边 Edge」组成。我们可以将图 $G$ 抽象地表示为一组顶点 $V$ 和一组边 $E$ 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。
「图 graph」是一种非线性数据结构由「顶点 vertex」和「边 edge」组成。我们可以将图 $G$ 抽象地表示为一组顶点 $V$ 和一组边 $E$ 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。
$$
\begin{aligned}
@ -12,43 +12,43 @@ $$
![链表、树、图之间的关系](graph.assets/linkedlist_tree_graph.png)
那么,图与其他数据结构的关系是什么?如果我们把顶点看作节点,把看作连接各个节点的指针,则可将看作是一种从链表拓展而来的数据结构。**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂**。
那么,图与其他数据结构的关系是什么?如果我们把顶点看作节点,把边看作连接各个节点的指针,则可将图看作是一种从链表拓展而来的数据结构。**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂**。
## 图常见类型
根据边是否具有方向,可分为「无向图 Undirected Graph」和「有向图 Directed Graph」。
根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」。
- 在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
- 在有向图中,边具有方向性,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
![有向图与无向图](graph.assets/directed_graph.png)
根据所有顶点是否连通,可分为「连通图 Connected Graph」和「非连通图 Disconnected Graph」。
根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」。
- 对于连通图,从某个顶点出发,可以到达其余任意顶点。
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达。
![连通图与非连通图](graph.assets/connected_graph.png)
我们还可以为边添加“权重”变量,从而得到「有权图 Weighted Graph」。例如在王者荣耀等手游中系统会根据共同游戏时间来计算玩家之间的“亲密度”这种亲密度网络就可以用有权图来表示。
我们还可以为边添加“权重”变量,从而得到「有权图 weighted graph」。例如在王者荣耀等手游中系统会根据共同游戏时间来计算玩家之间的“亲密度”这种亲密度网络就可以用有权图来表示。
![有权图与无权图](graph.assets/weighted_graph.png)
## 图常用术语
- 「邻接 Adjacency」当两顶点之间存在边相连时称这两顶点“邻接”。在上图中顶点 1 的邻接顶点为顶点 2、3、5。
- 「路径 Path」从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
- 「度 Degree」表示一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点「出度 Out-Degree」表示有多少条边从该顶点指出。
- 「邻接 adjacency」当两顶点之间存在边相连时称这两顶点“邻接”。在上图中顶点 1 的邻接顶点为顶点 2、3、5。
- 「路径 path」从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
- 「度 degree」一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点「出度 Out-Degree」表示有多少条边从该顶点指出。
## 图的表示
图的常用表示方法包括「邻接矩阵」和「邻接表」。以下使用无向图进行举例。
图的常用表示方法包括“邻接矩阵”和“邻接表”。以下使用无向图进行举例。
### 邻接矩阵
设图的顶点数量为 $n$ ,「邻接矩阵 Adjacency Matrix」使用一个 $n \times n$ 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间是否存在边。
设图的顶点数量为 $n$ ,「邻接矩阵 adjacency matrix」使用一个 $n \times n$ 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间是否存在边。
如下图所示,设邻接矩阵为 $M$ 、顶点列表为 $V$ ,那么矩阵元素 $M[i][j] = 1$ 表示顶点 $V[i]$ 到顶点 $V[j]$ 之间存在边,反之 $M[i][j] = 0$ 表示两顶点之间无边。
如下图所示,设邻接矩阵为 $M$ 、顶点列表为 $V$ ,那么矩阵元素 $M[i, j] = 1$ 表示顶点 $V[i]$ 到顶点 $V[j]$ 之间存在边,反之 $M[i, j] = 0$ 表示两顶点之间无边。
![图的邻接矩阵表示](graph.assets/adjacency_matrix.png)
@ -62,17 +62,18 @@ $$
### 邻接表
「邻接表 Adjacency List」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。
「邻接表 adjacency list」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。
![图的邻接表表示](graph.assets/adjacency_list.png)
邻接表仅存储实际存在的边,而边的总数通常远小于 $n^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。
观察上图可发现,**邻接表结构与哈希表中的「链地址法」非常相似,因此我们也可以采用类似方法来优化效率**。例如,当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ,还可以通过中序遍历获取有序序列;此外,还可以将链表转换为哈希表,将时间复杂度降低至 $O(1)$ 。
观察上图**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降低至 $O(1)$ 。
## 图常见应用
实际应用中,许多系统都可以用图来建模,相应的待求解问题也可以约化为图计算问题。
<p align="center"> 表:现实生活中常见的图 </p>
| | 顶点 | 边 | 图计算问题 |

@ -1,6 +1,6 @@
# 图基础操作
图的基础操作可分为对「边」的操作和对「顶点」的操作。在「邻接矩阵」和「邻接表」两种表示方法下,实现方式有所不同。
图的基础操作可分为对“边”的操作和对“顶点”的操作。在“邻接矩阵”和“邻接表”两种表示方法下,实现方式有所不同。
## 基于邻接矩阵的实现
@ -206,6 +206,7 @@
## 效率对比
设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。
<p align="center"> 表:邻接矩阵与邻接表对比 </p>
| | 邻接矩阵 | 邻接表(链表) | 邻接表(哈希表) |

@ -4,9 +4,9 @@
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作是图的一种特例。显然,**树的遍历操作也是图的遍历操作的一种特例**,建议你在学习本章节时融会贯通两者的概念与实现方法。
都是非线性数据结构,都需要使用搜索算法来实现遍历操作。
图和树都是非线性数据结构,都需要使用搜索算法来实现遍历操作。
与树类似,图的遍历方式也可分为两种,即「广度优先遍历 Breadth-First Traversal」和「深度优先遍历 Depth-First Traversal」也称为「广度优先搜索 Breadth-First Search」和「深度优先搜索 Depth-First Search」简称 BFS 和 DFS
与树类似,图的遍历方式也可分为两种,即「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」简称 BFS 和 DFS
## 广度优先遍历
@ -16,7 +16,7 @@
### 算法实现
BFS 通常借助队列来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
BFS 通常借助队列来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。
1. 将遍历起始顶点 `startVet` 加入队列,并开启循环。
2. 在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。

@ -11,7 +11,7 @@
## 链式地址
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 Separate Chaining」将单个元素转换为链表将键值对作为链表节点将所有发生冲突的键值对都存储在同一链表中。
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表将键值对作为链表节点将所有发生冲突的键值对都存储在同一链表中。
![链式地址哈希表](hash_collision.assets/hash_table_chaining.png)
@ -105,11 +105,11 @@
!!! tip
当链表很长时,查询效率 $O(n)$ 很差,**此时可以将链表转换为「AVL 树」或「红黑树」**,从而将查询操作的时间复杂度优化至 $O(\log n)$ 。
当链表很长时,查询效率 $O(n)$ 很差,**此时可以将链表转换为“AVL 树”或“红黑树”**,从而将查询操作的时间复杂度优化至 $O(\log n)$ 。
## 开放寻址
「开放寻址 Open Addressing」不引入额外的数据结构而是通过“多次探测”来处理哈希冲突探测方式主要包括线性探测、平方探测、多次哈希等。
「开放寻址 open addressing」不引入额外的数据结构而是通过“多次探测”来处理哈希冲突探测方式主要包括线性探测、平方探测、多次哈希等。
### 线性探测

@ -1,6 +1,6 @@
# 哈希表
散列表,又称「哈希表 Hash Table」,其通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value`
「哈希表 hash table」又称「散列表」,其通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value`
以一个包含 $n$ 个学生的数据库为例,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用哈希表来实现。
@ -11,6 +11,7 @@
- **添加元素**:仅需将元素添加至数组(链表)的尾部即可,使用 $O(1)$ 时间。
- **查询元素**:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 $O(n)$ 时间。
- **删除元素**:需要先查询到元素,再从数组中删除,使用 $O(n)$ 时间。
<p align="center"> 表:元素查询效率对比 </p>
| | 数组 | 链表 | 哈希表 |
@ -436,9 +437,9 @@
## 哈希表简单实现
我们先考虑最简单的情况,**仅用一个数组来实现哈希表**。在哈希表中,我们将数组中的每个空位称为「桶 Bucket」每个桶可存储一个键值对。因此查询操作就是找到 `key` 对应的桶,并在桶中获取 `value`
我们先考虑最简单的情况,**仅用一个数组来实现哈希表**。在哈希表中,我们将数组中的每个空位称为「桶 bucket」每个桶可存储一个键值对。因此查询操作就是找到 `key` 对应的桶,并在桶中获取 `value`
那么,如何基于 `key` 来定位对应的桶呢?这是通过「哈希函数 Hash Function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` **我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
那么,如何基于 `key` 来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` **我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
输入一个 `key` ,哈希函数的计算过程分为两步:
@ -573,7 +574,7 @@ index = hash(key) % capacity
20336 % 100 = 36
```
如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 Hash Collision」。
如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 hash collision」。
![哈希冲突示例](hash_map.assets/hash_collision.png)
@ -583,4 +584,4 @@ index = hash(key) % capacity
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
「负载因子 Load Factor」是哈希表的一个重要概念其定义为哈希表的元素数量除以桶数量用于衡量哈希冲突的严重程度**也常被作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表容量扩展为原先的 $2$ 倍。
「负载因子 load factor」是哈希表的一个重要概念其定义为哈希表的元素数量除以桶数量用于衡量哈希冲突的严重程度**也常被作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表容量扩展为原先的 $2$ 倍。

@ -1,6 +1,6 @@
# 建堆操作
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为「建堆」
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”
## 借助入堆方法实现

@ -1,25 +1,26 @@
# 堆
「堆 Heap」是一种满足特定条件的完全二叉树可分为两种类型
「堆 heap」是一种满足特定条件的完全二叉树可分为两种类型
- 「大顶堆 Max Heap」任意节点的值 $\geq$ 其子节点的值。
- 「小顶堆 Min Heap」任意节点的值 $\leq$ 其子节点的值。
- 「大顶堆 max heap」任意节点的值 $\geq$ 其子节点的值。
- 「小顶堆 min heap」任意节点的值 $\leq$ 其子节点的值。
![小顶堆与大顶堆](heap.assets/min_heap_and_max_heap.png)
堆作为完全二叉树的一个特例,具有以下特性:
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为「堆顶」,将底层最靠右的节点称为「堆底」
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”
- 对于大顶堆(小顶堆),堆顶元素(即根节点)的值分别是最大(最小)的。
## 堆常用操作
需要指出的是,许多编程语言提供的是「优先队列 Priority Queue」这是一种抽象数据结构定义为具有优先级排序的队列。
需要指出的是,许多编程语言提供的是「优先队列 priority queue」这是一种抽象数据结构定义为具有优先级排序的队列。
实际上,**堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将「优先队列」和「堆」看作等价的数据结构。因此,本书对两者不做特别区分,统一使用「堆」来命名。
实际上,**堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一使用“堆“来命名。
堆的常用操作见下表,方法名需要根据编程语言来确定。
<p align="center"> 表:堆的操作效率 </p>
| 方法名 | 描述 | 时间复杂度 |
@ -524,7 +525,7 @@
### 元素入堆
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
考虑从入堆节点开始,**从底至顶执行堆化**。具体来说,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。

@ -16,7 +16,7 @@
!!! tip
当 $k = n$ 时,我们可以得到从大到小的序列,等价于「选择排序」算法。
当 $k = n$ 时,我们可以得到从大到小的序列,等价于“选择排序”算法。
## 方法二:排序

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

@ -25,7 +25,7 @@
=== "<5>"
![binary_search_dictionary_step_5](algorithms_are_everywhere.assets/binary_search_dictionary_step_5.png)
查阅字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的「数组」;从算法的角度,我们可以将上述查字典的一系列操作看作是「二分查找」
查阅字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的“数组”;从算法的角度,我们可以将上述查字典的一系列操作看作是“二分查找”
**例二:整理扑克**。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下图所示。
@ -35,7 +35,7 @@
![扑克排序步骤](algorithms_are_everywhere.assets/playing_cards_sorting.png)
上述整理扑克牌的方法本质上是「插入排序」算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
**例三:货币找零**。假设我们在超市购买了 $69$ 元的商品,给收银员付了 $100$ 元,则收银员需要找我们 $31$ 元。他会很自然地完成如下图所示的思考。
@ -47,7 +47,7 @@
![货币找零过程](algorithms_are_everywhere.assets/greedy_change.png)
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是「贪心算法」
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是“贪心”算法
小到烹饪一道菜,大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现使我们能够通过编程将数据结构存储在内存中,同时编写代码调用 CPU 和 GPU 执行算法。这样一来,我们就能把生活中的问题转移到计算机上,以更高效的方式解决各种复杂问题。

@ -50,4 +50,4 @@
!!! tip "约定俗成的简称"
在实际讨论时,我们通常会将「数据结构与算法」简称为「算法」。比如众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。
在实际讨论时,我们通常会将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。

@ -7,10 +7,10 @@
## 行文风格约定
- 标题后标注 `*` 的是选读章节,内容相对困难。如果你的时间有限,建议可以先跳过。
- 文章中的重要名词会用 `「 」` 括号标注,例如 `「数组 Array」` 。请务必记住这些名词,包括英文翻译,以便后续阅读文献时使用。
- **加粗的文字** 表示重点内容或总结性语句,这类文字值得特别关注。
- 专有名词和有特指含义的词句会使用 `“双引号”` 标注,以避免歧义。
- 涉及到编程语言之间不一致的名词,本书均以 Python 为准,例如使用 $\text{None}$ 来表示“空”。
- 重要专有名词及其英文翻译会用 `「 」` 括号标注,例如 `「数组 array」` 。建议记住它们,以便阅读文献。
- **加粗的文字** 表示重点内容或总结性语句,这类文字值得特别关注。
- 当涉及到编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 $\text{None}$ 来表示“空”。
- 本书部分放弃了编程语言的注释规范,以换取更加紧凑的内容排版。注释主要分为三种类型:标题注释、内容注释、多行注释。
=== "Java"

@ -1,6 +1,6 @@
# 二分查找
「二分查找 Binary Search」是一种基于分治思想的高效搜索算法。它利用数据的有序性每轮减少一半搜索范围直至找到目标元素或搜索区间为空为止。
「二分查找 binary search」是一种基于分治思想的高效搜索算法。它利用数据的有序性每轮减少一半搜索范围直至找到目标元素或搜索区间为空为止。
!!! question

@ -1,6 +1,6 @@
# 重识搜索算法
「搜索算法 Searching Algorithm」用于在数据结构例如数组、链表、树或图中搜索一个或一组满足特定条件的元素。
「搜索算法 searching algorithm」用于在数据结构例如数组、链表、树或图中搜索一个或一组满足特定条件的元素。
根据实现思路,搜索算法总体可分为两种:
@ -13,8 +13,8 @@
暴力搜索通过遍历数据结构的每个元素来定位目标元素。
- 「线性搜索」适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
- 「广度优先搜索」和「深度优先搜索」是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索是从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。
- “线性搜索”适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
- “广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索是从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。
暴力搜索的优点是简单且通用性好,**无须对数据做预处理和借助额外的数据结构**。
@ -24,9 +24,9 @@
自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。
- 「二分查找」利用数据的有序性实现高效查找,仅适用于数组。
- 「哈希查找」利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。
- 「树查找」在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。
- “二分查找”利用数据的有序性实现高效查找,仅适用于数组。
- “哈希查找”利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。
- “树查找”在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。
此类算法的优点是效率高,**时间复杂度可达到 $O(\log n)$ 甚至 $O(1)$** 。
@ -43,6 +43,7 @@
![多种搜索策略](searching_algorithm_revisited.assets/searching_algorithms.png)
上述几种方法的操作效率与特性如下表所示。
<p align="center"> 表:查找算法效率对比 </p>
| | 线性搜索 | 二分查找 | 树查找 | 哈希查找 |

@ -1,6 +1,6 @@
# 冒泡排序
「冒泡排序 Bubble Sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样因此得名冒泡排序。
「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样因此得名冒泡排序。
我们可以利用元素交换操作模拟上述过程:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。

@ -2,7 +2,7 @@
前述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
「桶排序 Bucket Sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶每个桶对应一个数据范围将数据平均分配到各个桶中然后在每个桶内部分别执行排序最终按照桶的顺序将所有数据合并。
「桶排序 bucket sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶每个桶对应一个数据范围将数据平均分配到各个桶中然后在每个桶内部分别执行排序最终按照桶的顺序将所有数据合并。
## 算法流程

@ -1,6 +1,6 @@
# 计数排序
「计数排序 Counting Sort」通过统计元素数量来实现排序通常应用于整数数组。
「计数排序 counting sort」通过统计元素数量来实现排序通常应用于整数数组。
## 简单实现
@ -92,7 +92,7 @@
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。例如,输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter`「前缀和」。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和,即
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter`“前缀和”。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和,即
$$
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}

@ -2,9 +2,9 @@
!!! tip
阅读本节前,请确保已学完「堆」章节。
阅读本节前,请确保已学完“堆“章节。
「堆排序 Heap Sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序
「堆排序 heap sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序
1. 输入数组并建立小顶堆,此时最小元素位于堆顶。
2. 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。

@ -1,6 +1,6 @@
# 插入排序
「插入排序 Insertion Sort」是一种简单的排序算法它的工作原理与手动整理一副牌的过程非常相似。
「插入排序 insertion sort」是一种简单的排序算法它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。

@ -1,6 +1,6 @@
# 归并排序
「归并排序 Merge Sort」基于分治思想实现排序包含“划分”和“合并”两个阶段
「归并排序 merge sort」基于分治思想实现排序包含“划分”和“合并”两个阶段
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
2. **合并阶段**:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。

@ -1,8 +1,8 @@
# 快速排序
「快速排序 Quick Sort」是一种基于分治思想的排序算法运行高效应用广泛。
「快速排序 quick sort」是一种基于分治思想的排序算法运行高效应用广泛。
快速排序的核心操作是「哨兵划分」,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:
1. 选取数组最左端元素作为基准数,初始化两个指针 `i``j` 分别指向数组的两端。
2. 设置一个循环,在每轮中使用 `i``j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
@ -133,8 +133,8 @@
## 算法流程
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组。
2. 然后,对左子数组和右子数组分别递归执行「哨兵划分」
1. 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
2. 然后,对左子数组和右子数组分别递归执行“哨兵划分”
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
![快速排序流程](quick_sort.assets/quick_sort_overview.png)
@ -219,15 +219,15 @@
## 快排为什么快?
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与「归并排序」和「堆排序」相同,但通常快速排序的效率更高,原因如下:
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,原因如下:
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行。
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像「堆排序」这类算法需要跳跃式访问元素,从而缺乏这一特性。
- **复杂度的常数系数低**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与「插入排序」比「冒泡排序」更快的原因类似。
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
- **复杂度的常数系数低**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
## 基准数优化
**快速排序在某些输入下的时间效率可能降低**。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 $n - 1$ 、右子数组长度为 $0$ 。如此递归下去,每轮哨兵划分后的右子数组长度都为 $0$ ,分治策略失效,快速排序退化为「冒泡排序」
**快速排序在某些输入下的时间效率可能降低**。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 $n - 1$ 、右子数组长度为 $0$ 。如此递归下去,每轮哨兵划分后的右子数组长度都为 $0$ ,分治策略失效,快速排序退化为“冒泡排序”
为了尽量避免这种情况发生,**我们可以优化哨兵划分中的基准数的选取策略**。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。

@ -2,14 +2,14 @@
上一节我们介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
「基数排序 Radix Sort」的核心思想与计数排序一致也通过统计个数来实现排序。在此基础上基数排序利用数字各位之间的递进关系依次对每一位进行排序从而得到最终的排序结果。
「基数排序 radix sort」的核心思想与计数排序一致也通过统计个数来实现排序。在此基础上基数排序利用数字各位之间的递进关系依次对每一位进行排序从而得到最终的排序结果。
## 算法流程
以学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的步骤如下:
1. 初始化位数 $k = 1$ 。
2. 对学号的第 $k$ 位执行「计数排序」。完成后,数据会根据第 $k$ 位从小到大排序。
2. 对学号的第 $k$ 位执行“计数排序”。完成后,数据会根据第 $k$ 位从小到大排序。
3. 将 $k$ 增加 $1$ ,然后返回步骤 `2.` 继续迭代,直到所有位都排序完成后结束。
![基数排序算法流程](radix_sort.assets/radix_sort_overview.png)

@ -1,6 +1,6 @@
# 选择排序
「选择排序 Selection Sort」的工作原理非常直接开启一个循环每轮从未排序区间选择最小的元素将其放到已排序区间的末尾。
「选择排序 selection sort」的工作原理非常直接开启一个循环每轮从未排序区间选择最小的元素将其放到已排序区间的末尾。
设数组的长度为 $n$ ,选择排序的算法流程如下:

@ -1,6 +1,6 @@
# 排序算法
「排序算法 Sorting Algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用因为有序数据通常能够被更有效地查找、分析和处理。
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用因为有序数据通常能够被更有效地查找、分析和处理。
在排序算法中,数据类型可以是整数、浮点数、字符或字符串等;顺序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
@ -12,9 +12,9 @@
**就地性**:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。稳定排序是优良特性,也是多级排序场景的必要条件。
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。
假设我们有一个存储学生信息的表格,第 1, 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。
```shell
# 输入数据是按照姓名排序好的

@ -1,12 +1,13 @@
# 双向队列
对于队列,我们仅能在头部删除或在尾部添加元素。然而,「双向队列 Deque」提供了更高的灵活性允许在头部和尾部执行元素的添加或删除操作。
对于队列,我们仅能在头部删除或在尾部添加元素。然而,「双向队列 deque」提供了更高的灵活性允许在头部和尾部执行元素的添加或删除操作。
![双向队列的操作](deque.assets/deque_operations.png)
## 双向队列常用操作
双向队列的常用操作如下表所示,具体的方法名称需要根据所使用的编程语言来确定。
<p align="center"> 表:双向队列操作效率 </p>
| 方法名 | 描述 | 时间复杂度 |
@ -323,7 +324,7 @@
回顾上一节内容,我们使用普通单向链表来实现队列,因为它可以方便地删除头节点(对应出队操作)和在尾节点后添加新节点(对应入队操作)。
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用「双向链表」作为双向队列的底层数据结构。
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。
我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。

@ -1,14 +1,15 @@
# 队列
「队列 Queue」是一种遵循先入先出First In, First Out规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。
「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。
我们把队列的头部称为「队首」,尾部称为「队尾」,把将元素加入队尾的操作称为「入队」,删除队首元素的操作称为「出队」
我们把队列的头部称为“队首”,尾部称为“队尾”,把将元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”
![队列的先入先出规则](queue.assets/queue_operations.png)
## 队列常用操作
队列的常见操作如下表所示。需要注意的是,不同编程语言的方法名称可能会有所不同。我们在此采用与栈相同的方法命名。
<p align="center"> 表:队列操作效率 </p>
| 方法名 | 描述 | 时间复杂度 |
@ -288,7 +289,7 @@
### 基于链表的实现
对于链表实现,我们可以将链表的「头节点」和「尾节点」分别视为队首和队尾,规定队尾仅可添加节点,而队首仅可删除节点。
对于链表实现,我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
=== "LinkedListQueue"
![基于链表实现队列的入队出队操作](queue.assets/linkedlist_queue.png)
@ -395,7 +396,7 @@
=== "pop()"
![array_queue_pop](queue.assets/array_queue_pop.png)
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的「环形数组」
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的“环形数组”
对于环形数组,我们需要让 `front``rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示。

@ -1,16 +1,17 @@
# 栈
「栈 Stack」是一种遵循先入后出First In, Last Out原则的线性数据结构。
「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
在栈中,我们把堆叠元素的顶部称为「栈顶」,底部称为「栈底」。将把元素添加到栈顶的操作叫做「入栈」,而删除栈顶元素的操作叫做「出栈」
在栈中,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,而删除栈顶元素的操作叫做“出栈”
![栈的先入后出规则](stack.assets/stack_operations.png)
## 栈常用操作
栈的常用操作如下表所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 `push()` , `pop()` , `peek()` 命名为例。
<p align="center"> 表:栈的操作效率 </p>
| 方法 | 描述 | 时间复杂度 |
@ -19,7 +20,7 @@
| pop() | 栈顶元素出栈 | $O(1)$ |
| peek() | 访问栈顶元素 | $O(1)$ |
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的「数组」或「链表」视作栈来使用,并通过“脑补”来忽略与栈无关的操作。
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”视作栈来使用,并在程序逻辑上忽略与栈无关的操作。
=== "Java"
@ -377,7 +378,7 @@
### 基于数组的实现
在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
使用数组实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
=== "ArrayStack"
![基于数组实现栈的入栈出栈操作](stack.assets/array_stack.png)
@ -489,5 +490,5 @@
## 栈典型应用
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。

@ -2,7 +2,7 @@
在链表表示下,二叉树的存储单元为节点 `TreeNode` ,节点之间通过指针相连接。在上节中,我们学习了在链表表示下的二叉树的各项基本操作。
那么,能否用数组来表示二叉树呢?答案是肯定的。
那么,我们能否用数组来表示二叉树呢?答案是肯定的。
## 表示完美二叉树

@ -14,7 +14,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
## AVL 树常见术语
AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树」。
AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树 balanced binary search tree」。
### 节点高度
@ -188,7 +188,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
```
「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
“节点高度”是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
=== "Java"
@ -288,7 +288,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
### 节点平衡因子
节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
=== "Java"
@ -368,13 +368,13 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
## AVL 树旋转
AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持树的「二叉搜索树」属性,也能使树重新变为「平衡二叉树」**。
AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”**。
我们将平衡因子绝对值 $> 1$ 的节点称为「失衡节点」。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。
我们将平衡因子绝对值 $> 1$ 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。
### 右旋
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树将该节点记为 `node` ,其左子节点记为 `child` ,执行「右旋」操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树将该节点记为 `node` ,其左子节点记为 `child` ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。
=== "<1>"
![右旋操作步骤](avl_tree.assets/avltree_right_rotate_step1.png)
@ -388,7 +388,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
=== "<4>"
![avltree_right_rotate_step4](avl_tree.assets/avltree_right_rotate_step4.png)
此外,如果节点 `child` 本身有右子节点(记为 `grandChild` ),则需要在右旋中添加一步:将 `grandChild` 作为 `node` 的左子节点。
此外,如果节点 `child` 本身有右子节点(记为 `grandChild` ),则需要在右旋中添加一步:将 `grandChild` 作为 `node` 的左子节点。
![有 grandChild 的右旋操作](avl_tree.assets/avltree_right_rotate_with_grandchild.png)
@ -468,15 +468,15 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
### 左旋
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行“左旋”操作。
![左旋操作](avl_tree.assets/avltree_left_rotate.png)
同理,若节点 `child` 本身有左子节点(记为 `grandChild` ),则需要在左旋中添加一步:将 `grandChild` 作为 `node` 的右子节点。
同理,若节点 `child` 本身有左子节点(记为 `grandChild` ),则需要在左旋中添加一步:将 `grandChild` 作为 `node` 的右子节点。
![有 grandChild 的左旋操作](avl_tree.assets/avltree_left_rotate_with_grandchild.png)
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们可以轻松地从右旋的代码推导出左旋的代码。具体地,只需将「右旋」代码中的把所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到「左旋」代码。
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码。
=== "Java"
@ -552,13 +552,13 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
### 先左旋后右旋
对于下图中的失衡节点 3仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」
对于下图中的失衡节点 3仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋即先对 `child` 执行“左旋”,再对 `node` 执行“右旋”
![先左旋后右旋](avl_tree.assets/avltree_left_right_rotate.png)
### 先右旋后左旋
同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 `child` 执行「右旋」,然后对 `node` 执行「左旋」
同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 `child` 执行“右旋”,然后对 `node` 执行“左旋”
![先右旋后左旋](avl_tree.assets/avltree_right_left_rotate.png)
@ -569,6 +569,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
![AVL 树的四种旋转情况](avl_tree.assets/avltree_rotation_cases.png)
在代码中,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图中的哪种情况。
<p align="center"> 表:四种旋转情况的选择条件 </p>
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
@ -656,7 +657,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
### 插入节点
AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。
AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。
=== "Java"

@ -1,6 +1,6 @@
# 二叉搜索树
「二叉搜索树 Binary Search Tree」满足以下条件
「二叉搜索树 binary search tree」满足以下条件
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值。
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.`
@ -310,6 +310,7 @@
给定一组数据,我们考虑使用数组或二叉搜索树存储。
观察可知,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
<p align="center"> 表:数组与搜索树的效率对比 </p>
| | 无序数组 | 二叉搜索树 |

@ -1,6 +1,6 @@
# 二叉树
「二叉树 Binary Tree」是一种非线性数据结构代表着祖先与后代之间的派生关系体现着“一分为二”的分治逻辑。与链表类似二叉树的基本单元是节点每个节点包含值、左子节点引用、右子节点引用。
「二叉树 binary tree」是一种非线性数据结构代表着祖先与后代之间的派生关系体现着“一分为二”的分治逻辑。与链表类似二叉树的基本单元是节点每个节点包含值、左子节点引用、右子节点引用。
=== "Java"
@ -161,9 +161,9 @@
```
节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。
每个节点都有两个引用(指针),分别指向「左子节点 left-child node」和「右子节点 right-child node」该节点被称为这两个子节点的「父节点 parent node」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树 left subtree」,同理可得「右子树 right subtree」。
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。例如,在以下示例中,若将“节点 2”视为父节点则其左子节点和右子节点分别是“节点 4”和“节点 5”左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。在以下示例中,若将“节点 2”视为父节点则其左子节点和右子节点分别是“节点 4”和“节点 5”左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
![父节点、子节点、子树](binary_tree.assets/binary_tree_definition.png)
@ -171,20 +171,20 @@
二叉树涉及的术语较多,建议尽量理解并记住。
- 「根节点 Root Node」位于二叉树顶层的节点没有父节点。
- 「叶节点 Leaf Node」没有子节点的节点其两个指针均指向 $\text{None}$ 。
- 节点的「层 Level」从顶至底递增根节点所在层为 1
- 节点的「度 Degree」节点的子节点的数量。在二叉树中度的范围是 0, 1, 2
- 「边 Edge」连接两个节点的线段即节点指针
- 二叉树的「高度」:从根节点到最远叶节点所经过的边的数量。
- 节点的「深度 Depth」 :从根节点到该节点所经过的边的数量。
- 节点的「高度 Height」从最远叶节点到该节点所经过的边的数量。
- 「根节点 root node」位于二叉树顶层的节点没有父节点。
- 「叶节点 leaf node」没有子节点的节点其两个指针均指向 $\text{None}$ 。
- 「边 edge」连接两个节点的线段即节点引用指针
- 节点所在的「层 level」从顶至底递增根节点所在层为 1
- 节点的「度 degree」节点的子节点的数量。在二叉树中度的取值范围是 0, 1, 2
- 二叉树的「高度 height」:从根节点到最远叶节点所经过的边的数量。
- 节点的「深度 depth」 :从根节点到该节点所经过的边的数量。
- 节点的「高度 height」从最远叶节点到该节点所经过的边的数量。
![二叉树的常用术语](binary_tree.assets/binary_tree_terminology.png)
!!! tip "高度与深度的定义"
请注意,我们通常将「高度」和「深度」定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。
请注意,我们通常将“高度”和“深度”定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。
## 二叉树基本操作
@ -512,35 +512,35 @@
### 完美二叉树
「完美二叉树 Perfect Binary Tree」除了最底层外其余所有层的节点都被完全填满。在完美二叉树中叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
「完美二叉树 perfect binary tree」除了最底层外其余所有层的节点都被完全填满。在完美二叉树中叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
!!! tip
在中文社区中,完美二叉树常被称为「满二叉树」,请注意区分
请注意,在中文社区中,完美二叉树常被称为「满二叉树」。
![完美二叉树](binary_tree.assets/perfect_binary_tree.png)
### 完全二叉树
「完全二叉树 Complete Binary Tree」只有最底层的节点未被填满且最底层节点尽量靠左填充。
「完全二叉树 complete binary tree」只有最底层的节点未被填满且最底层节点尽量靠左填充。
![完全二叉树](binary_tree.assets/complete_binary_tree.png)
### 完满二叉树
「完满二叉树 Full Binary Tree」除了叶节点之外其余所有节点都有两个子节点。
「完满二叉树 full binary tree」除了叶节点之外其余所有节点都有两个子节点。
![完满二叉树](binary_tree.assets/full_binary_tree.png)
### 平衡二叉树
「平衡二叉树 Balanced Binary Tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
![平衡二叉树](binary_tree.assets/balanced_binary_tree.png)
## 二叉树的退化
当二叉树的每层节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一侧时,二叉树退化为「链表」
当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ 。
@ -548,6 +548,7 @@
![二叉树的最佳与最差结构](binary_tree.assets/binary_tree_best_worst_cases.png)
如下表所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。
<p align="center"> 表:二叉树的最佳与最差情况 </p>
| | 完美二叉树 | 链表 |

@ -6,13 +6,13 @@
## 层序遍历
「层序遍历 Level-Order Traversal」从顶部到底部逐层遍历二叉树并在每一层按照从左到右的顺序访问节点。
「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树并在每一层按照从左到右的顺序访问节点。
层序遍历本质上属于「广度优先搜索 Breadth-First Traversal」它体现了一种“一圈一圈向外扩展”的逐层搜索方式。
层序遍历本质上属于「广度优先遍历 breadth-first traversal」它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
![二叉树的层序遍历](binary_tree_traversal.assets/binary_tree_bfs.png)
广度优先遍历通常借助「队列」来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。
广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。
=== "Java"
@ -92,7 +92,7 @@
## 前序、中序、后序遍历
相应地,前序、中序和后序遍历都属于「深度优先遍历 Depth-First Traversal」它体现了一种“先走到尽头再回溯继续”的遍历方式。
相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」它体现了一种“先走到尽头再回溯继续”的遍历方式。
如下图所示,左侧是深度优先遍历的示意图,右上方是对应的递归代码。深度优先遍历就像是绕着整个二叉树的外围“走”一圈,在这个过程中,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。

Loading…
Cancel
Save