这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记
为什么要学习数据结构与算法
《数据结构与算法》对于编程人员来说可谓是一门至关重要的必修课,业界也有着“程序 = 数据结构 + 算法”的说法。然而,为什么数据结构与算法有着这样重要的地位呢?我们不妨从一个真实的业务场景出发来分析一下数据结构与算法的重要性。
假设我们有一个抖音直播排行榜的功能需要去实现,而这个排行榜功能遵循着这样一个规则:某个时间段内,直播间礼物数TOP10房间获得奖励,需要在每个房间展示排行榜。
针对这样一个业务需求,我们可以给出如下一个可行方案:
- 将礼物数量存储在Redis-zset中,使用skiplist使得元素整体有序
2.使用Redis集群,避免单机压力过大,使用主从算法、分片算法
3.保证集群原信息的稳定,使用一致性算法
4.后端使用缓存算法(LRU)降低Redis压力,展示房间排行榜
从上述解决方案中,我们可以发现:要想把数据合理地存储起来需要借助Redis-zset和skiplist这样的数据结构去操作;面对业务性能上的需求也需要我们提供主从算法、分片算法、一致性算法、缓存算法等一系列解决方案来解决。
由此可见,数据结构和算法几乎存在于程序开发中的所有地方。
经典排序算法
在众多的算法之中,有一类应用广泛且十分经典的算法,叫做“排序算法”。近年来,各式各样的排序算法的提出也是层出不穷。然而,再高效的算法也是一步一步由基础算法组合而成的。下面,我们就来看一下“排序算法”中的三个经典算法。
插入排序
插入排序是排序算法中最为经典的一个算法,它与选择排序和冒泡排序并称“三大基于交换的经典排序算法”。
插入排序的基本思想是“将元素不断地插入已经排好序的数组当中”,每个元素会从后向前依次比较元素大小并进行交换操作,直到插入到正确的位置。
插入排序在有序的情况下可以达到最优的时间复杂度O(N),而在一般情况下,插入排序的平均时间复杂度和最差时间复杂度均为O(N^2)。
插入排序的优点在于有序时可达到最优时间复杂度O(N)完成排序操作,缺点在于平均时间复杂度和最差时间复杂度均为O(N^2)。
快速排序
除了插入排序,另一个经典的排序算法就是快速排序。快速排序算法遵循着“分治”思想,每次选择一个轴点(pivot),将数组分成比pivot大和比pivot小两个部分,并固定出pivot的位置。依照此规则,不断地将数组进行分割,直到整个数组变成整体有序为止。
当每次选择的pivot都恰好是当前判断序列的中位数时算法达到最优时间复杂度O(NlogN);当每次选择的pivot都恰好是当前判断序列的最小值时算法达到最差时间复杂度O(N^2)。对于一般情况下来说,快速排序的平均时间复杂度为O(NlogN)。
快速排序的优点在于平均时间复杂度为O(N * logN),缺点在于最差的时间复杂度为O(N^2)。
堆排序
暂时无法在文档外展示此内容
此外,还有一个时间复杂度稳定性很好的排序算法,堆排序。堆排序是利用堆的性质所形成的一类排序算法,在排序的过程中会构造出一个大顶堆,每次将根节点与最后一个元素交换位置,然后调整整个堆,依次类推,便可以使整个数组有序。
无论是在何种情况下,堆排序的时间复杂度都稳定地保持为O(N * logN)。其优点在于最坏情况下依然保持O(N * logN)的时间复杂度,缺点在于最优时间复杂度也为O(N * logN)。
Benchmark
分别介绍完三种经典的排序算法,我们从理论层面上来简单地将它们对比一下:
- 插入排序的平均和最坏时间复杂度都是O(N * 2),性能较差
- 快速排序整体性能处于中间层次
- 堆排序性能稳定,“众生平等”
然而,算法只有应用在实际中才有意义,我们可以看一下在实际场景中三个算法有着哪些性能差异。
在对比时,我们需要创设一个场景。我们可以根据序列元素排序情况来做一些情况的划分:
- 元素完全随机
- 元素整体有序/逆序
- 元素重复度较高
在此基础上,我们还可以根据序列的长短再进行划分。
情景一:元素完全随机
| 短序列 | BenchmarkRandom / InsertionSort_16 | 7018838 | 170.7 ns/op |
|---|---|---|---|
| BenchmarkRandom / QuickSort_16 | 4478763 | 267.9 ns/op | |
| BenchmarkRandom / HeapSort_16 | 4673740 | 257.2 ns/op | |
| 中序列 | BenchmarkRandom / InsertionSort_128 | 231906 | 5188 ns/op |
| BenchmarkRandom / QuickSort_128 | 404396 | 2966 ns/op | |
| BenchmarkRandom / HeapSort_128 | 324348 | 3558 ns/op | |
| 长序列 | BenchmarkRandom / InsertionSort_1024 | 3999 | 285938 ns/op |
| BenchmarkRandom / QuickSort_1024 | 37371 | 32209 ns/op | |
| BenchmarkRandom / HeapSort_1024 | 29060 | 41069 ns/op |
从表中我们可以看出:
- 插入排序在短序列中速度最快
- 快速排序在其他情况中速度最快
- 堆排序速度与最快算法差距不大
情景二:元素整体有序
| 短序列 | BenchmarkRandom / InsertionSort_16 | 41242923 | 29.18 ns/op |
|---|---|---|---|
| BenchmarkRandom / QuickSort_16 | 7484462 | 176.3 ns/op | |
| BenchmarkRandom / HeapSort_16 | 9987447 | 120.3 ns/op | |
| 中序列 | BenchmarkRandom / InsertionSort_128 | 11491232 | 104.3 ns/op |
| BenchmarkRandom / QuickSort_128 | 282652 | 4099 ns/op | |
| BenchmarkRandom / HeapSort_128 | 976645 | 1085 ns/op | |
| 长序列 | BenchmarkRandom / InsertionSort_1024 | 1845013 | 648.8 ns/op |
| BenchmarkRandom / QuickSort_1024 | 6148 | 180353 ns/op | |
| BenchmarkRandom / HeapSort_1024 | 76885 | 15476 ns/op |
从表中我们可以看出:插入排序在序列整体有序条件下速度最快
从上面两个情况我们可以基本上得出如下结论:
- 所有短序列和元素有序的情况下插入排序的性能最好
- 在大部分情况下,快速排序有较好的综合性能
- 几乎在任何情况下,堆排序的表现都比较稳定
从零开始打造pdqsort
基于上述结论,我们有没有办法综合这三种经典排序算法的优点,提出一个更优的排序算法呢?下面,我们就来介绍一下综合三种经典算法而提出的pdqsort排序算法。
pdqdort简介
pdqsort是pattern defeating quicksort的缩写,它是一种不稳定的混合排序算法,它的不同版本被应用在了C++、BOOST、Rust以及Go1.19中。它对常见的序列类型做了特殊的优化,使得在不同条件下都拥有不错的性能。
Version 1
基于最简单的想法:结合上述三种经典算法的优点来构造新算法。
- 对于短序列使用插入排序
- 其他情况,使用快速排序(选择首个元素作为pivot)来保证整体性能
- 当快速排序表现不佳时,使用堆排序来保证最坏情况下时间复杂度仍为O(N * logN)。
算法思想很简单,但是有两个问题需要做出明确:
Q1:短序列具体长度设置为多少合适?
A1:12~32之间均可,在不同语言和场景中有所不同,在泛型版本根据测试选定24
Q2:如何得知快速排序表现不佳,何时切换到堆排序?
A2:当最终pivot的位置距离序列两端很接近时(距离小于length / 8),判定其表现不佳,当这种情况次数达到limit(bits.Len(length))时,切换到堆排序。
于是,我们可以获得pdqsort算法的第一个版本。
暂时无法在文档外展示此内容
Version 2
对于第一个版本来说,排序性能已经有了很大的提升,但其实它还可以做的更好。比如:每次都选首元素作为pivot这显然是可以进行改进的,另外,partition也可以进行改进来提升性能。
在前面三个经典排序算法的讨论中我们已经知道,当快速排序的pivot选取到中位数时可以使得排序效率达到最优,而选取序列的中位数有需要有额外的开销。如何做到提升效率和降低选取pivot开销的平衡就成为了新的问题。于是,寻找近似中位数的思想就被提出了。
近似中位数的提取综合考虑了序列的长度和序列的有序性,其具体方法是:
- 在短序列中,选择固定的元素作为pivot;
- 在中序列中,采样3个元素取中位数作为pivot;
- 在长序列中,采样9个元素取中位数作为pivot。
在采样元素中进行进一步判断:
- 采样元素都是逆序排序 --》 序列可能已经逆序 --》 翻转整个序列
- 采样元素都是正序排序 --》 序列可能已经有序 --》 使用插入排序(有限制次数的插入排序)
于是,我们可以获得pdqsort算法的第二个版本。
暂时无法在文档外展示此内容
Final version
到此为止,我们已经考虑到了关于排序的多种情况:
- 短序列的排序 --》 使用插入排序(version 1)
- 极端情况 --》 使用堆排序保证算法可行性(version 1)
- 完全随机的情况 --》 选择更好的pivot(verion 2)
- 有序/逆序的情况 --》 根据序列状态翻转或插入排序(version 2)
最后,还有一个元素重复度较高的情况(mod 8)没有解决。下面我们咋最终版本中把这个问题处理掉。
由于采样的数量有限,无法保证采样数据中包含相同元素,因此在采样过程中检测重复度是不合适的。一个可行的解决思路是:在排序的过程中,如果有两次partition生成的pivot相同时,就认为当前partition进行了无效分割,此时的pivot的值为重复元素。
对于这种重复元素较多的情况,可以采用partitionEqual将重复元素排列到一起,从而减少重复元素对于pivot选择的干扰。
而当pivot选择策略表现不佳时,则要考虑随机交换元素,从而避免一些极端情况导致的QuickSort总是表现不佳,以及一些黑客攻击的情况。
这样,我们就得到了最终的pdqsort排序算法。
暂时无法在文档外展示此内容
该算法的最优时间复杂度为O(N),平均时间复杂度和最差时间复杂度都为O(N * logN)。经过云服务器上的性能测试,发现pdqsort算法相较于经典算法来说:有序或逆序情况下性能提高了10倍,而一般情况下也有10~50%的性能提升。
课程总结
通过本节课程的学习,我们可以得到下面三个问题的解答:
Q1:高性能的排序算法是如何设计的?
A1:根据不同情况选择不同策略,取长补短。
Q2:生产环境中使用的排序算法和课本上的排序算法有什么区别?
A2:理论算法注重理论性能,例如时间、空间复杂度等。生产环境中的算法需要面对不同的实践场景,更加注重实践性能。
Q3:Go语言(<=1.18)的排序算法是快速排序吗?
A3:实际一直都是混合排序算法,主体是快速排序。Go<=1.18时的算法也是基于快速排序,和pdqsort的区别在于fallback时机、pivot选择策略以及是否有针对不同pattern进行优化等。