Go 排序算法笔记 | 青训营笔记

81 阅读10分钟

这是我的第四篇青训营笔记,Go 排序算法这节课讲的是 Go 1.19 中的 pdqSort,实际上是一种混合排序算法,在特定的场景下切换到更优的算法策略(插入排序,快速排序,堆排序)来保证性能。

1. 为什么要学数据结构和算法

例 抖音直播排行榜功能

某个时间段内 礼物数 TOP10 获奖 需要在每个房间展示排行榜

解决方案:

  • 礼物数量存储在 Redis-zset 中,使用 skiplist 使得元素整体有序
  • 使用redis集群,避免单机压力过大(单台 Redis 会有安全性问题,比如宕机),使用 主从算法,分片算法
  • 保证集群辕信息的稳定(redis集群中的每台机器都可能单独宕机),使用一致性算法(比如Raft)
  • 后端使用缓存算法(LRU)降低 Redis压力,展示房间排行榜。(每个房间的排行榜不是实时展示,而是隔1,2秒再去刷新一遍排行榜,如果每次都要从redis拿数据,对redis压力太大)

数据结构和算法几乎存在于程序开发中的所有地方

什么是最快的排序算法? 要看具体情况,

  • 数量多:并发排序算法
  • 特殊场景:特殊场景的算法
  • 每个语言自带的算法: python - timsort C++ introsort rust pdqsort

Go 的排序算法有没有提升空间

Go在1.18版本之前是 introsort 的算法的改版,它也是一种混合排序,也就是快排。

去年10月字节向官方提了一个proposal,实现了一个新的算法的变体,认为这个算法比 Go 原来的算法在一些常见的场景上最多能提升10倍

之后字节重新实现了 Go 的排序算法,在某些场景场景中比之前算法块大约10倍,成为 Go 1.19 的默认排序算法。

在 sorted 和 reverse 场景比之前的算法快 10 倍左右,在其它不同的场景也有一定提升。

几个问题:

  • Go 1.19 的排序算法是如何设计的?
  • 如何设计出一个比之前标准库算法更好的排序算法?
  • 生产环境使用的排序算法和课本上的排序算法有什么区别
  • Go 语言的排序算法是快速排序吗?

02 经典排序算法

插入排序(类似洗牌)

将元素不断插入已经排序好的array中 eg: 5,2,4,6,1,3

  • 起始只有一个元素5, 本身是一个有序序列(即是逆序也是正序)
  • 后续元素插入有序序列中,即不断交换,直到找到第一个比起小的元素

2,5 交换,现在 [2,5],4,6,1,3 其中 2 和 5 是有序序列 对于4,4找到第一个比它小的元素是52,所以4和5交换以后就不动了,像这样不断交换,最后变得有序

image.png

时间复杂度:

最好平均最坏
O(n)O(n^2)O(n^2)

最好情况的时间复杂度是 O(n) (有序情况下) 平均时间复杂度是 O(n^2) 由翻转对数来决定,具体解释较为复杂,但你可以认为每一次都会对前面的元素也做一次调换,放下去的元素并不是最终的位置 最坏情况也是 O(n^2) (逆序情况下)

缺点:

  • 平均和最坏情况的时间复杂度高达O(n^2)

优点

  • 最好情况时间复杂度为 O(n)(这已经是比较排序的最好的情况)

快速排序

快速排序基于分治思想,不断分割序列直到序列整体有序

  • 选定一个pivot(轴点)
  • 使用 pivot 分割序列,分成元素比pivot打和元素比pivot小两个序列(这个过程叫partition)

快速排序是面向性能的一个算法

时间复杂度:

最好平均最坏
O(n*logn)O(n*logn)O(n^2)

最好情况下单时间复杂度,是每一次选择的轴点恰好是中位数,这样每次分割都能分成两个几乎相等大小的元素。 最好情况下的时间复杂度也是评价情况下的时间复杂度。 最坏情况的时间复杂度是 O(n^2),就是每一次只将一个元素放到最终的位置,比如 12345657 每一次都选中第一个首节点为 pivot,其实只将一个元素分成有序的一部分,尽管它是最终有序,其实这时候只解决了问题的一个最小规模,只把一个元素变成了有序,其它元素还是老样子,这就是它的最坏情况。

缺点

  • 最坏情况的时间复杂度高达O(n^2)

堆排序

使用堆的性质形成的排序算法

  • 构造一个大顶堆(最上面的元素,也就是根节点元素最大)
  • 将根节点(最大元素)交换到最后一个位置,调整整个堆,如此反复

时间复杂度:

最好平均最坏
O(n*logn)O(n*logn)O(n*logn)

缺点:

  • 最好情况的时间复杂度高达O(n*logn)

image.png

  • 插入排序平均和最坏情况时间复杂度都是 O(n^2) 性能不好
  • 快速排序整体性能处于中间层次
  • 堆排序性能稳定,“众生平等”

实际场景 Benchmark

根据序列元素排列情况划分

  • 完全随机的情况(random)
  • 有序/逆序的情况(sorted/reverse)
  • 元素重复度较高的情况(mod8)(比如考试时成绩一样)

在此基础上,还需要根据序列长度的划分(16/128/1024)

随机情况: image.png

  • 插入排序在短序列中速度最快
  • 快速排序在其它情况中速度最快
  • 堆排序速度于最快算法差距不大

有序情况

image.png

  • 插入排序在序列已经有序的情况下速度最快

结论:

  • 所有短序列和元素有序的情况下,插入排序性能最好
  • 大部分情况下,快速排序有比较好的综合性能
  • 几乎在任何的情况下,堆排序的表现都比较稳定

插入排序像单车,适合短途出现 快速排序像汽车,会堵车 堆排序像地铁,比较稳定

设计一个更好的算法?

能否使用上面三种经典的排序算法设计出一种更强的排序算法,能结合三者的优点?

image.png

从零打造 pdqsort

pdqsort(pattern-deeating-quicksort) 是一种不稳定的混合排序算法,它的不同版本倍应用在 C++ BOOST、Rust 以及 Go 1.19当中,他对场景的序列类型做了特殊优化,使得在不同条件下都拥有不错的性能

什么样的业务场景会考虑排序的稳定性? pdqsort 是一种不稳定的算法,它对同样的元素,可能在排序的过程中交换它的位置。比如,两名同学都考了96,并列第二名,本来是2和3的排名,有可能调用了一次排序算法,你第二我第三变成了你第三我第二,这就是不稳定的排序算法。它可能会对值相同的元素调整位置。

pdqsort - version1

结合三种排序方法的优点

  • 对于短序列(小于一定长度)我们使用插入排序
  • 其他情况,使用快速排序来保证整体性能(比如随机情况下的中序列和长序列)
  • 当快速排序表现不佳时(特殊序列),使用堆排序来保证最欢情况下时间复杂度仍为O(n*logn)

image.png Q&A

  1. 短序列的具体长度是多少? 12-32,在不同语言和场景会有不同,泛型版本测试选定24

  2. 如何得知快速排序表现不佳,以及合适切换到堆排序? 当最终pivot的位置距离序列两端很接近时(距离小于length/8)判定其表现不佳,当这种情况的次数达到limit 即 (bits.Len(length))时,切换到堆排序

  • 对于短序列(<=24) 使用插入排序
  • 其它情况,使用快速排序(选择首个元素作为pivot)来保证整体性能
  • 当快速排序表现不佳时(limit==0, limit 是一个根据初始序列长度计算的值),使用堆排序来保证最坏情况的时间复杂度仍为O(n*logn)

如何让 pqsort 速度更快?

  • 尽量使得 QuickSort 的pivot 为序列的中位数 -> 改进 choose pivot
  • Partition 速度更快 -> 改进 partition,但是此优化在 Go 表现不好,略

pdqsort - version1

思考关于poivot的选择

  • 使用首个元素作为pivot(最简单的方案) 实现简单,但是往往效果不好,例如在 sorted 情况下性能很差
  • 遍历数组,寻找真正的中位数 遍历比对代价很高 性能不好

需要在 寻找pivot所需要的开销 和 pivot带来的性能优化中间找一个平衡

所以采取寻找近似中位数的方法

根据序列长度的不同,来决定选择策略

优化-Pivot的选择

随机寻找一个 pivot 是否可行?

实践中不是最优的,因为生成一个伪随机数,需要一定的时间。 随机的pivot并不能代表实际情况,实践中效果不好

  • 短序列(<=8),选择固定元素
  • 中序列(<=50),采样三个元素(元素位置根据序列元素决定,大概是前中后),median of three
  • 长序列(>50),采样 9 个元素,median of medians
排序算法是如何探知序列状态的?

Pivot 的采样方式,使得我们有探知序列当前状态的能力

比如采样前中后三个元素都是逆序的,可以猜测序列可能已经逆序,翻转整个序列。

这种方法的优点是,假设序列真的逆序,瞬间就完成了排序,缺点是,如果3个只是巧合,那就效果不好。这其实是rust pqsort的实现者提出来的。这里也采用的同样的方式,因为发现对于不同的采样区间,这个方式依然会带来收益

假设采样的元素都是顺序排列,序列可能已经有序,使用插入排序。

插入排序实际使用 patiallnsertionSort,即有限制次数的插入排序(插入排序在非有序情况下性能表现非常差,所以要限制最坏情况)

Version 1 升级到 Version 2 优化总结

  • 升级 pivot 选择策略(近似中位数)
  • 发现序列可能逆序,则翻转序列,应对 reverse 场景
  • 发现序列可能有序,使用优先插入排序,应对 sorted 场景
还有什么场景我们没有优化?
  • 短序列情况
    • 使用插入排序(v1)
  • 极端情况
    • 使用堆排序保证算法的可行性(v1)
  • 完全随机的情况(random)
    • 更好的 pivot 选择策略(v2)(通过采样得到近似中位数)
  • 有序/逆序的情况(sorted/reverse)
    • 根据序列状态翻转或插入排序(v2)

pdqsort - final version

如何优化重复元素很多的情况?
  • 采样 pivot 的时候检测重复度? 不是很好,因为采样数了有限,不一定能采样到相同元素

解决方案: 如果两次 partition 生产的 pivot 相同,即 partition 进行了无效分割,此时认为 pivot 的值为重复元素 (相比像一种方法有更高的采样率)

优化-重复元素较多的情况(partitionEqual) 当检测到此时的pivot和上次相同时(发生在leftSubArray),使用patitionEqual 将重复元素排列在一起,减少重复元素对于 pivot 选择的干扰

优化-当pivot选择策略表现不佳时,随即交换元素 避免一些极端情况使得 QuickSort 总是表现不佳,以及一攻击情况。

image.png

标黄的地方可能发生也可能不发生

image.png

性能测试(由国外友人自行测试,非字节测试) image.png

高性能的排序算法是如何设计的?

根据不同情况选择不同策略,取长补短

生产环境中使用的排序算法和课本上的排序算法有什么区别?

理论算法更注重理论性能,例如时间、空间复杂度等。生产环境中的算法需要面对不同的实践场景,更加注重实践性能

Go 语言(<=1.18)的排序算法是快速排序吗?

实际一直是混合排序算法,主体是快速排序。Go<=1.18 时的算法也是基于快速排序,和 pdfqsort 的区别在于 fallback 时机,pivot 选择策略、是否有针对不同的 pattern 优化等。