排序算法
排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。我们这里说说八大排序的内部排序。
概述
排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
我们这里说说八大排序的内部排序。
当n较大,则应采用时间复杂度为O(nlog2n)的排序方法:快速排序、堆排序或归并排序序。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;*
如何分析一个算法
算法的执行效率
- 最好情况、最坏情况、平均情况时间复杂度
我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
- 时间复杂度的系数、常数 、低阶
我们知道,时间复杂度反应的是数据规模n很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能
是10个、 100个、 1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
- 比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如
果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
排序算法的内存消耗
我们前面讲过,算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念, 原地排
序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。
冒泡排序、插入排序、选择排序,都是原地排序算法。
排序算法的稳定性
这个概念是说,如果待排序的序列中存在
值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
我通过一个例子来解释一下。比如我们有一组数据2, 9, 3, 4, 8, 3,按照大小排序之后就是2, 3, 3, 4, 8, 9。
这组数据里有两个3。经过某种排序算法排序之后,如果两个3的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那
对应的排序算法就叫作不稳定的排序算法。
有序度/逆序度
有序度是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:
1 | 有序元素对: |
2, 4, 3, 1, 5, 6 这组数据的有序度 11,其有序元素对为11个,分别为:
1 | (2,4), (2,3), (2,5), (2,6), |
同理,对于一个倒序排列的数组,比如6, 5, 4, 3, 2, 1,有序度是0;对于一个完全有序的数组,比如1, 2, 3, 4, 5, 6,有序度就是 n * (n-1) / 2 ,也就是15。我
们把这种完全有序的数组的有序度叫作满有序度。
逆序度的定义正好跟有序度相反(默认从小到大为有序)。
1 | 逆序元素对: |
公式
(n为元素个数)
满有序度 = n * (n-1) / 2
逆序度=满有序度-有序度。
举个栗子
要排序的数组的初始状态是4, 5, 6, 3, 2, 1
其中,有序元素对有(4, 5) (4, 6)(5, 6),所以有序度是3。 n=6,
所以排序完成之后终态的满有序度为 n * (n-1) / 2 = 15。
冒泡排序包含两个操作原子, 比较和交换。每交换一次,有序度就加1。不管算法怎么改进,交换次数总是确定的,即为逆序度, 也就是 n * (n-1) / 2 – 初始有序度。
此例中就是15–3=12,要进行12次交换操作。
经典算法示例
基于比较的排序
冒泡排序
原地排序算法
稳定的排序算法
最好时间复杂度O(n)
最坏时间复杂度O(n^2)
平均时间复杂度O(n^2)
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1)
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的
数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,
冒泡排序的核心是从头遍历序列。以升序排列为例:将第一个元素和第二个元素比较,若前者大于后者,则交换两者的位置,再将第二个元素与第三个元素比较,若前者大于后者则交换两者位置,以此类推直到倒数第二个元素与最后一个元素比较,若前者大于后者,则交换两者位置。这样一轮比较下来将会把序列中最大的元素移至序列末尾,这样就***安排好了最大数的位置(每次找到一个最大的)***,接下来只需对剩下的(n-1)个元素,重复上述操作即可.
1 | //时间复杂度O(N^2), 额外空间复杂度O(1) |
插入排序
原地排序算法
稳定的排序算法
最好时间复杂度O(n)
最坏时间复杂度O(n^2)
平均时间复杂度O(n^2)
从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),也就是说,这是一个原地排序算法。
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定
的排序算法。如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位
置。所以这种情况下,最好是时间复杂度为O(n)。注意,这里是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为O(n2)。还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环
执行n次插入操作,所以平均时间复杂度为O(n2)。
插入即表示将一个新的数据插入到一个有序数组中,并继续保持有序。例如有一个长度为N的无序数组,进行N-1次的插入即能完成排序;第一次,数组第1个数认为是有序的数组,将数组第二个元素插入仅有1个有序的数组中;第二次,数组前两个元素组成有序的数组,将数组第三个元素插入由两个元素构成的有序数组中……第N-1次,数组前N-1个元素组成有序的数组,将数组的第N个元素插入由N-1个元素构成的有序数组中,则完成了整个插入排序。
类似手里有一把排好序的牌,心哪一张和最后一个比较,看能插到前边哪个位置上。
1 | //时间复杂度O(N^2), 额外空间复杂度O(1) |
选择排序
原地排序算法
稳定的排序算法
最好时间复杂度O(n^2)
最坏时间复杂度O(n^2)
平均时间复杂度O(n^2)
选择排序空间复杂度为O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n2)。
选择排序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素
交换位置,这样破坏了稳定性。比如5, 8, 5, 2, 9这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
选择排序也是一种简单直观的排序算法。它的工作原理很容易理解:初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列;然后,再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
注意:
选择排序与冒泡排序的区别:冒泡排序通过依次交换相邻两个顺序不合法的元素位置,从而将当前最小(大)元素放到合适的位置;而选择排序每遍历一次都记住了当前最小(大)元素的位置,最后仅需一次交换操作即可将其放到合适的位置
1 | //时间复杂度O(N^2), 额外空间复杂度O(1) |
归并排序
- 原地排序算法
- 稳定的排序算法
- 最好时间复杂度O(nlogn)
- 最坏时间复杂度O(nlogn)
- 平均时间复杂度O(nlogn)
递归方法的复杂度分析,见另一篇 master公式。
归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案”修补”在一起,即分而治之)。
归并排序是稳定排序,他也是一种十分高效的排序,能利用完全二叉树特性的排序一般性能都不会太差。java中Arrays.sort()采用了一种名为TimSort的排序算法,就是归并排序的优化版本。
从上图中可看出,**每次合并操作的平均时间复杂度为O(n),而完全二叉树的深度为|log2n|**。总的平均时间复杂度为O(nlogn)。而且,归并排序的最好,最坏,平均时间复杂度均为O(nlogn)。
1 | /** |
快速排序
- 原地排序算法
- 稳定的排序算法
- 最好时间复杂度O(nlogn)
- 最坏时间复杂度O(n^2)
- 平均时间复杂度O(nlogn)
归并-快排对比
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。
我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
1 | /** |
非基于比较的排序
桶排序
原地排序算法
稳定的排序算法
最好时间复杂度O(n)
最坏时间复杂度O(n)
平均时间复杂度O(n)
如果要排序的数据有n个,我们把它们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k * logk)。 m个桶排序的时
间复杂度就是O(m * k * logk),因为k=n/m,所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时, log(n/m)就是一个非常小的常量,这
个时候桶排序的时间复杂度接近O(n)。
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之
后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
- 设置一个定量的数组当作空桶。
- Divide - 从待排序数组中取出元素,将元素按照一定的规则塞进对应的桶子去。
- 对每个非空桶进行排序,通常可在塞元素入桶时进行插入排序。
- Conquer - 从非空桶把元素再放回原来的数组中。
1 | /** |
计数排序
顾名思义,就是对待排序数组按元素进行计数。使用前提是需要先知道待排序数组的元素范围,将这些一定范围的元素置于新数组中,新数组的大小为待排序数组中最大元素与最小元素的差值。
维基上总结的四个步骤如下:
- 定新数组大小——找出待排序的数组中最大和最小的元素
- 统计次数——统计数组中每个值为i的元素出现的次数,存入新数组C的第i项
- 对统计次数逐个累加——对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
- 反向填充目标数组——将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
其中反向填充主要是为了避免重复元素落入新数组的同一索引处。
基数排序
原理类似桶排序,这里总是需要10个桶,多次使用
首先以个位数的值进行装桶,即个位数为1则放入1号桶,为9则放入9号桶,暂时忽视十位数
例如
待排序数组[62,14,59,88,16]简单点五个数字
分配10个桶,桶编号为0-9,以个位数数字为桶编号依次入桶,变成下边这样
| 0 | 0 | 62 | 0 | 14 | 0 | 16 | 0 | 88 | 59 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |桶编号
将桶里的数字顺序取出来,
输出结果:[62,14,16,88,59]
再次入桶,不过这次以十位数的数字为准,进入相应的桶,变成下边这样:
由于前边做了个位数的排序,所以当十位数相等时,个位数字是由小到大的顺序入桶的,就是说,入完桶还是有序
| 0 | 14,16 | 0 | 0 | 0 | 59 | 62 | 0 | 88 | 0 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |桶编号
因为没有大过100的数字,没有百位数,所以到这排序完毕,顺序取出即可
最后输出结果:[14,16,59,62,88]
小结
如果对小规模数据进行排序,可以选择时间复杂度是O(n2)的算法;如果对大规模数据进行排序,时间复杂度是O(nlogn)的算法更加高效。
所以,为了兼顾任意规
模数据的排序,一般都会首选时间复杂度是O(nlogn)的排序算法来实现排序函数。
工程中使用
数据量小于60会使用插入排序
原因: 虽然插入排序时间复杂度O(n^2) 但是常数项较小,在数据量小的时候,复杂度不会表现出劣势
数据是基础类型用快排
原因: 因为基础类型数据不用区分数据的先后顺序,是无差别的。即基础数据不用关心排序的稳定性。
数据是自定义类型用归并排序
归并排序可以保持排序后的稳定性。
堆排序
- 原地排序算法
- 稳定的排序算法
- 最好时间复杂度O(nlogn)
- 最坏时间复杂度O(nlogn)
- 平均时间复杂度O(nlogn)
1 | /** |
快速排序要比堆排序性能好
堆排序数据访问的方式没有快排友好。对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对 堆顶节点进行堆化,会依次访问数组下标是$1,2,4,8$的元素,而不是像快速排序那样,局部顺序访问,所以,这样对CPU缓存是不友好的。
对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。
但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数 据反而变得更无序了。