数据结构与算法之美笔记——线性排序

摘要:

本章主要介绍三种线性排序,分别是「桶排序」「计数排序」「基数排序」,时间复杂度都为 O ( n ) O(n) O(n),但是只适合于某些特殊场景下的数据排序。

本章主要会介绍三种「线性排序」算法,为何会称为线性排序?因为这三种排序算法的时间复杂度都是 O ( n ) O(n) O(n),时间复杂度是呈线性增长,所以称为线性排序。

桶算法( Bucket Sort )

原理

桶算法的原理是将原数据的数据范围拆分为多个范围,这些小范围的空间就像桶一样,原数据根据所在范围被分配进相应的桶中,在桶内对数据采用 O ( n l o g n ) O(nlogn) O(nlogn) 的排序算法进行排序。因为桶与桶之间是自然大小排序的,桶内数据排序完成后按照桶顺序输出,输出的结果就是已经排序好的数组。

代码实现
private int bucketAmount = 5;

@Override
public void sort(int[] array, int len) {
  if(array == null || array.length < 2) {
    return;
  }

  int[] range = getRange(array);
  int min = range[0];
  int bucketSize = (range[1] - min + 1) / bucketAmount;

  int[][] bucketArray = new int[bucketAmount][array.length];

  // initialize end index of every bucket array
  int[] bucketCount = new int[bucketAmount];
  for(int i = 0; i < bucketCount.length; i++) {
    bucketCount[i] = 0;
  }

  for(int i = 0; i < array.length; i++) {
    int bucketIndex = (array[i] - min) / bucketAmount;
    bucketArray[bucketIndex][bucketCount[bucketIndex]++] = array[i];
  }

  QuickSort quickSort = new QuickSort();
  for(int i = 0; i < bucketAmount; i++) {
    quickSort.sort(bucketArray[i], bucketArray[i].length);
  }

  int arrayCount = 0;
  for(int i = 0; i < bucketAmount; i++) {
    for(int j = 0; j < bucketCount[i]; j++) {
      array[arrayCount++] = bucketArray[i][j];
    }
  }
}

/**
 * get range of array, index 0 of return array is min,
 * index 1 of return array is max
 *
 * @param array
 * @return
 */
private int[] getRange(int[] array) {
  int min = Integer.MAX_VALUE;
  int max = Integer.MIN_VALUE;

  for(int i = 0; i < array.length; i++) {
    min = Math.min(array[i], min);
    max = Math.max(array[i], max);
  }
  return new int[] {min, max};
}
时间复杂度分析

假设数据规模为 n n n,根据数据范围拆分为 m m m 个桶,数据能够被平均分配到桶中,每个桶可以分配到 k k k 个数据, k k k 满足关系 k = n m k=\frac{n}{m} k=mn,每个桶内的排序采用 O ( n l o g n ) O(nlogn) O(nlogn) 的排序算法,所以每个桶内排序时间复杂度为 O ( k ∗ l o g k ) O(k*logk) O(klogk),整个桶排序算法的时间复杂度为 O ( m ∗ k ∗ l o g k ) O(m*k*logk) O(mklogk),将 k k k 的关系式代入结果为 O ( m ∗ n m ∗ l o g n m ) = O ( n ∗ l o g n m ) O(m*\frac{n}{m}*log\frac{n}{m})=O(n*log\frac{n}{m}) O(mmnlogmn)=O(nlogmn),当桶个数 m m m 趋近于数据规模 n n n l o g n m log\frac{n}{m} logmn 会成为一个较小的常量,所以桶排序的时间复杂度为 O ( n ) O(n) O(n)

虽然桶排序的时间复杂度为 O ( n ) O(n) O(n),但这是基于我们进行了许多理想化的假设基础上。首先数据要容易划分为 m m m 个桶,桶之间有天然的大小顺序,数据分配到桶要平均,所以桶排序对数据的要求比较苛刻。事实上数据很难平均分配到桶内,当桶内数据出现多少之别时桶内的排序时间复杂度也就不是常量级的了,甚至在极端情况下,数据都被分配到一个桶中,这个时候桶排序的时间复杂度就会退化为 O ( n l o g n ) O(nlogn) O(nlogn)

应用场景

桶排序适合使用在「外部排序」的场景中,外部排序就是指外部磁盘数据量大,而内存有限相对较小,无法将数据一次性读入内存中的情况。

例如,有日志数据 10G 大小,内存 200M,需要将日志数据排序,这种情况就适合使用桶排序。可以先扫描日志数据的数据范围,按日志时间拆分为多个连续范围,例如 20190601,20190602……,以此类推,并将相应范围的文件按先后顺序编号,再将日志数据分配到相应范围的文件内,对文件内数据进行快排,再按文件顺序读入输出到同一文件中。

虽然对大规模数据进行了拆分,但是数据分配肯定有不均匀的情况,导致某些文件依然过大,无法读入内存中,这样的文件可以依照之前的方式再次进行拆分,直到所有的文件大小都可以被读入内存中。

计数排序( Counting Sort )

原理

计数排序的原理与桶排序相似,也是将数据拆分为小范围,不过这个小范围的只指定某个数,相当于一个值形成一个桶,只有等于这个值的数据才能分配到桶内。读到这里计数排序和桶排序相似度已经接近 99% 了,那为什么还单独取个算法名称?

虽然也是将数据分配到桶中,但计数排序只记录桶中分配到数据的个数,而不将数据分配到相应的空间内存储进来,当分配完成,再将每个桶中的数据个数按顺序累计,便能得到桶对应数字排序后应该分配到数组中的相应最末位置。此时再对原数据进行遍历,通过数据找到其排序后应该被放置到数组中的相应位置,放置数据完成后对相应数字的累计值减一,最后得到的数组就是对原数据排序完成的数组。

代码实现
@Override
public void sort(int[] array, int len) {
  if(array == null || array.length < 2) {
    return;
  }

  // initialize counting array
  int[] range = getRange(array);
  int min = range[0];
  int[] countingArray = new int[range[1] - min + 1];
  for(int i = 0; i < countingArray.length; i++) {
    countingArray[i] = 0;
  }

  // count value of array
  for(int i = 0; i < array.length; i++) {
    countingArray[array[i] - min]++;
  }

  // amount num of counting
  for(int i = 1; i < countingArray.length; i++) {
    countingArray[i] = countingArray[i] + countingArray[i - 1];
  }

  int[] sortArray = new int[array.length];
  for(int i = array.length - 1; i >= 0; i--) {
    int countingIndex = array[i] - min;
    sortArray[countingArray[countingIndex] - 1] = array[i];
    countingArray[countingIndex]--;
  }

  for(int i = 0; i < array.length; i++) {
    array[i] = sortArray[i];
  }
}

public int[] getRange(int[] array) {
  int min = Integer.MAX_VALUE;
  int max = Integer.MIN_VALUE;

  for(int i = 0; i < array.length; i++) {
    min = Math.min(array[i], min);
    max = Math.max(array[i], max);
  }
  return new int[] {min, max};
}
时间复杂度分析

因为计数排序只涉及到对数组的循环遍历,所以时间复杂度为 O ( n ) O(n) O(n)

应用场景

其实计数排序可以认为是桶排序的特殊情况,当数据范围不大时可以考虑使用计数排序。例如,高考时要计算考生排名,高考分数范围为 0~900。先创建 901 个桶,将相同分数的考生数据进行计数,在完成遍历后对计数进行顺序累计,再对高考数据进行遍历,按累计数据放入数组相应位置,完成后数组就为排序完成的高考成绩。

基数排序( Radix Sort )

原理

基数排序直接描述原理比较抽象,我们以一个实际的例子进行讲述。例如,对手机号码进行排序应该采用哪种排序算法较为合适?

因为手机号码有十一位,数据范围比较大,使用桶排序或计数排序不太理想,但手机号码的比较有个特点,我们通常对手机号码从左至右进行比较,当数字相同就比较下一位,但数字有大小之别就不用继续比较后面的数字。

其实手机号码的排序就可以先排序最后一位,再按倒数第二位重新排序,以此类推,每位的排序使用稳定线性排序算法,如果使用非稳定的排序算法会导致只顾当前位排序大小,而忽略了其他位的排序,在当前位相同情况下其他位的排序会出现错乱情况,这样算法思路就不正确了。

时间复杂度分析

基数排序每一位都使用桶排序或者计数排序,单独位上的时间复杂度为 O ( n ) O(n) O(n),假设数据有 k 位,总时间复杂度为 O ( k ∗ n ) O(k*n) O(kn),但基数排序的位数有限且较小,例如手机号码只有 11 位,所以 k 作为常数处理,基数排序的时间复杂度为 O ( n ) O(n) O(n)

应用场景

基数排序的原理中的手机号排序就是其中一个应用场景。当数据可以按位分割比较,且位与位之间有递进关系,每位的数据范围比较有限,就是适合基数排序应用的场景。

类似的场景还有英文单词的排序,但英文单词不像手机号码位数一致,其存在位数长短不一的情况,这种情况以最长单词为位数基准,对位数不足的在尾部补 0,因为在 ASCII 码中 0 比所有的字母都小,补 0 对最后的排序结果不会造成影响。

总结

虽然桶排序、计数排序和基数排序时间复杂度都是 O ( n ) O(n) O(n),但由于都对数据都有特殊要求,只能应用在某些适宜场景下,这也是它们没有广泛运用的原因。


文章中如有问题欢迎留言指正
本章节代码已经上传 GitHub,可点击跳转查看代码详情。
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值