「Go语言引入的新型排序算法pdqsort」笔记 | 青训营笔记
这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记
lnsertion Sort 插入排序
插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
时间复杂度:
- 最优:O(n)
- 平均:O(n^2)
- 最差:O(n^2)
代码实现:
Go
func insertionSort(arr []int) []int {
for i := range arr {
preIndex := i - 1
current := arr[i]
for preIndex >= 0 && arr[preIndex] > current {
arr[preIndex+1] = arr[preIndex]
preIndex -= 1
}
arr[preIndex+1] = current
}
return arr
}
Java
public class InsertSort implements IArraySort {
@Override
public int[] sort(int[] sourceArray) throws Exception {
// 对 arr 进行拷贝,不改变参数内容
int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
// 从下标为1的元素开始选择合适的位置插入,下标为0看作第一个元素,默认是有序的
for(int i = 1; i < arr.length; i++){
// 记录要插入的数据
int tmp = arr[i];
int j = i;
// 从已排序序列最右边的开始比较,找到比其小的数
while(j > 0 && tmp < arr[j - 1]){
arr[j] = arr[j - 1];
j--;
}
// 存在比其小的数,插入
if(j != i){
arr[j] = tmp;
}
}
return arr;
}
}
Quick Sort 快速排序
- 从数列中挑出一个元素,称为 "基准"(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
时间复杂度:
- 最优:O(nlogn)
- 平均:O(nlogn)
- 最差:O(n^2)
Heap Sort 堆排序
堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
- 创建一个堆 H[0……n-1];
- 把堆首(最大值)和堆尾互换;
- 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
- 重复步骤 2,直到堆的尺寸为 1。
时间复杂度:
- 最优:O(nlogn)
- 平均:O(nlogn)
- 最差:O(nlogn)
实际场景Benchmark
根据序列元素排列情况划分
- 完全随机的情况(random)
- 有序/逆序的情况(sorted/reverse)
- 元素重复度较高的情况(mod8)
在此基础上,还需要根据序列长度的划分(16/128/1024)
Benchmark-random
- 插入排序在短序列中速度最快
- 快速排序在其他情况中速度最快
- 堆排序速度于最快算法差距不大
Benchmark-sorted
- 插入排序在序列已经有序的情况下最快
结论:
- 所有短序列和元素有序情况下,插入排序性能最好
- 在大部分的情况下,快速排序有较好的综合性能
- 几乎在任何情况下,堆排序的表现都比较稳定
pdqsort算法
简介
pdqsort (pattern-defeating-quicksort)是一种不稳定的混合排序算法,它的不同版本被应用在C++ BOOST、Rust 以及Go 1.19中。它对常见的序列类型做了特殊的优化,使得在不同条件下都拥有不错的性能。
pdqsort - version1
- 对于短序列(<=24)我们使用插入排序
- 其他情况,使用快速排序(选择首个元素作为pivot))来保证整体性能
- 当快速排序表现不佳时(limit==0) ,使用堆排序来保证最坏情况下时间复杂度仍然为O(nlogn)
当最终 pivot的位置离序列两端很接近时(距离小于length/8)判定其表现不佳,当这种情况的次数达到 limit (即 bits.Len(length))时,切换到堆排序
如何让pdqsort速度更快?
- 尽量使得QuickSort的pivot为序列的中位数->改进choose pivot
- Partition速度更快->改进partition ,但是此优化在Go表现并不好,不做过多描述。
pdqsort - version2
根据序列长度的不同,来决定选择策略
优化Pivot的选择
- 短序列(<=8),选择固定元素
- 中序列(<=50),采样三个元素,median of three
- 长序列(>50).采样九个元素,median of medians
Pivot的采样方式使得我们有探知序列当前状态的能力
采样的元素都是逆序排列 -> 序列可能已经逆序 -> 翻转整个序列
采样的元素都是顺序排列 -> 序列可能已经有序 -> 使用插入排序
插入排序实际使用partiallnsertionSort,即有限制次数的插入排序
Version1升级到version2优化总结
- 升级 pivot选择策略(近似中位数)
- 发现序列可能逆序,则翻转序列->应对reverse场景
- 发现序列可能有序,使用有限插入排序->应对sorted场景
pdqsort - final version(Go1.19 default)
如何优化重复元素很多的情况?
解决方案︰如果两次partition生成的pivot相同,即partition进行了无效分割,此时认为pivot的值为重复元素。
优化 - 重复元素较多的情况(partitionEqual)
当检测到此时的 pivot和上次相同时(发生在leftSubArray),使用partitionEqual将重复元素排列在一起,减少重复元素对于pivot选择的干扰。
优化-当pivot选择策略表现不佳时,随机交换元素
避免一些极端情况使得QuickSort总是表现不佳,以及一些黑客攻击情况。
时间复杂度: