Add quick sort.

pull/8/head
krahets 2 years ago
parent 088f5f4ed1
commit 550024f69b

@ -1,10 +1,33 @@
# Hello 算法 <p align="center">
<a href="https://krahets.github.io/hello-algo/">
[<img src="docs/index.assets/conceptual_rendering.png" height="260" />](https://krahets.github.io/hello-algo/) <img src="docs/index.assets/conceptual_rendering.png" width="220">
</a>
动画图解、能运行、可讨论的 </p>
数据结构与算法快速入门教程 <h3 align="center">
《 Hello算法 》
</h3>
<p align="center">
动画图解、能运行、可讨论的</br>数据结构与算法快速入门教程
</p>
<p align="center">
<a href="https://krahets.github.io/hello-algo/">
<img src="docs/index.assets/demo.png" width="700">
</a>
</p>
<p align="center">
<em>
前往阅读 -
<a href="https://hello-algo.pages.dev/">
hello-algo.pages.dev
</a>
</em>
</p>
---
## 更新日志 ## 更新日志
@ -27,6 +50,7 @@
| 更新:关于本书</br>新增:如何使用本书</br>新增:一起参与创作 | 2022-11-16 | | 更新:关于本书</br>新增:如何使用本书</br>新增:一起参与创作 | 2022-11-16 |
| 新增:查找算法 | 2022-11-19 | | 新增:查找算法 | 2022-11-19 |
| 更新Markdown Stylesheet</br>新增:冒泡排序、插入排序 | 2022-11-21 | | 更新Markdown Stylesheet</br>新增:冒泡排序、插入排序 | 2022-11-21 |
| 新增:快速排序 | 2022-11-22 |
## License ## License

@ -693,3 +693,7 @@ $$
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数则是数组长度的一半 $\frac{n}{2}$ ,平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$ 。 对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数则是数组长度的一半 $\frac{n}{2}$ ,平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$ 。
但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准。 但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准。
!!! question "为什么很少看到 $\Theta$ 符号?"
实际中我们经常使用「大 $O$ 符号」来表示「平均复杂度」,这样严格意义上来说是不规范的。这可能是因为 $O$ 符号实在是太朗朗上口了。</br>如果在本书和其他资料中看到类似 **平均时间复杂度 $O(n)$** 的表述,请你直接理解为 $\Theta(n)$ 即可。

@ -44,11 +44,11 @@ comments: true
## 算法流程 ## 算法流程
设数组长度为 $n$ ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素。 1. 设数组长度为 $n$ ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素。
同理,对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个。 2. 同理,对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个。
以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**。 3. 以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**。
![bubble_sort](bubble_sort.assets/bubble_sort.png) ![bubble_sort](bubble_sort.assets/bubble_sort.png)
@ -72,7 +72,7 @@ comments: true
} }
``` ```
## 算法分析 ## 算法特性
**时间复杂度 $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)$ 时间。

@ -4,21 +4,21 @@ comments: true
# 插入排序 # 插入排序
顾名思义,「插入排序 Insertion Sort」是一种基于 **数组插入操作** 的排序算法。 「插入排序 Insertion Sort」是一种基于 **数组插入操作** 的排序算法。
「插入操作」思想:选定数组的某个元素 `base` ,将 `base` 与其左边的元素依次对比大小,并 “插入” 到正确位置。 「插入操作」思想:选定数组的某个元素为基准数 `base` ,将 `base` 与其左边的元素依次对比大小,并 “插入” 到正确位置。
然而,由于数组元素是连续的,因此我们无法直接把 `base` 插入到目标位置,而是需要把从正确位置到 `base` 之间的所有元素向右移动一位。 然而,由于数组在内存中的存储方式是连续的,我们无法直接把 `base` 插入到目标位置,而是需要将从目标位置到 `base` 之间的所有元素向右移动一位(本质上是一次数组插入操作)
![insertion_operation](insertion_sort.assets/insertion_operation.png) ![insertion_operation](insertion_sort.assets/insertion_operation.png)
## 算法流程 ## 算法流程
第 1 轮先选取数组的 **第 2 个元素** 为 `base` ,执行「插入操作」后, **数组前 2 个元素已完成排序**。 1. 第 1 轮先选取数组的 **第 2 个元素** 为 `base` ,执行「插入操作」后, **数组前 2 个元素已完成排序**。
第 2 轮选取 **第 3 个元素** 为 `base` ,执行「插入操作」后, **数组前 3 个元素已完成排序**。 2. 第 2 轮选取 **第 3 个元素** 为 `base` ,执行「插入操作」后, **数组前 3 个元素已完成排序**。
以此类推……最后一轮选取 **数组尾元素** 为 `base` ,执行「插入操作」后 **所有元素已完成排序**。 3. 以此类推……最后一轮选取 **数组尾元素** 为 `base` ,执行「插入操作」后 **所有元素已完成排序**。
![insertion_sort](insertion_sort.assets/insertion_sort.png) ![insertion_sort](insertion_sort.assets/insertion_sort.png)
@ -40,9 +40,9 @@ comments: true
} }
``` ```
## 算法分析 ## 算法特性
**时间复杂度 $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(1)$ ** 指针 $i$ , $j$ 使用常数大小的额外空间。 **空间复杂度 $O(1)$ ** 指针 $i$ , $j$ 使用常数大小的额外空间。
@ -66,6 +66,3 @@ comments: true
- 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$ - 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$
在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。 在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@ -4,3 +4,174 @@ comments: true
# 快速排序 # 快速排序
「快速排序 Quick Sort」是一种基于分治思想的排序算法速度很快、应用很广。
快速排序的核心操作为「哨兵划分」,其目标为:选取数组某个元素为 **基准数** ,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。「哨兵划分」的实现流程为:
1. 以数组最左端元素作为基准数,初始化两个指针 `i` , `j` 指向数组两端;
2. 设置一个循环,每轮中使用 `i` / `j` 分别寻找首个比基准数大 / 小的元素,并交换此两元素;
3. 不断循环步骤 `2.` ,直至 `i` , `j` 相遇时跳出,最终把基准数交换至两个子数组的分界线;
「哨兵划分」执行完毕后,原数组被划分成两个部分,即 **左子数组** 和 **右子数组** ,且满足 **左子数组任意元素 < 基准数 < 右子数组任意元素**。因此,接下来我们只需要排序两个子数组即可。
=== "Step 1"
![pivot_division_step1](quick_sort.assets/pivot_division_step1.png)
=== "Step 2"
![pivot_division_step2](quick_sort.assets/pivot_division_step2.png)
=== "Step 3"
![pivot_division_step3](quick_sort.assets/pivot_division_step3.png)
=== "Step 4"
![pivot_division_step4](quick_sort.assets/pivot_division_step4.png)
=== "Step 5"
![pivot_division_step5](quick_sort.assets/pivot_division_step5.png)
=== "Step 6"
![pivot_division_step6](quick_sort.assets/pivot_division_step6.png)
=== "Step 7"
![pivot_division_step7](quick_sort.assets/pivot_division_step7.png)
=== "Step 8"
![pivot_division_step8](quick_sort.assets/pivot_division_step8.png)
=== "Step 9"
![pivot_division_step9](quick_sort.assets/pivot_division_step9.png)
哨兵划分实现代码如下。
=== "Java"
``` java
/* 哨兵划分 */
int partition(int[] nums, int left, int right) {
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left])
j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left])
i++; // 从左向右找首个大于基准数的元素
swap(nums, i, j); // 交换这两个元素
}
swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
/* 元素交换 */
void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
```
!!! note "快速排序的分治思想"
哨兵划分的实质是将 **一个长数组的排序问题** 简化为 **两个短数组的排序问题**。
## 算法流程
1. 首先,对数组执行一次「哨兵划分」,得到待排序的 **左子数组** 和 **右子数组** 。
2. 接下来,对 **左子数组** 和 **右子数组** 分别 **递归执行**「哨兵划分」……
3. 直至子数组长度为 1 时 **终止递归** ,即可完成对整个数组的排序。
观察发现,快速排序和「二分查找」的原理类似,都是以对数阶的时间复杂度来缩小处理区间。
![quick_sort](quick_sort.assets/quick_sort.png)
=== "Java"
```java
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (l >= r) return;
// 哨兵划分
int pivot = partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
```
## 算法特性
**平均时间复杂度 $O(n \log n)$ ** 平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。
**最差时间复杂度 $O(n^2)$ ** 最差情况下,哨兵划分操作将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
**空间复杂度 $O(n)$ ** 输入数组完全倒序下,达到最差递归深度 $n$ 。
**原地性:** 只在递归中使用 $O(\log n)$ 大小的栈帧空间,为 **原地排序** 。
**稳定性:** 哨兵划分操作可能改变相等元素的相对位置,为 **非稳定排序** 。
**自适应:** 最差时间复杂度是 $O(n^2)$ ,为 **自适应排序** 。
## 快排为什么快?
从命名能够看出,快速排序在效率方面一定 “有两把刷子” 。快速排序的平均时间复杂度虽然与「归并排序」和「堆排序」一致,但实际 **效率更高** ,这是因为:
- **出现最差情况的概率很低:** 虽然快速排序的最差时间复杂度为 $O(n^2)$ ,不如归并排序,但绝大部分情况下,快速排序可以达到 $O(n \log n)$ 的复杂度。
- **缓存使用效率高:** 哨兵划分操作时,将整个子数组加载入缓存中,访问元素效率很高。而诸如「堆排序」需要跳跃式访问元素,因此不具有此特性。
- **复杂度的常数系数低:** 在提及的三种算法中,快速排序的 **比较**、**赋值**、**交换** 三种操作的总体数量最少(类似于「插入排序」快于「冒泡排序」的原因)。
## 基准数优化
**普通快速排序在某些输入下的时间效率变差。** 举个极端例子,假设输入数组是完全倒序的,由于我们选取最左端元素为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,从而 **左子数组长度为 $n - 1$ 、右子数组长度为 $0$** 。这样进一步递归下去,**每轮哨兵划分后的右子数组长度都为 $0$** ,分治策略失效,快速排序退化为「冒泡排序」了。
为了尽量避免这种情况发生,我们可以优化一下基准数的选取策略。首先,在哨兵划分中,我们可以 **随机选取一个元素作为基准数** 。但如果运气很差,每次都选择到比较差的基准数,那么效率依然不好。
进一步地,我们可以在数组中选取 3 个候选元素(一般为数组的首、尾、中点元素),**并将三个候选元素的中位数作为基准数**,这样基准数 “既不大也不小” 的概率就大大提升了。当然,如果数组很长的话,我们也可以选取更多候选元素,来进一步提升算法的稳健性。采取该方法后,时间复杂度劣化至 $O(n^2)$ 的概率极低。
=== "Java"
```java
/* 选取三个元素的中位数 */
int medianThree(int[] nums, int left, int mid, int right) {
// 使用了异或操作来简化代码
// 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
if ((nums[left] > nums[mid]) ^ (nums[left] > nums[right]))
return left;
else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right]))
return mid;
else
return right;
}
/* 哨兵划分 */
int partition(int[] nums, int left, int right) {
// 选取三个候选元素的中位数
int med = medianThree(nums, left, (left + right) / 2, right);
// 将中位数交换至数组最左端
swap(left, med);
// 继续以 nums[left] 作为基准数
// 下同...
}
```
## 尾递归优化
**普通快速排序在某些输入下的空间效率变差。** 仍然以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0 ,那么将形成一个高度为 $n - 1$ 的递归树,此时使用的栈帧空间大小劣化至 $O(n)$ 。
为了避免栈帧空间的累积,我们可以在每轮哨兵排序完成后,判断两个子数组的长度大小,仅递归排序较短的子数组。由于较短的子数组长度不会超过 $\frac{n}{2}$ ,因此这样做能保证递归深度不超过 $\log n$ ,即最差空间复杂度被优化至 $O(\log n)$ 。
=== "Java"
```java
/* 快速排序(尾递归优化)*/
void quickSort(int[] nums, int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = partition(nums, left, right);
// 对两个子数组中较短的那个执行快排
if (pivot - left < right - pivot) {
// 递归排序左子数组
quickSort(nums, left, pivot - 1);
// 剩余待排序区间为 [pivot + 1, right]
left = pivot + 1;
} else {
// 递归排序右子数组
quickSort(nums, pivot + 1, right);
// 剩余待排序区间为 [left, pivot - 1]
right = pivot - 1;
}
}
}
```

@ -219,7 +219,7 @@ comments: true
## 二叉搜索树的退化 ## 二叉搜索树的退化
二叉搜索树的理想状态是「完美二叉树」,我们称这样的二叉树是 “平衡” 的,此时可以在 $\log n$ 轮循环内查找任意结点。 理想情况下,我们希望二叉搜索树的是 “左右平衡” 的(详见「平衡二叉树」章节),此时可以在 $\log n$ 轮循环内查找任意结点。
如果我们动态地在二叉搜索树中插入与删除结点,**则可能导致二叉树退化为链表**,此时各种操作的时间复杂度也退化之 $O(n)$ 。 如果我们动态地在二叉搜索树中插入与删除结点,**则可能导致二叉树退化为链表**,此时各种操作的时间复杂度也退化之 $O(n)$ 。

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@ -8,13 +8,12 @@ hide:
<div class="result" markdown> <div class="result" markdown>
![conceptual_rendering](index.assets/conceptual_rendering.png){ align=left width=300 } ![conceptual_rendering](index.assets/conceptual_rendering.png){ align=left width=300 }
</br> </br></br></br>
</br> <p align="center">
</br> <h1 align="center"> 《 Hello算法 》 </h1>
<h1 style="text-align:center"> 《 Hello算法 》 </h1> <h3 align="center"> 动画图解、能运行、可讨论的</br>数据结构与算法快速入门教程 </h3>
<h3 style="text-align:center"> 动画图解、能运行、可讨论的</br>数据结构与算法快速入门教程 </h3> <h3 align="center"> [![github-stars](https://img.shields.io/github/stars/krahets/hello-algo?style=social)](https://github.com/krahets/hello-algo) </h3>
<p style="text-align:center"> [@Krahets](https://leetcode.cn/u/jyd/) </p> </p>
<p style="text-align:center"> [![github-stars](https://img.shields.io/github/stars/krahets/hello-algo?style=social)](https://github.com/krahets/hello-algo) </p>
</br> </br>
</div> </div>

@ -158,7 +158,7 @@ nav:
- 排序算法: - 排序算法:
- 冒泡排序: chapter_sorting/bubble_sort.md - 冒泡排序: chapter_sorting/bubble_sort.md
- 插入排序: chapter_sorting/insertion_sort.md - 插入排序: chapter_sorting/insertion_sort.md
- 归并排序: chapter_sorting/merge_sort.md
- 快速排序: chapter_sorting/quick_sort.md - 快速排序: chapter_sorting/quick_sort.md
- 归并排序: chapter_sorting/merge_sort.md
- 参考文献: - 参考文献:
- chapter_reference/index.md - chapter_reference/index.md

Loading…
Cancel
Save