title: 12排序(下)
date: 2019/12/5
tags: 数据结构与算法
categories:
- 算法
如何用快排思想在O(n)内查找第K大元素?
- 上一节学习了冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是O(n*n),比较高,适合小规模数据的排序。
- 这节学习复杂度为O(nlogn)的排序算法,归并排序和快速排序。这两种排序算法适合大规模的数据排序,比上节的排序算法更要常用。
归并排序(Merge Sort)
原理
- 核心思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组都有序了。
- 归并排序使用的就是分治思想。分治,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
- 分治一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧。
实现
- 递归代码的编写技巧
- 分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。
- 归并排序的递归公式
递推公式:
merge_sort(p..r)=merge(merge_sort(p..q),merge_sort(q+1..r))
终止条件:
p>=r 不用再继续分解
-
解释递推公式:q等于(p+r)/2。两个子数组都排好序之后,再将两个有序的子数组组合并在一起,这样p到r之间的数据也排好序了。
-
递归代码
//归并排序,a表示数组,n表示数组大小
public void merge_sort(int[] a,n){
merge_sort_c(a,0,n-1)
}
//调用递归函数
public int[] merge_sort_c(a,p,r){
//递归终止条件
if(p>=r){
return a;
}
//取p到r之间的中间位置q;
int q=(p+r)/2;
//分治递归
merge_sort_c(a,p,q);
merge_sort_c(a,q+1,r);
//将a[p...q]和a[q+1...r]合并为a[p...r](伪代码)
merge(a[p...r],a[p...q],a[q+1...r]);
}
//merge函数的伪代码
merge(a[p...r],a[p...q],a[q+1...r]){
//初始化变量
int i=p;
int j=q+1;
int k=0;
//申请一个大小跟a[p...r]一样的临时数组
int[] tmp=new int[];
while(i<q&&j<r>){
if(a[i]<=a[j]){
tmp[k++]=a[i++];
}else{
tmp[k++]=a[j++];
}
}
//判断哪个子数组中有剩余的数据
int start=i;
int end=q;
if(j<=r>){
start=j;
end=r;
}
//将剩余的数据拷贝到临时数组tmp
while(start<=end){
tmp[k++]=a[start++];
}
//将tmp中的数组拷贝回a[p...r]
for(int i=0;i<=r-p;i++){
a[p+i]=tmp[i];
}
}
性能分析
- 归并排序稳定吗?
- 在合并过程中,如果a[p…q]和a[q+1…r]之间有值相同的元素,那么可以像伪代码中那样,先把a[p…q]中的元素放入tmp数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以归并排序是一个稳定的排序算法。
- 归并排序的时间复杂度?
- 归并排序涉及递归,时间复杂度计算:
- 递归适用的场景是,一个问题a可以分解称为多个子问题b、c,那能求解问题a就可以分解为求解问题b、c。问题b、c解决之后,再把b、c的结果合并成a的结果。
- 如果我们定义求解问题a的时间是T(a),求解问题b、c的时间分别是T(b)和T©,那么我们可以得到这样的递推关系式:
T(a)=T(b)+T(c)+K
。其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。 - 不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
- 推导时间复杂度的递推公式可以得到归并排序的时间复杂度为O(nlogn)。
- 归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管最好、最坏、还是平均情况,时间复杂度都是O(nlogn)。
- 归并排序涉及递归,时间复杂度计算:
- 归并排序空间复杂度
- 不是原地排序算法,因为归并排序的合并函数,合并两个有序数组为一个有序数组时,需要借助额外的存储空间,每次合并之后,临时开辟的内存空间就被释放掉了,所以空间复杂度是O(n)。
快速排序(Quicksort)
原理
- 核心思想:
- 如果要排序数组中下标从p到r之间的一组数据,我选择p到r之间的任意一个数据作为pivot(分区点)。
- 遍历p到r之间的数据,将小于pivot的放到左边,小于pivot的放到右边,pivot放在中间。
- 根据分治递归的思想,用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据(q为分区点),直到区间长度缩小为1,就说明所有数据都有序了。
实现
- 递推公式表示:quick_sort(p…r)=quick_sort(p…q-1)+quick_sort(q+1…r)
- 终止条件:q>=r
- 伪代码
//快速排序,a是数组,n表示数组大小
quick_sort(int[] a,n){
quick_sort_c(a,0,n-1);
}
//快速排序递归函数,p、r为下标
public int[] quick_sort_c(int[] a,p,r){
if(p>=r){
return a;
}
int q=partition(a[],p,r)//获取分区点
quick_sort_c(a[],p,q-1);
quick_sort_c(a[],q+1,r);
return a;
}
- partition()分区函数就是随机选择一个元素做为pivot(一般情况下可以选择p到r区间的最后一个元素),然后随a[p…r]分区,函数返回pivot的下标。
- partition()原地分区函数伪代码
public int partition(int a[],int p,int r){
int pivot=a[r];
int i=p;
for(int j=p;j<r;j++){
if(a[j]<pivot){
int tmp=a[i];
a[i]=a[j];
a[j]=tmp;
i++;
}
}
int tmp1=a[i];
a[i]=a[r];
a[r]=tmp1;
return i;
}
归并排序和快速排序的区别
- 归并排序的过程是由下到上的,可以先处理子问题,然后再合并。而快速排序是由下到上的,先分区,然后再处理子问题。
- 归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但它是非原地排序算法。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
性能分析
- 快速排序是一种原地的、不稳定的排序算法。
- 如果每次选择的分区点pivot都很合适,正好能将大区间对等地一分为二,快速排序的时间复杂度是O(nlogn),实际上很难实现。
- 极端的情况下,如果数组中的数据已经是有序的了,每次选择最后一个元素作为分区点pivot,那么每次得到的区间都是不均等的,我们需要大概n次分区操作,才能完成快排。此时时间复杂度O(n*n)
- 大部分情况下,快排时间复杂度都能做到O(nlogn),极端情况下O(n*n),而且也有很多方法降低极端情况概率。(具体方法后续讲)
如何用快排思想在O(n)内查找第K大元素?
- 选择数组区间a[0…n-1]的最后一个元素a[n-1]作为pivot,对数组原地分区,这样就分成了三部分,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]区间内查找。
课后思考
- 现在你有10个接口访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件,合并为1个日志文件,合并之后的日志仍然要按照时间戳从小到大排列。如果处理上述排序人物的机器内存只有1GB,你有什么好的解决思路,能“快速”地将这10个日志文件合并吗?
- 太难了。我不会。