mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-14 10:20:40 +08:00
build
This commit is contained in:
@@ -6,7 +6,7 @@ comments: true
|
||||
|
||||
「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。
|
||||
|
||||
如图 11-4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。
|
||||
如图 11-4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。
|
||||
|
||||
=== "<1>"
|
||||
{ class="animation-figure" }
|
||||
@@ -44,6 +44,8 @@ comments: true
|
||||
|
||||
<p align="center"> 图 11-5 冒泡排序流程 </p>
|
||||
|
||||
示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="bubble_sort.py"
|
||||
@@ -279,7 +281,7 @@ comments: true
|
||||
|
||||
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
|
||||
|
||||
经过优化,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;但当输入数组完全有序时,可达到最佳时间复杂度 $O(n)$ 。
|
||||
经过优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为 $O(n^2)$ ;但当输入数组完全有序时,可达到最佳时间复杂度 $O(n)$ 。
|
||||
|
||||
=== "Python"
|
||||
|
||||
|
||||
@@ -4,22 +4,24 @@ comments: true
|
||||
|
||||
# 11.8 桶排序
|
||||
|
||||
前述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
|
||||
前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
|
||||
|
||||
「桶排序 bucket sort」是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
|
||||
|
||||
## 11.8.1 算法流程
|
||||
|
||||
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如图 11-13 所示。
|
||||
考虑一个长度为 $n$ 的数组,其元素是范围 $[0, 1)$ 内的浮点数。桶排序的流程如图 11-13 所示。
|
||||
|
||||
1. 初始化 $k$ 个桶,将 $n$ 个元素分配到 $k$ 个桶中。
|
||||
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)。
|
||||
3. 按照桶的从小到大的顺序,合并结果。
|
||||
2. 对每个桶分别执行排序(这里采用编程语言的内置排序函数)。
|
||||
3. 按照桶从小到大的顺序合并结果。
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
<p align="center"> 图 11-13 桶排序算法流程 </p>
|
||||
|
||||
代码如下所示:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="bucket_sort.py"
|
||||
@@ -30,7 +32,7 @@ comments: true
|
||||
buckets = [[] for _ in range(k)]
|
||||
# 1. 将数组元素分配到各个桶中
|
||||
for num in nums:
|
||||
# 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
# 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
i = int(num * k)
|
||||
# 将 num 添加进桶 i
|
||||
buckets[i].append(num)
|
||||
@@ -56,7 +58,7 @@ comments: true
|
||||
vector<vector<float>> buckets(k);
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (float num : nums) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
int i = num * k;
|
||||
// 将 num 添加进桶 bucket_idx
|
||||
buckets[i].push_back(num);
|
||||
@@ -89,7 +91,7 @@ comments: true
|
||||
}
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (float num : nums) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
int i = (int) (num * k);
|
||||
// 将 num 添加进桶 i
|
||||
buckets.get(i).add(num);
|
||||
@@ -122,7 +124,7 @@ comments: true
|
||||
}
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
foreach (float num in nums) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
int i = (int)(num * k);
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i].Add(num);
|
||||
@@ -155,7 +157,7 @@ comments: true
|
||||
}
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for _, num := range nums {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
i := int(num * float64(k))
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i] = append(buckets[i], num)
|
||||
@@ -186,7 +188,7 @@ comments: true
|
||||
var buckets = (0 ..< k).map { _ in [Double]() }
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for num in nums {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
let i = Int(num * Double(k))
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i].append(num)
|
||||
@@ -220,7 +222,7 @@ comments: true
|
||||
}
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (const num of nums) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
const i = Math.floor(num * k);
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i].push(num);
|
||||
@@ -253,7 +255,7 @@ comments: true
|
||||
}
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (const num of nums) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
const i = Math.floor(num * k);
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i].push(num);
|
||||
@@ -284,7 +286,7 @@ comments: true
|
||||
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (double _num in nums) {
|
||||
// 输入数据范围 [0, 1),使用 _num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 _num * k 映射到索引范围 [0, k-1]
|
||||
int i = (_num * k).toInt();
|
||||
// 将 _num 添加进桶 bucket_idx
|
||||
buckets[i].add(_num);
|
||||
@@ -313,7 +315,7 @@ comments: true
|
||||
let mut buckets = vec![vec![]; k];
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for &mut num in &mut *nums {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
let i = (num * k as f64) as usize;
|
||||
// 将 num 添加进桶 i
|
||||
buckets[i].push(num);
|
||||
@@ -349,7 +351,7 @@ comments: true
|
||||
|
||||
// 1. 将数组元素分配到各个桶中
|
||||
for (int i = 0; i < size; i++) {
|
||||
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
// 输入数据范围为 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
|
||||
int bucket_idx = nums[i] * k;
|
||||
int j = 0;
|
||||
// 如果桶中有数据且数据小于当前值 nums[i], 要将其放到当前桶的后面,相当于 cpp 中的 push_back
|
||||
@@ -397,17 +399,17 @@ comments: true
|
||||
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
|
||||
|
||||
- **时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历所有桶和元素,花费 $O(n + k)$ 时间。
|
||||
- **自适应排序**:在最坏情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。
|
||||
- **自适应排序**:在最差情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。
|
||||
- **空间复杂度 $O(n + k)$、非原地排序**:需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。
|
||||
- 桶排序是否稳定取决于排序桶内元素的算法是否稳定。
|
||||
|
||||
## 11.8.3 如何实现平均分配
|
||||
|
||||
桶排序的时间复杂度理论上可以达到 $O(n)$ ,**关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。
|
||||
桶排序的时间复杂度理论上可以达到 $O(n)$ ,**关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 个,各个桶中的商品数量差距会非常大。
|
||||
|
||||
为实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。
|
||||
为实现平均分配,我们可以先设定一条大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。
|
||||
|
||||
如图 11-14 所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
|
||||
如图 11-14 所示,这种方法本质上是创建一棵递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
|
||||
@@ -10,14 +10,16 @@ comments: true
|
||||
|
||||
先来看一个简单的例子。给定一个长度为 $n$ 的数组 `nums` ,其中的元素都是“非负整数”,计数排序的整体流程如图 11-16 所示。
|
||||
|
||||
1. 遍历数组,找出数组中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter` 。
|
||||
1. 遍历数组,找出其中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter` 。
|
||||
2. **借助 `counter` 统计 `nums` 中各数字的出现次数**,其中 `counter[num]` 对应数字 `num` 的出现次数。统计方法很简单,只需遍历 `nums`(设当前数字为 `num`),每轮将 `counter[num]` 增加 $1$ 即可。
|
||||
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经被排序好了**。接下来,我们遍历 `counter` ,根据各数字的出现次数,将它们按从小到大的顺序填入 `nums` 即可。
|
||||
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经排序好了**。接下来,我们遍历 `counter` ,根据各数字出现次数从小到大的顺序填入 `nums` 即可。
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
<p align="center"> 图 11-16 计数排序流程 </p>
|
||||
|
||||
代码如下所示:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="counting_sort.py"
|
||||
@@ -321,11 +323,11 @@ comments: true
|
||||
|
||||
!!! note "计数排序与桶排序的联系"
|
||||
|
||||
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
|
||||
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
|
||||
|
||||
## 11.9.2 完整实现
|
||||
|
||||
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。假设输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
|
||||
细心的读者可能发现了,**如果输入数据是对象,上述步骤 `3.` 就失效了**。假设输入数据是商品对象,我们想按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
|
||||
|
||||
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的“前缀和”。顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和:
|
||||
|
||||
@@ -366,7 +368,7 @@ $$
|
||||
|
||||
<p align="center"> 图 11-17 计数排序步骤 </p>
|
||||
|
||||
计数排序的实现代码如下所示。
|
||||
计数排序的实现代码如下所示:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -784,8 +786,8 @@ $$
|
||||
|
||||
## 11.9.4 局限性
|
||||
|
||||
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格。
|
||||
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序。然而,使用计数排序的前置条件相对较为严格。
|
||||
|
||||
**计数排序只适用于非负整数**。若想要将其用于其他类型的数据,需要确保这些数据可以被转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。
|
||||
**计数排序只适用于非负整数**。若想将其用于其他类型的数据,需要确保这些数据可以转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去。
|
||||
|
||||
**计数排序适用于数据量大但数据范围较小的情况**。比如,在上述示例中 $m$ 不能太大,否则会占用过多空间。而当 $n \ll m$ 时,计数排序使用 $O(m)$ 时间,可能比 $O(n \log n)$ 的排序算法还要慢。
|
||||
|
||||
@@ -22,11 +22,11 @@ comments: true
|
||||
1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
|
||||
2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减 $1$ ,已排序元素数量加 $1$ 。
|
||||
3. 从堆顶元素开始,从顶到底执行堆化操作(Sift Down)。完成堆化后,堆的性质得到修复。
|
||||
4. 循环执行第 `2.` 和 `3.` 步。循环 $n - 1$ 轮后,即可完成数组排序。
|
||||
4. 循环执行第 `2.` 步和第 `3.` 步。循环 $n - 1$ 轮后,即可完成数组排序。
|
||||
|
||||
!!! tip
|
||||
|
||||
实际上,元素出堆操作中也包含第 `2.` 和 `3.` 步,只是多了一个弹出元素的步骤。
|
||||
实际上,元素出堆操作中也包含第 `2.` 步和第 `3.` 步,只是多了一个弹出元素的步骤。
|
||||
|
||||
=== "<1>"
|
||||
{ class="animation-figure" }
|
||||
@@ -66,7 +66,7 @@ comments: true
|
||||
|
||||
<p align="center"> 图 11-12 堆排序步骤 </p>
|
||||
|
||||
在代码实现中,我们使用了与堆章节相同的从顶至底堆化 `sift_down()` 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 `sift_down()` 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。
|
||||
在代码实现中,我们使用了与“堆”章节相同的从顶至底堆化 `sift_down()` 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 `sift_down()` 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。代码如下所示:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -97,7 +97,7 @@ comments: true
|
||||
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)
|
||||
@@ -136,7 +136,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (int i = nums.size() - 1; i > 0; --i) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
swap(nums[0], nums[i]);
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
siftDown(nums, i, 0);
|
||||
@@ -178,7 +178,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (int i = nums.length - 1; i > 0; i--) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
int tmp = nums[0];
|
||||
nums[0] = nums[i];
|
||||
nums[i] = tmp;
|
||||
@@ -220,7 +220,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (int i = nums.Length - 1; i > 0; i--) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
(nums[i], nums[0]) = (nums[0], nums[i]);
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
SiftDown(nums, i, 0);
|
||||
@@ -263,7 +263,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for i := len(*nums) - 1; i > 0; i-- {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
(*nums)[0], (*nums)[i] = (*nums)[i], (*nums)[0]
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
siftDown(nums, i, 0)
|
||||
@@ -307,7 +307,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for i in stride(from: nums.count - 1, to: 0, by: -1) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
nums.swapAt(0, i)
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
siftDown(nums: &nums, n: i, i: 0)
|
||||
@@ -350,7 +350,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (let i = nums.length - 1; i > 0; i--) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
[nums[0], nums[i]] = [nums[i], nums[0]];
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
siftDown(nums, i, 0);
|
||||
@@ -393,7 +393,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (let i = nums.length - 1; i > 0; i--) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
[nums[0], nums[i]] = [nums[i], nums[0]];
|
||||
// 以根节点为起点,从顶至底进行堆化
|
||||
siftDown(nums, i, 0);
|
||||
@@ -432,7 +432,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (int i = nums.length - 1; i > 0; i--) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
int tmp = nums[0];
|
||||
nums[0] = nums[i];
|
||||
nums[i] = tmp;
|
||||
@@ -479,7 +479,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for i in (1..=nums.len() - 1).rev() {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
let tmp = nums[0];
|
||||
nums[0] = nums[i];
|
||||
nums[i] = tmp;
|
||||
@@ -524,7 +524,7 @@ comments: true
|
||||
}
|
||||
// 从堆中提取最大元素,循环 n-1 轮
|
||||
for (int i = n - 1; i > 0; --i) {
|
||||
// 交换根节点与最右叶节点(即交换首元素与尾元素)
|
||||
// 交换根节点与最右叶节点(交换首元素与尾元素)
|
||||
int tmp = nums[0];
|
||||
nums[0] = nums[i];
|
||||
nums[i] = tmp;
|
||||
|
||||
@@ -8,7 +8,7 @@ comments: true
|
||||
|
||||
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
|
||||
|
||||
图 11-6 展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后再将 `base` 赋值给目标索引。
|
||||
图 11-6 展示了数组插入元素的操作流程。设基准元素为 `base` ,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后将 `base` 赋值给目标索引。
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
@@ -27,6 +27,8 @@ comments: true
|
||||
|
||||
<p align="center"> 图 11-7 插入排序流程 </p>
|
||||
|
||||
示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="insertion_sort.py"
|
||||
@@ -250,17 +252,17 @@ comments: true
|
||||
|
||||
## 11.4.2 算法特性
|
||||
|
||||
- **时间复杂度 $O(n^2)$、自适应排序**:最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
|
||||
- **时间复杂度 $O(n^2)$、自适应排序**:在最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
|
||||
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **稳定排序**:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
|
||||
|
||||
## 11.4.3 插入排序优势
|
||||
|
||||
插入排序的时间复杂度为 $O(n^2)$ ,而我们即将学习的快速排序的时间复杂度为 $O(n \log n)$ 。尽管插入排序的时间复杂度相比快速排序更高,**但在数据量较小的情况下,插入排序通常更快**。
|
||||
插入排序的时间复杂度为 $O(n^2)$ ,而我们即将学习的快速排序的时间复杂度为 $O(n \log n)$ 。尽管插入排序的时间复杂度更高,**但在数据量较小的情况下,插入排序通常更快**。
|
||||
|
||||
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 $O(n \log n)$ 的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时,$n^2$ 和 $n \log n$ 的数值比较接近,复杂度不占主导作用;每轮中的单元操作数量起到决定性因素。
|
||||
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 $O(n \log n)$ 的算法属于基于分治策略的排序算法,往往包含更多单元计算操作。而在数据量较小时,$n^2$ 和 $n \log n$ 的数值比较接近,复杂度不占主导地位;每轮中的单元操作数量起到决定性作用。
|
||||
|
||||
实际上,许多编程语言(例如 Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
|
||||
实际上,许多编程语言(例如 Java)的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序。
|
||||
|
||||
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 $O(n^2)$ ,但在实际情况中,**插入排序的使用频率显著高于冒泡排序和选择排序**,主要有以下原因。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ comments: true
|
||||
如图 11-11 所示,“划分阶段”从顶至底递归地将数组从中点切分为两个子数组。
|
||||
|
||||
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]` )。
|
||||
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分。
|
||||
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时终止。
|
||||
|
||||
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
|
||||
|
||||
@@ -59,6 +59,8 @@ comments: true
|
||||
- **后序遍历**:先递归左子树,再递归右子树,最后处理根节点。
|
||||
- **归并排序**:先递归左子数组,再递归右子数组,最后处理合并。
|
||||
|
||||
归并排序的实现如以下代码所示。请注意,`nums` 的待合并区间为 `[left, right]` ,而 `tmp` 的对应区间为 `[0, right - left]` 。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="merge_sort.py"
|
||||
@@ -631,19 +633,17 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
值得注意的是,`nums` 的待合并区间为 `[left, right]` ,而 `tmp` 的对应区间为 `[0, right - left]` 。
|
||||
|
||||
## 11.6.2 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$、非自适应排序**:划分产生高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,因此总体时间复杂度为 $O(n \log n)$ 。
|
||||
- **空间复杂度 $O(n)$、非原地排序**:递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 $O(n)$ 大小的额外空间。
|
||||
- **稳定排序**:在合并过程中,相等元素的次序保持不变。
|
||||
|
||||
## 11.6.3 链表排序 *
|
||||
## 11.6.3 链表排序
|
||||
|
||||
对于链表,归并排序相较于其他排序算法具有显著优势,**可以将链表排序任务的空间复杂度优化至 $O(1)$** 。
|
||||
|
||||
- **划分阶段**:可以通过使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
|
||||
- **划分阶段**:可以使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
|
||||
- **合并阶段**:在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建额外链表。
|
||||
|
||||
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。
|
||||
具体实现细节比较复杂,有兴趣的读者可以查阅相关资料进行学习。
|
||||
|
||||
@@ -52,7 +52,7 @@ comments: true
|
||||
```python title="quick_sort.py"
|
||||
def partition(self, nums: list[int], left: int, right: int) -> int:
|
||||
"""哨兵划分"""
|
||||
# 以 nums[left] 作为基准数
|
||||
# 以 nums[left] 为基准数
|
||||
i, j = left, right
|
||||
while i < j:
|
||||
while i < j and nums[j] >= nums[left]:
|
||||
@@ -78,7 +78,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
int partition(vector<int> &nums, int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -104,7 +104,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
int partition(int[] nums, int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -128,7 +128,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
int Partition(int[] nums, int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -147,7 +147,7 @@ comments: true
|
||||
```go title="quick_sort.go"
|
||||
/* 哨兵划分 */
|
||||
func (q *quickSort) partition(nums []int, left, right int) int {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
i, j := left, right
|
||||
for i < j {
|
||||
for i < j && nums[j] >= nums[left] {
|
||||
@@ -177,7 +177,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
func partition(nums: inout [Int], left: Int, right: Int) -> Int {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
var i = left
|
||||
var j = right
|
||||
while i < j {
|
||||
@@ -206,7 +206,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
partition(nums, left, right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let i = left,
|
||||
j = right;
|
||||
while (i < j) {
|
||||
@@ -236,7 +236,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
partition(nums: number[], left: number, right: number): number {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let i = left,
|
||||
j = right;
|
||||
while (i < j) {
|
||||
@@ -266,7 +266,7 @@ comments: true
|
||||
|
||||
/* 哨兵划分 */
|
||||
int _partition(List<int> nums, int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left]) j--; // 从右向左找首个小于基准数的元素
|
||||
@@ -283,7 +283,7 @@ comments: true
|
||||
```rust title="quick_sort.rs"
|
||||
/* 哨兵划分 */
|
||||
fn partition(nums: &mut [i32], left: usize, right: usize) -> usize {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let (mut i, mut j) = (left, right);
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
@@ -312,7 +312,7 @@ comments: true
|
||||
/* 快速排序类 */
|
||||
// 快速排序类-哨兵划分
|
||||
int partition(int nums[], int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left]) {
|
||||
@@ -345,7 +345,7 @@ comments: true
|
||||
|
||||
// 哨兵划分
|
||||
fn partition(nums: []i32, left: usize, right: usize) usize {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
var i = left;
|
||||
var j = right;
|
||||
while (i < j) {
|
||||
@@ -537,7 +537,7 @@ comments: true
|
||||
/* 快速排序类 */
|
||||
// 快速排序类-哨兵划分
|
||||
int partition(int nums[], int left, int right) {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left]) {
|
||||
@@ -588,17 +588,17 @@ comments: true
|
||||
|
||||
## 11.5.2 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
|
||||
- **时间复杂度 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ ,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
|
||||
- **空间复杂度 $O(n)$、原地排序**:在输入数组完全倒序的情况下,达到最差递归深度 $n$ ,使用 $O(n)$ 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
|
||||
- **非稳定排序**:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
|
||||
|
||||
## 11.5.3 快排为什么快?
|
||||
## 11.5.3 快速排序为什么快
|
||||
|
||||
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
|
||||
|
||||
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行。
|
||||
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
|
||||
- **复杂度的常数系数低**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
|
||||
- **复杂度的常数系数小**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。
|
||||
|
||||
## 11.5.4 基准数优化
|
||||
|
||||
@@ -610,6 +610,8 @@ comments: true
|
||||
|
||||
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),**并将这三个候选元素的中位数作为基准数**。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 $O(n^2)$ 的概率大大降低。
|
||||
|
||||
示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="quick_sort.py"
|
||||
@@ -625,11 +627,11 @@ comments: true
|
||||
|
||||
def partition(self, nums: list[int], left: int, right: int) -> int:
|
||||
"""哨兵划分(三数取中值)"""
|
||||
# 以 nums[left] 作为基准数
|
||||
# 以 nums[left] 为基准数
|
||||
med = self.median_three(nums, left, (left + right) // 2, right)
|
||||
# 将中位数交换至数组最左端
|
||||
nums[left], nums[med] = nums[med], nums[left]
|
||||
# 以 nums[left] 作为基准数
|
||||
# 以 nums[left] 为基准数
|
||||
i, j = left, right
|
||||
while i < j:
|
||||
while i < j and nums[j] >= nums[left]:
|
||||
@@ -664,7 +666,7 @@ comments: true
|
||||
int med = medianThree(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -699,7 +701,7 @@ comments: true
|
||||
int med = medianThree(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -734,7 +736,7 @@ comments: true
|
||||
int med = MedianThree(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
Swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -765,11 +767,11 @@ comments: true
|
||||
|
||||
/* 哨兵划分(三数取中值)*/
|
||||
func (q *quickSortMedian) partition(nums []int, left, right int) int {
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
med := q.medianThree(nums, left, (left+right)/2, right)
|
||||
// 将中位数交换至数组最左端
|
||||
nums[left], nums[med] = nums[med], nums[left]
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
i, j := left, right
|
||||
for i < j {
|
||||
for i < j && nums[j] >= nums[left] {
|
||||
@@ -835,7 +837,7 @@ comments: true
|
||||
);
|
||||
// 将中位数交换至数组最左端
|
||||
this.swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let i = left,
|
||||
j = right;
|
||||
while (i < j) {
|
||||
@@ -882,7 +884,7 @@ comments: true
|
||||
);
|
||||
// 将中位数交换至数组最左端
|
||||
this.swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let i = left,
|
||||
j = right;
|
||||
while (i < j) {
|
||||
@@ -920,7 +922,7 @@ comments: true
|
||||
int med = _medianThree(nums, left, (left + right) ~/ 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
_swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left]) j--; // 从右向左找首个小于基准数的元素
|
||||
@@ -953,7 +955,7 @@ comments: true
|
||||
let med = Self::median_three(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
nums.swap(left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
let (mut i, mut j) = (left, right);
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
@@ -991,7 +993,7 @@ comments: true
|
||||
int med = medianThree(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
int i = left, j = right;
|
||||
while (i < j) {
|
||||
while (i < j && nums[j] >= nums[left])
|
||||
@@ -1027,7 +1029,7 @@ comments: true
|
||||
var med = medianThree(nums, left, (left + right) / 2, right);
|
||||
// 将中位数交换至数组最左端
|
||||
swap(nums, left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
// 以 nums[left] 为基准数
|
||||
var i = left;
|
||||
var j = right;
|
||||
while (i < j) {
|
||||
@@ -1044,7 +1046,7 @@ comments: true
|
||||
|
||||
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,设递归中的子数组长度为 $m$ ,每轮哨兵划分操作都将产生长度为 $0$ 的左子数组和长度为 $m - 1$ 的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
|
||||
|
||||
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $n / 2$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。
|
||||
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $n / 2$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。代码如下所示:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -1055,7 +1057,7 @@ comments: true
|
||||
while left < right:
|
||||
# 哨兵划分操作
|
||||
pivot = self.partition(nums, left, right)
|
||||
# 对两个子数组中较短的那个执行快排
|
||||
# 对两个子数组中较短的那个执行快速排序
|
||||
if pivot - left < right - pivot:
|
||||
self.quick_sort(nums, left, pivot - 1) # 递归排序左子数组
|
||||
left = pivot + 1 # 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1073,7 +1075,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
int pivot = partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1094,7 +1096,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
int pivot = partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1115,7 +1117,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
int pivot = Partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
QuickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1136,7 +1138,7 @@ comments: true
|
||||
for left < right {
|
||||
// 哨兵划分操作
|
||||
pivot := q.partition(nums, left, right)
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if pivot-left < right-pivot {
|
||||
q.quickSort(nums, left, pivot-1) // 递归排序左子数组
|
||||
left = pivot + 1 // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1159,7 +1161,7 @@ comments: true
|
||||
while left < right {
|
||||
// 哨兵划分操作
|
||||
let pivot = partition(nums: &nums, left: left, right: right)
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left) < (right - pivot) {
|
||||
quickSortTailCall(nums: &nums, left: left, right: pivot - 1) // 递归排序左子数组
|
||||
left = pivot + 1 // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1180,7 +1182,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
let pivot = this.partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
this.quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1201,7 +1203,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
let pivot = this.partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
this.quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1222,7 +1224,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
int pivot = _partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1243,7 +1245,7 @@ comments: true
|
||||
while left < right {
|
||||
// 哨兵划分操作
|
||||
let pivot = Self::partition(nums, left as usize, right as usize) as i32;
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if pivot - left < right - pivot {
|
||||
Self::quick_sort(left, pivot - 1, nums); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1265,7 +1267,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
int pivot = partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
quickSortTailCall(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
@@ -1288,7 +1290,7 @@ comments: true
|
||||
while (left < right) {
|
||||
// 哨兵划分操作
|
||||
var pivot = partition(nums, left, right);
|
||||
// 对两个子数组中较短的那个执行快排
|
||||
// 对两个子数组中较短的那个执行快速排序
|
||||
if (pivot - left < right - pivot) {
|
||||
quickSort(nums, left, pivot - 1); // 递归排序左子数组
|
||||
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
|
||||
|
||||
@@ -4,7 +4,7 @@ comments: true
|
||||
|
||||
# 11.10 基数排序
|
||||
|
||||
上一节我们介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
|
||||
上一节介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
|
||||
|
||||
「基数排序 radix sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。
|
||||
|
||||
@@ -20,7 +20,7 @@ comments: true
|
||||
|
||||
<p align="center"> 图 11-18 基数排序算法流程 </p>
|
||||
|
||||
下面来剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
|
||||
下面剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
|
||||
|
||||
$$
|
||||
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d
|
||||
@@ -28,7 +28,7 @@ $$
|
||||
|
||||
其中 $\lfloor a \rfloor$ 表示对浮点数 $a$ 向下取整,而 $\bmod \: d$ 表示对 $d$ 取余。对于学号数据,$d = 10$ 且 $k \in [1, 8]$ 。
|
||||
|
||||
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序。
|
||||
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -40,7 +40,7 @@ $$
|
||||
|
||||
def counting_sort_digit(nums: list[int], exp: int):
|
||||
"""计数排序(根据 nums 第 k 位排序)"""
|
||||
# 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
# 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
counter = [0] * 10
|
||||
n = len(nums)
|
||||
# 统计 0~9 各数字的出现次数
|
||||
@@ -87,7 +87,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
void countingSortDigit(vector<int> &nums, int exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
vector<int> counter(10, 0);
|
||||
int n = nums.size();
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -137,7 +137,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
void countingSortDigit(int[] nums, int exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
int[] counter = new int[10];
|
||||
int n = nums.length;
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -190,7 +190,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
void CountingSortDigit(int[] nums, int exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
int[] counter = new int[10];
|
||||
int n = nums.Length;
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -245,7 +245,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
func countingSortDigit(nums []int, exp int) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
counter := make([]int, 10)
|
||||
n := len(nums)
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -302,7 +302,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
func countingSortDigit(nums: inout [Int], exp: Int) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
var counter = Array(repeating: 0, count: 10)
|
||||
let n = nums.count
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -359,7 +359,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
function countingSortDigit(nums, exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
const counter = new Array(10).fill(0);
|
||||
const n = nums.length;
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -416,7 +416,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
function countingSortDigit(nums: number[], exp: number): void {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
const counter = new Array(10).fill(0);
|
||||
const n = nums.length;
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -473,7 +473,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
void countingSortDigit(List<int> nums, int exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
List<int> counter = List<int>.filled(10, 0);
|
||||
int n = nums.length;
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -524,7 +524,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
fn counting_sort_digit(nums: &mut [i32], exp: i32) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
let mut counter = [0; 10];
|
||||
let n = nums.len();
|
||||
// 统计 0~9 各数字的出现次数
|
||||
@@ -574,7 +574,7 @@ $$
|
||||
|
||||
/* 计数排序(根据 nums 第 k 位排序) */
|
||||
void countingSortDigit(int nums[], int size, int exp) {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
int *counter = (int *)malloc((sizeof(int) * 10));
|
||||
// 统计 0~9 各数字的出现次数
|
||||
for (int i = 0; i < size; i++) {
|
||||
@@ -631,7 +631,7 @@ $$
|
||||
|
||||
// 计数排序(根据 nums 第 k 位排序)
|
||||
fn countingSortDigit(nums: []i32, exp: i32) !void {
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
|
||||
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
|
||||
var mem_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
// defer mem_arena.deinit();
|
||||
const mem_allocator = mem_arena.allocator();
|
||||
@@ -686,7 +686,7 @@ $$
|
||||
|
||||
!!! question "为什么从最低位开始排序?"
|
||||
|
||||
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
|
||||
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,因此应该先排序低位再排序高位。
|
||||
|
||||
## 11.10.2 算法特性
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ comments: true
|
||||
|
||||
# 11.2 选择排序
|
||||
|
||||
「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
|
||||
「选择排序 selection sort」的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
|
||||
|
||||
设数组的长度为 $n$ ,选择排序的算法流程如图 11-2 所示。
|
||||
|
||||
1. 初始状态下,所有元素未排序,即未排序(索引)区间为 $[0, n-1]$ 。
|
||||
2. 选取区间 $[0, n-1]$ 中的最小元素,将其与索引 $0$ 处元素交换。完成后,数组前 1 个元素已排序。
|
||||
3. 选取区间 $[1, n-1]$ 中的最小元素,将其与索引 $1$ 处元素交换。完成后,数组前 2 个元素已排序。
|
||||
2. 选取区间 $[0, n-1]$ 中的最小元素,将其与索引 $0$ 处的元素交换。完成后,数组前 1 个元素已排序。
|
||||
3. 选取区间 $[1, n-1]$ 中的最小元素,将其与索引 $1$ 处的元素交换。完成后,数组前 2 个元素已排序。
|
||||
4. 以此类推。经过 $n - 1$ 轮选择与交换后,数组前 $n - 1$ 个元素已排序。
|
||||
5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
|
||||
|
||||
@@ -49,7 +49,7 @@ comments: true
|
||||
|
||||
<p align="center"> 图 11-2 选择排序步骤 </p>
|
||||
|
||||
在代码中,我们用 $k$ 来记录未排序区间内的最小元素。
|
||||
在代码中,我们用 $k$ 来记录未排序区间内的最小元素:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -288,7 +288,7 @@ comments: true
|
||||
|
||||
- **时间复杂度为 $O(n^2)$、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$、$n - 1$、$\dots$、$3$、$2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。
|
||||
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **非稳定排序**:如图 11-3 所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。
|
||||
- **非稳定排序**:如图 11-3 所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者的相对顺序发生改变。
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ comments: true
|
||||
|
||||
# 11.1 排序算法
|
||||
|
||||
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。
|
||||
「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更高效地查找、分析和处理。
|
||||
|
||||
如图 11-1 所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。
|
||||
|
||||
@@ -14,13 +14,13 @@ comments: true
|
||||
|
||||
## 11.1.1 评价维度
|
||||
|
||||
**运行效率**:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(即时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。
|
||||
**运行效率**:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对于大数据量的情况,运行效率显得尤为重要。
|
||||
|
||||
**就地性**:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
|
||||
|
||||
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。
|
||||
|
||||
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。
|
||||
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失:
|
||||
|
||||
```shell
|
||||
# 输入数据是按照姓名排序好的
|
||||
@@ -41,11 +41,11 @@ comments: true
|
||||
('E', 23)
|
||||
```
|
||||
|
||||
**自适应性**:「自适应排序」的时间复杂度会受输入数据的影响,即最佳、最差、平均时间复杂度并不完全相等。
|
||||
**自适应性**:「自适应排序」的时间复杂度会受输入数据的影响,即最佳时间复杂度、最差时间复杂度、平均时间复杂度并不完全相等。
|
||||
|
||||
自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
|
||||
|
||||
**是否基于比较**:「基于比较的排序」依赖于比较运算符($<$、$=$、$>$)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。而「非比较排序」不使用比较运算符,时间复杂度可达 $O(n)$ ,但其通用性相对较差。
|
||||
**是否基于比较**:「基于比较的排序」依赖比较运算符($<$、$=$、$>$)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。而「非比较排序」不使用比较运算符,时间复杂度可达 $O(n)$ ,但其通用性相对较差。
|
||||
|
||||
## 11.1.2 理想排序算法
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ comments: true
|
||||
### 1. 重点回顾
|
||||
|
||||
- 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 $O(n)$ 。
|
||||
- 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,它在小数据量的排序任务中非常受欢迎。
|
||||
- 插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,因此在小数据量的排序任务中非常受欢迎。
|
||||
- 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 $O(n^2)$ 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到 $O(\log n)$ 。
|
||||
- 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 $O(n)$ ;然而排序链表的空间复杂度可以优化至 $O(1)$ 。
|
||||
- 桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。
|
||||
@@ -22,11 +22,11 @@ comments: true
|
||||
|
||||
### 2. Q & A
|
||||
|
||||
!!! question "排序算法稳定性在什么情况下是必须的?"
|
||||
!!! question "排序算法稳定性在什么情况下是必需的?"
|
||||
|
||||
在现实中,我们有可能是在对象的某个属性上进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序/
|
||||
在现实中,我们有可能是基于对象的某个属性进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序:
|
||||
|
||||
先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` ;接下来对身高进行排序。由于排序算法不稳定,我们可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)` 。
|
||||
先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` ;再对身高进行排序。由于排序算法不稳定,因此可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)` 。
|
||||
|
||||
可以发现,学生 D 和 C 的位置发生了交换,姓名的有序性被破坏了,而这是我们不希望看到的。
|
||||
|
||||
@@ -42,13 +42,13 @@ comments: true
|
||||
|
||||
!!! question "关于尾递归优化,为什么选短的数组能保证递归深度不超过 $\log n$ ?"
|
||||
|
||||
递归深度就是当前未返回的递归方法的数量。每轮哨兵划分我们将原数组划分为两个子数组。在尾递归优化后,向下递归的子数组长度最大为原数组的一半长度。假设最差情况,一直为一半长度,那么最终的递归深度就是 $\log n$ 。
|
||||
递归深度就是当前未返回的递归方法的数量。每轮哨兵划分我们将原数组划分为两个子数组。在尾递归优化后,向下递归的子数组长度最大为原数组长度的一半。假设最差情况,一直为一半长度,那么最终的递归深度就是 $\log n$ 。
|
||||
|
||||
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为 $n$、$n - 1$、$\dots$、$2$、$1$ ,递归深度为 $n$ 。尾递归优化可以避免这种情况的出现。
|
||||
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为 $n$、$n - 1$、$\dots$、$2$、$1$ ,递归深度为 $n$ 。尾递归优化可以避免这种情况出现。
|
||||
|
||||
!!! question "当数组中所有元素都相等时,快速排序的时间复杂度是 $O(n^2)$ 吗?该如何处理这种退化情况?"
|
||||
|
||||
是的。这种情况可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。仅向下递归小于和大于的两部分。在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可完成排序。
|
||||
是的。对于这种情况,可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。仅向下递归小于和大于的两部分。在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可完成排序。
|
||||
|
||||
!!! question "桶排序的最差时间复杂度为什么是 $O(n^2)$ ?"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user