Go语言实现十大排序算法超细节图片讲解

基础排序

冒泡排序

将序列中的元素进行两两比较,将大的元素移动到序列的末尾。

平均时间复杂度是O(n^2),最坏时间复杂度是O(n^2),最好时间复杂度是O(n),排序结果具有稳定性,空间复杂度是O(1)。

这里所说的稳定性是针对相同元素而言的,比如5,5,3进行冒泡排序,第一个5由于并不大于第二个5,所以两个5的位置不会发生交换,如果排序的时候,两个5的位置发生了交换我们就说,这种排序是不稳定的。

Go 复制代码
func BubleSort(nums []int) {
	n := len(nums)
	finish := false
	for i := 0; i < n; i++ {
		for j := 0; j < n-1-i; j++ {
			if nums[j] > nums[j+1] {
				nums[j], nums[j+1] = nums[j+1], nums[j]
				finish = true
			}
		}
		if !finish {
			break
		}
		finish = false
	}
}

选择排序

假设序列中的第一个元素为最小值,然后遍历后续的元素找到真正的最小值,每次在剩余元素中选择最小的元素与当前元素进行交换。

**平均时间复杂度是O(n^2),最好时间复杂度是O(n^2),最坏时间复杂度是O(n^2),排序结果不稳定,空间复杂度是O(1)。**使用选择排序对5,5,3进行排序,会将第一个5和最后一个3进行交换,这样两个5的前后顺序就变了,所以选择排序是不稳定的。

Go 复制代码
func ChoiceSort(nums []int) {
	n := len(nums)
	for i := 0; i < n-1; i++ {
		minIndex := i
		for j := i + 1; j < n; j++ {
			if nums[j] < nums[minIndex] {
				minIndex = j
			}
		}
		nums[i], nums[minIndex] = nums[minIndex], nums[i]
	}
}

插入排序

从第二个元素开始,把前面的元素当作有序的,然后在这些元素中找到小于等于当前元素的元素,在这个元素的后面插入当前元素。

最坏时间复杂度和平均时间复杂度都是O(n^2),最好时间复杂度是O(n),排序结果稳定,对于趋于有序的数据,具有最高效率,空间复杂度是O(1)。

Go 复制代码
func InsertSort(nums []int) {
	n := len(nums)
	for i := 1; i < n; i++ {
		num := nums[i]
		j := i - 1
		for ; j >= 0; j-- {
			nums[j+1] = nums[j]
			if nums[j] < num {
				break
			}
		}
		nums[j+1] = num
	}
}

高级排序

希尔排序

对插入排序的优化,其实就是多路的插入排序,插入排序本身只有一个分组,而希尔排序通过设置分组间隔的方式可以产生多个分组,对这些分组进行插入排序,分组的数据越趋于有序,则整体的数据越趋于有序。

最坏时间复杂度是O(n^2),最好时间复杂度是O(n),平均时间复杂度是O(n^1.3),排序结果稳定,空间复杂度是O(1)。

Go 复制代码
func ShellSort(nums []int) {
	n := len(nums)
	for gap := n / 2; gap != 0; gap /= 2 {
		for i := gap; i < n; i++ {
			num := nums[i]
			j := i - gap
			for ; j >= 0; j -= gap {
				nums[j+gap] = nums[j]
				if nums[j] < num {
					break
				}
			}
			nums[j+gap] = num
		}
	}
}

对比代码可以发现,希尔排序其实就是将插入排序的1换成了分组间隔gap。

快速排序

选取一个基准数,把小于基准数的元素放到基准数的右边,将大于基准数的元素放到基准数的左边,然后以基准数作为划分,将序列划分为左右两个序列,再次将元素与基准数进行比对和移动,直到整个序列变为有序。

快排一般使用递归实现,先选取基准数,一般就是序列的最左边的元素,然后从右边开始找第一个比基准数小的元素,令此时遍历到的左边的元素等于该元素,并且左索引向后移动1,再从左边寻找第一个比基准数大的元素,令此时遍历到的右边的元素等于该元素,右索引向前移动1,重复以上操作,直到序列有序。

快排的最好和平均时间复杂度是O(nlogn),最坏时间复杂度是O(n^2),空间复杂度在O(logn)到O(n)之间,排序结果不稳定,一般来说序列越乱序,快排效率越高,与插入排序相反。

Go 复制代码
func QuickSort(nums []int, left int, right int) {
	if left >= right {
		return
	}
	num := nums[left]
	i := left
	j := right
	for i < j {
		for i < j && nums[j] >= num {
			j--
		}
		if i < j {
			nums[i] = nums[j]
			i++
		}
		for i < j && nums[i] <= num {
			i++
		}
		if i < j {
			nums[j] = nums[i]
			j--
		}
	}
	nums[i] = num
	QuickSort(nums, left, i-1)
	QuickSort(nums, i+1, right)
}

实际上快速排序的效率取决于左右序列的划分,也就是基准数的选取,要保证划分的序列可以组成一个平衡的二叉树,这时排序的效率通常是最好的,可以通过三数取中法的方式有效选取较好的基准数,每次较好地划分左右序列,当然也可以使用快排加插入排序结合的方式,提高快排的效率,毕竟快速排序的左右序列都是趋于有序的,适用于插入排序。

归并排序

先递进(可以用二分递进)到序列的各个元素,然后回归将各个元素的顺序进行编排,并将编排好的左右序列进行合并,最终使得整个序列变得有序。

归并排序的最好时间复杂度和最坏时间复杂度以及平均时间复杂度都是O(nlogn),因为归并排序无论序列是否有序,它都会进行二分递归将序列分为单个元素,再合并为有序的序列,所以没有最好和最坏时间复杂度的区别,空间复杂度是O(n+logn),也就是O(n)。

Go 复制代码
func MergeSort(nums []int, left int, right int) {
	if left >= right {
		return
	}
	mid := left + (right-left)/2
	// 递进
	MergeSort(nums, left, mid)
	MergeSort(nums, mid+1, right)
	// 回溯合并
	order_res := make([]int, right-left+1)
	i := left
	j := mid + 1
	index := 0
	// 合并两个已排序的子数组
	for i <= mid && j <= right {
		if nums[i] <= nums[j] {
			order_res[index] = nums[i]
			index++
			i++
		} else {
			order_res[index] = nums[j]
			index++
			j++
		}
	}
	// 如果左边还有元素
	for i <= mid {
		order_res[index] = nums[i]
		index++
		i++
	}
	// 如果右边还有元素
	for j <= right {
		order_res[index] = nums[j]
		index++
		j++
	}
	// 将排序后的结果放回原数组
	for i := 0; i < len(order_res); i++ {
		nums[left+i] = order_res[i]
	}
}

堆排序

堆排序实际上是基于二叉堆进行的,而二叉堆就是一个数组,只是我们从逻辑的角度将这个给数组看作一颗完全二叉树,这个二叉树就是二叉堆,二叉堆又分为大根堆和小根堆,堆排序如果是基于大根堆实现的就是,从小到大排序,小根堆就是从大到小排序。

进行堆排序的第一步就是从第一个非叶子节点开始到堆顶元素的每一个节点都进行下沉操作,将本来的二叉堆调整为大根堆,第一个非叶子节点的数组索引可以通过数组末尾元素的索引n得到,(n-1)/2。接着把堆顶元素和末尾元素进行交换,从0号位继续开始进行堆的下沉操作。实际上就是将每一个有孩子的节点所对应的子树都调整为大根堆。

堆排序的平均时间复杂度和最好最坏时间复杂度都是nlogn,空间复杂度是O(1),排序的结果不稳定。

Go 复制代码
func siftDown(nums []int, n, i int) {
	for {
		l := 2*i + 1
		r := 2*i + 2
		max := i
		if l < n && nums[l] > nums[max] {
			max = l
		}
		if r < n && nums[r] > nums[max] {
			max = r
		}
		if max == i {
			break
		}
		nums[i], nums[max] = nums[max], nums[i]
		i = max
	}
}
func HeapSort(nums []int) {
	//构建完全二叉树
	for i := len(nums)/2 - 1; i >= 0; i-- {
		siftDown(nums, len(nums), i)
	}
	for i := len(nums) - 1; i > 0; i-- {
		nums[0], nums[i] = nums[i], nums[0]
		siftDown(nums, i, 0)
	}
}

需要注意的是,尽管快排和归并排序和堆排序,它们的时间复杂度基本相差不大,但是实际使用进行排序的时候,堆排序的效率要比快排和归并排序差的多主要是因为快排和归并排序是顺序遍历数组元素的,我们知道,CPU在执行一段指令或者使用一段数据的时候,它不是一条一条从内存中获取的,而是将一块相邻的内存都加载到自己的空间中,如果是当前要使用的数据或者要执行的指令就放入到CPU寄存器中,如果暂时不需要使用的数据和不执行的指令则放入到缓存中,但是所有的指令和数据都是相邻一块内存中的,所以快排和归并排的机制对于CPU的缓存使用相当友好,可以提高CPU的缓存命中率,快排和归并派使用到的数据大部分都是从缓存中拿取的,而不是从进程的内存中拿取的,自然效率要更高,而堆排序就不一样了,它访问元素是通过上浮和下沉操作,并不是顺序访问元素,因此更多是从内存取数据,因此效率要低的多,而且堆排序在下沉操作中的无效交换次数较多,因为每次进行下沉的时候,都会直接将数组末尾元素也就是堆尾部的元素直接覆盖堆顶部的元素,然后再对堆顶元素进行下沉,本身数组末尾元素在二叉堆里面的属性就比较极端,如果是在大根堆,那这个元素极有可能是最小的,这时候直接放到堆顶,后面又要下沉回来,因此极其浪费时间。

但是归并排序也有自己独特的优势,那就是它是八大排序算法中唯一的外排序,其他排序都是内排序,只能对内存上的数据进行排序,但是归并排序可以对磁盘里面的数据进行排序,比如我D盘里面一个test.txt中有1024MB的数据序列,但是我的内存只有100MB,这时候只有归并排序的思想可以在使用100MB内存的情况下,完成磁盘上1024MB数据的排序,实际上就是将test.txt中的数据序列分为多个文件存储,比如test1.txt、test2.txt等,这些.txt文件中只有100MB的文件序列,然后分别对它们进行归并排序,最后从这些已经排好序的文件中不断选取最小值,组成一个有序的1024MB的序列,这就是归并排序的先分再合的思想。

桶排序

按照数据的某种性质将一组数组序列映射分配到数量有限的桶里面,每个桶再分别排序,然后将每个桶里面排序好序的序列重新组合为一个新的有序序列。

这里说的某种性质,就是例如数据的大小范围、分布情况等,如果我们知道数据的范围在[0,1)之间,我们可以将数据乘以桶的数量来映射到不同的桶中;另外也可以根据数据的分布情况,将数据按照一定的映射规则划分到桶中。

桶排序在对每个桶中的元素进行排序时,通常依赖于其他的排序算法,比如快排或者插入排序等,因此桶排序的时间复杂度也取决于所使用到的排序算法的时间复杂度,一般来说,如果使用Go标准库自带的sort函数对各个桶里面的元素进行排序,桶排序的最好时间复杂度是O(n),平均时间复杂度也是O(n+k),最坏时间复杂度是O(n^2),空间复杂度是O(n+k),需要k个桶和n个元素的额外空间,排序结果稳定。

Go 复制代码
func BucketSort(nums []float64) {
	n := len(nums)
	var bucket [][]float64 = make([][]float64, n)
	var index []int = make([]int, n)
	for i := 0; i < len(nums); i++ {
		index[i] = int(float64(n) * nums[i])
		bucket[index[i]] = append(bucket[index[i]], nums[i])
	}
	for i := 0; i < len(nums); i++ {
		sort.Float64s(bucket[i])
	}
	j := 0
	for _, v1 := range bucket {
		for _, v2 := range v1 {
			nums[j] = v2
			j++
		}
	}
}

需要注意的是,桶排序适用于均匀分布的数据,主要用于特定范围内的浮点数排序,按照上面的映射方式,如果是对整数进行排序,数据产生的映射下标,直接就超出桶的大小了,这时需要转换映射关系。

计数排序

由桶排序衍生出来的排序,同样是根据数据的某种性质将数据分配到不同的桶中,然后进行排序,但是它是根据原始数据序列中最小元素和最大元素的值开辟桶的大小,那么这个桶实际上是一个一维的数组,并且的索引的范围是从0到原始数据序列中的最大值,将原始数据序列中每一个元素对应在桶里的位置都+1,原始数据序列中同一个元素出现几次,对应桶中这个元素索引的值就是几,比如我原始序列是{1,2,5,9,1,3,8,10},显然桶的大小是10,其中桶的第0号索引对应的值显然是0,因为0没有出现在原始数据序列中,第1号索引对应的值自然就是2,因为1在原始序列中出现了2两次,就是按照这种方式,将原始序列映射到了桶中,然后从桶的0索引开始依次将所有元素取出放入元素序列中,这样就能达到一个排序的效果。

计数排序从机制就可以看出来,非常的损耗空间,假如我上面那个序列的1改为99,那么就要开辟一个99大小的桶数组出来,计数排序的平均时间复杂度和最好最坏时间复杂度都是O(n+k),空间复杂度是O(k),排序结果稳定。

假设原始序列如下:

计数排序过程如下:

Go 复制代码
func CountingSort(nums []int) {
	maxElement := 0
	for _, value := range nums {
		if maxElement < value {
			maxElement = value
		}
	}
	var bucket []int = make([]int, maxElement+1)
	for _, value := range nums {
		bucket[value]++
	}
	index := 0
	for i := 0; i < maxElement+1; i++ {
		for bucket[i] > 0 {
			nums[index] = i
			index++
			bucket[i]--
		}
	}
}

基数排序

同样是桶排序衍生出来的排序,首先找到整个原始序列中位数最长的数字,确定桶排序的趟数,第一次将原始序列中的数据按照个位数字,依次放入到0~19的20个桶中进行分类,然后依次从这个0~19个桶中将数组拿出重新拼凑为一个新的序列,再按照十位的数字进行同样的操作,直到完成所有的趟数。

基数排序的最坏时间复杂度和平均时间复杂度以及最好时间复杂度都是O(n*k),空间复杂度是O(n),排序结果是稳定的。

Go 复制代码
func RadixSort(nums []int) {
	n := len(nums)
	maxElement := nums[0]
	for i := 1; i < n; i++ {
		abs_num := func(num int) int {
			if num < 0 {
				return -num
			}
			return num
		}(nums[i])
		if abs_num > maxElement {
			maxElement = abs_num
		}
	}
	turns := len(strconv.Itoa(maxElement))
	mod := 10
	dev := 1
	for i := 0; i < turns; i++ {
		var bucket [][]int = make([][]int, 20)
		for j := 0; j < n; j++ {
			//获取当前元素的第i位数
			index := nums[j]%mod/dev + 10 //+10是为了处理负数
			bucket[index] = append(bucket[index], nums[j])
		}
		dex := 0
		for _, v1bucket := range bucket {
			for _, v2bucket := range v1bucket {
				nums[dex] = v2bucket
				dex++
			}
		}
		mod *= 10
		dev *= 10
	}
}

需要注意的是,这里选用20个桶,是为了基数排序可以处理负数排序,用20个桶就可以容下-9~9这20个数字,除此之外,从基数排序的位数处理方式来看,就可以很明显看出,基数排序不适合浮点数排序。

相关推荐
Dizzy.51710 分钟前
数据结构(查找)
数据结构·学习·算法
分别努力读书3 小时前
acm培训 part 7
算法·图论
武乐乐~3 小时前
欢乐力扣:赎金信
算法·leetcode·职场和发展
Jared_devin3 小时前
数据结构——模拟栈例题B3619
数据结构
'Debug3 小时前
算法从0到100之【专题一】- 双指针第一练(数组划分、数组分块)
算法
sushang~3 小时前
leetcode21.合并两个有序链表
数据结构·链表
Fansv5873 小时前
深度学习-2.机械学习基础
人工智能·经验分享·python·深度学习·算法·机器学习
yatingliu20195 小时前
代码随想录算法训练营第六天| 242.有效的字母异位词 、349. 两个数组的交集、202. 快乐数 、1. 两数之和
c++·算法
uhakadotcom5 小时前
Google DeepMind最近发布了SigLIP 2
人工智能·算法·架构
sjsjs115 小时前
【数据结构-并查集】力扣1202. 交换字符串中的元素
数据结构·leetcode·并查集