From 5842243d5a0c1d06befef3bf01a15ebd17b3687b Mon Sep 17 00:00:00 2001 From: krahets Date: Fri, 26 May 2023 04:47:54 +0800 Subject: [PATCH] build --- chapter_heap/heap.md | 11 +- .../replace_linear_by_hashing.md | 2 +- chapter_sorting/bucket_sort.md | 8 +- chapter_sorting/counting_sort.md | 10 +- chapter_sorting/heap_sort.md | 244 +++++++++++++++++- chapter_sorting/quick_sort.md | 10 - chapter_sorting/radix_sort.md | 6 +- chapter_sorting/summary.md | 15 +- 8 files changed, 279 insertions(+), 27 deletions(-) diff --git a/chapter_heap/heap.md b/chapter_heap/heap.md index 449cdc720..7f94e9515 100644 --- a/chapter_heap/heap.md +++ b/chapter_heap/heap.md @@ -631,6 +631,15 @@ comments: true === "<6>" ![heap_push_step6](heap.assets/heap_push_step6.png) +=== "<7>" + ![heap_push_step7](heap.assets/heap_push_step7.png) + +=== "<8>" + ![heap_push_step8](heap.assets/heap_push_step8.png) + +=== "<9>" + ![heap_push_step9](heap.assets/heap_push_step9.png) + 设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ ,**元素入堆操作的时间复杂度为 $O(\log n)$** 。 === "Java" @@ -1348,5 +1357,5 @@ comments: true ## 8.1.3.   堆常见应用 - **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。 -- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。当然,堆排序还有一种更优雅的实现,详见后续的堆排序章节。 +- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见后续的堆排序章节。 - **获取最大的 $k$ 个元素**:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。 diff --git a/chapter_searching/replace_linear_by_hashing.md b/chapter_searching/replace_linear_by_hashing.md index 14c649455..807add3da 100755 --- a/chapter_searching/replace_linear_by_hashing.md +++ b/chapter_searching/replace_linear_by_hashing.md @@ -200,7 +200,7 @@ comments: true 考虑借助一个哈希表,键值对分别为数组元素和元素索引。循环遍历数组,每轮执行: 1. 判断数字 `target - nums[i]` 是否在哈希表中,若是则直接返回这两个元素的索引; -2. 将键值对 `num[i]` 和索引 `i` 添加进哈希表; +2. 将键值对 `nums[i]` 和索引 `i` 添加进哈希表; === "<1>" ![辅助哈希表求解两数之和](replace_linear_by_hashing.assets/two_sum_hashtable_step1.png) diff --git a/chapter_sorting/bucket_sort.md b/chapter_sorting/bucket_sort.md index 297bb67c2..429f2129f 100644 --- a/chapter_sorting/bucket_sort.md +++ b/chapter_sorting/bucket_sort.md @@ -2,13 +2,13 @@ comments: true --- -# 11.7.   桶排序 +# 11.8.   桶排序 前述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性水平。 「桶排序 Bucket Sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。 -## 11.7.1.   算法流程 +## 11.8.1.   算法流程 考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下: @@ -289,14 +289,14 @@ comments: true 桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。 -## 11.7.2.   算法特性 +## 11.8.2.   算法特性 - **时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历 $n$ 个桶,花费 $O(k)$ 时间。 - **自适应排序**:在最坏情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。 - **空间复杂度 $O(n + k)$ 、非原地排序** :需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。 - 桶排序是否稳定取决于排序桶内元素的算法是否稳定。 -## 11.7.3.   如何实现平均分配 +## 11.8.3.   如何实现平均分配 桶排序的时间复杂度理论上可以达到 $O(n)$ ,**关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。 diff --git a/chapter_sorting/counting_sort.md b/chapter_sorting/counting_sort.md index e183523f4..afd9a94e2 100644 --- a/chapter_sorting/counting_sort.md +++ b/chapter_sorting/counting_sort.md @@ -2,11 +2,11 @@ comments: true --- -# 11.8.   计数排序 +# 11.9.   计数排序 「计数排序 Counting Sort」通过统计元素数量来实现排序,通常应用于整数数组。 -## 11.8.1.   简单实现 +## 11.9.1.   简单实现 先来看一个简单的例子。给定一个长度为 $n$ 的数组 `nums` ,其中的元素都是“非负整数”。计数排序的整体流程如下: @@ -269,7 +269,7 @@ comments: true 从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。 -## 11.8.2.   完整实现 +## 11.9.2.   完整实现 细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。例如,输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。 @@ -647,13 +647,13 @@ $$ [class]{}-[func]{countingSort} ``` -## 11.8.3.   算法特性 +## 11.9.3.   算法特性 - **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。 - **空间复杂度 $O(n + m)$ 、非原地排序** :借助了长度分别为 $n$ 和 $m$ 的数组 `res` 和 `counter` 。 - **稳定排序**:由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历 `nums` 也可以得到正确的排序结果,但结果是非稳定的。 -## 11.8.4.   局限性 +## 11.9.4.   局限性 看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格。 diff --git a/chapter_sorting/heap_sort.md b/chapter_sorting/heap_sort.md index 4d70ad81b..ef0ba885e 100644 --- a/chapter_sorting/heap_sort.md +++ b/chapter_sorting/heap_sort.md @@ -1,2 +1,244 @@ -# 堆排序 +--- +comments: true +--- +# 11.7.   堆排序 + +!!! tip + + 阅读本节前,请确保已完成堆章节的学习。 + +「堆排序 Heap Sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序: + +1. 输入数组并建立小顶堆,此时最小元素位于堆顶。 +2. 初始化一个数组 `res` ,用于存储排序结果。 +3. 循环执行 $n$ 轮出堆操作,并依次将出堆元素记录至 `res` ,即可得到从小到大排序的序列。 + +该方法虽然可行,但需要借助一个额外数组,比较浪费空间。在实际中,我们通常使用一种更加优雅的实现方式。设数组的长度为 $n$ ,堆排序的流程如下: + +1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶。 +2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 $1$ ,已排序元素数量加 $1$ 。 +3. 从堆顶元素开始,从顶到底执行堆化操作(Sift Down)。完成堆化后,堆的性质得到修复。 +4. 循环执行第 `2.` 和 `3.` 步。循环 $n - 1$ 轮后,即可完成数组排序。 + +实际上,元素出堆操作中也包含第 `2.` 和 `3.` 步,只是多了一个弹出元素的步骤。 + +=== "<1>" + ![堆排序步骤](heap_sort.assets/heap_sort_step1.png) + +=== "<2>" + ![heap_sort_step2](heap_sort.assets/heap_sort_step2.png) + +=== "<3>" + ![heap_sort_step3](heap_sort.assets/heap_sort_step3.png) + +=== "<4>" + ![heap_sort_step4](heap_sort.assets/heap_sort_step4.png) + +=== "<5>" + ![heap_sort_step5](heap_sort.assets/heap_sort_step5.png) + +=== "<6>" + ![heap_sort_step6](heap_sort.assets/heap_sort_step6.png) + +=== "<7>" + ![heap_sort_step7](heap_sort.assets/heap_sort_step7.png) + +=== "<8>" + ![heap_sort_step8](heap_sort.assets/heap_sort_step8.png) + +=== "<9>" + ![heap_sort_step9](heap_sort.assets/heap_sort_step9.png) + +=== "<10>" + ![heap_sort_step10](heap_sort.assets/heap_sort_step10.png) + +=== "<11>" + ![heap_sort_step11](heap_sort.assets/heap_sort_step11.png) + +=== "<12>" + ![heap_sort_step12](heap_sort.assets/heap_sort_step12.png) + +在代码实现中,我们使用了与堆章节相同的从顶至底堆化(Sift Down)的函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 Sift Down 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。 + +=== "Java" + + ```java title="heap_sort.java" + /* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */ + void siftDown(int[] nums, int n, int i) { + while (true) { + // 判断节点 i, l, r 中值最大的节点,记为 ma + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 若节点 i 最大或索引 l, r 越界,则无需继续堆化,跳出 + if (ma == i) + break; + // 交换两节点 + int temp = nums[i]; + nums[i] = nums[ma]; + nums[ma] = temp; + // 循环向下堆化 + i = ma; + } + } + + /* 堆排序 */ + void heapSort(int[] nums) { + // 建堆操作:堆化除叶节点以外的其他所有节点 + for (int i = nums.length / 2 - 1; i >= 0; i--) { + siftDown(nums, nums.length, i); + } + // 从堆中提取最大元素,循环 n-1 轮 + for (int i = nums.length - 1; i > 0; i--) { + // 交换根节点与最右叶节点(即交换首元素与尾元素) + int tmp = nums[0]; + nums[0] = nums[i]; + nums[i] = tmp; + // 以根节点为起点,从顶至底进行堆化 + siftDown(nums, i, 0); + } + } + ``` + +=== "C++" + + ```cpp title="heap_sort.cpp" + /* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */ + void siftDown(vector &nums, int n, int i) { + while (true) { + // 判断节点 i, l, r 中值最大的节点,记为 ma + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 若节点 i 最大或索引 l, r 越界,则无需继续堆化,跳出 + if (ma == i) { + break; + } + // 交换两节点 + swap(nums[i], nums[ma]); + // 循环向下堆化 + i = ma; + } + } + + /* 堆排序 */ + void heapSort(vector &nums) { + // 建堆操作:堆化除叶节点以外的其他所有节点 + for (int i = nums.size() / 2 - 1; i >= 0; --i) { + siftDown(nums, nums.size(), i); + } + // 从堆中提取最大元素,循环 n-1 轮 + for (int i = nums.size() - 1; i > 0; --i) { + // 交换根节点与最右叶节点(即交换首元素与尾元素) + swap(nums[0], nums[i]); + // 以根节点为起点,从顶至底进行堆化 + siftDown(nums, i, 0); + } + } + ``` + +=== "Python" + + ```python title="heap_sort.py" + def sift_down(nums: list[int], n: int, i: int): + """堆的长度为 n ,从节点 i 开始,从顶至底堆化""" + while True: + # 判断节点 i, l, r 中值最大的节点,记为 ma + l = 2 * i + 1 + r = 2 * i + 2 + ma = i + if l < n and nums[l] > nums[ma]: + ma = l + if r < n and nums[r] > nums[ma]: + ma = r + # 若节点 i 最大或索引 l, r 越界,则无需继续堆化,跳出 + if ma == i: + break + # 交换两节点 + nums[i], nums[ma] = nums[ma], nums[i] + # 循环向下堆化 + i = ma + + def heap_sort(nums: list[int]): + """堆排序""" + # 建堆操作:堆化除叶节点以外的其他所有节点 + for i in range(len(nums) // 2 - 1, -1, -1): + sift_down(nums, len(nums), i) + # 从堆中提取最大元素,循环 n-1 轮 + for i in range(len(nums) - 1, 0, -1): + # 交换根节点与最右叶节点(即交换首元素与尾元素) + nums[0], nums[i] = nums[i], nums[0] + # 以根节点为起点,从顶至底进行堆化 + sift_down(nums, i, 0) + ``` + +=== "Go" + + ```go title="heap_sort.go" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +=== "JavaScript" + + ```javascript title="heap_sort.js" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +=== "TypeScript" + + ```typescript title="heap_sort.ts" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +=== "C" + + ```c title="heap_sort.c" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +=== "C#" + + ```csharp title="heap_sort.cs" + [class]{heap_sort}-[func]{siftDown} + + [class]{heap_sort}-[func]{heapSort} + ``` + +=== "Swift" + + ```swift title="heap_sort.swift" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +=== "Zig" + + ```zig title="heap_sort.zig" + [class]{}-[func]{siftDown} + + [class]{}-[func]{heapSort} + ``` + +## 11.7.1.   算法特性 + +- **时间复杂度 $O(n \log n)$ 、非自适应排序** :从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。 +- **空间复杂度 $O(1)$ 、原地排序** :几个指针变量使用 $O(1)$ 空间。元素交换和堆化操作都是在原数组上进行的。 +- **非稳定排序**:在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。 diff --git a/chapter_sorting/quick_sort.md b/chapter_sorting/quick_sort.md index 75491bd34..93f118bcb 100755 --- a/chapter_sorting/quick_sort.md +++ b/chapter_sorting/quick_sort.md @@ -1121,13 +1121,3 @@ comments: true } } ``` - -!!! question "哨兵划分中“从右往左查找”与“从左往右查找”的顺序可以交换吗?" - - 不行,当我们以最左端元素为基准数时,必须先“从右往左查找”再“从左往右查找”。这个结论有些反直觉,我们来剖析一下原因。 - - 哨兵划分 `partition()` 的最后一步是交换 `nums[left]` 和 `nums[i]` 。完成交换后,基准数左边的元素都 `<=` 基准数,**这就要求最后一步交换前 `nums[left] >= nums[i]` 必须成立**。假设我们先“从左往右查找”,那么如果找不到比基准数更小的元素,**则会在 `i == j` 时跳出循环,此时可能 `nums[j] == nums[i] > nums[left]`**。也就是说,此时最后一步交换操作会把一个比基准数更大的元素交换至数组最左端,导致哨兵划分失败。 - - 举个例子,给定数组 `[0, 0, 0, 0, 1]` ,如果先“从左向右查找”,哨兵划分后数组为 `[1, 0, 0, 0, 0]` ,这个结果是不正确的。 - - 再深入思考一下,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。 diff --git a/chapter_sorting/radix_sort.md b/chapter_sorting/radix_sort.md index 9e8e4c4dc..d8a546f70 100644 --- a/chapter_sorting/radix_sort.md +++ b/chapter_sorting/radix_sort.md @@ -2,13 +2,13 @@ comments: true --- -# 11.9.   基数排序 +# 11.10.   基数排序 上一节我们介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。 「基数排序 Radix Sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。 -## 11.9.1.   算法流程 +## 11.10.1.   算法流程 以学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的步骤如下: @@ -587,7 +587,7 @@ $$ 在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。 -## 11.9.2.   算法特性 +## 11.10.2.   算法特性 相较于计数排序,基数排序适用于数值范围较大的情况,**但前提是数据必须可以表示为固定位数的格式,且位数不能过大**。例如,浮点数不适合使用基数排序,因为其位数 $k$ 过大,可能导致时间复杂度 $O(nk) \gg O(n^2)$ 。 diff --git a/chapter_sorting/summary.md b/chapter_sorting/summary.md index 4ee0df32f..7284545ff 100644 --- a/chapter_sorting/summary.md +++ b/chapter_sorting/summary.md @@ -2,7 +2,7 @@ comments: true --- -# 11.10.   小结 +# 11.11.   小结 - 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 $O(n)$ 。 - 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,它在小数据量的排序任务中非常受欢迎。 @@ -16,5 +16,16 @@ comments: true

Fig. 排序算法对比

-- 总体来看,我们追求运行快、稳定、原地、正向自适应性的排序。显然,如同其他数据结构与算法一样,同时满足这些条件的排序算法并不存在,我们需要根据问题特点来选择排序算法。 - 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。 + +## 11.11.1.   Q & A + +!!! question "哨兵划分中“从右往左查找”与“从左往右查找”的顺序可以交换吗?" + + 不行,当我们以最左端元素为基准数时,必须先“从右往左查找”再“从左往右查找”。这个结论有些反直觉,我们来剖析一下原因。 + + 哨兵划分 `partition()` 的最后一步是交换 `nums[left]` 和 `nums[i]` 。完成交换后,基准数左边的元素都 `<=` 基准数,**这就要求最后一步交换前 `nums[left] >= nums[i]` 必须成立**。假设我们先“从左往右查找”,那么如果找不到比基准数更小的元素,**则会在 `i == j` 时跳出循环,此时可能 `nums[j] == nums[i] > nums[left]`**。也就是说,此时最后一步交换操作会把一个比基准数更大的元素交换至数组最左端,导致哨兵划分失败。 + + 举个例子,给定数组 `[0, 0, 0, 0, 1]` ,如果先“从左向右查找”,哨兵划分后数组为 `[1, 0, 0, 0, 0]` ,这个结果是不正确的。 + + 再深入思考一下,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。