“排序是计算机的核心内容。事实上,从很多方面看,如果没有排序,计算机就不会变成现实。”
《算法之美:指导工作与生活的算法》
排序算法,或许是我们日常最常见也是使用频率最多的算法。比如你在电商网站买东西,推荐商品往往基于相似度或者基于销售量等维度排序。我们每日接收的邮件是程序中按照时间进行排序好的,我们点外卖时候推送的列表是按照评分和地理位置进行排序的。搜索引擎检索内容也是按照一定的相似度算法进行排序好才呈现给你的,所以搜索引擎实际上就是一个排序引擎。
本篇文章通过图例的方式逐一讲解最常使用的七大排序算法,如下表所示,其中前四种为比较排序法,后三种则属于非比较排序。
比较排序法基于元素与元素之间的比较实现排序,这种排序方法的最坏情况理论边界为Θ(nlgn),也就是这类排序的最坏情况必定会大于和等于该时间复杂度。而非比较排序理论最佳的时间可以达到线性的处理时间。
算法 | 最坏运行时间 | 平均运行时间 | 是否稳定 | 原址排序 |
---|---|---|---|---|
插入排序 | Θ(n^2) | Θ(n^2) | 是 | 是 |
归并排序 | Θ(nlgn) | Θ(nlgn) | 是 | 否 |
堆排序 | O(nlgn) | ---- | 否 | 是 |
快速排序 | Θ(n^2) | Θ(nlgn) | 否 | 是 |
计数排序 | Θ(n+k) | Θ(n+k) | 是 | 否 |
基数排序 | Θ(d(n+k)) | Θ(d(n+k)) | 是 | 否 |
桶排序 | Θ(n^2) | Θ(n) | 是 | 否 |
1. 插入排序
插入排序是一种原址(in-place)排序,原址的意思就是对于任何的输入数据序列都可以在只占用常量的额外存储空间来处理临时数据的情况下完成排序。插入排序对于已经排序好的数据可以实现Θ(n)的最佳排序时间,对于逆序的数据序列排序运行时间最差Θ(n^2). 插入排序的示意图如下:
- 黄色代表尚未实现排序的元素
- 绿色代表已经排序好的元素
- 每一轮循环确定一个元素的位置
- 箭头代表元素之间的比较,如果比较发现左边的元素大于右边的该元素,则两个位置进行交换,如果小于则退出本轮循环
插入排序的理解,我们可以想象一下,我们与朋友一起玩扑克牌。在摸牌阶段,从牌堆中一张张的取牌,为了保持手里面一副牌一直保持有序状态,每次都会把新取得的扑克牌与手头的牌进行比较,比较的方式是从右向左,找到合适的位置,插入即可,这就是一种典型的插入排序。唯一需要注意的是,排序算法中的插入实际上是一种逐个交换实现的,而不是直接插到指定的位置。以下就是整个插入排序的源码:
我们上面的表格中提及到插入排序为稳定排序,稳定的意思是,假如一组元素中有多个重复的元素,那么排序后多个重复元素的相对位置不会发生变化。
func insertSort(arr []int){
size := len(arr)
if size == 0 || size == 1{
return
}
for j:=1;j<=size-1;j++{
current := j
for i:=j-1;i>=0;i--{
if arr[current] < arr[i] {
arr[current], arr[i] = arr[i],arr[current]
current = i
}else{
break
}
}
}
}
2. 归并排序
归并排序(MergeSort)利用分治法来实现的排序方式,分治法的思想是:将原问题分解为几个规模较小的但类似于原问题的子问题,递归求解这些子问题,然后再合并这些问题来建立原问题的解。对于归并排序的话:
- 分解: 将待排序的n个元素序列切分为两个n/2的子序列
- 解决: 使用同样的算法归并排序递归排序两个子序列
- 合并: 合并两个已经排序的子序列用以差生已排序的答案
下面就是整个的归并排序的流程,图中黄色为尚未发生改变的元素,绿色代表已经排序好的元素(包含小范围内排序完成)。我们首先对于整个的排序数组进行切分,切分完成后再逐层进行合并,这里合并是整个归并排序的核心算法部分,需要逐一比较待合并的元素并将元素顺序写入到新的临时数组中,假设待合并的数组长度为n, 整个的合并操作为O(2n)的复杂度。
排序算法本身不属于原址排序,在合并阶段需要额外的存储空间来管理中间数据。归并排序的代码如下:
func mergeSort(arr []int) []int{
size := len(arr)
if size < 2 {
return arr
}
left := mergeSort(arr[0:size/2])
right := mergeSort(arr[size/2:])
leftSize := len(left)
rightSize := len(right)
i := 0
j := 0
var result []int
for i < leftSize && j
3. 堆排序
堆排序时间复杂度为O(nlgn), 且是一种原址排序,因此算是同时拥有了上面两种排序算法的优势。堆排序本身需要借助于一种称之为堆的数据结构实现,堆数据结构内部使用数组存储元素即可,无需额外的容器。
堆主要在于对于元素的操作需要按照属性结构完成,可以把堆数据结构可以看做是一个近似完全二叉树。树上每个节点对应数组中的一个元素,除了最底层外,树形结构为完全满的。
比如下面的数组和其对应的堆结构,这是一个大顶堆,也就是根节点元素要比左右节点元素均大。对应的小顶堆就是根元素要比左右节点要小。下面就是数组到二叉树的一种映射关系。
堆数据结构除了实现堆排序外,还可以实现优先队列。针对我们要介绍的堆排序包含以下的几个步骤:
- 构建堆结构(Build), 从一个普通数组转换为具有堆性质的数组
- 维护堆结构(Heapify),只是针对其中一个节点,保持节点以及子树为一个堆的过程,用于维护堆的性质(大顶或小顶),时间复杂度为O(logn)
- 堆排序(HeapSort): 对一个数组实现其原址排序。
我们先来看下维护堆结构(Heapify)的过程,这是一个递归处理的过程, 首先选择一个待处理的节点与子节点执行比较操作,如果均大于子节点,则退出,如果不是的话,与值较大的子节点进行交换,重复此过程还需要考虑交换后的节点。比如下面的实例:
- 我们选择处理的元素为1
- 由于元素1的两个子元素为8和3,均大于1,我们选择最大8与当前的元素1进行交换
- 交换完成后元素1下移到8的位置。
- 比较元素1和新的两个子元素,继续重复相同的流程,直到当前的节点满足大顶的要求
我们构建堆的过程,其实就是对于多个元素逐一执行Heapify的过程,需要执行Heapify的元素占总元素的1/2, 因为叶子节点在根节点执行的时候会被一起处理掉,所以不需要单独的处理。这里我们通过一个示例来完成整个的堆排序的过程。
其中对于上面的第一个二叉树,我们只是将数组映射为了一个树结构,而没有做任何的处理,我们开始构建大顶堆结构的过程需要先知道哪些节点需要执行heapify, 图中用蓝色标记的需要执行,而且需要逆序执行直到根节点。这样执行完成后的结果如第二个二叉树所示。为了实现堆排序,我们需要交换第一个和最后一个元素,然后对于第一个进行heapify操作。执行完成后最大的元素自动的移动到数组的最后一位上。我们下一步操作的数组容量自动减少一位,不再处理最后一个元素。重复上述的过程,直到完成最后一个元素的处理。此时整个数组序列为一个有序的数组,如下图的最后一个二叉树所示。
整个堆排序的处理代码如下所示,其中heapify为辅助函数,heapSort为实际执行排序的函数:
func heapify(arr []int,size int , index int){
left := 2 * index
right := 2 * index +1
max := index-1
if size >= left && arr[max] < arr [left-1]{
max = left-1
}
if size >= right && arr[max] < arr[right-1]{
max = right-1
}
if max != index-1 {
arr[max], arr[index-1] = arr[index-1], arr[max]
heapify(arr, size, max+1)
}
}
func heapSort(arr []int){
size := len(arr)
for i:= size/2; i>=1; i--{
heapify(arr,size, i)
}
lenOfArr := size
for i:=size-1; i >=1 ;i--{
arr[i], arr[0] = arr[0], arr[i]
heapify(arr,lenOfArr-1, 1)
lenOfArr--
}
}
4. 快速排序算法
快速排序算法是一种最坏情况下时间复杂度为θ(n^2)的比较排序算法,期望时间复杂度为θ(nlgn), 其算法本身隐藏的常数因子相对于堆排序和归并排序都要小, 本身是一种原址排序,所以实际使用中较为常见。同归并排序,算法本身也是采用分治法来实现。分治的步骤包括:
- 分解: 数组划分为两个子数组,其中左数组中所有元素都小于等于Arr[x], 右数组中所有元素都大于等于Arr[x], x为开始选择的其中一个元素索引。
- 解决: 通过递归调用快速排序,对于其左数组和右数组进行不断的切分和排序
- 合并: 子数组为原址排序,因此本身就已经是排序好的,无需类似于归并排序一样的合并操作。
对与快速排序算法, 分解阶段,也就是划分子数组的阶段较为复杂,也是整个程序执行的主要步骤,我们通过一个示例的方式来介绍。
对于输入的数组[7,4,1,3,5,2,8,6], 我们选择最左和最右点作为控制节点,内循环从[0, right-1], 选择right作为比较节点。分解的目的就是分成两个数组,大于right=6的节点组和小于等于right=6的节点组。
内循环执行节点不断位移i,当发现较小的值的时候就与left进行交换,这时候left和i增加1, 继续循环,否则只增加i即可。
分解的其余步骤不再给出详细的偏移变化,整个的变化结束后需要将right节点与left节点交换即可,这样左右分别分解完成。
当分解完成后,我们递归的对于上述分解的两个数组执行同样的逻辑即可,最终快速排序将获得一个完全排序好的数据。
整个快速排序的代码其实并不复杂,主要是获取两个数组的过程。假设我们每次获取做比较的都是最大的值,则会产生最差的性能,降级为θ(n^2), 因为每次左数组大小=0,右数组大小=n-1。 比如已经有序的数据也会导致排序性能的下降。
func quickSort(a []int) []int{
size := len(a)
if size < 2 {
return a
}
left, right := 0, size-1
for i:=0; i< size-1; i++{
if a[i] < a[right]{
a[left], a[i] = a[i], a[left]
left++
}
}
a[left], a[right] = a[right], a[left]
quickSort(a[:left])
quickSort(a[left+1:])
return a
}
上面每次都是选择最右端的值进行比较,这在现实的数据序列可能会出现一些有序序列,且这也是一种很常见的模式, 但是对于快速排序则可能导致数据不均衡。为了降低不均衡的可能性,可以采用随机化的方式完成,每次选择一个随机值与最右端的值进行交换,这样可以极大的降低不均衡产生的情况。
func quickSort(a []int) []int{
size := len(a)
if size < 2 {
return a
}
left, right := 0, size-1
pivot := rand.Int() % size
a[pivot], a[right] = a[right], a[pivot]
for i:=0; i< size-1; i++{
if a[i] < a[right]{
a[left], a[i] = a[i], a[left]
left++
}
}
a[left], a[right] = a[right], a[left]
quickSort(a[:left])
quickSort(a[left+1:])
return a
}
另外比较有意思的一点是,有一篇论文专门介绍了如何去构造一个输入,使得快速排序总是产生O(n^2)的排序复杂度,
感兴趣的可以访问:https://www.cs.dartmouth.edu/~doug/mdmspe.pdf
5. 计数排序算法
计数排序算法是一种非比较的排序算法,可以达到线性的排序时间,但是前提就是该排序算法对于输入序列有一定的限制, 它要求对于序列中的每一个元素都是0-k区间内的数据,k为一个整数。至于为什么可以实现线性的复杂度我们可以看下排序的方式:
- 初始化一个[0..k]的计数数组,用于存储n个数中每个不同的数出现的次数
- 循环迭代n数组中的元素,并统计每个数的个数进行累加
- 最后循环计数数组产生输出结果
初始化阶段, 根据输入的范围,比如下面的实例中,所有取值的范围为[0-4],生成对应的计数数组,长度为5,初始值设置为0.
统计阶段: 循环原始数组中的值,并更新计数数组,比如当原始数组为2时候,更新计数数组中countArr[2]+1,最终形成完整的计数数组。
还原阶段: 根据计数数组中数据项和统计计数,生成对应的数组。比如当我们发现countArr[0]=1, 我们就插入一个0到数组,当我们发现countArr[4]=4, 我们就插入四个4到数组。按顺序写入后新的排序树组就产生了。
程序的源代码如下所示:
func countSort(arr []int, max int) []int{
size := len(arr)
if size < 2 {
return arr
}
countArr := make([]int, max+1)
result := make([]int, len(arr))
for _, v := range arr{
countArr[v]++
}
for i:=1; i< max+1 ;i ++{
countArr[i] = countArr[i-1]+countArr[i]
}
for i:=size-1;i>=0;i--{
result[countArr[arr[i]]-1] =arr[i]
countArr[arr[i]]--
}
return result
}
计数排序的时间复杂度为θ(k+n),适用于比如类型统计,取值范围有限的数据序列的统计等,这类数据借助于计数排序可以实现线性的排序时间,同时注意上面的最后一个for循环,我们是按照逆序迭代取值,如果正序取值,可导致整个的排序结果不稳定,也就是本来在前面的元素会被排到后面。这在一些排序中是不可接受的。
6. 基数排序算法
基数排序法也是非比较排序算法的一种,是一种非常古老的排序算法,最早可以追溯到1887年 应用在打孔机设备上。但是尽管比较古老,但是当今仍旧用很多的系统在使用,比如部分大数据分析系统中使用这种排序来完成数据的排序。这种排序方式非常的简单,我们可以通过一个实例来看一下:
我们对于下面的8个三位数进行排序,第一次排序的时候仅仅排序个位数上的数组,形成排序结果。
第二次排序在上一次排序基础上再对于十位数上数字进行排序, 最后一次排序对于百位数进行排序。我们排序完成后可以看到整个的排序已经完成了。基数排序就是这么简单。假设我们最大位数为d,每一个位数均有k个可选值。则基数排序的时间复杂度为θ(d(n+k))。 因为每一次排序的过程都需要借助于计数排序的方式,计数排序为θ(n+k), 而这样排序需要进行d轮。
程序源代码如下所示: 这里需要注意的是如果我们能够直到确定的范围,也就不需要执行一次完整O(n)的查找最大值的过程,并且最外层for循环实际执行的是计数排序的过程,并将结果保存在一个中间数组中。每次循环向前执行一位,最终完成排序。
func radixSort(arr []int)[]int {
largestNum := findLargestNum(arr)
size := len(arr)
significantDigit := 1
semiSorted := make([]int, size, size)
for largestNum / significantDigit > 0 {
bucket := [10]int{0}
for i := 0; i < size; i++ {
bucket[(arr[i] / significantDigit) % 10]++
}
for i := 1; i < 10; i++ {
bucket[i] += bucket[i - 1]
}
for i := size - 1; i >= 0; i-- {
bucket[(arr[i] / significantDigit) % 10]--
semiSorted[bucket[(arr[i] / significantDigit) % 10]] = arr[i]
}
for i := 0; i < size; i++ {
arr[i] = semiSorted[i]
}
significantDigit *= 10
}
return arr
}
由于本身并非原址排序算法,所以对于内存有限的环境下,基数排序并非一种好的选择,同时基数排序尽管可以达到线性运行时间,执行的循环次数少,但是本身由于每轮执行的时间相对于比如快速排序要更长一些,所以排序效率依旧需要根据实际的特性(环境,数据集合,硬件等)而定。
7. 桶排序算法
桶排序和计数排序一样,也是一种非比较排序算法, 计数排序要求数据集合是一个小区间的整数集合,而桶排序则要求数据集合可以进行范围切分的集合,这种假设也是对于桶排序算法的一种实用约束条件。比如数据都是属于0-1范围内, 那么我们可以将这段范围切分多个大小相同的子区间, [0,0.1), [0.1, 0.2)… [0.9,1)十个子区间,这些子区间称之为桶。每个区间包含一个链表结构(或其他数据存储结构),存储属于这个范围内的数据。循环读取数据并将数据写入对应的链表中,结束后对于链表进行排序。再循环写出数据到结果集合。程序执行的示意图:
这里针对上述实例的桶排序的算法程序如下面所示:
func bucketSort(arr []float64){
buckets := make([][]float64,10)
for _, v := range arr{
bucketIndex := int(v * 10) % 10
buckets[bucketIndex]= append(buckets[bucketIndex], v)
}
for _ , bucket := range buckets{
sort.Float64s(bucket)
}
currentIndex := 0
for _, bucket := range buckets{
for _, v := range bucket{
arr[currentIndex] = v
currentIndex++
}
}
}
这里我们简化了一下,使用内置的slice来代替链表,从而实现更简单的编码,实际使用中需要考虑针对链表或者数组进行排序的代价,从而选择更合适的数据结构存储。同时选择合适的排序算法也很重要,比如数据集合比较集中则使用插入排序这种算法可能会导致θ(n^2)的时间排序复杂度。
大家感兴趣的话也可以扫描下面的二维码,获取更多算法内容,欢迎大家订阅和转发。