diff --git a/chapter_hashing/hash_collision.md b/chapter_hashing/hash_collision.md index 6756418fa..2c93962b2 100644 --- a/chapter_hashing/hash_collision.md +++ b/chapter_hashing/hash_collision.md @@ -10,7 +10,7 @@ comments: true 为了缓解哈希冲突,一方面,**我们可以通过哈希表扩容来减小冲突概率**。极端情况下,当输入空间和输出空间大小相等时,哈希表就等价于数组了,每个 key 都对应唯一的数组索引,可谓“大力出奇迹”。 -另一方面,**考虑通过优化哈希表的表示来缓解哈希冲突**,常见的方法有「链式地址」和「开放寻址」。 +另一方面,**考虑通过优化哈希表的表示来缓解哈希冲突**,常见的方法有「链式地址 Separate Chaining」和「开放寻址 Open Addressing」。 ## 6.2.1.   哈希表扩容 @@ -22,7 +22,7 @@ comments: true ## 6.2.2.   链式地址 -在原始哈希表中,每个桶只能存储一个元素(即键值对)。**考虑将单个元素转化成一个链表,将所有冲突元素都存储在一个链表中**。 +在原始哈希表中,每个桶只能存储一个键值对。**链式地址考虑将单个元素转化成一个链表,将键值对作为链表结点,将所有冲突键值对都存储在一个链表中**。 ![链式地址](hash_collision.assets/hash_collision_chaining.png) @@ -31,7 +31,7 @@ comments: true 链式地址下,哈希表操作方法为: - **查询元素**:输入 key ,经过哈希函数得到数组索引,即可访问链表头结点,再通过遍历链表并对比 key 来查找键值对。 -- **添加元素**:先通过哈希函数访问链表头部,再将结点(即键值对)添加到链表头部即可。 +- **添加元素**:先通过哈希函数访问链表头结点,再将结点(即键值对)添加到链表即可。 - **删除元素**:同样先根据哈希函数结果访问链表头部,再遍历链表查找对应结点,删除之即可。 链式地址虽然解决了哈希冲突问题,但仍存在局限性,包括: diff --git a/chapter_sorting/bubble_sort.md b/chapter_sorting/bubble_sort.md index 9313d36b3..3419c32a5 100755 --- a/chapter_sorting/bubble_sort.md +++ b/chapter_sorting/bubble_sort.md @@ -4,15 +4,15 @@ comments: true # 11.2.   冒泡排序 -「冒泡排序 Bubble Sort」是一种最基础的排序算法,非常适合作为第一个学习的排序算法。顾名思义,「冒泡」是该算法的核心操作。 +「冒泡排序 Bubble Sort」是一种基于元素交换实现排序的算法,非常适合作为第一个学习的排序算法。 !!! question "为什么叫“冒泡”" 在水中,越大的泡泡浮力越大,所以最大的泡泡会最先浮到水面。 -「冒泡」操作则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若 **左元素 > 右元素** 则将它俩交换,最终可将最大元素移动至数组最右端。 +「冒泡操作」则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若“左元素 > 右元素”则将它俩交换,最终可将最大元素移动至数组最右端。 -完成此次冒泡操作后,**数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素**。 +完成一次冒泡操作后,**数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素**。 === "<1>" ![冒泡操作步骤](bubble_sort.assets/bubble_operation_step1.png) @@ -37,9 +37,11 @@ comments: true ## 11.2.1.   算法流程 -1. 设数组长度为 $n$ ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素。 -2. 同理,对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个。 -3. 以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**。 +设输入数组长度为 $n$ ,循环执行「冒泡」操作: + +1. 完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素; +2. 对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个; +3. 以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**; ![冒泡排序流程](bubble_sort.assets/bubble_sort_overview.png) @@ -231,21 +233,17 @@ comments: true ## 11.2.2.   算法特性 -**时间复杂度 $O(n^2)$** :各轮「冒泡」遍历的数组长度为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。 +**时间复杂度 $O(n^2)$** :各轮冒泡遍历的数组长度为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。引入下文的 `flag` 优化后,最佳时间复杂度可以达到 $O(N)$ ,因此是“自适应排序”。 -**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间。 +**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间,因此是“原地排序”。 -**原地排序**:指针变量仅使用常数大小额外空间。 - -**稳定排序**:不交换相等元素。 - -**自适应排序**:引入 `flag` 优化后(见下文),最佳时间复杂度为 $O(N)$ 。 +在冒泡操作中遇到相等元素不交换,因此是“稳定排序”。 ## 11.2.3.   效率优化 我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 `flag` 来监听该情况,若出现则直接返回。 -优化后,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;而在输入数组 **已排序** 时,达到 **最佳时间复杂度** $O(n)$ 。 +优化后,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;而在输入数组完全有序时,达到最佳时间复杂度 $O(n)$ 。 === "Java" diff --git a/chapter_sorting/counting_sort.md b/chapter_sorting/counting_sort.md index 620deb0a5..1fff566b3 100644 --- a/chapter_sorting/counting_sort.md +++ b/chapter_sorting/counting_sort.md @@ -436,17 +436,9 @@ $$ **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,此时使用线性 $O(n)$ 时间。 -**空间复杂度 $O(n + m)$** :数组 `res` 和 `counter` 长度分别为 $n$ , $m$ 。 +**空间复杂度 $O(n + m)$** :借助了长度分别为 $n$ , $m$ 的数组 `res` 和 `counter` ,是“非原地排序”; -**非原地排序**:借助了辅助数组 `counter` 和结果数组 `res` 的额外空间。 - -**稳定排序**:倒序遍历 `nums` 保持了相等元素的相对位置。 - -**非自适应排序**:与元素分布无关。 - -!!! question "为什么是稳定排序?" - - 由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”;其实正序遍历 `nums` 也可以得到正确的排序结果,但结果“非稳定”。 +**稳定排序**:由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”;其实正序遍历 `nums` 也可以得到正确的排序结果,但结果“非稳定”。 ## 11.6.4.   局限性 diff --git a/chapter_sorting/insertion_sort.md b/chapter_sorting/insertion_sort.md index 67559b7c4..1533505e6 100755 --- a/chapter_sorting/insertion_sort.md +++ b/chapter_sorting/insertion_sort.md @@ -16,9 +16,11 @@ comments: true ## 11.3.1.   算法流程 -1. 第 1 轮先选取数组的 **第 2 个元素** 为 `base` ,执行「插入操作」后,**数组前 2 个元素已完成排序**。 -2. 第 2 轮选取 **第 3 个元素** 为 `base` ,执行「插入操作」后,**数组前 3 个元素已完成排序**。 -3. 以此类推……最后一轮选取 **数组尾元素** 为 `base` ,执行「插入操作」后,**所有元素已完成排序**。 +循环执行插入操作: + +1. 先选取数组的 **第 2 个元素** 为 `base` ,执行插入操作后,**数组前 2 个元素已完成排序**。 +2. 选取 **第 3 个元素** 为 `base` ,执行插入操作后,**数组前 3 个元素已完成排序**。 +3. 以此类推……最后一轮选取 **数组尾元素** 为 `base` ,执行插入操作后,**所有元素已完成排序**。 ![插入排序流程](insertion_sort.assets/insertion_sort_overview.png) @@ -199,27 +201,22 @@ comments: true ## 11.3.2.   算法特性 -**时间复杂度 $O(n^2)$** :最差情况下,各轮插入操作循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,使用 $O(n^2)$ 时间。 +**时间复杂度 $O(n^2)$** :最差情况下,各轮插入操作循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,使用 $O(n^2)$ 时间。输入数组完全有序下,达到最佳时间复杂度 $O(n)$ ,因此是“自适应排序”。 -**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间。 +**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间,因此是“原地排序”。 -**原地排序**:指针变量仅使用常数大小额外空间。 - -**稳定排序**:不交换相等元素。 - -**自适应排序**:最佳情况下,时间复杂度为 $O(n)$ 。 +在插入操作中,我们会将元素插入到相等元素的右边,不会改变它们的次序,因此是“稳定排序”。 ## 11.3.3.   插入排序 vs 冒泡排序 -!!! question +回顾「冒泡排序」和「插入排序」的复杂度分析,两者的循环轮数都是 $\frac{(n - 1) n}{2}$ 。但不同的是: - 虽然「插入排序」和「冒泡排序」的时间复杂度皆为 $O(n^2)$ ,但实际运行速度却有很大差别,这是为什么呢? +- 冒泡操作基于 **元素交换** 实现,需要借助一个临时变量实现,共 3 个单元操作; +- 插入操作基于 **元素赋值** 实现,只需 1 个单元操作; -回顾复杂度分析,两个方法的循环次数都是 $\frac{(n - 1) n}{2}$ 。但不同的是,「冒泡操作」是在做 **元素交换**,需要借助一个临时变量实现,共 3 个单元操作;而「插入操作」是在做 **赋值**,只需 1 个单元操作;因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍。 - -插入排序运行速度快,并且具有原地、稳定、自适应的优点,因此很受欢迎。实际上,包括 Java 在内的许多编程语言的排序库函数的实现都用到了插入排序。库函数的大致思路: +因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍,因此更受欢迎。实际上,许多编程语言(例如 Java)的内置排序函数都使用到了插入排序,大致思路为: - 对于 **长数组**,采用基于分治的排序算法,例如「快速排序」,时间复杂度为 $O(n \log n)$ ; - 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$ ; -在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。 +**在数据量较小时插入排序更快**,这是因为复杂度中的常数项(即每轮中的单元操作数量)占主导作用。这个现象与「线性查找」和「二分查找」的情况类似。 diff --git a/chapter_sorting/merge_sort.md b/chapter_sorting/merge_sort.md index 81d6f016f..d0771fc5f 100755 --- a/chapter_sorting/merge_sort.md +++ b/chapter_sorting/merge_sort.md @@ -497,11 +497,11 @@ comments: true ## 11.5.2.   算法特性 -- **时间复杂度 $O(n \log n)$** :划分形成高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,总体使用 $O(n \log n)$ 时间。 -- **空间复杂度 $O(n)$** :需借助辅助数组实现合并,使用 $O(n)$ 大小的额外空间;递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间。 -- **非原地排序**:辅助数组需要使用 $O(n)$ 额外空间。 -- **稳定排序**:在合并时可保证相等元素的相对位置不变。 -- **非自适应排序**:对于任意输入数据,归并排序的时间复杂度皆相同。 +**时间复杂度 $O(n \log n)$** :划分形成高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,总体使用 $O(n \log n)$ 时间。 + +**空间复杂度 $O(n)$** :需借助辅助数组实现合并,使用 $O(n)$ 大小的额外空间;递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间,因此是“非原地排序”。 + +在合并时,不改变相等元素的次序,是“稳定排序”。 ## 11.5.3.   链表排序 * diff --git a/chapter_sorting/quick_sort.md b/chapter_sorting/quick_sort.md index 1c95f83c4..f379ffb62 100755 --- a/chapter_sorting/quick_sort.md +++ b/chapter_sorting/quick_sort.md @@ -461,17 +461,11 @@ comments: true ## 11.4.2.   算法特性 -**平均时间复杂度 $O(n \log n)$** :平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。 +**时间复杂度 $O(n \log n)$** :平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间,因此是“非稳定排序”。 -**最差时间复杂度 $O(n^2)$** :最差情况下,哨兵划分操作将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。 +**空间复杂度 $O(n)$** :输入数组完全倒序下,达到最差递归深度 $n$ 。由于未借助辅助数组空间,因此是“原地排序”。 -**空间复杂度 $O(n)$** :输入数组完全倒序下,达到最差递归深度 $n$ 。 - -**原地排序**:只在递归中使用 $O(\log n)$ 大小的栈帧空间。 - -**非稳定排序**:哨兵划分操作可能改变相等元素的相对位置。 - -**自适应排序**:最差情况下,时间复杂度劣化至 $O(n^2)$ 。 +**非稳定排序**:哨兵划分最后一步可能会将基准数交换至相等元素的右边。 ## 11.4.3.   快排为什么快?