排序算法总结

排序算法总览

分类:

  • 插入类:直接插入排序、折半插入排序、希尔排序
  • 交换类:冒泡排序、快速排序
  • 选择类:直接选择排序、堆排序
  • 归并类:二路归并排序

特征:

  • 平均时间复杂度:快、希、归、堆排序为:O(nlog2 n);其余排序为O(n2 )(记忆口诀:快些归队)
  • 稳定性:快、希、选、堆排序为:不稳定;其他排序是稳定的(记忆口诀:快些选一堆)
  • 经过一趟排序就能保证一个元素到达最终位置:(交换类)冒泡排序、快速排序、(选择类)直接选择排序、堆排序
  • 元素比较次数与初始序列无关:直接选择排序、折半插入排序
  • 排序的趟数与初始序列有关:交换类
    • 冒泡排序:越有序,需要的趟数越少
    • 快速排序:越有序,需要的趟数越多
    • 注意插入排序需要的趟数是固定n-1的,只是越有序每趟元素比较的次数越少

——>下面具体分析每种排序:

  • 注:默认序列下标从0开始

一、插入排序

基本思想:将待排序表看做两个部分:无序区、有序区。整个排序过程就是将无序区的元素逐个插入到有序区中(注意①如何寻找插入点,②插入时如何移动有序区的元素是关键),构成新的有序区。

  • 直接插入排序
  • 折半插入排序
  • 希尔排序

1、直接插入排序

基本思想

将待排序表分为左右两个部分,无序区在左边,有序区在右边。一边寻找插入点一边后移。

-------○■
----------○■
-------------○■
  • 双层循环。
  • 外层:由小到大(从左到右)。由■代表i:待插入值的序号,从i = 1开始
  • 内层:从右到左。由○代表j=i-1:被比较值的序号

代码

/**
  * 直接插入排序
  * @param R[0..N-1] 待排序表
  * @param n 序列个数
  */
void insertSort(int[] R, int n) {
    int i, j;
    int temp;
    //外层循环:由小到大
    for (i = 1; i < n; i++) {
        temp = R[i];
        j = i-1;
        //内层循环:从右到左(首先要进行数组越界判断)
        while (j >= 0 && temp < R[j]) {
            //一边寻找插入点一边后移
            R[j+1] = R[j];
            --j;
        }
        //插入位置
        R[j+1] = temp;
    }   
}

方法改进:监视哨

对于待排序表R[1..N],由于R[0]不设置元素,在R[0]处设置“监视哨”,作用:

  • 相当于越界判断
  • 相当于temp

——>改进代码:

/**
  *  带监视哨的直接插入排序
  * @param R[1..N] 待排序表
  * @param n 序列个数
  */
void insertSort1(int[] R, int n) {
    int i, j;
    // 外层循环
    for (i = 1; i < n; i++) {
        R[0] = R[i];
        j = i-1;
        //内层循环(不需要j>=1)
        while (R[0] < R[j]) {
            R[j+1] = R[j];
            --j;
        }
        // 插入位置
        R[j+1] = R[0];
    }   
}

性能分析

适用在序列基本有序的情况中。

  • 最好的情况:整个序列已经有序,时间复杂度O(n)
  • 最坏的情况:整个序列逆序,基本操作需要执行i=2n(i1) =n(n-1)/2,时间复杂度O(n2 )
  • 平均时间复杂度:O(n2 )
  • 空间复杂度:O(1),额外空间只有一个temp

2、折半插入排序

基本思想

其排序的思想与直接插入排序一样,只不过在寻找插入点时不一样:先通过二分法找到插入点,即low处,然后在后移元素(不能像直接插入排序一边寻找一边后移)

-------○■
----------○■
-------------○■
  • 双层循环。
  • 外层:由小到大(从左到右)。由■代表i:待插入值的序号
  • 内层:从右到左。由○代表high=i-1(这里high相当于直接插入排序中的j)
  • 比较的方式不同:先二分查找,在整体后移

代码

/**
  * 折半插入排序
  * @param R[0..N-1] 待排序表
  * @param n 序列个数
  */
void binaryInsertSort(int[] R, int n) {
    //外层循环
    for (int i = 1; i < n; i++) {
        int temp = R[i];
        int low = 0;
        int high = i-1;
        //内层循环
        //①先寻找插入点
        while (low <= high) {
            int middle = (low + high) / 2;
            if (temp < R[middle]) {
                high = middle -1;
            }else {
                low = middle + 1;
            }   
        }
        //②插入点的索引就是low,然后后移元素
        for (int j = i; j > low; j--) {
            R[j] = R[j-1];
        }
        //插入
        R[low] = temp;  
    }
}

性能分析

折半插入排序适合序列数较多的场景。与直接插入排序相比,折半插入排序在寻找插入点所花费的时间将大大减少(比较次数与初始序列无关),但是在移动次数方面和直接插入排序一样的。所以时间复杂度与直接插入排序是一样的。

  • 最好的情况:整个序列已经有序,时间复杂度O(n)
  • 最坏的情况:整个序列逆序,时间复杂度O(n2 )
  • 平均时间复杂度:O(n2 )
  • 空间复杂度:O(1)

3、希尔排序

基本思想

将待排序表划分为若干组(步长d),在每组中进行直接插入排序,通过缩小步长使序列逐渐有序。

  • 注意是三层循环:最外层循环用于缩量步长,后两次循环按照直接插入排序的过程

——>为啥需要希尔排序?

  • 我们知道直接插入排序适合序列基本有序的情况,希尔排序在每次迭代(最外层循环)中通过缩小增量步长的方式来使整个序列逐渐基本有序
  • 元素个数越少,直接插入排序的效率越高

——>步长的取值

依次逐渐缩小:d1 = n/2,d2 = d1 /2,…,dk = 1
注意,最后一趟的dk 一定要为1

——>例子:

这里写图片描述

代码

/**
 * 希尔排序
 * @param R[0..N-1] 待排序表
 * @param n 元素个数
 */
void shellSort(int R[], int n) {
    //增加步长d
    for (int d = n/2; d >= 1 ; d /= 2) {
        //以下按照直接插入排序的过程
        for (int i = 0 + d; i < n; i++) {
            int temp = R[i];
            //相当于直接插入排序中的j = i-1
            int j = i-d;
            while (j > 0 && temp < R[j]) {
                //后移d位
                R[j+d] = R[j];
                j = j-d;
            }
            R[j+d] = temp;
        }
    }
}

性能分析

  • 时间复杂度O(nlog2 n):由于每一趟的序列都认为基本有序,则各趟的时间复杂度为O(n);总共需要的趟数为log2 n
  • 空间复杂度O(1)

二、交换排序

基本思想:两两比较待排序表的元素,发现倒序就交换。比较/交换的位置不同出现不同的方法。

  • 冒泡排序:相邻位置比较
  • 快速排序:与选出的中间元素比较

1、冒泡排序

基本思想

冒泡一趟,一定能将该趟序列中的最大(最小)元素交换到最终的位置。

------------
----------
--------
  • 双层循环
  • 外层:由大到小(从右到左)。由■代表i:每趟逐渐减小i。从i=n-1开始,到i=1结束
  • 内层:从左到右。由○代表j:一趟比较序号从j=1开始,到j=i结束(下标0基址)

代码

/**
 * 冒泡排序
 * @param R[0..N-1] 待排序表
 * @param n 元素个数
 */
void bubbleSort(int[] R, int n) {
    //外层循环
    for (int i = n-1; i >= 1; i--) {
        //用来标记该趟排序是否发生了交换
        boolean flag = false;
        //内层循环
        for (int j = 1; j <= i; j++) {
            if (R[j-1] > R[j]) {
                //交换
                int temp = R[j];
                R[j] = R[j-1];
                R[j-1] = temp;

                flag = true;
            }
        }
        //一趟排序过程中没有发生交换,说明序列已经有序
        if (!flag) {
            return;
        }
    }
}

性能分析

  • 最好的情况:整个序列已经有序,仅需要一趟排序,执行n-1次,时间复杂度O(n)
  • 最坏的情况:整个序列逆序,基本操作需要执行n(n-1)/2,时间复杂度O(n2 )
  • 平均时间复杂度:O(n2 )
  • 空间复杂度:O(1),额外空间只有一个temp

2、快速排序

基本思想

分治法的思想(在分阶段同时进行排序):首先选定一个元素作为中间元素,然后将表中所有元素与该元素比较,比它小的调到表的前面,比它大的调到表的后面。一趟排序完后以中间元素为分裂点将表分为左右两个子表继续排序。

通用的快速排序思想:首先选择表头作为中间元素temp。然后,从j开始扫描,遇到小于temp的停止扫描,将A[i](此时的i在中间元素位置,并保存在temp中)与A[j]交换,然后i++。接着,从i开始扫描,遇到大于temp的停止扫描,将A[j]与A[i]交换,然后j- -。以此类推,直到i与j交叉或相遇,将temp赋值到A[i]中。

具体分析详见:分治法中的合并排序和快速排序

代码

/**
 * 快速排序
 * @param R[0..N-1] 待排序表
 * @param n 元素个数
 */
void quickSort(int[] R,int l,int r){
     if (l < r) {   
          int i = l;
          int j = r;
          int temp = R[l];  
          while (i < j) {  
              //i<j为越界限制
              while(i < j && R[j] > temp) // 从右向左找第一个小于x的数  
                  j--;    
              if(i < j)   
                  R[i++] = R[j];  

              while(i < j && R[i] < temp) // 从左向右找第一个大于等于x的数  
                  i++;    
                  if(i < j)   
                      R[j--] = R[i];  
           }  
           R[i] = temp;  
           quickSort(R, l, i-1);
           quickSort(R, i+1, r);
      }
}

性能分析

从平均时间性能来说,快速排序目前被认为是最好的一种内部排序方法。

  • 一趟划分比较的时间复杂度固定在O(n)
  • 最好的情况:每趟都将子表等分成两部分,需要log2 n趟,理想的时间复杂度为O(nlog2 n)
  • 最坏的情况:序列基本有序,每次选取的中间元素要么最大要么最小,划分成一个空表一个n-1的子表,则需要n-1趟,最坏的时间复杂度O(n2 )
  • 平均时间复杂度:趋向于最好的情况,O(nlog2 n)
  • 空间复杂度:O(log2 n),递归需要栈的辅助

三、选择排序

基本思想:在每一趟排序中,在待排序表中都选出最大或最小的元素放到最终的位置。选择的方式不同,出现不同的方法。

  • 直接选择排序
  • 堆排序

1、直接选择排序

基本思想

每趟选择完,都将待排序表中的最大(小)元素放到表后(前)。这里每次选的是最小元素。

■○----------N-1
  ■○--------N-1
    ■○------N-1
  • 双层循环
  • 外层:由大到小(但从左到右)。由■代表i:每趟也逐渐减小i。但从i=0开始,到i=n-1结束
  • 内层:从左到右。由○代表j:一趟比较序号从j=i+1开始,到j=n-1结束

代码

/**
 * 直接选择排序
 * @param R R[0..N-1] 待排序表
 * @param n 元素个数
 */
void selcetSort(int[] R, int n) {
    int i, j;
    int minIndex;
    //外层循环
    for (i = 0; i < n; i++) {
        minIndex = i;
        //内层循环
        for (j = i+1; j < n; j++) {
            //选出最小元素索引
            if (R[j] < R[minIndex]) {
                minIndex = j;
            }
        }
        if (minIndex != i) {
            //交换
            int temp = R[i];
            R[i] = R[minIndex];
            R[minIndex] = temp;
        }   
    }
}

性能分析

  • 时间复杂度:由于比较次数固定:(n-1 + 1)(n-1)/2 = n(n-1)/2,且与初始序列无关。所以时间复杂度为O(n2 )
  • 空间复杂度:O(1),额外空间只有一个temp

2、堆排序

基本思想

可以把堆看成一颗完全二叉树。必须满足任何一个非叶子节点的值不大于(小根堆)或不小于(大根堆)左右孩子节点的值。

堆排序的过程:初始建堆——>n-1调整——>n-1-1调整——>…

建立完一趟的大根堆,并不意味着排序完成。虽然可以得到最大的元素,但是无法知道左右孩子的正确序列。所以需要将根删除继续n-1建堆,直到堆规模只剩下一个元素。

建堆的过程:先将待排序表转变成完全二叉树形式,然后建堆,可以参考:图解排序算法(三)之堆排序

但是要记住:从第一个非叶子节点开始,从右到左,从下到上,对每个节点进行调整,最终得到一颗大根堆。

代码

  • 注:序列的下标从1开始。则2i为节点i的左孩子,2i+1为节点i的右孩子
/**
 * 堆排序 
 * @param R[1..N] 待排序表
 * @param n 元素个数
 */
void heapSort(int[] R, int n) {
    //初始建堆
    //从n/2表示最后一个非叶子节点开始,从右到左,从下到上
    for (int i = n/2; i >= 1; i--) {
        sift(R, i, n);
    }
    for (int i = n; i >= 2 ; i--) {
        //交换,将根节点放到序列最终位置
        int temp = R[1];
        R[1] = R[i];
        R[i] = temp;
        //往后的调整,每次只需要从头结点开始就可以了
        sift(R, 1, i-1);
    }       
}

/**
 * 筛选
 * @param R
 * @param low
 * @param high
 */
void sift(int[] R, int low, int high) {
    //节点i,和其左孩子2i
    int i = low, j = 2*i;
    int temp = R[i];
    while (j <= high) {
        //如果右孩子大,则j指向有孩子
        if (j < high && R[j] < R[j+1]) {
            ++j;
        }
        //如果父节点小于孩子,赋值父节点
        if (temp < R[j]) {
            R[i] = R[j];
            i = j;
            //继续比较下去,孩子的孩子节点是否大于temp
            j = 2*i;
        }else {
            break;
        }
    }
    R[i] = temp;    
}

性能分析

  • 时间复杂度为:O(nlog2 n)
    • 筛选操作的时间:堆排序算法花费的时间最多用在初始建堆和调整时所进行的筛选上。每个节点筛选的时间复杂度为O(log2 n)(解释:完全二叉树的高度k=log2 n+1,最多需要比较2(k-1)次)。
    • 初次建堆的时间:初始建堆需要n/2次筛选操作
    • 调整操作的时间:剩下需要n-1调整操作,每次只需要筛选头节点
    • 因此堆排序算法的整个基本操作次数为O(log2 n)x(n/2) + O(log2 n)x(n-1)。简化后时间复杂度为O(nlog2 n)
  • 空间复杂度:O(1),额外空间只有一个temp
  • 堆排序适合的场景是记录数很多的情况,比如从10000个记录中选出前10个最小的。

四、归并排序

基本思想

所谓归并是指将两个或两个以上的有序表合并成一个新的有序表。归并排序也可以归纳为分治法的思想,但是重点在合并阶段。

具体分析及代码详见:分治法中的合并排序和快速排序

性能分析

  • 时间复杂度为:O(nlog2 n)。其中,一趟排序的时间复杂度为O(n),需要 log2 n趟递归
  • 空间复杂度:O(n),需要存储整个待排序表

参考

  • 《数据结构(C++描述)》 胡学刚 张晶 主编 人民邮电出版社
  • 《2015版数据结构高分笔记》
  • 图解排序算法
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章