上一节讲到冒泡排序、插入排序、选择排序这三种排序算法,他们的时间复杂度都是$O(n^2)$,比较高,适合小规模的排序。今天讲两种时间复杂度为$O(nlogN)$的排序算法,归并排序和快速排序。这两种算法适合大规模的数据排序,比上一节的三种算法更常用。
归并排序和快速排序都用到了分治思想,非常巧妙,我们可以借鉴这个思想,来解决非排序的问题,比如:如何在O(n)时间复杂度内查找一个无序数组中的第K大元素?,这就要用到今天讲的内容。
我们先来看看归并排序。
归并排序的核心思想还是蛮简单的。如果需要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就有序了。
归并排序使用的就是分治思想。分治,顾名思义就是分而治之。将一个大问题分解为若干个小问题来解决,小问题解决了,大问题也就解决了。
从我们刚才的描述,你有没有感觉到,分治思想跟我们前面讲过的递归想想很想。是的,分治思想一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。
前面我通过举例让你对归并有了一个感性的认识,又告诉你,归并排序用的是分治思想,可以用递归来实现。我们现在就来看看如何用递归代码实现归并排序。
我们在递归那一节讲的递归代码的编程技巧你还记得吗?递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。
1 | 递推公式 |
我来解释一下这个公式,merge_sort(p…r)表示给下标在p到r之间的数组排序,我们将这个问题转化为了两个子问题,merge_sort(p…q)和merge_sort(q+1…r),其中下标q就是p和r的中间位置,也就是q=(p+r)/2,。当下标p到q和从q+1到r这两个子数组都排好序之后,我们在将两个有序的子数组合并在一起,这样下标p到r之间的数据也就排好序了。
有了递推公式,转化成代码就简单多了。
1 | // 归并排序 |
你可能已经发现了,merge(A[p…r], A[p…q], A[q+1…r])这个函数的作用就是,讲已经有序的A[p…q]和A[q+1…r]合并成另一个有序的数组,并且放入A[p…r]。那这个过程具体该怎么做呢?
如图所示,我们申请一个临时数组temp,大小与A[p…r]相同。我们用两个指针i,j分别指向A[p…q]和A[q+1…r]的第一个元素,比较这两个元素A[i]和A[j],如果A[i]小于A[j],我们就把A[i]放入temp数组中,并将i后移一位,否则将A[j]放入temp数组中,j后移一位。
继续上述比较过程,知道其中一个子数组中的所有数据都放入临时数组中,再把另外一个数组中的数据依次加入到temp数组的末尾。这个时候,临时数组temp中存储的就是两个子数组合并之后的结果了。最后再把临时数组temp中数据拷贝到原数组里A[p…r]中。
还记得上节课分析排序算法时的三个问题吗?接下来,我们来看一看归并排序的三个问题。
第一、 归并排序是稳定的排序算法吗?
结合我们前面的原理图和归并排序的代码,不难发现,归并排序稳不稳定关键要看merge函数,也就是两个有序数组合并为一个有序数组时的那部分代码。
在合并的过程中,如果A[p…q]和A[q+1…r]之间有值相同的元素,我们可以像上面代码中那样,先把A[p…q]中的元素放入临时数组temp中,这样就保证了值相同的元素,合并前后顺序并不会改变。所以,归并排序是一个稳定的排序算法。
第二、归并排序的时间复杂度是多少?
归并排序涉及递归,时间复杂度的分析稍微有点复杂,我们正好借此机会来学习一下,如果很细递归代码的时间复杂度。
在递归那一节我们讲过,递归适用场景是,一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解子问题b、c。子问题b、c解决之后,我们再把b、c的结果合并成a的结果。
我们定义求解问题a的时间为T(a),求解问题b、c的时间分别是T(b)、T(c),那我们就可以得到这样的递推公式:$T(a) = T(b) + T(c) + K$。其中K是将两个子问题b、c的结果合并所需的时间。
从上面的分析,我们得出一个重要的结论:不仅递归求解的问题可以写成递推公式,递推代码的时间复杂度也可以写成递推公式。
套用这个公式,我们来分析一下归并排序的时间复杂度。
我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。我们知道,merge函数合并两个有序子数组的时间复杂度是O(n)。所以套用前面的公式,归并排序的时间复杂度计算公式是:
$$
\begin{cases}
T(1) = C; & n=1 \\[2ex]
T(n) = 2*T(\frac{n}{2}) + n; & n>1
\end{cases}
$$
通过这个公式,如何来求解T(n)呢?还不够直观,我们再来进一步分解一下计算过程
$$
\begin{align*}
T(n) \ &= \ 2*T(\frac{n}{2}) \ + \ n \\[2ex]
&= 2*(2 * T(\frac{n}{4}) + \frac{n}{2}) \ + \ n \qquad = 4*T(\frac{n}{4}) + 2*n \\[2ex]
&= 4*(2* T(\frac{n}{8}) + \frac{n}{4}) \ + \ 2 * n \ \; = 8*T(\frac{n}{8}) + 3*n \\[2ex]
&= 8*(2* T(\frac{n}{16}) + \frac{n}{8}) \ + \ 3 * n \ \; = 16*T(\frac{n}{16}) + 4*n \\[2ex]
&= …… \\[2ex]
&= 2^{k} * T(\frac{n}{2^{k}}) + k * n
\end{align*}
$$
这样一步步推导,我们可以得到$T(n) \ = \ 2^{k} * T(\frac{n}{2^{k}}) + k * n $。当$T(\frac{n}{2^{k}})=T(1)$时,也就是$\frac{n}{2^{k}} = 1$时,我们得到$k = log_{2}n$。我们将k值带入上面的公式得到$T(n) \ = \ Cn + n*log_{2}n$。如果我们用大O表示法来表示的话,$T(n)$就等于$O(n*log_{2}n)$。所以归并排序的时间复杂度是$O(n*log_{2}n)$。
从我们的原理分析和代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管最好、最坏、平均情况时间复杂度都为$O(n*log_{2}n)$。
第三、归并排序是不是原地排序算法呢?
归并排序的时间复杂度在任何情况下都是$O(n*log_{2}n)$,看起来非常优秀。待会你会发现,即使是快速排序,最坏情况下时间复杂度也是$O(n^2)$,但是归并排序并不像快排那样,应用广泛,这是为什么?因为它有一个指明的弱点,那就是归并排序并不是一个原地排序算法 。
这是因为归并排序的合并函数,在合并两个有数组为一个有序数组时,需要借助额外的临时存储空间。这一点很好理解,那归并排序的空间复杂度到底是多少呢?是O(n),还是$O(n*log_{2}n)$,该如何分析呢?
如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并排序的空间复杂度就是$O(n*log_{2}n)$。不过类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?
实际上,递归代码的空间复杂度并不像时间复杂度那样累加。我们刚刚忘了最重要的一点,那就是,尽管每次合并都需要申请额外的临时空间,但是在合并完成之后,临时空间就会被释放。在任意时刻,CPU只会有一个函数在执行,也就是只有一块临时空间在使用,临时空间内存大小最大不会超过n,所以归并排序的空间复杂度是O(n)。
我们再来看快速排序的原理,我们习惯性的把它简称为“快排”,快排利用的也是分治思想。乍看起来,他有点像归并排序,但其实思路完全不一样,待会再看两者的区别。现在我们先来看看快排的核心思想。
快排的思想是这样的,如果要排序数组中从下标p-r之间的一组数据,我们选择p到r之间的任意一个数作为pivot分区点。
第一次遍历,我们将p到r之间的数据分为两部分。将小于pivot的放到左边,将大于pivot的放到右边。讲过这一步之后,p-r之间的数据就被分成了三部分,前面p到q-1之间的数据都是小于pivot的,中间是pivot,后面q+1到r之间的数据都是大于pivot的。
根据分治、递归的思想,我们可以用递归排序p到q-1之间的数据和下边在q+1到r之间的数据,知道区间缩小为1,就说明所有的数据都有序了。
如果我们用递推公式来将上面的过程写出来的话,就是这样:
1 | # 递推公式 |
我将递推公式转换为递归代码,你可以根据代码将其翻译为你熟悉的任何语言的代码。
1 | private static void quickSort(int[] arr, int n) { |
归并排序有一个merge合并函数,快排这里也有一个partation分区函数。partation分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为pivot,然后对A[p…r]分区,函数返回pivot的小标。
如果我们不考虑空间消耗的话,partation分区函数可以写的非常简单。我们申请两个临时数组X和Y,遍历A[p…r],将小于pivot的元素都拷贝到临时数组X中,将大于pivot的元素都拷贝到临时数组Y中,最后再讲数组X和数组Y中的数据顺序拷贝到数组A[p…r]中。
不过如果按这种思路实现的话,partation函数就需要很多额外的内存空间,所以快排也就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度都是O(1),那partation分区函数就不能占用太多的内存空间,我们就需要在A[p…r]原地完成分区操作。
原地分区函数的实现思路非常巧妙,我下面用伪代码实现:
1 | partation(a,p,r){ |
这里的处理有点类似于选择排序。我们通过游标i把A[p…r-1]分成了两部分,A[p…i-1]的元素都是小于pivot的,我们暂且叫它“已处理区间”,A[i…r-1] 是“未处理区间”。我们每次从未处理区间A[i…r-1]中取一个元素A[j],与pivot对比,如果小于pivot,则将其加入到已处理区间的尾部,也就是 A[i] 的位置。
数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。当时我们也讲了一种技巧,就是交换,在O(1)时间复杂度内完成插入操作。我们也借助这个思想,只需要将 A[i] 和 A[j] 交换,就可以在O(1)时间复杂度内将 A[j] 放到小标 i 的位置。
因为分区的操作涉及交换操作,如果数组中出现两个相同的元素,比如序列6,8,7,6,3,5,9,4,在经过第一次分区之后,两个6的相对位置就会发现变化。所以快速排序并不是一个稳定的排序算法。
到此,快速排序的原理你应该掌握了。现在,我们来看另一个问题:快速排序和归并排序都是用的分治思想,递推公式和递归代码也非常相似,那它们的区别到底在哪里呢?
可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后在合并。而快排正好相反,他的处理过程是由上到下的,先分区,然后处理子问题。归并排序虽然是稳定的,时间复杂度为$O(n*log_{2}n)$的排序算法,但是它是非原地排序算法。我们上面讲过,归并排序之所以不是原地排序算法,是因为合并函数无法在原地执行。而快排通过设计巧妙的分区函数,可以实现原地排序,解决了归并排序占用太多内存空间的问题。
现在我们来分析一下快速排序的性能。上面在讲解快排原理的时候,已经分析了快速排序的稳定性和空间复杂度。快排是一种原地、不稳定的排序算法,现在我们来分析一下快排的时间复杂度。
快排也是用递归实现的,对于递归代码的时间复杂度,我前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那块拍的时间复杂度递推求解公式跟归并是一样的。所以快排的时间复杂度也是$O(n*log_{2}n)$。
$$
\begin{cases} \\
\ T(1) = C; & n=1 \\[2ex]
\ T(n) = 2*T(\frac{n}{2}) + n; & n>1
\end{cases}
$$
但是公式成立的前提是我们每次分区操作,选择的pivot都很合适,正好是将大区间对等一份为二,但这种情况是很难实现的。
我举一个极端的例子,加入数组中的数据原来就已经是有序的了,比如1,3,5,6,8,如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不对等的。我们需要进行大约n次分区操作,才能完成快排的整个过程,这种情况下,快排的时间复杂度就从$O(n*log_{2}n)$退化成了$O(n^2)$。
我们刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。他们分别对应到快排的最好时间复杂度和最坏情况时间复杂度。那快排的平均时间复杂度是多少呢?
实际上,递归的时间复杂度的求解除了递推公式之外,还有递归树,在树那一节再讲,这里暂且不说,这里直接给出结论:快排的平均复杂度也是$O(n*log_{2}n)$,只有在极端情况下才会退化为$O(n^2)$。而且我们也有办法将这个概率降到很低,如何来做,我们后面排序优化再讲。
快排的核心思想是分治和分区。我们可以利用快排的思想,来解答开篇的问题:O(n)的时间复杂度内求解无序数组中第K大元素,比如4,2,5,12,3这样一组数据,第三大元素就是4。
我们选择数组区间A[p…r]最后一个元素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个元素。第二次分区查找,只需要对大小为2/n的数组执行分区操作,需要遍历n/2个元素。以此类推,分区遍历的元素个数分别为n、n/2、n/4、n/8、n/16……直到区间缩小为1.
如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+……+1。这是一个等比数列求和。最后的和为2n-1,所以上述解决问题的时间复杂度为O(n)。
归并排序和快速排序是两种稍微复杂的排序算法,他们用的都是分治的思想,代码都是通过递归来实现的。过程非常相似。理解归并排序的重点是理解递推公式和merge合并函数。同理,理解快排的重点是理解递推公式和partation分区函数。
归并排序是一种在任何情况下时间复杂度都比较稳定的算法,这也使得它具有了致命的弱点,即归并排序并不是原地排序算法,空间复杂度比较高,是O(n)。正应为此,他也没有快排应用广泛。
快速排序算法虽然最坏情况时间复杂度是O(n^2),但是平均情况下时间复杂度都是$O(n*log_{2}n)$。不仅如此,快速排序时间复杂度退化到O(n^2)的概率也非常小,我们可以通过合理的选择pivot来避免这种情况。
课后思考
1、现在你有10个接口访问日志文件,每个日志文件大小300MB,每个日志文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件,合并为一个日志文件,合并之后的日志仍然按照时间从小到大排序。如果处理上述排序任务的机器内存只有1GB,你有什么好的解决思路,能快速的将10个日志文件合并吗?
多路归并、外排序