常见排序算法总结

上次面试时被问到了排序算法,还有复杂度,稳定性。很多都忘了,写一篇总结备忘。总共10种排序算法。

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 希尔排序
  • 归并排序
  • 快速排序
  • 堆排序
  • 计数排序
  • 桶排序
  • 基数排序

概览

Sorting algorithm - Wikipedia

Name Best Average Worst Memory Stable
冒泡排序 n n^2 n^2 1 Yes
选择排序 n^2 n^2 n^2 1 No
插入排序 n n^2 n^2 1 Yes
希尔排序 nlogn n^(4/3) n^(3/2) 1 No
归并排序 nlogn nlogn nlogn n Yes
快速排序 nlogn nlogn n^2 logn No
堆排序 nlogn nlogn nlogn 1 No
计数排序 - n+r n+r n+r Yes
桶排序 - n+n^2/k+k n^2 n+k Yes
基数排序(LSD) d*(n+b) d*(n+b) d*(n+b) n+b Yes

1、冒泡排序

package MySort

// 首字母大写暴露给别的包调用
func BubbleSort(nums []int) []int {
	for i := 0; i < len(nums); i++ {
		for j := 0; j < len(nums)-i-1; j++ {
			if nums[j] > nums[j+1] {
				nums[j], nums[j+1] = nums[j+1], nums[j]
			}
		}
	}
	return nums
}

算法的思路是连续地比较相邻的两个数,把较大的数放到后面,这样一轮下来最大的数就在最后面。下一轮就去掉上一轮最后那个位置,因为已经是最大的了,不需要再与前面的数字比较。这样每一轮比较会产生一个最大值,第一次是最大数放在最后一位,第二次是第二大放在倒数第二位,以此类推。总共n轮比较,每一轮比较的次数依次为: n, n-1, n-2, ... , 1,所以时间复杂度为O(n^2)

  • 上述代码没有实现最好时间复杂度O(n),如果要实现需要判断一轮比较下来是否发生了交换
  • 最好的情况是刚开始就是已经排好序的,第一轮就发现没有发生交换,直接结束程序,遍历的时间复杂度为O(n)
  • 之所以是稳定的排序,是因为只有当严格大于时才发生交换,那么当两个数相等时就不会发生交换。并且交换的两个数总是相邻的,也就不会因为两个数以及两数中间的数而产生不稳定。

2、选择排序

package MySort

func SelectionSort(nums []int) []int {
	// 每次从数组中找到最大的放到最后
	for maxLen := len(nums) - 1; maxLen > 0; maxLen-- {
		maxNumIdx := 0
		for i := 1; i <= maxLen; i++ {
			if nums[maxNumIdx] < nums[i] {
				maxNumIdx = i
			}
		}
		nums[maxLen], nums[maxNumIdx] = nums[maxNumIdx], nums[maxLen]
	}
	return nums
}

算法的思路是每次从数组中找一个最大值,放到最后,下一次找的范围减少1。复杂度计算上和冒泡排序类似。但是由于每次只找最大的,无法知道当前数组是否已经有序,所以无论怎样最终的比较次数都是固定的。

选择排序的不稳定性在于每次把最大的数放在最后,原来在最后的那个数会放在最大数原来的那个位置,可能会在两个数与两数中间的数之间发生不稳定。例如[5,1,1],第一轮找最大的数找到5,5和最后一个1交换,那么两个1的相对顺序就变了,所以不稳定。

关于选择排序使用数组和链表时是否稳定的讨论:https://www.zhihu.com/question/20926405

链表的话就不是交换,而是把排好序的串成一串,也就不存在两个数交换与两数之间的数产生不稳定。

3、插入排序

func InsertSort(nums []int) []int {
	// uint 二进制所有位都为0
	// 二进制的首位为符号位,0表示正数,1表示负数。因此对uint(0)按位求反,再向右移动一位(将首位的1右移后首位变为0),
	// 得到最大值。同理,对最大值按位求反,其二进制首位变为1,其余位变为0,得到最小值。
	// 最大值 int(^uint(0) >> 1)
	// 最小值 ^int(^uint(0) >> 1)  第一位为1,其余为0,最大值取反即可,补码?

	// 对nums进行append,传入的原数组不改变
	// 1. 如果nums的cap够用,则会直接在nums指向的数组后面追加元素,返回的slice和原来的slice是同一个对象。显然,这种情况下原来的slice的值会发生变化!
	// 2. 如果nums的cap不够用(上述代码就是这种情况),则会重新分配一个数组空间用来存储数据,并且返回指向新数组的slice。这时候原来的slice指向的数组并没有发生任何变化!
	nums = append([]int{^int(^uint(0) >> 1)}, nums...)
	for i := 1; i < len(nums); i++ {
		for j := i - 1; j >= 0; j-- {
			if nums[i] > nums[j] {
				// 把i插在j+1的位置
				tmp := nums[i]
				// 把nums[j+1:i] 复制到 nums[j+2:i+1]
				copy(nums[j+2:i+1], nums[j+1:i])
				nums[j+1] = tmp
				break
			}
		}
	}
	return nums[1:]
}

算法的思路是你玩扑克牌抽牌时如何保持手中的牌是有序的。每一轮抽到新的牌,你会把它放到合适的位置,以保持手中的牌有序。对于插入排序算法来说,我们也模拟这个过程。与抽牌不同的是我们一开始就已经有了所有牌,但是我们仍然按照抽牌的形式,左边排好序的数组相当于手牌,右边则相当于要抽的牌。也就是说,每一轮抽牌,我们手中有序的数组长度会加1。每一轮到来的新数字依次和它前面的数字比较,直到找到合适的位置。最好的情况是数组有序,每次抽到的牌和它前面的比较,发现比前面的数大,就应该放在它后面,也就是原来所在的位置,那么这一轮就结束了,继续抽下一张牌。每一张牌比较一次后,就放在最后开始下一轮,时间复杂度O(n)。

这个排序过程是稳定的。在算法中,每个数字都是往已经排好序的数组中插入,在插入之前,数字位置还没有发生变化时,它前面的数字一直在它前面。想象成在排一个队,你前面的人按照高低重新排列,但是他们一开始就在你前面,现在还在你前面。此时,没有发生不稳定。当数字往前插入时,我们只要保证只插入到严格大于到位置,就能保证相同的数字仍然保持原来的相对顺序。

4、希尔排序

package MySort

func insertSort(nums []int, startIdx, gap int) {
	// 对第i位进行插入
	for i := startIdx + gap; i < len(nums); i += gap {
		// 判断是否插在首位
		if nums[i] < nums[startIdx] {
			tmp := nums[i]
			for k := i; k > startIdx; k -= gap {
				nums[k] = nums[k-gap]
			}
			nums[startIdx] = tmp
			continue // 不需再进行比较
		}
		// 依次向前遍历j
		for j := i - gap; j >= 0; j -= gap {
			if nums[i] < nums[j] {
				continue
			} else {
				// 第i个数字应放在j+gap
				tmp := nums[i]
				for k := i; k > j+gap; k -= gap {
					nums[k] = nums[k-gap]
				}
				nums[j+gap] = tmp
				break // 插入完毕后就跳出循环
			}
		}

	}
}

func ShellSort(nums []int) []int {
	gap := (len(nums) + 2 - 1) / 2 //向上取整
	for gap > 0 {
		for i := 0; i < gap; i++ {
			insertSort(nums, i, gap)
		}
		gap = gap / 2
	}
	return nums
}

希尔排序(Shell's Sort)是插入排序的一种又称"缩小增量排序"(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。
算法的思路是进行隔位的插入排序,所谓的隔位就像站队分组时先报数,单数站一队,双数站一队。这里我们可以隔更多位,比如1、5、9一组,2、6、10一组等等。(同余?)将同一分组的数进行插入排序,然后下一次减小间距,直到最后没有间距,也就是只有一个组,也就是纯粹的插入排序。
为什么比插入排序好?我们知道插入排序最差的情况是逆序,每次都插入到第一个位置,都要跟当前序列的所有数比较。而最好情况则是本来就有序,每次都插入到序列的末尾,只比较一次。我们可以隐约看出来,如果一个序列是大致有序的,那么进行插入排序时的时间复杂度就不会很高。这就是希尔排序的出发点。
稳定性上,可以看出来由于进行了隔位的插入排序,隔位两头的元素和中间的元素可能相等,进而发生不稳定性。

5、归并排序

func merge(Nums1, Nums2 []int) []int {
	// int(^uint(0) >> 1) 是最大值
	nums1 := make([]int, len(Nums1)+1) // 不新建的话会影响原slice,不指定大小无法copy
	nums2 := make([]int, len(Nums2)+1)
	copy(nums1, Nums1)
	copy(nums2, Nums2)
	nums1[len(nums1)-1] = int(^uint(0) >> 1)
	nums2[len(nums2)-1] = int(^uint(0) >> 1)
	var nums []int
	i := 0
	j := 0
	for {
		if i == len(nums1)-1 && j == len(nums2)-1 {
			break
		}
		if nums1[i] < nums2[j] {
			nums = append(nums, nums1[i])
			i++
		} else {
			nums = append(nums, nums2[j])
			j++
		}
	}
	return nums
}
func MergeSort(nums []int) []int {
	if len(nums) < 2 {
		return nums
	}
	mid := len(nums) / 2
	nums1 := MergeSort(nums[:mid])
	nums2 := MergeSort(nums[mid:])
	nums = merge(nums1, nums2)
	return nums
}

并行版本(虽然更慢,可能是因为我写的代码太烂了)

package MySort

import (
	"sync"
)

func merge_parallel(Nums1, Nums2 []int) []int {
	// int(^uint(0) >> 1) 是最大值
	nums1 := make([]int, len(Nums1)+1) // 不新建的话会影响原slice,不指定大小无法copy
	nums2 := make([]int, len(Nums2)+1)
	copy(nums1, Nums1)
	copy(nums2, Nums2)
	nums1[len(nums1)-1] = int(^uint(0) >> 1)
	nums2[len(nums2)-1] = int(^uint(0) >> 1)
	var nums []int
	i := 0
	j := 0
	for {
		if i == len(nums1)-1 && j == len(nums2)-1 {
			break
		}
		if nums1[i] < nums2[j] {
			nums = append(nums, nums1[i])
			i++
		} else {
			nums = append(nums, nums2[j])
			j++
		}
	}
	return nums
}

func get_merge_count(n int) int {
	if n <= 1 {
		return 0
	}
	left := n / 2
	right := n - left
	return get_merge_count(left) + get_merge_count(right) + 1
}
func MergeSort_Parallel(nums []int) []int {
	var wg sync.WaitGroup

	merge_queue := make(chan []int, 16)
	for i := 0; i < len(nums); i++ {
		go func(i int) {
			merge_queue <- nums[i : i+1]
		}(i)
	}
	MERGE_COUNT := get_merge_count(len(nums))
	for i := 0; i < MERGE_COUNT; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			nums1 := <-merge_queue
			nums2 := <-merge_queue
			merge_queue <- merge_parallel(nums1, nums2)
		}()
	}
	wg.Wait()
	return <-merge_queue
}

算法的思路是分治(divide and conquer)。一个有序数组由两个有序数组合并而成,而两个有序的数组又各自由两个的有序数组合并成。最终,一组只剩一个数,自然是有序的,然后向上合并,两个有序的数组(每个只有一个数)合并成一个有序数组(两个数),然后再不断向上。最终整个数组有序。
可以想象一棵二叉树,高度是logn,每一层需要比较n次(不是严格的n次,但是最好和最差情况都是O(n)),所以时间复杂度为O(nlogn)。并且这个递归的过程不因数组有序或无序而改变,总要从一个长数组递归到单一的一个数组,再一层一层合并成一个有序数组。所以最好、最坏和平均时间复杂度都是O(nlogn)
空间复杂度O(n)是因为合并数组时需要辅助空间,比如两个1/2的数组要合并成1的数组,相较于原数组空间多出一份。如果考虑递归栈则再加logn,不过logn阶数没有n高。
合并两个有序数组可以保持稳定性。可以回想上面的插入排序,一个新数字插入有序数组时,也是合并两个有序数组。
我好像发现规律了,只要可能发生交换的两个元素中间没有无序的元素,就是稳定的。

6、快速排序

package MySort

// [startIdx, endIdx]
func quick_sort(nums []int, startIdx, endIdx int) {
	if startIdx > endIdx {
		return
	}
	i := startIdx
	j := endIdx
	for {
		if i >= j {
			break
		}
		// 顺序不能反吗
		// 例子:[2, 1, 3]
		for {
			if nums[j] >= nums[startIdx] && i < j {
				j--
			} else {
				break
			}
		}
		for {
			if nums[i] <= nums[startIdx] && i < j {
				i++
			} else {
				break
			}
		}
		if j > i {
			nums[i], nums[j] = nums[j], nums[i]
		}
	}
	nums[startIdx], nums[i] = nums[i], nums[startIdx]
	quick_sort(nums, startIdx, i-1)
	quick_sort(nums, i+1, endIdx)

}

func QuickSort(nums []int) []int {
	quick_sort(nums, 0, len(nums)-1)
	return nums
}

算法的思路是选定一个数作为枢轴(pivot)元素,找到它在数组中的正确位置。所谓的正确位置,就是最终排好序时它所在的位置。而这样的位置有什么特点?在此之前的数都比这个数小,在此之后的数都比这个数大。这样,在找到这个位置之后就可以将数组分为两部分,然后递归地对这两部分继续使用快速排序。
可以看出这是一种不稳定的排序方法。根据上面总结的规律:

只要可能发生交换的两个元素中间没有无序的元素,就是稳定的。

在进行枢轴元素的交换时,原位置和新位置之间可能存在相等的元素,进而导致不稳定。
在时间复杂度上,最好和平均都是nlogn。可以想象成一棵二叉树,左右子树分别是枢轴左右两边的数组。当每次递归左右两边长度都相等时是最好的情况,树高是logn,每层比较n次。
而当数组原本就有序或者逆序时是最坏的情况,此时在找到枢轴位置后,将数组分为两部分,一半是剩下的所有元素,另一半则一个元素也没有。并且每次的划分都是这样,那么最终的二叉树将只有左子树或右子树。树高是n,每一层比较n次。
空间复杂度为递归栈的深度,一般认为是O(logn)。当然,上面讨论的最差情况树高是n,此时就是O(n)。

7、堆排序

package MySort

// 建堆方法分为筛选法和插入法,这是筛选法
func buildHeap(nums []int) {
	for i := len(nums) / 2; i >= 0; i-- {
		maxHeapify(nums, i, len(nums))
	}
}

// 插入法建堆
func buildHeap_insert(nums []int) {
	for i := 0; i < len(nums); i++ {
		// 当前插入第i个元素,不断与父元素比较
		p := i
		father := (p - 1) / 2
		for father >= 0 {
			if nums[p] > nums[father] {
				nums[father], nums[p] = nums[p], nums[father]
				p = father
				father = (p - 1) / 2
			} else {
				break
			}
		}
	}
}

func maxHeapify(nums []int, idx int, heap_size int) {
	left := (idx+1)*2 - 1
	right := left + 1
	max_idx := idx
	if left < heap_size {
		if nums[idx] < nums[left] {
			max_idx = left
		} else {
			max_idx = idx
		}
	}
	if right < heap_size {
		if nums[max_idx] < nums[right] {
			max_idx = right
		}
	}
	if idx == max_idx {
		return
	}
	nums[idx], nums[max_idx] = nums[max_idx], nums[idx]
	maxHeapify(nums, max_idx, heap_size)
}

func HeapSort(nums []int) []int {
	buildHeap(nums)
	for heap_size := len(nums); heap_size > 0; heap_size-- {
		nums[0], nums[heap_size-1] = nums[heap_size-1], nums[0]
		maxHeapify(nums, 0, heap_size-1)
	}
	return nums
}

堆排序算法描述及时间复杂度分析 - 知乎

大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列
小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列
算法分为两步,首先要建堆,也就是初始化。当其具有大顶堆的性质时,根节点就是最大的。将根节点拿出来作为已经排好序的元素,然后将另一个节点放到根节点的位置。在实现时一般将根节点与当前堆的最后一个元素交换,这样当前堆作为前半部分,也就是正在处理的,后半部分就是已经排好序的。不断的取出最大元素,然后维护堆的性质,直到只剩最后一个元素。
maxHeapify函数将维护下标为idx的位置,实际上不止该位置,如果发生交换,还需要维护交换位置的堆的性质。具体而言就是,比较idx,idx左孩子,idx右孩子三个值的大小,如果idx是最大的,则已经满足了堆堆性质。否则,将idx与最大元素交换,然后递归地维护原最大元素位置的堆的性质。假设三者最大的元素位置在左孩子(left),那么将idx与left交换,然后递归地维护left,如果left已经是最大值,函数将退出,否则会继续递归。递归的主要作用在于当堆顶元素和最后一个元素发生交换时,为了维护堆的性质,新的堆顶一般不会大于左右节点的值(根节点总是大于左右节点,层数越深,数字越小),那么交换之后堆顶元素为了满足堆的性质应该下沉。递归操作就是为了实现元素下沉到合适的位置。
建堆堆方法有两种。插入法有点像插入排序,新插入的元素不断与父节点比较,直到不再比父节点大。筛选法则是从二叉树的倒数第二层倒着进行maxHeapify,因为是倒着进行的,所以元素不会下沉,只会上升。
筛选法建堆的时间复杂度分析:由于不存在元素下沉,只有上升,所以是O(n),最好最坏都是O(n),具体推导见上面的链接。
插入法建堆的时间复杂度分析:最好情况是O(n)(联想插入排序的最好情况);最差是O(nlogn),每次新插入节点都上升logn,再乘以n个节点。
维护堆一次的时间复杂度,也就是对堆顶元素maxHeapify,复杂度为下降的层数,也就是logn。应该是必然下降logn层,因为根据堆的性质,交换的最后一个元素,最起码应该比他原来的父元素小,而现在他在父元素的头上。所以,这个临时被交换到堆顶的元素,最起码应该下降到他原来父元素的下面,也就是差不多logn层。
总共需要n次维护,时间复杂度为O(nlogn)
容易看出是不稳定的排序,因为当堆顶和最后一个元素交换时,可能会与两者中间的某个元素相等,进而发生不稳定性。
空间复杂度为O(1)。为什么这里也有递归栈,空间复杂度却是O(1)?可能是因为没有栈的挂起操作吧。递归的maxHeapify在函数的最后一句。从两点来理解没有“挂起”:

  • 不需要递归的返回值(求二叉树高度)
  • 递归在最后一句,并且只有一句

想象DFS二叉树时,每次都要DFS左孩子和右孩子,当我们进入DFS左孩子函数时应该记着一会回来还要DFS右孩子,就是这个函数挂起了,一会回来放下。如果二叉树所有节点都只有左孩子,那么就不用挂起了,因为不用再记着回来DFS右孩子。也就类似退化成遍历一维数组了。
理论上任何递归都能改写成用栈实现。上面的maxHeapify也有递归栈,只是我们写成递归是为了好看,完全可以改成不用递归也不用栈的方式。这是我理解的为什么空间复杂度是O(1)

8、计数排序

package MySort

func CountSort(nums []int) []int {
	maxVal := nums[0]
	for _, v := range nums {
		if v > maxVal {
			maxVal = v
		}
	}
	bucket := make([]int, maxVal+1) // +1是因为数组从0开始
	// bucket[i] 表示 等于i的值在nums中有几个
	for _, v := range nums {
		bucket[v] += 1
	}
	sortedIdx := 0
	for i := 0; i < len(bucket); i++ {
		for bucket[i] > 0 {
			nums[sortedIdx] = i
			sortedIdx += 1
			bucket[i] -= 1
		}
	}
	return nums
}

算法假设都是int,统计每个数有几个。统计方法是建立一个大小为maxVal的哈希表,想象成Excel拉了两列表,第一列是数字,第二列是该数字的个数。传统的哈希表是无序的,而这里建立的哈希表是有序的,因为我们按照从小到大的顺序遍历key。为什么能实现这一点?因为我们在1、2、3、4...的数数。
结合上面的代码,用bucket储存每个数字的个数,bucket的大小为数组中最大的数。然后遍历bucket的位置,也就是1、2、3、4...的数数。记录当前的排序位置sortedIdx,然后依次将bucket的下标放到sortedIdx,直到bucket的储存的value为0。也就是说我们排序的数字在这一步转化成了哈希表的键(key),如果同一个值出现了多次,那么他们在排好序时应该相连。
分析时间复杂度:
(1)查找最大值,O(n)
(2)记录每个数字的个数,O(n)
(3)给每个数字排序。这里有两个for循环,但不是相乘的关系,因为每个for循环里面的操作次数不同。如果bucket[i]有数字,那么就需要操作“数字的个数”次。否则是一次。for循环一定会执行len(bucket)次,也就是maxVal次。每次for循环内最少是一步操作,即比较bucket[i] > 0。如果大于零,就是存在这个数字,内层的for最终将执行len(nums)次,也就是数组的大小。所以时间复杂度为O(n+r),n为数组大小,r为bucket大小。
可以看出无论何时都是O(n+r)。不知道为什么维基百科的Best那一列没写。
空间复杂度O(n+r),额外的储存空间是bucket。我感觉应该是n和r的较大值,不过写成O(n+r)也没错,O自动忽略数量级较小的那个。
计数排序是稳定的排序。其实稳定性单看数字而言,对于原版的计数排序是没有意义的。因为在排序时我们将数转换成了哈希表的key,至于稳定性,则是讨论两个相等数字的先后关系。而相等的数在这里是用相同的key来表示的。
我们可以稍作变换来实现稳定性。比如我们对人的年龄进行排序,key是年龄,value是一个数组,按照人出现的先后顺序依次放入。在从bucket往外取数据,也就是排序时,将key对应的数组里的元素从前到后排列。这样就实现了稳定性。

9、桶排序

package MySort

import (
	"sort"
)

func BucketSort(nums []int) []int {
	var MIN, MAX int
	MIN = nums[0]
	MAX = nums[0]
	for _, v := range nums {
		if v < MIN {
			MIN = v
		}
		if v > MAX {
			MAX = v
		}
	}
	BucketSize := (MAX - MIN + 10 - 1) / 10 // 向上取整
	var bucket [][]int
	for i := 0; i < 10; i++ {
		bucket = append(bucket, []int{})
	}
	for _, v := range nums {
		for i := range bucket {
			if v >= MIN+i*BucketSize && v <= MIN+(i+1)*BucketSize {
				bucket[i] = append(bucket[i], v)
				break
			}
		}
	}
	var ret []int
	for _, bkt := range bucket {
		sort.Ints(bkt)
		ret = append(ret, bkt...)
	}

	return ret
}

算法的思路是将数字分到若干个桶里,这里的桶指的是一个范围。然后再对每个桶进行排序,最后依次将所有桶内的元素排列起来。
在分析时间复杂度时,需要考虑桶的个数,对每个桶采用的排序方法,数字在各个桶内的分布情况。网上写啥的都有。空间复杂度和稳定性也是,取决于对每个桶采用的排序算法。
一般认为在桶内使用插入排序,因为默认认为每个桶内元素很少,此时就算是n^2也没多大。而且插入排序稳定。当然,还是那句话,想用什么都行。下面分析使用插入排序时的复杂度。
最坏时间复杂度就是都在一个桶内就是O(n^2)。
平均时间复杂度为O(n+n^2/k+k) = 将值域平均分成 k 块 + 排序 + 重新合并元素
将所有值分配到k个桶里,需要遍历所有的n个值,根据值的大小可以直接计算出放到k个桶中的哪一个。上面的代码写的是每次都遍历了所有桶的值域,实际上可以用整除区间长度的方式来直接算出来位于第几个区间(应该吧)。
每个桶内平均n/k个元素,插入排序的平均复杂度为平方,也就是O(n2/k2),再乘以k个桶,就是O(n^2/k)。
合并所有元素,将k个桶首尾相连需要O(k)。但我总感觉是O(n),根实现方式有关吧,如果是链表首尾相连确实不用遍历所有元素。
空间复杂度则是需要k个桶来存n个元素,为O(n+k)。注意,因为不知道每个桶到底会分配几个元素,所以k个桶是必须的。刚才想岔了,以为桶的区间是平均分的,分进去的元素个数也是平均的(错!)
稳定性则因为使用了插入排序,所以是稳定的。
在维基百科上看到一个优化比较有意思:

A common optimization is to put the unsorted elements of the buckets back in the original array first, then run insertion sort over the complete array; because insertion sort's runtime is based on how far each element is from its final position, the number of comparisons remains relatively small, and the memory hierarchy is better exploited by storing the list contiguously in memory.

很巧妙,把元素放到桶内不排序就进行插入排序。因为插入排序复杂度跟数组的混乱程度有关,而当把数字都放到相应桶内时数组已经不太混乱了。

10、基数排序

package MySort

// 返回数字是几位数
func getDigitCount(n int) int {
	i := 0
	for n != 0 {
		i++
		n /= 10
	}
	return i
}

// 返回数字的每一位数的数组,不够补0
func getDigits(n, MaxDigitNum int) []int {
	var digits []int
	for i := 0; i < MaxDigitNum; i++ {
		digits = append(digits, 0)
	}
	for n != 0 {
		MaxDigitNum--
		digits[MaxDigitNum] = n % 10
		n /= 10
	}
	return digits
}

// 将每一位数合并成一个int
func getNum(digits []int) int {
	n := 0
	for i := 0; i < len(digits); i++ {
		n += digits[i]
		n *= 10
	}
	n /= 10
	return n
}

// 在第idx位进行收集(桶排序)
func sortAtIdx(DigitNums [][]int, idx int) {
	var bucket [10][][]int
	for i := 0; i < len(DigitNums); i++ {
		bucket[DigitNums[i][idx]] = append(bucket[DigitNums[i][idx]], DigitNums[i])
	}
	k := 0
	for i := 0; i < 10; i++ {
		for j := 0; j < len(bucket[i]); j++ {
			DigitNums[k] = bucket[i][j]
			k++
		}
	}
}

func RadixSort(nums []int) []int {
	// 数字的最大位数
	MaxDigitNum := 0
	for _, v := range nums {
		if MaxDigitNum < getDigitCount(v) {
			MaxDigitNum = getDigitCount(v)
		}
	}
	var DigitNums [][]int
	for _, v := range nums {
		DigitNums = append(DigitNums, getDigits(v, MaxDigitNum))
	}
	for i := MaxDigitNum - 1; i >= 0; i-- {
		sortAtIdx(DigitNums, i)
	}
	k := 0
	for _, v := range DigitNums {
		nums[k] = getNum(v)
		k++
	}

	return nums
}

算法有两部分,分配和收集。根据数字的每一位(个十百千万)进行分配,对于10进制数来说就是将数字放到0-9对应的容器(链表好理解一些),比如0作为链表头指针,后面串了一串:90、80、70、100。这是对个位进行了“分配”。收集则是从0到9依次将链表串起来。此时,在当前权重位,及更小的权重位上是有序的。LSD(Least significant digital),最后排最高的权重位。当最高权重位排完时,数组就是有序的了。不难想象,1万块比7万块要少,最高权重位最后排,最终决定一个数的顺序。而在权重位数字相同的情况下,由上一次排序结果,也就是低一位的权重位排序决定。与我们的直观思考方式一样。
时间复杂度分析:每次分配和收集的时间复杂度都是O(n+b)(也就是计数排序的复杂度,同时取决于数组长度n和数字取值范围b)。如果数字总共d位,则需要d次循环,总时间复杂度为O(d*(n+b))。
一般情况忽略b,认为b是常数。
基数排序是稳定的。一方面在于对与每个权重位排序时使用的是计数排序,另一方面在收集时不改变上一次排序的结果。
空间复杂度等于计数排序的空间复杂度,O(n+b),这里n是数组长度,b是每个权重位的取值范围。b代表base,每次有b个链表头,连接着总共n个元素。

posted @ 2022-12-13 16:47  roadwide  阅读(40)  评论(0编辑  收藏  举报