前言
八大排序包含:冒泡排序、选择排序、插入排序、快速排序(快排)、归并排序、基数排序、希尔排序、堆排序。
本文主要讲述前5种,其中,建议冒泡排序、选择排序、插入排序可以放在一起对比;快速排序和归并排序可以放在一起对比。
注:本文的排序采用Golang实现。
排序基础
1.时间复杂度、空间复杂度以及大O表示法,这里不再赘述
2.线性时间和非线性时间:线性时间可理解为线性函数,指数为1,比如O(n); 非线性时间可理解为非线性函数,指数不为1,比如O(n2)
3.稳定排序和非稳定排序:
- 3.1 稳定排序:当a=b,a在b之前,排序后a仍然在b之前
- 3.2 非稳定排序:当a=b,a在b之前,排序后a可能在b之后
4.原地排序和非原地排序:原地排序就是指不申请多余的空间来进行的排序,只在原来的排序数据中比较和交换。非原地排序则相反。
排序算法比较
排序算法 | 时间复杂度(平均) | 时间复杂度(最好) | 时间复杂度(最坏) | 是否稳定 | 是否原地排序 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | 稳定 | 原地 |
选择排序 | O(n2) | O(n2) | O(n2) | 非稳定 | 原地 |
插入排序 | O(n2) | O(n) | O(n2) | 稳定 | 原地 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | 非稳定 | 原地 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | 稳定 | 非原地 |
冒泡排序
冒泡排序用一句话来总结就是:从左至右,对数组中相邻的两个元素进行比较,将较大的放到后面。
假设有一个数组:[5, 4, 3, 2, 1]
,使用冒泡排序的话,会先将第一个数(5)与第二个数比较(4),由于 5>4
,所以会互换位置
;接着第二个数(5)与第三个数(3)比较,依次类推,从而最大的数会一直移动到最右边。
这里列举下冒泡排序的两种实现方式,这两种方式的最坏和最优的时间复杂度不同。
冒泡实现一
func bubbleSort1(arr []int) []int {
// 代码中会大量用到len(arr),为了减少重复计算,可以用一个变量来存储
n := len(arr)
for i := 0; i < n-1; i++ {
for k := 0; k < n-1-i; k++ {
if arr[k] > arr[k+1] {
arr[k], arr[k+1] = arr[k+1], arr[k]
}
}
}
return arr
}
总结:这种是比较常见的冒泡排序,最坏和最优时间复杂度都为O(n2)。
冒泡实现二
func bubbleSort2(arr []int) []int {
n := len(arr)
for i := 0; i < n-1; i++ {
flag := 0
for k := 0; k < n-1-i; k++ {
if arr[k] > arr[k+1] {
arr[k], arr[k+1] = arr[k+1], arr[k]
flag += 1
}
}
// 当遍历了一遍,flag仍然为0,说明原数组本身有序,所以不用再遍历,复杂度为O(n)
if flag == 0 {
break
}
}
return arr
}
总结:这种是改良过的,最坏时间复杂度为O(n2),当数组本身有序的情况下,最优时间复杂度为O(n)
选择排序
选择排序用一句话来总结就是:从第一个位置开始,与后面的数进行比较,找出最小的,并和第一个位置互换(从而最小的数都会在最左边)。
假设有一个数组:[5, 4, 3, 2, 1]
,使用选择排序的话,会将数字5分别和数字4,3,2,1进行比较,第一轮结束后,数组变为:[1, 5, 4, 3, 2]
。
代码实现如下:
func selectSort(arr []int) []int {
n := len(arr)
for i := 0; i < n; i++ {
// k=i+1,表示下一个数
for k := i + 1; k < n; k++ {
if arr[i] > arr[k] {
arr[i], arr[k] = arr[k], arr[i]
}
}
}
return arr
}
总结: 选择排序的最坏、最优复杂度都是O(n2),因为遍历一次只能知道哪个数最小,无法节省遍历次数,所以最坏最优都是O(n2)。
冒泡排序 VS 选择排序
之前的我,对冒泡排序和选择排序有点傻傻分不清,经过最近的整理,总结出它们的几点区别:
1.比较方式不同:冒泡排序是相邻比较,选择排序是第一个数与其他数进行比较,具体如下:
![34fa22db6a8fcbe0ff5c890d61afbd5c.png](https://img-blog.csdnimg.cn/img_convert/34fa22db6a8fcbe0ff5c890d61afbd5c.png)
2.时间复杂度不同:在特殊的情况下,冒泡排序可以达到O(n)
的复杂度;而选择排序的复杂度永远为O(n2)
3.冒泡排序是稳定排序,选择排序是非稳定排序。以一个数组 [4, 6, 4, 3, 3]为例,在第一轮比较的时候,我们知道第一个4会和第一个3交换位置,从而原数组中2个4的前后顺序就被破坏了,所以选择排序不是稳定排序算法。
插入排序
插入排序有点像我们斗地主时,一般会对手中的牌进行排序。
还是以数组 [5, 4, 3, 2, 1]
为例,可以将其看做5张牌:
- 1.一开始拿到数字5,没有人和它比较,所以它放在最左边(最左边我们放最小的)
- 2.接着拿到数字4,和5做比较,发现比5小,所以手中的牌变成 [4, 5]
- 3.接着拿到数字3,和5比较后交换,然后和4比较,也交换,所以手中的牌变成 [3, 4, 5]
- 4.接着拿到数字2和数字1,以此类推
可以看到,插入排序是将一个值插入到一个已经排好序的数组,并一直持续到所有值插入完毕,最终得到一个新的有序数组。
这里也列举插入排序的两种实现方式,最坏最优时间复杂度也不同。
插入排序实现一
func insertSort1(arr []int) []int {
n := len(arr)
for i := 1; i < n; i++ {
idx := i
for idx > 0 {
if arr[idx] < arr[idx-1] {
arr[idx], arr[idx-1] = arr[idx-1], arr[idx]
}
// 通过这里,我们可以看到插入排序是往后比较的
idx -= 1
}
}
return arr
}
总结:插入排序的常规写法,最优复杂度和最坏复杂度为O(n2)
插入排序实现二
func insertSort2(arr []int) []int {
n := len(arr)
for i := 1; i < n; i++ {
idx := i
for idx > 0 {
if arr[idx] < arr[idx-1] {
arr[idx], arr[idx-1] = arr[idx-1], arr[idx]
idx -= 1
// 当要插入的值,比排好序的数组的最后一个值大,则不用再比较
} else {
break
}
}
}
return arr
}
总结:插入排序的一种优化实现,最坏复杂度为O(n2),当数组本身有序的情况下,最优复杂度为O(n), 有点类似冒泡排序。
选择排序 VS 插入排序
1.比较方式不同:个人对比后觉得可以将选择排序理解为往前比较,而插入排序是往后比较。
2.时间复杂度不同:在特殊的情况下,插入排序可以达到O(n)
的复杂度;而选择排序的复杂度永远为O(n2)
3.插入排序是稳定排序,选择排序是不稳定排序。
快速排序
快速排序,又称分区交换排序(partition-exchange sort),其实个人觉得用分区交换排序更贴近这种排序的思想。
关于快速排序这个命名,个人猜测是因为这句话 "快速排序 O(nlog n) 通常明显比其他算法更快",所以才有了这个名字。
快速排序有以下三个步骤:
- 1.挑选基准值
- 2.对数组进行划分:所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(与基准值相等的数可以到任何一边)
- 3.递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。
快速排序有两种版本,一种是原地排序的,但是不太好理解;一种不是原地排序的,但是相对好理解。
非原地排序版本
func quickSort(arr []int) []int {
n := len(arr)
if n <= 1 {
return arr
}
left, right := []int{}, []int{}
// 挑选基准值,这里以第一个元素为基准值
base := arr[0]
arr = arr[1:]
// 对数组进行分割
for _, num := range arr {
if num < base {
left = append(left, num)
}else{
right = append(right, num)
}
}
// 这两行代码等价于 quickSort(left) + [base] + quickSort(right)
result := append(quickSort(left), base)
result = append(result, quickSort(right)...)
return result
}
总结:这种实现会申请多余的空间(left、right两个数组),所以不是原地排序
原地排序版本
// 对数组进行划分,并返回下次划分的基准值
func partition(arr []int, startIdx, endIdx int) int {
// 基准值
base := arr[endIdx]
i := startIdx - 1
// 这一步把小于基准值的元素都挪到前面
for j := startIdx; j < endIdx; j++ {
if arr[j] < base {
i++
arr[j], arr[i] = arr[i], arr[j]
}
}
// 这一步把基准值放到比它小的元素的后面
arr[i+1], arr[endIdx] = arr[endIdx], arr[i+1]
return i + 1
}
func quickSort2(arr []int, startIdx, endIdx int) {
// 递归截止条件
if startIdx >= endIdx {
return
}
p := partition(arr, startIdx, endIdx)
quickSort2(arr, startIdx, p-1)
quickSort2(arr, p+1, endIdx)
}
// 如果对partition函数不理解的,建议使用下面的数组调试一遍,应该会更理解一些
func main() {
arr := []int{3, 2, 1, 6, 8, 5, 4}
quickSort2(arr, 0, len(arr) - 1)
fmt.Println(arr)
}
总结:这种实现并不会申请多余的空间,所以是原地排序。
最后,当基准值(base)取中间数时,复杂度最优,为O(log2n),当基准值一直为最小/最大时,复杂度最坏, 为O(n2)。(注意这里的中间数,以数组[5, 4, 1, 3, 2]为例,中间数指的是3,而不是1)
归并排序
如果你对快速排序有一定了解,初看归并排序的时候会觉得这两个排序算法非常像,这也是我为什么建议大家可以将两者进行对比。
归并排序的思想是将一个数组划分为两部分,然后对两部分进行排序,接着将排序后的两部分进行合并,合并的时候需要新建一个临时数组保存合并的结果。
// 合并
func merge(left []int, right []int) []int {
leftIdx, rightIdx := 0, 0
result := []int{}
for leftIdx < len(left) && rightIdx < len(right) {
if left[leftIdx] <= right[rightIdx] {
result = append(result, left[leftIdx])
leftIdx += 1
} else {
result = append(result, right[rightIdx])
rightIdx += 1
}
}
// 上述for循环有两种情况:
// 1.left数组和right数组长度一致
// 2.left数组和right数组长度不一致,则必定有一方有多余元素,需要把这些多余元素复制到队尾
result = append(result, left[leftIdx:]...)
result = append(result, right[rightIdx:]...)
return result
}
func mergeSort(arr []int) []int {
n := len(arr)
if n <= 1 {
return arr
}
split := n / 2
left := mergeSort(arr[:split])
right := mergeSort(arr[split:])
return merge(left, right)
}
总结:归并排序划分部分时间复杂度为O(logn),合并部分时间复杂度为O(n),所以总时间复杂度为O(nlogn)
归并排序 VS 快速排序
1.基准值:归并排序并不需要基准值,而快速排序需要一个基准值
2.稳定性:归并排序是稳定排序,但不是原地排序;快速排序是非稳定排序,但可以实现原地排序。