美文网首页
数据结构与算法(七)排序

数据结构与算法(七)排序

作者: 0胡杨0 | 来源:发表于2021-09-29 22:21 被阅读0次

    如何分析一个排序算法?

    1,排序算法的执行效率

    执行效率包括:
    最好情况、最坏情况、平均情况时间复杂度,
    时间复杂度的系数、常数、低阶,
    比较次数和交换(或移动)次数

    2,排序算法的内存消耗

    空间复杂度为O(1)的排序算法为原地排序

    3,排序算法的稳定性

    稳定排序算法:就是两个相同的数据,经过排序之后,前后位置没有发生改变.
    例:

    var dic = [{"key":1,"name":"张1"},{"key":5,"name":"张5"},{"key":3,"name":"张31"},{"key":2,"name":"张2"},{"key":3,"name":"张32"}];
    

    根据key的值将数组进行排序.
    稳定排序的结果:

    var dic = [{"key":1,"name":"张1"},{"key":2,"name":"张2"},{"key":3,"name":"张31"},{"key":3,"name":"张32"},{"key":5,"name":"张5"}];
    

    张31和张32的顺序未发生改变

    冒泡排序

    原理

    比较相邻的两个数据,看是否满足大小要求,如果不满足就让他俩互换.重复n次,就完成了n个数据的排序

    代码实现

    - (NSArray *)bubbleSortWithArray:(NSArray *)array
    {
        if (array.count <= 1) {
            return array;
        }
        
        NSMutableArray *tmpArray = [NSMutableArray arrayWithArray:array];
        
        for (int i = 0; i < tmpArray.count; i ++) {
            BOOL isNoChange = NO;
            for (int j = 0; j < tmpArray.count - i - 1; j ++) {
                if (array[j] > array[j + 1]) {
                    int tmp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = tmp;
                    isNoChange = YES;
                }
            }
            if (!isNoChange) {
                return tmpArray;
            }
        }
        return tmpArray;
    }
    

    是否为原地排序

    是,只做相邻两个数的交换,空间复杂度为O(1)

    是否为稳定排序

    是,当相邻两个数据大小相等的时候我们可以不交换数据.

    时间复杂度

    最好时间复杂度

    要排序的数据已经是有序的了,我们只需要一次冒泡就可以结束排序,所以最好情况时间复杂度是O(n)

    最坏时间复杂度

    要排序的数据是倒序的,那么我们需要n次冒泡才可以结束排序,所以最坏情况时间复杂度是O(n²)

    平均情况时间复杂度

    平均情况时间复杂度我们可以通过有序度逆序度这两个概念来进行分析.有数据1,2,3,4,5,6 这是一个完全有序的数据,我们称它为满有序度 公式为 n(n-1)/2. 这个数据的有序度就是6(6-1)/2=15,逆序度就是0. 满有序度=有序度+逆序度.
    计算平均情况时间复杂度我们可以取这个公式的中间值n*(n-1)/4,所以平均情况时间复杂度就是O(n²).

    插入排序

    原理

    我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。


    插入排序原理

    代码实现

    // 插入排序,a表示数组,n表示数组大小
    public void insertionSort(int[] a, int n) {
      if (n <= 1) return;
    
      for (int i = 1; i < n; ++i) {
        int value = a[i];
        int j = i - 1;
        // 查找插入的位置
        for (; j >= 0; --j) {
          if (a[j] > value) {
            a[j+1] = a[j];  // 数据移动
          } else {
            break;
          }
        }
        a[j+1] = value; // 插入数据
      }
    }
    

    是否为原地排序

    是,并不需要额外的存储空间,空间复杂度为O(1).

    是否为稳定排序

    是,我们可以选择将后面出现的元素插入到前面出现元素的后面,这样就保持原有的顺序不变.

    时间复杂度

    最好时间复杂度

    最好的情况就是数组是有序的,这个时候的复杂度为O(n).

    最坏时间复杂度

    最坏的情况就是数组是完全倒序的,这个时候的复杂度是O(n²).

    选择排序

    原理

    选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。


    选择排序

    是否为原地排序

    是,并不需要额外的存储空间,空间复杂度为O(1).

    是否为稳定排序

    不是,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。

    时间复杂度

    选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2).


    总结

    归并排序

    原理

    归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
    归并排序使用的是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。


    归并原理

    归并排序这种借助分治思想来完成的排序需要使用递归技巧来实现,下面的是递推公式:

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

    
    // 归并排序算法, A是数组,n表示数组大小
    merge_sort(A, n) {
      merge_sort_c(A, 0, n-1)
    }
    
    // 递归调用函数
    merge_sort_c(A, p, r) {
      // 递归终止条件
      if p >= r  then return
    
      // 取p到r之间的中间位置q
      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(A[p...r], A[p...q], A[q+1...r]) {
      var i := p,j := q+1,k := 0 // 初始化变量i, j, k
      var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组
      while i<=q AND j<=r do {
        if A[i] <= A[j] {
          tmp[k++] = A[i++] // i++等于i:=i+1
        } else {
          tmp[k++] = A[j++]
        }
      }
      
      // 判断哪个子数组中有剩余的数据
      var start := i,end := q
      if j<=r then start := j, end:=r
      
      // 将剩余的数据拷贝到临时数组tmp
      while start <= end do {
        tmp[k++] = A[start++]
      }
      
      // 将tmp中的数组拷贝回A[p...r]
      for i:=0 to r-p do {
        A[p+i] = tmp[i]
      }
    }
    

    merge函数解析:
    你可能已经发现了,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]中。

    是否为原地排序

    不是,空间复杂度是 O(n)。

    是否为稳定排序

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

    时间复杂度

    归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 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,就说明所有的数据都有序了。
    代码:

    func sort(arr:inout Array<Int>,left:Int,right:Int) {
        if left >= right {
            return;
        }
        
        
        let pivot:Int = pivotMethod(arr: &arr, left: left, right: right);
    
        sort(arr: &arr, left: 0, right: pivot - 1);
        sort(arr: &arr, left: pivot + 1, right: right);
    }
    //以一个基数把数组分区
    func pivotMethod(arr:inout Array<Int>,left:Int,right:Int) -> Int {
        let pivot:Int = arr[right];
        var i = left;
        
        for j in 0..<arr.count {
            if arr[i] < pivot {
                // 交换数组内的元素
                arr.swapAt(i, j);
                i = i + 1;
            }
        }
        arr.swapAt(i, right);
        return i;
    }
    
    var arr:[Int] = [4,1,3,6,8,5,9];
    
    sort(arr: &arr, left: 0, right: arr.count - 1);
    print(arr);
    

    pivotMethod说明:

    这里的处理有点类似选择排序。我们通过游标 i 把 A[left...right]分成两部分。A[p...i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i...right]是“未处理区间”。我们每次都从未处理的区间 A[i...right]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。在数组某个位置插入元素,需要搬移数据,非常耗时。如果在 O(1) 的时间复杂度内完成插入操作,只需要将 A[i]与 A[j]交换,就可以在 O(1) 时间复杂度内将 A[j]放到下标为 i 的位置。 pivotMethod说明

    是否为原地排序

    是,在排序的时候没有产生额外的空间开销.

    是否为稳定排序

    不是,在pivotMethod步骤的时候元素位置会发生交换.

    时间复杂度

    在大部分情况下快排的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。

    优化快速排序

    如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n2)。实际上,这种 O(n2) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现最坏情况O(n2)。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均。这里介绍两个比较常用、比较简单的分区算法。

    1. 三数取中法我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。
    2. 随机法随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大。

    归并排序和快速排序的区别

    归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。 区别

    ps:内容整理自极客时间--数据结构与算法之美

    相关文章

      网友评论

          本文标题:数据结构与算法(七)排序

          本文链接:https://www.haomeiwen.com/subject/xeqxvhtx.html