Go标准库中的sort是如何实现的?
礼物说本文诣在解析Go中的标准库sort是如何排序的,做了哪些优化,以及pdqsort
1排序算法在Go中的源码实现。
一直自认为Go中默认的排序是快速排序,疑惑如果碰到了极端情况sort标准库会如何进行排序,优化方向有哪些?直到最近看到了一个issue,了解Go中已经使用了pdqsort.才发现自己的思维还停留在教科书上的几种单一排序方式。其实现在大部分业界使用的不稳定排序其实都已经是混合排序算法了。现在借此篇文章通过源码了解Go中的pdqsort是如何实现的。
前置知识
quicksort(快速排序)
经典的快排其实就是分治的思想,找到pivot
2,比pivot小的放左边,比pivot大的放右边,然后分别在对左右子数组进行递归。
- 好的情况:pivot是中位数,这样效率是最高的。 O(logn)
- 坏的情况:pivot是最值,这样效率是最差的。 O(n^2)
所以 选择pivot的好坏直接决定此次快排的效率。
常见的选取pivot的方式:
- 选取首个元素
- median of three : 选取首中尾三个元素取中位数
- Tukey’s ninther : 选取前三个元素的中位数 中间三个元素的中位数 后三个元素的中位数 再取中位数
insertsort(插入排序)
遍历数组一个个插入已经排好序的数组里,在数组长度较短的情况下表现良好
heapsort(堆排序)
利用堆结构设计出来的一种排序,特性为在最坏情况下复杂度仍然为O(n* logn)。所以很多混合排序都以它为兜底排序方式。
pdqsort (pattern-defeating quicksort)
一种混合排序算法,在不同情况下切换不同的排序机制。
源码解析
大致思路为:
循环执行上述流程直至全局有序
// pdqsort sorts data[a:b].
// The algorithm based on pattern-defeating quicksort(pdqsort),
// but without the optimizations from BlockQuicksort.
// pdqsort paper: https://arxiv.org/pdf/2106.05123.pdf
// C++ implementation: https://github.com/orlp/pdqsort
// Rust implementation: https://docs.rs/pdqsort/latest/pdqsort/
// limit is the number of allowed bad (very unbalanced) pivots
// before falling back to heapsort.
func pdqsort(data Interface, a, b, limit int) {
const maxInsertion = 12
var (
= true // whether the last partitioning was reasonably balanced
wasBalanced = true // whether the slice was already partitioned
wasPartitioned )
for {
:= b - a
length
if length <= maxInsertion {
(data, a, b)
insertionSortreturn
}
// Fall back to heapsort if too many bad choices were made.
if limit == 0 {
(data, a, b)
heapSortreturn
}
// If the last partitioning was imbalanced, we need to breaking patterns.
if !wasBalanced {
(data, a, b)
breakPatterns--
limit}
, hint := choosePivot(data, a, b)
pivotif hint == decreasingHint {
(data, a, b)
reverseRange// 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.
= (b - 1) - (pivot - a)
pivot = increasingHint
hint }
// The slice is likely already sorted.
if wasBalanced && wasPartitioned && hint == increasingHint {
if partialInsertionSort(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) {
:= partitionEqual(data, a, b, pivot)
mid = mid
a continue
}
, alreadyPartitioned := partition(data, a, b, pivot)
mid= alreadyPartitioned
wasPartitioned
, rightLen := mid-a, b-mid
leftLen:= length / 8
balanceThreshold if leftLen < rightLen {
= leftLen >= balanceThreshold
wasBalanced (data, a, mid, limit)
pdqsort= mid + 1
a } else {
= rightLen >= balanceThreshold
wasBalanced (data, mid+1, b, limit)
pdqsort= mid
b }
}
}
insertionSort
maxInsertion
默认为12,数组长度小于maxInsertion
时,直接使用插入排序。
heapSort
limit
为优化次数,取自2^x
func Sort(data Interface) {
:= data.Len()
n if n <= 1 {
return
}
:= bits.Len(uint(n))
limit (data, 0, n, limit)
pdqsort}
当limit
为0时,代表多次优化,pivot仍未接近中位数,改用堆排序。
wasBalanced
wasBalanced
代表了上次分组是否均衡。结果取决于 分完组后数量少的一组的长度与总长度的1/8的比较值.
:= length / 8
balanceThreshold if leftLen < rightLen {
= leftLen >= balanceThreshold
wasBalanced ...
} else {
= rightLen >= balanceThreshold
wasBalanced ...
}
breakPatterns
breakPatterns
函数是当wasBalanced==false
(上次分组不均衡)时,会随机swap几个元素来避免极端情况的发生。同时limit
还会减1,代表上次使用快排表现不佳(如果limit减为0,则会使用堆排序)。
// breakPatterns scatters some elements around in an attempt to break some patterns
// that might cause imbalanced partitions in quicksort.
func breakPatterns(data Interface, a, b int) {
:= b - a
length if length >= 8 {
:= xorshift(length)
random := nextPowerOfTwo(length)
modulus for idx := a + (length/4)*2 - 1; idx <= a+(length/4)*2+1; id ++ {
:= int(uint(random.Next()) & (modulus - 1))
other if other >= length {
-= length
other }
.Swap(idx, a+other)
data}
}
choosePivot
choosePivot
函数从数组中选择合适的pivot。
- 当len<8,选择中间的元素。
- 当8 <= len < shortestNinther(默认为50),使用
median of three
。 - 当 len >= shortestNinther,使用
Tukey’s ninther
。
同时第三种情况还会判断是否局部有序,如果 a-1 <a < a+1 && b-1 < b < b+1 && c-1 < c < c+1 && a < b < c
则判断为局部正序,反之则为局部逆序
func choosePivot(data Interface, a, b int) (pivot int, hint sortedHint) {
const (
= 50
shortestNinther = 4 * 3
maxSwaps )
:= b - a
l var (
int
swaps = a + l/4*1
i = a + l/4*2
j = a + l/4*3
k )
if l >= 8 {
if l >= shortestNinther {
// Tukey ninther method, the idea came from Rust's implementation.
= medianAdjacent(data, i, &swaps)
i = medianAdjacent(data, j, &swaps)
j = medianAdjacent(data, k, &swaps)
k }
// Find the median among i, j, k and stores it into j.
= median(data, i, j, k, &swaps)
j }
switch swaps {
case 0:
return j, increasingHint
case maxSwaps:
return j, decreasingHint
default:
return j, unknownHint
}
}
reverseRange
如果上一步判断为局部逆序,则使用reverseRange
反转数组变为局部正序。
wasPartitioned
wasPartitioned
表示上次循环没有交换元素,即局部正序的.
partialInsertionSort
如果wasBalanced && wasPartitioned && hint == increasingHint
,则很有可能此数组已经有序了。那我们就使用partial insertion sort
算法,相比insertion sort
多了尝试次数,避免我们误判导致时间复杂度上升。 partialInsertionSort
函数就是为了实现上述逻辑,如果maxSteps
之内未有序则退出,反之则直接返回有序数组。
// partialInsertionSort partially sorts a slice, returns true if the slice is sorted at the end.
func partialInsertionSort(data Interface, a, b int) bool {
const (
= 5 // maximum number of adjacent out-of-order pairs that will get shifted
maxSteps = 50 // don't shift any elements on short arrays
shortestShifting )
:= a + 1
i for j := 0; j < maxSteps; j++ {
for i < b && !data.Less(i, i-1) {
++
i}
if i == b {
return true
}
if b-a < shortestShifting {
return false
}
.Swap(i, i-1)
data// Shift the smaller one to the left.
if i-a >= 2 {
for j := i - 1; j >= 1; j-- {
if !data.Less(j, j-1) {
break
}
.Swap(j, j-1)
data}
}
// Shift the greater one to the right.
if b-i >= 2 {
for j := i + 1; j < b; j++ {
if !data.Less(j, j-1) {
break
}
.Swap(j, j-1)
data}
}
}
return false
}
partitionEqual
当a > 0 && !data.Less(a-1, pivot)
,意味着上一组左组中的最大值和本次的pivot相等。数组中可能存在很多重复元素。partitionEqual
函数就是解决这个问题,把重复元素放在一起,然后重新选pivot,然后开始下一轮循环。
partition
partition
实现了快排逻辑,并且会判断本次循环是否交换了元素,设置wasPartitioned
.
总结
排序方式千千万,标准库中的方式很大程度上是为了满足各种情况而出现的通用解,在某些垂直领域其实并不会比单一排序更有效。这个在之前的解析map一文中也能发现。所以当我们碰到一些性能问题时,可以适当的摒弃标准库而选择合适业务的方案,如此才能有自己的积累。