diff --git a/README.md b/README.md index edde58438..7bdf6df21 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,33 @@ -# Hello 算法 - -[](https://krahets.github.io/hello-algo/) - -动画图解、能运行、可讨论的 - -数据结构与算法快速入门教程 +

+ + + +

+ +

+ 《 Hello,算法 》 +

+ +

+ 动画图解、能运行、可讨论的
数据结构与算法快速入门教程 +

+ +

+ + + +

+ +

+ + 前往阅读 - + + hello-algo.pages.dev + + +

+ +--- ## 更新日志 @@ -27,6 +50,7 @@ | 更新:关于本书
新增:如何使用本书
新增:一起参与创作 | 2022-11-16 | | 新增:查找算法 | 2022-11-19 | | 更新:Markdown Stylesheet
新增:冒泡排序、插入排序 | 2022-11-21 | +| 新增:快速排序 | 2022-11-22 | ## License diff --git a/docs/chapter_computational_complexity/time_complexity.md b/docs/chapter_computational_complexity/time_complexity.md index bc9f8e384..745431b2b 100644 --- a/docs/chapter_computational_complexity/time_complexity.md +++ b/docs/chapter_computational_complexity/time_complexity.md @@ -693,3 +693,7 @@ $$ 对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数则是数组长度的一半 $\frac{n}{2}$ ,平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$ 。 但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准。 + +!!! question "为什么很少看到 $\Theta$ 符号?" + + 实际中我们经常使用「大 $O$ 符号」来表示「平均复杂度」,这样严格意义上来说是不规范的。这可能是因为 $O$ 符号实在是太朗朗上口了。
如果在本书和其他资料中看到类似 **平均时间复杂度 $O(n)$** 的表述,请你直接理解为 $\Theta(n)$ 即可。 diff --git a/docs/chapter_sorting/bubble_sort.md b/docs/chapter_sorting/bubble_sort.md index 6d74edd09..bd9d9923c 100644 --- a/docs/chapter_sorting/bubble_sort.md +++ b/docs/chapter_sorting/bubble_sort.md @@ -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) @@ -72,7 +72,7 @@ comments: true } ``` -## 算法分析 +## 算法特性 **时间复杂度 $O(n^2)$ :** 各轮「冒泡」遍历的数组长度为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。 diff --git a/docs/chapter_sorting/insertion_sort.md b/docs/chapter_sorting/insertion_sort.md index f7a065de6..5c6cdc94a 100644 --- a/docs/chapter_sorting/insertion_sort.md +++ b/docs/chapter_sorting/insertion_sort.md @@ -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) ## 算法流程 -第 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) @@ -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$ 使用常数大小的额外空间。 @@ -66,6 +66,3 @@ comments: true - 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$ ; 在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。 - - - diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step1.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step1.png new file mode 100644 index 000000000..41f143c4e Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step1.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step2.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step2.png new file mode 100644 index 000000000..ebb3d43f8 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step2.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step3.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step3.png new file mode 100644 index 000000000..8eb4f872c Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step3.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step4.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step4.png new file mode 100644 index 000000000..b3e1fd129 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step4.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step5.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step5.png new file mode 100644 index 000000000..0fc46a4b9 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step5.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step6.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step6.png new file mode 100644 index 000000000..808db7e2b Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step6.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step7.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step7.png new file mode 100644 index 000000000..cf9653021 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step7.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step8.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step8.png new file mode 100644 index 000000000..418b17cb4 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step8.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/pivot_division_step9.png b/docs/chapter_sorting/quick_sort.assets/pivot_division_step9.png new file mode 100644 index 000000000..3e423596a Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/pivot_division_step9.png differ diff --git a/docs/chapter_sorting/quick_sort.assets/quick_sort.png b/docs/chapter_sorting/quick_sort.assets/quick_sort.png new file mode 100644 index 000000000..7b71ecd36 Binary files /dev/null and b/docs/chapter_sorting/quick_sort.assets/quick_sort.png differ diff --git a/docs/chapter_sorting/quick_sort.md b/docs/chapter_sorting/quick_sort.md index 3b1a107af..c3acb74f3 100644 --- a/docs/chapter_sorting/quick_sort.md +++ b/docs/chapter_sorting/quick_sort.md @@ -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; + } + } + } + ``` diff --git a/docs/chapter_tree/binary_search_tree.md b/docs/chapter_tree/binary_search_tree.md index 1d392d2ae..a59493b25 100644 --- a/docs/chapter_tree/binary_search_tree.md +++ b/docs/chapter_tree/binary_search_tree.md @@ -219,7 +219,7 @@ comments: true ## 二叉搜索树的退化 -二叉搜索树的理想状态是「完美二叉树」,我们称这样的二叉树是 “平衡” 的,此时可以在 $\log n$ 轮循环内查找任意结点。 +理想情况下,我们希望二叉搜索树的是 “左右平衡” 的(详见「平衡二叉树」章节),此时可以在 $\log n$ 轮循环内查找任意结点。 如果我们动态地在二叉搜索树中插入与删除结点,**则可能导致二叉树退化为链表**,此时各种操作的时间复杂度也退化之 $O(n)$ 。 diff --git a/docs/index.assets/demo.png b/docs/index.assets/demo.png new file mode 100644 index 000000000..af3e00593 Binary files /dev/null and b/docs/index.assets/demo.png differ diff --git a/docs/index.md b/docs/index.md index b156ed10e..d767a517d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,13 +8,12 @@ hide:
![conceptual_rendering](index.assets/conceptual_rendering.png){ align=left width=300 } -
-
-
-

《 Hello,算法 》

-

动画图解、能运行、可讨论的
数据结构与算法快速入门教程

-

[@Krahets](https://leetcode.cn/u/jyd/)

-

[![github-stars](https://img.shields.io/github/stars/krahets/hello-algo?style=social)](https://github.com/krahets/hello-algo)

+


+

+

《 Hello,算法 》

+

动画图解、能运行、可讨论的
数据结构与算法快速入门教程

+

[![github-stars](https://img.shields.io/github/stars/krahets/hello-algo?style=social)](https://github.com/krahets/hello-algo)

+


diff --git a/mkdocs.yml b/mkdocs.yml index e95456569..33f4115b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -158,7 +158,7 @@ nav: - 排序算法: - 冒泡排序: chapter_sorting/bubble_sort.md - 插入排序: chapter_sorting/insertion_sort.md - - 归并排序: chapter_sorting/merge_sort.md - 快速排序: chapter_sorting/quick_sort.md + - 归并排序: chapter_sorting/merge_sort.md - 参考文献: - chapter_reference/index.md