之前我们讲了两类排序算法,它们的时间复杂度分别为O(n^2)和O(nlogn)。这两类排序算法都是基于元素之间比较的算法,接下里要说的几种排序算法都是不基于元素之间比较的算法,因此它们的时间复杂度能够做到O(n),所以这几种排序算法被称为线性排序。 那么既然这几种算法性能这么好,那它们有什么缺点吗?有,其一空间复杂度较之前的比较高,其二它们对数据本身要求很苛刻。现在我们就来看看。
桶大家都不陌生,它用来装一个或者多个东西的,我们将n个待排序的数据分别放入k个桶中,然后对桶中的数据单独排序。这k个桶的都是按顺序排列的,因此在最后排序的时候我们只需要顺序取出桶中的数据依次存入一个数组中即可。
我们来分析一下桶排序的时间复杂度,如果要排序的数据有 n 个,我们把它们均匀地划分到 k 个桶内,每个桶里就有 m=n/k 个元素。每个桶内部使用快速排序,时间复杂度为 O(m * logm)。k 个桶排序的时间复杂度就是 O(k * m * logm),因为 m=n/k,所以整个桶排序的时间复杂度就是 O(n*log(n/k))。当桶的个数 k 接近数据个数 n 时,log(n/k) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
可以看到桶排序性能相当好,但是它也是有缺点的。其一,它的空间复杂度较高,因为我们借助了多个桶作为辅助存储;其二,桶排序对数据要求很苛刻,我们需要这些数据能够均匀地地分布在每个桶中,试想一下这种情况,假如所有的数据都分布在一个桶中的时候,这个时候桶内的数据复杂度就会退化为O(nlogn)。此外,如果某些数据离群程度比较高,那么有些桶中只会存在一个或者没有数据,这样还会浪费存储空间。
我在之前的关于排序算法的分类中提到过关于外部排序,外部排序说的是当待排的数据量非常大,不能一次性全部读入内存中,可以采用外部排序。根据桶排序的特点,我们看出桶排序比较适合使用在外部排序中,例如,我们现在有几十个G的数据需要对它进行排序,如果我们内存较小不能全部读入这些数据,我们就可以根据桶排序的思想,将这些数据划分到n个桶中,这些桶按照数据大小排列,每一个桶中的数据量都是可以被内存存放的,那么我们只需要对每一个桶中的数据进行快速排序即可,然后将排好的数据写入一个文件存放到外部存储器并对文件编号,直到每一个桶中所有数据都排序完,然后我们只需要对编号文件顺序取出放入一个大的文件中,这样数据就是有序的了。
我们这里给出部分代码:
public class BucketSort {
/**
* 桶排序
* @param arr 数组
* @param bucketSize 桶容量
*/
public static void bucketSort(int[] arr, int bucketSize) {
if (arr.length < 2) {
return;
}
int minValue = arr[0];
// 数组最大值
for (int i = 0; i < arr.length; i++) {
// 寻找数组中的最大值和最小值
if (arr[i] < minValue) {
minValue = arr[i];
} else if (arr[i] > maxValue) {
maxValue = arr[i];
}
}
// 桶数量
int bucketCount = (maxValue - minValue) / bucketSize + 1;
int[][] buckets = new int[bucketCount][bucketSize];
int[] indexArr = new int[bucketCount];
// 将数组中值分配到各个桶里
for (int i = 0; i < arr.length; i++) {
// 确定数据该放的桶的index
int bucketIndex = (arr[i] - minValue) / bucketSize;
buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
}
// 对每个桶进行排序
int k = 0;
for (int i = 0; i < buckets.length; i++) {
if (indexArr[i] == 0) {
continue;
}
// 使用快速排序
quickSortC(buckets[i], 0, indexArr[i] - 1);
for (int j = 0; j < indexArr[i]; j++) {
arr[k++] = buckets[i][j];
}
}
}
其实计数排序也是一种桶排序,只不过是特殊化的桶排序。它的特殊之处在于桶的粒度不一样,如果需要排序的数据值的区间(例如从start~end)不大,那我们就可以将桶划分为end个,这样每个桶中的数据都是一样大,这样就节省了桶内排序的时间。
我们举个例子来说明,假如现在有一个数组arry[10] = {2, 5, 3, 1, 5, 2, 3, 0, 6, 3},现在我们对它进行计数排序。
step1、 首先我们看到它最大的数字为6,区间从0~6,因此我们可以分为7个桶,因此我们设置一个数组b[],用来存储这个区间的数字,下标索引就是arry数组的值,b数组中的值就是对应arry数组中的数字的个数。通过遍历arry数组就能将原数组中数字的个数放入b数组中。
step2、 现在我们需要将arry数组中的数字放入一个res[] 的有序数组中,怎么做呢?这里用了一个非常巧妙的办法,开始我是想不到的。先让b数组中的元素顺序相加,这样得到的数组中元素就是比当前位置小的元素的个数(包括自己),因此b[k]存储的就是小于等于k的元素的个数。
step3、 接下来我们开始遍历待排序的数组arry,用arry数组中当前遍历的元素并将它作为b数组的索引取出b数组的值。这个值就是小于等于当前遍历的arry数组中元素的个数。举例说明,假如当前我遍历到5这个数字,在b数组中看到它的值为9,也就是说,小于等于5的数字有9个。OK,接下来我们将当前遍历的这个元素取出,根据它在b数组中所对应的值,将元素放入到res的对应位置。例如刚才我们遍历到5,发现它对应的数字是9,那么我们就将5这个元素放入res数组中的下标为8的位置(因为数组校表从0开始所以减1)。将元素放入res后,因为这时小于等于5的元素只剩下8个了,因此b[5]相应减1,当下一次遍历到5的时候就将它放入下标为7的位置。依此类推直到遍历完整个arry数组。这时res中的所有元素就有序了。下图我只给出了前面三个元素的遍历,剩下的类似。
/**
* Counting Sort
*
* Author: Lessen
*/
public class CountingSort {
// 计数排序,a是数组,假设数组中存储的都是非负整数。
public static void countingSort(int[] arry) {
arrySize = arry.length;
if (arrySize <= 1) return;
// 查找数组中数据的范围
int max = arry[0];
for (int i = 1; i < arrySize; ++i) {
if (max < arry[i]) {
max = arry[i];
}
}
// 申请一个计数数组b,下标大小[0,max]
int[] b = new int[max + 1];
// 计算每个元素的个数,放入c中
for (int i = 0; i < arrySize; ++i) {
b[arry[i]]++;
}
// 依次累加
for (int i = 1; i < max + 1; ++i) {
b[i] = b[i-1] + b[i];
}
// 临时数组res,存储排序之后的结果
int[] res = new int[arrySize];
// 将arry数组元素根据b数组中小于等于它的个数放入res指定位置
for (int i = arrySize - 1; i >= 0; --i) {
int index = b[arry[i]]-1;
res[index] = arry[i];
b[arry[i]]--;
}
// 将结果拷贝会a数组
for (int i = 0; i < arrySize; ++i) {
arry[i] = res[i];
}
}
}
我们看到计数排序的使用场景是数据范围不大的情况,例如给一大堆的年龄排序,给考试成绩排序等等。而且根据上面我们讲的原理,可以看出它只能给非负整数排序。如果出现负数在不影响原数组元素相对大小的情况下可以转换为正整数例如所有元素加上一个正整数。从算法的实现中我们可以看到,计数排序是稳定排序。,但不是原地排序。
我们再来看这样一个排序问题。假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?我们之前讲的快排,时间复杂度可以做到 O(nlogn),还有更高效的排序算法吗?桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢?现在我就来介绍一种新的排序算法,基数排序。刚刚这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。借助稳定排序算法,这里有一个巧妙的实现思路。还记得我们第 11 节中,在阐述排序算法的稳定性的时候举的订单的例子吗?我们这里也可以借助相同的处理思路,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。
这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。
这里如果待排数据不是等长度的,我们可以将它补为等长的,但是不能影响原来的顺序,这就要求补的数字不能超过所有出现的数字大小。基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
public static void radixSort(int[] arr) {
int max = arr[0];
for (int i = 0; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
// 从个位开始,对数组arr按"指数"进行排序
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSort(arr, exp);
}
}