排序算法

troysps 2020-07-19

内部排序:数据记录在内存中进行排序
外部排序:待排序文件较大,需要访问外存

常见的内部排序:插入排序(直接插入、折半插入、希尔排序)、交换排序(冒泡、快排)、选择排序(简单选择、堆排序)、归并排序(2路归并)、基数排序
外排:归并排序(多路归并)、

各种内排的性能比较:
排序算法


插入排序

  • 每次将一个待排序的记录按关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成
  • 每一轮能够确定一个最终位置的记录,某时刻的状态为:[有序序列] Li [无序序列]
  • 一般采用就地排序(空间复杂度为O(1))
  • 步骤:
    • 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
    • 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

1. 直接插入

  • 边比较边移动
  • 时间复杂度:插入操作n-1趟,每趟的比较和移动次数取决于待排序表的初始状态
    • 最好情况(有序):只需要 比较n-1次,不需要移动,O(n)
    • 最坏(逆序):比较次数和移动次数都达到最大,O(n^2)
    • 平均情况:取最好最坏的平均值,比较次数和移动次数都约为n^2/4,O(n^2)
  • 稳定性:从后向前先比较再移动,因此相同元素的相对位置不会发生改变,稳定。
  • 适用性:直插适用于顺序存储和链式存储的顺序表
    //直接插入
    void insertSort(int A[], int n){
    int i,j,tmp;
    for(i=1;i<n;++i){ //从后往前寻找A[i]插入位置 
        if(A[i]<A[i-1]){ //需要将A[i]插入到前面已排序序列 
            tmp = A[i];         
            for(j=i-1;j>=0 && A[j]>tmp;--j){
                A[j+1]=A[j]; //往后移动 
            } 
            A[j+1]= tmp; //插入 
        }
    }
    }

    2. 折半插入

  • 先比较,再插入
  • 只能用于顺序存储的线性表
  • 时间复杂度:相比直接插入,减少了比较次数,约为O(nlogn),比较次数与初始序列无关;移动次数不变,还是依赖于初始序列,因此,折半插入的时间复杂度仍为O(n^2).
  • 稳定
  • 上述两种插入排序比较适合基本有序和数据量不大的排序表
    //折半插入
    void insertSort2(int A[], int n){
    int i,j,tmp,low,high,mid;
    for(i=1;i<n;i++){
        //先折半查找
        tmp = A[i];
        low=0;high=i-1;
        while(low<=high){
            mid=(low+high)/2;
            if(tmp<A[mid])
                high=mid-1;
            else
                low=mid+1;
        } 
        //往后移动元素
        for(j=i-1;j>=high+1;--j)
            A[j+1]=A[j];
        A[high+1]=tmp; //插入 
    }
    }

    3. 希尔排序

  • 缩小增量排序(有种分组排序的感觉)
  • 先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
  • 步骤:
    • 选择一个递减增量序列 d1,d2,……,dk,其中 dk = 1;
      1. 按增量序列个数 k,对序列进行 k 趟排序
      2. 每趟排序,根据对应的增量 di,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
      3. 一般,初始增量gap=n/2(就是上面说的d1),缩小增量继续以gap=gap/2,并且最后一个增量等于1
  • 举个例子:对【8 9 1 7 2 3 5 4 6 0】希尔排序。
    • 选择初始增量gap=10/2=5,即将数组分成5组:【8 3】、【9 5】、【1 4】、【7 6】【2 0】,对这5组分别进行插入排序得到:【3 5 1 6 0 8 9 4 7 2】
    • 缩小增量gap=5/2=2,即将数组分成2组:【3 1 0 9 7】、【5 6 8 4 2】,对这2组分别进行插入排序,得到:【0 2 1 4 3 5 7 6 9 8】
    • 缩小增量gap=2/2=1,即直接对数组【0 2 1 4 3 5 7 6 9 8】进行插排,这时的数组已经比较有序,移动次数较少
  • 时间复杂度:在n为某个特定范围时,时间复杂度约为O(n^1.3),最坏情况下O(n^2)
  • 稳定性:不稳定,因为相同关键字可能被划分到不同的子表
  • 适用性:仅适用于线性表为顺序存储的情况
    //希尔排序
    void shellSort(int A[], int n){
    int i,j,tmp,dk;
    for(dk=n/2;dk>=1;dk /= 2){ //增量变化 
        for(i=dk;i<n;++i){
            if(A[i]<A[i-dk]){ //需要将A[i]插入到前面已排序序列
                tmp = A[i]; 
                for(j=i-dk;j>=0 && A[j]>tmp;j -= dk){
                    A[j+dk]=A[j]; //往后移动 
                } 
                A[j+dk]= tmp; //插入 
            }
        } 
    }
    }

    交换排序

    所谓交换,是指根据两个元素关键字比较的结果来对换这两个记录的位置

1. 冒泡排序:

  • 从后往前(或者从前往后)两两比较相邻的值,若为逆序,就交换,直到序列比较完。这为一趟冒泡,结果是将最小的元素交换到第一个位置。最多n-1趟完成。
  • 特点:每次都能确定一个元素的最终位置!
  • 空间复杂度:O(1)
  • 时间复杂度:
    • 最好情况:比较次数n-1,移动次数0,时间复杂度O(n)
    • 最坏情况:n-1趟排序,第i趟需要n-i次比较,每次比较,需要移动元素3次来交换元素位置,比较次数n(n-1)/2,移动次数3n(n-1)/2,时间复杂度O(n^2)
    • 平均:O(n^2)
  • 稳定性:元素相等的时候,不会交换,是稳定算法。
    //冒泡 
    void bubbleSort(int A[], int n){
    int i,j,tmp;
    for(i=1;i<n;i++){ //n-1趟 
        /* 
        for(j=0;j<n-i;++j){ //从前往后冒泡 
            if(A[j+1]<A[j]){ //每次冒最大值,实现升序 
                tmp = A[j];
                A[j] = A[j+1];
                A[j+1] = tmp;
            }
        }
        */
        for(j=n-1;j>i;--j){ //从后往前冒泡 
            if(A[j-1]>A[j]){ //每次冒最小值,实现升序 
                A[j] = A[j]^A[j-1];
                A[j-1] = A[j-1]^A[j];
                A[j] = A[j]^A[j-1];
            } 
        }
    }
    }

    2. 快排

  • 对冒泡的改进,基于分治的思想,其划分操作决定了算法的性能
  • 步骤:
    • 首先,选出一个元素pivot作为基准
    • 通过一趟快排之后,所有比pivot小的元素都在该元素之前,比它大的所有元素都在该基准的后面,而该基准放在了最终的位置上,这个过程叫做partition
    • 然后分别递归地对上述两个区间做以上的排序操作
  • 空间复杂度:快排是递归的,需要借助递归工作栈来实现,其容量与递归深度有关
    • 最好情况:O(logn)
    • 最差情况:进行n-1次递归,深度为O(n)
    • 平均情况:O(logn)
  • 时间复杂度:
    • 最坏情况:两个区间分别包含n-1和0个元素,并且初始排序表基本有序或者逆序,时间复杂度为O(n^2)
    • 最好情况:最平衡的划分,使得两个子问题的大小都不可能大于n/2,时间复杂度为O(nlogn)
    • 平均情况:快排平均情况下的运行时间与最好情况接近,而不是与最坏的接近,因此,快排是所有内部排序算法中平均性能最优的
  • 如何提高算法性能:
    • 当递归到子序列的规模比较小的时候,就不用快排了,改用直接插入
    • 尽量选取一个可以将数据中分的pivot。(比如,从序列的头尾和中间取三个,再取这三个元素的中间值作为基准,或者用随机的方式)
  • 稳定性:比如在右端区间有两个小于基准值且相等的元素,在交换到左区间的时候,相对位置发生改变,不稳定。
    //快排
    void quickSort(int A[], int low, int high){
    int pivot, pivotpos, left, right;
    if(low < high){
        //1.划分
         pivot = A[low];  //取第一个元素作为基准
         left = low; right = high;
         while(left<right){
            while(left<right && A[right]>=pivot){
                --right;
             }  
             A[left] = A[right]; //把比基准小的元素交换到左边
             while(left<right && A[left]<=pivot){
                ++left;
             } 
             A[right] = A[left]; //把比基准值大的元素移动到右边 
         } 
         A[left] = pivot; //基准元素放到最终位置 
         pivotpos = left;
         //2.递归 
         quickSort(A,low,pivotpos-1);
         quickSort(A,pivotpos+1,high); 
    }
    }

    选择排序

    每一趟(第i趟)在后面n-i+1个待排序列中选取最小的元素,作为有序子序列的第i个元素,直到n-1趟做完,就只剩1个元素,就不用选了。

1. 简单选择

  • 第i趟排序从序列[i,n]中选出最小的与L(i)交换,每趟排序可以确定一个元素的最终位置.
  • 空间复杂度:O(1)
  • 时间复杂度:移动次数较少,主要取决于比较次数,而比较次数与初始序列无关,始终是n(n-1)/2,所以复杂度始终为O(n^2).
  • 稳定性:min和i交换时,就肯能导致i与其他相同元素的相对位置发生改变,不稳定。
    //简单选择
    void selectSort(int A[], int n){
    int i,j,min,tmp;
    for(i=0;i<n;i++){ //依次确定0到n-1位置上的元素 
        min=i;
        for(j=i+1;j<n;j++){
            if(A[j]<A[min])
                min = j;
        }
        if(min!=i){
            tmp=A[min];
            A[min]=A[i];
            A[i]=tmp;
        }
    }
    }

    2. 堆排序

  • 树形选择排序,将待排序列视为一棵完全二叉树的顺序存储结构,利用完全二叉树双亲节点和孩子节点的内在关系,在当前无序区中挑选最小元素。
    • 大根堆:L(i)>=L(2i) && L(i)>=L(2i+1)
    • 小根堆:L(i)<=L(2i) && L(i)<=L(2i+1)
      排序算法
      排序算法
  • 步骤:包括建堆和调整堆的过程。
    • 建堆,建堆过程要不断向下调整,构造出大根堆
    • 建堆完成后,将堆顶元素取出,将末尾元素置于堆顶,重新调整结构。反复输出堆顶并调整(输出堆顶的方式,就是将堆顶元素置于堆底,然后再调整未排序堆的结构),最终得到一个有序序列
  • 空间复杂度:O(1)
  • 时间复杂度:建堆时间为O(n),之后有n-1次的向下调整,每次调整时间为O(h),所以在最好、最坏和平均情况下,时间为O(nlogn).
  • 稳定性:在筛选的时候,很可能把后面相同的元素调整到前面,不稳定。
    //将元素k向下调整 
    void AdjustDown(int A[], int k, int n){
    int i,tmp;
    tmp = A[k];
    for(i=2*k;i<n;i*=2){  //沿较大的子节点向下筛选 
        if(i<n && A[i]<A[i+1]){
            ++i;            //取较大的子节点的下标 
        }
        if(A[i] <= tmp)
            break;          //筛选结束 
        A[k]=A[i];          //将A[i]调整到双亲节点
        k=i;                //更新k值,继续向下筛选 
    } 
    A[k]=tmp;       //被筛选的点放入最终位置 
    } 
    //堆排序(大根堆) 
    void heapSort(int A[], int n) {
    int i,tmp; 
    //建堆,从i=[n,2]~1,反复调整堆(也就是从最后一个叶子节点的父节点开始,往前)
    for(i=n/2;i>=0;--i){
        AdjustDown(A,i,n);
    } 
    //n-1趟交换和调整堆
    for(i=n-1;i>=0;--i){ //输出堆顶元素,和堆底元素交换 
        tmp=A[0];
        A[0]=A[i];
        A[i]=tmp;
        AdjustDown(A,0,i-1); 
    } 
    }

    归并排序

  • 建立在归并操作上,也是分治法的典型应用
  • 和选择排序一样,性能不受初始序列的影响,但是表现比选择排序好,因为任何情况下都是O(nlogn)的时间复杂度,代价是需要额外的内存空间
  • 两种实现:
    • 自上而下的递归,基于分治
    • 自下而上的迭代(就是递归改迭代写法)
  • 步骤:
    • 需要一个辅助数组,申请空间=两个已排序序列之和
    • 设定两个指针,初始位置分别为两个已排序序列的起始位置,做合并有序列表的工作
  • 空间复杂度:需要辅助空间,O(n)
  • 时间复杂度:每趟归并的复杂度为O(n),共需要进行logn趟归并,复杂度为O(nlogn).
  • 稳定性:由于归并操作不会改变相同元素的相对位置,是稳定的。
    //归并排序
    void Merge(int A[], int low, int mid, int high){
    int i,j,k;
    int B[n]={0};
    //A的[low...mid]和[mid+1...high]各自有序,将它们合成一个有序表
    for(i=low;i<=high;i++){
        B[i]=A[i];
    } 
    for(i=low,j=mid+1,k=i; i<=mid && j<=high; ++k){  
        if(B[i]<B[j])
            A[k]=B[i++];    //把较小的值拷到A中
        else
            A[k]=B[j++]; 
    }
    while(i<=mid){
        A[k++]=B[i++];      //拷贝前一个表剩余元素 
    }
    while(j<=high){
        A[k++]=B[j++];      //拷贝后一个表剩余元素 
    } 
    }
    void mergeSort(int A[],int low,int high){
    if(low<high){
        int mid=(low+high)/2;
        mergeSort(A,low,mid);
        mergeSort(A,mid+1,high);
        Merge(A,low,mid,high); //归并 
    }
    }

    基数排序

  • 基于多关键字排序,即根据关键字各位的大小进行排序,分为最高位优先(MSD)和最低位(LSD)排序,会使用到桶(bucket)。
  • 步骤:
    • 将所有元素(正整数)统一为同样的数位长度,数位较短的前面补零
    • 从最低位开始,依次进行排序
  • 空间复杂度:假设每个关键字由d元组构成,关键码的范围为r(基数为r),由于一趟排序需要的辅助空间是r个队列,所以空间复杂度为O(r)
  • 时间复杂度:d趟排序,每趟分配的时间为O(n),收集的时间为O(r),所以时间复杂度为O(d(n+r))
  • 稳定

相关推荐