这是我参与「第三届青训营 -后端场」笔记创作活动的的6篇笔记。根据老师给的课后作业我实现了v1版本的pqd排序。这节课的内容涉及到了相当多的数据结构的知识点,我尽量大白话简单解释一下这三种算法的原理。理解了三大排序的过程和各类排序在什么样的情况下能达到最优,就能够理解pqd排序算法。
插入排序
比较简单的一种排序。将一个数组分为两段,左侧是已排序的一段,右侧是尚未排序的部分,每次从右侧取出一个元素插入到左侧合适位置即可。
- 最好情况:数据基本有序的情况,如果数据本来就有序,那就几乎省去了所有的插入以及插入带来的数组后移的操作,时间复杂度为O(n)
- 最坏情况:简单来说是数据倒过来的情况。如果你要将一组数据升序排序,但这组数据本来是降序的,那么你每插入一个元素,就要将该元素之后所有的元素进行后移一位的操作,时间复杂度大大增加,为O(n^2)
- 平均情况:数组接近随机的情况下,时间复杂度依然为O(n^2)
此外,对于较短的序列,插入排序的性能明显好于其他的排序算法。
go语言实现插入排序如下
func InsertSort(arr []int) {
var i, j int
for i = 1; i <= len(arr)-1; i++ {
if arr[i] < arr[i-1] {
t := arr[i]
for j = i - 1; j >= 0 && t < arr[j]; j-- {
arr[j+1] = arr[j]
}
arr[j+1] = t
}
}
}
快速排序
快速排序简单来说,就是选择一个枢轴量,一般选择数组第一个元素,通过一次从两边到中间的遍历,将其放置在其应该在的位置上,这个过程称为partition操作。此时该数据的位置已经确定了,利用分治的思想,对该位置两侧的两段数组重复地进行如上操作。
- 最好情况:快速排序的主要思想就是分治,如果每次partition都能将数组分为相等的长度,那么接下来的partition的数组长度就降到了原来的一半,这种情况下省去了较多的时间,复杂度为O(nlogn)
- 最坏情况:如果数组是基本有序或者倒序的,那么这种情况下快排的优势就几乎没有了。打个比方,我们选择的枢轴量是第一个元素,遍历一圈下来,还是在边缘的位置,那么我们进行分治时,并没有有效降低数组的长度,甚至要从头到尾一个一个进行partition操作。最坏的情况下,时间复杂度为O(n^2)
- 平均情况:在大多数情况下,快排的时间复杂度为O(nlogn),是一般排序算法中性能最好的
go语言实现快速排序如下:
func partition(arr []int, low int, high int) int {
pivot := arr[low]
for low < high {
for low < high && arr[high] >= pivot {
high--
}
arr[low] = arr[high]
for low < high && arr[low] <= pivot {
low++
}
arr[high] = arr[low]
}
arr[low] = pivot
return low
}
func quickSort(arr []int, low int, high int) {
if low < high {
pivotpos := partition(arr, low, high)
quickSort(arr, low, pivotpos-1)
quickSort(arr, pivotpos+1, high)
}
}
堆排序
堆排序用二叉树、大/小顶堆的形式降低了数据查找和数据移动的复杂度。本来在数组中,要查找一个数据需要O(n),一个一个遍历,但对于堆而言,复杂度就降到了O(logn) 堆排序相比较于前两种排序最大的特点是时间复杂度很稳定,原始数据再任何情况下都能达到O(nlogn)的复杂度,虽然和快排在同一个数量级上,但性能是稍弱的。
堆排序比较复杂,不理解堆排序的小伙伴可以阅读一些博客,堆排序算法(图解详细流程)_阿顾同学的博客-CSDN博客_堆排序过程图解,或者仔细阅读教材进行理解
go语言实现堆排序如下:
func BuildMaxHeap(arr []int, len int) {
for i := len / 2; i > 0; i-- {
AdjustDown(arr, i, len)
}
}
func AdjustDown(arr []int, k int, len int) {
arr[0] = arr[k]
for i := 2 * k; i <= len; i *= 2 {
if i < len && arr[i] < arr[i+1] {
i++
}
if arr[0] >= arr[i] {
break
} else {
arr[k] = arr[i]
k = i
}
}
arr[k] = arr[0]
}
func HeapSort(arr []int, len int) {
BuildMaxHeap(arr, len)
var t int
for i := len; i > 1; i-- {
t = arr[i]
arr[i] = arr[1]
arr[1] = t
AdjustDown(arr, 1, i-1)
}
}
PQD排序
讲完了以上三种排序,我们希望能够结合三种算法各自的优点,在适当的时候选择合适的算法,以提高排序算法的性能。
- 插入排序对于短序列的排序表现最好
- 快排在绝大多数情况下是性能最高的算法,但在部分情况下(枢轴量总是选择在数组边缘)表现较差
- 堆排序的特点则是稳定
我们选择扬长避短,快排在大多数情况下性能最好,因此基于分治的逻辑,以快排为主;当经过分治后的数组序列较短(length<=24)时,我们选择采用插入排序快速完成排序,而当快排表现不好,即出现了一定次数(次数定为bits.Len(length),length的二进制最短位数)的枢轴量选在数组边缘的情况(枢轴量到两侧的距离小于length/8)
流程图如下
PQD算法实现如下
var (
limit int
)
func PqdSort(arr []int, low int, high int) {
len := len(arr)
if len <= 24 {
// fmt.Println("insert")
InsertSort(arr[low : high+1])
return
} else if limit == 0 {
// fmt.Println("heap")
HeapSort(arr[low:high+1], high-low)
return
} else {
// fmt.Println("quick")
if low < high {
pivotpos := Partition(arr, low, high)
if pivotpos-low <= len/8 || high-pivotpos <= len/8 {
limit--
}
PqdSort(arr, low, pivotpos-1)
PqdSort(arr, pivotpos+1, high)
}
}
}
func PqdInit(arr []int) {
length := len(arr)
limit = bits.Len(uint(length))
PqdSort(arr, 0, length-1)
}
Benchmark
之后我自己造了一些数据进行了一些benchmark,
这里遇到一些问题,一开始我想用rand.Intn()直接在benchmark内生成随机数组,但发现仅仅生成随机数组就耗费了大量的时间,最后还是把生成的随机数组输出出来,直接复制粘贴上去了。
这里我对四种算法都进行了benchmark,代码其实大同小异,就不全部贴上了
func BenchmarkPQDSortRandom16(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
test_arr := Random_16()
PqdInit(test_arr)
}
}
//Random_16(),好吧,直接暴力生成了
func Random_16() []int {
arr := []int{12, 5, 8, 1, 15, 0, 4, 9, 3, 13, 11, 2, 6, 14, 7, 10}
return arr
}
benchmark结果:
- 第一组是长度为1024下的有序数组在不同算法的表现。对于有序数组,很明显插入排序的表现最佳,快排的表现最差,而pqd排序的性能与堆排序较为接近,说明我们识别快排表现不好的情况的算法取得了很好的效果,当快排表现不好时,使用了堆排序
BenchmarkInsertSorted1024-16 811419 1510 ns/op
BenchmarkQuickSorted1024-16 5792 210212 ns/op
BenchmarkHeapSorted1024-16 62096 19612 ns/op
BenchmarkPQDSorted1024-16 49873 23243 ns/op
- 第二是长度为16的有序数组,插入排序在短序列和有序序列上都有最好的表现,而这次pqd排序的性能接近插入排序,由于序列较短,直接进入了插入排序
BenchmarkInsertSorted16-16 141053293 8.421 ns/op
BenchmarkQuickSorted16-16 8024766 142.7 ns/op
BenchmarkHeapSorted16-16 16791966 71.45 ns/op
BenchmarkPQDSorted16-16 100000000 12.02 ns/op
- 第三组是长度为1024的随机数组,插入排序的表现明显较差,而pqd排序的性能介于快排和堆排序之间,主要采用了快排的方式进行排序
BenchmarkInsertSortRandom1024-16 7918 155011 ns/op
BenchmarkQuickSortRandom1024-16 79518 14965 ns/op
BenchmarkHeapSortRandom1024-16 66806 17560 ns/op
BenchmarkPQDSortRandom1024-16 69585 16529 ns/op
- 第四组是长度为16的随机数组,插入排序在短序列上表现明显较好,其次则是pqd排序,说明也是采用了插入排序的方式进行排序。但pqd排序的性能没有想象的那么好,可能是我在一些不必要的地方浪费了一定的时间
BenchmarkInsertSortRandom16-16 32213622 39.80 ns/op
BenchmarkQuickSortRandom16-16 13286709 85.07 ns/op
BenchmarkHeapSortRandom16-16 17989312 69.89 ns/op
BenchmarkPQDSortRandom16-16 19061372 60.69 ns/op