文章目录
总结
没有绝对最优的排序算法,只有针对具体情况来分析,
- 如果数据有大量重复元素——三路快排
- 如果数据近乎有序——插入排序
- 如果数据的取值范围非常有限(比如学生成绩)——计数排序
- 是否有稳定性要求——归并排序(选择排序/快速排序/希尔排序/堆排序不稳定)
- 数据的存储状况——快排非常依赖于数据的随机存取,如果数据是使用链表存储的——归并排序(链表存储的数据,一次只能访问一个元素)
- 数据量的大小——如果非常大,不足以装载在内存里,需要使用外排序算法
插入排序在数据量少的时候十分快,甚至比O(nlogn)的还快,因此数据量少的时候,使用插入排序,可以达到优化归并排序和快速排序的效果。
排序算法的最优复杂度为O(nlogn),这里按照复杂度来说:
一、O(n^2)复杂度
1. 选择排序O(n^2)
排序思想:在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数中再找最小(或者最大)的与第2个位置的数交换,以此类推,知道第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
复杂度:O(n^2)复杂度,两层循环,复杂度较高,不实用。
def selectionSort(array):
if not array:
return
for i in range(len(array)):
minIndex = i
for j in range(minIndex+1, len(array)):
if array[j] < array[minIndex]:
minIndex = j
# 内层循环结束后,minIndex中存放的是最小元素的下标
array[i], array[minIndex] = array[minIndex], array[i] # 交换位置,将最小元素放到此时i的位置
return array
2. 冒泡排序O(n^2)
稳定排序,复杂度为O(n^2),没有插入排序效率高,该算法每经过一轮遍历,都要遍历所有所有的元素,而且遍历的轮数和元素数量相当。
当经过第一轮遍历后,最大的元素已经排好序了,第二轮遍历后,次大的元素已经排好序了,依次类推(假定是从小到大排序)。
参考 https://blog.csdn.net/lu_1079776757/article/details/80459370
def BubbleSort(array, n):
for i in range(n): # 遍历n轮,才能将所有元素归位
for j in range(n-1-i): # 每次比较相邻两个位置的元素
if array[j] > array[j + 1]:
array[j], array[j + 1] = array[j+1], array[j]
return array
3. 插入排序(最差情况O(n^2))(重要)
排序思想:从第2个元素开始,和前面所有的元素比较,将其插入到合适的位置。
最差情况下复杂度
θ
(
n
2
)
\theta(n^2)
θ(n2),对于近乎有序的数组,插入排序排序效率非常高,复杂度为O(n)。(因为只需要比较,不需要交换)
而且,插入排序因为可以提前跳出循环,因此本身效率也要比选择排序更高一点,所以常用。
def InsertSort(array, n):
for i in range(1, n):
key = array[i]
j = i -1
while j >= 0 and array[j] > key:
array[j + 1] = array[j]
j = j - 1
array[j + 1] = key
return array
4. 希尔排序O(n^3/2)
讲的非常好 https://blog.csdn.net/qq_39207948/article/details/80006224
希尔排序是插入排序的延申,我们知道,插入排序在小规模数据或者数据基本有序时十分高效。 但是对于大规模无序的数据,插入排序效率很差,改进算法就是——希尔排序。希尔排序是不稳定排序。
【希尔排序的思想】:
-
首先它把较大的数据集合按一定的增量(gap)分割成若干个小组(逻辑上分组),然后对每一个小组分别进行插入排序,此时,插入排序所作用的数据量比较小(每一个小组),插入的效率比较高。
-
对每个分组进行插入排序后,各个分组就变成有序的了,我们称为部分有序(整体不一定有序)。
-
不断缩短增量(二分法),重复第一步操作,直至步长缩减为1,则整个数组被分为一组,此时,整个数组已经接近有序了,插入排序效率高。
-
希尔排序的代码相比于插入排序,只是在外层多加了一个控制增量gap的循环而已。
【希尔排序复杂度分析】:
希尔排序的复杂度分析极其复杂,我们只需要记住这个结论:希尔排序的复杂度和增量序列是相关的,
- 例如使用{1,2,4,8,…}这种序列并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏情形)是
O(n^2)
, - Hibbard提出了另一个增量序列
{1,3,7,...,2^k-1}
,这种序列的时间复杂度(最坏情形)为O(n^1.5)
- Sedgewick提出了几种增量序列,其最坏情形运行时间为
O(n^1.3)
,其中最好的一个序列是{1,5,19,41,109,...}
【希尔排序代码】:
def ShellSort(array):
n = len(array)
gap = n // 2
while gap > 0:
for i in range(gap, len(array)):
while i >= gap and array[i] < array[i - gap]:
array[i], array[i - gap] = array[i - gap], array[i]
i -= gap
gap = gap // 2
return array
二、O(nlogn)复杂度
1. 归并排序O(nlogn)
归并排序是将数组分成log(n)的层级,每个层级使用O(n)的复杂度来排序,合并的复杂度也是O(n),因此达到了O(nlogn)的复杂度。
归并排序需要O(n)辅助空间,这个辅助空间用来存放归并的结果。
class MergeSort:
def MergeSort(self, array):
if not array or len(array) <= 1:
return array
mid = len(array) // 2
left = self.MergeSort(array[:mid])
right = self.MergeSort(array[mid:])
return self.merge(left, right)
def merge(self, left, right):
if not left:
return right
if not right:
return left
res = []
i,j = 0, 0
while i <= len(left)-1 or j < len(right):
if i == len(left):
res.extend(right[j:])
return res
if j == len(right):
res.extend(left[i:])
return res
if left[i] < right[j]:
res.append(left[i])
i += 1
else:
res.append(right[j])
j += 1
return res
排序算法的选择:
- 当数组近乎有序的时候,插入排序要比归并排序好,这是因为插入排序在数组近乎有序的时候,复杂度退化到了O(n),
- 数据少的时候使用插入排序比归并排序好,这是因为复杂度虽然一个是O(n^2),一个是O(nlogn),但其实前面都忽略了一个常数项,而插入排序的前面这个常数项更小一些,所以数据少的时候,插入排序更快。
归并排序可以自顶向下,使用递归的方法实现,也可以自底向上,不用递归,使用迭代的方法实现。前者更快。但是后者由于没有使用数组的索引,因此可以对链表进行归并排序。
参考这里 https://www.jianshu.com/p/3f27384387c1
https://blog.csdn.net/su_bao/article/details/81053871
2. 快速排序O(nlogn)
快速排序,最关键的就是Partition函数的思想:
第一个元素v设为主元,j是区分大于和小于主元的两个子序列的分界线,更准确来说,j是指向<=主元pivot的最后一个元素的下标。i是待比较的数组下标,如果arr[i] > pivot,什么都不用做,继续循环即可(蓝色区域直接往后延伸1),如果arr[i] < pivot,那么将j先加1,即j指向第一个大于pivot的数,然后交换arr[j]和arr[i]的值,即可将这个元素添加到橙色区域的最后,依次类推。在i遍历到数组末尾时,橙色和蓝色部分已经完全分开,这时,只需将pivot的值和arr[j]的值互换,即可将主元pivot换到中间位置了。Partition的结果是:
# 对array[low, high]进行快速排序
def QuickSort(array, low, high):
if low >= high:
return
if low < high:
r = Partition(array, low, high) # r是切分点的下标,左边的值都小于等于array[r],右边的都大于等于array[r]
QuickSort(array, low, r) # 不能写成r-1,python和c++不一样,python是前闭后开区间,相当于array[low,r)
QuickSort(array, r + 1, high) # array[r+1, high) array[r]位置存放的是主元,已经排好序了,所以只需对其两端的数组进行递归的快排
def Partition(array, low, high):
pivot = array[low]
# array[low+1, j] < pivot, array[j+1, i] > pivot, i是待比较元素的下标
j = low
for i in range(j + 1, high):
if array[i] <= pivot:
j += 1
array[j], array[i] = array[i], array[j]
array[low], array[j] = array[j], array[low]
return j
nums = [6,10,13,5,8,3,2,11]
QuickSort(nums, 0, len(nums))
print(nums)
将主函数和Partation函数融合成一个函数,快速排序代码为:
def quicksort(data, start, end):
if start >= end:
return
left, right = start, end
base = data[left]
while left < right:
while left < right and data[right] >= base:
right -= 1
data[left] = data[right]
while left < right and data[left] < base:
left += 1
data[right] = data[left]
data[left] = base
quicksort(data, start, left - 1)
quicksort(data, left + 1, end)
3. 快速排序的两个改进(随机快排,Partition2)
-
快速排序的优点:
在数据完全随机的情况下,快速排序比归并排序要快30%左右 -
快速排序的缺点
如果数据完全有序或近乎有序的时候,快速排序十分十分慢,快速排序复杂度退化为O(n^2) -
归并排序每次都是将数组一分为二的,但是快速排序是根据主元的大小对数组分成两份,也就是说容易出现一大一小的情况,两边不平衡,它的子数组高度不一定是logn,很有可能比logn大,最坏情况下,递归树的高度为n,每层处理的时候使用O(n)的复杂度来处理,因此复杂度退化为O(n^2)。
– 改进方法:随机选择主元,快速排序复杂度的期望为O(nlogn) -
当数组中含有大量重复元素时,快速排序比 归并排序慢的多。快排又退化成了O(n^2),因为大量的重复键值会导致递归树的两边不平衡。(等于主元的重复元素会被分到其中一边)
– 改进方法:重写Partition函数 ,命名为Partition2,:将等于pivot的元素,分散到左右两边,就能保证平衡。
i从左向右遍历,如果arr[i]小于pivot,不做处理,直到走到arr[i]大于等于pivot的第一个位置停止,j同理,从右向左走到第一个小于等于pivot的位置停止,此时,如果坐标i < j,则交换arr[i]和arr[j]的值,如果i>j,则说明已经遍历完成,直接跳出循环。当遍历完成后,将pivot的值和arr[j]的值互换。(为什么要跟arr[j]的值互换?因为i指向数组中第一个大于等于pivot的位置,j指向数组中最后一个小于等于pivot的位置,只有跟j互换,才能使得主元正好在中间,将两边分开。 )
4. 三路快速排序
三路快速排序用来处理具有重复键值的数组效果非常好。主要是因为它将等于键值的部分放在了中间,递归的时候,值递归两边,减少了数据量。
思想:将数组分成大于,小于,等于主元三个部分,递归处理左右两边大于和小于的部分,中间部分不处理(节省了时间)
i是遍历的下标,根据arr[i]的大小将其放到不同的位置,如果arr[i]<pivot,交换arr[i]和arr[lt+1](arr[lt+1]是等于pivot部分的第一个元素),并且将lt指针往后一位,如果arr[i]>pivot,交换arr[i]和arr[gt-1],然后将gt索引往前一位,如果arr[i]=pivot,不做处理,继续遍历即可。这里只是说了大致思想,边界条件需要特别注意。当i和gt指针重合时,一轮遍历完成,这时需要将pivot的值和lt位置的元素交换即可完成一轮循环:
### array[low,high)排序
class QuickSort3Ways:
def quickSort3Ways(self, array, low, high):
if low >= high:
return
if low < high:
lt, gt = self.Partition(array, low, high) # lt是等于pivot的第一个元素,gt是大于pivot的第一个元素
self.quickSort3Ways(array, low, lt)
self.quickSort3Ways(array, gt, high)
return array[low:high - 1] # 返回排好序的数组
def Partition(self, array, low, high):
if low >= high:
return
# lt指向小于区间内的最后一个位置,gt是大于区间内的前一个位置
lt, gt = low, high # 初始化
i = lt + 1
pivot = array[low]
while i < gt:
if array[i] < pivot:
array[lt + 1], array[i] = array[i], array[lt + 1]
lt += 1
i += 1
elif array[i] > pivot:
array[gt - 1], array[i] = array[i], array[gt - 1]
gt -= 1
else: # array[i] == pivot的情况
i += 1
array[low], array[lt] = array[lt], array[low] # 交换lr和pivot位置的元素,这是lr指向的是等于区间内的第一个元素,gt仍指向大于区间内的第一个位置
return lt, gt
5. 归并排序和快速排序的衍生问题
二者都用了分治算法(分而治之)。现在来看几个实际的例子。
求逆序对的个数
逆序对:可以用来衡量数组的有序程度。
暴力解法,两重循环,复杂度O(n^2)
归并排序来解决该问题,复杂度O(nlogn)
取数组中第n大的元素
特殊情况:取数组中的最大值,最小值
遍历:复杂度O(n)
对于一般性问题:取数组中的第n大的元素,两种思路:
- 排序,再找索引n,算法复杂度O(nlogn)
- 快速排序,但是每轮结束之后,只需处理n所在的那一半序列,另一半都是不符合要求的,复杂度O(n)
(算法复杂度 = n + n/2 + n/4 + … + 1=O(2n))
6. 堆排序
堆排序也是一种O(nlogn)级别的排序算法,具体的看这里。
7. 排序算法总结
平均时间复杂度 | 最差时间复杂度 | 原地排序 | 额外空间 | 稳定排序 | |
---|---|---|---|---|---|
插入排序 | O(n^2) | O(n^2) | yes | O(1) | yes |
归并排序 | O(nlogn) | O(nlogn) | no(必须开辟额外空间) | O(n) | yes |
快速排序 | O(nlogn) | O(n^2) | yes | O(logn) | no |
堆排序 | O(nlogn) | O(n^2) | yes | O(1) | no |
【注意】:
- 指的是平均时间复杂度,如果数组已经有序,插入排序退化到O(n),对于极其特殊的情况,快速排序可能会退化到O(n^2),因此常用随机快排
- 总体而言,快速排序是最快的,一般系统级别的排序都是用快排,对于有大量重复键值的数组,一般使用三路快排。
- 插入排序和堆排序可以直接在原地完成,因此额外空间为O(1)
- 快排虽然也可以在原地完成,但是在递归的过程中,需要使用额外的O(logn)的栈空间来存储每层的节点,以便递归。
-----2019.9.10更新-----
排序算法的稳定性
稳定排序:对于相等的元素,在排序后,原来靠前的元素依然靠前,相等元素的相对位置没有发生改变。
如图所示,排序之前,三个3的相对位置为“红绿蓝”,排序之后的相对位置依旧为“红绿蓝”,则称为稳定排序。
【插入排序是稳定排序】
因为对于一个待插入的元素,从后往前判断,只有当 待插入元素小于该元素时,才插到该元素前面,因此保持了稳定性。
【归并排序是稳定排序】
归并的时候,只有序列2中的元素小于序列1中的元素时,才归并进去,因此保持了稳定性。
- 排序算法的稳定性与算法的实现的过程有关,如果实现的不好,可能会将插入排序和归并排序变成不稳定的。
- 可以通过自定义比较函数,让排序算法不存在稳定性的问题(对于相等的元素,自定义一个比较函数,将这些相等的元素也排序)
三、线性时间排序算法
前面的所有排序算法都是基于比较的排序算法,下面说的这几种不是基于比较的排序算法。 【参考】
1. 计数排序
【参考】
计数排序不是基于比较的排序算法,其核心在于将输入的数据转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
计数排序特别适用于数字的范围十分有限的情况,leetcode 75. Sort Colors就是一个计数排序的典型例子。只需统计数字0,数字1,数字2的出现次数即可。
2. 桶排序
桶排序是计数排序的升级版。 它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。 【参考】
3. 基数排序
基数排序是对桶排序的一种改进,这种改进是让“桶排序”适合于更大的元素值集合的情况,而不是提高性能。一我们平时常见的基数排序是按照低位先排序,然后收集,然后再按照高位排序,然后收集;依次类推。直到最高位。
基数排序的时间复杂度为O (n log( r ) m),其中r为所采取的基数,而m为堆数。基数排序是稳定排序。