数据结构与算法之美-11线性排序

前两篇学习了五种常见的排序算法,时间复杂度最低的也需要O(nlogn)。

本片介绍三种时间复杂度为O(n)的排序算法:桶排序、计数排序、基数排序,但是这三种排序算法都需要特定场景前提才能适用。

桶排序

前提:假设需要排序的n大小数据源可以平分为m个桶(区块),每个区块里的数据是无序的,但桶与桶之间是有序的,即m-1桶里的数据肯定是小于m桶里的数据。

思路:有了上面的排序,我们只需要将每个小桶进行快速排序,最后再进行拼接每个桶即可。

桶排序.png

举个例子:
现在有3个数组,每个数组3个元素,数组间是有序的,数组里元素时无序的,进行桶排序。

arr1 = [3,1,2] 、 arr2 = [4,6,5] 、 arr3 = [9,8,7]

代码如下:

private static int[] bucketSort(int[] arr1, int[] arr2, int[] arr3) {
    int[] result = new int[arr1.length + arr2.length + arr3.length];
    quickSort(arr1, 0, arr1.length - 1);
    quickSort(arr2, 0, arr2.length - 1);
    quickSort(arr3, 0, arr3.length - 1);
    for (int i = 0; i < result.length; i++) {
        int num;
        if (i < arr1.length) {
            num = arr1[i];
        } else if (i < arr1.length + arr2.length) {
            num = arr2[i - arr1.length];
        } else {
            num = arr3[i - arr1.length - arr2.length];
        }
        result[i] = num;
    }
    return result;
}

测试:

int[] arr1 = new int[]{3, 1, 2};
int[] arr2 = new int[]{4, 6, 5};
int[] arr3 = new int[]{9, 8, 7};
int[] result = bucketSort(arr1, arr2, arr3);
printArr(result);

输出:

1 2 3 4 5 6 7 8 9 

显然,桶排序的代码并不是通用代码,是我们根据实际需求编写的。

实际开发中,桶排序一般使用在外部排序中,或者说磁盘排序。比如日志文件、记录文件之类的,我们将数据按照需求划分为多个有序文件,每个文件内保存满足这个文件条件的数据可以是无序的,之后我们将每个文件读到内存中进行快排,最后在合并这些文件,这里就使用桶排序的思想。

说了这么多,桶排序的性能又如何呢?

时间复杂度:O(n)

假设对于n个大小的数据源可以平分为m个桶,每个桶里有k=n/m个元素。

每个桶内都是用的快排,每个桶的时间复杂度就为O(klogk)

m个桶则为:T(n) = O(m * k * logk)

带入k=n/m:T(n) = O(m* n/m * logn/m) = O(n*logn/m)

当桶的个数m越接近n, logn/m就越是一个更小的常量,此时我们就可以忽略调它

最终T(n) = O(n)

空间复杂度:不确定

如果包含分桶操作,需要额外的桶空间:O(n)

但如果数据源本身就表明满足桶排序的前提条件,例如[3, 1, 2, 4, 6, 5,9, 8, 7]我们只要根据下标规律控制p…r的取值,这时空间复杂度为:O(1)

所以空间时间复杂度,对于桶排序来说是不确定,需要根据实际的使用场景来确定

稳定性:不确定

同理,虽然上面在桶内部排序使用了快排,导致其不稳定。但实际情况中如果考虑稳定性,我们可以使用其它稳定排序来替换。

计数排序

前提:排序值可取范围是精确的。例如绩效考核评分只能取[5,4,3,2,1,0],然后对员工绩效考核进行排序。

思路:

  • 1、将所有可能出现的排序值初始化一个同等大小数组,例如上面的绩效评分,初始化一个大小为6的数组r;
  • 2、遍历要排序的数据源,每出现一个数就在对应的r数组位置+1;
  • 3、将计数完的数组r依次计算当前下标前(包含当前下标)出现的总次数;
    • 举个例子,现在有4个人,评分为[3,4,4,2]
    • 进行前两步之后,数组r = [0,0,1,1,2,0]
    • 第3步计算结果后,数组r = [0,0,1,2,4,4], 这个结果表示:
      • 小于等于0的评分有0个人
      • 小于等于1的评分有0个人
      • 小于等于2的评分有1个人
      • 小于等于3的评分有2个人
      • 小于等于4的评分有4个人
      • 小于等于5的评分有4个人
  • 4、有了第3步计算的r数组,我们遍历源数组时就会发现规律。 遍历到第一个元素3,它的前面有就只有2-1个元素,所以它的位置就是下标为1的位置,此时r数组对应3的计数器减1即可,当循环结束时源数据也就有序了。

计数排序.png

代码实现:

/**
 * 计数排序
 */
private static int[] countingSort(int[] arr, int count) {
    int[] result = new int[arr.length];
    int r[] = new int[count];
    int i;
    for (i = 0; i < arr.length; i++) {
        r[arr[i]]++;
    }
    for (i = 1; i < r.length; i++) {
        r[i] = r[i] + r[i - 1];
    }
    for (i = arr.length - 1; i >= 0; i--) {
        int num = arr[i];
        int index = --r[num];
        result[index] = num;
    }
    return result;
}

测试:

int[] arr = {3, 4, 2, 2, 4, 3, 5, 1, 0, 4};
int[] result = countingSort(arr, 6);
printArr(result);

输出:

0 1 2 2 3 3 4 4 4 5 

相较于桶排序来说,计数排序的代码算是比较固定,但对于数据源的要求却更高,需要明确知道数据源排序值的范围

除了明确的排序值范围外,因为整个算法中巧妙的用到了数组下标,所以对于非正整数类型数据也是无法使用计数排序的

并且如果排序值范围远大于原数据源大小,计数排序未必优于其它排序算法

时间复杂度:O(n)

可以看到整个排序算法就主要由3个循环组成,假设对n个元素排序,那么公式为T(n) = 2n + count , count我们知道它是个常量(前提条件),所以最终的时间复杂度为:O(n)

空间复杂度:O(n)

我们使用了一个新的同等大小数组存储排序后的结果。

稳定性:稳定

细心的朋友可能已经发现,在最后一层循环计算index位置时,我们使用的是倒序遍历。也正因此,计数排序是稳定排序。

基数排序

基数排序的前提条件就更为复杂,为了方便理解,我们试着解决以下问题的方式进行讲解:

现在有一个数组存储着20个人员的年龄,人员年龄范围是1-120岁,试着对这20个人员年龄进行排序。

当然使用桶排序和计数排序是可以解决这个问题的,但它们的时间复杂度真的是O(n)吗?

显然1-120的范围对于20个人的年龄数据源来说还是很大的,有没有更好的排序方式呢?

拿3个年龄举例:

[8,116,116]
我们先将不够3位的年龄补齐3位
[008,116,116]
我们先对个位进行排序
[116,116,008] 
再对十位进行排序
[008,116,116]
最后百位排序
[071,116,116]

我们可以对年龄从低位到高位的每一位进行稳定排序,经过3次稳定排序后得到的就是最终的有序数组。

代码:

  /**
 * 基数排序
 */
private static int[] radixSort(int[] arr) {
    String[] result;
    //转string
    String[] arrStr = parseStr(arr);
    result = countingSortChange(arrStr, 10, 1);
    result = countingSortChange(result, 10, 2);
    result = countingSortChange(result, 10, 3);
    return parseInt(result);
}

  /**
 * 基数排序 * 计数排序
 */
private static String[] countingSortChange(String[] arr, int count, int position) {
    String[] result = new String[arr.length];
    int[] positionArr = new int[arr.length];
    int i;
    for (i = 0; i < arr.length; i++) {
        String numStr = arr[i].substring(arr[i].length() - position, arr[i].length() - position + 1);
        positionArr[i] = Integer.parseInt(numStr);
    }

    int r[] = new int[count];

    for (i = 0; i < arr.length; i++) {
        r[positionArr[i]]++;
    }

    for (i = 1; i < r.length; i++) {
        r[i] = r[i] + r[i - 1];
    }
    for (i = arr.length - 1; i >= 0; i--) {
        int num = positionArr[i];
        int index = --r[num];
        result[index] = arr[i];
    }
    return result;
}

测试:

int[] arr = {10, 9, 3, 4, 87, 34, 21, 42, 5, 86, 22, 116, 120, 9, 29, 90, 110, 3, 77, 6};
int[] result = radixSort(arr);
printArr(result);

输出:

3 3 4 5 6 9 9 10 21 22 29 34 42 77 86 87 90 110 116 120 

时间复杂度:O(n)

基数排序内部使用3次计数排序,但是将排序范围从120个数减少到了10个以内,从而使得时间复杂度更接近O(n).

空间复杂度:O(n)

同计数排序。

稳定性:稳定

同计数排序。

小结

今天学习了三个线性排序算法:桶排序、计数排序、基数排序

由于这三种排序算法对排序数据源有很多前提要求,实际开发中使用的并不广泛。但如果要排序的数据源满足这些前提要求,那么使用这些排序算法会大大提高代码的执行效率。