排序算法总结

·  阅读 72
  • O(n*n):冒泡,插入,选择
  • O(nlogn):归并,快速 
  • O(n):桶,计数,基数

排序算法主要从 时间复杂度,空间复杂度和稳定性三个方面考虑。

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

实际上,冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。

// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}
复制代码

时间复杂度:最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n*n)。平均情况下的时间复杂度是O(n*n)

空间复杂度:冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。

稳定性:在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

插入排序

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

// 插入排序,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(n)。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n*n)。每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n*n)

空间复杂度:插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

稳定性:在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。

选择排序

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

public static void selectionSort(int[] arr){  
   for(int i = 0; i < arr.length - 1; i++){
     //假设每次循环时,最小数的索引为i     
     int minIndex = i;     //每一个元素都和剩下的未排序的元素比较    
     for(int j = i + 1; j < arr.length; j++){        
        if(arr[j] < arr[minIndex]){   
        minIndex = j;//将最小数的索引保存     
     }
     //经过一轮循环,就可以找出第一个最小值的索引,然后把最小值放到i的位置
     int temp = arr[i];
     arr[i] = arr[minIndex];
     arr[j] = temp;
  }
}
复制代码

时间复杂度:选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n*n)。因为不管原来的顺序是什么,都要从无序数组中找到最小值,而最小值只能通过全部比较一次才能得到。

空间复杂度:选择排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

稳定性:选择排序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

另外,代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。所以如果我们希望把性能优化做到极致,那肯定首选插入排序。

冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}

插入排序中数据的移动操作:
if (a[j] > value) {
  a[j+1] = a[j];  // 数据移动
} else {
  break;
}
复制代码

归并排序

归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

// 归并排序算法, 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++] 
    } 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]
  }
}
复制代码

时间复杂度T(a) = T(b) + T(c) + K。其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:T(n) = 2*T(n/2) + n=O(nlogn)。

空间复杂度:归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。所以空间复杂度是 O(n),也就是说,这不是一个原地排序算法。

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

快速排序

快速排序也使用了分治思想。快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。 我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

const quickSort = (array) => { 
   const sort = (arr, left = 0, right = arr.length - 1) => {
     if (left >= right) {
        //如果左边的索引大于等于右边的索引说明整理完毕
        return  
     } 
     let i = left 
     let j = right 
     const baseVal = arr[j] // 取无序数组最后一个数为基准值 
     while (i < j) {
        //把所有比基准值小的数放在左边大的数放在右边  
        while (i < j && arr[i] <= baseVal) { 
           //找到一个比基准值大的数交换   
           i++  
        }  
        arr[j] = arr[i] // 将较大的值放在右边如果没有比基准值大的数就是将自己赋值给自己(i 等于 j)  
        while (j > i && arr[j] >= baseVal) {
            //找到一个比基准值小的数交换   
            j-- 
        }  
        arr[i] = arr[j] // 将较小的值放在左边如果没有找到比基准值小的数就是将自己赋值给自己(i 等于 j) 
     } 
     arr[j] = baseVal // 将基准值放至中央位置完成一次循环(这时候 j 等于 i ) 
     sort(arr, left, j-1) // 将左边的无序数组重复上面的操作 
     sort(arr, j+1, right) // 将右边的无序数组重复上面的操作 } 
     const newArr = array.concat() // 为了保证这个函数是纯函数拷贝一次数组 
     sort(newArr) 
     return newArr
  }
}
复制代码

时间复杂度:如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)。但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n*n)。

空间复杂度:快速排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

稳定性:因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并是一个不稳定的排序算法。

线性排序算法

  • 线性排序算法包括桶排序、计数排序、基数排序
  • 线性排序算法的时间复杂度为O(n)。  
  • 对排序数据的要求很苛刻,重点掌握此3种排序算法的适用场景。

桶排序

算法原理:

  • 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序。
  • 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

使用条件:

  • 要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。

  • 数据在各个桶之间分布是均匀的。

适用场景:

  • 桶排序比较适合用在外部排序中。 
  • 外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中。

应用案例:

  • 需求描述: 有10GB的订单数据,需按订单金额(假设金额都是正整数)进行排序 但内存有限,仅几百MB。
  • 解决思路: 扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。 第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。 每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。 将100个小文件依次放入内存并用快排排序。 所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。 
  • 注意点:若单个文件无法全部载入内存,则针对该文件继续按照前面的思路进行处理即可。

计数排序

算法原理:

  • 计数其实就是桶排序的一种特殊情况。

  • 当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶。

  • 每个桶内的数据值都是相同的,就省掉了桶内排序的时间。

使用条件:

  • 只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序。
  • 计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数。
  • 比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。

应用案例:

  •  假设只有8个考生分数在0-5分之间,成绩存于数组A[8] = [2,5,3,0,2,3,0,3]。

  • 使用大小为6的数组C[6]表示桶,下标对应分数,即0,1,2,3,4,5。 C[6]存储的是考生人数,只需遍历一边考生分数,就可以得到C[6] = [2,0,2,3,0,1]。 

  • 对C[6]数组顺序求和则C[6]=[2,2,4,7,7,8],c[k]存储的是小于等于分数k的考生个数。

  • 数组R[8] = [0,0,2,2,3,3,3,5]存储考生名次。那么如何得到R[8]的呢? 从后到前依次扫描数组A,比如扫描到3时,可以从数组C中取出下标为3的值7,也就是说,到目前为止,包括自己在内,分数小于等于3的考生有7个,也就是说3是数组R的第7个元素(也就是数组R中下标为6的位置)。当3放入数组R后,小于等于3的元素就剩下6个了,相应的C[3]要减1变成6。 以此类推,当扫描到第二个分数为3的考生时,就会把它放入数组R中第6个元素的位置(也就是下标为5的位置)。当扫描完数组A后,数组R内的数据就是按照分数从小到大排列的了。(这么做是为了保证稳定排序)

  •  

基数排序

算法原理(以排序10万个手机号为例来说明):

  • 如果只比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。

  • 比较多个手机号码,借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推。注意,这里按照每位来排序的算法要是稳定的,否则这个实现思路就是不正确的。

  • 经过11次排序后,手机号码就变为有序的了。

  • 每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。

使用条件:

  • 要求数据可以分割独立的“位”来比较。
  • 位之间由递进关系,如果a数据的高位比b数据大,那么剩下的低位就不用比较了。
  • 每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。 

如何实现一个通用的,高性能的排序函数

线性排序算法虽然时间复杂度比较低,但使用场景特殊,缺乏通用性。如果针对小规模数据排序,可以选择时间复杂度未O(n*n)的算法;如果对大规模数据排序,时间复杂度为O(nlogn)的算法更加高效。所以,为了兼顾任意规模数据的排序,一般选择时间复杂度为O(nlogn)的算法实现排序函数。

考虑到归并排序的空间复杂度为O(n),快速排序更加适合来实现排序函数,但是,快速排序再最坏情况下的时间复杂度为O(n*n),如何来优化这个问题呢?

我们先来看下,为什么最坏情况下快速排序的时间复杂度是 O(n*n) 呢?前面讲过,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n*n)。实际上,这种 O(n*n) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。 那什么样的分区点是好的分区点呢?或者说如何来选择分区点呢? 最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多

这里介绍两个比较常用、比较简单的分区算法。 

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

为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。

**在小规模数据面前,O(n*n) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。**因为在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。

分类:
阅读
标签:
收藏成功!
已添加到「」, 点击更改