在数据结构与算法之排序篇(上),我们学到了冒泡排序、插入排序、选择排序这三种排序算法,他们的时间复杂度都是O(n^2)比较高,适合小规模数据的排序。今天来学习两种复杂度为O(nlogn) 的排序算法,归并排序和快速排序,这两种排序算法适合大规模的数据排序。
一、归并排序
(1)、什么是归并排序?
如果要排序一个数组,我们把数组从中间分成前后两个部分,然后对前后两个部分分别排序,再将排序好的两个部分合并在一起,这样整个数组就有序啦。(如图)
(2)、思想:
归并排序使用的思想就是分治思想分治,顾名思义,就是分而治之,建一个大问题分解成小的子问题来解决。小的问题解决了,大的问题也就解决了。
其实分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,而递归是一种编程技巧,这两者并不冲突。
(3)、如何用递归实现?
在数据结构与算法之递归篇中,我们学习到写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。因此,写出归并排序的代码的前提条件是我们先写出贵宾排序的递推公式。
注释:merge_sort(p…r)表示,给下标从P到r之间的数组排序。我们将这个排序问题转化为两个子问题,merge_sort(p…q)和merge_sort(q+1…r),其中下标q等于p和r的中间位置,也就是(p+r)/2。当下标从P到q和从q+1到r这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从p到r之间的数据也就排好序了。
伪代码如下:
代码中的merge(A[p…r)],A(p…q),A[q+1…r])这个函数的作用就是,将已经有序的A(p…q),A[q+1…r]合并成一个有序的数组,并且放入A[p…r)]。但这个过程是如何实现的
如图所示,我们申请了一个临时数组tmp,大小与A[p…r]相同。我们用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j],如果A[i]<=A[j],我们就把A[i]放入到临时数组tmp,并且i后移一位,否则将A[j]放入到数组tmp,j后移一位。
继续上述比较过程,直到其中一个字数徐中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p…r]中。
伪代码如下:
(4)、归并排序的性能分析
第一、归并排序是稳定的排序算法吗?
结合前面两张归并排序的伪代码图,其实归并排序稳不稳定关键要看merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代码。
在合并过程中,如果A[p…q]和A[q+1…r]之间有值相同的元素,那我们就可以像伪代码中那样,先把A[p…q]中的元素放入tmp数组。这样就保证值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
第二、归并排序的时间复杂度是多少?
在数据结构与算法之递归篇中,我们学习到递归适用的场景,一个问题a可以分解成多个子问题b、c,问题b、c解决之后,我们再把b、c的结果合并成a的结果。
如果我们定义求解问题a时间是T(a),求解问题b、c的时间分别是T(b)和T©,我们就可以得到这样的递推公式:
其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。
从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
套用这个公式,我们来分析一下归并排序的时间复杂度。
我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2). 我们知道,merge()函数合并成两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
进一步分解得到:
通过这样一步一步分解推导,我们可以得到T(n)=2^KT(n/2^k)+kn。当T(n/2^k)=T(1)时,也就是n/2^k=1,我们得到k=log2^n。我们将K值代入上面的公式,得到T(n)=Cn+nlog2^n。如果我们用大O标记法来表示的话,T(n)就等于O(nlogn)。所以归并排序的时间复杂度是O(nlogn).
从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况、还是平均情况,时间复杂度都是O(nlogn)。
第三、归并排序的空间复杂度是多少?
归并排序的时间复杂度在任何情况下都是O(nlogn),但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的"弱点",那就是归并排序不是原地排序算法。
归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间,但是再合并完成之后,临时开辟的内存空间就被释放掉。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度是O(n)。
二、快速排序
(1)、快排的思想:
如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。
我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分为三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。
根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。
如果我们用递推公式来将上面的过程写出来的话,就是这样:
将递推公式转化成递归代码,伪代码如下:
归并排序中有一个merge()合并函数,这里有一个partition()分区函数。Partition()分区函数就是随机选择一个函数作为pivot(一般情况下,可以选择p到r区间的最后一个元素),然后对A[p…r]分区,函数返回pivot的下标。
如果我们不考虑空间消耗的话,partition()分区函数可以写得很简单。我们申请临时数组X和Y,遍历A[p…r],将小于pivot得元素都拷贝到临时数组X,将大于pivot得元素都拷贝到临时数组Y,最后再将数组X和数组Y中数据顺序拷贝到A[p…r]。
但是,如果按照这种思路非常巧妙,下面是伪代码:
这里得处理有点类似选择排序。我们通过游标i把A[p…r-1]分成两个部分。A[p…i-1]的元素都是小于pivot的,我们暂且叫它"已处理区间",A[i…r-1]是"未处理区间"。我们每次都从未处理的区间A[i…r-1]中去一个元素A[j],与pivot对比,如果小于piovt,则将其加入到已处理区间的尾部,也就是A[i]的位置。
分区过程如图:
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个6的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。
(2)、快速排序的性能分析
对于快排的稳定性和空间复杂度已经在实现原理的时候分析了,下面来看看快排的时间复杂度。
快排也是通过递归来实现的。对于递归代码的时间复杂度,我们前面总结的公式,这里也适用。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是O(nlogn).
三、快速排序与归并排序的区别?
由图可知:归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排序正好相反,它处理的过程由上到下的,先区分,然后再处理子问题,归并排序虽然是稳定的,时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。快速排序通过设计巧妙的原地区分函数,可以实现原地排序解决,解决了归并排序占用太多内存的问题。
但是,公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等地一分为二,但实际上这种情况是很难是很难实现的。
举个极端例子:
如果数组中的数据原来已经是有序的了,比如1,3,5,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就从O(nlogn)退化成O(n^2)。
刚刚是两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡,它们分别对应快排的最好情况和最坏情况,那快排的平均情况时间复杂度是多少?
我们假设每次分区操作都将区间分成9:1的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:
T(n)在大部分情况下的时间复杂度为O(nlogn),只有在极端情况下,才会退化到O(n^2)。
Q:如何在O(n)的时间复杂度内查找一个无序数组中的第K大元素?比如,4,2,5,12,3 这样一组数据,第3大元素就是4。
我们选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1…n-1]区间,我们再按照上面的思路递归在A[p+1…n-1]这个区间内查找。同理,如果K<p+1,那我们就在A[0…p-1]内查找。
为什么上述解决思路的时间复杂度是O(n)?
第一次分区查找,我们需要对大小为n的数组执行分区操作,需要遍历n个元素。第二次分区查找,我们只需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。以此类推,分区遍历元素的个数分别为n/2、n/4、n/8、n/16……知道区间缩小为1。
如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+……+1。这个就是一个等比数列求和,最后的和等于2n-1。所以,上述解决思路的时间复杂度为O(n)。
如果每次去数组中的最小值,将其移动到数组的最前面,然后,在剩下的数组中继续寻找最小的值,以此类推,执行K次,找到的数据不就是第K大元素了吗?
这种情况下,时间复杂度为O(K*n)。注意,这里的时间复杂度前面的系数不可以简单省略,当K为比较小的常量(1,2,3……)时才可以,但当K等于n/2或者n时。时间复杂度就为O(n^2)。
扫码关注微信公众号,欢迎技术交流,其中含有大量免费的人工智能、图像处理、IT资料: