排序1-几大经典排序算法

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u013904227/article/details/88093299

[Github pages]

本文仅先对一些经典的排序算法做一个比较简略的综述,后续会按照本文的算法列表顺序来进行逐个地实现以及剖析优化。本系列是自己学习过程中的总结,所以不免会记录一些看起来比较无聊的概念之类的东西,并且会有很多的不足之处。

本文并不具体分析推导时间复杂度这些东西,它们会放到后面的独篇介绍里面去,这里相当于是一个大纲索引,走马观花看下都有哪些排序算法,有个全局的小概念。

概念

排序算是数据结构与算法入门最先接触的一个种类了,关于排序算法有几个方面的概念:

  1. 时间、空间复杂度极其稳定性。
  2. 稳定度(不同于上一个概念)。
  3. 是否基于数值比较。
  4. 是否是原地排序。

时间复杂度与空间复杂度的概念都很容易理解,无非就是一个算法从理论上来计算出来的时间消耗、空间消耗评估,常用大O表示法。其中空间复杂度相对来说是比较稳定的一个数据指标,对于同样一个排序算法来讲,其空间复杂度不会有多大的波动。

但是时间复杂度就分为最好、最坏、平均这好几种了,因为数据集合(Data set)的不同会导致有些算法的时间复杂度表现波动很大,在实际的使用当中不同的场景会有不同特点的数据,比如有的数据连续性已经很高了,有的数据范围没有那么大,有的数据随机性很强等等(其实我的使用案例不多,但是我很确定这一点)。这几个复杂度的分析可以帮助我们在不同的场景下去选择最合适的算法来完成业务场景。

并且时间复杂度只是一个粗略的量级概念指标,很多时候同样的的复杂度量级的排序算法的实际时间消耗是不一样的,在数据集合规模比较小的时候有些高量级的排序算法时间消耗反而比低量级的要少。量级主要就是指的指数、平方这个级别的,比如 N 的平方与 NlogN 就属于两个量级,而比例比如 2N 与 3N 在大多数时候就是一个量级的。

另外一个稳定度用于描述具有相同数值的两个实体在排序前后它们的顺序是否发生了改变,这一点对于同一个排序实体具有多个需要排序数值的情境下是十分有用的,比较好的一个例子就是我在学习的那个专栏说的订单排序,订单很多时候需要按照时间先后与金额先后分别进行排序。

直觉能够想到的排序算法都应该是基于数值比较的,如果不比较那怎么知道谁大谁小嘛,我开始也是这样想的,但是后来发现还有不基于数值比较的排序算法,严格来讲是:不是以数值比较为核心基础的排序算法,比如「桶排序」、「计数排序」。

有的排序算法只需要原始待排序序列那么大的空间即可,但是有的算法就需要另外开辟一块新的与原始待排序序列等大小的空间,就比如「归并排序」,待排序数组小的话还好说,但是如果有1G这么大的话,「归并排序」就需要额外的1G空间,有些时候还是要顾及下这点的。

冒泡排序(Bubble sort)

冒泡排序是最原始最简单,所有人直觉上都能够直接想到的一种排序算法,它是完完全全地基于比较的排序算法,它的基本原理就是:比较并交换两个相邻的元素。

听起来也确实是非常简单的,如果要想把数据从小到大进行排序,那么就比较相邻元素,并且在前一个元素大于等于后一个元素的时候发生交换动作,如果要保持稳定的话就把交换条件改为大于等于,如果是从大到小,那就把交换条件反过来即可。

可以看到冒泡排序的理论确实非常的简单,理论上来看,它需要遍历一个长度为 N 的需排序数据 N 次就可以绝对完成整个的排序过程。但是对于实际的数据来讲,大多数时候不会这么的衰,正好就遇到一个完全倒序的数据。

所以冒泡排序有它自己的改进算法,通常原则是基于减少交换、遍历数据的次数来的。通用的优化手段有:

  1. 发现某次遍历之后没有交换动作,那就结束整个排序,因为这个时候数据肯定是有序的了。
  2. 每次遍历之后就记录下最后一个有元素交换的位置,下次遍历到那里就结束。
  3. 从前往后遍历时大于(小于)的时候交换,然后到结尾之后倒序遍历,并在小于(大于)的时候交换。

其实「鸡尾酒排序」(Cocktail sort)就是上面第三个点的应用,它是一个变形的冒泡排序,主旨在于减少大循环的次数,至于为什么反向遍历就能够少点时间呢,反向遍历难道就不是遍历了么,这个之后的文章里面再探讨。

比如「梳排序」(Comb sort)。简单介绍下,冒泡排序的每次比较步进为1,也就是每次都是相邻元素的比较,而梳排序的步进就不是1了,这个步进是可以自定的,但是维基百科给的数值是 N/1.3,具体怎么来的我还没去深究,也就是从 N/1.3 取整之后的步进开始比较,然后逐次减少步进,每次减少1,直到步进为1就结束整个排序。

对于上面的两个变形算法,其实优化手段里面的第1~2条也是适用的。

插入排序(Insertion sort)

插入排序与冒泡排序的时间复杂度是一个样的,但是插入排序的实际时间消耗还是要比冒泡排序更少一点的,它也是基于数值比较的,其主要原理为:把待排序数组分为已排序区间和未排序区间,通常是从数组下标第0项开始,之后每次从未排序区间的最开始,也就是紧邻已排序区间末尾那个元素的下一个元素取出数据,按照其大小插入到已排序区间的合适位置。

听起来挺麻烦的,但是实现起来是比较简单的,此处应该有图,但是来不及画了,后续在具体篇目再画。插入排序从代码上来看只需要一次未排序数组的遍历,但是在插入的过程中是需要对已排序区间进行查找与数据搬移的(试想一下数组的数据插入)。

插入排序方然也是有自己的优化手段的,常用的有以下几个:

  1. 在已排序区间进行查找的时候使用二分查找法,此时需要注意重复的数据处理方式,用于保证算法的稳定性。
  2. 对插入排序的步长做改动,以大于1的步长做插入排序。

插入排序的改进算法就是「希尔排序」(Shell sort),又称壳排序(雾)。希尔排序跟梳排序的优化原理有点类似,也是把插入排序的步长改为比1大的数,而这个步长的选择也是极为神奇,我在维基百科上面看到有各种奇怪的数字,像等比数列、等差数列这些还好理解,有些就是跟魔数一样的东西,看的眼花缭乱,后续再去研究。

二分查找就很容易理解了,在已排序区间进行二分查找的效率是极高的,唯一需要注意的就是当找到一个等于的数值的时候继续往后查找,直到找到第一个大于(小于)要查找值的数出现的时候,或者查找到已排序区间的末尾(开头)的时候,再把这个数插入,这样不会破坏原有数据的稳定性。

选择排序(Selection sort)

选择排序可以理解为一种插入排序的变体,其基本原理为:把整个待排序数组分为已排序区间与未排序区间,从未排序区间找到最大(最小)的那个数值,然后把它与未排序区间的第一个值进行交换。

可以看到它与插入排序的区别,一个是拿到一个数就用,一个是拿到指定的数再用。选择排序也有一些可以优化的手段,比如每次循环遍历的时候确定一个最大值和一个最小值,每次对两个数进行操作,这样整体的大循环次数就可以减少为原来的一半。找到的最大值与最小值一个与未排序区间的第一个值进行交换,一个与未排序区间的最后一个值进行交换,具体哪个在前哪个在后取决于是升序排列还是降序排列。

上面这个优化需要注意的一点是,如果最大的值和最小的值恰好处于要互相交换的位置,比如一个在未排序区间的头部,一个在未排序区间的尾部,这个时候交换就要注意了,它们只需要交换一次,如果交换两次就会导致错误的排序结果。

从上面的排序过程可以知道,它是非稳定的排序算法,在排序完成之后具有相同数值的实体先后顺序可能会发生变化。

归并排序(Merge sort)

单纯从英文的字义上来理解,它就是合并排序,但是翻译过来是归并排序,我想是因为归并排序通常会利用递归的手段来逐级合并子序列,所以叫做归并排序。它的基本原理是:把待排序序列分为两两相邻的元素(奇数的话就会有一个只有一个实体的子序列),然后把较小的子序列按照升序或者降序合并为较大的子序列,直到全部合并。

归并排序是本篇文章介绍里面第一个利用到递归方法的排序算法,后续在写到这里的时候可能会先记录下递归的思想方法,然后再开始归并排序的介绍。归并排序的时间复杂度是 Onlogn,比上面的 On*n 好多了,只不过它需要递归,所以栈空间的使用就是必须的了,并且由于合并的原因,它不是原地排序算法,需要另外开辟新的存储空间。

归并排序也有优化的手段,比如:

  1. 子序列合并的时候交替使用原始序列存储空间与新开辟的存储空间交替使用,避免每次排序的两次拷贝操作。
  2. 合并操作通过某种手段可以只需要申请 N/2 的存储空间即可完成整个的排序过程。
  3. 不要把子序列彻底切分为2 个一组的,可以在切分到 10 个一组的时候切换为选择排序或者插入排序对子序列进行排序,后面的再进行合并。

归并排序需要注意的就是递归的深度,可以限制递归的深度,子序列合并时候的空间使用,以及子序列的排序方式等。由于它不是原地排序算法,所以在数据量比较大并且内存较小的时候选择其它的排序算法,或者是改进归并排序,使用分段排序解决问题。

快速排序(Quick sort)

快速排序的基本原理:从待排序序列选择一个数值区分点,然后把数据按照该区分点左右放置,重复排序区分点左边与右边的数据,递归直到排序完成。

快速排序比较类似归并排序,它们有几个区别与相同点:

  1. 快速排序是原地排序算法,归并排序不是。
  2. 快速排序是自顶向下的排序算法,归并排序是自底向上的排序算法。
  3. 快速排序与归并排序一样,也会用到递归的方法。
  4. 快速排序的平均复杂度与归并排序一样。

看起来快速排序就是为了解决归并排序的缺点而存在的,但是快速排序也有缺点,就是在极端情况下会退化为 On*n 的时间复杂度。这个与区分点的选择有关,由此便引出了快速排序的优化方向:

  1. 区分点三数取中法。
  2. 随机选择区分点。
  3. 五数或者更多的区分点选择法。

从资料了解到,貌似快速排序的优化手段并不多,可能是已经在理论和代码实现上就属于比较优秀的算法了。一般像C库里面的 qsort 函数是用到了很多的排序算法,杂糅到一块进行实现的,快速排序也是其中一种。快速排序也是一种不稳定的排序算法,也就是说不能够保证具有相同数值的实体在排序前后的顺序保持一致。

桶排序

桶排序的基本原理是:先遍历一遍待排序序列,把指定范围的实体放到指定的桶里面,然后对桶里面的数据进行排序,最后依次从桶里面取出并合并数据。

从上面可以看到,它也不是一个原地排序算法,需要额外 O(k) 的若干空间,具体大小与桶的数量有关系,也与合并过程的实现有关。桶排序要求原始数据的数值范围分布是比较均匀的,如果原始数据的数值极为集中到某几个值,但是整体范围又特别大,那么桶排序的优势就完全发挥不出来。

桶排序比较适合用于存放在磁盘数据当中的数据排序,因为我们可以把原始文件按照桶的划分分为一个个小的排序序列,然后对每一个桶(文件)进行排序,对于大容量文件,小内存设备的排序环境比较适用。

桶排序的时间复杂度是 O(n),算是一个效率很高的排序算法了。

计数排序(Counting sort)

计数排序类似桶排序的一种特殊情况,其实现原理为:统计待排序序列里面数值的出现次数,然后把它们放到一个计数队列,队列的下标代表数值的具体值,队列存放的内容是小于等于该数值的实体的数量,然后从后往前遍历原始数组,不断从计数队列找到其位置,放入即可。

计数排序的空间复杂度比较高,是 O(n+k),主要是因为计数队列这块比较耗费内存。计数排序也是仅仅适合小范围内的数据排序,如果数据范围太大,那么需要的计数队列长度就太长了,内存上就不一定搞的定。

另外基数排序还要求待排序序列的实体数值不能为负数或者是小数,因为数组下标不会是负数或者小数,所以在排序的时候还需要对原始数据做一个转换。

基数排序(Radix sort)

基数排序用于等长数值的排序,比如电话号码、订单号码等,它的基本实现原理是:从后往前遍历排序实体,把单独的一位抽出来放在全局排序实体中进行排序,排序完毕之后前移一位继续排序,直到排完最开始的那位。

基数排序要求能够分割出来具体的位来进行比较,并且要求数据是等长的(其实不等长在大多数时候可以尾部补0来实现等长)。要求每一位的数据范围不能够太大,像az,AZ,0~9 这种就属于比较小的范围。

在满足条件的情况下,它的时间复杂度也是 O(n)。

堆排序

堆是一个完全二叉树结构,通常使用数组来进行组织,排序原理为:把原始数据按照大顶堆(小顶堆)进行建堆与堆化,然后把堆顶数据放到数组末尾,重新调整除去某尾已排序元素的数组序列,重新堆化,不断重复过程。

完全二叉树的结构十分有利于数组结构存储,堆排序便是基于数组结构来进行的,链表结构也不是不行,但是效率上就比数组差很多。数组结构的随机访问特性就要求有连续的虚拟内存空间,如果待排序数组过大,就可能会申请不到虚拟内存空间来存放数据。

瞎 JB 排的

猴子排序(Bogo Sort):随机打乱数组,检查是否排好序,如果排好的话就输出,否则重复以上步骤,最佳情况O(n),平均O(n*n!),最坏可执行到宇宙坍缩,那么为什么叫猴子排序呢。有这么一个定理,注意了,是定理,叫「无限猴子定理」,就是说

让一只猴子在打字机上随机地按键,当按键时间达到无穷时,几乎必然能够打出任何给定的文字,比如莎士比亚的全套著作。

面条排序(Spaghetti Sort):首先去买一捆面条,最好是硬的,最好粗一点,越粗越好,后面有用,买回来之后先遍历下原始序列,用指定的面条长度代表数组数值,长度太长了就切掉,然后把面条立到桌子上面,用手或者平面的东西水平放在面条上方,缓慢向下移动,碰到第一根面条就移走它,然后把对应的实体数值移动到结果数组中,不断重复直到面条用完。

睡眠排序(Sleep Sort):其实我个人觉得这个还是有那么一点靠谱的,思想很活跃,我之前也自己想到过,那就是遍历数组,开 N 个线程,然后线程里面什么都不干,仅睡眠数组值对应的时间(单位可以是 us、ms、S、分钟、小时、年、世纪都可以),等线程返回的时候就输出数值到结果数组的已排序区间末尾,是不是很完美(其实并不)。

End

排序算法 时间复杂度(平均) 是否基于比较 是否稳定 是否是原地排序
冒泡排序 O(n*n)
插入排序 O(n*n)
选择排序 O(n*n)
归并排序 O(nlogn)
快速排序 O(n*n)
桶排序 O(n)
计数排序 O(n)
基数排序 O(nk)
堆排序 O(nlogn)

下周开始更新冒泡排序的以及其变种极其优化等等,或许会把它与其他几个 O(n*n) 的排序算法一块说了,主要取决于时间是否够。


想做的事情就去做吧

没有更多推荐了,返回首页