mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-13 18:00:18 +08:00
build
This commit is contained in:
@@ -4,15 +4,15 @@ comments: true
|
||||
|
||||
# 11.4. 快速排序
|
||||
|
||||
「快速排序 Quick Sort」是一种基于“分治思想”的排序算法,速度很快、应用很广。
|
||||
「快速排序 Quick Sort」是一种基于分治思想的排序算法,运行高效,应用广泛。
|
||||
|
||||
快速排序的核心操作为「哨兵划分」,其目标为:选取数组某个元素为 **基准数**,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。「哨兵划分」的实现流程为:
|
||||
快速排序的核心操作是「哨兵划分」,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:
|
||||
|
||||
1. 以数组最左端元素作为基准数,初始化两个指针 `i` , `j` 指向数组两端;
|
||||
2. 设置一个循环,每轮中使用 `i` / `j` 分别寻找首个比基准数大 / 小的元素,并交换此两元素;
|
||||
3. 不断循环步骤 `2.` ,直至 `i` , `j` 相遇时跳出,最终把基准数交换至两个子数组的分界线;
|
||||
1. 选取数组最左端元素作为基准数,初始化两个指针 `i` 和 `j` 分别指向数组的两端;
|
||||
2. 设置一个循环,在每轮中使用 `i`(`j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素;
|
||||
3. 循环执行步骤 `2.` ,直到 `i` 和 `j` 相遇时停止,最后将基准数交换至两个子数组的分界线;
|
||||
|
||||
「哨兵划分」执行完毕后,原数组被划分成两个部分,即 **左子数组** 和 **右子数组**,且满足 **左子数组任意元素 < 基准数 < 右子数组任意元素**。因此,接下来我们只需要排序两个子数组即可。
|
||||
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 $\leq$ 基准数 $\leq$ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@@ -43,7 +43,7 @@ comments: true
|
||||
|
||||
!!! note "快速排序的分治思想"
|
||||
|
||||
哨兵划分的实质是将 **一个长数组的排序问题** 简化为 **两个短数组的排序问题**。
|
||||
哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -288,11 +288,9 @@ comments: true
|
||||
|
||||
## 11.4.1. 算法流程
|
||||
|
||||
1. 首先,对数组执行一次「哨兵划分」,得到待排序的 **左子数组** 和 **右子数组**;
|
||||
2. 接下来,对 **左子数组** 和 **右子数组** 分别 **递归执行**「哨兵划分」……
|
||||
3. 直至子数组长度为 1 时 **终止递归**,即可完成对整个数组的排序;
|
||||
|
||||
观察发现,快速排序和「二分查找」的原理类似,都是以对数阶的时间复杂度来缩小处理区间。
|
||||
1. 首先,对原数组执行一次「哨兵划分」,得到待排序的左子数组和右子数组;
|
||||
2. 然后,对左子数组和右子数组分别递归执行「哨兵划分」;
|
||||
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序;
|
||||
|
||||

|
||||
|
||||
@@ -451,29 +449,31 @@ 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)$ 时间,因此是“非稳定排序”。
|
||||
在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间;因此快速排序是“自适应排序”。
|
||||
|
||||
**空间复杂度 $O(n)$** :输入数组完全倒序下,达到最差递归深度 $n$ 。由于未借助辅助数组空间,因此是“原地排序”。
|
||||
**空间复杂度 $O(n)$** :在输入数组完全倒序的情况下,达到最差递归深度 $n$ 。由于未使用辅助数组,因此算法是“原地排序”。
|
||||
|
||||
**非稳定排序**:哨兵划分最后一步可能会将基准数交换至相等元素的右边。
|
||||
在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧,因此是“非稳定排序”。
|
||||
|
||||
## 11.4.3. 快排为什么快?
|
||||
|
||||
从命名能够看出,快速排序在效率方面一定“有两把刷子”。快速排序的平均时间复杂度虽然与「归并排序」和「堆排序」一致,但实际 **效率更高**,这是因为:
|
||||
从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与「归并排序」和「堆排序」相同,但通常快速排序的效率更高,原因如下:
|
||||
|
||||
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,不如归并排序,但绝大部分情况下,快速排序可以达到 $O(n \log n)$ 的复杂度。
|
||||
- **缓存使用效率高**:哨兵划分操作时,将整个子数组加载入缓存中,访问元素效率很高。而诸如「堆排序」需要跳跃式访问元素,因此不具有此特性。
|
||||
- **复杂度的常数系数低**:在提及的三种算法中,快速排序的 **比较**、**赋值**、**交换** 三种操作的总体数量最少(类似于「插入排序」快于「冒泡排序」的原因)。
|
||||
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行。
|
||||
- **缓存使用效率高**:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像「堆排序」这类算法需要跳跃式访问元素,从而缺乏这一特性。
|
||||
- **复杂度的常数系数低**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与「插入排序」比「冒泡排序」更快的原因类似。
|
||||
|
||||
## 11.4.4. 基准数优化
|
||||
|
||||
**普通快速排序在某些输入下的时间效率变差**。举个极端例子,假设输入数组是完全倒序的,由于我们选取最左端元素为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,从而 **左子数组长度为 $n - 1$、右子数组长度为 $0$** 。这样进一步递归下去,**每轮哨兵划分后的右子数组长度都为 $0$** ,分治策略失效,快速排序退化为「冒泡排序」了。
|
||||
**快速排序在某些输入下的时间效率可能降低**。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 $n - 1$ 、右子数组长度为 $0$ 。如此递归下去,每轮哨兵划分后的右子数组长度都为 $0$ ,分治策略失效,快速排序退化为「冒泡排序」。
|
||||
|
||||
为了尽量避免这种情况发生,我们可以优化一下基准数的选取策略。首先,在哨兵划分中,我们可以 **随机选取一个元素作为基准数**。但如果运气很差,每次都选择到比较差的基准数,那么效率依然不好。
|
||||
为了尽量避免这种情况发生,**我们可以优化哨兵划分中的基准数的选取策略**。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。
|
||||
|
||||
进一步地,我们可以在数组中选取 3 个候选元素(一般为数组的首、尾、中点元素),**并将三个候选元素的中位数作为基准数**,这样基准数“既不大也不小”的概率就大大提升了。当然,如果数组很长的话,我们也可以选取更多候选元素,来进一步提升算法的稳健性。采取该方法后,时间复杂度劣化至 $O(n^2)$ 的概率极低。
|
||||
需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,那么快速排序的效率仍然可能劣化。
|
||||
|
||||
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),**并将这三个候选元素的中位数作为基准数**。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 $O(n^2)$ 的概率大大降低。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -792,9 +792,9 @@ comments: true
|
||||
|
||||
## 11.4.5. 尾递归优化
|
||||
|
||||
**普通快速排序在某些输入下的空间效率变差**。仍然以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0 ,那么将形成一个高度为 $n - 1$ 的递归树,此时使用的栈帧空间大小劣化至 $O(n)$ 。
|
||||
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
|
||||
|
||||
为了避免栈帧空间的累积,我们可以在每轮哨兵排序完成后,判断两个子数组的长度大小,仅递归排序较短的子数组。由于较短的子数组长度不会超过 $\frac{n}{2}$ ,因此这样做能保证递归深度不超过 $\log n$ ,即最差空间复杂度被优化至 $O(\log n)$ 。
|
||||
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $\frac{n}{2}$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -1001,8 +1001,8 @@ comments: true
|
||||
|
||||
不行,当我们以最左端元素为基准数时,必须先“从右往左查找”再“从左往右查找”。这个结论有些反直觉,我们来剖析一下原因。
|
||||
|
||||
哨兵划分 `partition()` 的最后一步是交换 `nums[left]` 和 `nums[i]` ,完成交换后,基准数左边的元素都 `<=` 基准数,**这就要求最后一步交换前 `nums[left] >= nums[i]` 必须成立**。假设我们先“从左往右查找”,那么如果找不到比基准数更小的元素,**则会在 `i == j` 时跳出循环,此时可能 `nums[j] == nums[i] > nums[left]`** ;也就是说,此时最后一步交换操作会把一个比基准数更大的元素交换至数组最左端,导致哨兵划分失败。
|
||||
哨兵划分 `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]` ,这个结果是不对的。
|
||||
举个例子,给定数组 `[0, 0, 0, 0, 1]` ,如果先“从左向右查找”,哨兵划分后数组为 `[1, 0, 0, 0, 0]` ,这个结果是不正确的。
|
||||
|
||||
再深想一步,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。
|
||||
再深入思考一下,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。
|
||||
|
||||
Reference in New Issue
Block a user