文章目录
前言
排序算法是数组相关算法的基础知识之一,它们的经典思想可以用于很多算法之中。这里详细介绍和总结 7 种最常见排序算法,并用 Go 做了实现,同时对比这几种算法的时间复杂度、空间复杂度和稳定性 。后一部分是对 Go 标准库排序实现的源码阅读和分析, 理解官方是如何通过将以上排序算法进行组合来提高排序性能,完成生产环境的排序实践。
排序算法分类
常见的这 7 种排序算法分别是:
- 选择排序
- 冒泡排序
- 插入排序
- 希尔排序
- 归并排序
- 快速排序
- 堆排序
我们可以根据算法特点像复杂度、是否比较元素、内外部排序等特点对它们做分类,比如上面的算法都是内部排序的。一般可以基于算法是否比较了元素,将排序分为两类:
- 比较类排序:通过比较来决定元素间的相对次序。由于其平均时间复杂度不能突破 O ( N log N ) O(N\log N) O(NlogN),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序。它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。主要实现有: 桶排序、计数排序和基数排序。
通过这个的分类,可以先有一个基本的认识,就是比较类排序算法的平均时间复杂度较好的情况下是 O ( N log N ) O(N\log N) O(NlogN)(一遍找元素 O ( N ) O(N) O(N),一遍找位置 O ( log N ) O(\log N) O(logN))。
注: 有重复大量元素的数组,可以通过三向切分快速排序, 将平均时间复杂度降低到 O ( N ) O(N) O(N)
比较类排序算法
因为非比较排序有其局限性,所以它们并不常用。本文将要介绍的 7 种算法都是比较类排序。
选择排序
原理:遍历数组, 从中选择最小元素,将它与数组的第一个元素交换位置。继续从数组剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。循环以上过程,直到将整个数组排序。
时间复杂度分析: O ( N 2 ) O(N^{2}) O(N2)。选择排序大约需要 N 2 / 2 N^{2}/2 N2/2 次比较和 N N N 次交换,它的运行时间与输入无关,这个特点使得它对一个已经排序的数组也需要很多的比较和交换操作。
实现:
// 选择排序 (selection sort)
package sorts
func SelectionSort(arr []int) []int {
for i := 0; i < len(arr); i++ {
min := i
for j := i + 1; j < len(arr); j++ {
if arr[j] < arr[min] {
min = j
}
}
tmp := arr[i]
arr[i] = arr[min]
arr[min] = tmp
}
return arr
}
冒泡排序
原理:遍历数组,比较并将大的元素与下一个元素交换位置, 在一轮的循环之后,可以让未排序i的最大元素排列到数组右侧。在一轮循环中,如果没有发生元素位置交换,那么说明数组已经是有序的,此时退出排序。
时间复杂度分析: O ( N 2 ) O(N^{2}) O(N2)
实现:
// 冒泡排序 (bubble sort)
package sorts
func bubbleSort(arr []int) []int {
swapped := true
for swapped {
swapped = false
for i := 0; i < len(arr)-1; i++ {
if arr[i+1] < arr[i] {
arr[i+1], arr[i] = arr[i], arr[i+1]
swapped = true
}
}
}
return arr
}
插入排序
原理:数组先看成两部分,排序序列和未排序序列。排序序列从第一个元素开始,该元素可以认为已经被排序。遍历数组, 每次将扫描到的元素与之前的元素相比较,插入到有序序列的适当位置。
时间复杂度分析:插入排序的时间复杂度取决于数组的排序序列,如果数组已经部分有序了,那么未排序元素较少,需要的插入次数也就较少,时间复杂度较低。
- 平均情况下插入排序需要 N 2 / 4 N^{2}/4 N2/4 次比较以及 N 2 / 4 N^{2}/4 N2/4 次交换;
- 最坏的情况下需要 N 2 / 2 N^{2}/2 N2/2 比较以及 N 2 / 2 N^{2}/2 N2/2 次交换,最坏的情况是数组都是未排序序列(倒序)的;
- 最好的情况下需要 $ N-1$ 次比较和 0 次交换,最好的情况就是数组已经是排序序列。
实现:
// 插入排序 (insertion sort)
package sorts
func InsertionSort(arr []int) []int {
for currentIndex := 1; currentIndex < len(arr); currentIndex++ {
temporary := arr[currentIndex]
iterator := currentIndex
for ; iterator > 0 && arr[iterator-1] >= temporary; iterator-- {
arr[iterator] = arr[iterator-1]
}
arr[iterator] = temporary
}
return arr
}
希尔排序
原理:希尔排序,也称递减增量排序算法,实质是插入排序的优化(分组插入排序)。对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素位置,每次只能将未排序序列数量减少 1。希尔排序的出现就是为了解决插入排序的这种局限性,通过交换不相邻的元素位置,使每次可以将未排序序列的减少数量变多。
希尔排序使用插入排序对间隔 d 的序列进行排序。通过不断减小 d,最后令 d=1,就可以使得整个数组是有序的。
时间复杂度: O ( d N ∗ M ) O(dN*M) O(dN∗M), M 表示已排序序列长度,d 表示间隔, 即 N 的若干倍乘于递增序列的长度
实现:
// 希尔排序 (shell sort)
package sorts
func ShellSort(arr []int) []int {
for d := int(len(arr) / 2); d > 0; d /= 2 {
for i := d; i < len(arr); i++ {
for j := i; j >= d && arr[j-d] > arr[j]; j -= d {
arr[j], arr[j-d] = arr[j-d], arr[j]
}
}
}
return arr
}
归并排序
原理: 将数组分成两个子数组, 分别进行排序,然后再将它们归并起来(自上而下)。
具体算法描述:先考虑合并两个有序数组,基本思路是比较两个数组的最前面的数,谁小就先取谁,取了后相应的指针就往后移一位。然后再比较,直至一个数组为空,最后把另一个数组的剩余部分复制过来即可。
再考虑递归分解,基本思路是将数组分解成left
和right
,如果这两个数组内部数据是有序的,那么就可以用上面合并数组的方法将这两个数组合并排序。如何让这两个数组内部是有序的?可以二分,直至分解出的小组只含有一个元素时为止,此时认为该小组内部已有序。然后合并排序相邻二个小组即可。
归并算法是分治法 的一个典型应用, 所以它有两种实现方法:
- 自上而下的递归: 每次将数组对半分成两个子数组再归并(分治)
- 自下而上的迭代:先归并子数组,然后成对归并得到的子数组
时间复杂度分析: O ( N log N ) O(N\log N) O(NlogN)
实现:
// 归并排序 (merge sort)
package sorts
func merge(a []int, b []int) []int {
var r = make([]int, len(a)+len(b))
var i = 0
var j = 0
for i < len(a) && j < len(b) {
if a[i] <= b[j] {
r[i+j] = a[i]
i++
} else {
r[i+j] = b[j]
j++
}
}
for i < len(a) {
r[i+j] = a[i]
i++
}
for j < len(b) {
r[i+j] = b[j]
j++
}
return r
}
// Mergesort 合并两个数组
func Mergesort(items []int) []int {
if len(items) < 2 {
return items
}
var middle = len(items) / 2
var a = Mergesort(items[:middle])
var b = Mergesort(items[middle:])
return merge(a, b)
}
快速排序
原理:快速排序也是分治法的一个应用,先随机拿到一个基准 pivot,通过一趟排序将数组分成两个独立的数组,左子数组小于或等于 pivot,右子数组大于等于 pivot。 然后可在对这两个子数组递归继续以上排序,最后使整个数组有序。
具体算法描述:
- 从数组中挑选一个切分元素,称为“基准” (pivot)
- 排序数组,把所有比基准值小的元素排到基准前面,所有比基准值大的元素排到基准后面(相同元素不对位置做要求)。这个排序完成后,基准就排在数组的中间位置。这个排序过程称为“分区” (partition)
- 递归地把小于基准值元素的子数组和大于基准值的子数组排序
空间复杂度分析:快速排序是原地排序,不需要辅助数据,但是递归调用需要辅助栈,最好情况下是递归 log 2 N \log 2N log2N