这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记
数据结构与算法
课程目录如下:
01.为什么要学习数据结构与算法 --- 了解课本上学习到的算法和真正在生产实践中使用的算法之间的差异。
02.经典排序算法 --- 复现在课本上学习到的经典排序算法,讲解它们的原理以及特点
03.从零开始打造pdqsort --- 目前业界性能一流的排序算法 pdqsort (Pattern-Defeating-QuickSort)
一、为什么要学习数据结构和算法
1.数据结构和算法几乎存在于程序开发的所有地方
2.什么是最快的排序算法?
- Python-timesort
- C++-introsort
- Rust-pdqsort
3.生产环境中使用的排序算法和课本上的排序算法有什么区别?
理论算法注重理论性能,例如时间、空间复杂度等。而生产环境中的算法需要面对不同的实践场景,更加注重实践性能。
4.Go语言的排序算法是快速排序吗?
不是,其实一直都是混合排序算法,主体是快速排序。Go<=1.18时的算法也是基于快速排序,和pdqsort的区别在于fallback时机、pivot选择策略、是否有针对不同pattern优化等。
5.高性能的排序算法是如何设计的?
根据不同情况选择不同的策略,取长补短
二、经典排序算法 时间复杂度如下:
| 算法 | 最好时间复杂度 | 最差时间复杂度 | 平均时间复杂度 |
|---|---|---|---|
| 插入排序 | O(n) | O(n^2) | O(n^2) |
| 快速排序 | O(nlog(n)) | O(nlog(n)) | O(n^2) |
| 堆排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) |
| pdqsort | O(n) | O(nlog(n)) | O(nlog(n)) |
1.插入排序
基本思想:将整个数组a分为有序和无序的两个部分。前者在左边,后者在右边。开始有序的部分只有a0, 其余都属于无序的部分。每次取出无序部分的第一个(最左边)元素,把它加入有序部分。假设插入合适的位置p,则原p位置及其后面的有序部分元素都向右移动一个位置,有序的部分即增加了一个元素。一直做下去,直到无序的部分没有元素。
2.快速排序
基本思想:快速排序使用分治的思想,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字均比另一部分记录的关键字小。之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
快速排序的三个步骤:
(1)选择基准:在待排序列中,按照某种方式挑出一个元素,作为 "基准"(pivot)
(2)分割操作:以该基准在序列中的实际位置,把序列分成两个子序列。此时,在基准左边的元素都比该基准小,在基准右边的元素都比基准大
(3)递归地对两个序列进行快速排序,直到序列为空或者只有一个元素。
3.堆排序
基本思想:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了
三、从零开始打造pdqsort
1.简介
pdqsort是一种不稳定的混合排序算法,其不同版本被应用在C++、Boost、Rust以及Go1.19中,它对常见的序列类型做了特殊的优化,使得在不同的条件下都拥有不错的性能。
2.pdqsort结合三种排序算法的优点
①对于短序列(小于一定的长度)---使用插入排序
②其他情况 ---使用快速排序来保证整体性能
③当快速排序表现不佳时 ---使用堆排序来保证最坏情况下时间复杂度为O(nlog(n))
注意以下Questions:
①短序列的具体长度是多少?
12~32,在不同语言和场景中有所不同,在泛型版本根据测试选定24
②如何得知快速排序表现不佳,以及何时切换到堆排序?
当最终pivot的位置序列两端很接近时(距离小于length/8)判定其表现不佳
当这种情况的次数达到limit(即bits.Len(length))时,切换到堆排序
3.pdqsort - version1
- 对于短序列(<= 24)使用插入排序
- 其他情况,使用快速排序(选择首个元素作为pivot)来保证整体性能
- 当快速排序表现不佳时(limit == 0),使用堆排序来保证最坏情况下的时间复杂度仍然为O(nlog(n))
注:如何让pdqsort速度更快?
- 尽量使得QuickSort的pivot为序列中的中位数 --> 改进choose pivot
- Partition速度更快 --> 改进partition,但是此优化在Go中的表现并不好
4.pdqsort - version2 思考关于pivot的选择
- 使用首个元素作为pivot(最简单的方案) 这种方法实现简单,但是往往效果不好,例如在sorted情况下性能很差
- 遍历数组,寻找真正的中位数 遍历对比代价很高,性能不好
解决办法:需要平衡寻找pivot所需要的开销 和pivot带来的性能优化 所以解决办法是:寻找近似中位数
pdqsort - version2的做法如下:
优化pivot的选择:
- 短序列(<= 8 ),选择固定元素
- 中序列(<= 50),采样3个元素
- 长序列(> 50),采样9个元素
version1升级到version2的总结如下:
- 升级pivot选择策略(近似中位数)
- 发现序列可能逆序,则翻转序列 -> 应对reverse场景
- 发现序列可能有序,使用优有限插入排序 -> 应对sorted的场景
5.思考还有什么场景没有进行优化?
- 短序列情况 使用插入排序(v1)
- 极端情况 使用堆排序保证算法的可行性(v1)
- 完全随机的情况(random) 有更好的选择策略(v2)
- 有序/逆序的情况(sorted/reverse) 根据序列状态翻转或者插入排序(v2)
- 元素复杂度较高的情况(mod8)?
-如何优化重复元素很多的情况?
方法一:采用pivot的时候检测重复度?这个方法不是很好,因为采样数量优先,不一定能采样到相同元素
解决方法:如果两次partition生成的pivot相同,及partition进行了无效分割,此时认为pivot的值为重复元素(相比于方法一有更高的采样率)。其实也就是当检测到此时的pivot和上次相同时(发生在leftSubArray),缉拿少重复元素对于pivot选择的干扰。
pdqsort - final version(Go1.19 default)