Golang实现7种常用排序算法以及时间突破到On的排序

排序概述

  1. 对于一个序列,任意两个数的顺序不符合约定则称为一个逆序。例如对于一个随机数组如[3,2,1]我们希望它能按增序排列,则其逆序对为3和2,3和1,2和1。可以认为一个随机数组的逆序数为O(n2)。各种排序本质上就是在消除逆序对,如冒泡排序每次交换相邻值可消除1个逆序;因此其时间复杂度也为O(n2)。要将时间复杂度降低至O(nlogn)则需要一个交换可以消除多个逆序,例如快排某次交换将基数右边的一个值移动到了左边,就消灭了1~n个逆序。

  2. 对于基于比较的排序,在序列是随机序列的情况下,每次比较的结果无非true or false,只有为true的时候才能消灭逆序对,而目前的排序算法无法保证每次比较true和false的几率各占一半,往往是最高占几率才一半,而随着递归或迭代的进行为true的概率越来越低。例如在快速排序中只有在第一轮比较的时候左右两边的数大于基数的概率为1/2,而此后的概率均不足1/2,忘了怎么证明的了…总之基于比较的排序无法突破nlogn的时间限制;要做到线性时间复杂度O(n),只有在实际应用中数据具备一定的规律性质才能实现,如桶排序、计数排序、基数排序以及位图法排序

  3. 另外排序还具有另一个重要性质:稳定性。即在排序结束后两个等值元素顺序发生了交换,则称其为不稳定。例如现在有一串姓名[张三、张四、张五],按其姓氏排序已经排好,但使用不稳定排序就可能使其顺序发生交换。排序稳定性的详细叙述可以参考这篇文章 浅谈各种排序的稳定性

以下增量排序均经过测试100%可直接运行

1.冒泡排序(稳定)

依次比较相邻元素,若当前元素大于其后元素,则将其进行交换。是最简单、代码量最少、性能最差的排序,在数据较少时使用冒泡排序少写点代码。另外通过一个辅助变量标志flag,若本轮循环没有发生任何交换即已经没有逆序,可直接break,所以冒泡排序最好情况下的时间复杂度可以达到O(n)

最好时间平均时间最坏时间辅助存储
O(n)O(n2)O(n2)O(1)
func bubbleSort(arr []int) {
	var flag bool
	/*冒泡排序就像气泡一样让大数往上升*/
	for i := 0; i < len(arr) - 1; i++ {
		flag = true
		for j := 0; j < len(arr)-i-1; j++ {
			if arr[j] > arr[j+1] {
				flag = false
				arr[j],arr[j+1] = arr[j+1],arr[j]
			}
		}
		if flag {
			break
		}
	}
}

2.选择排序(不稳定)

每轮遍历选出一个最小值元素下标,将其与当前下标元素进行交换,每轮遍历均消灭On个逆序对。不过我没想到这种排序的应用场景hha,可能是它的平均性能比冒泡好一些(不用频繁地交换相邻值),有朋友知道的话可以留下评论~

最好时间平均时间最坏时间辅助存储
O(n2)O(n2)O(n2)O(1)
func selectSort(arr []int) {
	var minIndex int
	for i := 0; i < len(arr)-1; i++ {
		minIndex = i
		for j := i + 1; j < len(arr); j++ {
			if arr[j] < arr[minIndex] {
				minIndex = j
			}
		}
		arr[i],arr[minIndex] = arr[minIndex],arr[i]
	}
}

3.插入排序(稳定)

假设第一个元素已经排好序,从第二个元素开始遍历,依次将元素插入到前面已排好序的列表中,最好情况下已经排好序,插入排序的速度也可以达到O(n),因此插入排序非常适用于大部分数据已经排好序的情况。另外在前面这三种复杂度为O(n2)的排序中,插入排序表现的性能要比另外两种更高

最好时间平均时间最坏时间辅助存储
O(n)O(n^2)O(n^2)O(1)
func insertSort(arr []int) {
	var tmp, j int
	/*将当前元素插入到前面已经排好序的列表中*/
	for i := 1; i < len(arr); i++ {
		tmp = arr[i]
		j = i
		for j > 0 && arr[j-1] > tmp {
			arr[j] = arr[j-1]
			j--
		}
		arr[j] = tmp
	}
}

4.快速排序(不稳定)

快速排序正如其名,在数据良好情况下算是"最快”的排序之一(我想去掉这个之一来着…)。采用分而治之的思想,将大数据拆成小数据排序。通过一轮排序,将原数据分为两部分:前一部分的值全部比后一部分的值小。然后在对两部分记录进行递归快排,直到全部数据均有序。
快排也是通过比较两个数后进行交换,在极端情况下每次比较都需要交换,此时快排退化为冒泡,复杂度达到O(n2)。因此快排适合于数据重复情况较少的情况。
我测试了两组包含一千万个数据的序列,第一组数据平均每个数重复出现1e4次,快排的性能严重下降
大量重复数据
对比另外一组数据平距每个数仅出现一次,速度比归并、希尔排序还要快上2~3倍:
在这里插入图片描述

最好时间平均时间最坏时间辅助存储
O(nlogn)O(nlogn)O(n2)O(logn)
func quickSort(arr []int) {
	var qSort func(l, r int)
	qSort = func(l, r int) {
	    //当从i到j的数均大于基值,j会一直移动到i的左侧,因此需要>=而非==
		if l >= r {
			return
		}
		var (
			i, j = l, r
			tmp  = arr[l]	//这是基数,基数左边的值全部小于基数,右边的值全部大于基数。
							//这里取左端的值为基数;良好的基数可以减少递归次数,这里就直接取左端值
		)
		//i和j分别是指向左端和右端的指针
		for i < j {
			for i < j && arr[j] > tmp {
				j--
			}
			arr[i] = arr[j]
			for i < j && arr[i] <= tmp {
				i++
			}
			arr[j] = arr[i]
		}
		//前面交换已经覆盖了基数,基数丢失。但通过tmp保存了基数
		arr[i] = tmp
		qSort(l, i-1)
		qSort(i+1, r)
	}
	qSort(0, len(arr)-1)
}

5.归并排序(稳定)

归并排序同样采用分而治之的思想,将序列划分为更小的序列。先将序列递归地分离折半,再两两合并排序。归并排序平均性能比快排低一些,但适用于任何情况,对数据的质量不挑剔

最好时间平均时间最坏时间辅助存储
O(nlogn)O(nlogn)O(nlogn)O(n)
func mergeSort(arr []int) {
	var (
		resolve func(arr []int, l, r int)
		merge   func(arr []int, l, mid, r int)
	)
	//划分子表
	resolve = func(arr []int, l, r int) {
		if l < r {
			var mid = l + ((r - l) >> 1)
			resolve(arr, l, mid)
			resolve(arr, mid+1, r)
			merge(arr, l, mid, r)
		}
	}
	//合并子表
	merge = func(arr []int, l, mid, r int) {
		var tmpArr = make([]int, r-l+1)
		for i := l; i <= r; i++ {
			tmpArr[i-l] = arr[i]
		}
		var p, q = 0, mid - l + 1 		//标记左右起点
		for i := l; i <= r; i++ {
			if l+p > mid { 				//左下标越过中点
				arr[i] = tmpArr[q]
				q++
			} else if l+q > r { 		//右下标越过中点
				arr[i] = tmpArr[p]
				p++
			} else if tmpArr[p] < tmpArr[q] {
				arr[i] = tmpArr[p] 		//左小右大 取左
				p++
			} else {
				arr[i] = tmpArr[q] 		//右小左大 取右
				q++
			}
		}
	}
	resolve(arr, 0, len(arr)-1)
}

6.希尔排序(不稳定)

属于插入排序的升级版。插入排序在最好情况数据即大部分数据已经排好序时时间复杂度可达到On线性级别,所以换个思路我们让先数组变为大部分有序后再插入排序,同样可以实现高性能排序,这就是希尔排序。希尔排序通过一个增量gap将原序列分为多个小组,如初始gap为3一开始下标为0,3,6,9,12的下标为一个小组,1,4,7,10,13分为一组,2,5,8,11,14的下标分为一组,对每一组单独进行插入排序,结束后已经消除了许多逆序。再将gap/=2变为1,即正常的插入排序,此时的插入排序效率非常高。
希尔排序本质上也是插入排序,因此适用于数据量较大数据又有部分已经序列化的排序。

最好时间平均时间最坏时间辅助存储
O(n)O(nlogn)O(n1~2)O(1)
func shellSort(arr []int) {
	var gap = len(arr) >> 1
	var tmp, j int
	//利用插入排序最好情况下可达到On时间的性质,用gap将数组划分为多个组,依次对每个组进行插入排序;
	//然后不断减小gap重复前面的插入排序直到gap为1时,数组已经基本排好顺序,此时进行插入排序其时间复杂度将接近On
	for gap > 0 {
		for x := 0; x < gap; x++ {
			for i := x + gap; i < len(arr); i += gap {
				tmp = arr[i]
				j = i
				for j > x && arr[j-gap] > tmp {
					arr[j] = arr[j-gap]
					j -= gap
				}
				arr[j] = tmp
			}
		}
		gap >>= 1
	}
}

7.堆排序(不稳定)

不得不说二叉树是个很神奇的东西,而堆一般采用完全二叉树的形式。
堆的树形式

我们通过构建堆、排序堆两步可以实现最最最最最稳定的排序:堆排序。堆排又分为大顶堆和小顶堆,这里以大顶堆为例,大顶堆的任何一个父节点都大于它的两个子节点(与子节点左右无关):arr[i]>arr[i * 2+1] && arr[i]>arr[i * 2+2]。构建堆时从最后一个非叶子节点(下标:len(arr)/2-1)开始向前进行构建,然后得到一个大顶堆。接下来在将堆顶元素移除,将剩下部分堆再次构建建新的大顶堆,直到整个堆的节点都构建一次。看代码会比较清晰:

func heapSort(arr []int) {
	var (
		//用于将目标父节点不断下沉 调整堆
		adjustHeap = func(arr []int, parentIndex, length int) {
			var (
				child = parentIndex*2 + 1
				temp  = arr[parentIndex] //记录父亲节点的值,避免频繁交换父子节点
			)
			//将这个数值小的父节点不断下沉
			for child < length {
				if child+1 < length && arr[child+1] > arr[child] { //如果存在右子节点 且左子节点小于右子节点
					child++
				}
				if arr[child] < temp {
					break
				}
				arr[parentIndex] = arr[child]
				parentIndex, child = child, child*2+1
			}
			arr[parentIndex] = temp
		}
	)
	//构建堆:从堆的最后一个非叶子节点开始调整,将大值上浮
	for i := len(arr)/2 - 1; i >= 0; i-- {
		adjustHeap(arr, i, len(arr))
	}
	//排序堆:将堆顶元素移到最后,在重新构建剩余部分的堆
	for i := len(arr) - 1; i > 0; i-- {
		arr[0], arr[i] = arr[i], arr[0]
		adjustHeap(arr, 0, i)
	}
}
最好时间平均时间最坏时间辅助存储
O(n2)O(n2)O(n2)O(1)
每次调整堆的消耗都是O(logn),一共调整n个节点,因此其复杂度为O(nlogn)。堆排属于时间极其稳定的排序,但堆排做了许多无用的比较导致与其他O(nlogn)排序相比性能较差,进一步研究堆排性能可以参考 这里

8.猴子排序和睡眠排序

埃米尔·博雷尔 曾经说过,让一只猴子在键盘上不停地敲字,很久很久以后总有一次会将莎士比亚的所有作品一字不差地敲出来。
猴子排序也是如此,让一个数组乱序一次就可能使这个数组排序成功。猴子排序不是基于比较的排序,因此时间复杂度最好可以达到“O(n)”
睡眠排序的每个数字决定它要睡多久(开个协程然后自己time.sleep(n)),醒了自己去列表后面append报道,然排序完成。因为数据值相差较小时无法保证每个协程的唤醒顺序。要是存在一个非常大的数,等我们已经在使用数据时可能还有家伙在睡觉

9.其它速度达到O(n)线性的排序方法

以上的正常排序算法都是基于比较的排序,时间无法突破O(nlogn),这里有篇非常棒的文章 数学之美番外篇:快排为什么那样快 详细介绍了原因,在特殊场景下可以通过一些特殊排序将复杂度降至O(n)。

  1. 桶排序
    这先创建若干已经排好序的桶,每个桶对应一定的数字(如第一桶表示[10,20)),遍历序列将每个数存放到各个桶中,再对各个桶进行快排。最后将各个桶从小到大依次取出即可完成排序。
    复杂度分析:假设这里有n个数据和m个桶,则平均每个桶有k=n/m个数据。每个桶的复杂度为O(klog2),则m个桶的复杂度为mO(n/m * log(n/m))= O(n*log(n/m))。当n接近m即平均每个桶只有少数元素时,时间即为O(n)。
    有没有一种在“睡眠排序”的感觉???都是自己去对应的位置主动“报道”而非比较值。可以看到假设有n个数据,每个数据在指定位置报道后都将消除(i-1)/ i个逆序!因此效率特别高,那为什么平时还是用快排比较多而非桶排序呢?因为使用桶排序对数据的要求非常严格。首先要求数据很容易地划分到m个桶中,并且数据要均匀分布在各个桶中,否则极端情况下所有数据都分到一个桶,那就又退化成了O(nlogn)的复杂度。
    在这里插入图片描述
  2. 计数排序
    计数排序可以看做特殊的桶排序。当数据范围特别小时,我们用每个桶来表示一个数据重复出现的次数。如对于一组范围在[10,18]的长度为1000的数据,我们arr := make([]int,9),遍历序列将对应的arr[i]++。最后输出目标序列。
  3. 基数排序
    通过将所有数值统一为同样数位长度,要求数位短的在补0且均为正整数,然后依次从低位开始排序。时间复杂度为数位长度 x n。接近O(n)。例如:[24,132,301,276]:
    基数排序
  4. 位图法排序
    对于一组没有重复数的数据使用位图法是极其高效的排序。可以用1个bit来表示一个数据是否出现过,在遍历序列时假如一个元素的值为1000,那么可以将下标为1000的bit标记为1。然后在遍历一遍位图将值为1的元素依次加入列表中即可。时间复杂度为稳定的O(n).

新人尝试写文章,如果对大家有帮助,还请点赞支持一下~如果文中有错误或对内容有疑问欢迎在评论区中指出

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值