数据结构与算法--排序(二)

一,前言

归并排序和快速排序都用了分治的思想,来解决排序的问题。

二,归并排序的原理

归并排序的核心思想是:先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起。例图如下:

归并排序使用的就是分治的思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。类似于递归的思想。即归并排序可以用递归实现。

用递归实现算法的步骤如下:

1,找出递推公式

归并的思想就是将其分成两个小区间,先分别排序,再按大小合并。我们可以得到公式如下:

            mergeSort(p, r) = merge(mergeSort(p, p), mergeSort(p+1, r))

其中下标q等于p和r的中 间位置,也就是(p+r)/2。当下标从p到q和从q+1到r这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从p到r之间的数据就也排好序了。

2,找到终止条件

终止的条件是当 p>=r 时,即只剩一个元素时达到最小区域,停止递归。

3,合并写出代码

伪代码大致如下:

void MergeSort(A,n){
    MergeSort_c(A, 0, n-1);
}

void MergeSort_c(A, p, r){
    if(p>=r) return;

    q= (p+r)/2;
    MergeSort_c(A, p, q);
    MergeSort_c(A, q+1, r);
    Merge(A[p, r], A[p,q], A[q+1,r]);
}

对于Merge()函数的目的就是将两个小区间排序,后将其合并传回给大区间。在排序过程中,可以定义临时数据来存放已排序好的数组,待排序结束再复制给大区间。但合并函数如果借助哨兵,代码会更加简洁。

三,归并排序的性能分析

1,归并算法是否是稳定算法?

归并排序稳不稳定关键要看Merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代 码。 在合并的过程中,如果A[p…q]和A[q+1…r]之间有值相同的元素,那我们可以像伪代码中那样,先把A[p…q]中的元素放入tmp数组。这样就保证了值相同的元素, 在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。 

2,归并算法的时间复杂度

分析归并算法的时间复杂度,即分析递归的时间复杂度,问题a的时间是T(a),求解问题b、c的时间分别是T(b)和 T( c),那我们就可以得到这样的递推关系式:

                                                       T(a) = T(b) + T(c) + K

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

递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。并套用这个公式,可以分析一下归并排序的时间复杂度。 

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

T(1) = C;   n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1 通过这个公式,如何来求解T(n)呢?还不够直观?那我们再进一步分解一下计算过程。

T(n) = 2*T(n/2) + n    

= 2*(2*T(n/4) + n/2) + n

= 4*T(n/4) + 2*n    

= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n    

= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n    

......    

= 2^k * T(n/2^k) + k * n

可以得到T(n) = 2^kT(n/2^k)+kn。当T(n/2^k)=T(1)时,也就是n/2^k=1,我们得到k={log_{2}}^{n} 。我们将k值代入上面的公式,得到T(n)=Cn+n{log_{2}}^{n}。如果我们用大O标记法来表示的话,T(n)就等于O(nlogn)。所以归并排序的时间复杂度是O(n{log_{2}}^{n})

归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情 况,还是平均情况,时间复杂度都是O(n{log_{2}}^{n})。 

3,归并算法的空间复杂度

递归代码的空间复杂度并不能像时间复杂度那样累加。尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会 超过n个数据的大小,所以空间复杂度是O(n)。 

四,快速排序的原理

快排利用的也是分治思想。有点像归并排序,但是思路其实完全不一样。

快排的思想是这样的:如果要排序数组中下标从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是,所以的数据都排好序了。

根据递归的思想,实现步骤如下:

1,找出递归公式:

                             QuestSort(p, r) = QuestSort(p, q-1) + QuestSort(q+1, r)

2,终止条件

终止的条件是当 p>=r 时,即只剩一个元素时达到最小区域,停止递归。 

3,合并伪代码:

void QuestSort(A, n){
    QuestSort_c(A, 0, n-1);
}

void QuestSort_c(A, p, r){
    if(p>=r) return;
    q = Partition(A,p,r);  //获取分区点
    QuestSort_c(A, p, q-1);
    QuestSort_c(A, q+1, r);
}

归并排序中有Merge()合并函数将分区排好序的小区间合并,快速排序有Partition()分区函数,就是随机选择一个元素作为pivot(一般情况下,可以选择p到r区间的最后一个元素),然后对A[p…r]分区,函数返回pivot的下标。 

对于Partition()分区函数的实现,如果不考虑内存问题,可以写得非常简单:申请两个临时数组X和Y,遍历A[p…r],将小于pivot的元素都拷贝到临时数组X,将大于pivot的元素都拷贝到临时数组Y,最后再将数组X和数组Y中数据顺序拷贝到A[p…r]。

如果按照这种思路实现的话,partition()函数就需要很多额外的内存空间。分区函数也可以原地排序, 来完成分区操作,这样其空间复杂度就是O(1) ,不用占多额外空间,原地完成排序,是一种原地排序算法。如图:

如图,除了类似于选择排序,通过游标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的位置。伪代码如下:

int Partition(A, p, r){
    int i = p;
    int j = p;
    int pivot = A[r];
    for(int j = i; j<r; j++){
        if(A[j] < pivot){
            swap A[i] with A[j]
            i++;     //小于pivot个数+1,空间长度+1
        }
    }
    swap A[i] with A[r];
    return i;
}

五,快速排序的性能分析

快排是一种原地的,不稳定排序,因为在分区过程中涉及交换操作,若数值中存在两相同元素,比如6,8,7,6,3,5,9,5,以最好一个元素未pivot,两个相同元素在分区时会改变先后顺序。

快排的时间复杂度是O(n{log_{2}}^{n}),只有在极端情况下会退化到O(n^{2})。,比如1,3,5,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间 都是不均等的。我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约n/2个元素,这种情况下O(n^{2})

六,归并和快排的区别

快排和归并用的都是分治思想,递推公式和递归代码也非常相似,但是还是有区别的:

可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法,因为合并函数无法原地执行。而快排的分区函数可以利用交换的思想实现原地排序。

七,小结

归并排序和快速排序,都是用分治的思想,通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和Merge()合并函数。同理,理解快排的重点也是理解递推公式,还有Partition()分区函数。 归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是O(n)。 正因为此,它也没有快排应用广泛。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值