This commit is contained in:
krahets 2023-03-25 18:41:53 +08:00
parent 0dfdcf0bab
commit aedf4c4dff
6 changed files with 38 additions and 57 deletions

View file

@ -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 来查找键值对。
- **添加元素**:先通过哈希函数访问链表头部,再将结点(即键值对)添加到链表头部即可。
- **添加元素**:先通过哈希函数访问链表头结点,再将结点(即键值对)添加到链表即可。
- **删除元素**:同样先根据哈希函数结果访问链表头部,再遍历链表查找对应结点,删除之即可。
链式地址虽然解决了哈希冲突问题,但仍存在局限性,包括:

View file

@ -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. &nbsp; 算法流程
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. &nbsp; 算法特性
**时间复杂度 $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. &nbsp; 效率优化
我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 `flag` 来监听该情况,若出现则直接返回。
优化后,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;而在输入数组 **已排序** 时,达到 **最佳时间复杂度** $O(n)$ 。
优化后,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;而在输入数组完全有序时,达到最佳时间复杂度 $O(n)$ 。
=== "Java"

View file

@ -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. &nbsp; 局限性

View file

@ -16,9 +16,11 @@ comments: true
## 11.3.1. &nbsp; 算法流程
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. &nbsp; 算法特性
**时间复杂度 $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. &nbsp; 插入排序 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)$
在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。
**在数据量较小时插入排序更快**,这是因为复杂度中的常数项(即每轮中的单元操作数量)占主导作用。这个现象与「线性查找」和「二分查找」的情况类似。

View file

@ -497,11 +497,11 @@ comments: true
## 11.5.2. &nbsp; 算法特性
- **时间复杂度 $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. &nbsp; 链表排序 *

View file

@ -461,17 +461,11 @@ comments: true
## 11.4.2. &nbsp; 算法特性
**平均时间复杂度 $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. &nbsp; 快排为什么快?