第十一次课:数据结构与算法 | 青训营笔记

71 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的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)

流程图如下

image.png

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内生成随机数组,但发现仅仅生成随机数组就耗费了大量的时间,最后还是把生成的随机数组输出出来,直接复制粘贴上去了。

347276245DAF46AFBA94EF518DD33111.jpg

这里我对四种算法都进行了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