概览
快速排序:是目前基于比较的内部排序中最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短
算法实现
插入排序
思想:先将序列的第1个记录看成是一个有序的子序列,然后从第2个记录开始逐个进行插入,直至整个序列有序为止
时间复杂度:
O
(
N
2
)
O(N^2)
O(N2)
直接插入排序示例:
如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的
func InsertSort(nums []int) {
for i := 1; i < len(nums); i++ {
j := i
for j > 0 && nums[j-1] > nums[j] {
nums[j-1], nums[j] = nums[j], nums[j-1]
j--
}
}
}
希尔排序
是对插入排序的改进,又称为缩小增量排序
思想:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
伪代码:
- 选择一个增量序列 { t 1 , t 2 , … , t k } \{t_1, t_2, …, t_k\} {t1,t2,…,tk},其中 t i > t j , t k = 1 t_i>t_j, t_k=1 ti>tj,tk=1;可以选择 { 3 n + 1 , . . . , 4 , 1 } \{3n+1,...,4,1\} {3n+1,...,4,1}
- 按增量序列个数k,对序列进行 k 趟排序;
- 每趟排序,根据对应的增量 t i t_i ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。当增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
希尔排序示例:
希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于增量因子序列d的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。大致比
O
(
N
2
)
O(N^2)
O(N2)好一些,为
O
(
N
1
+
δ
)
,
δ
∈
[
0
,
1
]
O(N^{1+\delta}),\delta \in [0,1]
O(N1+δ),δ∈[0,1]
希尔排序方法是一个不稳定的排序方法,由于相同元素会在各自的插入排序中打乱位置。
func ShellSort(nums []int) {
// 计算增量序列
k := 1
for k < len(nums)/3 {
k = 3*k + 1
}
for k > 0 {
for i := k; i < len(nums); i++ {
j := i
for j >= k && nums[j-k] > nums[j] {
nums[j-k], nums[j] = nums[j], nums[j-k]
j -= k
}
}
k /= 3
}
}
简单选择排序
思想:在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数当中再找最小(或者最大)的与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止
时间复杂度:
O
(
N
2
)
O(N^2)
O(N2)
简单选择排序示例:
效率较高的实现是不稳定的,但是需要额外的空间来实现稳定排序。
(每次从未排序部分选择第一个最小元素后不与未排序部分第一个元素交换,而是插入到未排序部分第一个元素之前,这样是稳定的)
func SelectSort(nums []int) {
for i := 0; i < len(nums)-1; i++ {
index := i
for j := i + 1; j < len(nums); j++ {
if nums[j] < nums[index] {
index = j
}
}
nums[index], nums[i] = nums[i], nums[index]
}
}
堆排序
思想:通过先对数组处理形成最大堆的一维数组形式,然后将第一个元素与最后一个元素交换,并对第一个元素到倒数第二个元素之间进行下沉操作,这样前n-1个元素形成最大堆的一维数组形式,第n个元素为前n个数的最大值;依次处理后即得排序。
时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
作为一种树形选择排序,也是不稳定的
注意这里索引是k,则左子节点的索引是2k,右子节点的索引是2k+1,其父节点是(k-1)//2
func HeapSort(nums []int) {
for i := len(nums)/2 - 1; i >= 0; i-- {
sink(nums, i, len(nums))
}
for i := len(nums) - 1; i > 0; i-- {
nums[0], nums[i] = nums[i], nums[0]
sink(nums, 0, i)
}
}
func sink(nums []int, k int, N int) {
for 2*k+1 < N {
index := 2*k + 1
if 2*k+2 < N && nums[2*k+2] > nums[2*k+1] {
index = 2*k + 2
}
if nums[index] > nums[k] {
nums[index], nums[k] = nums[k], nums[index]
k = index
} else {
break
}
}
}
冒泡排序
思想:每次都通过两两比较的方式,将最大的一个泡泡置于末尾
时间复杂度:
O
(
N
2
)
O(N^2)
O(N2)
冒泡排序是一种稳定排序
冒泡排序示例:
func BubbleSort(nums []int) {
for i := len(nums); i > 1; i-- {
for j := 0; j < i-1; j++ {
if nums[j+1] < nums[j] {
nums[j+1], nums[j] = nums[j], nums[j+1]
}
}
}
}
快速排序
思想:
-
选择一个基准元素,通常选择第一个元素或者最后一个元素
-
通过一趟排序将待排序的记录分割成独立的两部分,其中一部分记录的元素值均比基准元素值小。另一部分记录的元素值比基准值大
-
此时基准元素在其排序后的正确位置
-
然后分别对这两部分用同样的方法继续进行排序,直到整个序列有序。
一趟快速排序示例:
时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
快速排序是不稳定的
func QuickSort(nums []int) {
if len(nums) <= 1 {
return
}
lo := 0
hi := len(nums) - 1
tmp := nums[lo]
for lo < hi {
for lo < hi && nums[hi] >= tmp {
hi--
}
nums[lo] = nums[hi]
for lo < hi && nums[lo] <= tmp {
lo++
}
nums[hi] = nums[lo]
}
nums[lo] = tmp
QuickSort(nums[:lo])
QuickSort(nums[lo+1:])
}
若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序
归并排序
思想:归并排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列
归并排序示例:
时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
归并排序是稳定的
func MergeSort(nums []int) {
tmp := make([]int, len(nums))
MergeSortAssist(nums, 0, len(nums)-1, tmp)
}
func MergeSortAssist(nums []int, lo int, hi int, tmp []int) {
if lo >= hi {
return
}
mid := (hi-lo)/2 + lo
MergeSortAssist(nums, lo, mid, tmp)
MergeSortAssist(nums, mid+1, hi, tmp) // 这里必须是mid+1
for i := lo; i <= hi; i++ {
tmp[i] = nums[i]
}
left := lo
right := mid + 1
for i := lo; i <= hi; i++ {
if left == mid+1 {
nums[i] = tmp[right]
right++
} else if right == hi+1 {
nums[i] = tmp[left]
left++
} else {
if tmp[left] > tmp[right] {
nums[i] = tmp[right]
right++
} else { // 两数相等的时候取左边,所以稳定
nums[i] = tmp[left]
left++
}
}
}
}
桶排序
思想:把数据分组,放在一个个的桶中,然后对每个桶内部再进行排序
例如要对大小为 [ 1 , 1000 ] [1,1000] [1,1000]范围内的n个整数 { a 1 . . . a n } \{a_1...a_n\} {a1...an}排序
- 首先,可以把桶设为大小为10的范围,具体而言,设集合 B 1 B_1 B1存储 [ 1 , 10 ] [1,10] [1,10]的整数,集合 B 2 B_2 B2存储 ( 10 , 20 ] (10,20] (10,20]的整数,集合 B i B_i Bi存储 ( ( i − 1 ) ∗ 10 , i ∗ 10 ] ((i-1)*10, i*10] ((i−1)∗10,i∗10]的整数, i = 1 , 2 , . . . , 100 i = 1,2,...,100 i=1,2,...,100。总共有 100个桶。
- 然后,对 { a 1 . . . a n } \{a_1...a_n\} {a1...an}从头到尾扫描一遍,把每个 a i a_i ai放入对应的桶 B j B_j Bj中。 再对这100个桶中每个桶里的数字排序,这时可用冒泡,选择,乃至快排,一般来说任何排序法都可以。
- 最后,依次输出每个桶里面的数字,且每个桶中的数字从小到大输出,这样就得到所有数字排好序的一个序列了。
时间复杂度:最好情况下为 O ( N ) O(N) O(N)
假设有n个数字,有m个桶,如果数字是平均分布的,则每个桶里面平均有n/m个数字。如果对每个桶中的数字采用快速排序,那么整个算法的复杂度是
O
(
n
+
m
×
n
m
×
l
o
g
n
m
)
=
O
(
n
+
n
l
o
g
n
−
n
l
o
g
m
)
O(n + m \times \frac{n}{m} \times log\frac{n}{m}) = O(n + nlogn - nlogm)
O(n+m×mn×logmn)=O(n+nlogn−nlogm)
从上式看出,当m接近n的时候,桶排序复杂度接近 O ( n ) O(n) O(n)
当然,以上复杂度的计算是基于输入的n个数字是平均分布这个假设的。这个假设是很强的 ,实际应用中效果并没有这么好。如果所有的数字都落在同一个桶中,那就退化成一般的排序了。
复杂度总结
当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至
O
(
n
)
O(n)
O(n);
而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为
O
(
n
2
)
O(n^2)
O(n2);
原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。
稳定性总结
排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对次序发生了改变,则称该算法是不稳定的。
稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,可以避免多余的比较;
稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序
不稳定的排序算法:选择排序、快速排序、希尔排序、堆排序
选择排序算法原则
选择排序算法的依据
- 待排序的记录数目n的大小;
- 记录本身数据量的大小,也就是记录中除关键字外的其他信息量的大小;
- 关键字的结构及其分布情况;
- 对排序稳定性的要求。
(1) 当n较大,则应采用时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的排序方法:快速排序、堆排序或归并排序。
- 快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
- 堆排序 :如果内存空间允许且不要求稳定性的,
- 归并排序:它有一定数量的数据移动,所以我们可能与插入排序组合,先获得一定长度的序列,然后再合并,在效率上将有所提高。
(2) 当n较大,内存空间允许,且要求稳定性选择归并排序
(3) 当n较小,可采用直接插入或直接选择排序。
- 直接插入排序:当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数;且插入排序是稳定的
- 直接选择排序 :元素分布有序,如果不要求稳定性,选择直接选择排序
(4) 一般不使用或不直接使用传统的冒泡排序。
(5) 基数排序
它是一种稳定的排序算法,但有一定的要求:
- 关键字可分解。
- 记录的关键字位数较少,如果密集更好
- 如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开排序