diff --git a/chapter_hashing/hash_collision/index.html b/chapter_hashing/hash_collision/index.html index 666a93c0d..06732b8cb 100644 --- a/chapter_hashing/hash_collision/index.html +++ b/chapter_hashing/hash_collision/index.html @@ -1765,20 +1765,20 @@

理想情况下,哈希函数应该为每个输入产生唯一的输出,使得 key 和 value 一一对应。而实际上,往往存在向哈希函数输入不同的 key 而产生相同输出的情况,这种情况被称为「哈希冲突 Hash Collision」。哈希冲突会导致查询结果错误,从而严重影响哈希表的可用性。

那么,为什么会出现哈希冲突呢?本质上看,由于哈希函数的输入空间往往远大于输出空间,因此不可避免地会出现多个输入产生相同输出的情况,即为哈希冲突。比如,输入空间是全体整数,输出空间是一个固定大小的数组,那么必定会有多个整数映射到同一个数组索引。

为了缓解哈希冲突,一方面,我们可以通过哈希表扩容来减小冲突概率。极端情况下,当输入空间和输出空间大小相等时,哈希表就等价于数组了,每个 key 都对应唯一的数组索引,可谓“大力出奇迹”。

-

另一方面,考虑通过优化哈希表的表示来缓解哈希冲突,常见的方法有「链式地址」和「开放寻址」。

+

另一方面,考虑通过优化哈希表的表示来缓解哈希冲突,常见的方法有「链式地址 Separate Chaining」和「开放寻址 Open Addressing」。

6.2.1.   哈希表扩容

哈希函数的最后一步往往是对桶数量 \(n\) 取余,以将哈希值映射到桶的索引范围,从而将 key 放入对应的桶中。当哈希表容量越大(即 \(n\) 越大)时,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。

因此,在哈希表内的冲突整体比较严重时,编程语言一般通过扩容哈希表来缓解。与数组扩容类似,哈希表扩容需要将所有键值对从原哈希表移动至新哈希表,开销很大

编程语言一般使用「负载因子 Load Factor」来评估哈希冲突的严重程度,其定义为哈希表中元素数量除以桶数量,常用作哈希表扩容的触发条件。比如在 Java 中,当负载因子 \(> 0.75\) 时,系统会将 HashMap 容量扩充至原先的 \(2\) 倍。

6.2.2.   链式地址

-

在原始哈希表中,每个桶只能存储一个元素(即键值对)。考虑将单个元素转化成一个链表,将所有冲突元素都存储在一个链表中

+

在原始哈希表中,每个桶只能存储一个键值对。链式地址考虑将单个元素转化成一个链表,将键值对作为链表结点,将所有冲突键值对都存储在一个链表中

链式地址

Fig. 链式地址

链式地址下,哈希表操作方法为:

链式地址虽然解决了哈希冲突问题,但仍存在局限性,包括:

diff --git a/chapter_sorting/bubble_sort/index.html b/chapter_sorting/bubble_sort/index.html index 703643471..9ab4d4640 100644 --- a/chapter_sorting/bubble_sort/index.html +++ b/chapter_sorting/bubble_sort/index.html @@ -1722,13 +1722,13 @@

11.2.   冒泡排序

-

「冒泡排序 Bubble Sort」是一种最基础的排序算法,非常适合作为第一个学习的排序算法。顾名思义,「冒泡」是该算法的核心操作。

+

「冒泡排序 Bubble Sort」是一种基于元素交换实现排序的算法,非常适合作为第一个学习的排序算法。

为什么叫“冒泡”

在水中,越大的泡泡浮力越大,所以最大的泡泡会最先浮到水面。

-

「冒泡」操作则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若 左元素 > 右元素 则将它俩交换,最终可将最大元素移动至数组最右端。

-

完成此次冒泡操作后,数组最大元素已在正确位置,接下来只需排序剩余 \(n - 1\) 个元素

+

「冒泡操作」则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若“左元素 > 右元素”则将它俩交换,最终可将最大元素移动至数组最右端。

+

完成一次冒泡操作后,数组最大元素已在正确位置,接下来只需排序剩余 \(n - 1\) 个元素

@@ -1755,10 +1755,11 @@

11.2.1.   算法流程

+

设输入数组长度为 \(n\) ,循环执行「冒泡」操作:

    -
  1. 设数组长度为 \(n\) ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 \(n - 1\) 个元素。
  2. -
  3. 同理,对剩余 \(n - 1\) 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 \(n - 2\) 个。
  4. -
  5. 以此类推…… 循环 \(n - 1\) 轮「冒泡」,即可完成整个数组的排序
  6. +
  7. 完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 \(n - 1\) 个元素;
  8. +
  9. 对剩余 \(n - 1\) 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 \(n - 2\) 个;
  10. +
  11. 以此类推…… 循环 \(n - 1\) 轮「冒泡」,即可完成整个数组的排序

冒泡排序流程

Fig. 冒泡排序流程

@@ -1932,14 +1933,12 @@

11.2.2.   算法特性

-

时间复杂度 \(O(n^2)\) :各轮「冒泡」遍历的数组长度为 \(n - 1\) , \(n - 2\) , \(\cdots\) , \(2\) , \(1\) 次,求和为 \(\frac{(n - 1) n}{2}\) ,因此使用 \(O(n^2)\) 时间。

-

空间复杂度 \(O(1)\) :指针 \(i\) , \(j\) 使用常数大小的额外空间。

-

原地排序:指针变量仅使用常数大小额外空间。

-

稳定排序:不交换相等元素。

-

自适应排序:引入 flag 优化后(见下文),最佳时间复杂度为 \(O(N)\)

+

时间复杂度 \(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\) 使用常数大小的额外空间,因此是“原地排序”。

+

在冒泡操作中遇到相等元素不交换,因此是“稳定排序”。

11.2.3.   效率优化

我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 flag 来监听该情况,若出现则直接返回。

-

优化后,冒泡排序的最差和平均时间复杂度仍为 \(O(n^2)\) ;而在输入数组 已排序 时,达到 最佳时间复杂度 \(O(n)\)

+

优化后,冒泡排序的最差和平均时间复杂度仍为 \(O(n^2)\) ;而在输入数组完全有序时,达到最佳时间复杂度 \(O(n)\)

diff --git a/chapter_sorting/counting_sort/index.html b/chapter_sorting/counting_sort/index.html index 2edd17966..71d24b16e 100644 --- a/chapter_sorting/counting_sort/index.html +++ b/chapter_sorting/counting_sort/index.html @@ -2128,14 +2128,8 @@

11.6.3.   算法特性

时间复杂度 \(O(n + m)\) :涉及遍历 nums 和遍历 counter ,都使用线性时间。一般情况下 \(n \gg m\) ,此时使用线性 \(O(n)\) 时间。

-

空间复杂度 \(O(n + m)\) :数组 rescounter 长度分别为 \(n\) , \(m\)

-

非原地排序:借助了辅助数组 counter 和结果数组 res 的额外空间。

-

稳定排序:倒序遍历 nums 保持了相等元素的相对位置。

-

非自适应排序:与元素分布无关。

-
-

为什么是稳定排序?

-

由于向 res 中填充元素的顺序是“从右向左”的,因此倒序遍历 nums 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”;其实正序遍历 nums 也可以得到正确的排序结果,但结果“非稳定”。

-
+

空间复杂度 \(O(n + m)\) :借助了长度分别为 \(n\) , \(m\) 的数组 rescounter ,是“非原地排序”;

+

稳定排序:由于向 res 中填充元素的顺序是“从右向左”的,因此倒序遍历 nums 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”;其实正序遍历 nums 也可以得到正确的排序结果,但结果“非稳定”。

11.6.4.   局限性

看到这里,你也许会觉得计数排序太妙了,咔咔一通操作,时间复杂度就下来了。然而,使用技术排序的前置条件比较苛刻。

计数排序只适用于非负整数。若想要用在其他类型数据上,则要求该数据必须可以被转化为非负整数,并且不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。

diff --git a/chapter_sorting/insertion_sort/index.html b/chapter_sorting/insertion_sort/index.html index 800ba87a3..a6d1294fd 100644 --- a/chapter_sorting/insertion_sort/index.html +++ b/chapter_sorting/insertion_sort/index.html @@ -1729,10 +1729,11 @@

Fig. 单次插入操作

11.3.1.   算法流程

+

循环执行插入操作:

    -
  1. 第 1 轮先选取数组的 第 2 个元素base ,执行「插入操作」后,数组前 2 个元素已完成排序
  2. -
  3. 第 2 轮选取 第 3 个元素base ,执行「插入操作」后,数组前 3 个元素已完成排序
  4. -
  5. 以此类推……最后一轮选取 数组尾元素base ,执行「插入操作」后,所有元素已完成排序
  6. +
  7. 先选取数组的 第 2 个元素base ,执行插入操作后,数组前 2 个元素已完成排序
  8. +
  9. 选取 第 3 个元素base ,执行插入操作后,数组前 3 个元素已完成排序
  10. +
  11. 以此类推……最后一轮选取 数组尾元素base ,执行插入操作后,所有元素已完成排序

插入排序流程

Fig. 插入排序流程

@@ -1895,23 +1896,21 @@

11.3.2.   算法特性

-

时间复杂度 \(O(n^2)\) :最差情况下,各轮插入操作循环 \(n - 1\) , \(n-2\) , \(\cdots\) , \(2\) , \(1\) 次,求和为 \(\frac{(n - 1) n}{2}\) ,使用 \(O(n^2)\) 时间。

-

空间复杂度 \(O(1)\) :指针 \(i\) , \(j\) 使用常数大小的额外空间。

-

原地排序:指针变量仅使用常数大小额外空间。

-

稳定排序:不交换相等元素。

-

自适应排序:最佳情况下,时间复杂度为 \(O(n)\)

+

时间复杂度 \(O(n^2)\) :最差情况下,各轮插入操作循环 \(n - 1\) , \(n-2\) , \(\cdots\) , \(2\) , \(1\) 次,求和为 \(\frac{(n - 1) n}{2}\) ,使用 \(O(n^2)\) 时间。输入数组完全有序下,达到最佳时间复杂度 \(O(n)\) ,因此是“自适应排序”。

+

空间复杂度 \(O(1)\) :指针 \(i\) , \(j\) 使用常数大小的额外空间,因此是“原地排序”。

+

在插入操作中,我们会将元素插入到相等元素的右边,不会改变它们的次序,因此是“稳定排序”。

11.3.3.   插入排序 vs 冒泡排序

-
-

Question

-

虽然「插入排序」和「冒泡排序」的时间复杂度皆为 \(O(n^2)\) ,但实际运行速度却有很大差别,这是为什么呢?

-
-

回顾复杂度分析,两个方法的循环次数都是 \(\frac{(n - 1) n}{2}\) 。但不同的是,「冒泡操作」是在做 元素交换,需要借助一个临时变量实现,共 3 个单元操作;而「插入操作」是在做 赋值,只需 1 个单元操作;因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍。

-

插入排序运行速度快,并且具有原地、稳定、自适应的优点,因此很受欢迎。实际上,包括 Java 在内的许多编程语言的排序库函数的实现都用到了插入排序。库函数的大致思路:

+

回顾「冒泡排序」和「插入排序」的复杂度分析,两者的循环轮数都是 \(\frac{(n - 1) n}{2}\) 。但不同的是:

+ +

因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍,因此更受欢迎。实际上,许多编程语言(例如 Java)的内置排序函数都使用到了插入排序,大致思路为:

-

在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。

+

在数据量较小时插入排序更快,这是因为复杂度中的常数项(即每轮中的单元操作数量)占主导作用。这个现象与「线性查找」和「二分查找」的情况类似。

diff --git a/chapter_sorting/merge_sort/index.html b/chapter_sorting/merge_sort/index.html index b9666e996..ddcf5bd1b 100644 --- a/chapter_sorting/merge_sort/index.html +++ b/chapter_sorting/merge_sort/index.html @@ -2198,13 +2198,9 @@
  • 判断 tmp[i]tmp[j] 的大小的操作中,还 需考虑当子数组遍历完成后的索引越界问题,即 i > leftEndj > rightEnd 的情况,索引越界的优先级是最高的,例如如果左子数组已经被合并完了,那么不用继续判断,直接合并右子数组元素即可。
  • 11.5.2.   算法特性

    - +

    时间复杂度 \(O(n \log n)\) :划分形成高度为 \(\log n\) 的递归树,每层合并的总操作数量为 \(n\) ,总体使用 \(O(n \log n)\) 时间。

    +

    空间复杂度 \(O(n)\) :需借助辅助数组实现合并,使用 \(O(n)\) 大小的额外空间;递归深度为 \(\log n\) ,使用 \(O(\log n)\) 大小的栈帧空间,因此是“非原地排序”。

    +

    在合并时,不改变相等元素的次序,是“稳定排序”。

    11.5.3.   链表排序 *

    归并排序有一个很特别的优势,用于排序链表时有很好的性能表现,空间复杂度可被优化至 \(O(1)\) ,这是因为: