• 数据结构与算法之美读书笔记11


    归并排序的原理

    如果要排序一个数组,那么先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

    归并排序使用的是分治思想

    分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

    分治思想跟递归思想很像。分治算法一般都是用递归来实现的。

    如何用递归代码来实现归并排序

    写递归代码的技巧

            先分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。

    1. 递推公式:
    2. merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
    3. 终止条件:
    4. p >= 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之间的数据就也排好序了。

    申请一个临时数组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]中。

    merge()函数伪代码

    1. merge(A[p...r], A[p...q], A[q+1...r]) {
    2. var i := p,j := q+1,k := 0 // 初始化变量i, j, k
    3. var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组
    4. while i<=q AND j<=r do {
    5. if A[i] <= A[j] {
    6. tmp[k++] = A[i++] // i++等于i:=i+1
    7. } else {
    8. tmp[k++] = A[j++]
    9. }
    10. }
    11. // 判断哪个子数组中有剩余的数据
    12. var start := i,end := q
    13. if j<=r then start := j, end:=r
    14. // 将剩余的数据拷贝到临时数组tmp
    15. while start <= end do {
    16. tmp[k++] = A[start++]
    17. }
    18. // 将tmp中的数组拷贝回A[p...r]
    19. for i:=0 to r-p do {
    20. A[p+i] = tmp[i]
    21. }
    22. }

    归并排序的时间复杂度任何情况下都是O(nlogn)。

    快速排序的原理

    快排利用的也是分治思想。

    快排的思想是这样的:如果要排序数组中下标从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. 递推公式:
    2. quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
    3. 终止条件:
    4. p >= r

    伪代码

    1. // 快速排序,A是数组,n表示数组的大小
    2. quick_sort(A, n) {
    3. quick_sort_c(A, 0, n-1)
    4. }
    5. // 快速排序递归函数,p,r为下标
    6. quick_sort_c(A, p, r) {
    7. if p >= r then return
    8. q = partition(A, p, r) // 获取分区点
    9. quick_sort_c(A, p, q-1)
    10. quick_sort_c(A, q+1, r)
    11. }

    但是,如果按照这种思路实现的话,partition()函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度得是O(1),那partition()分区函数就不能占用太多额外的内存空间,我们就需要在A[p…r]的原地完成分区操作。

    原地分区函数的实现思路非常巧妙,我写成了伪代码,我们一起来看一下。

    1. partition(A, p, r) {
    2. pivot := A[r]
    3. i := p
    4. for j := p to r-1 do {
    5. if A[j] < pivot {
    6. swap A[i] with A[j]
    7. i := i+1
    8. }
    9. }
    10. swap A[i] with A[r]
    11. return i

    这里的处理有点类似选择排序。我们通过游标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(nlogn)的排序算法,但是它是非原地排序算法。归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

  • 相关阅读:
    leetcode路飞吃桃,递归做法
    使用服务器训练模型的注意事项
    MySQL表的约束
    海康威视面试经历
    Oracle之SQL plus的一些经验心得
    5.Maven实战 --- 坐标和依赖
    Synopsys Sentaurus TCAD系列教程之-Tcl《2》
    Module object(emscripten)
    基于SWAT-MODFLOW地表水与地下水耦合
    音视频播放器—快进快退及逐帧播放
  • 原文地址:https://blog.csdn.net/m0_62742402/article/details/126615101