Golang语言系列-标准库中的pdq排序
本文将从源代码的角度来分析golang标准库中如何实现的排序,一般来说,为了性能的考虑,标准库中的排序往往混合使用多种排序算法,一般包括插入排序和快速排序。
源码分析
sort.Slice
golang标准库中排序相关的函数在sort包中,比较常用泛用的sort.Slice在文件slice.go中,代码如下:
func Slice(x any, less func(i, j int) bool) {
rv := reflectlite.ValueOf(x)
swap := reflectlite.Swapper(x)
length := rv.Len()
limit := bits.Len(uint(length))
pdqsort_func(lessSwap{
less, swap}, 0, length, limit)
}
这里使用的pdqsort是一种混合形式的排序算法,全称为 pattern-defeating-quicksort,混合使用快速排序、插入排序和堆排序,刚开始使用三点中值法形式的快速排序,在小的子序列情况下使用性能更加稳定的插入排序,如果长期分区不平衡会切换为堆排序完成整个的排序过程。以下是pdqsort的整体排序过程,注释代码如下:
// a, b 分别为起点和终点,limit 是一个阈值,用来控制是否转而使用堆排序
func pdqsort_func(data lessSwap, a, b, limit int) {
const maxInsertion = 12
var (
wasBalanced = true // whether the last partitioning was reasonably balanced
wasPartitioned = true // whether the slice was already partitioned // 用来表示上次分区是否在正常分区前已经处于完成分区状态
)
for {
length := b - a
if length <= maxInsertion {
// 长度小于等于12的子序列使用插入排序
insertionSort_func(data, a, b)
return
}
// Fall back to heapsort if too many bad choices were made.
if limit == 0 {
// 分区多次不平衡,转而使用堆排序
heapSort_func(data, a, b)
return
}
// If the last partitioning was imbalanced, we need to breaking patterns.
if !wasBalanced {
// 上次分区不平衡,需要随机扰乱数组,尝试保证本次分区尽可能平衡
breakPatterns_func(data, a, b)
limit--
}
pivot, hint := choosePivot_func(data, a, b) // 选择一个基准点
if hint == decreasingHint {
// 此时数组可能处于一种降序性质较为明显,通过翻转调整为一种升序状态
reverseRange_func(data, a, b) // 直接翻转数组
// The chosen pivot was pivot-a elements after the start of the array.
// After reversing it is pivot-a elements before the end of the array.
// The idea came from Rust's implementation.
pivot = (b - 1) - (pivot - a) // 因为数组翻转了,基准点也要调整
hint = increasingHint
}
// The slice is likely already sorted.
// 上次分区比较平衡,且数组可能处于一种升序状态,此时考虑直接插入排序
if wasBalanced && wasPartitioned && hint == increasingHint {
if partialInsertionSort_func(data, a, b) {
return
}
}
// Probably the slice contains many duplicate elements, partition the slice into
// elements equal to and elements greater than the pivot.
// 如果基准点的位置要小于等于前面一个分组的数据,而我们知道,这不太可能,因为前面分组的数字一般会大于后面分组的数字,很大可能就是数据量比较重复
if a > 0 && !data.Less(a-1, pivot) {
// 这个函数的作用就是完成了一个分组,在[a, newpivot-1] 是小于等于基准点的数据,[newpivot+1 b]是大于基准点的数据, mid=newpivot
mid := partitionEqual_func(data, a, b, pivot)
// 只处理右边部分,这里直接继续循环
a = mid
continue
}
// 数据点相对分散,重复不多,则进行正常分区
mid, alreadyPartitioned := partition_func(data, a, b, pivot)
wasPartitioned = alreadyPartitioned
leftLen, rightLen := mid-a, b-mid
balanceThreshold := length / 8
// 是否平衡只需要短的分区长度至少是总长度的八分之一
// 而且对于短分区,递归地去调用pdqsort,长分区继续在当前的函数循环中处理
if leftLen < rightLen {
// 左分区短一些
wasBalanced = leftLen >= balanceThreshold
pdqsort_func(data, a, mid, limit)
a = mid + 1
} else {
// 右分区短一些
wasBalanced = rightLen >= balanceThreshold
pdqsort_func(data, mid+1, b, limit)
b = mid
}
}
}
首先大致总结一下这个过程:
- 首先判断当前数组片段的长度,如果长度小于12,则使用插入排序完成排序
- 接着通过判断limit是否为零,来判断数组分区是否长期分区不平衡,如果是,则转而使用堆排序完成排序操作,避免一些极端情况下快速排序性能的急速恶化。需要注意的是,这里传入的limit大致是数组长度的二进制位数
- 如果数组上次分区不平衡,则尝试基于一些随机数来交换数组中的位置,尝试扰乱数组的原有模式,保证下一次分区可能比较平衡
- 然后选取基准点,根据数组的不同长度,大致是三个四分点之间的中位数,并且在选取基准点的过程中,会尝试捕获数组的有序性,如果数组处于大致降序状态,则通过翻转数组调整为大致升序状态
- 如果上次分区比较平衡,且上次分区时选取的基准点基本上就是就是完成了分区,不需要通过交换方式来调整,且本次选取基准点反馈的数组也处于大致升序状态,这些条件一起说明此时的数组升序行很强,尝试使用效率更高的局部插入排序,注意,这里在插入的时候,只有当无序对的数量小于等于且数组长度大于50,才会正常完成插入排序,一旦发现无序对过多,说明此时数组的有序行还不够强,继续进入快速排序的循环中
- 如果判断发现当前分区比上一个分区的要大于等于,按理说这种情况只能是等于,说明可能数组中的重复元素比较多,因此进行一种特殊的分区partition_equal,将等于基准点的作为左分区,大于基准点的作为右分区,然后在循环里只处理右分区
- 然后到了这一步,就是要进行正常分区,并且会返回一个alreadyPartitioned,表示本次分区过程中,选取的基准点是否刚好将数组的这一个判断分区了
- 检查两个分区的长度,通过短分区的长度是否达到了总长度的八分之一来判断是否是平衡分区,然后短一点的分区递归调用pdqsort完成排序,长一点的继续在本函数的循环里处理。这样做估计是为了减少函数调用的开销,能够在一个循环里完成在一个循环里完成。
接下来具体来看各个被调用函数的实现代码。首先是插入排序,实现的逻辑和非常经典的:
// insertionSort_func sorts data[a:b] using insertion sort.
func insertionSort_func(data lessSwap, a, b int) {
for i := a + 1

最低0.47元/天 解锁文章
7828

被折叠的 条评论
为什么被折叠?



