数据结构与算法之排序篇(下)

数据结构与算法之排序篇(上),我们学到了冒泡排序、插入排序、选择排序这三种排序算法,他们的时间复杂度都是O(n^2)比较高,适合小规模数据的排序。今天来学习两种复杂度为O(nlogn) 的排序算法,归并排序和快速排序,这两种排序算法适合大规模的数据排序。

一、归并排序

       (1)、什么是归并排序?

         如果要排序一个数组,我们把数组从中间分成前后两个部分,然后对前后两个部分分别排序,再将排序好的两个部分合并在一起,这样整个数组就有序啦。(如图)

 

 

        (2)、思想:

        归并排序使用的思想就是分治思想分治,顾名思义,就是分而治之,建一个大问题分解成小的子问题来解决。小的问题解决了,大的问题也就解决了。

       其实分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,而递归是一种编程技巧,这两者并不冲突。

        (3)、如何用递归实现?

        数据结构与算法之递归篇中,我们学习到写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。因此,写出归并排序的代码的前提条件是我们先写出贵宾排序的递推公式。

计算机生成了可选文字:涕 推 公 贰 : merge_sort()- r) 终 止 条 件 : 「 不 用 再 组 分 merge(merge_sort 〔 . ) , merge_sort(q+l.„r) )

    

注释: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之间的数据也就排好序了。

伪代码如下:

计算机生成了可选文字:归 并 排 序 算 法 , 胃 是 數 组 , n 表 示 數 组 大 小 merge_sort(), n) { merge_sort = 〔 胃 , a, n-l) 涕 归 调 用 函 數 merge_sort_c 〔 胃 , p , r) { 涕 归 终 止 条 件 If p 》 = 「 then return 取 P 到 r 之 阎 的 中 阎 位 置 分 治 涕 归 merge 5 0 rt = 〔 胃 , P, merge_sort = 〔 胃 , q 1 , r) 和 ACq*1. 台 并 为 胃 [ 0 . merge 〔 .rl, 胃 [ q + 1 一 . r l)

 

 代码中的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]中。

计算机生成了可选文字:A 印 “ ALP A 印 A 印 豳 一 . 协 户

 

伪代码如下:

计算机生成了可选文字:var tmp new array , ACq+l. .r-pl 始 化 变 里 i, , 申 清 一 个 大 小 跟 一 祥 的 临 时 數 组 h11e 蟊 O 〕 < = 「 匕 0 If ALIl < = 胃 [ 〕 ] { = ACI 判 断 哪 个 子 數 组 中 有 剩 余 的 數 掘 = 二 1 等 于 i If ] ( then start 〕 , end : = r 将 剩 余 的 數 掘 幬 贝 到 临 时 數 组 tmp h11e start < = end , 0 { = 胃 [ a 「 t 将 tmp 中 的 數 组 幬 贝 回 ACP. for 1 : to r 一 p 匕 0 { tmpC1)

 

(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©,我们就可以得到这样的递推公式:

计算机生成了可选文字:T(a) = 豳 0

其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。

从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。

套用这个公式,我们来分析一下归并排序的时间复杂度。

我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2). 我们知道,merge()函数合并成两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

计算机生成了可选文字:T(1) T(n) = 2 町 ( 酊 2 ) 时 , 只 要 常 里 的 行 时 阎 , 所 以 表 示 为

 

进一步分解得到:

计算机生成了可选文字:T(n) 2 町 ( 酊 2 ) 2 町 ( n74 ) = ( 2 町 ( n 咫 ) 2 町 ( n710 Ak T 〔 n/2Ak) n/2) n/4) n/8) n = 、 T ( n74 ) 2*n = 8 , 1 ( n / 8 ) 3 , n = 16 , T 〔 n / 1 4*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的。

 

计算机生成了可选文字:Pivot 丁 0 匚 二 亡 'he , 0 一 010

根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。

如果我们用递推公式来将上面的过程写出来的话,就是这样:

计算机生成了可选文字:涕 推 公 贰 : quick_sort()- r) 终 止 条 件 : quick_sort(p•-l) quick r)

将递推公式转化成递归代码,伪代码如下:

计算机生成了可选文字:快 排 序 , quick_sort(A 是 數 组 , n 表 示 數 组 的 大 小 丿 n) { n-l) 「 为 下 标 quick 5 0 rt = 〔 胃 , a, 快 排 序 归 函 , quick_sort_c 〔 胃 丿 p , r) { If p 》 = 「 then return = partition(A, quick 5 0 rt = 〔 胃 , quick 5 0 rt = 〔 胃 , 取 分 区 貞 p , r) 7 / p, q-l) q 1 , r)

归并排序中有一个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]。

计算机生成了可选文字:尸 诤 0t 河二国,@二 , 0 , , 步 豳 , 豳 刁

但是,如果按照这种思路非常巧妙,下面是伪代码:

 

计算机生成了可选文字:partition ( 胃 , p, r) { PIVOt : = p 意 0 r 一 1 do { If < p 二 VO 意 { p ALI) WIth swap ALIl With ACrl 1

这里得处理有点类似选择排序。我们通过游标i把A[p…r-1]分成两个部分。A[p…i-1]的元素都是小于pivot的,我们暂且叫它"已处理区间",A[i…r-1]是"未处理区间"。我们每次都从未处理的区间A[i…r-1]中去一个元素A[j],与pivot对比,如果小于piovt,则将其加入到已处理区间的尾部,也就是A[i]的位置。

分区过程如图:

计算机生成了可选文字:才 夸 兮 日 冫 8 刃 0 ' 7 冫 8 n 0 印 丿 “ 叩 觥 為 囝 右 P 礻 了 方 冫

因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个6的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。

   (2)、快速排序的性能分析

对于快排的稳定性和空间复杂度已经在实现原理的时候分析了,下面来看看快排的时间复杂度。

快排也是通过递归来实现的。对于递归代码的时间复杂度,我们前面总结的公式,这里也适用。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是O(nlogn).

计算机生成了可选文字:T(1) T(n) = 2 町 ( 酊 2 ) 时 , 只 要 常 里 的 行 时 阎 , 所 以 表 示 为

三、快速排序与归并排序的区别?

计算机生成了可选文字:3 , 卜 甩 丿 , 刀 \

由图可知:归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排序正好相反,它处理的过程由上到下的,先区分,然后再处理子问题,归并排序虽然是稳定的,时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。快速排序通过设计巧妙的原地区分函数,可以实现原地排序解决,解决了归并排序占用太多内存的问题。

但是,公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等地一分为二,但实际上这种情况是很难是很难实现的。

举个极端例子:

如果数组中的数据原来已经是有序的了,比如1,3,5,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就从O(nlogn)退化成O(n^2)。

刚刚是两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡,它们分别对应快排的最好情况和最坏情况,那快排的平均情况时间复杂度是多少?

我们假设每次分区操作都将区间分成9:1的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:

计算机生成了可选文字:T(1) T(n) = n/le ) 时 , 只 要 常 里 的 行 时 阎 , 所 以 表 示 为 · T(9*n/1e)

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资料:

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值