这是我参与「第三届青训营 -后端场」笔记创作活动的第3篇笔记.
01 为什么要学习数据结构与算法
好的数据结构与算法能够提升程序的运行速度。
数据结构与算法几乎存在于程序开发中的所有地方!
本文主要介绍的是排序算法。
02 经典排序算法
堆排序其实是一种选择排序,每次选择出剩下元素中最大的放到最后面,只不过借助大根堆,选择最大元素的时间复杂度可以降为O(logn)
理论时间复杂度
| Best | Avg | Worst | |
|---|---|---|---|
| 插入排序 | O(n) | O(n^2) | O(n^2) |
| 快速排序 | O(nlogn) | O(nlogn) | O(n^2) |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) |
实际场景benchmark
测试的场景有
- 完全随机的情况(random)
- 有序/逆序的情况(sorted/reverse)
- 元素重复度较高的情况(模(mod)8处理)
在上面的基础上,还根据序列的长度进行了划分(16/128/1024)
Benchmark-random
结论:在随机的情况下:
- 插入排序在短序列中速度最快
- 快速排序在其他情况下速度最快
- 堆排序速度与最快算法差距不大
为什么快速排序和堆排序的期望时间复杂度都是O(nlogn),而快速排序要稍微快一点呢?
- 因为快速排序在partion的时候访问的都是按顺序访问元素,可以利用局部性原理,命中cache的几率更大
- 而堆排序在访问儿子节点(
i*2,i*2+1)的时候是有跨度的
Benchmark-sorted
- 插入排序遇到了最好的情况:O(n),速度最快
- 快速排序遇到了最坏的情况:O(n^2),速度最慢
实际场景 benchmark 结论
- 所有短序列和元素有序的情况下,插入排序性能最好
- 在除了有序的情况的场景下,快速排序的性能最好
- 几乎在任何情况下,堆排序的表现都比较稳定,与最快的相差不大
设计一个更好的算法?
能否设计一个算法,使得这个算法能同时拥有插入排序,快速排序,堆排序的优点?
| Best | Avg | Worst | |
|---|---|---|---|
| 插入排序 | O(n) | O(n^2) | O(n^2) |
| 快速排序 | O(nlogn) | O(nlogn) | O(n^2) |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) |
| ??? | O(n) | O(nlogn) | O(nlogn) |
这个算法就是下面要介绍的pdqsort算法
03 从零开始打造pdqsort
pdqsort(pattern-defeating-quicksort) 是一种不稳定的混合排序算法,它的不同版本被应用在C++ BOOST、Rust以及Go 1.19中。它对常见的序列类型做了特殊优化,使得在不同条件下都拥有不错的性能。
pdqsort-version1
思想是结合三种排序算法的优点
- 对于短序列(小于一定长度)我们使用插入排序
- 其他情况,是哦那个快速排序来保证整体性能
- 当快速排序表现不佳时,使用堆排序来保证最坏情况下时间复杂度仍为O(nlogn)
问题
- 短序列的长度具体是多少?
- 12~32,在不同语言和场景中会有不同,在泛型版本根据测试选定24
- 怎样知道快速排序表现不佳,以及何时切换到堆排序?
- 当最终pivot的位置离序列两端很接近时(距离小于length/8)判定其表现不佳
- 当这种情况的次数达到limit(bits.Len(length))时,切换到堆排序
具体的流程
还能再改进吗?
- 快速排序中pivot的选择会影响速度,改进:改进choose pivot,尽量使pivot为序列的中位数
- 改进partition(此优化在Go表现不好,略)
pdqsort-version2
回顾pivot的选择策略:使用首个元素作为pivot:O(1)复杂度,但在某些情况下效果不好(当pivot经常离中位数比较远的时候)
为了保证快排的平均时间复杂度为O(nlogn),则选择pivot的时间复杂度就不能超过O(n)。
所以我们优化的目标是:
- 选择pivot的时间复杂度尽可能的低,最多不能超过O(n)
- pivot尽可能地接近中位数
即 “寻找pivot所需要的开销”和“pivot带来的性能优化”之间的平衡
如果要精确地找到序列中的中位数,则时间复杂度至少是O(nlogn)的(计数排序虽然可以做到O(n)但是使用场景有限,所以不考虑),这样的时间复杂度显然是不行的。
所以我们只能近似地寻找中位数:通过采样去估计中位数(概率论里面的知识)。很显然,采样的个数占总个数的比例越高,估计中位数就可能越接近真实中位数,但用时就会越高。所以会依据序列长度不同,来决定选择策略:
- 短序列(<=8),选择固定元素
- 中序列(<=50),采样3个元素 median of three
- 长序列(>50),采样9个元素 median of medians
通过采样我们可以预测序列的状态:
- 采样的元素都是逆序排列 -> 序列可能已经逆序 -> 反转整个序列
- 采样的元素是都顺序排列 -> 序列可能已经有序 -> 使用插入排序
注:插入排序实际使用partialInsertionSort,即有限制次数的插入排序(因为序列只是有概率有序,如果不是的话,插入排序则会耗费大量的时间)
具体的流程
version1升级到version2优化总结:
- 通过采样估计序列的信息:
- 序列的中位数 -> 改进了pivot的选择策略
- 发现序列可能逆序,则反转序列 -> 应对了reverse场景
- 发现序列可能有序,使用有限插入排序 -> 应对了sorted场景
至此,我们已经完成了大部分场景的优化,回顾开头提到的测试场景,我们还没有对元素重复度比较高的序列进行优化。
pdqsort-final version
- 元素重复度较高可能会带来什么问题?
- 元素重复度较高,可能会导致两次partition生成的pivot相同,即进行了无效的分割。
- 采样的时候检查重复度能解决这个问题吗?
- 不是很好,因为采样数量有限,不一定能采样到相同元素
- 解决方案(partitionEqual)
- 当检测到此时的pivot和上次相同时(发生在leftSubArray),使用partitionEqual将重复元素排列在一起,减少重复元素对于pivot选择的干扰
其他的优化:当pivot选择策略表现不佳时,随机交换元素,避免一些极端情况使得快速排序总是表现不佳,以及一些黑客攻击情况。
具体流程:
至此,我们终于可以完成开头的表格了:
| Best | Avg | Worst | |
|---|---|---|---|
| 插入排序 | O(n) | O(n^2) | O(n^2) |
| 快速排序 | O(nlogn) | O(nlogn) | O(n^2) |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) |
| pqdsort | O(n) | O(nlogn) | O(nlogn) |
一台云服务器上的测试
- 在有序或者逆序情况下提升10x
- 其他情况下有10~50%提升
其他
- 高性能的排序算法是如何设计的?
- 根据不同情况选择不同功能策略,取长补短
- 生产环境中使用的排序算法和课本上的排序算法有什么区别?
- 理论算法注重理论性能,例如时间、空间复杂度等。生产环境中的算法需要面对不同的事件场景,更加注重实践性能
- Go语言(<=1.18)的排序算法是快速排序么?
- 实际一直是混合排序算法,主体是快速排序。Go<=1.18时的排序发也是基于快速排序,和pdqsort的区别在于fallback时机、pivot选择策略、是否有针对不同pattern优化等。
参考资料
- 课程PPT:数据结构与算法.pptx
- Proposal: github.com/golang/go/i…
- Paper: arxiv.org/pdf/2106.05…
- Code: github.com/golang/go/b…
- 公众号文章:mp.weixin.qq.com/s/5HqfRGqPy…